Wiki Phase 3: internal links, backlinks, and tags
Connectivity phase of the wiki upgrade (see WIKI_UPGRADE.md). Schema (additive new tables): wiki_tags, wiki_page_tags, wiki_links. Internal links & backlinks: - new wiki.links.js parses a saved body for /wiki/<slug> (and data-wiki-slug) targets; wiki_links is rebuilt on every save - article shows a "Linked from" section (published backlinks) and renders links to non-existent pages as red links (server returns missing_links) - editor gains an internal-link picker listing existing pages Tags: - pages accept a tags[] array; tags upsert on save, page tag-set is replaced, and orphaned tags are auto-pruned (on save and delete) - public/admin list filter by ?tag=; /wiki/tags lists tags with published counts - article shows tag chips; the index has a flat tag-filtered view; editor has a comma-separated tags field Verified end-to-end: A->B backlink appears, red link detected, link index rebuilds on edit, tag filtering + chips + pruning all work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,19 +15,25 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
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('')
|
||||
|
||||
// Categories for the dropdown (both new and edit).
|
||||
// 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
|
||||
}
|
||||
@@ -48,6 +54,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
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.'))
|
||||
@@ -66,6 +73,10 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,10 +185,24 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
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 }))} />
|
||||
<RichTextEditor
|
||||
value={form.body}
|
||||
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
|
||||
pages={pages.filter((p) => p.slug !== form.slug)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user