Wiki Phase 1: categories, drafts/publish, HTML sanitization
Foundation & safety phase of the wiki upgrade (see WIKI_UPGRADE.md). Schema (additive, idempotent via ensureSchema): - new wiki_categories table; wiki_pages gains category_id, excerpt, published, published_at, sort_order, and a FULLTEXT index - migration ALTERs guarded with IF NOT EXISTS for existing databases - seed reworked into 4 sections with the 8 starter pages assigned Security: - new utils/sanitizeHtml.js (sanitize-html allowlist); wiki bodies are sanitized on every save, and the article renders through DOMPurify - strips <script>, event handlers (onerror), and javascript: URLs Backend: - public: published-only list with ?category filter + /wiki/categories - admin: extended page CRUD, PATCH publish toggle, category CRUD; drafts visible to admin, hidden from public - all writes logged to activity_log Frontend: - data-driven public wiki index (sections + real descriptions; removed hardcoded blurbs/Roman numerals) with ?category filtering - article: category breadcrumb + sanitized render - admin: Section/Status columns, draft/publish + section + excerpt in the editor, and a Manage sections modal Verified end-to-end against MariaDB 11: migration clean, XSS neutralized, drafts hidden, client builds, server boots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
17
client/package-lock.json
generated
17
client/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "uomysticmoon-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.11",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
@@ -1197,6 +1198,13 @@
|
||||
"dev": true,
|
||||
"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/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1311,6 +1319,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.5.380",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.11",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
|
||||
@@ -49,7 +49,8 @@ export const api = {
|
||||
status: () => req('/public/status'),
|
||||
posts: (category) => req(`/public/posts/${category}`),
|
||||
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
|
||||
wiki: () => req('/public/wiki'),
|
||||
wiki: (category) => req(`/public/wiki${category ? `?category=${encodeURIComponent(category)}` : ''}`),
|
||||
wikiCategories: () => req('/public/wiki/categories'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||
|
||||
@@ -69,11 +70,18 @@ export const api = {
|
||||
fd.append('image', file)
|
||||
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
listWiki: () => req('/admin/wiki'),
|
||||
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
||||
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
||||
createWiki: (data) => req('/admin/wiki', { method: 'POST', 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' }),
|
||||
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'),
|
||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||
|
||||
@@ -4,12 +4,14 @@ import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { shortDate } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import WikiEditor from './WikiEditor.jsx'
|
||||
import WikiCategories from './WikiCategories.jsx'
|
||||
|
||||
export default function WikiAdmin() {
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick])
|
||||
const [editing, setEditing] = useState(null) // null | 'new' | slug
|
||||
const [managingCats, setManagingCats] = useState(false)
|
||||
const pages = data || []
|
||||
|
||||
return (
|
||||
@@ -18,9 +20,14 @@ export default function WikiAdmin() {
|
||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
|
||||
</p>
|
||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||
+ New page
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={() => setManagingCats(true)} className="pill">
|
||||
Manage sections
|
||||
</button>
|
||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||
+ New page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
@@ -32,7 +39,8 @@ export default function WikiAdmin() {
|
||||
<thead>
|
||||
<tr>
|
||||
<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" />
|
||||
</tr>
|
||||
@@ -42,9 +50,15 @@ export default function WikiAdmin() {
|
||||
<tr key={w.slug}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{w.title}
|
||||
<span
|
||||
style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
|
||||
>
|
||||
{w.slug}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
|
||||
{w.slug}
|
||||
<td className="adm-td dim">{w.category_title || '—'}</td>
|
||||
<td className="adm-td">
|
||||
<StatusPill published={w.published} />
|
||||
</td>
|
||||
<td className="adm-td dim">{shortDate(w.updated_at)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
@@ -54,6 +68,13 @@ export default function WikiAdmin() {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pages.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td dim" colSpan={5}>
|
||||
No wiki pages yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -69,6 +90,37 @@ export default function WikiAdmin() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{managingCats && (
|
||||
<WikiCategories
|
||||
onClose={() => {
|
||||
setManagingCats(false)
|
||||
reload() // section titles may have changed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
182
client/src/routes/admin/views/WikiCategories.jsx
Normal file
182
client/src/routes/admin/views/WikiCategories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,17 +4,48 @@ import { api } from '../../../api/client.js'
|
||||
|
||||
export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
const isEdit = Boolean(slug)
|
||||
const [form, setForm] = useState({ slug: '', title: '', body: '' })
|
||||
const [form, setForm] = useState({
|
||||
slug: '',
|
||||
title: '',
|
||||
body: '',
|
||||
excerpt: '',
|
||||
category_id: '',
|
||||
published: true,
|
||||
})
|
||||
const [categories, setCategories] = useState([])
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Categories for the dropdown (both new and edit).
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin
|
||||
.listWikiCategories()
|
||||
.then((cats) => active && setCategories(cats))
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) return
|
||||
let active = true
|
||||
api.admin
|
||||
.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),
|
||||
}),
|
||||
)
|
||||
.catch(() => active && setError('Could not load this page.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
@@ -24,14 +55,26 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
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)
|
||||
setError('')
|
||||
try {
|
||||
if (isEdit) await api.admin.updateWiki(slug, { title: form.title.trim(), body: form.body })
|
||||
else await api.admin.createWiki({ slug: form.slug, title: form.title.trim(), body: form.body })
|
||||
if (isEdit) await api.admin.updateWiki(slug, payload())
|
||||
else await api.admin.createWiki({ slug: form.slug, ...payload() })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
@@ -67,7 +110,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -93,6 +136,40 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
<span className="field-label">Title</span>
|
||||
<input type="text" value={form.title} onChange={set('title')} className="input" />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 200px' }}>
|
||||
<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 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">Body (HTML — use <h2> for the table of contents)</span>
|
||||
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII']
|
||||
// Group published pages under their category, preserving category sort order and
|
||||
// collecting anything uncategorized into a trailing section.
|
||||
function groupByCategory(categories, pages) {
|
||||
const byId = new Map(categories.map((c) => [c.id, { ...c, pages: [] }]))
|
||||
const uncategorized = []
|
||||
for (const page of pages) {
|
||||
const bucket = page.category_id != null ? byId.get(page.category_id) : null
|
||||
if (bucket) bucket.pages.push(page)
|
||||
else uncategorized.push(page)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Short blurbs for the seeded categories (the list endpoint returns title/slug only).
|
||||
const BLURBS = {
|
||||
'new-player-guide': 'First steps, basic survival, and early goals.',
|
||||
'maps-atlas': 'Regions, towns, routes, and travel notes.',
|
||||
systems: 'Shard mechanics and custom features.',
|
||||
items: 'Equipment, treasures, rewards, and curiosities.',
|
||||
monsters: 'Creatures, bosses, spawns, and dangers.',
|
||||
crafting: 'Professions, materials, recipes, and tools.',
|
||||
lore: 'Stories, places, factions, and mysteries.',
|
||||
rules: 'Player conduct, shard expectations, and policies.',
|
||||
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() {
|
||||
const { loading, error, data } = useAsync(() => api.wiki())
|
||||
const pages = data || []
|
||||
const [searchParams] = useSearchParams()
|
||||
const activeCategory = searchParams.get('category')
|
||||
const { loading, error, data } = useAsync(() =>
|
||||
Promise.all([api.wikiCategories(), api.wiki()]).then(([categories, pages]) => ({
|
||||
categories,
|
||||
pages,
|
||||
})),
|
||||
)
|
||||
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
|
||||
|
||||
return (
|
||||
<PublicLayout section="wiki">
|
||||
@@ -30,26 +57,43 @@ export default function Wiki() {
|
||||
center
|
||||
eyebrow="Knowledge base"
|
||||
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."
|
||||
/>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the wiki right now." />}
|
||||
{!loading && !error && pages.length === 0 && <EmptyState>No wiki pages yet.</EmptyState>}
|
||||
<section className="grid-4">
|
||||
{pages.map((p, i) => (
|
||||
<Link key={p.slug} to={`/wiki/${p.slug}`} className="card" style={{ padding: 22 }}>
|
||||
<span className="display" style={{ color: 'var(--accent)', fontSize: '1.4rem', marginBottom: 10 }}>
|
||||
{ROMAN[i] || i + 1}
|
||||
</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>
|
||||
{!loading && !error && !hasPages && <EmptyState>No wiki pages yet.</EmptyState>}
|
||||
|
||||
{!loading && !error && activeCategory && (
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
|
||||
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
← All sections
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && hasPages && activeCategory && sections.length === 0 && (
|
||||
<EmptyState>No pages in this section yet.</EmptyState>
|
||||
)}
|
||||
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import DOMPurify from 'dompurify'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
@@ -13,11 +14,13 @@ function slugify(text) {
|
||||
.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
|
||||
// sanitizes on save), then assign ids to <h2> headings and collect a TOC.
|
||||
function buildArticle(body) {
|
||||
if (!body) return { html: '', toc: [] }
|
||||
if (typeof window === 'undefined' || !window.DOMParser) return { html: body, toc: [] }
|
||||
const doc = new DOMParser().parseFromString(body, 'text/html')
|
||||
if (typeof window === 'undefined' || !window.DOMParser) return { html: '', toc: [] }
|
||||
const safe = DOMPurify.sanitize(body)
|
||||
const doc = new DOMParser().parseFromString(safe, 'text/html')
|
||||
const toc = []
|
||||
doc.querySelectorAll('h2').forEach((h, i) => {
|
||||
const id = slugify(h.textContent || '') || `section-${i}`
|
||||
@@ -77,6 +80,17 @@ export default function WikiArticle() {
|
||||
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Wiki
|
||||
</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>{page.title}</span>
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user