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
+1 -1
View File
@@ -8,7 +8,7 @@
(target zip
(deps clean)
(zip-package))
(zip-package #:exclude '("wiki-data")))
(target clean
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "." #px"([.]bak|~)$" #:recursive #t))
+21
View File
@@ -1,5 +1,7 @@
# racket-wiki
Current development version: **0.2.19**.
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
Version 0.2.9 uses PostgreSQL as the wiki's complete content store. Users, sessions, pages, immutable page versions, attachment metadata and attachment bytes live in PostgreSQL. PostgreSQL full-text search is built into the page table and exposed by the wiki search UI.
@@ -344,3 +346,22 @@ Version 0.2.13 centers the wiki name in the sidebar and makes it a link to the f
## 0.2.15
The page table of contents now includes a `(context)` link. It opens a graph containing the current page and all pages that link directly to it or are linked directly from it. The current page is highlighted. Heading anchors also reserve space for the sticky top navigation so a selected heading remains visible.
## Recent pages and bookmarks
The sidebar provides dynamic Recent and Bookmarks views. Recent shows current pages ordered by their last edit time. Bookmarks are stored per user in PostgreSQL and can be grouped into user-defined sections. The database migration to schema version 4 creates the bookmarks table automatically.
## 0.2.19
Todo markers are case-insensitive. `todo(...)`, `Todo(...)` and `TODO(...)` are all indexed and rendered as todo items. Database migration 4 -> 5 rebuilds the todo index for existing pages so already-saved mixed-case markers become visible without re-saving the pages.
Editors can start section editing from the small edit link beside a heading in the page contents. The whole page is still saved as one version; section edit only opens the editor at the selected heading.
Pages whose slug starts with `template-` are available in the editor Template dropdown. Selecting a template copies that page's Markdown into the current editor. Existing non-empty content is only replaced after confirmation.
## 0.2.20
Inline `todo(...)` markers render as a yellow `Todo: text` link. Clicking the marker opens the Todo view at the matching item and highlights it. Todo markers inside fenced code blocks remain untouched.
+1 -1
View File
@@ -1,7 +1,7 @@
#lang info
(define pkg-authors '(hnmdijkema))
(define version "0.2.15")
(define version "0.2.21")
(define license 'MIT)
(define collection "racket-wiki")
(define pkg-desc
+31 -1
View File
@@ -11,7 +11,7 @@
database-schema-version
migrate-database!)
(define current-schema-version 3)
(define current-schema-version 5)
(define schema-1-statements
(list
@@ -215,6 +215,30 @@ SQL
(replace-page-todos! db (vector-ref row 0) (vector-ref row 1)))
(record-schema-version! db 3))
(define (migrate-3->4! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS bookmarks (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
section TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
PRIMARY KEY(user_id, page_id)
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS bookmarks_user_idx ON bookmarks(user_id, section, position, created_at)")
(record-schema-version! db 4))
(define (migrate-4->5! db)
(for ((row (in-list (query-rows db "SELECT id, markdown FROM pages WHERE archived = FALSE"))))
(replace-page-todos! db (vector-ref row 0) (vector-ref row 1)))
(record-schema-version! db 5))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
; pre : db is a writable PostgreSQL connection and config identifies the
@@ -235,6 +259,12 @@ SQL
(define after-attachments (database-schema-version db))
(when (= after-attachments 2)
(migrate-2->3! db))
(define after-todos (database-schema-version db))
(when (= after-todos 3)
(migrate-3->4! db))
(define after-bookmarks (database-schema-version db))
(when (= after-bookmarks 4)
(migrate-4->5! db))
(define resulting-version (database-schema-version db))
(when (> resulting-version current-schema-version)
(error 'migrate-database!
+110
View File
@@ -22,6 +22,10 @@
read-version
search-pages
list-todos
list-recent-pages
list-bookmarks
set-bookmark!
delete-bookmark!
save-upload!
uploaded-file)
@@ -390,6 +394,112 @@ SQL
'line (vector-ref row 3)
'text (vector-ref row 4))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List recently edited current wiki pages.
; pre : PostgreSQL schema 1 or newer is initialized.
; post : Page rows have only been read.
; result : At most limit page metadata hashes, newest first.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-recent-pages config [limit 50])
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows db
(string-append "SELECT " page-columns
" FROM pages WHERE archived = FALSE"
" ORDER BY updated_at DESC, lower(title), title"
" LIMIT $1")
limit))))
(row->page row #f)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List bookmarks for one wiki user.
; pre : PostgreSQL schema 4 or newer is initialized and user-id identifies a user.
; post : Bookmark and page rows have only been read.
; result : Bookmarks ordered by section and position.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-bookmarks config user-id)
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows db
#<<SQL
SELECT p.slug, p.title, b.section, b.position, b.created_at, p.updated_at
FROM bookmarks b
JOIN pages p ON p.id = b.page_id
WHERE b.user_id = $1
AND p.archived = FALSE
ORDER BY lower(b.section), b.section, b.position, b.created_at, lower(p.title), p.title
SQL
user-id))))
(hash 'slug (vector-ref row 0)
'title (vector-ref row 1)
'section (vector-ref row 2)
'position (vector-ref row 3)
'createdAt (vector-ref row 4)
'updatedAt (vector-ref row 5))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Add a page to a user's bookmarks or move it to another section.
; pre : PostgreSQL schema 4 or newer is initialized and slug identifies a current page.
; post : Exactly one bookmark exists for user-id and the page.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (set-bookmark! config user-id slug section)
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define page-id
(query-maybe-value db
"SELECT id FROM pages WHERE slug = $1 AND archived = FALSE"
slug))
(unless page-id
(error 'set-bookmark! "unknown page: ~a" slug))
(define clean-section (string-trim section))
(define position
(query-value db
#<<SQL
SELECT COALESCE(MAX(position), -1) + 1
FROM bookmarks
WHERE user_id = $1 AND section = $2
SQL
user-id
clean-section))
(query-exec db
#<<SQL
INSERT INTO bookmarks(user_id, page_id, section, position, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, page_id)
DO UPDATE SET section = EXCLUDED.section, position = EXCLUDED.position
SQL
user-id page-id clean-section position (current-seconds))))))
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Remove one page from a user's bookmarks.
; pre : PostgreSQL schema 4 or newer is initialized.
; post : The bookmark no longer exists; other bookmarks are unchanged.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (delete-bookmark! config user-id slug)
(call-with-wiki-database
config
(λ (db)
(query-exec db
#<<SQL
DELETE FROM bookmarks
WHERE user_id = $1
AND page_id = (SELECT id FROM pages WHERE slug = $2)
SQL
user-id slug)))
(void))
(define (safe-file-name name)
(define clean
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
+1 -1
View File
@@ -23,7 +23,7 @@
((regexp-match? #px"^(```|~~~)" trimmed)
(set! in-fence? (not in-fence?)))
((not in-fence?)
(for ((match (in-list (regexp-match* #px"todo\\([^()]+\\)" line))))
(for ((match (in-list (regexp-match* #px"[Tt][Oo][Dd][Oo]\\([^()]+\\)" line))))
(define text (string-trim (substring match 5 (- (string-length match) 1))))
(when (not (string=? text ""))
(set! item-number (+ item-number 1))
+46
View File
@@ -192,6 +192,44 @@ CSS
(λ (_session)
(json-response (hash 'items (list-todos config))))))
(define (recent-list-handler config req)
(require-role
config req 'reader
(λ (_session)
(json-response (hash 'pages (list-recent-pages config))))))
(define (bookmark-list-handler config req)
(require-role
config req 'reader
(λ (session)
(define user-id (wiki-user-id (wiki-session-user session)))
(json-response (hash 'bookmarks (list-bookmarks config user-id))))))
(define (bookmark-save-handler config req)
(require-write-role
config req 'reader
(λ (session)
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
(define body (request-json req))
(define slug (hash-ref body 'slug ""))
(define section (hash-ref body 'section ""))
(unless (string? slug)
(raise-argument-error 'bookmark-save-handler "string?" slug))
(unless (string? section)
(raise-argument-error 'bookmark-save-handler "string?" section))
(define user-id (wiki-user-id (wiki-session-user session)))
(set-bookmark! config user-id slug section)
(json-response (hash 'ok #t))))))
(define (bookmark-delete-handler config req slug)
(require-write-role
config req 'reader
(λ (session)
(define user-id (wiki-user-id (wiki-session-user session)))
(delete-bookmark! config user-id slug)
(json-response (hash 'ok #t)))))
(define (translations-handler config req)
(require-role
config req 'reader
@@ -471,6 +509,14 @@ CSS
(λ (req) (translations-handler config req))]
[("api" "todos") #:method "get"
(λ (req) (todo-list-handler config req))]
[("api" "recent") #:method "get"
(λ (req) (recent-list-handler config req))]
[("api" "bookmarks") #:method "get"
(λ (req) (bookmark-list-handler config req))]
[("api" "bookmarks") #:method "post"
(λ (req) (bookmark-save-handler config req))]
[("api" "bookmarks" (string-arg)) #:method "delete"
(λ (req slug) (bookmark-delete-handler config req slug))]
[("api" "search") #:method "get"
(λ (req) (search-handler config req))]
[("api" "pages") #:method "get"
+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 = "";
+4 -4
View File
@@ -16,9 +16,9 @@
(define english
(hash
'users "Users" 'translations "Translations" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'users "Users" 'translations "Translations" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages"
'edit "Edit" 'history "History" 'delete "Delete" 'page-not-found "Page not found"
'edit "Edit" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found"
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history"
'back "Back" 'user-administration "User administration" 'username "Username"
@@ -49,9 +49,9 @@
(define dutch
(hash
'users "Gebruikers" 'translations "Vertalingen" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'users "Gebruikers" 'translations "Vertalingen" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's"
'edit "Bewerken" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"
'edit "Bewerken" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam"