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>
142 lines
5.0 KiB
JavaScript
142 lines
5.0 KiB
JavaScript
import { useCallback, useState } from 'react'
|
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|
import { useAsync } from '../../../lib/useAsync.js'
|
|
import { ago, dateTime } from '../../../lib/format.js'
|
|
import { api } from '../../../api/client.js'
|
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
|
|
|
export default function Dashboard() {
|
|
const { refresh: refreshSite } = useSite()
|
|
const [tick, setTick] = useState(0)
|
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
|
|
|
const { loading, error, data } = useAsync(
|
|
() => Promise.all([api.admin.dashboard(), api.admin.listPosts(), api.admin.listWiki()]),
|
|
[tick],
|
|
)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
if (loading) return <Loading />
|
|
if (error) return <ErrorState message="Could not load the dashboard." />
|
|
|
|
const [dash, posts, wiki] = data
|
|
const mode = dash.site_mode || 'live'
|
|
const isLive = mode === 'live'
|
|
const modeDot = isLive ? 'var(--mode-live)' : 'var(--mode-maint)'
|
|
const published = posts.filter((p) => p.published).length
|
|
|
|
const stats = [
|
|
{ value: published, label: 'Published posts' },
|
|
{ value: posts.length - published, label: 'Drafts' },
|
|
{ value: wiki.length, label: 'Wiki pages' },
|
|
{ value: dash.counts?.users ?? 0, label: 'Users' },
|
|
]
|
|
|
|
async function toggle() {
|
|
setBusy(true)
|
|
try {
|
|
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
|
|
await refreshSite()
|
|
reload()
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const changed = dash.last_change || {}
|
|
|
|
let modeLabel = isLive ? 'Switch to Maintenance' : 'Switch to Live'
|
|
if (busy) modeLabel = 'Saving…'
|
|
|
|
return (
|
|
<section>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 18,
|
|
padding: 24,
|
|
border: '1px solid var(--line)',
|
|
borderRadius: 12,
|
|
background: 'var(--panel-grad)',
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
<div>
|
|
<div className="card-kicker" style={{ marginBottom: 8 }}>
|
|
Site mode
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span style={{ width: 11, height: 11, borderRadius: '50%', background: modeDot, boxShadow: `0 0 10px ${modeDot}` }} />
|
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', textTransform: 'capitalize' }}>
|
|
{mode}
|
|
</span>
|
|
</div>
|
|
<div className="sans dim" style={{ fontSize: '0.8rem', marginTop: 6 }}>
|
|
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
|
|
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={toggle}
|
|
disabled={busy}
|
|
className="sans"
|
|
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
|
|
>
|
|
{modeLabel}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
|
|
{stats.map((s) => (
|
|
<div key={s.label} style={{ padding: 20, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)' }}>
|
|
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>
|
|
{s.value}
|
|
</div>
|
|
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>
|
|
{s.label}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.25rem', color: 'var(--head)' }}>
|
|
Recent activity
|
|
</h2>
|
|
<div className="panel-flat">
|
|
{(dash.recent_activity || []).length === 0 && (
|
|
<div className="adm-td" style={{ borderBottom: 'none' }}>No activity yet.</div>
|
|
)}
|
|
{(dash.recent_activity || []).map((a) => (
|
|
<div
|
|
key={a.id}
|
|
className="sans"
|
|
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
|
|
>
|
|
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
|
|
{a.action}
|
|
</span>
|
|
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>
|
|
<span className="dim" style={{ flex: 'none' }}>{ago(a.created_at)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// Render the JSON `detail` column in a human-ish way.
|
|
export function formatDetail(a) {
|
|
if (!a.detail) return a.username ? `by ${a.username}` : '—'
|
|
try {
|
|
const obj = JSON.parse(a.detail)
|
|
return Object.entries(obj)
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join(', ')
|
|
} catch {
|
|
return a.detail
|
|
}
|
|
}
|