Multiple features. Admin page with verweeste items. Breadcrump working.

This commit is contained in:
2026-08-15 02:13:05 +02:00
parent ea7cd45eed
commit 6eef2d681c
10 changed files with 663 additions and 27 deletions
+51
View File
@@ -1150,3 +1150,54 @@ body.editor-mode .editor-metadata-row {
outline: 2px solid #e1c75a;
outline-offset: 2px;
}
/* 0.2.23: source-mode and section editing */
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-1,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-2,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-3,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-4,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-5,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-6,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-strong,
.EasyMDEContainer.raw-markdown .CodeMirror .cm-em {
font-size: inherit !important;
line-height: inherit !important;
font-weight: normal !important;
font-style: normal !important;
}
.EasyMDEContainer .CodeMirror .section-edit-highlight {
background: #fff3a3;
}
.wiki-auto-link {
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.orphaned-uploads-list {
display: grid;
gap: 12px;
}
.orphaned-upload-row {
padding: 12px 0;
border-bottom: 1px solid var(--wiki-border);
}
.orphaned-upload-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.orphaned-upload-uses {
margin: 4px 0 0 18px;
padding: 0;
}
.orphaned-upload-uses {
margin: 4px 0 0 18px;
padding: 0;
}
+9
View File
@@ -158,9 +158,18 @@
<nav class="admin-menu">
<a id="admin-users-link" href="#" data-tr="users">Users</a>
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
<a id="admin-orphaned-uploads-link" href="#" data-tr="orphaned-uploads">Orphaned uploads</a>
</nav>
</section>
<section id="orphaned-uploads-view" class="hidden">
<header class="page-header">
<h1 data-tr="orphaned-uploads">Orphaned uploads</h1>
<a id="close-orphaned-uploads" href="#" data-tr="back">Back</a>
</header>
<div id="orphaned-uploads-list" class="orphaned-uploads-list"></div>
</section>
<section id="user-admin-view" class="hidden">
<header class="page-header">
<h1 data-tr="user-administration">User administration</h1>
+328 -12
View File
@@ -12,7 +12,9 @@
translationPage: "wiki-translations",
translationTemplate: "",
siteTitle: "Racket Wiki",
bookmarks: []
bookmarks: [],
breadcrumbTrail: [],
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
};
let easyMDE = null;
@@ -20,7 +22,7 @@
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"]) {
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", "orphaned-uploads-view"]) {
$(id).classList.toggle("hidden", id !== viewId);
}
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
@@ -148,9 +150,112 @@
});
}
function normalizeMentionText(text) {
return String(text || "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
}
function camelCaseParts(text) {
return String(text || "").match(/[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]*)+/g) || [];
}
function mentionAliases(page) {
const aliases = new Set();
const values = [page.title, slugTitle(page.slug), page.slug.replaceAll("-", " ")];
for (const value of values) {
if (!value) continue;
aliases.add(value);
for (const camel of camelCaseParts(value)) aliases.add(camel);
}
return Array.from(aliases)
.map((value) => ({ text: value, key: normalizeMentionText(value) }))
.filter((alias) => alias.key.length >= 5);
}
function pageMentionMap(currentSlug = null) {
const map = new Map();
for (const page of state.pages) {
if (page.slug === currentSlug) continue;
for (const alias of mentionAliases(page)) {
if (!map.has(alias.key)) {
map.set(alias.key, page);
} else if (map.get(alias.key)?.slug !== page.slug) {
map.set(alias.key, null);
}
}
}
return map;
}
function linkUnlinkedMentions(html, currentSlug = null) {
const host = document.createElement("div");
host.innerHTML = html || "";
const aliases = pageMentionMap(currentSlug);
if (aliases.size === 0) return host.innerHTML;
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const textNode of textNodes) {
const parent = textNode.parentElement;
if (!parent || parent.closest("a, code, pre, script, style, textarea")) continue;
const text = textNode.nodeValue || "";
const words = Array.from(text.matchAll(/[\p{L}\p{N}]+/gu));
if (words.length === 0) continue;
const replacements = [];
let wordIndex = 0;
while (wordIndex < words.length) {
let match = null;
const maxWords = Math.min(5, words.length - wordIndex);
for (let count = maxWords; count >= 1; count -= 1) {
const first = words[wordIndex];
const last = words[wordIndex + count - 1];
const start = first.index;
const end = last.index + last[0].length;
const candidate = text.slice(start, end);
const page = aliases.get(normalizeMentionText(candidate));
if (page) {
match = { start, end, page };
break;
}
}
if (match) {
replacements.push(match);
while (wordIndex < words.length && words[wordIndex].index < match.end) wordIndex += 1;
} else {
wordIndex += 1;
}
}
if (replacements.length === 0) continue;
const fragment = document.createDocumentFragment();
let position = 0;
for (const replacement of replacements) {
if (replacement.start > position) fragment.append(document.createTextNode(text.slice(position, replacement.start)));
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(replacement.page.slug)}`;
link.className = "wiki-auto-link";
link.title = tr("automatic-wiki-link", "Automatic wiki link");
link.textContent = text.slice(replacement.start, replacement.end);
fragment.append(link);
position = replacement.end;
}
if (position < text.length) fragment.append(document.createTextNode(text.slice(position)));
textNode.replaceWith(fragment);
}
return host.innerHTML;
}
function renderMarkdown(markdown, pageSlug = null) {
const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug));
return DOMPurify.sanitize(applyImageWidthMarkup(html));
const withImages = applyImageWidthMarkup(html);
return DOMPurify.sanitize(linkUnlinkedMentions(withImages, pageSlug));
}
function slugTitle(slug) {
@@ -277,6 +382,18 @@
}
}
function applyRawMarkdownMode() {
if (!easyMDE) return;
editorContainer().classList.toggle("raw-markdown", state.rawMarkdown);
}
function toggleRawMarkdown() {
state.rawMarkdown = !state.rawMarkdown;
window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false");
applyRawMarkdownMode();
easyMDE.codemirror.refresh();
}
function initializeEditor() {
if (!window.EasyMDE) {
throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup.");
@@ -338,12 +455,15 @@
"|",
toolbarButton("undo", EasyMDE.undo, "undo", tr("undo", "Undo")),
toolbarButton("redo", EasyMDE.redo, "repeat", tr("redo", "Redo")),
toolbarButton("raw-markdown", toggleRawMarkdown, "file-text-o", tr("raw-markdown", "Raw Markdown"), { noDisable: true }),
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 })
]
});
applyRawMarkdownMode();
easyMDE.codemirror.on("change", () => {
if (!$("editor-view").classList.contains("hidden")) {
renderEditorToc();
@@ -513,10 +633,26 @@
});
}
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();
}
@@ -527,6 +663,68 @@
});
}
const breadcrumbStorageKey = "racket-wiki-breadcrumb-trail";
const breadcrumbTrailLimit = 8;
function saveBreadcrumbTrail() {
window.sessionStorage.setItem(breadcrumbStorageKey, JSON.stringify(state.breadcrumbTrail));
}
function loadBreadcrumbTrail() {
let stored = [];
try {
stored = JSON.parse(window.sessionStorage.getItem(breadcrumbStorageKey) || "[]");
} catch (_error) {
stored = [];
}
const pageSlugs = new Set(state.pages.map((page) => page.slug));
state.breadcrumbTrail = Array.isArray(stored)
? stored.filter((slug) => typeof slug === "string" && pageSlugs.has(slug))
: [];
saveBreadcrumbTrail();
}
function clearBreadcrumbTrail() {
state.breadcrumbTrail = [];
saveBreadcrumbTrail();
}
function recordPageVisit(page) {
if (!page) return;
const firstPage = startPage();
if (firstPage && page.slug === firstPage.slug) {
clearBreadcrumbTrail();
return;
}
const existingIndex = state.breadcrumbTrail.indexOf(page.slug);
if (existingIndex >= 0) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, existingIndex + 1);
} else {
state.breadcrumbTrail.push(page.slug);
if (state.breadcrumbTrail.length > breadcrumbTrailLimit) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(-breadcrumbTrailLimit);
}
}
saveBreadcrumbTrail();
}
function truncateBreadcrumbTrail(slug) {
const firstPage = startPage();
if (firstPage && slug === firstPage.slug) {
clearBreadcrumbTrail();
return;
}
const index = state.breadcrumbTrail.indexOf(slug);
if (index >= 0) {
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, index + 1);
saveBreadcrumbTrail();
}
}
function renderBreadcrumbs(items) {
const breadcrumbs = $("breadcrumbs");
breadcrumbs.replaceChildren();
@@ -554,7 +752,8 @@
if (item.href) {
const link = document.createElement("a");
link.href = item.href;
if (item.href === "/") link.dataset.home = "true";
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 {
@@ -567,13 +766,36 @@
}
function pageBreadcrumbs(page, suffix = null) {
const items = [
{ label: state.siteTitle, href: "/" },
{ label: page?.title || page?.slug || "New page" }
];
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 state.breadcrumbTrail) {
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[items.length - 1].href = `#/${encodeURIComponent(page.slug)}`;
items.push({ label: suffix });
}
@@ -601,6 +823,7 @@
async function goHome() {
const firstPage = startPage();
clearBreadcrumbTrail();
if (!firstPage) {
await route();
return;
@@ -708,6 +931,7 @@
async function openPage(slug) {
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
state.currentPage = page;
recordPageVisit(page);
state.editingNew = false;
state.newPageSlug = null;
$("page-title").textContent = page.title;
@@ -1109,6 +1333,82 @@
show("admin-view");
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadOrphanedUploadsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("orphaned-uploads", "Orphaned uploads") }
]);
show("orphaned-uploads-view");
const result = await api("/api/admin/uploads/orphaned");
const list = $("orphaned-uploads-list");
list.replaceChildren();
if (!result.uploads || result.uploads.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-orphaned-uploads", "No orphaned uploads.");
list.append(empty);
return;
}
for (const upload of result.uploads) {
const row = document.createElement("article");
row.className = "orphaned-upload-row";
const title = document.createElement("strong");
title.textContent = upload.originalName;
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = `${upload.mimeType} · ${formatFileSize(upload.size)} · ${tr("uploaded-by", "uploaded by")} ${upload.uploadedBy}`;
const history = document.createElement("div");
const lastUses = Array.isArray(upload.lastUses) ? upload.lastUses : [];
if (lastUses.length > 0) {
const label = document.createElement("div");
label.textContent = `${tr("last-used", "Last used")}:`;
history.append(label);
const uses = document.createElement("ul");
uses.className = "orphaned-upload-uses";
for (const use of lastUses) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(use.slug)}`;
link.textContent = use.title || use.slug;
item.append(link);
if (use.version) item.append(document.createTextNode(` · ${tr("version", "Version")} ${use.version}`));
uses.append(item);
}
history.append(uses);
} else {
history.textContent = tr("never-referenced", "Never referenced by a saved page.");
}
const actions = document.createElement("div");
actions.className = "orphaned-upload-actions";
const view = document.createElement("a");
view.href = `/uploads/${encodeURIComponent(upload.ownerSlug)}/${encodeURIComponent(upload.storedName)}`;
view.target = "_blank";
view.rel = "noopener";
view.textContent = tr("view", "View");
const remove = document.createElement("button");
remove.className = "danger";
remove.textContent = tr("delete", "Delete");
remove.addEventListener("click", async () => {
if (!confirm(tr("delete-orphaned-upload-confirm", "Delete this orphaned upload?"))) return;
await api(`/api/admin/uploads/orphaned/${upload.id}`, { method: "DELETE" });
await loadOrphanedUploadsAdmin();
});
actions.append(view, remove);
row.append(title, meta, history, actions);
list.append(row);
}
}
async function loadUsersAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
@@ -1608,6 +1908,7 @@
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
updateRoleUi();
await loadPages();
loadBreadcrumbTrail();
await loadBookmarks();
await route();
startKeepAlive();
@@ -1634,10 +1935,23 @@
goHome().catch((error) => console.error(error));
});
$("breadcrumbs").addEventListener("click", (event) => {
const home = event.target.closest("a[data-home='true']");
if (!home) return;
const link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
if (!link) return;
event.preventDefault();
goHome().catch((error) => console.error(error));
const slug = link.dataset.breadcrumbSlug;
if (link.dataset.home === "true") {
goHome().catch((error) => console.error(error));
return;
}
truncateBreadcrumbTrail(slug);
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(); showRecent().catch((error) => console.error(error)); });
$("bookmarks-link").addEventListener("click", (event) => { event.preventDefault(); showBookmarks().catch((error) => console.error(error)); });
@@ -1651,6 +1965,7 @@
});
$("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); });
$("admin-orphaned-uploads-link").addEventListener("click", (event) => { event.preventDefault(); loadOrphanedUploadsAdmin().catch(console.error); });
$("admin-translations-link").addEventListener("click", (event) => {
event.preventDefault();
const page = state.pages.find((item) => item.slug === state.translationPage);
@@ -1665,6 +1980,7 @@
beginNewPage(state.translationPage);
});
$("close-history").addEventListener("click", () => show("page-view"));
$("close-orphaned-uploads").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("cancel-edit").addEventListener("click", () => {
if (state.currentPage) {