breadcrumps, todos, etc.
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
(target zip
|
(target zip
|
||||||
(deps clean)
|
(deps clean)
|
||||||
(zip-package))
|
(zip-package #:exclude '("wiki-data")))
|
||||||
|
|
||||||
(target clean
|
(target clean
|
||||||
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "." #px"([.]bak|~)$" #:recursive #t))
|
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "." #px"([.]bak|~)$" #:recursive #t))
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# racket-wiki
|
# racket-wiki
|
||||||
|
|
||||||
|
Current development version: **0.2.19**.
|
||||||
|
|
||||||
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
|
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.
|
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
|
## 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.
|
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
|
#lang info
|
||||||
|
|
||||||
(define pkg-authors '(hnmdijkema))
|
(define pkg-authors '(hnmdijkema))
|
||||||
(define version "0.2.15")
|
(define version "0.2.21")
|
||||||
(define license 'MIT)
|
(define license 'MIT)
|
||||||
(define collection "racket-wiki")
|
(define collection "racket-wiki")
|
||||||
(define pkg-desc
|
(define pkg-desc
|
||||||
|
|||||||
+31
-1
@@ -11,7 +11,7 @@
|
|||||||
database-schema-version
|
database-schema-version
|
||||||
migrate-database!)
|
migrate-database!)
|
||||||
|
|
||||||
(define current-schema-version 3)
|
(define current-schema-version 5)
|
||||||
|
|
||||||
(define schema-1-statements
|
(define schema-1-statements
|
||||||
(list
|
(list
|
||||||
@@ -215,6 +215,30 @@ SQL
|
|||||||
(replace-page-todos! db (vector-ref row 0) (vector-ref row 1)))
|
(replace-page-todos! db (vector-ref row 0) (vector-ref row 1)))
|
||||||
(record-schema-version! db 3))
|
(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.
|
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
|
||||||
; pre : db is a writable PostgreSQL connection and config identifies the
|
; pre : db is a writable PostgreSQL connection and config identifies the
|
||||||
@@ -235,6 +259,12 @@ SQL
|
|||||||
(define after-attachments (database-schema-version db))
|
(define after-attachments (database-schema-version db))
|
||||||
(when (= after-attachments 2)
|
(when (= after-attachments 2)
|
||||||
(migrate-2->3! db))
|
(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))
|
(define resulting-version (database-schema-version db))
|
||||||
(when (> resulting-version current-schema-version)
|
(when (> resulting-version current-schema-version)
|
||||||
(error 'migrate-database!
|
(error 'migrate-database!
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
read-version
|
read-version
|
||||||
search-pages
|
search-pages
|
||||||
list-todos
|
list-todos
|
||||||
|
list-recent-pages
|
||||||
|
list-bookmarks
|
||||||
|
set-bookmark!
|
||||||
|
delete-bookmark!
|
||||||
save-upload!
|
save-upload!
|
||||||
uploaded-file)
|
uploaded-file)
|
||||||
|
|
||||||
@@ -390,6 +394,112 @@ SQL
|
|||||||
'line (vector-ref row 3)
|
'line (vector-ref row 3)
|
||||||
'text (vector-ref row 4))))))
|
'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 (safe-file-name name)
|
||||||
(define clean
|
(define clean
|
||||||
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
|
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
((regexp-match? #px"^(```|~~~)" trimmed)
|
((regexp-match? #px"^(```|~~~)" trimmed)
|
||||||
(set! in-fence? (not in-fence?)))
|
(set! in-fence? (not 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))))
|
(define text (string-trim (substring match 5 (- (string-length match) 1))))
|
||||||
(when (not (string=? text ""))
|
(when (not (string=? text ""))
|
||||||
(set! item-number (+ item-number 1))
|
(set! item-number (+ item-number 1))
|
||||||
|
|||||||
+46
@@ -192,6 +192,44 @@ CSS
|
|||||||
(λ (_session)
|
(λ (_session)
|
||||||
(json-response (hash 'items (list-todos config))))))
|
(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)
|
(define (translations-handler config req)
|
||||||
(require-role
|
(require-role
|
||||||
config req 'reader
|
config req 'reader
|
||||||
@@ -471,6 +509,14 @@ CSS
|
|||||||
(λ (req) (translations-handler config req))]
|
(λ (req) (translations-handler config req))]
|
||||||
[("api" "todos") #:method "get"
|
[("api" "todos") #:method "get"
|
||||||
(λ (req) (todo-list-handler config req))]
|
(λ (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"
|
[("api" "search") #:method "get"
|
||||||
(λ (req) (search-handler config req))]
|
(λ (req) (search-handler config req))]
|
||||||
[("api" "pages") #:method "get"
|
[("api" "pages") #:method "get"
|
||||||
|
|||||||
@@ -273,6 +273,48 @@ body {
|
|||||||
font-style: italic;
|
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 {
|
.pages-section {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid #d7d7d7;
|
border-top: 1px solid #d7d7d7;
|
||||||
@@ -1048,3 +1090,63 @@ body.editor-mode .editor-metadata-row {
|
|||||||
.wiki-graph-node-focus text {
|
.wiki-graph-node-focus text {
|
||||||
font-weight: 700;
|
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">
|
<div id="app">
|
||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<a id="wiki-brand" class="brand" href="#">Racket Wiki</a>
|
<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="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>
|
<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">
|
<form id="search-form" class="wiki-search" role="search">
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
<div class="navigation-row">
|
<div class="navigation-row">
|
||||||
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav>
|
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav>
|
||||||
<div id="page-action-links" class="page-action-links hidden">
|
<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="edit-page" class="editor-only hidden" href="#" data-tr="edit">Edit</a>
|
||||||
<a id="history-page" href="#" data-tr="history">History</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>
|
<a id="delete-page" class="editor-only danger hidden" href="#" data-tr="delete">Delete</a>
|
||||||
@@ -76,6 +79,12 @@
|
|||||||
<div class="editor-heading">
|
<div class="editor-heading">
|
||||||
<input id="editor-title" class="title-input" placeholder="Page title" data-tr-placeholder="page-title">
|
<input id="editor-title" class="title-input" placeholder="Page title" data-tr-placeholder="page-title">
|
||||||
<div id="editor-slug-info" class="muted"></div>
|
<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>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="cancel-edit" data-tr="cancel">Cancel</button>
|
<button id="cancel-edit" data-tr="cancel">Cancel</button>
|
||||||
@@ -101,6 +110,21 @@
|
|||||||
<div id="search-results" class="search-results"></div>
|
<div id="search-results" class="search-results"></div>
|
||||||
</section>
|
</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">
|
<section id="todo-view" class="hidden">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1 data-tr="todo-items">Todo items</h1>
|
<h1 data-tr="todo-items">Todo items</h1>
|
||||||
|
|||||||
+294
-19
@@ -11,7 +11,8 @@
|
|||||||
translations: {},
|
translations: {},
|
||||||
translationPage: "wiki-translations",
|
translationPage: "wiki-translations",
|
||||||
translationTemplate: "",
|
translationTemplate: "",
|
||||||
siteTitle: "Racket Wiki"
|
siteTitle: "Racket Wiki",
|
||||||
|
bookmarks: []
|
||||||
};
|
};
|
||||||
|
|
||||||
let easyMDE = null;
|
let easyMDE = null;
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
function show(viewId) {
|
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);
|
$(id).classList.toggle("hidden", id !== viewId);
|
||||||
}
|
}
|
||||||
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
|
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
|
||||||
@@ -50,16 +51,25 @@
|
|||||||
.replaceAll('"', """);
|
.replaceAll('"', """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandTodoMarkup(markdown) {
|
function expandTodoMarkup(markdown, pageSlug = null) {
|
||||||
let inFence = false;
|
let inFence = false;
|
||||||
|
let todoNumber = 0;
|
||||||
return (markdown || "").split("\n").map((line) => {
|
return (markdown || "").split("\n").map((line) => {
|
||||||
if (/^\s*(```|~~~)/.test(line)) {
|
if (/^\s*(```|~~~)/.test(line)) {
|
||||||
inFence = !inFence;
|
inFence = !inFence;
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
if (inFence) return line;
|
if (inFence) return line;
|
||||||
return line.replace(/todo\(([^()\r\n]+)\)/g, (_match, text) =>
|
return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => {
|
||||||
`<span class="wiki-todo"><strong>TODO</strong> ${escapeHtml(text.trim())}</span>`);
|
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");
|
}).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +148,8 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(markdown) {
|
function renderMarkdown(markdown, pageSlug = null) {
|
||||||
const html = easyMDE.markdown(expandTodoMarkup(markdown || ""));
|
const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug));
|
||||||
return DOMPurify.sanitize(applyImageWidthMarkup(html));
|
return DOMPurify.sanitize(applyImageWidthMarkup(html));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +312,7 @@
|
|||||||
.then(onSuccess)
|
.then(onSuccess)
|
||||||
.catch((error) => onError(error.message));
|
.catch((error) => onError(error.message));
|
||||||
},
|
},
|
||||||
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText) : "",
|
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText, state.currentPage?.slug || state.newPageSlug) : "",
|
||||||
renderingConfig: {
|
renderingConfig: {
|
||||||
codeSyntaxHighlighting: true,
|
codeSyntaxHighlighting: true,
|
||||||
hljs: window.hljs,
|
hljs: window.hljs,
|
||||||
@@ -419,7 +429,7 @@
|
|||||||
return headings;
|
return headings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToc(entries, onSelect) {
|
function renderToc(entries, onSelect, onEdit = null) {
|
||||||
const toc = $("toc-list");
|
const toc = $("toc-list");
|
||||||
toc.replaceChildren();
|
toc.replaceChildren();
|
||||||
|
|
||||||
@@ -432,6 +442,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "toc-row";
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = entry.href || "#";
|
link.href = entry.href || "#";
|
||||||
link.className = `toc-link toc-level-${Math.min(entry.level, 4)}`;
|
link.className = `toc-link toc-level-${Math.min(entry.level, 4)}`;
|
||||||
@@ -440,14 +453,32 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSelect(entry);
|
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() {
|
function renderPageToc() {
|
||||||
const article = $("markdown-preview");
|
const article = $("markdown-preview");
|
||||||
const usedIds = new Set();
|
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 text = heading.textContent.trim();
|
||||||
const id = heading.id || headingId(text, usedIds);
|
const id = heading.id || headingId(text, usedIds);
|
||||||
heading.id = id;
|
heading.id = id;
|
||||||
@@ -455,14 +486,21 @@
|
|||||||
level: Number(heading.tagName.slice(1)),
|
level: Number(heading.tagName.slice(1)),
|
||||||
text,
|
text,
|
||||||
href: `#${id}`,
|
href: `#${id}`,
|
||||||
element: heading
|
element: heading,
|
||||||
|
line: sourceHeadings[index]?.line ?? null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
renderToc(entries, (entry) => {
|
renderToc(
|
||||||
entry.element.scrollIntoView({ behavior: "smooth", block: "start" });
|
entries,
|
||||||
history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`);
|
(entry) => {
|
||||||
});
|
entry.element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`);
|
||||||
|
},
|
||||||
|
can("editor")
|
||||||
|
? (entry) => beginEditPageAtLine(entry.line)
|
||||||
|
: null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditorToc() {
|
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) {
|
function renderBreadcrumbs(items) {
|
||||||
const breadcrumbs = $("breadcrumbs");
|
const breadcrumbs = $("breadcrumbs");
|
||||||
breadcrumbs.replaceChildren();
|
breadcrumbs.replaceChildren();
|
||||||
@@ -584,6 +636,73 @@
|
|||||||
state.pages = result.pages;
|
state.pages = result.pages;
|
||||||
updateWikiIdentity();
|
updateWikiIdentity();
|
||||||
renderPageList();
|
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) {
|
async function openPage(slug) {
|
||||||
@@ -593,13 +712,14 @@
|
|||||||
state.newPageSlug = null;
|
state.newPageSlug = null;
|
||||||
$("page-title").textContent = page.title;
|
$("page-title").textContent = page.title;
|
||||||
$("page-meta").textContent = "";
|
$("page-meta").textContent = "";
|
||||||
$("markdown-preview").innerHTML = renderMarkdown(page.markdown);
|
$("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug);
|
||||||
renderPageDetails(page);
|
renderPageDetails(page);
|
||||||
pageBreadcrumbs(page);
|
pageBreadcrumbs(page);
|
||||||
show("page-view");
|
show("page-view");
|
||||||
setPageActionVisibility(true);
|
setPageActionVisibility(true);
|
||||||
renderPageToc();
|
renderPageToc();
|
||||||
renderPageList();
|
renderPageList();
|
||||||
|
updateBookmarkAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEditorSlugInfo() {
|
function updateEditorSlugInfo() {
|
||||||
@@ -628,6 +748,7 @@
|
|||||||
}
|
}
|
||||||
$("edit-summary").value = "";
|
$("edit-summary").value = "";
|
||||||
$("save-status").textContent = "";
|
$("save-status").textContent = "";
|
||||||
|
$("template-select").value = "";
|
||||||
updateEditorSlugInfo();
|
updateEditorSlugInfo();
|
||||||
activateEditor();
|
activateEditor();
|
||||||
$("editor-title").focus();
|
$("editor-title").focus();
|
||||||
@@ -647,6 +768,7 @@
|
|||||||
easyMDE.value(state.currentPage.markdown);
|
easyMDE.value(state.currentPage.markdown);
|
||||||
$("edit-summary").value = "";
|
$("edit-summary").value = "";
|
||||||
$("save-status").textContent = "";
|
$("save-status").textContent = "";
|
||||||
|
$("template-select").value = "";
|
||||||
updateEditorSlugInfo();
|
updateEditorSlugInfo();
|
||||||
activateEditor();
|
activateEditor();
|
||||||
}
|
}
|
||||||
@@ -951,7 +1073,7 @@
|
|||||||
view.textContent = tr("view", "View");
|
view.textContent = tr("view", "View");
|
||||||
view.addEventListener("click", async () => {
|
view.addEventListener("click", async () => {
|
||||||
const full = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`);
|
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");
|
const compare = document.createElement("button");
|
||||||
compare.textContent = index + 1 < result.versions.length ? tr("compare-previous", "Compare previous") : "";
|
compare.textContent = index + 1 < result.versions.length ? tr("compare-previous", "Compare previous") : "";
|
||||||
@@ -1081,6 +1203,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function route() {
|
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(/^#\/([^/]+)$/);
|
const match = location.hash.match(/^#\/([^/]+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const slug = decodeURIComponent(match[1]);
|
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";
|
state.previousView = state.currentPage ? "page-view" : "todo-view";
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
{ label: state.siteTitle, href: "/" },
|
{ label: state.siteTitle, href: "/" },
|
||||||
@@ -1137,6 +1388,7 @@
|
|||||||
for (const item of result.items) {
|
for (const item of result.items) {
|
||||||
const row = document.createElement("article");
|
const row = document.createElement("article");
|
||||||
row.className = "todo-item";
|
row.className = "todo-item";
|
||||||
|
row.id = `todo-${item.slug}-${item.number}`;
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = `#/${encodeURIComponent(item.slug)}`;
|
link.href = `#/${encodeURIComponent(item.slug)}`;
|
||||||
link.textContent = item.title;
|
link.textContent = item.title;
|
||||||
@@ -1149,6 +1401,14 @@
|
|||||||
row.append(link, text, location);
|
row.append(link, text, location);
|
||||||
list.append(row);
|
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) {
|
function pageLinks(markdown) {
|
||||||
@@ -1348,6 +1608,7 @@
|
|||||||
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
||||||
updateRoleUi();
|
updateRoleUi();
|
||||||
await loadPages();
|
await loadPages();
|
||||||
|
await loadBookmarks();
|
||||||
await route();
|
await route();
|
||||||
startKeepAlive();
|
startKeepAlive();
|
||||||
}
|
}
|
||||||
@@ -1359,6 +1620,12 @@
|
|||||||
searchWiki($("search-input").value).catch((error) => console.error(error));
|
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(); });
|
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
|
||||||
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
|
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
|
||||||
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
|
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
|
||||||
@@ -1372,6 +1639,8 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goHome().catch((error) => console.error(error));
|
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)); });
|
$("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)); });
|
$("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)); });
|
$("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraph().catch((error) => console.error(error)); });
|
||||||
@@ -1410,6 +1679,12 @@
|
|||||||
route();
|
route();
|
||||||
});
|
});
|
||||||
$("save-page").addEventListener("click", savePage);
|
$("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) => {
|
$("file-input").addEventListener("change", (event) => {
|
||||||
uploadFiles(event.target.files).catch(() => {});
|
uploadFiles(event.target.files).catch(() => {});
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
|
|||||||
+4
-4
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
(define english
|
(define english
|
||||||
(hash
|
(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"
|
'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)"
|
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
|
||||||
'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history"
|
'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history"
|
||||||
'back "Back" 'user-administration "User administration" 'username "Username"
|
'back "Back" 'user-administration "User administration" 'username "Username"
|
||||||
@@ -49,9 +49,9 @@
|
|||||||
|
|
||||||
(define dutch
|
(define dutch
|
||||||
(hash
|
(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"
|
'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)"
|
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
|
||||||
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
|
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
|
||||||
'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam"
|
'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam"
|
||||||
|
|||||||
Reference in New Issue
Block a user