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(); }); });