Files
website/client/src/routes/admin/views/WikiEditor.jsx
whitlocktech 7bb992f58d Wiki Phase 4: full-text search + revision history
Final phase of the wiki upgrade (see WIKI_UPGRADE.md).

Schema (additive): wiki_revisions table (per-save content snapshots).
The FULLTEXT index on wiki_pages(title, body) shipped in Phase 1.

Search:
- MATCH ... AGAINST natural-language search over title + body, ordered by
  relevance
- public: GET /public/wiki?q= (published only); admin: GET /admin/wiki?q=
  (all statuses)
- public wiki index gains a search box; admin list gains a search field

Revision history:
- every create/update snapshots the page into wiki_revisions
- admin endpoints: list revisions, get one, and restore (restore overwrites
  the page, rebuilds links, and appends a new revision — history stays
  append-only); logged as wiki.revision.restore
- editor gains a History modal: revision list + word-level diff (jsdiff) of a
  chosen revision against the current page, with one-click restore

Verified end-to-end: search matches body and title; two edits produce three
revisions; diff renders added/removed words; restore reverts and records a new
revision. No console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:49:05 -05:00

243 lines
7.4 KiB
JavaScript

import { lazy, Suspense, useEffect, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import WikiHistory from './WikiHistory.jsx'
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 }) {
const isEdit = Boolean(slug)
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 [busy, setBusy] = useState(false)
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(() => {
if (!isEdit) return
let active = true
api.admin
.getWiki(slug)
.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.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [slug, isEdit])
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() {
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.')
}
setBusy(true)
setError('')
try {
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.')
setBusy(false)
}
}
async function remove() {
if (!confirm('Delete this wiki page?')) return
setBusy(true)
try {
await api.admin.deleteWiki(slug)
onSaved()
} catch (err) {
setError(err.message || 'Could not delete.')
setBusy(false)
}
}
return (
<>
<Modal
title={isEdit ? 'Edit wiki page' : 'New wiki page'}
onClose={onClose}
width={640}
footer={
<>
{isEdit && (
<button onClick={remove} disabled={busy} className="sans" style={delStyle}>
Delete
</button>
)}
{isEdit && (
<button onClick={() => setShowHistory(true)} disabled={busy} className="pill">
History
</button>
)}
<button onClick={onClose} disabled={busy} className="pill">
Cancel
</button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
</button>
</>
}
>
{loading ? (
<span className="spin" />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<label>
<span className="field-label">Slug</span>
<input
type="text"
value={form.slug}
onChange={set('slug')}
disabled={isEdit}
className="input"
style={{ fontFamily: 'ui-monospace,Menlo,monospace', opacity: isEdit ? 0.6 : 1 }}
placeholder="new-player-guide"
/>
</label>
<label>
<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">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>
)}
</Modal>
{showHistory && (
<WikiHistory
slug={slug}
onClose={() => setShowHistory(false)}
onRestored={() => {
setShowHistory(false)
onSaved()
}}
/>
)}
</>
)
}
const delStyle = {
border: '1px solid #6e3b38',
borderRadius: 999,
padding: '7px 16px',
background: 'rgba(110,59,56,0.18)',
color: '#d98b84',
fontSize: '0.86rem',
cursor: 'pointer',
marginRight: 'auto',
}