Files
website/client/src/routes/admin/views/WikiEditor.jsx
wtclaude 12d50fd615
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
chore(quality): resolve SonarQube code smells across website
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00

246 lines
7.5 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)
}
}
let saveLabel = form.published ? 'Save & publish' : 'Save draft'
if (busy) saveLabel = 'Saving…'
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">
{saveLabel}
</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',
}