Merge pull request 'Wiki upgrade: rich-text editing, categories, drafts, links/backlinks, tags, search, revisions' (#3) from wiki-upgrade into main

Reviewed-on: UOM/website#3
This commit is contained in:
2026-06-27 21:22:44 +00:00
24 changed files with 3552 additions and 108 deletions

366
WIKI_UPGRADE.md Normal file
View File

@@ -0,0 +1,366 @@
# UOMysticmoon Website — Wiki Upgrade Spec
> Branch: **`wiki-upgrade`**. This document is the contract for upgrading the CMS
> wiki from a flat single-table page store into a feature-complete wiki.
> It follows the project workflow: **design (this doc) → build in phases → verify**.
>
> Companion to [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md); reuses its stack, auth,
> logging, and Docker decisions unchanged.
---
## 1. Goal & scope
Turn the wiki into something that behaves like a typical wiki, while staying inside
the existing Node/Express + MariaDB + React/Vite architecture and the **staff-only**
auth model (admin/editor — no new roles, no public contributions).
**In scope**
| Feature | Summary |
|---|---|
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
| Sanitization | Server-side allowlist on save **and** client-side on render (fixes today's stored-XSS gap) |
| Categories / sections | First-class `wiki_categories` table; replaces hardcoded frontend blurbs |
| Drafts & publish | `published` + `published_at`, mirroring the `posts` pattern |
| Tags | Many-to-many tags with filtering |
| Internal links | `[[slug]]`-style links authored in the editor; red-link detection |
| Backlinks | "Linked from" list, maintained on save |
| Inline images | Reuse/generalize the existing multer upload for in-body images |
| Search | MariaDB `FULLTEXT` over title + body |
| Revision history | Per-save snapshots with view / diff / restore |
**Out of scope (this branch)**
- Public/player editing or suggestion workflow, moderation/review queues.
- New roles or per-page ACLs (all staff with a login can edit all pages).
- Real-time collaborative editing, comments/discussion pages, file attachments
other than images, page templates/transclusion, multilingual pages.
**Decisions locked from planning**
- Editor: **TipTap**, storing **HTML** (not Markdown, not JSON).
- Search: **MariaDB FULLTEXT** (no new infrastructure).
- Revision history and search are **included** (recommended additions beyond the
minimum requested set).
- Authoring is **admin + editor** (`isLoggedIn`); no anonymous edits.
---
## 2. Current state (baseline being replaced)
| Layer | Today | File |
|---|---|---|
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
**Known issues this upgrade resolves**
- **Stored XSS**: body is raw HTML rendered with `dangerouslySetInnerHTML` and never
sanitized ([WikiArticle.jsx:91](client/src/routes/wiki/WikiArticle.jsx)).
- Category blurbs and ordering are **faked in the component** ([Wiki.jsx:11](client/src/routes/wiki/Wiki.jsx)), not data.
- No drafts (every save is instantly public), no history, no search, no tags, no links.
---
## 3. Data model
`utf8mb4`, InnoDB throughout. All changes are **additive and idempotent** so
`ensureSchema()` upgrades existing databases on boot with no data loss. New columns
are nullable or have safe defaults; **existing pages default to `published = 1`** so
nothing disappears on deploy.
### 3.1 `wiki_categories` (new)
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `guides` |
| title | VARCHAR(200) NOT NULL | |
| description | VARCHAR(400) NULL | card teaser on the wiki index |
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
| created_at / updated_at | DATETIME | standard stamps |
### 3.2 `wiki_pages` (altered)
Add to the existing table:
| col | type | notes |
|---|---|---|
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
| published_at | DATETIME NULL | set on first publish |
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
| FULLTEXT idx_wiki_search (title, body) | | search |
### 3.3 `wiki_tags` + `wiki_page_tags` (new)
```
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
tag_id FK→wiki_tags ON DELETE CASCADE,
PRIMARY KEY(page_id, tag_id) )
```
### 3.4 `wiki_links` (new) — backlinks index
Rebuilt for a page on every save by parsing its body for internal links.
| col | type | notes |
|---|---|---|
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
| INDEX idx_wiki_links_target (target_slug) | | backlink lookups |
Backlinks for page X = `SELECT source pages WHERE target_slug = X.slug AND source is published`.
### 3.5 `wiki_revisions` (new) — history
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| title / body / excerpt | snapshot of content at save time | |
| category_id | INT NULL | snapshot |
| editor_id | INT NULL FK→users(id) | who saved |
| change_note | VARCHAR(280) NULL | optional summary |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
A revision is written **inside the same transaction** as each page create/update.
### 3.6 Seed changes
Rework [seed.js](server/db/seed.js): the current 8 hardcoded pages become **categories**
(title + the blurb currently living in the frontend), each seeded idempotently via a new
`seedDefaultCategory`. Existing seeded pages are migrated/attached where applicable.
`seedDefault` for pages stays `INSERT IGNORE` so reseeding is safe.
---
## 4. Backend changes
Keep the `model` (entity) / `db` (SQL) split and the route grouping by access level.
### 4.1 Models (`server/src/model/wiki/`)
- `wiki.db.js` — add SQL for: category CRUD; page list with `category`, `published`,
`q` (FULLTEXT) filters and ordering; tag upsert + attach/detach; `wiki_links` rebuild;
revision insert/list/get; backlink query.
- `wiki.model.js` — orchestration. On **create/update** (single transaction):
1. sanitize `body` with the allowlist (§6),
2. upsert the page,
3. insert a `wiki_revisions` snapshot,
4. parse body for internal links → rebuild `wiki_links` for the page,
5. sync tags.
- A small `wiki.links.js` helper: parse internal links out of the saved HTML
(anchors written by the editor as `href="/wiki/<slug>"` / a `data-wiki-slug` attr),
return the set of target slugs.
### 4.2 Public API (`/api/v1/public`)
| Method | Path | Notes |
|---|---|---|
| GET | `/wiki/categories` | ordered categories with page counts |
| GET | `/wiki?category=&tag=&q=` | **published only**; list/filter/search summaries |
| GET | `/wiki/:slug` | page + category + tags + backlinks (published only) |
Still passes through the `siteMode` maintenance gate like other public content.
### 4.3 Admin API (`/api/v1/admin`, behind `isLoggedIn` + `noindex`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/wiki` | all pages incl. drafts (filters: category, tag, q, status) |
| GET | `/wiki/:slug` | one page incl. draft, tags, category |
| POST | `/wiki` | create (slug, title, body, excerpt, category_id, tags, published) |
| PUT | `/wiki/:slug` | update (allows slug rename — see §7) |
| PATCH | `/wiki/:slug/publish` | `{published}` toggle, stamps `published_at` |
| DELETE | `/wiki/:slug` | delete (cascades revisions/links/tags) |
| GET | `/wiki/:slug/revisions` | list snapshots |
| GET | `/wiki/:slug/revisions/:id` | one snapshot (for diff/preview) |
| POST | `/wiki/:slug/revisions/:id/restore` | restore (writes a new revision) |
| GET/POST/PUT/DELETE | `/wiki/categories[...]` | category CRUD + reorder |
| GET/POST | `/wiki/tags` | list/create tags |
| POST | `/uploads` | generalized image upload (see §4.4) → `{url}` |
Validation via `express-validator` (slug regex `^[a-z0-9-]+$`, title required, etc.),
centralized error handler unchanged. **Every write logs to `activity_log`**
(`wiki.create`, `wiki.update`, `wiki.publish`, `wiki.delete`, `wiki.revision.restore`,
`wiki.category.*`) following the existing convention.
### 4.4 Image uploads
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
the post editor and the wiki editor. Same size/mime limits. No new storage —
served from the existing `uploads/` volume.
---
## 5. Frontend changes
### 5.1 Admin
- **`WikiEditor.jsx`** — replace the raw-HTML `<textarea>` with a **TipTap** editor:
bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, **image insert**
(uploads via `/admin/uploads`), and an **internal-link picker** (`[[`-triggered
autocomplete over existing slugs; flags red links). Adds: category dropdown, tag
input (create-on-type), excerpt field, **Save draft / Publish** actions, and a
**History** tab (revision list → preview → diff → restore).
- **`WikiAdmin.jsx`** — list gains status (draft/published), category column, and
filters; plus a **Categories** manager (CRUD + drag-to-reorder).
### 5.2 Public
- **`Wiki.jsx`** — fully data-driven: categories + real excerpts from the API
(delete the hardcoded `BLURBS`/`ROMAN` constants), a **search box**, optional
tag filter.
- **`WikiArticle.jsx`** — keep auto-TOC; add category breadcrumb, tag chips, a
**"Linked from"** backlinks section, "last updated by", and **render via DOMPurify**
(`dangerouslySetInnerHTML` only after sanitize).
### 5.3 API client & routes
- Extend [client/src/api/client.js](client/src/api/client.js) with the new public/admin
wiki calls (categories, search params, revisions, tags, uploads).
- Add a public search/category route if needed; admin categories view registered in
[App.jsx](client/src/App.jsx) under `/admin/wiki` (sub-tab, no new top-level route required).
### 5.4 Dependencies (new)
- **client**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`,
`@tiptap/extension-image` (+ a small diff lib for history, e.g. `diff`); `dompurify`.
- **server**: `sanitize-html`.
(The client currently ships only React + react-router, so this is the first feature
dependency addition — keep the bundle lean, import only the extensions used.)
---
## 6. Security
- **Two-layer sanitization.** Server sanitizes on save with a strict `sanitize-html`
allowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt],
strong/em, hr, table basics); strips scripts, event handlers, `javascript:` URLs,
styles. Client re-sanitizes with DOMPurify before render. The stored value is already
clean, so even direct DB edits or future API clients can't inject script.
- **Upload safety** unchanged from posts: mime allowlist (png/jpe/gif/webp/avif),
8 MB cap, random filenames, served as static files (no execution).
- **Authorization**: all mutating wiki/category/tag/upload routes stay behind
`isLoggedIn` (admin or editor). Public routes are read-only and published-only.
- **No secrets/logging changes**; reuse existing rate-limit, helmet/CSP, noindex.
CSP `img-src` already covers `/uploads`.
---
## 7. Migration & backward compatibility
- Schema migration is additive; run by `ensureSchema()` on boot and shipped in
`schema.sql` for fresh containers. Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
/ `ADD INDEX` guarded for idempotency (MariaDB 11 supports `IF NOT EXISTS`).
- Existing pages: `published` backfills to `1`, `published_at` to `updated_at`,
`category_id` left NULL (surface as "Uncategorized" until assigned).
- **Slug rename** (new capability): on `PUT` slug change, update the page slug and
best-effort rewrite known internal links pointing at the old slug; old slug is not
auto-redirected (acceptable for a staff-curated wiki) — note in release notes.
- Public API response shape is **extended, not broken**: existing fields
(`slug`, `title`, `body`, `updated_at`) remain; new fields are additive, so the
current frontend keeps working between phases.
---
## 8. Implementation process (phased)
Each phase is a self-contained, shippable unit: build → run locally → verify in the
browser preview → commit on `wiki-upgrade`. Open a PR into `main` at the end (or per
phase if preferred). Do not merge a phase that hasn't been verified.
### Phase 0 — Branch & scaffolding ✅ (this doc)
- `wiki-upgrade` branch created; this spec committed.
### Phase 1 — Foundation & safety (highest value) ✅
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
published_at, sort_order, FULLTEXT), update `seed.js`.
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
public list filtered to published + categories endpoint.
- Client: data-driven `Wiki.jsx` (remove hardcoded blurbs); DOMPurify render in
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
- **Exit check**: existing pages still render; XSS payload in body is neutralized;
draft pages hidden from the public list/article.
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
the public API and are absent from the public list while visible in admin; public
index is data-driven (categories + sections); article shows category breadcrumb;
client builds and server boots with no errors.
### Phase 2 — Authoring UX ✅
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.
- **Exit check**: create/edit a page with headings, a list, a link, and an inline
image; verify it renders sanitized on the public page.
- **Verified** (2026-06-27): `/admin/uploads` returns `{url}` and the file serves as
an image; a page authored with H2/H3, lists, a link, and an uploaded inline image
round-trips through the WYSIWYG and renders sanitized publicly (link `rel` forced,
`<script>` stripped); a toolbar edit (insert divider) saved and persisted. TipTap
is code-split into its own chunk (lazy-loaded), keeping it off the public bundle.
### Phase 3 — Connectivity ✅
- Internal `[[slug]]` links + red-link detection; `wiki_links` rebuild on save;
backlinks on the article; tags + tag/category filtering.
- **Exit check**: link page A→B, confirm B shows A under "Linked from"; tag filter works.
- **Verified** (2026-06-27): internal links authored via an in-editor page picker
(links to `/wiki/<slug>`); A→B made B list A under "Linked from"; a link to a
non-existent page renders as a red link; removing the link on save cleared the
backlink (link index rebuilt). Tags upsert on save, filter via `?tag=` (chips +
flat index view), list with published counts, and orphan tags are auto-pruned.
- Implementation note: links are plain anchors to `/wiki/<slug>` (the WYSIWYG fits
this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`.
### Phase 4 — Discovery & trust ✅
- FULLTEXT search (public search box + admin filter); revision history list /
diff / restore.
- **Exit check**: search returns expected pages; edit a page twice, diff the
revisions, restore an older one, confirm a new revision is recorded.
- **Verified** (2026-06-27): `?q=` natural-language search matches on both body
(`recipes`→crafting) and title (`monsters`); the public search box and admin
filter both work. A page edited twice produced 3 revisions; the History modal
shows a word-level diff (added vs removed) of an old revision against current;
restoring reverted the page and appended a "Restored from revision #N" entry.
### Verification (every phase)
Use the preview workflow, not manual hand-off: start the dev server, exercise the
public wiki and the admin editor, check console/network for errors, and capture a
screenshot of the changed surface. Confirm `npm run` lint/build passes for the client
and the server boots cleanly with `ensureSchema()` applying the migration.
---
## 9. File-change map (reference)
| Area | Files |
|---|---|
| Schema/seed | `server/db/schema.sql`, `server/db/seed.js`, `server/src/utils/db.js` (ensureSchema) |
| Models | `server/src/model/wiki/wiki.db.js`, `wiki.model.js`, **new** `wiki.links.js` |
| API | `server/src/router/v1/public/public.{routes,controller}.js`, `server/src/router/v1/admin/admin.{routes,controller}.js` |
| Sanitize | **new** `server/src/utils/sanitizeHtml.js` |
| Client API | `client/src/api/client.js` |
| Public UI | `client/src/routes/wiki/Wiki.jsx`, `WikiArticle.jsx` |
| Admin UI | `client/src/routes/admin/views/WikiAdmin.jsx`, `WikiEditor.jsx`, **new** category manager + revisions view |
| Deps | `client/package.json`, `server/package.json` |
---
## 10. Open questions / assumptions
1. **Slug redirects**: assumed not needed on rename (staff wiki). Revisit if pages get
external inbound links.
2. **Search ranking**: FULLTEXT natural-language mode assumed; can switch to BOOLEAN
mode if operators are wanted later.
3. **Diff granularity**: line/word diff of the HTML source is assumed sufficient for
revision compare; a rendered visual diff is a later nice-to-have.
4. **Editor scope**: tables and embeds beyond images are deferred unless requested.

836
client/package-lock.json generated
View File

@@ -8,6 +8,12 @@
"name": "uomysticmoon-client", "name": "uomysticmoon-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",
"dompurify": "^3.4.11",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2"
@@ -740,6 +746,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.23.3", "version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
@@ -1145,6 +1167,421 @@
"win32" "win32"
] ]
}, },
"node_modules/@tiptap/core": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz",
"integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.27.2",
"@tiptap/extension-floating-menu": "^2.27.2",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.27.2",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-dropcursor": "^2.27.2",
"@tiptap/extension-gapcursor": "^2.27.2",
"@tiptap/extension-hard-break": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-history": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/pm": "^2.27.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1197,6 +1634,41 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1218,6 +1690,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -1293,6 +1771,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1311,6 +1795,24 @@
} }
} }
}, },
"node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.380", "version": "1.5.380",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
@@ -1318,6 +1820,18 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1367,6 +1881,24 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1424,6 +1956,31 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/linkify-it": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1446,6 +2003,39 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.1",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1482,6 +2072,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1518,6 +2114,210 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.9",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.9",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1630,6 +2430,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1659,6 +2465,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1690,6 +2511,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -1750,6 +2580,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -9,6 +9,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",
"dompurify": "^3.4.11",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2"

View File

@@ -49,7 +49,16 @@ export const api = {
status: () => req('/public/status'), status: () => req('/public/status'),
posts: (category) => req(`/public/posts/${category}`), posts: (category) => req(`/public/posts/${category}`),
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`), post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
wiki: () => req('/public/wiki'), wiki: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.category) qs.set('category', opts.category)
if (opts.tag) qs.set('tag', opts.tag)
if (opts.q) qs.set('q', opts.q)
const s = qs.toString()
return req(`/public/wiki${s ? `?${s}` : ''}`)
},
wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`), wikiPage: (slug) => req(`/public/wiki/${slug}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }), contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
@@ -69,11 +78,29 @@ export const api = {
fd.append('image', file) fd.append('image', file)
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true }) return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
}, },
listWiki: () => req('/admin/wiki'), // Generalized upload for rich-text editors → { url }.
upload: (file) => {
const fd = new FormData()
fd.append('image', file)
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
},
listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`), getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }), createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }), updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
publishWiki: (slug, published) =>
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }), deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
restoreWikiRevision: (slug, id) =>
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
listWikiTags: () => req('/admin/wiki/tags'),
listWikiCategories: () => req('/admin/wiki/categories'),
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
updateWikiCategory: (id, data) =>
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'), getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }), updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),

View File

@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { api } from '../api/client.js'
// Toolbar button.
function Btn({ onClick, active, disabled, title, children }) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => e.preventDefault()} // keep editor selection
onClick={onClick}
disabled={disabled}
className={`rte-btn${active ? ' is-active' : ''}`}
>
{children}
</button>
)
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
}
export default function RichTextEditor({ value, onChange, pages = [] }) {
const fileRef = useRef(null)
const [uploading, setUploading] = useState(false)
const [linkMenu, setLinkMenu] = useState(false)
const [linkFilter, setLinkFilter] = useState('')
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Link.configure({ openOnClick: false, autolink: true }),
Image.configure({ inline: false }),
],
content: value || '',
onUpdate: ({ editor }) => onChange(editor.getHTML()),
})
// Safety net: sync if the parent resets `value` externally (won't fire during
// normal typing because the parent value equals what the editor just emitted).
useEffect(() => {
if (!editor) return
if (value != null && value !== editor.getHTML()) {
editor.commands.setContent(value, false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, editor])
if (!editor) return null
function setLink() {
const prev = editor.getAttributes('link').href || ''
const url = window.prompt('Link URL (leave blank to remove)', prev)
if (url === null) return
if (url === '') return editor.chain().focus().extendMarkRange('link').unsetLink().run()
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
function insertInternalLink(page) {
const { from, to } = editor.state.selection
if (from === to) {
editor.chain().focus().insertContent(`<a href="/wiki/${page.slug}">${escapeHtml(page.title)}</a> `).run()
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: `/wiki/${page.slug}` }).run()
}
setLinkMenu(false)
setLinkFilter('')
}
async function onPickImage(e) {
const file = e.target.files?.[0]
e.target.value = '' // allow re-selecting the same file
if (!file) return
setUploading(true)
try {
const { url } = await api.admin.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name }).run()
} catch (err) {
alert(err.message || 'Image upload failed.')
} finally {
setUploading(false)
}
}
return (
<div className="rte">
<div className="rte-toolbar">
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<b>B</b>
</Btn>
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<i>I</i>
</Btn>
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
<s>S</s>
</Btn>
<span className="rte-sep" />
<Btn title="Heading 2 (table of contents)" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
H2
</Btn>
<Btn title="Heading 3" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H3
</Btn>
<span className="rte-sep" />
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
List
</Btn>
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1. List
</Btn>
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
</Btn>
<Btn title="Code block" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
{'</>'}
</Btn>
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
</Btn>
<span className="rte-sep" />
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
🔗
</Btn>
<Btn title="Link to another wiki page" disabled={pages.length === 0} onClick={() => setLinkMenu((v) => !v)}>
📄
</Btn>
<Btn title="Insert image" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? '…' : '🖼'}
</Btn>
<span className="rte-sep" />
<Btn title="Undo" disabled={!editor.can().undo()} onClick={() => editor.chain().focus().undo().run()}>
</Btn>
<Btn title="Redo" disabled={!editor.can().redo()} onClick={() => editor.chain().focus().redo().run()}>
</Btn>
</div>
{linkMenu && (
<div className="rte-linkmenu">
<input
autoFocus
className="input"
placeholder="Filter pages…"
value={linkFilter}
onChange={(e) => setLinkFilter(e.target.value)}
/>
<div className="rte-linkmenu-list">
{pages
.filter((p) => {
const q = linkFilter.trim().toLowerCase()
return !q || p.title.toLowerCase().includes(q) || p.slug.includes(q)
})
.slice(0, 30)
.map((p) => (
<button key={p.slug} type="button" className="rte-linkmenu-item" onClick={() => insertInternalLink(p)}>
<span>{p.title}</span>
<span className="rte-linkmenu-slug">/{p.slug}</span>
</button>
))}
</div>
</div>
)}
<EditorContent editor={editor} className="rte-content prose" />
<input ref={fileRef} type="file" accept="image/*" onChange={onPickImage} hidden />
</div>
)
}

View File

@@ -4,12 +4,18 @@ import { useAsync } from '../../../lib/useAsync.js'
import { shortDate } from '../../../lib/format.js' import { shortDate } from '../../../lib/format.js'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import WikiEditor from './WikiEditor.jsx' import WikiEditor from './WikiEditor.jsx'
import WikiCategories from './WikiCategories.jsx'
export default function WikiAdmin() { export default function WikiAdmin() {
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), []) const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick]) const [q, setQ] = useState('')
const { loading, error, data } = useAsync(
() => api.admin.listWiki(q.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''),
[tick, q],
)
const [editing, setEditing] = useState(null) // null | 'new' | slug const [editing, setEditing] = useState(null) // null | 'new' | slug
const [managingCats, setManagingCats] = useState(false)
const pages = data || [] const pages = data || []
return ( return (
@@ -18,10 +24,23 @@ export default function WikiAdmin() {
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}> <p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure {pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
</p> </p>
<div style={{ display: 'flex', gap: 10 }}>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
className="input"
placeholder="Search pages…"
style={{ width: 200 }}
/>
<button onClick={() => setManagingCats(true)} className="pill">
Manage sections
</button>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq"> <button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New page + New page
</button> </button>
</div> </div>
</div>
{loading && <Loading />} {loading && <Loading />}
{error && <ErrorState message="Could not load wiki pages." />} {error && <ErrorState message="Could not load wiki pages." />}
@@ -32,7 +51,8 @@ export default function WikiAdmin() {
<thead> <thead>
<tr> <tr>
<th className="adm-th">Page</th> <th className="adm-th">Page</th>
<th className="adm-th">Slug</th> <th className="adm-th">Section</th>
<th className="adm-th">Status</th>
<th className="adm-th">Updated</th> <th className="adm-th">Updated</th>
<th className="adm-th" /> <th className="adm-th" />
</tr> </tr>
@@ -42,9 +62,15 @@ export default function WikiAdmin() {
<tr key={w.slug}> <tr key={w.slug}>
<td className="adm-td" style={{ color: 'var(--head)' }}> <td className="adm-td" style={{ color: 'var(--head)' }}>
{w.title} {w.title}
</td> <span
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}> style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
>
{w.slug} {w.slug}
</span>
</td>
<td className="adm-td dim">{w.category_title || '—'}</td>
<td className="adm-td">
<StatusPill published={w.published} />
</td> </td>
<td className="adm-td dim">{shortDate(w.updated_at)}</td> <td className="adm-td dim">{shortDate(w.updated_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}> <td className="adm-td" style={{ textAlign: 'right' }}>
@@ -54,6 +80,13 @@ export default function WikiAdmin() {
</td> </td>
</tr> </tr>
))} ))}
{pages.length === 0 && (
<tr>
<td className="adm-td dim" colSpan={5}>
No wiki pages yet.
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -69,6 +102,37 @@ export default function WikiAdmin() {
}} }}
/> />
)} )}
{managingCats && (
<WikiCategories
onClose={() => {
setManagingCats(false)
reload() // section titles may have changed
}}
/>
)}
</section> </section>
) )
} }
function StatusPill({ published }) {
const live = Boolean(published)
return (
<span
className="sans"
style={{
fontSize: '0.72rem',
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
padding: '2px 9px',
borderRadius: 999,
border: `1px solid ${live ? 'rgba(108,176,140,0.5)' : 'var(--line)'}`,
color: live ? '#8fc7a6' : 'var(--dim)',
background: live ? 'rgba(108,176,140,0.12)' : 'transparent',
}}
>
{live ? 'Published' : 'Draft'}
</span>
)
}

View File

@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { api } from '../../../api/client.js'
const EMPTY = { slug: '', title: '', description: '', sort_order: 0 }
export default function WikiCategories({ onClose }) {
const [cats, setCats] = useState([])
const [editing, setEditing] = useState(null) // null = create mode, else category id
const [form, setForm] = useState(EMPTY)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(() => {
api.admin
.listWikiCategories()
.then(setCats)
.catch(() => setError('Could not load sections.'))
}, [])
useEffect(() => {
load()
}, [load])
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
function startEdit(c) {
setEditing(c.id)
setForm({ slug: c.slug, title: c.title, description: c.description || '', sort_order: c.sort_order })
setError('')
}
function reset() {
setEditing(null)
setForm(EMPTY)
}
async function save() {
if (!form.title.trim()) return setError('Title is required.')
if (!editing && !/^[a-z0-9-]+$/.test(form.slug)) {
return setError('Slug must be lowercase letters, numbers, and dashes.')
}
setBusy(true)
setError('')
const payload = {
title: form.title.trim(),
description: form.description.trim(),
sort_order: Number(form.sort_order) || 0,
}
try {
if (editing) await api.admin.updateWikiCategory(editing, payload)
else await api.admin.createWikiCategory({ slug: form.slug, ...payload })
reset()
load()
} catch (err) {
setError(err.message || 'Could not save section.')
} finally {
setBusy(false)
}
}
async function remove(c) {
if (!confirm(`Delete section "${c.title}"? Its ${c.page_count} page(s) become uncategorized.`)) return
setBusy(true)
try {
await api.admin.deleteWikiCategory(c.id)
if (editing === c.id) reset()
load()
} catch (err) {
setError(err.message || 'Could not delete section.')
} finally {
setBusy(false)
}
}
return (
<Modal
title="Wiki sections"
onClose={onClose}
width={640}
footer={
<button onClick={onClose} className="pill">
Done
</button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{/* Create / edit form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Slug</span>
<input
type="text"
value={form.slug}
onChange={set('slug')}
disabled={Boolean(editing)}
className="input"
style={{ fontFamily: 'ui-monospace,Menlo,monospace', opacity: editing ? 0.6 : 1 }}
placeholder="guides"
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" />
</label>
<label style={{ flex: '0 0 90px' }}>
<span className="field-label">Order</span>
<input type="number" value={form.sort_order} onChange={set('sort_order')} className="input" />
</label>
</div>
<label>
<span className="field-label">Description</span>
<input
type="text"
value={form.description}
onChange={set('description')}
className="input"
maxLength={400}
placeholder="Shown under the section heading on the wiki home."
/>
</label>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{editing ? 'Save section' : '+ Add section'}
</button>
{editing && (
<button onClick={reset} disabled={busy} className="pill">
Cancel edit
</button>
)}
</div>
</div>
{/* Existing categories */}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Section</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Pages</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{cats.map((c) => (
<tr key={c.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{c.title}
</td>
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
{c.slug}
</td>
<td className="adm-td dim">{c.page_count}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<span className="link-accent" onClick={() => startEdit(c)}>
Edit
</span>
<span style={{ color: 'var(--line)', margin: '0 8px' }}>·</span>
<span className="link-accent" style={{ color: '#d98b84' }} onClick={() => remove(c)}>
Delete
</span>
</td>
</tr>
))}
{cats.length === 0 && (
<tr>
<td className="adm-td dim" colSpan={4}>
No sections yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</Modal>
)
}

View File

@@ -1,20 +1,64 @@
import { useEffect, useState } from 'react' import { lazy, Suspense, useEffect, useState } from 'react'
import Modal from '../../../components/Modal.jsx' import Modal from '../../../components/Modal.jsx'
import WikiHistory from './WikiHistory.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
// Admin-only and heavy (TipTap) — load as its own chunk so the public bundle
// never pays for it.
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
export default function WikiEditor({ slug, onClose, onSaved }) { export default function WikiEditor({ slug, onClose, onSaved }) {
const isEdit = Boolean(slug) const isEdit = Boolean(slug)
const [form, setForm] = useState({ slug: '', title: '', body: '' }) const [form, setForm] = useState({
slug: '',
title: '',
body: '',
excerpt: '',
category_id: '',
published: true,
tags: '',
})
const [categories, setCategories] = useState([])
const [pages, setPages] = useState([])
const [loading, setLoading] = useState(isEdit) const [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showHistory, setShowHistory] = useState(false)
// Categories (dropdown) + pages (internal-link picker), for both new and edit.
useEffect(() => {
let active = true
api.admin
.listWikiCategories()
.then((cats) => active && setCategories(cats))
.catch(() => {})
api.admin
.listWiki()
.then((list) => active && setPages(list.map((p) => ({ slug: p.slug, title: p.title }))))
.catch(() => {})
return () => {
active = false
}
}, [])
useEffect(() => { useEffect(() => {
if (!isEdit) return if (!isEdit) return
let active = true let active = true
api.admin api.admin
.getWiki(slug) .getWiki(slug)
.then((p) => active && setForm({ slug: p.slug, title: p.title, body: p.body || '' })) .then(
(p) =>
active &&
setForm({
slug: p.slug,
title: p.title,
body: p.body || '',
excerpt: p.excerpt || '',
category_id: p.category_id != null ? String(p.category_id) : '',
published: Boolean(p.published),
tags: (p.tags || []).map((t) => t.label).join(', '),
}),
)
.catch(() => active && setError('Could not load this page.')) .catch(() => active && setError('Could not load this page.'))
.finally(() => active && setLoading(false)) .finally(() => active && setLoading(false))
return () => { return () => {
@@ -24,14 +68,30 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value })) const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
function payload() {
return {
title: form.title.trim(),
body: form.body,
excerpt: form.excerpt.trim(),
category_id: form.category_id ? Number(form.category_id) : null,
published: form.published,
tags: form.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}
}
async function save() { async function save() {
if (!form.title.trim()) return setError('Title is required.') if (!form.title.trim()) return setError('Title is required.')
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) return setError('Slug must be lowercase letters, numbers, and dashes.') if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) {
return setError('Slug must be lowercase letters, numbers, and dashes.')
}
setBusy(true) setBusy(true)
setError('') setError('')
try { try {
if (isEdit) await api.admin.updateWiki(slug, { title: form.title.trim(), body: form.body }) if (isEdit) await api.admin.updateWiki(slug, payload())
else await api.admin.createWiki({ slug: form.slug, title: form.title.trim(), body: form.body }) else await api.admin.createWiki({ slug: form.slug, ...payload() })
onSaved() onSaved()
} catch (err) { } catch (err) {
setError(err.message || 'Could not save.') setError(err.message || 'Could not save.')
@@ -52,6 +112,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
} }
return ( return (
<>
<Modal <Modal
title={isEdit ? 'Edit wiki page' : 'New wiki page'} title={isEdit ? 'Edit wiki page' : 'New wiki page'}
onClose={onClose} onClose={onClose}
@@ -63,11 +124,16 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
Delete Delete
</button> </button>
)} )}
{isEdit && (
<button onClick={() => setShowHistory(true)} disabled={busy} className="pill">
History
</button>
)}
<button onClick={onClose} disabled={busy} className="pill"> <button onClick={onClose} disabled={busy} className="pill">
Cancel Cancel
</button> </button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq"> <button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save'} {busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
</button> </button>
</> </>
} }
@@ -93,13 +159,74 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
<span className="field-label">Title</span> <span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" /> <input type="text" value={form.title} onChange={set('title')} className="input" />
</label> </label>
<label> <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span className="field-label">Body (HTML use &lt;h2&gt; for the table of contents)</span> <label style={{ flex: '1 1 200px' }}>
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} /> <span className="field-label">Section</span>
<select value={form.category_id} onChange={set('category_id')} className="input">
<option value=""> Uncategorized </option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</label> </label>
<label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 10 }}>
<input
type="checkbox"
checked={form.published}
onChange={(e) => setForm((f) => ({ ...f, published: e.target.checked }))}
/>
<span className="field-label" style={{ margin: 0 }}>
Published
</span>
</label>
</div>
<label>
<span className="field-label">Excerpt (card teaser on the wiki index)</span>
<input
type="text"
value={form.excerpt}
onChange={set('excerpt')}
className="input"
maxLength={400}
placeholder="One-line summary shown on the wiki home."
/>
</label>
<label>
<span className="field-label">Tags (comma-separated)</span>
<input
type="text"
value={form.tags}
onChange={set('tags')}
className="input"
placeholder="beginner, pvp, towns"
/>
</label>
<div>
<span className="field-label">Body (use Heading 2 for table-of-contents sections)</span>
<Suspense fallback={<span className="spin" />}>
<RichTextEditor
value={form.body}
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
pages={pages.filter((p) => p.slug !== form.slug)}
/>
</Suspense>
</div>
</div> </div>
)} )}
</Modal> </Modal>
{showHistory && (
<WikiHistory
slug={slug}
onClose={() => setShowHistory(false)}
onRestored={() => {
setShowHistory(false)
onSaved()
}}
/>
)}
</>
) )
} }

View File

@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { diffWords } from 'diff'
import Modal from '../../../components/Modal.jsx'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Plain-text view of a body, for a readable word-level diff.
function toText(html) {
if (!html) return ''
if (typeof window === 'undefined' || !window.DOMParser) return html
const doc = new DOMParser().parseFromString(html, 'text/html')
return doc.body.textContent || ''
}
export default function WikiHistory({ slug, onClose, onRestored }) {
const [revisions, setRevisions] = useState([])
const [current, setCurrent] = useState(null)
const [selected, setSelected] = useState(null) // full revision snapshot
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let active = true
Promise.all([api.admin.listWikiRevisions(slug), api.admin.getWiki(slug)])
.then(([revs, page]) => {
if (!active) return
setRevisions(revs)
setCurrent(page)
})
.catch(() => active && setError('Could not load history.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [slug])
async function selectRevision(id) {
setError('')
try {
const rev = await api.admin.getWikiRevision(slug, id)
setSelected(rev)
} catch (err) {
setError(err.message || 'Could not load revision.')
}
}
async function restore() {
if (!selected) return
if (!confirm(`Restore the page to revision #${selected.id}? This creates a new revision.`)) return
setBusy(true)
try {
await api.admin.restoreWikiRevision(slug, selected.id)
onRestored()
} catch (err) {
setError(err.message || 'Could not restore.')
setBusy(false)
}
}
// Diff the selected revision (old) against the current saved page (new).
const parts = useMemo(
() => (selected ? diffWords(toText(selected.body), toText(current?.body || '')) : []),
[selected, current],
)
return (
<Modal
title={`History — ${slug}`}
onClose={onClose}
width={760}
footer={
<>
<button onClick={onClose} disabled={busy} className="pill">
Close
</button>
<button onClick={restore} disabled={busy || !selected} className="btn btn-primary btn-sq">
{busy ? 'Restoring…' : 'Restore this revision'}
</button>
</>
}
>
{loading ? (
<span className="spin" />
) : error ? (
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
) : (
<div className="wiki-history">
<ul className="wiki-history-list">
{revisions.map((r, i) => (
<li key={r.id}>
<button
type="button"
className={`wiki-rev${selected?.id === r.id ? ' is-active' : ''}`}
onClick={() => selectRevision(r.id)}
>
<span className="wiki-rev-note">
{r.change_note || 'Edit'}
{i === 0 && <span className="wiki-rev-latest"> · latest</span>}
</span>
<span className="wiki-rev-meta">
{dateTime(r.created_at)}
{r.editor ? ` · ${r.editor}` : ''}
</span>
</button>
</li>
))}
{revisions.length === 0 && <li className="dim sans" style={{ fontSize: '0.85rem' }}>No revisions yet.</li>}
</ul>
<div className="wiki-history-diff">
{!selected ? (
<p className="muted sans" style={{ fontSize: '0.9rem' }}>
Select a revision to see what changed between it and the current page.
</p>
) : (
<>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.76rem' }}>
Diff: revision #{selected.id} current
</p>
<div className="wiki-diff">
{parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? (
<span className="muted">No textual differences.</span>
) : (
parts.map((p, i) => (
<span key={i} className={p.added ? 'diff-add' : p.removed ? 'diff-del' : ''}>
{p.value}
</span>
))
)}
</div>
</>
)}
</div>
</div>
)}
</Modal>
)
}

View File

@@ -1,27 +1,90 @@
import { Link } from 'react-router-dom' import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx' import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx' import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js' import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'] function SearchBox({ initial, onSubmit }) {
const [term, setTerm] = useState(initial || '')
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit(term.trim())
}}
style={{ display: 'flex', gap: 8, maxWidth: 460, margin: '0 auto 8px' }}
>
<input
type="search"
value={term}
onChange={(e) => setTerm(e.target.value)}
className="input"
placeholder="Search the wiki…"
/>
<button type="submit" className="btn btn-primary btn-sq">
Search
</button>
</form>
)
}
// Short blurbs for the seeded categories (the list endpoint returns title/slug only). // Group published pages under their category, preserving category sort order and
const BLURBS = { // collecting anything uncategorized into a trailing section.
'new-player-guide': 'First steps, basic survival, and early goals.', function groupByCategory(categories, pages) {
'maps-atlas': 'Regions, towns, routes, and travel notes.', const byId = new Map(categories.map((c) => [c.id, { ...c, pages: [] }]))
systems: 'Shard mechanics and custom features.', const uncategorized = []
items: 'Equipment, treasures, rewards, and curiosities.', for (const page of pages) {
monsters: 'Creatures, bosses, spawns, and dangers.', const bucket = page.category_id != null ? byId.get(page.category_id) : null
crafting: 'Professions, materials, recipes, and tools.', if (bucket) bucket.pages.push(page)
lore: 'Stories, places, factions, and mysteries.', else uncategorized.push(page)
rules: 'Player conduct, shard expectations, and policies.', }
const sections = [...byId.values()].filter((c) => c.pages.length > 0)
if (uncategorized.length) {
sections.push({ id: 'uncategorized', title: 'Other Pages', description: '', pages: uncategorized })
}
return sections
}
function PageCard({ page }) {
return (
<Link to={`/wiki/${page.slug}`} className="card" style={{ padding: 22 }}>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
{page.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
{page.excerpt || 'Open the guide →'}
</p>
</Link>
)
} }
export default function Wiki() { export default function Wiki() {
const { loading, error, data } = useAsync(() => api.wiki()) const [searchParams, setSearchParams] = useSearchParams()
const pages = data || [] const activeCategory = searchParams.get('category')
const activeTag = searchParams.get('tag')
const activeQ = searchParams.get('q')
// Search / tag views fetch a filtered page list; otherwise all pages (grouped here).
const pageOpts = activeQ ? { q: activeQ } : activeTag ? { tag: activeTag } : {}
const { loading, error, data } = useAsync(
() =>
Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({
categories,
pages,
})),
[activeTag, activeQ],
)
const allSections = data ? groupByCategory(data.categories, data.pages) : []
const sections = activeCategory
? allSections.filter((s) => s.slug === activeCategory)
: allSections
const hasPages = data && data.pages.length > 0
const flat = Boolean(activeTag || activeQ) // flat-list views
const filtered = Boolean(activeCategory || activeTag || activeQ)
const runSearch = (term) => setSearchParams(term ? { q: term } : {})
return ( return (
<PublicLayout section="wiki"> <PublicLayout section="wiki">
@@ -30,26 +93,61 @@ export default function Wiki() {
center center
eyebrow="Knowledge base" eyebrow="Knowledge base"
title="Mysticmoon Wiki" title="Mysticmoon Wiki"
lead="A calm starting point for shard guides, maps, systems, items, monsters, crafting, lore, and rules." lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
/> />
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
{loading && <Loading />} {loading && <Loading />}
{error && <ErrorState message="Could not load the wiki right now." />} {error && <ErrorState message="Could not load the wiki right now." />}
{!loading && !error && pages.length === 0 && <EmptyState>No wiki pages yet.</EmptyState>} {!loading && !error && !hasPages && !filtered && <EmptyState>No wiki pages yet.</EmptyState>}
<section className="grid-4">
{pages.map((p, i) => ( {!loading && !error && filtered && (
<Link key={p.slug} to={`/wiki/${p.slug}`} className="card" style={{ padding: 22 }}> <p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
<span className="display" style={{ color: 'var(--accent)', fontSize: '1.4rem', marginBottom: 10 }}> <Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{ROMAN[i] || i + 1} All sections
</span>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
{p.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
{BLURBS[p.slug] || 'Open the guide →'}
</p>
</Link> </Link>
{activeTag && <span className="muted"> · Tagged #{activeTag}</span>}
{activeQ && <span className="muted"> · Results for {activeQ}</span>}
</p>
)}
{/* Flat list: search results or a tag filter (both cross categories). */}
{!loading && !error && flat &&
(data.pages.length === 0 ? (
<EmptyState>{activeQ ? 'No pages match that search.' : 'No pages with this tag.'}</EmptyState>
) : (
<div className="grid-4" style={{ marginTop: 12 }}>
{data.pages.map((p) => (
<PageCard key={p.slug} page={p} />
))} ))}
</div>
))}
{/* Category / full view: grouped sections. */}
{!loading && !error && !flat && hasPages && activeCategory && sections.length === 0 && (
<EmptyState>No pages in this section yet.</EmptyState>
)}
{!flat &&
sections.map((section) => (
<section key={section.id} style={{ marginTop: 36 }}>
<h2
className="display"
style={{ margin: '0 0 4px', fontSize: '1.5rem', color: 'var(--accent)' }}
>
{section.title}
</h2>
{section.description && (
<p className="muted" style={{ margin: '0 0 16px', fontSize: '0.95rem' }}>
{section.description}
</p>
)}
<div className="grid-4" style={{ marginTop: section.description ? 0 : 12 }}>
{section.pages.map((p) => (
<PageCard key={p.slug} page={p} />
))}
</div>
</section> </section>
))}
</div> </div>
</PublicLayout> </PublicLayout>
) )

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import PublicLayout from '../../components/PublicLayout.jsx' import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js' import { useAsync } from '../../lib/useAsync.js'
@@ -13,24 +14,36 @@ function slugify(text) {
.replace(/(^-|-$)/g, '') .replace(/(^-|-$)/g, '')
} }
// Parse the stored body HTML: assign ids to <h2> headings and collect a TOC. // Parse the stored body HTML: sanitize (defense in depth — the server also
function buildArticle(body) { // sanitizes on save), assign ids to <h2> headings and collect a TOC, and mark
// internal links to pages that don't exist as "red links".
function buildArticle(body, missing) {
if (!body) return { html: '', toc: [] } if (!body) return { html: '', toc: [] }
if (typeof window === 'undefined' || !window.DOMParser) return { html: body, toc: [] } if (typeof window === 'undefined' || !window.DOMParser) return { html: '', toc: [] }
const doc = new DOMParser().parseFromString(body, 'text/html') const safe = DOMPurify.sanitize(body)
const doc = new DOMParser().parseFromString(safe, 'text/html')
const toc = [] const toc = []
doc.querySelectorAll('h2').forEach((h, i) => { doc.querySelectorAll('h2').forEach((h, i) => {
const id = slugify(h.textContent || '') || `section-${i}` const id = slugify(h.textContent || '') || `section-${i}`
h.id = id h.id = id
toc.push({ id, label: h.textContent }) toc.push({ id, label: h.textContent })
}) })
doc.querySelectorAll('a[href^="/wiki/"]').forEach((a) => {
const target = a.getAttribute('href').replace(/^\/wiki\//, '').replace(/[#?].*$/, '')
a.removeAttribute('target') // internal links stay in-app
if (missing.has(target)) {
a.classList.add('wiki-red-link')
a.setAttribute('title', 'This page does not exist yet')
}
})
return { html: doc.body.innerHTML, toc } return { html: doc.body.innerHTML, toc }
} }
export default function WikiArticle() { export default function WikiArticle() {
const { slug } = useParams() const { slug } = useParams()
const { loading, error, data: page } = useAsync(() => api.wikiPage(slug), [slug]) const { loading, error, data: page } = useAsync(() => api.wikiPage(slug), [slug])
const { html, toc } = useMemo(() => buildArticle(page?.body), [page]) const missing = useMemo(() => new Set(page?.missing_links || []), [page])
const { html, toc } = useMemo(() => buildArticle(page?.body, missing), [page, missing])
return ( return (
<PublicLayout section="wiki"> <PublicLayout section="wiki">
@@ -77,6 +90,17 @@ export default function WikiArticle() {
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}> <Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Wiki Wiki
</Link> </Link>
{page.category_title && (
<>
<span>/</span>
<Link
to={`/wiki?category=${page.category_slug}`}
style={{ color: 'var(--accent)', textDecoration: 'none' }}
>
{page.category_title}
</Link>
</>
)}
<span>/</span> <span>/</span>
<span>{page.title}</span> <span>{page.title}</span>
</p> </p>
@@ -86,6 +110,15 @@ export default function WikiArticle() {
<p className="sans" style={{ margin: '18px 0 0', color: 'var(--dim)', fontSize: '0.78rem', letterSpacing: '0.04em' }}> <p className="sans" style={{ margin: '18px 0 0', color: 'var(--dim)', fontSize: '0.78rem', letterSpacing: '0.04em' }}>
Last updated {longDate(page.updated_at) || '—'} Last updated {longDate(page.updated_at) || '—'}
</p> </p>
{page.tags && page.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 14 }}>
{page.tags.map((t) => (
<Link key={t.slug} to={`/wiki?tag=${t.slug}`} className="wiki-tag">
#{t.label}
</Link>
))}
</div>
)}
<div style={{ height: 1, background: 'var(--line)', margin: '30px 0' }} /> <div style={{ height: 1, background: 'var(--line)', margin: '30px 0' }} />
{html ? ( {html ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: html }} /> <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
@@ -93,6 +126,28 @@ export default function WikiArticle() {
<p className="muted">This page has no content yet.</p> <p className="muted">This page has no content yet.</p>
)} )}
{page.backlinks && page.backlinks.length > 0 && (
<section
style={{ marginTop: 40, borderTop: '1px solid var(--line)', paddingTop: 22 }}
>
<p
className="sans"
style={{ margin: '0 0 12px', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase' }}
>
Linked from
</p>
<ul style={{ margin: 0, paddingLeft: 18, fontFamily: 'var(--sans)', fontSize: '0.92rem' }}>
{page.backlinks.map((b) => (
<li key={b.slug} style={{ marginBottom: 6 }}>
<Link to={`/wiki/${b.slug}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{b.title}
</Link>
</li>
))}
</ul>
</section>
)}
<nav style={{ display: 'flex', justifyContent: 'flex-start', marginTop: 40 }}> <nav style={{ display: 'flex', justifyContent: 'flex-start', marginTop: 40 }}>
<Link to="/wiki" className="pill"> <Link to="/wiki" className="pill">
All wiki pages All wiki pages

View File

@@ -312,6 +312,221 @@ button[disabled] {
border: 1px solid var(--line); border: 1px solid var(--line);
} }
/* ===== Rich text editor (TipTap) ===== */
.rte {
position: relative;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
}
.rte:focus-within {
border-color: var(--accent);
}
.rte-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
background: var(--panel-flat);
}
.rte-btn {
min-width: 30px;
height: 30px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-family: var(--sans);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.rte-btn:hover:not([disabled]) {
background: var(--blue);
color: var(--ink);
}
.rte-btn.is-active {
background: var(--blue);
border-color: var(--accent);
color: var(--accent-bright);
}
.rte-btn[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
.rte-sep {
width: 1px;
align-self: stretch;
margin: 2px 4px;
background: var(--line);
}
.rte-content {
padding: 14px 16px;
max-height: 460px;
overflow-y: auto;
}
.rte-content .ProseMirror {
min-height: 220px;
outline: none;
}
.rte-content .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
color: var(--dim);
pointer-events: none;
}
/* Internal-link picker popover */
.rte-linkmenu {
position: absolute;
z-index: 20;
top: 50px;
left: 10px;
width: min(360px, calc(100% - 20px));
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-a);
box-shadow: var(--shadow-card);
}
.rte-linkmenu-list {
margin-top: 8px;
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.rte-linkmenu-item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 7px 9px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
font-family: var(--sans);
font-size: 0.88rem;
text-align: left;
cursor: pointer;
}
.rte-linkmenu-item:hover {
background: var(--blue);
color: var(--ink);
}
.rte-linkmenu-slug {
color: var(--dim);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.76rem;
}
/* ===== Wiki connectivity (tags + red links) ===== */
.wiki-tag {
display: inline-block;
padding: 3px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(127, 153, 189, 0.1);
color: var(--accent);
font-family: var(--sans);
font-size: 0.78rem;
text-decoration: none;
}
.wiki-tag:hover {
border-color: var(--accent);
background: var(--blue);
}
.prose a.wiki-red-link {
color: #d98b84;
border-bottom: 1px dotted #d98b84;
}
/* ===== Wiki revision history ===== */
.wiki-history {
display: grid;
grid-template-columns: 240px 1fr;
gap: 18px;
align-items: start;
}
@media (max-width: 640px) {
.wiki-history {
grid-template-columns: 1fr;
}
}
.wiki-history-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.wiki-rev {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-flat);
color: var(--text);
text-align: left;
cursor: pointer;
}
.wiki-rev:hover {
border-color: var(--accent);
}
.wiki-rev.is-active {
border-color: var(--accent);
background: var(--blue);
}
.wiki-rev-note {
font-family: var(--sans);
font-size: 0.86rem;
color: var(--head);
}
.wiki-rev-latest {
color: var(--accent);
font-size: 0.74rem;
}
.wiki-rev-meta {
font-family: var(--sans);
font-size: 0.72rem;
color: var(--dim);
}
.wiki-diff {
white-space: pre-wrap;
word-break: break-word;
font-family: var(--sans);
font-size: 0.92rem;
line-height: 1.6;
color: var(--text);
max-height: 360px;
overflow-y: auto;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
}
.diff-add {
background: rgba(95, 185, 138, 0.22);
color: #b9e6cd;
}
.diff-del {
background: rgba(176, 102, 95, 0.24);
color: #e3b0aa;
text-decoration: line-through;
}
/* ===== Admin tables ===== */ /* ===== Admin tables ===== */
.adm-table { .adm-table {
width: 100%; width: 100%;

View File

@@ -28,15 +28,74 @@ CREATE TABLE IF NOT EXISTS posts (
INDEX idx_posts_feed (category, published, published_at) INDEX idx_posts_feed (category, published, published_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a
-- fresh install. Pages reference a category (nullable = "Uncategorized").
CREATE TABLE IF NOT EXISTS wiki_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
description VARCHAR(400) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_pages ( CREATE TABLE IF NOT EXISTS wiki_pages (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE, slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL, body MEDIUMTEXT NULL,
excerpt VARCHAR(400) NULL,
category_id INT NULL,
published TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
updated_by INT NULL, updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL published_at DATETIME NULL,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL,
FULLTEXT INDEX idx_wiki_search (title, body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Wiki tags (many-to-many with pages).
CREATE TABLE IF NOT EXISTS wiki_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
label VARCHAR(120) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_page_tags (
page_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (page_id, tag_id),
CONSTRAINT fk_wpt_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_wpt_tag FOREIGN KEY (tag_id) REFERENCES wiki_tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Internal-link index, rebuilt on each save. target_slug may point at a page
-- that does not exist yet (a "red link").
CREATE TABLE IF NOT EXISTS wiki_links (
source_page_id INT NOT NULL,
target_slug VARCHAR(120) NOT NULL,
CONSTRAINT fk_wiki_links_src FOREIGN KEY (source_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
INDEX idx_wiki_links_target (target_slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-save content snapshots for history / diff / restore.
CREATE TABLE IF NOT EXISTS wiki_revisions (
id INT AUTO_INCREMENT PRIMARY KEY,
page_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL,
excerpt VARCHAR(400) NULL,
category_id INT NULL,
editor_id INT NULL,
change_note VARCHAR(280) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_wiki_rev_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_wiki_rev_editor FOREIGN KEY (editor_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_wiki_rev_page (page_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
@@ -57,3 +116,15 @@ CREATE TABLE IF NOT EXISTS activity_log (
CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_activity_created (created_at) INDEX idx_activity_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
-- (The category foreign key is only added on fresh installs; on upgraded databases
-- referential integrity for category_id is enforced in application code.)
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);

View File

@@ -22,24 +22,39 @@ const DEFAULT_SETTINGS = {
site_title: 'UOMysticmoon', site_title: 'UOMysticmoon',
} }
// The 8 starter wiki categories (editable later via the admin panel). // Starter wiki sections (editable later via the admin panel).
// [slug, title, description, sort_order]
const WIKI_CATEGORIES = [
['guides', 'Guides', 'Getting started and how-to guides.', 10],
['world', 'World & Lore', 'Regions, maps, and the story of Mysticmoon.', 20],
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
['community', 'Community & Rules', 'Player conduct and shard policies.', 40],
]
// The 8 starter pages, each mapped to a section. [slug, title, body, categorySlug]
const WIKI_PAGES = [ const WIKI_PAGES = [
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.'], ['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.', 'guides'],
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.'], ['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.', 'world'],
['systems', 'Server Systems', 'Shard mechanics and custom features.'], ['lore', 'Lore', 'Stories, places, factions, and mysteries.', 'world'],
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.'], ['systems', 'Server Systems', 'Shard mechanics and custom features.', 'gameplay'],
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.'], ['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.', 'gameplay'],
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.'], ['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', 'gameplay'],
['lore', 'Lore', 'Stories, places, factions, and mysteries.'], ['crafting', 'Crafting', 'Professions, materials, recipes, and tools.', 'gameplay'],
['rules', 'Rules', 'Player conduct, shard expectations, and policies.'], ['rules', 'Rules', 'Player conduct, shard expectations, and policies.', 'community'],
] ]
async function seedDefaults() { async function seedDefaults() {
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
await settingsDb.seedDefault(key, value) await settingsDb.seedDefault(key, value)
} }
for (const [slug, title, body] of WIKI_PAGES) { for (const [slug, title, description, sortOrder] of WIKI_CATEGORIES) {
await wikiDb.seedDefaultCategory(slug, title, description, sortOrder)
}
for (const [slug, title, body, categorySlug] of WIKI_PAGES) {
await wikiDb.seedDefault(slug, title, body) await wikiDb.seedDefault(slug, title, body)
// Attach to its section (only if not already categorized — safe re-run /
// migration of pages seeded before the wiki upgrade).
await wikiDb.assignCategoryBySlug(slug, categorySlug)
} }
log.info('settings and wiki defaults ensured') log.info('settings and wiki defaults ensured')
} }

228
server/package-lock.json generated
View File

@@ -21,7 +21,8 @@
"mariadb": "^3.3.1", "mariadb": "^3.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^2.0.1", "multer": "^2.0.1",
"nodemailer": "^9.0.1" "nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4" "nodemon": "^3.1.4"
@@ -345,6 +346,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -354,6 +361,15 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -382,6 +398,73 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -432,6 +515,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -468,6 +563,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -729,6 +836,25 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -829,6 +955,15 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -878,6 +1013,15 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1097,6 +1241,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1221,6 +1383,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1236,6 +1404,12 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@@ -1249,6 +1423,34 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1362,6 +1564,21 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sanitize-html": {
"version": "2.17.5",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1510,6 +1727,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -9,7 +9,12 @@
"seed": "node db/seed.js", "seed": "node db/seed.js",
"test": "echo \"no tests yet\" && exit 0" "test": "echo \"no tests yet\" && exit 0"
}, },
"keywords": ["express", "mariadb", "jwt", "bcrypt"], "keywords": [
"express",
"mariadb",
"jwt",
"bcrypt"
],
"author": "whitlocktech", "author": "whitlocktech",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -25,7 +30,8 @@
"mariadb": "^3.3.1", "mariadb": "^3.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^2.0.1", "multer": "^2.0.1",
"nodemailer": "^9.0.1" "nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4" "nodemon": "^3.1.4"

View File

@@ -1,33 +1,273 @@
const { query } = require('../../utils/db') const { query } = require('../../utils/db')
async function listSummaries() { // Full page row + joined category fields.
return query('SELECT slug, title, updated_at FROM wiki_pages ORDER BY title ASC') const PAGE_COLS =
'p.id, p.slug, p.title, p.body, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_by, p.created_at, p.updated_at, p.published_at, ' +
'c.slug AS category_slug, c.title AS category_title'
// List rows omit the body (lighter payload for indexes/tables).
const SUMMARY_COLS =
'p.id, p.slug, p.title, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_at, p.published_at, c.slug AS category_slug, c.title AS category_title'
const FROM = 'FROM wiki_pages p LEFT JOIN wiki_categories c ON c.id = p.category_id'
const ORDER = 'ORDER BY p.sort_order ASC, p.title ASC'
// Shared summary query builder with optional category / tag / status filters.
function buildSummaryQuery({ categoryId = null, tagId = null, published = null }) {
const joins = []
const where = []
const params = []
if (tagId != null) {
joins.push('JOIN wiki_page_tags pt ON pt.page_id = p.id AND pt.tag_id = ?')
params.push(tagId)
}
if (categoryId != null) {
where.push('p.category_id = ?')
params.push(categoryId)
}
if (published != null) {
where.push('p.published = ?')
params.push(published ? 1 : 0)
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
return {
sql: `SELECT ${SUMMARY_COLS} ${FROM} ${joins.join(' ')} ${clause} ${ORDER}`,
params,
}
}
// Published summaries (public). Optional category / tag filters.
async function listPublishedSummaries({ categoryId = null, tagId = null } = {}) {
const { sql, params } = buildSummaryQuery({ categoryId, tagId, published: true })
return query(sql, params)
}
// All summaries (admin), with optional category / tag / status filters.
async function listAllSummaries({ categoryId = null, tagId = null, published = null } = {}) {
const { sql, params } = buildSummaryQuery({ categoryId, tagId, published })
return query(sql, params)
} }
async function findBySlug(slug) { async function findBySlug(slug) {
const rows = await query('SELECT * FROM wiki_pages WHERE slug = ? LIMIT 1', [slug]) const rows = await query(`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? LIMIT 1`, [slug])
return rows[0] || null return rows[0] || null
} }
async function insert({ slug, title, body, updatedBy = null }) { async function findPublishedBySlug(slug) {
const rows = await query(
`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? AND p.published = 1 LIMIT 1`,
[slug],
)
return rows[0] || null
}
// Full-text search over title + body, ordered by relevance.
async function searchSummaries(q, { publishedOnly = true } = {}) {
const pub = publishedOnly ? 'AND p.published = 1' : ''
return query(
`SELECT ${SUMMARY_COLS} ${FROM}
WHERE MATCH(p.title, p.body) AGAINST (? IN NATURAL LANGUAGE MODE) ${pub}
ORDER BY MATCH(p.title, p.body) AGAINST (?) DESC, p.title ASC`,
[q, q],
)
}
// ── Page writes ────────────────────────────────────────────────────────
async function insert({
slug,
title,
body = null,
excerpt = null,
categoryId = null,
published = true,
sortOrder = 0,
updatedBy = null,
}) {
const res = await query( const res = await query(
'INSERT INTO wiki_pages (slug, title, body, updated_by) VALUES (?, ?, ?, ?)', 'INSERT INTO wiki_pages (slug, title, body, excerpt, category_id, published, sort_order, published_at, updated_by) ' +
[slug, title, body || null, updatedBy], 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
slug,
title,
body,
excerpt,
categoryId,
published ? 1 : 0,
sortOrder,
published ? new Date() : null,
updatedBy,
],
) )
return res.insertId return res.insertId
} }
async function updateBySlug(slug, { title, body, updatedBy = null }) { // Dynamic update — only the provided columns are written.
await query( async function updateBySlug(slug, fields) {
'UPDATE wiki_pages SET title = ?, body = ?, updated_by = ? WHERE slug = ?', const cols = []
[title, body || null, updatedBy, slug], const params = []
) for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(slug)
await query(`UPDATE wiki_pages SET ${cols.join(', ')} WHERE slug = ?`, params)
} }
async function deleteBySlug(slug) { async function deleteBySlug(slug) {
return query('DELETE FROM wiki_pages WHERE slug = ?', [slug]) return query('DELETE FROM wiki_pages WHERE slug = ?', [slug])
} }
// ── Categories ─────────────────────────────────────────────────────────
const CAT_COLS = 'id, slug, title, description, sort_order, created_at, updated_at'
// Categories with page counts (total + published) for index/admin views.
async function listCategories() {
return query(
`SELECT c.id, c.slug, c.title, c.description, c.sort_order, c.created_at, c.updated_at,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id) AS page_count,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id AND p.published = 1) AS published_count
FROM wiki_categories c
ORDER BY c.sort_order ASC, c.title ASC`,
)
}
async function findCategoryBySlug(slug) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function findCategoryById(id) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertCategory({ slug, title, description = null, sortOrder = 0 }) {
const res = await query(
'INSERT INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description, sortOrder],
)
return res.insertId
}
async function updateCategory(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE wiki_categories SET ${cols.join(', ')} WHERE id = ?`, params)
}
// Detach pages first (works even on upgraded DBs that lack the FK), then delete.
async function deleteCategory(id) {
await query('UPDATE wiki_pages SET category_id = NULL WHERE category_id = ?', [id])
return query('DELETE FROM wiki_categories WHERE id = ?', [id])
}
// ── Tags ───────────────────────────────────────────────────────────────
async function listTags() {
return query(
`SELECT t.id, t.slug, t.label,
(SELECT COUNT(*) FROM wiki_page_tags pt
JOIN wiki_pages p ON p.id = pt.page_id
WHERE pt.tag_id = t.id AND p.published = 1) AS published_count
FROM wiki_tags t ORDER BY t.label ASC`,
)
}
async function findTagBySlug(slug) {
const rows = await query('SELECT id, slug, label FROM wiki_tags WHERE slug = ? LIMIT 1', [slug])
return rows[0] || null
}
async function getTagsForPage(pageId) {
return query(
'SELECT t.slug, t.label FROM wiki_tags t ' +
'JOIN wiki_page_tags pt ON pt.tag_id = t.id WHERE pt.page_id = ? ORDER BY t.label ASC',
[pageId],
)
}
async function upsertTag(slug, label) {
await query('INSERT INTO wiki_tags (slug, label) VALUES (?, ?) ON DUPLICATE KEY UPDATE label = VALUES(label)', [
slug,
label,
])
const rows = await query('SELECT id FROM wiki_tags WHERE slug = ? LIMIT 1', [slug])
return rows[0].id
}
async function setPageTags(pageId, tagIds) {
await query('DELETE FROM wiki_page_tags WHERE page_id = ?', [pageId])
for (const tagId of tagIds) {
await query('INSERT IGNORE INTO wiki_page_tags (page_id, tag_id) VALUES (?, ?)', [pageId, tagId])
}
}
// Drop tags no longer attached to any page (keeps the tag list tidy).
async function deleteOrphanTags() {
return query('DELETE FROM wiki_tags WHERE id NOT IN (SELECT tag_id FROM wiki_page_tags)')
}
// ── Internal links / backlinks ─────────────────────────────────────────
async function clearLinks(pageId) {
return query('DELETE FROM wiki_links WHERE source_page_id = ?', [pageId])
}
async function insertLink(pageId, targetSlug) {
return query('INSERT INTO wiki_links (source_page_id, target_slug) VALUES (?, ?)', [pageId, targetSlug])
}
// Pages that link TO targetSlug (excludes the page linking to itself).
async function getBacklinks(targetSlug, { publishedOnly = true } = {}) {
const pub = publishedOnly ? 'AND p.published = 1' : ''
return query(
`SELECT DISTINCT p.slug, p.title FROM wiki_links l
JOIN wiki_pages p ON p.id = l.source_page_id
WHERE l.target_slug = ? AND p.slug <> ? ${pub}
ORDER BY p.title ASC`,
[targetSlug, targetSlug],
)
}
// Of the given slugs, which actually exist (for red-link detection).
async function getExistingSlugs(slugs) {
if (!slugs || slugs.length === 0) return new Set()
const placeholders = slugs.map(() => '?').join(',')
const rows = await query(`SELECT slug FROM wiki_pages WHERE slug IN (${placeholders})`, slugs)
return new Set(rows.map((r) => r.slug))
}
// ── Revisions ──────────────────────────────────────────────────────────
async function insertRevision({ pageId, title, body, excerpt, categoryId, editorId, changeNote }) {
return query(
'INSERT INTO wiki_revisions (page_id, title, body, excerpt, category_id, editor_id, change_note) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)',
[pageId, title, body || null, excerpt || null, categoryId ?? null, editorId ?? null, changeNote || null],
)
}
async function listRevisions(pageId) {
return query(
`SELECT r.id, r.change_note, r.created_at, r.editor_id, u.username AS editor
FROM wiki_revisions r LEFT JOIN users u ON u.id = r.editor_id
WHERE r.page_id = ? ORDER BY r.id DESC`,
[pageId],
)
}
async function findRevision(id) {
const rows = await query('SELECT * FROM wiki_revisions WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
// ── Seeding (idempotent) ───────────────────────────────────────────────
async function seedDefault(slug, title, body) { async function seedDefault(slug, title, body) {
await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [ await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [
slug, slug,
@@ -36,11 +276,52 @@ async function seedDefault(slug, title, body) {
]) ])
} }
async function seedDefaultCategory(slug, title, description, sortOrder = 0) {
await query(
'INSERT IGNORE INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description || null, sortOrder],
)
}
// Assign a seeded page to a category by slug, only if not already categorized —
// migrates pre-upgrade pages without clobbering manual changes.
async function assignCategoryBySlug(pageSlug, categorySlug) {
await query(
'UPDATE wiki_pages SET category_id = (SELECT id FROM wiki_categories WHERE slug = ?) ' +
'WHERE slug = ? AND category_id IS NULL',
[categorySlug, pageSlug],
)
}
module.exports = { module.exports = {
listSummaries, listPublishedSummaries,
listAllSummaries,
findBySlug, findBySlug,
findPublishedBySlug,
searchSummaries,
insert, insert,
updateBySlug, updateBySlug,
deleteBySlug, deleteBySlug,
listCategories,
findCategoryBySlug,
findCategoryById,
insertCategory,
updateCategory,
deleteCategory,
listTags,
findTagBySlug,
getTagsForPage,
upsertTag,
setPageTags,
deleteOrphanTags,
clearLinks,
insertLink,
getBacklinks,
getExistingSlugs,
insertRevision,
listRevisions,
findRevision,
seedDefault, seedDefault,
seedDefaultCategory,
assignCategoryBySlug,
} }

View File

@@ -0,0 +1,15 @@
// Extract internal wiki-link targets from a saved (already sanitized) body.
// Internal links are anchors to /wiki/<slug> or elements carrying a
// data-wiki-slug attribute. Returns a de-duplicated array of slugs.
function extractTargets(html) {
if (!html) return []
const targets = new Set()
const hrefRe = /href="\/wiki\/([a-z0-9-]+)"/g
const dataRe = /data-wiki-slug="([a-z0-9-]+)"/g
let m
while ((m = hrefRe.exec(html))) targets.add(m[1])
while ((m = dataRe.exec(html))) targets.add(m[1])
return [...targets]
}
module.exports = { extractTargets }

View File

@@ -1,25 +1,241 @@
const wikiDb = require('./wiki.db') const wikiDb = require('./wiki.db')
const { cleanBody } = require('../../utils/sanitizeHtml')
const { extractTargets } = require('./wiki.links')
async function list() { function slugifyTag(label) {
return wikiDb.listSummaries() return String(label)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
} }
// Upsert each label into wiki_tags and set the page's tag set exactly.
async function syncTags(pageId, tags) {
const ids = []
const seen = new Set()
for (const raw of tags) {
const label = String(raw).trim()
if (!label) continue
const slug = slugifyTag(label)
if (!slug || seen.has(slug)) continue
seen.add(slug)
ids.push(await wikiDb.upsertTag(slug, label))
}
await wikiDb.setPageTags(pageId, ids)
await wikiDb.deleteOrphanTags()
}
// Rebuild the page's outgoing internal-link rows from its (sanitized) body.
async function rebuildLinks(pageId, html) {
await wikiDb.clearLinks(pageId)
for (const target of extractTargets(html)) {
await wikiDb.insertLink(pageId, target)
}
}
// Snapshot the current content of a page into the revision history.
async function writeRevision(page, editorId, changeNote = null) {
await wikiDb.insertRevision({
pageId: page.id,
title: page.title,
body: page.body,
excerpt: page.excerpt,
categoryId: page.category_id,
editorId,
changeNote,
})
}
// ── Pages ──────────────────────────────────────────────────────────────
async function listPublished(filters = {}) {
return wikiDb.listPublishedSummaries(filters)
}
async function listAll(filters = {}) {
return wikiDb.listAllSummaries(filters)
}
async function search(q, opts = {}) {
return wikiDb.searchSummaries(q, opts)
}
// Admin detail: page + its tags.
async function getBySlug(slug) { async function getBySlug(slug) {
return wikiDb.findBySlug(slug) const page = await wikiDb.findBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
return page
} }
async function create({ slug, title, body, updatedBy }) { // Public detail: page + tags + backlinks + missing (red) link targets.
await wikiDb.insert({ slug, title, body, updatedBy }) async function getPublishedBySlug(slug) {
return wikiDb.findBySlug(slug) const page = await wikiDb.findPublishedBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
page.backlinks = await wikiDb.getBacklinks(slug, { publishedOnly: true })
const targets = extractTargets(page.body)
const existing = await wikiDb.getExistingSlugs(targets)
page.missing_links = targets.filter((t) => !existing.has(t))
return page
} }
async function update(slug, { title, body, updatedBy }) { async function create({ slug, title, body, excerpt, categoryId, published, updatedBy, tags }) {
await wikiDb.updateBySlug(slug, { title, body, updatedBy }) const clean = cleanBody(body)
return wikiDb.findBySlug(slug) await wikiDb.insert({
slug,
title,
body: clean,
excerpt: excerpt || null,
categoryId: categoryId ?? null,
published: published !== false,
updatedBy,
})
const page = await wikiDb.findBySlug(slug)
if (Array.isArray(tags)) await syncTags(page.id, tags)
await rebuildLinks(page.id, clean)
await writeRevision(page, updatedBy, 'Created')
return getBySlug(slug)
}
// Partial update — only keys present in `input` are written.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { updated_by: input.updatedBy ?? null }
let cleanForLinks = null
if ('title' in input) fields.title = input.title
if ('body' in input) {
fields.body = cleanBody(input.body)
cleanForLinks = fields.body
}
if ('excerpt' in input) fields.excerpt = input.excerpt || null
if ('categoryId' in input) fields.category_id = input.categoryId ?? null
if ('published' in input) {
fields.published = input.published ? 1 : 0
if (input.published && !current.published_at) fields.published_at = new Date()
}
await wikiDb.updateBySlug(slug, fields)
if (Array.isArray(input.tags)) await syncTags(current.id, input.tags)
if (cleanForLinks != null) await rebuildLinks(current.id, cleanForLinks)
const page = await wikiDb.findBySlug(slug)
await writeRevision(page, input.updatedBy ?? null, input.changeNote || null)
return getBySlug(slug)
}
async function setPublished(slug, published) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { published: published ? 1 : 0 }
if (published && !current.published_at) fields.published_at = new Date()
await wikiDb.updateBySlug(slug, fields)
return getBySlug(slug)
} }
async function remove(slug) { async function remove(slug) {
return wikiDb.deleteBySlug(slug) const res = await wikiDb.deleteBySlug(slug)
await wikiDb.deleteOrphanTags() // page_tags cascade on delete; drop now-empty tags
return res
} }
module.exports = { list, getBySlug, create, update, remove } // ── Revisions ──────────────────────────────────────────────────────────
async function listRevisions(slug) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
return wikiDb.listRevisions(page.id)
}
async function getRevision(slug, revId) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
const rev = await wikiDb.findRevision(revId)
if (!rev || rev.page_id !== page.id) return null
return rev
}
// Restore an old revision: overwrite the page with the snapshot, rebuild links,
// then record a new revision (history stays append-only).
async function restoreRevision(slug, revId, editorId) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
const rev = await wikiDb.findRevision(revId)
if (!rev || rev.page_id !== page.id) return null
await wikiDb.updateBySlug(slug, {
title: rev.title,
body: rev.body,
excerpt: rev.excerpt,
category_id: rev.category_id,
updated_by: editorId,
})
await rebuildLinks(page.id, rev.body)
const restored = await wikiDb.findBySlug(slug)
await writeRevision(restored, editorId, `Restored from revision #${revId}`)
return getBySlug(slug)
}
// ── Tags ───────────────────────────────────────────────────────────────
async function listTags() {
return wikiDb.listTags()
}
async function getTagBySlug(slug) {
return wikiDb.findTagBySlug(slug)
}
// ── Categories ─────────────────────────────────────────────────────────
async function listCategories() {
return wikiDb.listCategories()
}
async function getCategoryBySlug(slug) {
return wikiDb.findCategoryBySlug(slug)
}
async function getCategoryById(id) {
return wikiDb.findCategoryById(id)
}
async function createCategory({ slug, title, description, sortOrder }) {
const id = await wikiDb.insertCategory({ slug, title, description, sortOrder })
return wikiDb.findCategoryById(id)
}
async function updateCategory(id, input) {
const fields = {}
if ('title' in input) fields.title = input.title
if ('slug' in input) fields.slug = input.slug
if ('description' in input) fields.description = input.description || null
if ('sortOrder' in input) fields.sort_order = input.sortOrder
await wikiDb.updateCategory(id, fields)
return wikiDb.findCategoryById(id)
}
async function removeCategory(id) {
return wikiDb.deleteCategory(id)
}
module.exports = {
listPublished,
listAll,
search,
getBySlug,
getPublishedBySlug,
create,
update,
setPublished,
remove,
listRevisions,
getRevision,
restoreRevision,
listTags,
getTagBySlug,
listCategories,
getCategoryBySlug,
getCategoryById,
createCategory,
updateCategory,
removeCategory,
}

View File

@@ -160,10 +160,33 @@ async function uploadImage(req, res) {
return res.status(201).json({ image_url: imageUrl }) return res.status(201).json({ image_url: imageUrl })
} }
// ── Wiki ────────────────────────────────────────────────────────────── // Generalized upload used by rich-text editors (wiki, etc.). Same multer config
// as the screenshot upload; returns a neutral { url }.
async function uploadFile(req, res) {
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
const url = `/uploads/${req.file.filename}`
await activity.log({ req, action: 'upload', detail: { url } })
return res.status(201).json({ url })
}
// ── Wiki pages ─────────────────────────────────────────────────────────
async function listWiki(req, res) { async function listWiki(req, res) {
try { try {
return res.json(await wiki.list()) const q = (req.query.q || '').trim()
if (q) return res.json(await wiki.search(q, { publishedOnly: false }))
const filters = {}
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
filters.categoryId = category ? category.id : -1 // unknown → match nothing
}
if (req.query.tag) {
const tag = await wiki.getTagBySlug(req.query.tag)
filters.tagId = tag ? tag.id : -1 // unknown → match nothing
}
if (req.query.status === 'draft') filters.published = false
if (req.query.status === 'published') filters.published = true
return res.json(await wiki.listAll(filters))
} catch (err) { } catch (err) {
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })
} }
@@ -179,15 +202,33 @@ async function getWiki(req, res) {
} }
} }
// Resolve a category_id from the request, validating it exists. Returns
// { ok, value } so the caller can distinguish "not provided" from "invalid".
async function resolveCategoryId(body) {
if (!('category_id' in body) || body.category_id == null || body.category_id === '') {
return { ok: true, value: null }
}
const category = await wiki.getCategoryById(Number(body.category_id))
if (!category) return { ok: false }
return { ok: true, value: category.id }
}
async function createWiki(req, res) { async function createWiki(req, res) {
try { try {
if (await wiki.getBySlug(req.body.slug)) { if (await wiki.getBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A page with that slug already exists' }) return res.status(409).json({ message: 'A page with that slug already exists' })
} }
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
const page = await wiki.create({ const page = await wiki.create({
slug: req.body.slug, slug: req.body.slug,
title: req.body.title, title: req.body.title,
body: req.body.body || null, body: req.body.body || null,
excerpt: req.body.excerpt || null,
categoryId: cat.value,
published: req.body.published !== false,
tags: Array.isArray(req.body.tags) ? req.body.tags : undefined,
updatedBy: req.user.id, updatedBy: req.user.id,
}) })
await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } }) await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } })
@@ -202,11 +243,21 @@ async function updateWiki(req, res) {
try { try {
const existing = await wiki.getBySlug(req.params.slug) const existing = await wiki.getBySlug(req.params.slug)
if (!existing) return res.status(404).json({ message: 'Not found' }) if (!existing) return res.status(404).json({ message: 'Not found' })
const page = await wiki.update(req.params.slug, {
title: req.body.title, const input = { updatedBy: req.user.id }
body: req.body.body || null, if ('title' in req.body) input.title = req.body.title
updatedBy: req.user.id, if ('body' in req.body) input.body = req.body.body || null
}) if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null
if ('published' in req.body) input.published = Boolean(req.body.published)
if ('change_note' in req.body) input.changeNote = req.body.change_note
if ('tags' in req.body) input.tags = Array.isArray(req.body.tags) ? req.body.tags : []
if ('category_id' in req.body) {
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
input.categoryId = cat.value
}
const page = await wiki.update(req.params.slug, input)
await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } }) await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } })
return res.json(page) return res.json(page)
} catch (err) { } catch (err) {
@@ -215,6 +266,22 @@ async function updateWiki(req, res) {
} }
} }
async function publishWiki(req, res) {
try {
const page = await wiki.setPublished(req.params.slug, Boolean(req.body.published))
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.publish',
detail: { slug: req.params.slug, published: Boolean(req.body.published) },
})
return res.json(page)
} catch (err) {
log.error('publishWiki', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWiki(req, res) { async function deleteWiki(req, res) {
try { try {
await wiki.remove(req.params.slug) await wiki.remove(req.params.slug)
@@ -225,6 +292,121 @@ async function deleteWiki(req, res) {
} }
} }
// ── Wiki revisions ─────────────────────────────────────────────────────
async function listWikiRevisions(req, res) {
try {
const revisions = await wiki.listRevisions(req.params.slug)
if (revisions == null) return res.status(404).json({ message: 'Not found' })
return res.json(revisions)
} catch (err) {
log.error('listWikiRevisions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiRevision(req, res) {
try {
const rev = await wiki.getRevision(req.params.slug, Number(req.params.id))
if (!rev) return res.status(404).json({ message: 'Not found' })
return res.json(rev)
} catch (err) {
log.error('getWikiRevision', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function restoreWikiRevision(req, res) {
try {
const page = await wiki.restoreRevision(req.params.slug, Number(req.params.id), req.user.id)
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.revision.restore',
detail: { slug: req.params.slug, revision: Number(req.params.id) },
})
return res.json(page)
} catch (err) {
log.error('restoreWikiRevision', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Wiki tags ──────────────────────────────────────────────────────────
async function listWikiTags(req, res) {
try {
return res.json(await wiki.listTags())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Wiki categories ────────────────────────────────────────────────────
async function listWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function createWikiCategory(req, res) {
try {
if (await wiki.getCategoryBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A category with that slug already exists' })
}
const category = await wiki.createCategory({
slug: req.body.slug,
title: req.body.title,
description: req.body.description || null,
sortOrder: Number(req.body.sort_order) || 0,
})
await activity.log({ req, action: 'wiki.category.create', detail: { slug: category.slug } })
return res.status(201).json(category)
} catch (err) {
log.error('createWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
const input = {}
if ('title' in req.body) input.title = req.body.title
if ('description' in req.body) input.description = req.body.description || null
if ('sort_order' in req.body) input.sortOrder = Number(req.body.sort_order) || 0
if ('slug' in req.body && req.body.slug !== existing.slug) {
const clash = await wiki.getCategoryBySlug(req.body.slug)
if (clash) return res.status(409).json({ message: 'A category with that slug already exists' })
input.slug = req.body.slug
}
const category = await wiki.updateCategory(id, input)
await activity.log({ req, action: 'wiki.category.update', detail: { id } })
return res.json(category)
} catch (err) {
log.error('updateWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
await wiki.removeCategory(id) // pages in it become uncategorized
await activity.log({ req, action: 'wiki.category.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Settings ────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────
async function getSettings(req, res) { async function getSettings(req, res) {
try { try {
@@ -342,11 +524,21 @@ module.exports = {
publishPost, publishPost,
deletePost, deletePost,
uploadImage, uploadImage,
uploadFile,
listWiki, listWiki,
getWiki, getWiki,
createWiki, createWiki,
updateWiki, updateWiki,
publishWiki,
deleteWiki, deleteWiki,
listWikiRevisions,
getWikiRevision,
restoreWikiRevision,
listWikiTags,
listWikiCategories,
createWikiCategory,
updateWikiCategory,
deleteWikiCategory,
getSettings, getSettings,
updateSettings, updateSettings,
listActivity, listActivity,

View File

@@ -54,6 +54,8 @@ adminRouter.post(
ctrl.createPost, ctrl.createPost,
) )
adminRouter.post('/posts/upload', upload.single('image'), ctrl.uploadImage) adminRouter.post('/posts/upload', upload.single('image'), ctrl.uploadImage)
// Generalized upload (rich-text editors). Same multer middleware; returns { url }.
adminRouter.post('/uploads', upload.single('image'), ctrl.uploadFile)
adminRouter.get('/posts/:id', param('id').isInt(), validate, ctrl.getPost) adminRouter.get('/posts/:id', param('id').isInt(), validate, ctrl.getPost)
adminRouter.put('/posts/:id', param('id').isInt(), validate, ctrl.updatePost) adminRouter.put('/posts/:id', param('id').isInt(), validate, ctrl.updatePost)
adminRouter.patch( adminRouter.patch(
@@ -65,22 +67,71 @@ adminRouter.patch(
) )
adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost) adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost)
// ── Wiki ────────────────────────────────────────────────────────────── // ── Wiki categories (static paths registered before /wiki/:slug) ───────
adminRouter.get('/wiki/categories', ctrl.listWikiCategories)
adminRouter.post(
'/wiki/categories',
body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.createWikiCategory,
)
adminRouter.put(
'/wiki/categories/:id',
param('id').isInt(),
body('slug').optional().matches(/^[a-z0-9-]+$/),
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.updateWikiCategory,
)
adminRouter.delete('/wiki/categories/:id', param('id').isInt(), validate, ctrl.deleteWikiCategory)
// ── Wiki tags ──────────────────────────────────────────────────────────
adminRouter.get('/wiki/tags', ctrl.listWikiTags)
// ── Wiki pages ─────────────────────────────────────────────────────────
adminRouter.get('/wiki', ctrl.listWiki) adminRouter.get('/wiki', ctrl.listWiki)
adminRouter.post( adminRouter.post(
'/wiki', '/wiki',
body('slug').matches(/^[a-z0-9-]+$/), body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty(), body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
body('tags').optional().isArray(),
validate, validate,
ctrl.createWiki, ctrl.createWiki,
) )
adminRouter.get('/wiki/:slug', ctrl.getWiki) adminRouter.get('/wiki/:slug', ctrl.getWiki)
adminRouter.put( adminRouter.put(
'/wiki/:slug', '/wiki/:slug',
body('title').isString().trim().notEmpty(), body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
body('tags').optional().isArray(),
body('change_note').optional({ values: 'falsy' }).isString().isLength({ max: 280 }),
validate, validate,
ctrl.updateWiki, ctrl.updateWiki,
) )
adminRouter.patch(
'/wiki/:slug/publish',
body('published').isBoolean(),
validate,
ctrl.publishWiki,
)
adminRouter.get('/wiki/:slug/revisions', ctrl.listWikiRevisions)
adminRouter.get('/wiki/:slug/revisions/:id', param('id').isInt(), validate, ctrl.getWikiRevision)
adminRouter.post(
'/wiki/:slug/revisions/:id/restore',
param('id').isInt(),
validate,
ctrl.restoreWikiRevision,
)
adminRouter.delete('/wiki/:slug', ctrl.deleteWiki) adminRouter.delete('/wiki/:slug', ctrl.deleteWiki)
// ── Settings ────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────

View File

@@ -50,9 +50,40 @@ async function getPost(req, res) {
} }
} }
async function getWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiTags(req, res) {
try {
return res.json(await wiki.listTags())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiList(req, res) { async function getWikiList(req, res) {
try { try {
return res.json(await wiki.list()) // Full-text search takes precedence over category/tag filters.
const q = (req.query.q || '').trim()
if (q) return res.json(await wiki.search(q, { publishedOnly: true }))
const filters = {}
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
if (!category) return res.json([]) // unknown category → no pages
filters.categoryId = category.id
}
if (req.query.tag) {
const tag = await wiki.getTagBySlug(req.query.tag)
if (!tag) return res.json([]) // unknown tag → no pages
filters.tagId = tag.id
}
return res.json(await wiki.listPublished(filters))
} catch (err) { } catch (err) {
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })
} }
@@ -60,7 +91,8 @@ async function getWikiList(req, res) {
async function getWikiPage(req, res) { async function getWikiPage(req, res) {
try { try {
const page = await wiki.getBySlug(req.params.slug) // Public sees published pages only; drafts 404 like any missing page.
const page = await wiki.getPublishedBySlug(req.params.slug)
if (!page) return res.status(404).json({ message: 'Not found' }) if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page) return res.json(page)
} catch (err) { } catch (err) {
@@ -84,6 +116,8 @@ module.exports = {
getStatus, getStatus,
getPosts, getPosts,
getPost, getPost,
getWikiCategories,
getWikiTags,
getWikiList, getWikiList,
getWikiPage, getWikiPage,
contact, contact,

View File

@@ -25,6 +25,9 @@ publicRouter.post(
publicRouter.get('/posts/:category', siteMode, ctrl.getPosts) publicRouter.get('/posts/:category', siteMode, ctrl.getPosts)
publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost) publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost)
publicRouter.get('/wiki', siteMode, ctrl.getWikiList) publicRouter.get('/wiki', siteMode, ctrl.getWikiList)
// Static paths must precede the :slug route so they aren't captured as a slug.
publicRouter.get('/wiki/categories', siteMode, ctrl.getWikiCategories)
publicRouter.get('/wiki/tags', siteMode, ctrl.getWikiTags)
publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage) publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage)
module.exports = publicRouter module.exports = publicRouter

View File

@@ -0,0 +1,45 @@
const sanitizeHtml = require('sanitize-html')
// Allowlist for wiki/post body HTML. Anything not listed is stripped. This runs
// on every save so the stored value is already safe; the client re-sanitizes on
// render as defense in depth. Tuned for rich-text content from the admin editor.
const OPTIONS = {
allowedTags: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
'a', 'img', 'figure', 'figcaption',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
},
// http/https for links and images, mailto for links, plus relative URLs so
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: { img: ['http', 'https'] },
allowProtocolRelative: false,
// Force safe rel on links that open a new tab; drop empty/odd attributes.
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
},
disallowedTagsMode: 'discard',
}
/**
* Sanitize a block of body HTML against the allowlist above.
* Null/empty input is returned unchanged.
* @param {string|null|undefined} html
* @returns {string|null|undefined}
*/
function cleanBody(html) {
if (html == null || html === '') return html
return sanitizeHtml(String(html), OPTIONS)
}
module.exports = { cleanBody, OPTIONS }