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>
139 lines
4.5 KiB
JavaScript
139 lines
4.5 KiB
JavaScript
import { useCallback, useState } from 'react'
|
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|
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 [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 [managingCats, setManagingCats] = useState(false)
|
|
const pages = data || []
|
|
|
|
return (
|
|
<section>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
|
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
|
|
</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">
|
|
+ New page
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load wiki pages." />}
|
|
|
|
{!loading && !error && (
|
|
<div className="panel-flat">
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Page</th>
|
|
<th className="adm-th">Section</th>
|
|
<th className="adm-th">Status</th>
|
|
<th className="adm-th">Updated</th>
|
|
<th className="adm-th" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pages.map((w) => (
|
|
<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 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' }}>
|
|
<span className="link-accent" onClick={() => setEditing(w.slug)}>
|
|
Edit
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{pages.length === 0 && (
|
|
<tr>
|
|
<td className="adm-td dim" colSpan={5}>
|
|
No wiki pages yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{editing && (
|
|
<WikiEditor
|
|
slug={editing === 'new' ? null : editing}
|
|
onClose={() => setEditing(null)}
|
|
onSaved={() => {
|
|
setEditing(null)
|
|
reload()
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{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>
|
|
)
|
|
}
|