2386 lines
85 KiB
JavaScript
2386 lines
85 KiB
JavaScript
import "/js/cmap/cmap-racket-wiki.js";
|
|
|
|
import {
|
|
newPageReference,
|
|
pageReference,
|
|
slugTitle,
|
|
splitPageReference
|
|
} from "./wiki/reference.js";
|
|
import { cmapRoute, pageRoute, parseWikiRoute } from "./wiki/routes.js";
|
|
import {
|
|
applyImageWidthMarkup,
|
|
escapeHtml,
|
|
expandNamespacedMarkdownLinks,
|
|
expandTodoMarkup,
|
|
expandWikiMentions,
|
|
normalizeMarkdownLinkDestinations,
|
|
protectCamelCaseWikiWords,
|
|
extractCmapEmbeds,
|
|
restoreCmapEmbeds
|
|
} from "./wiki/markdown.js";
|
|
import { BreadcrumbTrail } from "./wiki/breadcrumb-trail.js";
|
|
import { headingId, markdownHeadings } from "./wiki/page-outline.js";
|
|
import { AliasAdmin } from "./wiki/admin/alias-admin.js";
|
|
import { ArchivedCmapsAdmin } from "./wiki/admin/archived-cmaps-admin.js";
|
|
import { MailAdmin } from "./wiki/admin/mail-admin.js";
|
|
import { loadAdminOverview } from "./wiki/admin/overview.js";
|
|
import { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js";
|
|
import { UserAdmin } from "./wiki/admin/user-admin.js";
|
|
import { CmapWorkspaceController } from "./wiki/cmap/cmap-workspace-controller.js";
|
|
import { ComboBox } from "./widgets/combobox.js";
|
|
|
|
(() => {
|
|
"use strict";
|
|
|
|
/*
|
|
* racket-wiki browser application.
|
|
*
|
|
* The browser application uses direct native modules without a client
|
|
* framework or build step. This entry module owns application orchestration;
|
|
* cohesive, independently testable operations live below static/js/wiki/.
|
|
* Larger functions are documented with goal/pre/post/result-style comments,
|
|
* following the same readability rules as the Racket sources.
|
|
*/
|
|
|
|
const state = {
|
|
session: null,
|
|
pages: [],
|
|
pageAliases: [],
|
|
currentPage: null,
|
|
editingNew: false,
|
|
newPageSlug: null,
|
|
newPageSuggestedTitle: "",
|
|
previousView: "page-view",
|
|
translations: {},
|
|
translationPage: "wiki-translations",
|
|
translationTemplate: "",
|
|
siteTitle: "Racket Wiki",
|
|
bookmarks: [],
|
|
conceptMaps: [],
|
|
cmapConceptUsage: new Map(),
|
|
cmapConceptIdsByName: new Map(),
|
|
cmapPageConcepts: new Map(),
|
|
currentConceptMap: null,
|
|
currentConceptMapSource: null,
|
|
cmapLoadSequence: 0,
|
|
cmapSavedSnapshot: null,
|
|
cmapGuardHash: "",
|
|
cmapPrototype: null,
|
|
rawMarkdown: false
|
|
};
|
|
|
|
let easyMDE = null;
|
|
let pendingWikiCmapLinkLabel = "";
|
|
const sidebarPreferenceKey = "racket-wiki-sidebar-collapsed";
|
|
const editorSideBySidePreferenceKey = "racket-wiki-editor-side-by-side";
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// General UI and HTTP support
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox"));
|
|
const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
|
|
const cmapWorkspace = new CmapWorkspaceController(
|
|
state,
|
|
api,
|
|
tr,
|
|
can,
|
|
show,
|
|
renderBreadcrumbs,
|
|
renderToc,
|
|
pageDisplayDate,
|
|
loadPages,
|
|
openPage,
|
|
navigateToHash,
|
|
canonicalPageReference,
|
|
renderMarkdown,
|
|
renderPageCmapConnections,
|
|
escapeMarkdownLinkLabel
|
|
);
|
|
const aliasAdmin = new AliasAdmin(api, tr, loadPages);
|
|
const archivedCmapsAdmin = new ArchivedCmapsAdmin(
|
|
api, tr, pageDisplayDate, cmapWorkspace.loadConceptMaps);
|
|
const mailAdmin = new MailAdmin(api, tr);
|
|
const orphanedUploadsAdmin = new OrphanedUploadsAdmin(api, tr);
|
|
const userAdmin = new UserAdmin(api, tr);
|
|
|
|
function show(viewId) {
|
|
for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view", "archived-cmaps-view"]) {
|
|
$(id).classList.toggle("hidden", id !== viewId);
|
|
}
|
|
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
|
|
document.body.classList.toggle("editor-mode", viewId === "editor-view");
|
|
document.body.classList.toggle("cmap-mode", viewId === "cmap-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.querySelectorAll("[data-tr-title]").forEach((node) => {
|
|
node.title = tr(node.dataset.trTitle, node.title);
|
|
});
|
|
document.querySelectorAll("[data-tr-aria-label]").forEach((node) => {
|
|
node.setAttribute("aria-label", tr(node.dataset.trAriaLabel, node.getAttribute("aria-label")));
|
|
});
|
|
document.documentElement.lang = state.language || "en";
|
|
}
|
|
|
|
function setSidebarCollapsed(collapsed, remember = true) {
|
|
document.body.classList.toggle("sidebar-collapsed", collapsed);
|
|
const toggle = $("sidebar-toggle");
|
|
if (!toggle) return;
|
|
const translationKey = collapsed ? "expand-sidebar" : "collapse-sidebar";
|
|
toggle.dataset.trTitle = translationKey;
|
|
toggle.dataset.trAriaLabel = translationKey;
|
|
toggle.title = tr(translationKey, collapsed ? "Expand sidebar" : "Collapse sidebar");
|
|
toggle.setAttribute("aria-label", toggle.title);
|
|
toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
const icon = document.createElement("i");
|
|
icon.dataset.lucide = collapsed ? "panel-left-open" : "panel-left-close";
|
|
icon.setAttribute("aria-hidden", "true");
|
|
const oldIcon = toggle.querySelector("[data-lucide], svg");
|
|
if (oldIcon) {
|
|
oldIcon.replaceWith(icon);
|
|
} else {
|
|
toggle.append(icon);
|
|
}
|
|
if (window.lucide && typeof window.lucide.createIcons === "function") {
|
|
window.lucide.createIcons();
|
|
}
|
|
if (remember) {
|
|
try {
|
|
window.sessionStorage.setItem(sidebarPreferenceKey, collapsed ? "true" : "false");
|
|
} catch (_error) {
|
|
// A blocked session storage should not prevent the toggle from working.
|
|
}
|
|
}
|
|
}
|
|
|
|
function initializeSidebarToggle() {
|
|
const toggle = $("sidebar-toggle");
|
|
if (!toggle) return;
|
|
let collapsed = false;
|
|
try {
|
|
collapsed = window.sessionStorage.getItem(sidebarPreferenceKey) === "true";
|
|
} catch (_error) {
|
|
collapsed = false;
|
|
}
|
|
setSidebarCollapsed(collapsed, false);
|
|
toggle.addEventListener("click", () => setSidebarCollapsed(!document.body.classList.contains("sidebar-collapsed")));
|
|
}
|
|
|
|
function navigateToHash(targetHash) {
|
|
const navigate = () => {
|
|
if (location.hash === targetHash) return route();
|
|
location.hash = targetHash;
|
|
return undefined;
|
|
};
|
|
return cmapWorkspace.requestTransition(navigate);
|
|
}
|
|
|
|
function namespaceLabel(namespace) {
|
|
return namespace || tr("root-namespace", "Root");
|
|
}
|
|
|
|
function canonicalPageReference(reference) {
|
|
const value = String(reference || "");
|
|
const key = value.toLocaleLowerCase();
|
|
const alias = state.pageAliases.find((item) => String(item.slug || "").toLocaleLowerCase() === key);
|
|
return alias ? alias.targetSlug : value;
|
|
}
|
|
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* goal : Perform one authenticated API request and decode its response.
|
|
* pre : path identifies a racket-wiki API route.
|
|
* post : CSRF and JSON headers are applied when needed; 401 redirects to login.
|
|
* result : Decoded JSON/text response or a thrown Error.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Markdown rendering and wiki-link syntax
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function renderMarkdown(markdown, pageSlug = null) {
|
|
const extracted = extractCmapEmbeds(markdown || "");
|
|
const normalizedLinks = normalizeMarkdownLinkDestinations(extracted.markdown);
|
|
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(normalizedLinks);
|
|
const withWikiLinks = expandWikiMentions(
|
|
withExplicitWikiLinks,
|
|
state.pages,
|
|
state.pageAliases,
|
|
state.conceptMaps,
|
|
pageSlug);
|
|
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug, tr("todo", "Todo"));
|
|
const html = easyMDE.markdown(withTodos);
|
|
const withEmbeds = restoreCmapEmbeds(html, extracted.embeds, tr("loading", "Loading…"));
|
|
const withImages = applyImageWidthMarkup(withEmbeds);
|
|
const safeHtml = DOMPurify.sanitize(withImages);
|
|
if (extracted.embeds.length) cmapWorkspace.queueEmbedHydration();
|
|
if (/\b(?:language|lang)-mermaid\b/.test(safeHtml)) window.RacketWikiMermaid.queue(document);
|
|
return safeHtml;
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Page display and navigation
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function setPageActionVisibility(pageExists) {
|
|
if (!can("editor")) return;
|
|
$("edit-page").classList.remove("hidden");
|
|
$("rename-page").classList.toggle("hidden", !pageExists || state.currentPage?.slug === state.translationPage);
|
|
$("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 = [];
|
|
if (page.namespace) {
|
|
fields.push(`${tr("namespace", "Namespace")}: ${page.namespace}`);
|
|
}
|
|
fields.push(
|
|
`${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 renderPageCmapConnections(page) {
|
|
const section = $("page-cmap-connections");
|
|
const list = $("page-cmap-connections-list");
|
|
const pageKey = canonicalPageReference(page.slug).toLocaleLowerCase();
|
|
const concepts = [...(state.cmapPageConcepts.get(pageKey)?.values() || [])]
|
|
.sort((first, second) => first.label.localeCompare(second.label));
|
|
list.replaceChildren();
|
|
section.classList.toggle("hidden", concepts.length === 0);
|
|
if (!concepts.length) return;
|
|
|
|
for (const concept of concepts) {
|
|
const row = document.createElement("div");
|
|
row.className = "page-cmap-concept-row";
|
|
const description = document.createElement("div");
|
|
description.className = "page-cmap-concept-description";
|
|
const label = document.createElement("strong");
|
|
label.textContent = concept.label;
|
|
const usage = document.createElement("span");
|
|
usage.className = "muted";
|
|
usage.textContent = tr("concept-usage-count", "{count} placements across all concept maps")
|
|
.replace("{count}", String(concept.count));
|
|
description.append(label, usage);
|
|
|
|
const maps = document.createElement("nav");
|
|
maps.className = "page-cmap-concept-maps";
|
|
maps.setAttribute("aria-label", tr("concept-map-locations", "Concept map locations"));
|
|
for (const placement of [...concept.maps.values()]
|
|
.sort((first, second) => first.title.localeCompare(second.title))) {
|
|
const link = document.createElement("a");
|
|
link.href = cmapRoute(placement.slug);
|
|
link.textContent = placement.count > 1 ?
|
|
`${placement.title} (${placement.count})` : placement.title;
|
|
maps.append(link);
|
|
}
|
|
row.append(description, maps);
|
|
list.append(row);
|
|
}
|
|
}
|
|
|
|
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("?")) {
|
|
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 cmapSlugFromHref(href) {
|
|
if (!href) return null;
|
|
const candidate = href.startsWith("#cmap/") ? href.slice(6) :
|
|
(href.toLocaleLowerCase().startsWith("cmap:") ? href.slice(5) : null);
|
|
if (!candidate || candidate.includes("/") || candidate.includes("?") || candidate.includes("#")) return null;
|
|
try {
|
|
return decodeURIComponent(candidate);
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 cmapSlug = cmapSlugFromHref(link.getAttribute("href"));
|
|
if (cmapSlug) {
|
|
event.preventDefault();
|
|
location.hash = cmapRoute(cmapSlug);
|
|
return;
|
|
}
|
|
const slug = wikiSlugFromHref(link.getAttribute("href"));
|
|
if (!slug) return;
|
|
event.preventDefault();
|
|
location.hash = `#/${encodeURIComponent(slug)}`;
|
|
});
|
|
}
|
|
|
|
const editorToolbarIcons = new Map();
|
|
|
|
function toolbarButton(name, action, icon, title, options = {}) {
|
|
editorToolbarIcons.set(name, icon);
|
|
return {
|
|
name,
|
|
action,
|
|
className: `rw-mde-button rw-mde-${name}`,
|
|
title,
|
|
...options
|
|
};
|
|
}
|
|
|
|
function installLucideIcons() {
|
|
if (!window.lucide || typeof window.lucide.createIcons !== "function") {
|
|
throw new Error("Lucide is not installed. Open /setup to repair the frontend setup.");
|
|
}
|
|
for (const [name, iconName] of editorToolbarIcons) {
|
|
const button = document.querySelector(`.EasyMDEContainer .editor-toolbar .rw-mde-${name}`);
|
|
if (!button) continue;
|
|
const icon = document.createElement("i");
|
|
icon.dataset.lucide = iconName;
|
|
icon.setAttribute("aria-hidden", "true");
|
|
button.replaceChildren(icon);
|
|
}
|
|
window.lucide.createIcons();
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// EasyMDE editor setup and editing support
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function initializeHighlighting() {
|
|
if (!window.hljs) return;
|
|
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
|
|
window.hljs.registerAliases(["racket"], { languageName: "scheme" });
|
|
}
|
|
}
|
|
|
|
function applyRawMarkdownMode() {
|
|
if (!easyMDE) return;
|
|
editorContainer().classList.toggle("raw-markdown", state.rawMarkdown);
|
|
}
|
|
|
|
function toggleRawMarkdown() {
|
|
state.rawMarkdown = !state.rawMarkdown;
|
|
applyRawMarkdownMode();
|
|
easyMDE.codemirror.refresh();
|
|
}
|
|
|
|
function preferredEditorSideBySide() {
|
|
try {
|
|
const stored = window.sessionStorage.getItem(editorSideBySidePreferenceKey);
|
|
return stored === null ? true : stored === "true";
|
|
} catch (_error) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function rememberEditorSideBySide() {
|
|
if (!easyMDE) return;
|
|
try {
|
|
window.sessionStorage.setItem(
|
|
editorSideBySidePreferenceKey,
|
|
String(easyMDE.isSideBySideActive())
|
|
);
|
|
} catch (_error) {
|
|
// A blocked session storage should not prevent the editor from working.
|
|
}
|
|
}
|
|
|
|
function toggleEditorSideBySide() {
|
|
easyMDE.toggleSideBySide();
|
|
rememberEditorSideBySide();
|
|
}
|
|
|
|
function protectCamelCaseInPage() {
|
|
if (!easyMDE) return;
|
|
const source = easyMDE.value();
|
|
const protectedMarkdown = protectCamelCaseWikiWords(source);
|
|
if (protectedMarkdown === source) return;
|
|
easyMDE.value(protectedMarkdown);
|
|
renderEditorToc();
|
|
$("save-status").textContent = tr("camelcase-protected", "CamelCase links protected; save the page to keep the changes.");
|
|
}
|
|
|
|
/**
|
|
* goal : Configure the single EasyMDE instance used for page editing.
|
|
* pre : Vendor scripts and the editor textarea are loaded.
|
|
* post : easyMDE is ready with toolbar, uploads, Raw mode and live TOC updates.
|
|
*/
|
|
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.");
|
|
}
|
|
if (!window.RacketWikiMermaid) {
|
|
throw new Error("The Mermaid wiki integration is not installed.");
|
|
}
|
|
|
|
initializeHighlighting();
|
|
window.RacketWikiMermaid.initialize();
|
|
|
|
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, "heading", tr("heading", "Heading")),
|
|
"|",
|
|
toolbarButton("quote", EasyMDE.toggleBlockquote, "quote", tr("quote", "Quote")),
|
|
toolbarButton("unordered-list", EasyMDE.toggleUnorderedList, "list", tr("bulleted-list", "Bulleted list")),
|
|
toolbarButton("ordered-list", EasyMDE.toggleOrderedList, "list-ordered", tr("numbered-list", "Numbered list")),
|
|
toolbarButton("check-list", EasyMDE.toggleCheckList, "list-checks", 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("cmap-link", () => {
|
|
openWikiCmapLinkDialog().catch((error) => {
|
|
$("save-status").textContent = error.message;
|
|
console.error(error);
|
|
});
|
|
}, "share-2", tr("link-concept-map", "Link to CMap")),
|
|
toolbarButton("protect-camelcase", protectCamelCaseInPage, "ban", tr("protect-camelcase", "Protect CamelCase")),
|
|
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "image-plus", 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-2", tr("undo", "Undo")),
|
|
toolbarButton("redo", EasyMDE.redo, "redo-2", tr("redo", "Redo")),
|
|
toolbarButton("save", savePageFromFullscreen, "save", tr("save", "Save"), { noDisable: true }),
|
|
toolbarButton("raw-markdown", toggleRawMarkdown, "file-text", tr("raw-markdown", "Raw Markdown"), { noDisable: true }),
|
|
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
|
|
toolbarButton("side-by-side", toggleEditorSideBySide, "columns-2", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
|
|
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "maximize", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
|
|
]
|
|
});
|
|
|
|
installLucideIcons();
|
|
|
|
applyRawMarkdownMode();
|
|
|
|
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`);
|
|
}
|
|
|
|
/**
|
|
* goal : Show the editor and synchronize its sticky chrome and TOC.
|
|
* pre : easyMDE has been initialized.
|
|
* post : Editor view is visible and sized for the current viewport.
|
|
*/
|
|
function activateEditor() {
|
|
show("editor-view");
|
|
renderEditorToc();
|
|
requestAnimationFrame(() => {
|
|
easyMDE.codemirror.refresh();
|
|
const wideScreen = window.matchMedia("(min-width: 901px)").matches;
|
|
if (wideScreen && easyMDE.isSideBySideActive() !== preferredEditorSideBySide()) {
|
|
easyMDE.toggleSideBySide();
|
|
}
|
|
updateEditorChromeMetrics();
|
|
easyMDE.codemirror.refresh();
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Build the reader TOC from rendered headings and Markdown source lines.
|
|
* pre : state.currentPage and #markdown-preview represent the same page.
|
|
* post : TOC navigation scrolls below sticky chrome; editors get section-edit links.
|
|
*/
|
|
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();
|
|
});
|
|
}
|
|
|
|
let highlightedEditorLine = null;
|
|
let highlightedEditorTimer = null;
|
|
|
|
function clearEditorLineHighlight() {
|
|
if (!easyMDE || highlightedEditorLine === null) return;
|
|
easyMDE.codemirror.removeLineClass(highlightedEditorLine, "background", "section-edit-highlight");
|
|
highlightedEditorLine = null;
|
|
if (highlightedEditorTimer) {
|
|
window.clearTimeout(highlightedEditorTimer);
|
|
highlightedEditorTimer = null;
|
|
}
|
|
}
|
|
|
|
function focusEditorLine(line) {
|
|
if (!easyMDE || line === null || line === undefined) return;
|
|
clearEditorLineHighlight();
|
|
easyMDE.codemirror.setCursor({ line, ch: 0 });
|
|
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
|
|
highlightedEditorLine = easyMDE.codemirror.addLineClass(line, "background", "section-edit-highlight");
|
|
highlightedEditorTimer = window.setTimeout(clearEditorLineHighlight, 4500);
|
|
easyMDE.codemirror.focus();
|
|
}
|
|
|
|
function beginEditPageAtLine(line) {
|
|
beginEditPage();
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => focusEditorLine(line));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* goal : Render the sticky history breadcrumb as real navigation links.
|
|
* pre : items are ordered from wiki home to the current context.
|
|
* post : #breadcrumbs contains clickable previous page locations.
|
|
*/
|
|
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.home || item.href === "/") link.dataset.home = "true";
|
|
if (item.slug) link.dataset.breadcrumbSlug = item.slug;
|
|
link.textContent = item.label;
|
|
breadcrumbs.append(link);
|
|
} else {
|
|
const current = document.createElement("span");
|
|
current.className = "breadcrumb-current";
|
|
current.textContent = item.label;
|
|
breadcrumbs.append(current);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* goal : Build the page breadcrumb from the per-tab visit history.
|
|
* pre : state.pages and breadcrumbTrail are current.
|
|
* post : The sticky breadcrumb shows Home, previous pages and optional suffix.
|
|
*/
|
|
function pageBreadcrumbs(page, suffix = null) {
|
|
const firstPage = startPage();
|
|
const items = [];
|
|
|
|
if (firstPage) {
|
|
items.push({
|
|
label: state.siteTitle,
|
|
href: `#/${encodeURIComponent(firstPage.slug)}`,
|
|
slug: firstPage.slug,
|
|
home: true
|
|
});
|
|
} else {
|
|
items.push({ label: state.siteTitle });
|
|
}
|
|
|
|
for (const slug of breadcrumbTrail.slugs) {
|
|
const trailPage = state.pages.find((item) => item.slug === slug);
|
|
if (!trailPage) continue;
|
|
const isCurrent = page && trailPage.slug === page.slug && !suffix;
|
|
items.push({
|
|
label: trailPage.title,
|
|
href: isCurrent ? null : `#/${encodeURIComponent(trailPage.slug)}`,
|
|
slug: trailPage.slug
|
|
});
|
|
}
|
|
|
|
if (page && firstPage && page.slug === firstPage.slug && suffix) {
|
|
items[0].href = `#/${encodeURIComponent(firstPage.slug)}`;
|
|
}
|
|
|
|
if (suffix) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* goal : Return to the first/start page from every normal or special view.
|
|
* pre : Page metadata has been loaded.
|
|
* post : Breadcrumb history is cleared and the start page is opened.
|
|
*/
|
|
async function goHome() {
|
|
const firstPage = startPage();
|
|
breadcrumbTrail.clear();
|
|
if (!firstPage) {
|
|
await route();
|
|
return;
|
|
}
|
|
|
|
const targetHash = `#/${encodeURIComponent(firstPage.slug)}`;
|
|
if (location.hash === targetHash) {
|
|
await openPage(firstPage.slug);
|
|
} else {
|
|
location.hash = targetHash;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Render the secondary page list grouped by namespace.
|
|
* pre : state.pages contains current page metadata.
|
|
* post : #page-list reflects the current page set.
|
|
*/
|
|
function renderPageList() {
|
|
const list = $("page-list");
|
|
list.replaceChildren();
|
|
let previousNamespace = null;
|
|
for (const page of state.pages) {
|
|
const namespace = page.namespace || "";
|
|
if (namespace !== previousNamespace) {
|
|
const heading = document.createElement("div");
|
|
heading.className = "namespace-heading";
|
|
heading.textContent = namespaceLabel(namespace);
|
|
list.append(heading);
|
|
previousNamespace = namespace;
|
|
}
|
|
const link = document.createElement("a");
|
|
link.href = pageRoute(page.slug);
|
|
link.className = "page-link";
|
|
link.textContent = page.title;
|
|
link.classList.toggle("active", state.currentPage?.slug === page.slug);
|
|
list.append(link);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Refresh current page metadata from PostgreSQL through the API.
|
|
* pre : The user is authenticated.
|
|
* post : state.pages, site identity, page list and template list are refreshed.
|
|
*/
|
|
async function loadPages() {
|
|
const result = await api("/api/pages");
|
|
state.pages = result.pages;
|
|
state.pageAliases = result.aliases || [];
|
|
cmapWorkspace.clearDescriptionPreviews();
|
|
updateWikiIdentity();
|
|
renderPageList();
|
|
updateTemplateSelect();
|
|
}
|
|
|
|
|
|
function templatePages() {
|
|
return state.pages.filter((page) => (page.pageSlug || splitPageReference(page.slug).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 : "";
|
|
}
|
|
|
|
/**
|
|
* goal : Replace editor Markdown with a selected template-* wiki page.
|
|
* pre : slug identifies a readable template page.
|
|
* post : Editor contents are replaced after confirmation when necessary.
|
|
*/
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* goal : Open one page in reader mode.
|
|
* pre : slug is a root or namespace-qualified page reference.
|
|
* post : Current page state, breadcrumb, TOC and bookmark action are updated.
|
|
*/
|
|
async function openPage(slug) {
|
|
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
|
state.currentPage = page;
|
|
if (slug !== page.slug) {
|
|
history.replaceState(null, "", pageRoute(page.slug));
|
|
}
|
|
breadcrumbTrail.record(page.slug, startPage()?.slug || null);
|
|
state.editingNew = false;
|
|
state.newPageSlug = null;
|
|
$("page-title").textContent = page.title;
|
|
$("page-meta").textContent = "";
|
|
$("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug);
|
|
renderPageDetails(page);
|
|
renderPageCmapConnections(page);
|
|
pageBreadcrumbs(page);
|
|
show("page-view");
|
|
setPageActionVisibility(true);
|
|
renderPageToc();
|
|
renderPageList();
|
|
updateBookmarkAction();
|
|
}
|
|
|
|
function updateEditorSlugInfo() {
|
|
const namespace = $("editor-namespace")?.value.trim() || "";
|
|
if (!state.editingNew && state.currentPage) {
|
|
const rawSlug = state.currentPage.pageSlug || splitPageReference(state.currentPage.slug).slug;
|
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace, rawSlug)}`;
|
|
return;
|
|
}
|
|
if (state.newPageSlug) {
|
|
const requested = splitPageReference(state.newPageSlug);
|
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace || requested.namespace, requested.slug)}`;
|
|
return;
|
|
}
|
|
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
|
|
}
|
|
|
|
/**
|
|
* goal : Open an empty editor for a not-yet-existing page reference.
|
|
* pre : requestedSlug is null or a compact root/namespaced page reference.
|
|
* post : Namespace, title, template and Markdown controls are initialized for creation.
|
|
*/
|
|
function beginNewPage(requestedSlug = null, suggestedTitle = "") {
|
|
state.editingNew = true;
|
|
state.currentPage = null;
|
|
state.newPageSlug = requestedSlug;
|
|
const translationPage = requestedSlug && requestedSlug === state.translationPage;
|
|
$("editor-title").value = translationPage ? tr("translations", "Translations") :
|
|
(suggestedTitle || "");
|
|
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
|
|
$("editor-namespace").disabled = Boolean(translationPage);
|
|
$("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();
|
|
}
|
|
|
|
/**
|
|
* goal : Open the current page in EasyMDE.
|
|
* pre : state.currentPage is a current page, or newPageSlug identifies a missing page.
|
|
* post : Editor fields contain the page title, namespace, tags and Markdown.
|
|
*/
|
|
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-namespace").value = state.currentPage.namespace || "";
|
|
// Existing page addresses are changed only through Rename so an alias can be retained.
|
|
$("editor-namespace").disabled = true;
|
|
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
|
|
easyMDE.value(state.currentPage.markdown);
|
|
$("edit-summary").value = "";
|
|
$("save-status").textContent = "";
|
|
$("template-select").value = "";
|
|
updateEditorSlugInfo();
|
|
activateEditor();
|
|
}
|
|
|
|
/**
|
|
* goal : Save the current editor contents as a new or updated wiki page.
|
|
* pre : EasyMDE is active and title/namespace fields contain editor input.
|
|
* post : A successful save refreshes page metadata and opens the stored page.
|
|
*/
|
|
async function savePage() {
|
|
return savePageWithOptions();
|
|
}
|
|
|
|
function savePageFromFullscreen() {
|
|
savePageWithOptions({ keepEditing: true }).catch((error) => {
|
|
$("save-status").textContent = error.message;
|
|
});
|
|
}
|
|
|
|
/** Save the current Markdown and optionally keep the editor active. */
|
|
async function savePageWithOptions({ keepEditing = false } = {}) {
|
|
const title = $("editor-title").value.trim();
|
|
const namespace = $("editor-namespace").value.trim();
|
|
const currentSlug = state.editingNew ? state.newPageSlug : state.currentPage?.slug;
|
|
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,
|
|
namespace,
|
|
markdown,
|
|
tags,
|
|
summary: summary || tr("created-page", "Created page")
|
|
};
|
|
if (state.newPageSlug) {
|
|
const requested = splitPageReference(state.newPageSlug);
|
|
body.slug = pageReference(namespace || requested.namespace, requested.slug);
|
|
}
|
|
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,
|
|
namespace,
|
|
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);
|
|
}
|
|
if (keepEditing) {
|
|
$("editor-namespace").disabled = true;
|
|
updateEditorSlugInfo();
|
|
$("edit-summary").value = "";
|
|
} else {
|
|
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 escapeMarkdownLinkLabel(text) {
|
|
return String(text || "")
|
|
.replaceAll("\\", "\\\\")
|
|
.replaceAll("[", "\\[")
|
|
.replaceAll("]", "\\]");
|
|
}
|
|
|
|
async function openWikiCmapLinkDialog() {
|
|
pendingWikiCmapLinkLabel = easyMDE.codemirror.getDoc().getSelection();
|
|
await cmapWorkspace.loadConceptMaps();
|
|
wikiCmapLinkCombobox.setOptions(
|
|
state.conceptMaps.map((conceptMap) => cmapWorkspace.conceptMapEntry(conceptMap)), "");
|
|
$("wiki-cmap-link-submit").disabled = state.conceptMaps.length === 0;
|
|
$("wiki-cmap-embed-submit").disabled = state.conceptMaps.length === 0;
|
|
$("wiki-cmap-link").placeholder = state.conceptMaps.length ?
|
|
tr("filter-concept-maps", "Filter concept maps") :
|
|
tr("no-concept-maps", "No saved CMaps");
|
|
$("wiki-cmap-link-dialog").showModal();
|
|
$("wiki-cmap-link").focus();
|
|
}
|
|
|
|
function insertSelectedWikiCmapLink() {
|
|
const slug = wikiCmapLinkCombobox.value();
|
|
if (slug === null || !slug) {
|
|
const input = $("wiki-cmap-link");
|
|
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
|
|
input.reportValidity();
|
|
return false;
|
|
}
|
|
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
|
|
if (!conceptMap) return false;
|
|
const label = escapeMarkdownLinkLabel(pendingWikiCmapLinkLabel.trim() || conceptMap.title);
|
|
insertTextAtCursor(`[${label}](cmap:${slug})`);
|
|
$("wiki-cmap-link-dialog").close();
|
|
return true;
|
|
}
|
|
|
|
function insertSelectedWikiCmapEmbed() {
|
|
const slug = wikiCmapLinkCombobox.value();
|
|
if (slug === null || !slug) {
|
|
const input = $("wiki-cmap-link");
|
|
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
|
|
input.reportValidity();
|
|
return false;
|
|
}
|
|
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
|
|
if (!conceptMap) return false;
|
|
insertTextAtCursor(`\n\n{{cmap:${conceptMap.slug}}}\n\n`);
|
|
$("wiki-cmap-link-dialog").close();
|
|
return true;
|
|
}
|
|
|
|
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 encodeURI(result.url);
|
|
}
|
|
|
|
/**
|
|
* goal : Upload dropped/selected files and insert Markdown references at the cursor.
|
|
* pre : The current page has already been saved once.
|
|
* post : Uploaded files are stored through the API and referenced from the editor.
|
|
*/
|
|
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 url = encodeURI(result.url);
|
|
const markdown = isInlineImage(file)
|
|
? ``
|
|
: `[${escapedName}](${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);
|
|
}
|
|
|
|
/**
|
|
* goal : Open the rename/move form for the current page.
|
|
* pre : A current page exists and the user can edit pages.
|
|
* post : Rename fields contain current title, namespace and slug.
|
|
*/
|
|
function beginRenamePage() {
|
|
if (!state.currentPage || !can("editor")) return;
|
|
const address = splitPageReference(state.currentPage.slug);
|
|
$("rename-title").value = state.currentPage.title;
|
|
$("rename-namespace").value = state.currentPage.namespace || address.namespace;
|
|
$("rename-slug").value = state.currentPage.pageSlug || address.slug;
|
|
$("rename-status").textContent = "";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: state.currentPage.title, href: pageRoute(state.currentPage.slug) },
|
|
{ label: tr("rename", "Rename") }
|
|
]);
|
|
show("rename-view");
|
|
}
|
|
|
|
/**
|
|
* goal : Rename/move the current page while retaining the old address as an alias.
|
|
* pre : Rename fields contain a title, namespace and slug accepted by the server.
|
|
* post : Page metadata and aliases are refreshed and the canonical page is opened.
|
|
*/
|
|
async function saveRenamePage() {
|
|
if (!state.currentPage) return;
|
|
const oldReference = state.currentPage.slug;
|
|
$("rename-status").textContent = tr("saving", "Saving…");
|
|
try {
|
|
const page = await api(`/api/pages/${encodeURIComponent(oldReference)}/rename`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: $("rename-title").value.trim(),
|
|
namespace: $("rename-namespace").value.trim(),
|
|
slug: $("rename-slug").value.trim(),
|
|
summary: tr("rename-summary", "Renamed page")
|
|
})
|
|
});
|
|
await loadPages();
|
|
await loadBookmarks();
|
|
state.currentPage = page;
|
|
const targetHash = pageRoute(page.slug);
|
|
if (location.hash === targetHash) {
|
|
await openPage(page.slug);
|
|
} else {
|
|
location.hash = targetHash;
|
|
}
|
|
} catch (error) {
|
|
$("rename-status").textContent = error.message;
|
|
}
|
|
}
|
|
|
|
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";
|
|
article.dataset.resultType = item.type || "page";
|
|
const heading = document.createElement("h2");
|
|
const type = document.createElement("span");
|
|
type.className = "search-result-type";
|
|
type.textContent = item.type === "cmap" ?
|
|
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
|
|
const link = document.createElement("a");
|
|
link.href = item.type === "cmap" ?
|
|
cmapRoute(item.slug) : pageRoute(item.slug);
|
|
link.textContent = item.title;
|
|
heading.append(type, 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-search-results", "No matching pages or concept maps.");
|
|
results.append(empty);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Show immutable versions of the current page and diff actions.
|
|
* pre : state.currentPage identifies an existing page.
|
|
* post : History view contains newest-first versions.
|
|
*/
|
|
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 = `<strong>${pageDisplayDate(version.createdAt)}</strong><br><span class="muted"></span>`;
|
|
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 = `<article class="markdown-body">${renderMarkdown(full.markdown, state.currentPage?.slug)}</article>`;
|
|
});
|
|
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);
|
|
});
|
|
}
|
|
|
|
/** Show the administration overview and refresh its version information. */
|
|
async function showAdmin() {
|
|
state.previousView = state.currentPage ? "page-view" : "admin-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin") }
|
|
]);
|
|
show("admin-view");
|
|
await loadAdminOverview(api, tr);
|
|
}
|
|
|
|
/** Show retained aliases and their current and historical references. */
|
|
async function loadAliasesAdmin() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin") },
|
|
{ label: tr("page-aliases", "Page aliases") }
|
|
]);
|
|
show("alias-admin-view");
|
|
await aliasAdmin.load();
|
|
}
|
|
|
|
/** Show uploads that have no current page references. */
|
|
async function loadOrphanedUploadsAdmin() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin") },
|
|
{ label: tr("orphaned-uploads", "Orphaned uploads") }
|
|
]);
|
|
show("orphaned-uploads-view");
|
|
await orphanedUploadsAdmin.load();
|
|
}
|
|
|
|
/** Show archived CMaps that an administrator can restore. */
|
|
async function loadArchivedCmapsAdmin() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin"), href: "#admin" },
|
|
{ label: tr("archived-concept-maps", "Archived CMaps") }
|
|
]);
|
|
show("archived-cmaps-view");
|
|
await archivedCmapsAdmin.load();
|
|
}
|
|
|
|
/** Show the user administration controller in its application view. */
|
|
async function loadUsersAdmin() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin") },
|
|
{ label: tr("users", "Users") }
|
|
]);
|
|
show("user-admin-view");
|
|
await userAdmin.load();
|
|
}
|
|
|
|
function showProfile() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("profile", "Profile") }
|
|
]);
|
|
show("profile-view");
|
|
const user = state.session.user;
|
|
$("profile-username").value = user.username;
|
|
$("profile-display-name").value = user.displayName;
|
|
$("profile-email").value = user.email || "";
|
|
$("profile-current-password").value = "";
|
|
$("profile-new-password").value = "";
|
|
$("profile-repeat-password").value = "";
|
|
$("profile-status").textContent = "";
|
|
}
|
|
|
|
/** Show mail settings and initialize the test recipient for the current user. */
|
|
async function loadMailSettingsAdmin() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin"), href: "#admin" },
|
|
{ label: tr("email-and-password-reset", "Email and password reset") }
|
|
]);
|
|
show("mail-admin-view");
|
|
await mailAdmin.load(state.session.user.email || "");
|
|
}
|
|
|
|
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) {
|
|
const suggestedTitle = state.newPageSuggestedTitle;
|
|
state.newPageSuggestedTitle = "";
|
|
if (suggestedTitle) {
|
|
beginNewPage(slug, suggestedTitle);
|
|
return;
|
|
}
|
|
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();
|
|
$("page-cmap-connections").classList.add("hidden");
|
|
$("page-cmap-connections-list").replaceChildren();
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: slugTitle(slug) || slug }
|
|
]);
|
|
show("page-view");
|
|
setPageActionVisibility(false);
|
|
renderToc([], () => {});
|
|
renderPageList();
|
|
}
|
|
|
|
/**
|
|
* goal : Resolve the current hash route to a normal or special wiki view.
|
|
* pre : Session and page metadata have been loaded.
|
|
* post : Exactly one application view is made active.
|
|
* internals: parseWikiRoute recognizes syntax and decodes parameters. This
|
|
* dispatcher applies permissions and invokes the matching view.
|
|
*/
|
|
async function route() {
|
|
const currentRoute = parseWikiRoute(location.hash);
|
|
|
|
switch (currentRoute.name) {
|
|
case "profile":
|
|
showProfile();
|
|
return;
|
|
|
|
case "todo":
|
|
await showTodos(currentRoute.slug, currentRoute.number);
|
|
return;
|
|
|
|
case "cmap":
|
|
await cmapWorkspace.show(currentRoute.slug);
|
|
return;
|
|
|
|
case "cmaps":
|
|
await cmapWorkspace.show();
|
|
return;
|
|
|
|
case "recent":
|
|
await showRecent();
|
|
return;
|
|
|
|
case "bookmarks":
|
|
await showBookmarks();
|
|
return;
|
|
|
|
case "todos":
|
|
await showTodos();
|
|
return;
|
|
|
|
case "search":
|
|
$("search-input").value = currentRoute.query;
|
|
await searchWiki(currentRoute.query);
|
|
return;
|
|
|
|
case "admin":
|
|
if (can("admin")) {
|
|
await showAdmin();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "admin-users":
|
|
if (can("admin")) {
|
|
await loadUsersAdmin();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "admin-mail":
|
|
if (can("admin")) {
|
|
await loadMailSettingsAdmin();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "admin-aliases":
|
|
if (can("admin")) {
|
|
await loadAliasesAdmin();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "admin-orphaned-uploads":
|
|
if (can("admin")) {
|
|
await loadOrphanedUploadsAdmin();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "admin-archived-cmaps":
|
|
if (can("admin")) {
|
|
await loadArchivedCmapsAdmin();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "page":
|
|
try {
|
|
await openPage(currentRoute.slug);
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
if (can("editor")) {
|
|
showMissingEditablePage(currentRoute.slug);
|
|
} else {
|
|
showNotFound(currentRoute.slug);
|
|
}
|
|
}
|
|
return;
|
|
|
|
case "home":
|
|
break;
|
|
}
|
|
|
|
if (state.pages.length > 0) {
|
|
const firstPage = startPage();
|
|
location.hash = pageRoute(firstPage.slug);
|
|
return;
|
|
}
|
|
|
|
if (can("editor")) {
|
|
showMissingEditablePage("start");
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Special wiki views: Recent, Bookmarks and Todo
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
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 items = result.items || [];
|
|
if (items.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = tr("no-recent-changes", "No recent changes.");
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const item of items) {
|
|
const row = document.createElement("div");
|
|
row.className = "special-page-row";
|
|
row.dataset.resultType = item.type;
|
|
const heading = document.createElement("span");
|
|
heading.className = "special-page-heading";
|
|
const type = document.createElement("span");
|
|
type.className = "search-result-type";
|
|
type.textContent = item.type === "cmap" ?
|
|
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
|
|
const link = document.createElement("a");
|
|
link.href = item.type === "cmap" ? cmapRoute(item.slug) : pageRoute(item.slug);
|
|
link.textContent = item.title;
|
|
const meta = document.createElement("span");
|
|
meta.className = "special-page-meta";
|
|
meta.textContent = `${formatTimestamp(item.updatedAt)} · ${tr("changed-by", "changed by")} ${item.updatedBy}`;
|
|
heading.append(type, link);
|
|
row.append(heading, 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();
|
|
}
|
|
|
|
/**
|
|
* goal : Show bookmarks grouped first by page namespace and then user section.
|
|
* pre : The user is authenticated.
|
|
* post : The Bookmarks special view contains the latest bookmark state.
|
|
*/
|
|
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 namespaceGroups = new Map();
|
|
for (const bookmark of state.bookmarks) {
|
|
const namespace = bookmark.namespace || "";
|
|
if (!namespaceGroups.has(namespace)) namespaceGroups.set(namespace, new Map());
|
|
const sections = namespaceGroups.get(namespace);
|
|
const section = bookmark.section || "";
|
|
if (!sections.has(section)) sections.set(section, []);
|
|
sections.get(section).push(bookmark);
|
|
}
|
|
|
|
for (const [namespace, sections] of namespaceGroups.entries()) {
|
|
const namespaceSection = document.createElement("section");
|
|
const namespaceHeading = document.createElement("h2");
|
|
namespaceHeading.textContent = namespaceLabel(namespace);
|
|
namespaceSection.append(namespaceHeading);
|
|
|
|
for (const [section, bookmarks] of sections.entries()) {
|
|
const sectionElement = document.createElement("section");
|
|
sectionElement.className = "bookmark-section";
|
|
if (section) {
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = section;
|
|
sectionElement.append(heading);
|
|
}
|
|
|
|
for (const bookmark of bookmarks) {
|
|
const row = document.createElement("div");
|
|
row.className = "bookmark-row";
|
|
const link = document.createElement("a");
|
|
link.href = pageRoute(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);
|
|
}
|
|
namespaceSection.append(sectionElement);
|
|
}
|
|
target.append(namespaceSection);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Show page markers and TODO-aspect concepts in one Todo view.
|
|
* pre : Todo index is available through /api/todos.
|
|
* post : Optional target Todo is scrolled into view and highlighted.
|
|
*/
|
|
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;
|
|
}
|
|
let todoGroup = null;
|
|
for (const item of result.items) {
|
|
const conceptTodo = item.type === "concept";
|
|
const group = conceptTodo ? "__concepts__" : (item.namespace || "");
|
|
if (group !== todoGroup) {
|
|
const heading = document.createElement("h2");
|
|
heading.className = "namespace-heading";
|
|
heading.textContent = conceptTodo ?
|
|
tr("concept-todos", "Concepts") : namespaceLabel(item.namespace || "");
|
|
list.append(heading);
|
|
todoGroup = group;
|
|
}
|
|
const row = document.createElement("article");
|
|
row.className = `todo-item${conceptTodo ? " todo-item-concept" : ""}`;
|
|
row.id = conceptTodo ? `todo-concept-${item.conceptId}` :
|
|
`todo-${item.slug}-${item.number}`;
|
|
const link = document.createElement("a");
|
|
if (conceptTodo) {
|
|
const pageTarget = item.descriptionPageSlug || item.pageSlug;
|
|
const cmapTarget = item.cmapSlug || item.placementCmapSlug;
|
|
const externalTarget = cmapWorkspace.normalizeExternalUrl(item.externalUrl);
|
|
link.href = pageTarget ? pageRoute(pageTarget) :
|
|
(item.cmapSlug ? cmapRoute(item.cmapSlug) :
|
|
(externalTarget || (cmapTarget ? cmapRoute(cmapTarget) : "#cmaps")));
|
|
if (externalTarget && link.href === externalTarget) {
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
}
|
|
} else {
|
|
link.href = pageRoute(item.slug);
|
|
}
|
|
link.textContent = item.title;
|
|
const text = document.createElement("div");
|
|
text.className = "todo-item-text";
|
|
text.textContent = item.text || (conceptTodo ?
|
|
tr("concept-marked-todo", "Concept marked with the TODO aspect.") : "");
|
|
const location = document.createElement("span");
|
|
location.className = "muted";
|
|
location.textContent = conceptTodo ?
|
|
`${tr("concept-map", "Concept map")}: ${item.placementCmapTitle}` :
|
|
`${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 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Start periodic connectivity checks for the wiki backend.
|
|
* pre : Browser application initialization has completed.
|
|
* post : Offline/online state is updated approximately every 15 seconds.
|
|
*/
|
|
function startKeepAlive() {
|
|
window.setInterval(pingServer, 15000);
|
|
pingServer();
|
|
}
|
|
|
|
/**
|
|
* goal : Bootstrap the authenticated browser application.
|
|
* pre : index.html and all pinned vendor scripts are loaded.
|
|
* post : Session, translations, pages, editor, navigation and keep-alive are ready.
|
|
*/
|
|
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();
|
|
initializeSidebarToggle();
|
|
await cmapWorkspace.initialize();
|
|
initializeEditor();
|
|
$("account-name").textContent = state.session.user.displayName;
|
|
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
|
updateRoleUi();
|
|
await loadPages();
|
|
breadcrumbTrail.load(state.pages.map((page) => page.slug));
|
|
await loadBookmarks();
|
|
await route();
|
|
startKeepAlive();
|
|
}
|
|
|
|
installWikiLinkNavigation();
|
|
|
|
$("search-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const query = $("search-input").value.trim();
|
|
if (!query) {
|
|
const target = state.currentPage ? pageRoute(state.currentPage.slug) : pageRoute(startPage()?.slug || "start");
|
|
navigateToHash(target).catch((error) => console.error(error));
|
|
return;
|
|
}
|
|
navigateToHash(`#search/${encodeURIComponent(query)}`)
|
|
.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(); });
|
|
$("rename-page").addEventListener("click", (event) => { event.preventDefault(); beginRenamePage(); });
|
|
$("save-rename").addEventListener("click", () => { saveRenamePage().catch(console.error); });
|
|
$("cancel-rename").addEventListener("click", () => { if (state.currentPage) openPage(state.currentPage.slug).catch(console.error); });
|
|
$("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 link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
|
|
if (!link) return;
|
|
|
|
event.preventDefault();
|
|
const slug = link.dataset.breadcrumbSlug;
|
|
if (link.dataset.home === "true") {
|
|
goHome().catch((error) => console.error(error));
|
|
return;
|
|
}
|
|
|
|
breadcrumbTrail.truncate(slug, startPage()?.slug || null);
|
|
const targetHash = `#/${encodeURIComponent(slug)}`;
|
|
if (location.hash === targetHash) {
|
|
openPage(slug).catch((error) => console.error(error));
|
|
} else {
|
|
location.hash = targetHash;
|
|
}
|
|
});
|
|
$("recent-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#recent").catch((error) => console.error(error));
|
|
});
|
|
$("bookmarks-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#bookmarks").catch((error) => console.error(error));
|
|
});
|
|
$("todo-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#todos").catch((error) => console.error(error));
|
|
});
|
|
$("cmaps-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const preferred = cmapWorkspace.startSlug();
|
|
const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) ||
|
|
state.conceptMaps[0] || null;
|
|
navigateToHash(target ? cmapRoute(target.slug) : "#cmaps")
|
|
.catch((error) => console.error(error));
|
|
});
|
|
$("wiki-cmap-link-cancel").addEventListener("click", () => $("wiki-cmap-link-dialog").close());
|
|
$("wiki-cmap-link-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
insertSelectedWikiCmapLink();
|
|
});
|
|
$("wiki-cmap-embed-submit").addEventListener("click", insertSelectedWikiCmapEmbed);
|
|
$("wiki-cmap-link-dialog").addEventListener("close", () => {
|
|
pendingWikiCmapLinkLabel = "";
|
|
wikiCmapLinkCombobox.clear();
|
|
});
|
|
$("logout-link").addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await api("/api/logout", { method: "POST", body: "{}" });
|
|
window.location.replace("/login");
|
|
});
|
|
$("profile-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#profile").catch(console.error);
|
|
});
|
|
$("profile-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const newPassword = $("profile-new-password").value;
|
|
if (newPassword !== $("profile-repeat-password").value) {
|
|
$("profile-status").textContent = tr("passwords-do-not-match", "Passwords do not match.");
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/api/profile", {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
displayName: $("profile-display-name").value,
|
|
email: $("profile-email").value,
|
|
currentPassword: $("profile-current-password").value,
|
|
newPassword
|
|
})
|
|
});
|
|
state.session = result.session;
|
|
$("account-name").textContent = state.session.user.displayName;
|
|
$("profile-current-password").value = "";
|
|
$("profile-new-password").value = "";
|
|
$("profile-repeat-password").value = "";
|
|
$("profile-status").textContent = tr("profile-saved", "Profile saved.");
|
|
} catch (error) {
|
|
$("profile-status").textContent = error.message;
|
|
}
|
|
});
|
|
$("admin-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("admin-users-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/users").catch(console.error);
|
|
});
|
|
$("admin-mail-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/mail").catch(console.error);
|
|
});
|
|
$("admin-aliases-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/aliases").catch(console.error);
|
|
});
|
|
$("admin-orphaned-uploads-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/orphaned-uploads").catch(console.error);
|
|
});
|
|
$("admin-archived-cmaps-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/archived-cmaps").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-orphaned-uploads").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("close-archived-cmaps").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("close-user-admin").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("close-mail-admin").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("close-alias-admin").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("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);
|
|
$("editor-namespace").addEventListener("input", updateEditorSlugInfo);
|
|
|
|
$("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 = "";
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (!cmapWorkspace.hasUnsavedChanges()) return;
|
|
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
|
if (!link || link.target === "_blank") return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
cmapWorkspace.requestTransition(() => link.click()).catch((error) => console.error(error));
|
|
}, true);
|
|
|
|
window.addEventListener("beforeunload", (event) => {
|
|
if (!cmapWorkspace.hasUnsavedChanges()) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
});
|
|
|
|
window.addEventListener("hashchange", () => {
|
|
if (cmapWorkspace.hasUnsavedChanges()) {
|
|
const requestedHash = location.hash;
|
|
const previousHash = state.cmapGuardHash;
|
|
history.replaceState(history.state, "", `${location.pathname}${location.search}${previousHash}`);
|
|
cmapWorkspace.requestTransition(() => {
|
|
state.cmapGuardHash = requestedHash;
|
|
if (location.hash === requestedHash) {
|
|
return route();
|
|
}
|
|
location.hash = requestedHash;
|
|
return undefined;
|
|
}).catch(console.error);
|
|
return;
|
|
}
|
|
state.cmapGuardHash = location.hash;
|
|
route().catch(console.error);
|
|
});
|
|
|
|
initialize().catch((error) => {
|
|
console.error(error);
|
|
const main = $("main");
|
|
main.replaceChildren();
|
|
const message = document.createElement("div");
|
|
message.className = "card error";
|
|
const text = String(error && error.message || error);
|
|
const setupPath = "/setup";
|
|
const setupIndex = text.indexOf(setupPath);
|
|
if (setupIndex === -1) {
|
|
message.textContent = text;
|
|
} else {
|
|
message.append(document.createTextNode(text.slice(0, setupIndex)));
|
|
const setupLink = document.createElement("a");
|
|
setupLink.href = setupPath;
|
|
setupLink.textContent = setupPath;
|
|
message.append(setupLink, document.createTextNode(text.slice(setupIndex + setupPath.length)));
|
|
}
|
|
main.append(message);
|
|
});
|
|
})();
|