(() => { "use strict"; const state = { session: null, pages: [], currentPage: null, editingNew: false, newPageSlug: null, previousView: "page-view", translations: {}, translationPage: "wiki-translations", translationTemplate: "", siteTitle: "Racket Wiki", bookmarks: [] }; let easyMDE = null; const $ = (id) => document.getElementById(id); function show(viewId) { for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "history-view", "admin-view", "user-admin-view"]) { $(id).classList.toggle("hidden", id !== viewId); } $("page-action-links").classList.toggle("hidden", viewId !== "page-view"); $("context-link").classList.toggle("hidden", !(state.currentPage && ["page-view", "editor-view", "history-view"].includes(viewId))); document.body.classList.toggle("editor-mode", viewId === "editor-view"); } function tr(key, fallback = key) { return state.translations[key] || fallback; } function applyTranslations() { document.querySelectorAll("[data-tr]").forEach((node) => { node.textContent = tr(node.dataset.tr, node.textContent); }); document.querySelectorAll("[data-tr-placeholder]").forEach((node) => { node.placeholder = tr(node.dataset.trPlaceholder, node.placeholder); }); document.documentElement.lang = state.language || "en"; } function escapeHtml(text) { return String(text) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function expandTodoMarkup(markdown, pageSlug = null) { let inFence = false; let todoNumber = 0; return (markdown || "").split("\n").map((line) => { if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return line; } if (inFence) return line; return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => { todoNumber += 1; const label = escapeHtml(tr("todo", "Todo")); const todoText = escapeHtml(text.trim()); if (pageSlug) { const href = `#todo/${encodeURIComponent(pageSlug)}/${todoNumber}`; return `${label}: ${todoText}`; } return `${label}: ${todoText}`; }); }).join("\n"); } function roleLevel(role) { return { reader: 10, editor: 20, admin: 30 }[role] || 0; } function can(role) { return state.session?.authenticated && roleLevel(state.session.user.role) >= roleLevel(role); } function updateRoleUi() { document.querySelectorAll(".editor-only").forEach((node) => { node.classList.toggle("hidden", !can("editor")); }); document.querySelectorAll(".admin-only").forEach((node) => { node.classList.toggle("hidden", !can("admin")); }); } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (options.body && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) { headers.set("Content-Type", "application/json"); } if (state.session?.csrfToken && !["GET", "HEAD"].includes((options.method || "GET").toUpperCase())) { headers.set("X-CSRF-Token", state.session.csrfToken); } const response = await fetch(path, { ...options, headers }); const contentType = response.headers.get("content-type") || ""; const body = contentType.includes("application/json") ? await response.json() : await response.text(); if (!response.ok) { if (response.status === 401) { window.location.replace("/login"); } const error = new Error(body?.error || body || `HTTP ${response.status}`); error.status = response.status; throw error; } return body; } function applyImageWidthMarkup(html) { const imageOptionsSuffix = /(]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi; return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => { const width = `${amount}${unit || "px"}`; const closing = imageTag.endsWith("/>") ? "/>" : ">"; let tagBody = imageTag.slice(0, imageTag.length - closing.length); const classes = []; const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null); if (effectiveFloatSide) { classes.push(`wiki-image-float-${effectiveFloatSide}`); } else if (align) { classes.push(`wiki-image-${align}`); } if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) { tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) => ` style=${quote}${value}; width: ${width}${quote}`); } else { tagBody += ` style="width: ${width}"`; } if (classes.length > 0) { if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) { tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) => ` class=${quote}${value} ${classes.join(" ")}${quote}`); } else { tagBody += ` class="${classes.join(" ")}"`; } } return `${tagBody}${closing}`; }); } function renderMarkdown(markdown, pageSlug = null) { const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug)); return DOMPurify.sanitize(applyImageWidthMarkup(html)); } function slugTitle(slug) { return (slug || "") .split("-") .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } function setPageActionVisibility(pageExists) { if (!can("editor")) return; $("edit-page").classList.remove("hidden"); $("delete-page").classList.toggle("hidden", !pageExists); $("history-page").classList.toggle("hidden", !pageExists); } function pageDisplayDate(seconds) { return new Date(seconds * 1000).toLocaleString(); } function parseTags(text) { const seen = new Set(); const tags = []; for (const part of (text || "").split(",")) { const tag = part.trim(); const key = tag.toLocaleLowerCase(); if (tag && !seen.has(key)) { seen.add(key); tags.push(tag); } } return tags; } function renderPageDetails(page) { const details = $("page-details"); details.replaceChildren(); const fields = [ `${tr("created", "Created")} ${pageDisplayDate(page.createdAt)} ${tr("by", "by")} ${page.createdBy}`, `${tr("modified", "Modified")} ${pageDisplayDate(page.updatedAt)} ${tr("by", "by")} ${page.updatedBy}`, `${tr("version", "Version")} ${page.currentVersion}` ]; for (const text of fields) { const span = document.createElement("span"); span.textContent = text; details.append(span); } const tags = Array.isArray(page.tags) ? page.tags : []; const tagField = document.createElement("span"); tagField.className = "page-tags"; if (tags.length === 0) { tagField.textContent = tr("tags-none", "Tags: none"); } else { const label = document.createTextNode(tr("tags-label", "Tags: ")); tagField.append(label); tags.forEach((tag, index) => { if (index > 0) tagField.append(document.createTextNode(", ")); const value = document.createElement("span"); value.className = "page-tag"; value.textContent = tag; tagField.append(value); }); } details.append(tagField); } function wikiSlugFromHref(href) { if (!href) return null; let candidate = null; if (href.startsWith("#/") && !href.includes("?")) { candidate = href.slice(2); } else if (href.startsWith("/") && href.indexOf("/", 1) === -1 && !href.includes("?") && !href.includes("#")) { candidate = href.slice(1); } else if (href.startsWith("./") && href.indexOf("/", 2) === -1 && !href.includes("?") && !href.includes("#")) { candidate = href.slice(2); } else if (!href.includes("/") && !href.includes(":") && !href.includes("#") && !href.includes("?")) { candidate = href; } if (!candidate) return null; const reserved = new Set(["api", "uploads", "setup", "login", "vendor", "css", "js", "index.html"]); let decoded; try { decoded = decodeURIComponent(candidate); } catch (_error) { return null; } return reserved.has(decoded) ? null : decoded; } function installWikiLinkNavigation() { $("markdown-preview").addEventListener("click", (event) => { const link = event.target.closest("a"); if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } const slug = wikiSlugFromHref(link.getAttribute("href")); if (!slug) return; event.preventDefault(); location.hash = `#/${encodeURIComponent(slug)}`; }); } function toolbarButton(name, action, icon, title, options = {}) { return { name, action, className: `fa fa-${icon}`, title, ...options }; } function initializeHighlighting() { if (!window.hljs) return; if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) { window.hljs.registerAliases(["racket"], { languageName: "scheme" }); } } function initializeEditor() { if (!window.EasyMDE) { throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup."); } if (!window.DOMPurify) { throw new Error("DOMPurify is not installed. Open /setup to repair the frontend setup."); } initializeHighlighting(); easyMDE = new EasyMDE({ element: $("markdown-editor"), autoDownloadFontAwesome: false, autoRefresh: { delay: 200 }, forceSync: true, lineNumbers: true, lineWrapping: true, indentWithTabs: false, tabSize: 2, minHeight: "520px", nativeSpellcheck: true, spellChecker: false, previewImagesInEditor: true, previewClass: ["editor-preview", "markdown-body", "preview-pane"], sideBySideFullscreen: false, syncSideBySidePreviewScroll: true, status: ["lines", "words", "cursor"], uploadImage: true, imageMaxSize: 50 * 1024 * 1024, imageAccept: "image/png,image/jpeg,image/gif,image/webp", imageUploadFunction: (file, onSuccess, onError) => { uploadImageForEasyMDE(file) .then(onSuccess) .catch((error) => onError(error.message)); }, previewRender: (plainText) => easyMDE ? renderMarkdown(plainText, state.currentPage?.slug || state.newPageSlug) : "", renderingConfig: { codeSyntaxHighlighting: true, hljs: window.hljs, sanitizerFunction: (html) => DOMPurify.sanitize(html) }, toolbar: [ toolbarButton("bold", EasyMDE.toggleBold, "bold", tr("bold", "Bold")), toolbarButton("italic", EasyMDE.toggleItalic, "italic", tr("italic", "Italic")), toolbarButton("strikethrough", EasyMDE.toggleStrikethrough, "strikethrough", tr("strikethrough", "Strikethrough")), toolbarButton("heading", EasyMDE.toggleHeadingSmaller, "header", tr("heading", "Heading")), "|", toolbarButton("quote", EasyMDE.toggleBlockquote, "quote-left", tr("quote", "Quote")), toolbarButton("unordered-list", EasyMDE.toggleUnorderedList, "list-ul", tr("bulleted-list", "Bulleted list")), toolbarButton("ordered-list", EasyMDE.toggleOrderedList, "list-ol", tr("numbered-list", "Numbered list")), toolbarButton("check-list", EasyMDE.toggleCheckList, "check-square-o", tr("checklist", "Checklist")), toolbarButton("code", EasyMDE.toggleCodeBlock, "code", tr("code-block", "Code block")), toolbarButton("table", EasyMDE.drawTable, "table", tr("table", "Table")), "|", toolbarButton("link", EasyMDE.drawLink, "link", tr("link", "Link")), toolbarButton("upload-image", EasyMDE.drawUploadedImage, "picture-o", tr("upload-image", "Upload image")), toolbarButton("file", () => $("file-input").click(), "paperclip", tr("upload-file", "Upload file")), toolbarButton("horizontal-rule", EasyMDE.drawHorizontalRule, "minus", tr("horizontal-rule", "Horizontal rule")), "|", toolbarButton("undo", EasyMDE.undo, "undo", tr("undo", "Undo")), toolbarButton("redo", EasyMDE.redo, "repeat", tr("redo", "Redo")), toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }), toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }), toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "arrows-alt", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true }) ] }); easyMDE.codemirror.on("change", () => { if (!$("editor-view").classList.contains("hidden")) { renderEditorToc(); } }); installGeneralFileDrop(); window.addEventListener("resize", () => { if (!$("editor-view").classList.contains("hidden")) { updateEditorChromeMetrics(); easyMDE.codemirror.refresh(); } }); } function editorContainer() { return easyMDE.codemirror.getWrapperElement().closest(".EasyMDEContainer"); } function updateEditorChromeMetrics() { if (!easyMDE) return; const container = editorContainer(); const toolbar = container.querySelector(".editor-toolbar"); const statusbar = container.querySelector(".editor-statusbar"); const toolbarHeight = toolbar ? toolbar.offsetHeight : 0; const statusHeight = statusbar ? statusbar.offsetHeight : 0; container.style.setProperty("--editor-toolbar-height", `${toolbarHeight}px`); container.style.setProperty("--editor-status-height", `${statusHeight}px`); } function activateEditor() { show("editor-view"); renderEditorToc(); requestAnimationFrame(() => { easyMDE.codemirror.refresh(); const wideScreen = window.matchMedia("(min-width: 901px)").matches; if (wideScreen && !easyMDE.isSideBySideActive()) { easyMDE.toggleSideBySide(); } updateEditorChromeMetrics(); easyMDE.codemirror.refresh(); }); } function headingId(text, usedIds) { const base = text .trim() .toLocaleLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^\p{L}\p{N}]+/gu, "-") .replace(/^-+|-+$/g, "") || "section"; let id = base; let number = 2; while (usedIds.has(id)) { id = `${base}-${number}`; number += 1; } usedIds.add(id); return id; } function markdownHeadings(markdown) { const headings = []; let inFence = false; const fencePattern = /^\s*(```|~~~)/; (markdown || "").replace(/\r\n/g, "\n").split("\n").forEach((line, lineNumber) => { if (fencePattern.test(line)) { inFence = !inFence; return; } if (inFence) return; const match = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/); if (!match) return; headings.push({ level: match[1].length, text: match[2].trim(), line: lineNumber }); }); return headings; } function renderToc(entries, onSelect, onEdit = null) { const toc = $("toc-list"); toc.replaceChildren(); if (entries.length === 0) { const empty = document.createElement("div"); empty.className = "toc-empty"; empty.textContent = tr("no-headings", "No headings"); toc.append(empty); return; } for (const entry of entries) { const row = document.createElement("div"); row.className = "toc-row"; const link = document.createElement("a"); link.href = entry.href || "#"; link.className = `toc-link toc-level-${Math.min(entry.level, 4)}`; link.textContent = entry.text; link.addEventListener("click", (event) => { event.preventDefault(); onSelect(entry); }); row.append(link); if (onEdit) { const edit = document.createElement("a"); edit.href = "#"; edit.className = "toc-edit-link"; edit.textContent = "✎"; edit.title = tr("edit-section", "Edit section"); edit.setAttribute("aria-label", `${tr("edit-section", "Edit section")}: ${entry.text}`); edit.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); onEdit(entry); }); row.append(edit); } toc.append(row); } } function renderPageToc() { const article = $("markdown-preview"); const usedIds = new Set(); const sourceHeadings = markdownHeadings(state.currentPage?.markdown || ""); const entries = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6")).map((heading, index) => { const text = heading.textContent.trim(); const id = heading.id || headingId(text, usedIds); heading.id = id; return { level: Number(heading.tagName.slice(1)), text, href: `#${id}`, element: heading, line: sourceHeadings[index]?.line ?? null }; }); renderToc( entries, (entry) => { entry.element.scrollIntoView({ behavior: "smooth", block: "start" }); history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`); }, can("editor") ? (entry) => beginEditPageAtLine(entry.line) : null ); } function renderEditorToc() { if (!easyMDE) return; const entries = markdownHeadings(easyMDE.value()); renderToc(entries, (entry) => { easyMDE.codemirror.setCursor({ line: entry.line, ch: 0 }); easyMDE.codemirror.scrollIntoView({ line: entry.line, ch: 0 }, 120); easyMDE.codemirror.focus(); }); } function focusEditorLine(line) { if (!easyMDE || line === null || line === undefined) return; easyMDE.codemirror.setCursor({ line, ch: 0 }); easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160); easyMDE.codemirror.focus(); } function beginEditPageAtLine(line) { beginEditPage(); requestAnimationFrame(() => { requestAnimationFrame(() => focusEditorLine(line)); }); } function renderBreadcrumbs(items) { const breadcrumbs = $("breadcrumbs"); breadcrumbs.replaceChildren(); if (!items || items.length === 0) { breadcrumbs.classList.add("hidden"); return; } breadcrumbs.classList.remove("hidden"); const label = document.createElement("span"); label.className = "breadcrumb-label"; label.textContent = `${tr("you-are-here", "You are here")}:`; breadcrumbs.append(label); items.forEach((item, index) => { if (index > 0) { const separator = document.createElement("span"); separator.className = "breadcrumb-separator"; separator.textContent = "»"; breadcrumbs.append(separator); } if (item.href) { const link = document.createElement("a"); link.href = item.href; if (item.href === "/") link.dataset.home = "true"; link.textContent = item.label; breadcrumbs.append(link); } else { const current = document.createElement("span"); current.className = "breadcrumb-current"; current.textContent = item.label; breadcrumbs.append(current); } }); } function pageBreadcrumbs(page, suffix = null) { const items = [ { label: state.siteTitle, href: "/" }, { label: page?.title || page?.slug || "New page" } ]; if (suffix) { items[items.length - 1].href = `#/${encodeURIComponent(page.slug)}`; items.push({ label: suffix }); } renderBreadcrumbs(items); } function startPage() { const namedStartPage = state.pages.find((page) => page.slug === "start") || null; if (namedStartPage) return namedStartPage; if (state.pages.length === 0) return null; return state.pages.reduce((first, page) => { if (!first) return page; return Number(page.createdAt) < Number(first.createdAt) ? page : first; }, null); } function updateWikiIdentity() { const firstPage = startPage(); state.siteTitle = firstPage?.title || "Racket Wiki"; $("wiki-brand").textContent = state.siteTitle; $("wiki-brand").href = firstPage ? `#/${encodeURIComponent(firstPage.slug)}` : "#"; document.title = state.siteTitle; } async function goHome() { const firstPage = startPage(); if (!firstPage) { await route(); return; } const targetHash = `#/${encodeURIComponent(firstPage.slug)}`; if (location.hash === targetHash) { await openPage(firstPage.slug); } else { location.hash = targetHash; } } function renderPageList() { const list = $("page-list"); list.replaceChildren(); for (const page of state.pages) { const link = document.createElement("a"); link.href = `#/${encodeURIComponent(page.slug)}`; link.className = "page-link"; link.textContent = page.title; link.classList.toggle("active", state.currentPage?.slug === page.slug); link.addEventListener("click", (event) => { event.preventDefault(); location.hash = `#/${encodeURIComponent(page.slug)}`; }); list.append(link); } } async function loadPages() { const result = await api("/api/pages"); state.pages = result.pages; updateWikiIdentity(); renderPageList(); updateTemplateSelect(); } function templatePages() { return state.pages.filter((page) => page.slug.toLocaleLowerCase().startsWith("template-")); } function updateTemplateSelect() { const select = $("template-select"); const selected = select.value; select.replaceChildren(); const none = document.createElement("option"); none.value = ""; none.textContent = tr("no-template", "No template"); select.append(none); for (const page of templatePages()) { const option = document.createElement("option"); option.value = page.slug; option.textContent = page.title; select.append(option); } select.value = Array.from(select.options).some((option) => option.value === selected) ? selected : ""; } async function applyTemplate(slug) { if (!slug) return; const template = await api(`/api/pages/${encodeURIComponent(slug)}`); const current = easyMDE.value(); if (current.trim() !== "") { const message = tr("replace-with-template", "Replace the current page content with template {template}?") .replace("{template}", template.title); if (!window.confirm(message)) { $("template-select").value = ""; return; } } easyMDE.value(template.markdown || ""); $("template-select").value = ""; renderEditorToc(); easyMDE.codemirror.focus(); } function bookmarkForSlug(slug) { return state.bookmarks.find((bookmark) => bookmark.slug === slug) || null; } function updateBookmarkAction() { const link = $("bookmark-page"); if (!state.currentPage) { link.classList.add("hidden"); return; } link.classList.remove("hidden"); const bookmark = bookmarkForSlug(state.currentPage.slug); link.textContent = bookmark ? tr("bookmarked", "Bookmarked") : tr("bookmark", "Bookmark"); } async function loadBookmarks() { const result = await api("/api/bookmarks"); state.bookmarks = result.bookmarks || []; updateBookmarkAction(); } async function openPage(slug) { const page = await api(`/api/pages/${encodeURIComponent(slug)}`); state.currentPage = page; state.editingNew = false; state.newPageSlug = null; $("page-title").textContent = page.title; $("page-meta").textContent = ""; $("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug); renderPageDetails(page); pageBreadcrumbs(page); show("page-view"); setPageActionVisibility(true); renderPageToc(); renderPageList(); updateBookmarkAction(); } function updateEditorSlugInfo() { if (!state.editingNew && state.currentPage) { $("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.currentPage.slug}`; return; } if (state.newPageSlug) { $("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.newPageSlug}`; return; } $("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save."); } function beginNewPage(requestedSlug = null) { state.editingNew = true; state.currentPage = null; state.newPageSlug = requestedSlug; const translationPage = requestedSlug && requestedSlug === state.translationPage; $("editor-title").value = translationPage ? tr("translations", "Translations") : ""; $("editor-tags").value = ""; if (translationPage) { easyMDE.value(state.translationTemplate || ""); } else { easyMDE.value(""); } $("edit-summary").value = ""; $("save-status").textContent = ""; $("template-select").value = ""; updateEditorSlugInfo(); activateEditor(); $("editor-title").focus(); } function beginEditPage() { if (!state.currentPage) { if (state.newPageSlug) { beginNewPage(state.newPageSlug); } return; } state.editingNew = false; state.newPageSlug = null; $("editor-title").value = state.currentPage.title; $("editor-tags").value = (state.currentPage.tags || []).join(", "); easyMDE.value(state.currentPage.markdown); $("edit-summary").value = ""; $("save-status").textContent = ""; $("template-select").value = ""; updateEditorSlugInfo(); activateEditor(); } async function savePage() { const title = $("editor-title").value.trim(); const markdown = easyMDE.value(); const tags = parseTags($("editor-tags").value); const summary = $("edit-summary").value.trim(); $("save-status").textContent = tr("saving", "Saving…"); try { let page; if (state.editingNew) { const body = { title, markdown, tags, summary: summary || tr("created-page", "Created page") }; if (state.newPageSlug) { body.slug = state.newPageSlug; } page = await api("/api/pages", { method: "POST", body: JSON.stringify(body) }); } else { const slug = state.currentPage.slug; page = await api(`/api/pages/${encodeURIComponent(slug)}`, { method: "PUT", body: JSON.stringify({ title, markdown, tags, baseVersion: state.currentPage.currentVersion, summary: summary || tr("edited-page", "Edited page") }) }); } state.currentPage = page; state.editingNew = false; state.newPageSlug = null; await loadPages(); if (page.slug === state.translationPage) { const translationData = await api("/api/translations"); state.language = translationData.language; state.translationTemplate = translationData.template || state.translationTemplate; state.translations = translationData.translations || {}; applyTranslations(); $("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role); } location.hash = `#/${encodeURIComponent(page.slug)}`; await openPage(page.slug); $("save-status").textContent = tr("saved", "Saved"); } catch (error) { $("save-status").textContent = error.message; } } function insertTextAtCursor(text) { const doc = easyMDE.codemirror.getDoc(); doc.replaceSelection(text, "end"); easyMDE.codemirror.focus(); } function isInlineImage(file) { return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.type); } function requireUploadablePage() { if (state.editingNew || !state.currentPage) { throw new Error("Save a new page once before uploading files."); } return state.currentPage.slug; } async function uploadOneFile(file) { const slug = requireUploadablePage(); $("save-status").textContent = `${tr("uploading", "Uploading")} ${file.name}…`; return api(`/api/pages/${encodeURIComponent(slug)}/upload`, { method: "POST", headers: { "X-File-Name": file.name }, body: file }); } async function uploadImageForEasyMDE(file) { if (!isInlineImage(file)) { throw new Error("Only PNG, JPEG, GIF and WebP can be inserted as images."); } const result = await uploadOneFile(file); $("save-status").textContent = tr("image-upload-complete", "Image upload complete"); return result.url; } async function uploadFiles(files) { const fileList = Array.from(files || []); if (fileList.length === 0) return; try { requireUploadablePage(); for (const file of fileList) { const result = await uploadOneFile(file); const escapedName = file.name.replace(/[\[\]]/g, "\\$&"); const markdown = isInlineImage(file) ? `![${escapedName}](${result.url})` : `[${escapedName}](${result.url})`; insertTextAtCursor(`\n${markdown}\n`); } $("save-status").textContent = tr("upload-complete", "Upload complete"); } catch (error) { $("save-status").textContent = error.message; throw error; } } function containsNonImageFile(dataTransfer) { const items = Array.from(dataTransfer?.items || []).filter((item) => item.kind === "file"); return items.some((item) => !item.type.startsWith("image/")); } function installGeneralFileDrop() { const container = editorContainer(); if (!container) return; container.addEventListener("dragenter", (event) => { if (Array.from(event.dataTransfer?.types || []).includes("Files")) { container.classList.add("dragging"); } }, true); container.addEventListener("dragover", (event) => { if (containsNonImageFile(event.dataTransfer)) { event.preventDefault(); } }, true); container.addEventListener("dragleave", (event) => { if (!container.contains(event.relatedTarget)) { container.classList.remove("dragging"); } }, true); container.addEventListener("drop", (event) => { container.classList.remove("dragging"); const files = Array.from(event.dataTransfer?.files || []); if (files.some((file) => !isInlineImage(file))) { event.preventDefault(); event.stopImmediatePropagation(); uploadFiles(files).catch(() => {}); } }, true); } async function deleteCurrentPage() { if (!state.currentPage) return; if (!confirm(`Archive page “${state.currentPage.title}”?`)) return; await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}`, { method: "DELETE" }); state.currentPage = null; await loadPages(); if (state.pages.length > 0) { const firstPage = startPage(); location.hash = `#/${encodeURIComponent(firstPage.slug)}`; } else { $("page-title").textContent = tr("no-pages", "No pages yet"); $("markdown-preview").innerHTML = ""; renderBreadcrumbs([{ label: state.siteTitle }]); renderToc([], () => {}); show("page-view"); } } function buildUnifiedDiff(oldText, newText, oldName, newName) { const oldLines = oldText.replace(/\r\n/g, "\n").split("\n"); const newLines = newText.replace(/\r\n/g, "\n").split("\n"); const n = oldLines.length; const m = newLines.length; if (n * m > 4_000_000) { return [ `--- ${oldName}`, `+++ ${newName}`, `@@ -1,${n} +1,${m} @@`, ...oldLines.map((line) => `-${line}`), ...newLines.map((line) => `+${line}`) ].join("\n"); } const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { table[i][j] = oldLines[i] === newLines[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]); } } const body = []; let i = 0; let j = 0; while (i < n && j < m) { if (oldLines[i] === newLines[j]) { body.push(` ${oldLines[i]}`); i++; j++; } else if (table[i + 1][j] >= table[i][j + 1]) { body.push(`-${oldLines[i++]}`); } else { body.push(`+${newLines[j++]}`); } } while (i < n) body.push(`-${oldLines[i++]}`); while (j < m) body.push(`+${newLines[j++]}`); return [ `--- ${oldName}`, `+++ ${newName}`, `@@ -1,${n} +1,${m} @@`, ...body ].join("\n"); } function appendSearchSnippet(target, snippet) { const parts = String(snippet || "").split(/(\[\[\[|\]\]\])/); let highlighted = false; let mark = null; for (const part of parts) { if (part === "[[[") { highlighted = true; mark = document.createElement("mark"); target.append(mark); } else if (part === "]]]") { highlighted = false; mark = null; } else if (part) { const text = document.createTextNode(part.replace(/\s+/g, " ")); if (highlighted && mark) mark.append(text); else target.append(text); } } } async function searchWiki(query) { const text = (query || "").trim(); if (!text) { await route(); return; } const result = await api(`/api/search?q=${encodeURIComponent(text)}`); renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("search", "Search") } ]); renderToc([], () => {}); show("search-view"); const results = $("search-results"); results.replaceChildren(); const resultWord = result.results.length === 1 ? tr("result", "result") : tr("results", "results"); $("search-summary").textContent = `${result.results.length} ${resultWord} ${tr("for", "for")} “${text}”`; for (const item of result.results) { const article = document.createElement("article"); article.className = "search-result"; const heading = document.createElement("h2"); const link = document.createElement("a"); link.href = `#/${encodeURIComponent(item.slug)}`; link.textContent = item.title; heading.append(link); const snippet = document.createElement("p"); appendSearchSnippet(snippet, item.snippet); article.append(heading, snippet); results.append(article); } if (result.results.length === 0) { const empty = document.createElement("p"); empty.className = "muted"; empty.textContent = tr("no-matching-pages", "No matching pages."); results.append(empty); } } async function showHistory() { if (!state.currentPage) return; state.previousView = "page-view"; pageBreadcrumbs(state.currentPage, "History"); show("history-view"); const result = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/history`); const list = $("history-list"); list.replaceChildren(); $("diff-target").replaceChildren(); result.versions.forEach((version, index) => { const row = document.createElement("div"); row.className = "history-row"; const label = document.createElement("div"); label.innerHTML = `${pageDisplayDate(version.createdAt)}
`; label.querySelector("span").textContent = `${version.author} — ${version.summary}`; const view = document.createElement("button"); view.textContent = tr("view", "View"); view.addEventListener("click", async () => { const full = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`); $("diff-target").innerHTML = `
${renderMarkdown(full.markdown, state.currentPage?.slug)}
`; }); const compare = document.createElement("button"); compare.textContent = index + 1 < result.versions.length ? tr("compare-previous", "Compare previous") : ""; compare.disabled = index + 1 >= result.versions.length; compare.addEventListener("click", async () => { const older = result.versions[index + 1]; const [oldVersion, newVersion] = await Promise.all([ api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(older.version)}`), api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`) ]); const diff = buildUnifiedDiff(oldVersion.markdown, newVersion.markdown, older.version, version.version); const target = $("diff-target"); target.replaceChildren(); const ui = new Diff2HtmlUI(target, diff, { drawFileList: false, matching: "lines", outputFormat: "side-by-side", highlight: false }); ui.draw(); }); row.append(label, view, compare); list.append(row); }); } function showAdmin() { state.previousView = state.currentPage ? "page-view" : "admin-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("admin", "Admin") } ]); show("admin-view"); } async function loadUsersAdmin() { renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("admin", "Admin") }, { label: tr("users", "Users") } ]); show("user-admin-view"); const result = await api("/api/admin/users"); const list = $("user-list"); list.replaceChildren(); for (const user of result.users) { const row = document.createElement("div"); row.className = "user-row"; const username = document.createElement("strong"); username.textContent = user.username; const display = document.createElement("input"); display.value = user.displayName; const role = document.createElement("select"); for (const roleName of ["reader", "editor", "admin"]) { const option = document.createElement("option"); option.value = roleName; option.textContent = tr(`role-${roleName}`, roleName); option.selected = roleName === user.role; role.append(option); } const enabled = document.createElement("input"); enabled.type = "checkbox"; enabled.checked = user.enabled; const password = document.createElement("input"); password.type = "password"; password.placeholder = tr("new-password", "New password"); password.autocomplete = "new-password"; const actions = document.createElement("div"); const save = document.createElement("button"); save.textContent = tr("save", "Save"); save.addEventListener("click", async () => { await api(`/api/admin/users/${user.id}`, { method: "PUT", body: JSON.stringify({ displayName: display.value, role: role.value, enabled: enabled.checked, password: password.value || undefined }) }); password.value = ""; }); const remove = document.createElement("button"); remove.textContent = tr("delete", "Delete"); remove.className = "danger"; remove.addEventListener("click", async () => { if (!confirm(`Delete user ${user.username}?`)) return; await api(`/api/admin/users/${user.id}`, { method: "DELETE" }); await loadUsersAdmin(); }); actions.append(save, remove); row.append(username, display, role, enabled, password, actions); list.append(row); } } function showNotFound(slug) { state.currentPage = null; state.editingNew = false; state.newPageSlug = null; $("not-found-message").textContent = `${tr("page-does-not-exist", "The page does not exist.")} (${slug})`; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: slug } ]); show("not-found-view"); renderToc([], () => {}); renderPageList(); } function showMissingEditablePage(slug) { state.currentPage = null; state.editingNew = false; state.newPageSlug = slug; $("page-title").textContent = slugTitle(slug) || slug; $("page-meta").textContent = tr("this-page-missing", "This page does not exist yet."); $("markdown-preview").innerHTML = ""; $("page-details").replaceChildren(); renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: slugTitle(slug) || slug } ]); show("page-view"); setPageActionVisibility(false); renderToc([], () => {}); renderPageList(); } async function route() { const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/); if (todoMatch) { await showTodos(decodeURIComponent(todoMatch[1]), Number(todoMatch[2])); return; } const match = location.hash.match(/^#\/([^/]+)$/); if (match) { const slug = decodeURIComponent(match[1]); try { await openPage(slug); } catch (error) { if (error.status !== 404) { throw error; } if (can("editor")) { showMissingEditablePage(slug); } else { showNotFound(slug); } } return; } if (state.pages.length > 0) { const firstPage = startPage(); location.hash = `#/${encodeURIComponent(firstPage.slug)}`; } else { if (can("editor")) { showMissingEditablePage("start"); } else { state.currentPage = null; $("page-title").textContent = tr("no-pages", "No pages yet"); $("page-meta").textContent = ""; $("markdown-preview").innerHTML = ""; renderBreadcrumbs([{ label: state.siteTitle }]); renderToc([], () => {}); show("page-view"); } } } function formatTimestamp(value) { const date = new Date(Number(value) * 1000); return date.toLocaleString(state.language || undefined); } async function showRecent() { state.previousView = state.currentPage ? "page-view" : "recent-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("recent", "Recent") } ]); setPageActionVisibility(false); show("recent-view"); const result = await api("/api/recent"); const list = $("recent-list"); list.replaceChildren(); const pages = result.pages || []; if (pages.length === 0) { const empty = document.createElement("p"); empty.className = "muted"; empty.textContent = tr("no-recent-pages", "No recent pages."); list.append(empty); return; } for (const page of pages) { const row = document.createElement("div"); row.className = "special-page-row"; const link = document.createElement("a"); link.href = `#/${encodeURIComponent(page.slug)}`; link.textContent = page.title; const meta = document.createElement("span"); meta.className = "special-page-meta"; meta.textContent = `${formatTimestamp(page.updatedAt)} · ${tr("changed-by", "changed by")} ${page.updatedBy}`; row.append(link, meta); list.append(row); } } async function saveBookmark(slug, existingSection = "") { const section = window.prompt(tr("bookmark-section", "Bookmark section"), existingSection); if (section === null) return; await api("/api/bookmarks", { method: "POST", body: JSON.stringify({ slug, section: section.trim() }) }); await loadBookmarks(); } async function removeBookmark(slug) { await api(`/api/bookmarks/${encodeURIComponent(slug)}`, { method: "DELETE" }); await loadBookmarks(); } async function showBookmarks() { state.previousView = state.currentPage ? "page-view" : "bookmarks-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("bookmarks", "Bookmarks") } ]); setPageActionVisibility(false); show("bookmarks-view"); await loadBookmarks(); const target = $("bookmarks-list"); target.replaceChildren(); if (state.bookmarks.length === 0) { const empty = document.createElement("p"); empty.className = "muted"; empty.textContent = tr("no-bookmarks", "No bookmarks yet."); target.append(empty); return; } const groups = new Map(); for (const bookmark of state.bookmarks) { const section = bookmark.section || ""; if (!groups.has(section)) groups.set(section, []); groups.get(section).push(bookmark); } for (const [section, bookmarks] of groups.entries()) { const sectionElement = document.createElement("section"); sectionElement.className = "bookmark-section"; const heading = document.createElement("h2"); heading.textContent = section || tr("bookmarks", "Bookmarks"); sectionElement.append(heading); for (const bookmark of bookmarks) { const row = document.createElement("div"); row.className = "bookmark-row"; const link = document.createElement("a"); link.href = `#/${encodeURIComponent(bookmark.slug)}`; link.textContent = bookmark.title; const actions = document.createElement("span"); actions.className = "bookmark-section-actions"; const move = document.createElement("a"); move.href = "#"; move.textContent = tr("move", "Move"); move.addEventListener("click", async (event) => { event.preventDefault(); await saveBookmark(bookmark.slug, bookmark.section || ""); await showBookmarks(); }); const separator = document.createTextNode(" · "); const remove = document.createElement("a"); remove.href = "#"; remove.textContent = tr("remove", "Remove"); remove.addEventListener("click", async (event) => { event.preventDefault(); await removeBookmark(bookmark.slug); await showBookmarks(); }); actions.append(move, separator, remove); row.append(link, actions); sectionElement.append(row); } target.append(sectionElement); } } async function showTodos(targetSlug = null, targetNumber = null) { state.previousView = state.currentPage ? "page-view" : "todo-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("todo", "Todo") } ]); renderToc([], () => {}); show("todo-view"); const result = await api("/api/todos"); const list = $("todo-list"); list.replaceChildren(); if (result.items.length === 0) { const empty = document.createElement("p"); empty.className = "muted"; empty.textContent = tr("no-todos", "No todo items."); list.append(empty); return; } for (const item of result.items) { const row = document.createElement("article"); row.className = "todo-item"; row.id = `todo-${item.slug}-${item.number}`; const link = document.createElement("a"); link.href = `#/${encodeURIComponent(item.slug)}`; link.textContent = item.title; const text = document.createElement("div"); text.className = "todo-item-text"; text.textContent = item.text; const location = document.createElement("span"); location.className = "muted"; location.textContent = `${tr("line", "line")} ${item.line}`; row.append(link, text, location); list.append(row); } if (targetSlug && targetNumber) { const target = document.getElementById(`todo-${targetSlug}-${targetNumber}`); if (target) { target.classList.add("todo-item-target"); target.scrollIntoView({ block: "center" }); } } } function pageLinks(markdown) { const container = document.createElement("div"); container.innerHTML = renderMarkdown(markdown || ""); const links = new Set(); for (const link of container.querySelectorAll("a[href]")) { const slug = wikiSlugFromHref(link.getAttribute("href")); if (slug) links.add(slug); } return links; } async function loadGraphData() { const pageSlugs = new Set(state.pages.map((page) => page.slug)); const edges = []; const seen = new Set(); for (const page of state.pages) { const full = await api(`/api/pages/${encodeURIComponent(page.slug)}`); for (const target of pageLinks(full.markdown)) { if (!pageSlugs.has(target) || target === page.slug) continue; const key = `${page.slug}\u0000${target}`; if (seen.has(key)) continue; seen.add(key); edges.push({ from: page.slug, to: target }); } } return { nodes: state.pages, edges }; } function renderWikiGraph(data, focusSlug = null) { const svg = $("wiki-graph"); svg.replaceChildren(); const width = 1000; const height = 700; const centerX = width / 2; const centerY = height / 2; const radius = Math.min(width, height) * 0.36; const positions = new Map(); const nodes = data.nodes; nodes.forEach((page, index) => { const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index / nodes.length) - Math.PI / 2; positions.set(page.slug, { x: nodes.length <= 1 ? centerX : centerX + Math.cos(angle) * radius, y: nodes.length <= 1 ? centerY : centerY + Math.sin(angle) * radius }); }); const edgeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g"); edgeLayer.setAttribute("class", "wiki-graph-edges"); for (const edge of data.edges) { const from = positions.get(edge.from); const to = positions.get(edge.to); if (!from || !to) continue; const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.setAttribute("x1", from.x); line.setAttribute("y1", from.y); line.setAttribute("x2", to.x); line.setAttribute("y2", to.y); edgeLayer.append(line); } svg.append(edgeLayer); const nodeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g"); nodeLayer.setAttribute("class", "wiki-graph-nodes"); for (const page of nodes) { const position = positions.get(page.slug); const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); group.setAttribute("class", page.slug === focusSlug ? "wiki-graph-node wiki-graph-node-focus" : "wiki-graph-node"); group.setAttribute("transform", `translate(${position.x} ${position.y})`); group.setAttribute("tabindex", "0"); group.setAttribute("role", "link"); group.setAttribute("aria-label", page.title); const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); circle.setAttribute("r", "11"); const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); text.setAttribute("x", "17"); text.setAttribute("y", "4"); text.textContent = page.title.length > 34 ? `${page.title.slice(0, 31)}…` : page.title; const open = () => { location.hash = `#/${encodeURIComponent(page.slug)}`; }; group.addEventListener("click", open); group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); } }); group.append(circle, text); nodeLayer.append(group); } svg.append(nodeLayer); } async function showGraph() { state.previousView = state.currentPage ? "page-view" : "graph-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("graph", "Graph") } ]); renderToc([], () => {}); show("graph-view"); $("graph-title").textContent = tr("wiki-graph", "Wiki graph"); const data = await loadGraphData(); $("graph-summary").textContent = tr("graph-summary", "{pages} pages, {links} links") .replace("{pages}", String(data.nodes.length)) .replace("{links}", String(data.edges.length)); renderWikiGraph(data); } async function showContextGraph() { if (!state.currentPage) return; const page = state.currentPage; state.previousView = "page-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: page.title, href: `#/${encodeURIComponent(page.slug)}` }, { label: tr("context", "context") } ]); renderToc([], () => {}); show("graph-view"); $("graph-title").textContent = tr("context-graph", "Context graph"); const data = await loadGraphData(); const relatedSlugs = new Set([page.slug]); const contextEdges = []; for (const edge of data.edges) { if (edge.from === page.slug || edge.to === page.slug) { relatedSlugs.add(edge.from); relatedSlugs.add(edge.to); contextEdges.push(edge); } } const contextNodes = data.nodes.filter((node) => relatedSlugs.has(node.slug)); $("graph-summary").textContent = tr("context-graph-summary", "{pages} related pages, {links} links") .replace("{pages}", String(Math.max(0, contextNodes.length - 1))) .replace("{links}", String(contextEdges.length)); renderWikiGraph({ nodes: contextNodes, edges: contextEdges }, page.slug); } function setConnectionStatus(status) { const node = $("connection-status"); node.classList.remove("hidden", "online", "offline"); node.classList.add(status); node.textContent = status === "offline" ? tr("offline", "Offline") : tr("online", "Online"); if (status === "online") { window.setTimeout(() => node.classList.add("hidden"), 1800); } } async function pingServer() { const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 5000); try { const response = await fetch("/api/ping", { cache: "no-store", signal: controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); if ($("connection-status").classList.contains("offline")) setConnectionStatus("online"); } catch (_error) { setConnectionStatus("offline"); } finally { window.clearTimeout(timeout); } } function startKeepAlive() { window.setInterval(pingServer, 15000); pingServer(); } async function initialize() { state.session = await api("/api/session"); if (!state.session.authenticated) { window.location.replace("/login"); return; } const translationData = await api("/api/translations"); state.language = translationData.language; state.translationPage = translationData.page; state.translationTemplate = translationData.template || ""; state.translations = translationData.translations || {}; applyTranslations(); initializeEditor(); $("account-name").textContent = state.session.user.displayName; $("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role); updateRoleUi(); await loadPages(); await loadBookmarks(); await route(); startKeepAlive(); } installWikiLinkNavigation(); $("search-form").addEventListener("submit", (event) => { event.preventDefault(); searchWiki($("search-input").value).catch((error) => console.error(error)); }); $("bookmark-page").addEventListener("click", (event) => { event.preventDefault(); if (!state.currentPage) return; const existing = bookmarkForSlug(state.currentPage.slug); saveBookmark(state.currentPage.slug, existing?.section || "").catch((error) => console.error(error)); }); $("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); }); $("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); }); $("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); }); $("wiki-brand").addEventListener("click", (event) => { event.preventDefault(); goHome().catch((error) => console.error(error)); }); $("breadcrumbs").addEventListener("click", (event) => { const home = event.target.closest("a[data-home='true']"); if (!home) return; event.preventDefault(); goHome().catch((error) => console.error(error)); }); $("recent-link").addEventListener("click", (event) => { event.preventDefault(); showRecent().catch((error) => console.error(error)); }); $("bookmarks-link").addEventListener("click", (event) => { event.preventDefault(); showBookmarks().catch((error) => console.error(error)); }); $("todo-link").addEventListener("click", (event) => { event.preventDefault(); showTodos().catch((error) => console.error(error)); }); $("graph-link").addEventListener("click", (event) => { event.preventDefault(); showGraph().catch((error) => console.error(error)); }); $("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraph().catch((error) => console.error(error)); }); $("logout-link").addEventListener("click", async (event) => { event.preventDefault(); await api("/api/logout", { method: "POST", body: "{}" }); window.location.replace("/login"); }); $("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); }); $("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); }); $("admin-translations-link").addEventListener("click", (event) => { event.preventDefault(); const page = state.pages.find((item) => item.slug === state.translationPage); if (page) { location.hash = `#/${encodeURIComponent(state.translationPage)}`; return; } renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("translations", "Translations") } ]); beginNewPage(state.translationPage); }); $("close-history").addEventListener("click", () => show("page-view")); $("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); }); $("cancel-edit").addEventListener("click", () => { if (state.currentPage) { openPage(state.currentPage.slug); return; } if (state.newPageSlug) { state.newPageSlug = null; location.hash = ""; return; } route(); }); $("save-page").addEventListener("click", savePage); $("template-select").addEventListener("change", (event) => { applyTemplate(event.target.value).catch((error) => { $("save-status").textContent = error.message; event.target.value = ""; }); }); $("file-input").addEventListener("change", (event) => { uploadFiles(event.target.files).catch(() => {}); event.target.value = ""; }); $("new-user-form").addEventListener("submit", async (event) => { event.preventDefault(); await api("/api/admin/users", { method: "POST", body: JSON.stringify({ username: $("new-username").value, displayName: $("new-display-name").value || $("new-username").value, password: $("new-password").value, role: $("new-role").value, enabled: true }) }); event.target.reset(); await loadUsersAdmin(); }); window.addEventListener("hashchange", () => route().catch(console.error)); initialize().catch((error) => { console.error(error); const main = $("main"); main.replaceChildren(); const message = document.createElement("div"); message.className = "card error"; message.textContent = error.message; main.append(message); }); })();