breadcrumps, todos, etc.

This commit is contained in:
2026-08-15 01:46:15 +02:00
parent b3afd64769
commit ea7cd45eed
11 changed files with 635 additions and 27 deletions
+102
View File
@@ -273,6 +273,48 @@ body {
font-style: italic;
}
.toc-row {
display: flex;
align-items: center;
gap: 2px;
}
.toc-row .toc-link {
min-width: 0;
flex: 1 1 auto;
}
.toc-edit-link {
flex: 0 0 auto;
padding: 2px 5px;
color: #777;
text-decoration: none;
font-size: .78rem;
line-height: 1;
}
.toc-edit-link:hover {
color: var(--wiki-link);
}
.editor-template-row {
display: flex;
align-items: center;
gap: 8px;
font-size: .86rem;
}
.editor-template-row label {
color: var(--wiki-muted);
}
#template-select {
min-width: 180px;
max-width: 360px;
padding: 4px 6px;
}
.pages-section {
padding-top: 12px;
border-top: 1px solid #d7d7d7;
@@ -1048,3 +1090,63 @@ body.editor-mode .editor-metadata-row {
.wiki-graph-node-focus text {
font-weight: 700;
}
/* Recent changes and bookmark overview pages. */
.special-page-list,
.bookmarks-list {
max-width: 920px;
}
.special-page-row,
.bookmark-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
padding: .45rem 0;
border-bottom: 1px solid var(--wiki-border);
}
.special-page-row a,
.bookmark-row a,
.bookmark-section-actions a {
color: var(--wiki-link);
text-decoration: none;
}
.special-page-row a:hover,
.bookmark-row a:hover,
.bookmark-section-actions a:hover {
text-decoration: underline;
}
.special-page-meta,
.bookmark-section-actions {
color: var(--wiki-muted);
font-size: .86rem;
white-space: nowrap;
}
.bookmark-section {
margin: 1.4rem 0 1.8rem;
}
.bookmark-section h2 {
margin: 0 0 .35rem;
font-size: 1.15rem;
}
/* Linked todo markers --------------------------------------------------- */
.wiki-todo {
text-decoration: none;
}
.wiki-todo:hover {
text-decoration: underline;
}
.todo-item-target {
background: #fff3a3;
outline: 2px solid #e1c75a;
outline-offset: 2px;
}
+24
View File
@@ -16,6 +16,8 @@
<div id="app">
<aside id="sidebar">
<a id="wiki-brand" class="brand" href="#">Racket Wiki</a>
<a id="recent-link" class="sidebar-primary-link" href="#">* <span data-tr="recent">Recent</span></a>
<a id="bookmarks-link" class="sidebar-primary-link" href="#">* <span data-tr="bookmarks">Bookmarks</span></a>
<a id="todo-link" class="sidebar-primary-link" href="#">* <span data-tr="todo-list">Todo list</span></a>
<a id="graph-link" class="sidebar-primary-link" href="#">* <span data-tr="graph">Graph</span></a>
<form id="search-form" class="wiki-search" role="search">
@@ -45,6 +47,7 @@
<div class="navigation-row">
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav>
<div id="page-action-links" class="page-action-links hidden">
<a id="bookmark-page" href="#" data-tr="bookmark">Bookmark</a>
<a id="edit-page" class="editor-only hidden" href="#" data-tr="edit">Edit</a>
<a id="history-page" href="#" data-tr="history">History</a>
<a id="delete-page" class="editor-only danger hidden" href="#" data-tr="delete">Delete</a>
@@ -76,6 +79,12 @@
<div class="editor-heading">
<input id="editor-title" class="title-input" placeholder="Page title" data-tr-placeholder="page-title">
<div id="editor-slug-info" class="muted"></div>
<div class="editor-template-row">
<label for="template-select" data-tr="template">Template</label>
<select id="template-select">
<option value="" data-tr="no-template">No template</option>
</select>
</div>
</div>
<div class="toolbar">
<button id="cancel-edit" data-tr="cancel">Cancel</button>
@@ -101,6 +110,21 @@
<div id="search-results" class="search-results"></div>
</section>
<section id="recent-view" class="hidden">
<header class="page-header">
<h1 data-tr="recent-changes">Recent changes</h1>
</header>
<div id="recent-list" class="special-page-list"></div>
</section>
<section id="bookmarks-view" class="hidden">
<header class="page-header">
<h1 data-tr="bookmarks">Bookmarks</h1>
</header>
<div id="bookmarks-list" class="bookmarks-list"></div>
</section>
<section id="todo-view" class="hidden">
<header class="page-header">
<h1 data-tr="todo-items">Todo items</h1>
+294 -19
View File
@@ -11,7 +11,8 @@
translations: {},
translationPage: "wiki-translations",
translationTemplate: "",
siteTitle: "Racket Wiki"
siteTitle: "Racket Wiki",
bookmarks: []
};
let easyMDE = null;
@@ -19,7 +20,7 @@
const $ = (id) => document.getElementById(id);
function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "search-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"]) {
$(id).classList.toggle("hidden", id !== viewId);
}
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
@@ -50,16 +51,25 @@
.replaceAll('"', "&quot;");
}
function expandTodoMarkup(markdown) {
function expandTodoMarkup(markdown, pageSlug = null) {
let inFence = false;
let todoNumber = 0;
return (markdown || "").split("\n").map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
return line.replace(/todo\(([^()\r\n]+)\)/g, (_match, text) =>
`<span class="wiki-todo"><strong>TODO</strong> ${escapeHtml(text.trim())}</span>`);
return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => {
todoNumber += 1;
const label = escapeHtml(tr("todo", "Todo"));
const todoText = escapeHtml(text.trim());
if (pageSlug) {
const href = `#todo/${encodeURIComponent(pageSlug)}/${todoNumber}`;
return `<a class="wiki-todo" href="${href}"><strong>${label}:</strong> ${todoText}</a>`;
}
return `<span class="wiki-todo"><strong>${label}:</strong> ${todoText}</span>`;
});
}).join("\n");
}
@@ -138,8 +148,8 @@
});
}
function renderMarkdown(markdown) {
const html = easyMDE.markdown(expandTodoMarkup(markdown || ""));
function renderMarkdown(markdown, pageSlug = null) {
const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug));
return DOMPurify.sanitize(applyImageWidthMarkup(html));
}
@@ -302,7 +312,7 @@
.then(onSuccess)
.catch((error) => onError(error.message));
},
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText) : "",
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText, state.currentPage?.slug || state.newPageSlug) : "",
renderingConfig: {
codeSyntaxHighlighting: true,
hljs: window.hljs,
@@ -419,7 +429,7 @@
return headings;
}
function renderToc(entries, onSelect) {
function renderToc(entries, onSelect, onEdit = null) {
const toc = $("toc-list");
toc.replaceChildren();
@@ -432,6 +442,9 @@
}
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)}`;
@@ -440,14 +453,32 @@
event.preventDefault();
onSelect(entry);
});
toc.append(link);
row.append(link);
if (onEdit) {
const edit = document.createElement("a");
edit.href = "#";
edit.className = "toc-edit-link";
edit.textContent = "✎";
edit.title = tr("edit-section", "Edit section");
edit.setAttribute("aria-label", `${tr("edit-section", "Edit section")}: ${entry.text}`);
edit.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onEdit(entry);
});
row.append(edit);
}
toc.append(row);
}
}
function renderPageToc() {
const article = $("markdown-preview");
const usedIds = new Set();
const entries = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6")).map((heading) => {
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;
@@ -455,14 +486,21 @@
level: Number(heading.tagName.slice(1)),
text,
href: `#${id}`,
element: heading
element: heading,
line: sourceHeadings[index]?.line ?? null
};
});
renderToc(entries, (entry) => {
entry.element.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`);
});
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() {
@@ -475,6 +513,20 @@
});
}
function focusEditorLine(line) {
if (!easyMDE || line === null || line === undefined) return;
easyMDE.codemirror.setCursor({ line, ch: 0 });
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
easyMDE.codemirror.focus();
}
function beginEditPageAtLine(line) {
beginEditPage();
requestAnimationFrame(() => {
requestAnimationFrame(() => focusEditorLine(line));
});
}
function renderBreadcrumbs(items) {
const breadcrumbs = $("breadcrumbs");
breadcrumbs.replaceChildren();
@@ -584,6 +636,73 @@
state.pages = result.pages;
updateWikiIdentity();
renderPageList();
updateTemplateSelect();
}
function templatePages() {
return state.pages.filter((page) => page.slug.toLocaleLowerCase().startsWith("template-"));
}
function updateTemplateSelect() {
const select = $("template-select");
const selected = select.value;
select.replaceChildren();
const none = document.createElement("option");
none.value = "";
none.textContent = tr("no-template", "No template");
select.append(none);
for (const page of templatePages()) {
const option = document.createElement("option");
option.value = page.slug;
option.textContent = page.title;
select.append(option);
}
select.value = Array.from(select.options).some((option) => option.value === selected) ? selected : "";
}
async function applyTemplate(slug) {
if (!slug) return;
const template = await api(`/api/pages/${encodeURIComponent(slug)}`);
const current = easyMDE.value();
if (current.trim() !== "") {
const message = tr("replace-with-template", "Replace the current page content with template {template}?")
.replace("{template}", template.title);
if (!window.confirm(message)) {
$("template-select").value = "";
return;
}
}
easyMDE.value(template.markdown || "");
$("template-select").value = "";
renderEditorToc();
easyMDE.codemirror.focus();
}
function bookmarkForSlug(slug) {
return state.bookmarks.find((bookmark) => bookmark.slug === slug) || null;
}
function updateBookmarkAction() {
const link = $("bookmark-page");
if (!state.currentPage) {
link.classList.add("hidden");
return;
}
link.classList.remove("hidden");
const bookmark = bookmarkForSlug(state.currentPage.slug);
link.textContent = bookmark ? tr("bookmarked", "Bookmarked") : tr("bookmark", "Bookmark");
}
async function loadBookmarks() {
const result = await api("/api/bookmarks");
state.bookmarks = result.bookmarks || [];
updateBookmarkAction();
}
async function openPage(slug) {
@@ -593,13 +712,14 @@
state.newPageSlug = null;
$("page-title").textContent = page.title;
$("page-meta").textContent = "";
$("markdown-preview").innerHTML = renderMarkdown(page.markdown);
$("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug);
renderPageDetails(page);
pageBreadcrumbs(page);
show("page-view");
setPageActionVisibility(true);
renderPageToc();
renderPageList();
updateBookmarkAction();
}
function updateEditorSlugInfo() {
@@ -628,6 +748,7 @@
}
$("edit-summary").value = "";
$("save-status").textContent = "";
$("template-select").value = "";
updateEditorSlugInfo();
activateEditor();
$("editor-title").focus();
@@ -647,6 +768,7 @@
easyMDE.value(state.currentPage.markdown);
$("edit-summary").value = "";
$("save-status").textContent = "";
$("template-select").value = "";
updateEditorSlugInfo();
activateEditor();
}
@@ -951,7 +1073,7 @@
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)}</article>`;
$("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") : "";
@@ -1081,6 +1203,12 @@
}
async function route() {
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
if (todoMatch) {
await showTodos(decodeURIComponent(todoMatch[1]), Number(todoMatch[2]));
return;
}
const match = location.hash.match(/^#\/([^/]+)$/);
if (match) {
const slug = decodeURIComponent(match[1]);
@@ -1116,7 +1244,130 @@
}
}
async function showTodos() {
function formatTimestamp(value) {
const date = new Date(Number(value) * 1000);
return date.toLocaleString(state.language || undefined);
}
async function showRecent() {
state.previousView = state.currentPage ? "page-view" : "recent-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("recent", "Recent") }
]);
setPageActionVisibility(false);
show("recent-view");
const result = await api("/api/recent");
const list = $("recent-list");
list.replaceChildren();
const pages = result.pages || [];
if (pages.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-recent-pages", "No recent pages.");
list.append(empty);
return;
}
for (const page of pages) {
const row = document.createElement("div");
row.className = "special-page-row";
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(page.slug)}`;
link.textContent = page.title;
const meta = document.createElement("span");
meta.className = "special-page-meta";
meta.textContent = `${formatTimestamp(page.updatedAt)} · ${tr("changed-by", "changed by")} ${page.updatedBy}`;
row.append(link, meta);
list.append(row);
}
}
async function saveBookmark(slug, existingSection = "") {
const section = window.prompt(tr("bookmark-section", "Bookmark section"), existingSection);
if (section === null) return;
await api("/api/bookmarks", {
method: "POST",
body: JSON.stringify({ slug, section: section.trim() })
});
await loadBookmarks();
}
async function removeBookmark(slug) {
await api(`/api/bookmarks/${encodeURIComponent(slug)}`, { method: "DELETE" });
await loadBookmarks();
}
async function showBookmarks() {
state.previousView = state.currentPage ? "page-view" : "bookmarks-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("bookmarks", "Bookmarks") }
]);
setPageActionVisibility(false);
show("bookmarks-view");
await loadBookmarks();
const target = $("bookmarks-list");
target.replaceChildren();
if (state.bookmarks.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-bookmarks", "No bookmarks yet.");
target.append(empty);
return;
}
const groups = new Map();
for (const bookmark of state.bookmarks) {
const section = bookmark.section || "";
if (!groups.has(section)) groups.set(section, []);
groups.get(section).push(bookmark);
}
for (const [section, bookmarks] of groups.entries()) {
const sectionElement = document.createElement("section");
sectionElement.className = "bookmark-section";
const heading = document.createElement("h2");
heading.textContent = section || tr("bookmarks", "Bookmarks");
sectionElement.append(heading);
for (const bookmark of bookmarks) {
const row = document.createElement("div");
row.className = "bookmark-row";
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(bookmark.slug)}`;
link.textContent = bookmark.title;
const actions = document.createElement("span");
actions.className = "bookmark-section-actions";
const move = document.createElement("a");
move.href = "#";
move.textContent = tr("move", "Move");
move.addEventListener("click", async (event) => {
event.preventDefault();
await saveBookmark(bookmark.slug, bookmark.section || "");
await showBookmarks();
});
const separator = document.createTextNode(" · ");
const remove = document.createElement("a");
remove.href = "#";
remove.textContent = tr("remove", "Remove");
remove.addEventListener("click", async (event) => {
event.preventDefault();
await removeBookmark(bookmark.slug);
await showBookmarks();
});
actions.append(move, separator, remove);
row.append(link, actions);
sectionElement.append(row);
}
target.append(sectionElement);
}
}
async function showTodos(targetSlug = null, targetNumber = null) {
state.previousView = state.currentPage ? "page-view" : "todo-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
@@ -1137,6 +1388,7 @@
for (const item of result.items) {
const row = document.createElement("article");
row.className = "todo-item";
row.id = `todo-${item.slug}-${item.number}`;
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(item.slug)}`;
link.textContent = item.title;
@@ -1149,6 +1401,14 @@
row.append(link, text, location);
list.append(row);
}
if (targetSlug && targetNumber) {
const target = document.getElementById(`todo-${targetSlug}-${targetNumber}`);
if (target) {
target.classList.add("todo-item-target");
target.scrollIntoView({ block: "center" });
}
}
}
function pageLinks(markdown) {
@@ -1348,6 +1608,7 @@
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
updateRoleUi();
await loadPages();
await loadBookmarks();
await route();
startKeepAlive();
}
@@ -1359,6 +1620,12 @@
searchWiki($("search-input").value).catch((error) => console.error(error));
});
$("bookmark-page").addEventListener("click", (event) => {
event.preventDefault();
if (!state.currentPage) return;
const existing = bookmarkForSlug(state.currentPage.slug);
saveBookmark(state.currentPage.slug, existing?.section || "").catch((error) => console.error(error));
});
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
@@ -1372,6 +1639,8 @@
event.preventDefault();
goHome().catch((error) => console.error(error));
});
$("recent-link").addEventListener("click", (event) => { event.preventDefault(); showRecent().catch((error) => console.error(error)); });
$("bookmarks-link").addEventListener("click", (event) => { event.preventDefault(); showBookmarks().catch((error) => console.error(error)); });
$("todo-link").addEventListener("click", (event) => { event.preventDefault(); showTodos().catch((error) => console.error(error)); });
$("graph-link").addEventListener("click", (event) => { event.preventDefault(); showGraph().catch((error) => console.error(error)); });
$("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraph().catch((error) => console.error(error)); });
@@ -1410,6 +1679,12 @@
route();
});
$("save-page").addEventListener("click", savePage);
$("template-select").addEventListener("change", (event) => {
applyTemplate(event.target.value).catch((error) => {
$("save-status").textContent = error.message;
event.target.value = "";
});
});
$("file-input").addEventListener("change", (event) => {
uploadFiles(event.target.files).catch(() => {});
event.target.value = "";