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>
246 lines
7.5 KiB
JavaScript
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',
|
|
}
|