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>
145 lines
4.9 KiB
JavaScript
145 lines
4.9 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { diffWords } from 'diff'
|
|
import Modal from '../../../components/Modal.jsx'
|
|
import { dateTime } from '../../../lib/format.js'
|
|
import { api } from '../../../api/client.js'
|
|
|
|
// Plain-text view of a body, for a readable word-level diff.
|
|
function toText(html) {
|
|
if (!html) return ''
|
|
if (typeof window === 'undefined' || !window.DOMParser) return html
|
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
return doc.body.textContent || ''
|
|
}
|
|
|
|
export default function WikiHistory({ slug, onClose, onRestored }) {
|
|
const [revisions, setRevisions] = useState([])
|
|
const [current, setCurrent] = useState(null)
|
|
const [selected, setSelected] = useState(null) // full revision snapshot
|
|
const [loading, setLoading] = useState(true)
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
Promise.all([api.admin.listWikiRevisions(slug), api.admin.getWiki(slug)])
|
|
.then(([revs, page]) => {
|
|
if (!active) return
|
|
setRevisions(revs)
|
|
setCurrent(page)
|
|
})
|
|
.catch(() => active && setError('Could not load history.'))
|
|
.finally(() => active && setLoading(false))
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [slug])
|
|
|
|
async function selectRevision(id) {
|
|
setError('')
|
|
try {
|
|
const rev = await api.admin.getWikiRevision(slug, id)
|
|
setSelected(rev)
|
|
} catch (err) {
|
|
setError(err.message || 'Could not load revision.')
|
|
}
|
|
}
|
|
|
|
async function restore() {
|
|
if (!selected) return
|
|
if (!confirm(`Restore the page to revision #${selected.id}? This creates a new revision.`)) return
|
|
setBusy(true)
|
|
try {
|
|
await api.admin.restoreWikiRevision(slug, selected.id)
|
|
onRestored()
|
|
} catch (err) {
|
|
setError(err.message || 'Could not restore.')
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
// Diff the selected revision (old) against the current saved page (new).
|
|
const parts = useMemo(
|
|
() => (selected ? diffWords(toText(selected.body), toText(current?.body || '')) : []),
|
|
[selected, current],
|
|
)
|
|
|
|
return (
|
|
<Modal
|
|
title={`History — ${slug}`}
|
|
onClose={onClose}
|
|
width={760}
|
|
footer={
|
|
<>
|
|
<button onClick={onClose} disabled={busy} className="pill">
|
|
Close
|
|
</button>
|
|
<button onClick={restore} disabled={busy || !selected} className="btn btn-primary btn-sq">
|
|
{busy ? 'Restoring…' : 'Restore this revision'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
{loading && <span className="spin" />}
|
|
{!loading && error && (
|
|
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
|
)}
|
|
{!loading && !error && (
|
|
<div className="wiki-history">
|
|
<ul className="wiki-history-list">
|
|
{revisions.map((r, i) => (
|
|
<li key={r.id}>
|
|
<button
|
|
type="button"
|
|
className={`wiki-rev${selected?.id === r.id ? ' is-active' : ''}`}
|
|
onClick={() => selectRevision(r.id)}
|
|
>
|
|
<span className="wiki-rev-note">
|
|
{r.change_note || 'Edit'}
|
|
{i === 0 && <span className="wiki-rev-latest"> · latest</span>}
|
|
</span>
|
|
<span className="wiki-rev-meta">
|
|
{dateTime(r.created_at)}
|
|
{r.editor ? ` · ${r.editor}` : ''}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
{revisions.length === 0 && <li className="dim sans" style={{ fontSize: '0.85rem' }}>No revisions yet.</li>}
|
|
</ul>
|
|
|
|
<div className="wiki-history-diff">
|
|
{!selected ? (
|
|
<p className="muted sans" style={{ fontSize: '0.9rem' }}>
|
|
Select a revision to see what changed between it and the current page.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.76rem' }}>
|
|
Diff: revision #{selected.id} → current
|
|
</p>
|
|
<div className="wiki-diff">
|
|
{parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? (
|
|
<span className="muted">No textual differences.</span>
|
|
) : (
|
|
parts.map((p, i) => {
|
|
let cls = ''
|
|
if (p.added) cls = 'diff-add'
|
|
else if (p.removed) cls = 'diff-del'
|
|
return (
|
|
<span key={`${i}:${p.value}`} className={cls}>
|
|
{p.value}
|
|
</span>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
)
|
|
}
|