breadcrumps, todos, etc.
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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,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
@@ -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!
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+292
-17
@@ -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('"', """);
|
||||
}
|
||||
|
||||
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) => {
|
||||
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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user