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>
243 lines
7.4 KiB
JavaScript
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',
|
|
}
|