From 3d95b867c009f9df5af6320cc6a50c70cb9ff6fe Mon Sep 17 00:00:00 2001 From: Devon Campbell Date: Sat, 21 Mar 2026 20:22:54 -0400 Subject: [PATCH] Add Forgejo-backed editor --- package-lock.json | 254 +++++++++++++ package.json | 7 + quartz.config.ts | 2 +- quartz.layout.ts | 2 + quartz/components/Editor.tsx | 317 +++++++++++++++++ quartz/components/scripts/editor.inline.ts | 395 +++++++++++++++++++++ 6 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 quartz/components/Editor.tsx create mode 100644 quartz/components/scripts/editor.inline.ts diff --git a/package-lock.json b/package-lock.json index ff22d60..6d0a7e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,13 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^0.11.0", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", "@floating-ui/dom": "^1.7.4", + "@lezer/highlight": "^1.2.3", "@myriaddreamin/rehype-typst": "^0.6.0", "@napi-rs/simple-git": "0.1.22", "@tweenjs/tween.js": "^25.0.0", @@ -18,6 +24,7 @@ "async-mutex": "^0.5.0", "chokidar": "^5.0.0", "cli-spinner": "^0.2.10", + "codemirror": "^6.0.2", "d3": "^7.9.0", "esbuild-sass-plugin": "^3.6.0", "flexsearch": "^0.8.205", @@ -197,6 +204,147 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -1134,6 +1282,79 @@ "node": "20 || >=22" } }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@myriaddreamin/rehype-typst": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@myriaddreamin/rehype-typst/-/rehype-typst-0.6.0.tgz", @@ -2723,6 +2944,21 @@ "node": ">=20" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", @@ -2765,6 +3001,12 @@ "node": ">= 0.6" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -7107,6 +7349,12 @@ "node": ">=0.10.0" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", @@ -7556,6 +7804,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/package.json b/package.json index e1d138a..ea7c7ad 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,13 @@ }, "dependencies": { "@clack/prompts": "^0.11.0", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", "@floating-ui/dom": "^1.7.4", + "@lezer/highlight": "^1.2.3", "@myriaddreamin/rehype-typst": "^0.6.0", "@napi-rs/simple-git": "0.1.22", "@tweenjs/tween.js": "^25.0.0", @@ -44,6 +50,7 @@ "async-mutex": "^0.5.0", "chokidar": "^5.0.0", "cli-spinner": "^0.2.10", + "codemirror": "^6.0.2", "d3": "^7.9.0", "esbuild-sass-plugin": "^3.6.0", "flexsearch": "^0.8.205", diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d6..126430e 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -89,7 +89,7 @@ const config: QuartzConfig = { Plugin.Favicon(), Plugin.NotFoundPage(), // Comment out CustomOgImages to speed up build time - Plugin.CustomOgImages(), + //Plugin.CustomOgImages(), ], }, } diff --git a/quartz.layout.ts b/quartz.layout.ts index 970a5be..335d29e 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -1,5 +1,6 @@ import { PageLayout, SharedLayout } from "./quartz/cfg" import * as Component from "./quartz/components" +import Editor from "./quartz/components/Editor" // components shared across all pages export const sharedPageComponents: SharedLayout = { @@ -24,6 +25,7 @@ export const defaultContentPageLayout: PageLayout = { Component.ArticleTitle(), Component.ContentMeta(), Component.TagList(), + Editor(), ], left: [ Component.PageTitle(), diff --git a/quartz/components/Editor.tsx b/quartz/components/Editor.tsx new file mode 100644 index 0000000..7d5b9c0 --- /dev/null +++ b/quartz/components/Editor.tsx @@ -0,0 +1,317 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"; +import editorScript from "./scripts/editor.inline"; +import path from "path"; + +const Editor: QuartzComponent = ({ fileData, allFiles, displayClass }: QuartzComponentProps) => { + if (!fileData.filePath) return null; + + const dIndex = process.argv.findIndex(arg => arg === "-d" || arg === "--directory"); + const baseDir = dIndex !== -1 ? process.argv[dIndex + 1] : "content"; + + const absoluteBase = path.resolve(process.cwd(), baseDir); + const absoluteFile = path.resolve(process.cwd(), fileData.filePath); + const repoFilePath = path.relative(absoluteBase, absoluteFile); + + const directories = new Set(); + for (const file of allFiles) { + if (file.slug) { + const parts = file.slug.split("/"); + if (parts.length > 1) { + parts.pop(); + directories.add(parts.join("/")); + } + } + } + + return ( +
+ +
+ + +
+ + +
+ ); +}; + +Editor.afterDOMLoaded = editorScript; +Editor.css = ` + #fab-container { + position: fixed; + bottom: 2rem; + right: 2rem; + display: flex; + flex-direction: row; + gap: 1rem; + z-index: 999; + } + + .fab { + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + background: var(--secondary); + color: var(--light); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + cursor: pointer; + border: none; + transition: transform 0.2s ease, background-color 0.2s ease; + } + + .fab:hover:not(:disabled) { + background: var(--tertiary); + transform: scale(1.05); + } + + .fab:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + #editor-layout { + width: 100%; + display: flex; + flex-direction: column; + margin-top: 1rem; + margin-bottom: 2rem; + } + + #editor-layout.is-new-note { + margin-top: 6rem; + } + + #file-path-controls { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + } + + @media (max-width: 800px) { + #editor-layout.is-new-note { + margin-top: 1rem; + } + } + + @media (max-width: 600px) { + #file-path-controls { + flex-direction: column; + gap: 0.5rem; + } + } + + .input-group { + display: flex; + flex-direction: column; + flex: 1; + } + + .input-group label { + font-size: 0.8rem; + color: var(--gray); + margin-bottom: 0.2rem; + } + + .input-group input { + padding: 0.5rem; + border: 1px solid var(--gray); + border-radius: 4px; + background: var(--light); + color: var(--dark); + font-family: inherit; + } + + .input-group input:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + #editor-wrapper { + width: 100%; + border: 1px solid var(--darkgray); + border-radius: 5px; + overflow: hidden; + background: #2a2a2a; + cursor: text; + } + + .cm-editor { + min-height: 60vh; + height: auto; + background-color: #2a2a2a; + } + + .cm-scroller { + height: 100% !important; + min-height: 60vh; + } + + .cm-content { + min-height: 100%; + } + + #editor-controls { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + #commit-subject, #commit-body { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--gray); + border-radius: 4px; + background: var(--light); + color: var(--dark); + font-family: inherit; + box-sizing: border-box; + } + + #commit-subject:disabled, #commit-body:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + #commit-details { + margin-top: 0.2rem; + margin-bottom: 1.5rem; /* Increased vertical spacing above the buttons */ + } + + #commit-details summary { + cursor: pointer; + color: var(--gray); + font-size: 0.9rem; + user-select: none; + display: inline-block; + } + + #commit-details summary:hover { + color: var(--darkgray); + } + + #commit-body { + margin-top: 0.5rem; + resize: vertical; + min-height: 4rem; + } + + .editor-buttons { + display: flex; + justify-content: space-between; /* Pushes Delete to left, Save/Cancel to right */ + align-items: center; + } + + .editor-buttons-right { + display: flex; + gap: 0.5rem; + } + + .editor-buttons button { + padding: 0.5rem 1rem; + cursor: pointer; + border: none; + border-radius: 4px; + font-weight: bold; + } + + .editor-buttons button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + #save-button { + background: var(--secondary); + color: var(--light); + } + + #save-button:hover:not(:disabled) { + background: var(--tertiary); + } + + #cancel-button { + background: var(--lightgray); + color: var(--dark); + } + + #cancel-button:hover:not(:disabled) { + background: var(--gray); + } + + #delete-button { + background: transparent; + color: #e06c75; /* A distinct red color for destructive actions */ + border: 1px solid #e06c75; + } + + #delete-button:hover:not(:disabled) { + background: rgba(224, 108, 117, 0.1); + } + + #delete-button:disabled { + border-color: var(--gray); + color: var(--gray); + } + + @media (max-width: 800px) { + #fab-container { + bottom: 1.5rem; + right: 1.5rem; + } + + .fab { + width: 3rem; + height: 3rem; + } + } +`; + +export default (() => Editor) satisfies QuartzComponentConstructor; diff --git a/quartz/components/scripts/editor.inline.ts b/quartz/components/scripts/editor.inline.ts new file mode 100644 index 0000000..2eada66 --- /dev/null +++ b/quartz/components/scripts/editor.inline.ts @@ -0,0 +1,395 @@ +import { EditorState, Compartment } from "@codemirror/state"; +import { EditorView, basicSetup } from "codemirror"; +import { markdown } from "@codemirror/lang-markdown"; +import { syntaxHighlighting, HighlightStyle } from "@codemirror/language"; +import { tags as t } from "@lezer/highlight"; + +const FORGEJO_URL = "https://code.c.devon.lol"; +const REPO_OWNER = "Devon"; +const REPO_NAME = "knowledgebase"; +const BRANCH = "main"; + +const customHighlightStyle = HighlightStyle.define([ + { tag: t.heading, color: "#61afef", fontWeight: "bold" }, + { tag: t.keyword, color: "#c678dd" }, + { tag: t.url, color: "#56b6c2" }, + { tag: t.link, color: "#56b6c2", textDecoration: "underline" }, + { tag: t.punctuation, color: "#e06c75" }, + { tag: t.strong, fontWeight: "bold" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.meta, color: "#7f848e" }, + { tag: t.comment, color: "#7f848e", fontStyle: "italic" }, + { tag: t.string, color: "#98c379" }, + { tag: t.number, color: "#d19a66" }, + { tag: t.propertyName, color: "#e06c75" }, + { tag: t.variableName, color: "#e06c75" }, +]); + +const editorTheme = EditorView.theme({ + "&": { backgroundColor: "#2a2a2a", color: "#e0e0e0", fontSize: "16px" }, + ".cm-content": { minHeight: "60vh", padding: "1rem" }, + "&.cm-focused .cm-cursor": { borderLeftColor: "#e0e0e0" }, + "&.cm-focused .cm-selectionBackground, ::selection": { backgroundColor: "#444444" }, + ".cm-gutters": { backgroundColor: "#222222", color: "#888888", borderRight: "1px solid #444444" }, + ".cm-scroller": { fontFamily: "inherit" } +}, { dark: true }); + +document.addEventListener("nav", () => { + const fabContainer = document.getElementById("fab-container"); + const editBtn = document.getElementById("edit-button") as HTMLButtonElement; + const newBtn = document.getElementById("new-button") as HTMLButtonElement; + const saveBtn = document.getElementById("save-button") as HTMLButtonElement; + const cancelBtn = document.getElementById("cancel-button") as HTMLButtonElement; + const deleteBtn = document.getElementById("delete-button") as HTMLButtonElement; + const layout = document.getElementById("editor-layout"); + const wrapper = document.getElementById("editor-wrapper"); + const commitSubjectInput = document.getElementById("commit-subject") as HTMLInputElement; + const commitBodyInput = document.getElementById("commit-body") as HTMLTextAreaElement; + const commitDetails = document.getElementById("commit-details") as HTMLDetailsElement; + const fileDirectoryInput = document.getElementById("file-directory") as HTMLInputElement; + const fileNameInput = document.getElementById("file-name") as HTMLInputElement; + + if (!editBtn || !newBtn || !layout || !wrapper || !fabContainer || !deleteBtn) return; + + let editorView: EditorView | null = null; + let currentFileSha = ""; + const filePath = editBtn.getAttribute("data-filepath") || ""; + const editableCompartment = new Compartment(); + + let isCreatingNewNote = false; + + const encodeBase64 = (str: string) => btoa(unescape(encodeURIComponent(str))); + const decodeBase64 = (str: string) => decodeURIComponent(escape(atob(str))); + + const getPat = () => { + let pat = localStorage.getItem("forgejo_pat"); + if (!pat) { + pat = prompt("Enter your Forgejo Personal Access Token:"); + if (pat) localStorage.setItem("forgejo_pat", pat); + } + return pat; + }; + + const getCenterColumn = () => document.querySelector(".center"); + + const onWrapperClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target === wrapper || target.classList.contains("cm-scroller")) { + editorView?.focus(); + } + }; + + const performDOMClose = () => { + const center = getCenterColumn(); + if (center) { + Array.from(center.children).forEach(child => { + if (child !== layout) { + (child as HTMLElement).style.display = ""; + } + }); + } + + layout.style.display = "none"; + layout.classList.remove("is-new-note"); + fabContainer.style.display = "flex"; + + commitSubjectInput.value = ""; + commitBodyInput.value = ""; + fileDirectoryInput.value = ""; + fileNameInput.value = ""; + commitDetails.open = false; + isCreatingNewNote = false; + }; + + const closeEditor = () => { + if (history.state?.isEditor) { + history.back(); + } else { + performDOMClose(); + history.replaceState(null, "", window.location.pathname); + } + }; + + const openEditorUI = (content: string, mode: "edit" | "new", pushHistory = true) => { + const center = getCenterColumn(); + if (center) { + if (layout.parentNode !== center) { + center.appendChild(layout); + } + + Array.from(center.children).forEach(child => { + if (child !== layout) { + const el = child as HTMLElement; + if (mode === "edit" && el.classList.contains("page-header")) { + el.style.display = ""; + } else { + el.style.display = "none"; + } + } + }); + } + + fabContainer.style.display = "none"; + layout.style.display = "flex"; + + if (mode === "new") { + layout.classList.add("is-new-note"); + deleteBtn.style.display = "none"; + commitSubjectInput.placeholder = `Create new note...`; + } else { + layout.classList.remove("is-new-note"); + deleteBtn.style.display = "block"; + commitSubjectInput.placeholder = `Update ${filePath}...`; + } + + if (pushHistory) { + if (mode === "edit") { + history.pushState({ isEditor: true }, "", "?edit"); + } else { + history.pushState({ isEditor: true }, "", "/?new"); + } + } + + if (editorView) editorView.destroy(); + + editorView = new EditorView({ + state: EditorState.create({ + doc: content, + extensions: [ + basicSetup, + markdown(), + editorTheme, + syntaxHighlighting(customHighlightStyle), + editableCompartment.of(EditorView.editable.of(true)) + ] + }), + parent: wrapper + }); + }; + + const onEdit = async (e?: Event, pushHistory = true) => { + const pat = getPat(); + if (!pat || !filePath) return; + + isCreatingNewNote = false; + editBtn.disabled = true; + + const parts = filePath.split("/"); + const fName = parts.pop() || ""; + fileDirectoryInput.value = parts.join("/"); + fileNameInput.value = fName.replace(/\.md$/i, ""); + + const encodedFilePath = filePath.split('/').map(encodeURIComponent).join('/'); + + try { + const res = await fetch(`${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents/${encodedFilePath}?ref=${BRANCH}`, { + headers: { "Authorization": `token ${pat}` } + }); + + if (!res.ok) throw new Error("Failed to fetch file. Check your PAT."); + const data = await res.json(); + + currentFileSha = data.sha; + const content = decodeBase64(data.content); + + openEditorUI(content, "edit", pushHistory); + } catch (err) { + alert(err); + fabContainer.style.display = "flex"; + } finally { + editBtn.disabled = false; + } + }; + + const onNewNote = (e?: Event, pushHistory = true) => { + isCreatingNewNote = true; + currentFileSha = ""; + + fileDirectoryInput.value = localStorage.getItem("forgejo_default_dir") || ""; + fileNameInput.value = ""; + + openEditorUI("", "new", pushHistory); + }; + + const onCancel = () => closeEditor(); + + const lockUI = (isLocked: boolean) => { + if (saveBtn) saveBtn.disabled = isLocked; + if (cancelBtn) cancelBtn.disabled = isLocked; + if (deleteBtn) deleteBtn.disabled = isLocked; + commitSubjectInput.disabled = isLocked; + commitBodyInput.disabled = isLocked; + fileDirectoryInput.disabled = isLocked; + fileNameInput.disabled = isLocked; + + if (editorView) { + editorView.dispatch({ + effects: editableCompartment.reconfigure(EditorView.editable.of(!isLocked)) + }); + } + }; + + const onSave = async () => { + if (!editorView) return; + const pat = getPat(); + const newContent = editorView.state.doc.toString(); + + const dir = fileDirectoryInput.value.trim(); + let file = fileNameInput.value.trim(); + + if (!file) { + alert("Please enter a Note Name."); + return; + } + if (file.startsWith(".")) { + alert("Filename must not start with a dot."); + return; + } + if (/[\[\]#\^|\\\/:]/.test(file)) { + alert("Filename cannot contain the following characters: [ ] # ^ | \\ / :"); + return; + } + if (!file.toLowerCase().endsWith(".md")) { + file += ".md"; + } + + const cleanDir = dir.replace(/\/$/, ""); + const targetFilePath = cleanDir ? `${cleanDir}/${file}` : file; + + const isMovingOrRenaming = !isCreatingNewNote && targetFilePath !== filePath; + + let defaultSubject = `Update ${targetFilePath}`; + if (isCreatingNewNote) defaultSubject = `Create ${targetFilePath}`; + if (isMovingOrRenaming) defaultSubject = `Move ${filePath} to ${targetFilePath}`; + + const subject = commitSubjectInput.value.trim() || defaultSubject; + const body = commitBodyInput.value.trim(); + const fullMessage = body ? `${subject}\n\n${body}` : subject; + + lockUI(true); + if (saveBtn) saveBtn.innerText = "Saving..."; + + const encodedTargetFilePath = targetFilePath.split('/').map(encodeURIComponent).join('/'); + + try { + const payload: any = { + branch: BRANCH, + content: encodeBase64(newContent), + message: fullMessage + }; + + if (!isCreatingNewNote) { + payload.sha = currentFileSha; + if (isMovingOrRenaming) { + payload.from_path = filePath; + } + } + + const res = await fetch(`${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents/${encodedTargetFilePath}`, { + method: isCreatingNewNote ? "POST" : "PUT", + headers: { + "Authorization": `token ${pat}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to save. If creating a new file, ensure it doesn't already exist."); + } + + if (isCreatingNewNote) { + localStorage.setItem("forgejo_default_dir", cleanDir); + } + + closeEditor(); + alert(`Saved successfully!\n\nChanges will be live in about 5 minutes once the site rebuilds.\n\nNote: If you moved or renamed this note, it will be unavailable at its old path after the rebuild. Editing a moved/renamed note before the rebuild completes will fail.`); + + } catch (err) { + alert(err); + } finally { + lockUI(false); + if (saveBtn) saveBtn.innerText = "Save"; + } + }; + + const onDelete = async () => { + if (!filePath || !currentFileSha) return; + + const confirmDelete = confirm(`Are you sure you want to permanently delete:\n\n${filePath}\n\nThis cannot be undone.`); + if (!confirmDelete) return; + + const pat = getPat(); + if (!pat) return; + + const subject = commitSubjectInput.value.trim() || `Delete ${filePath}`; + const body = commitBodyInput.value.trim(); + const fullMessage = body ? `${subject}\n\n${body}` : subject; + + lockUI(true); + if (deleteBtn) deleteBtn.innerText = "Deleting..."; + + const encodedFilePath = filePath.split('/').map(encodeURIComponent).join('/'); + + try { + const res = await fetch(`${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents/${encodedFilePath}`, { + method: "DELETE", + headers: { + "Authorization": `token ${pat}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + branch: BRANCH, + message: fullMessage, + sha: currentFileSha + }) + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to delete file."); + } + + closeEditor(); + alert(`Deleted successfully!\n\nChanges will be live in about 5 minutes once the site rebuilds.`); + + } catch (err) { + alert(err); + } finally { + lockUI(false); + if (deleteBtn) deleteBtn.innerText = "Delete Note"; + } + }; + + const query = new URLSearchParams(window.location.search); + if (query.has("edit")) { + onEdit(undefined, false); + } else if (query.has("new")) { + onNewNote(undefined, false); + } + + window.addEventListener("popstate", (e) => { + if (layout.style.display !== "none" && !e.state?.isEditor) { + performDOMClose(); + } + }); + + wrapper.addEventListener("click", onWrapperClick); + editBtn.addEventListener("click", onEdit); + newBtn.addEventListener("click", onNewNote); + cancelBtn?.addEventListener("click", onCancel); + saveBtn?.addEventListener("click", onSave); + deleteBtn?.addEventListener("click", onDelete); + + window.addCleanup(() => { + wrapper.removeEventListener("click", onWrapperClick); + editBtn.removeEventListener("click", onEdit); + newBtn.removeEventListener("click", onNewNote); + cancelBtn?.removeEventListener("click", onCancel); + saveBtn?.removeEventListener("click", onSave); + deleteBtn?.removeEventListener("click", onDelete); + if (editorView) editorView.destroy(); + }); +});