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>
162 lines
6.0 KiB
JavaScript
162 lines
6.0 KiB
JavaScript
import { lazy, Suspense, useEffect, useState } from 'react'
|
||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||
import { api } from '../../../api/client.js'
|
||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||
import EmailDelivery from './EmailDelivery.jsx'
|
||
|
||
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||
|
||
// Editable settings shown on this screen (key -> label + control type).
|
||
const FIELDS = [
|
||
{ key: 'site_title', label: 'Site title' },
|
||
{
|
||
key: 'homepage_teaser',
|
||
label: 'Homepage teaser',
|
||
rich: true,
|
||
help: 'Rich text shown under the hero heading on the portal (when no custom hero layout is published).',
|
||
},
|
||
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
|
||
{ key: 'status_message', label: 'Status message' },
|
||
{
|
||
key: 'contact_email',
|
||
label: 'Contact email',
|
||
help: 'Where contact-form messages (and test emails) are delivered. Also the address shown when email delivery is unconfigured and the form falls back to a mailto: link.',
|
||
},
|
||
{
|
||
key: 'player_registration',
|
||
label: 'Player registration',
|
||
help: 'Who can create a player account, and how. Off by default.',
|
||
options: [
|
||
{ value: 'disabled', label: 'Disabled — no self-registration' },
|
||
{ value: 'password', label: 'Password — username + password sign-up' },
|
||
{ value: 'sso', label: 'SSO — sign up with a linked provider' },
|
||
{ value: 'both', label: 'Both — password and SSO' },
|
||
],
|
||
fallback: 'disabled',
|
||
},
|
||
{
|
||
key: 'game_account_signup',
|
||
label: 'Game-account creation',
|
||
help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
|
||
options: [
|
||
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||
],
|
||
fallback: 'disabled',
|
||
},
|
||
]
|
||
|
||
export default function SettingsAdmin() {
|
||
const { refresh: refreshSite } = useSite()
|
||
const [values, setValues] = useState(null)
|
||
const [initial, setInitial] = useState({})
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [saved, setSaved] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
api.admin
|
||
.getSettings()
|
||
.then((all) => {
|
||
if (!active) return
|
||
const v = {}
|
||
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? f.fallback ?? ''))
|
||
setValues(v)
|
||
setInitial(v)
|
||
})
|
||
.catch(() => active && setError('Could not load settings.'))
|
||
.finally(() => active && setLoading(false))
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [])
|
||
|
||
if (loading) return <Loading />
|
||
if (error) return <ErrorState message={error} />
|
||
|
||
// setRaw takes the next value directly (rich editor onChange), set adapts a
|
||
// DOM change event onto it.
|
||
const setRaw = (k) => (val) => {
|
||
setValues((v) => ({ ...v, [k]: val }))
|
||
setSaved(false)
|
||
}
|
||
const set = (k) => (e) => setRaw(k)(e.target.value)
|
||
|
||
async function save() {
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
await api.admin.updateSettings(values)
|
||
setInitial(values)
|
||
setSaved(true)
|
||
await refreshSite()
|
||
} catch (err) {
|
||
setError(err.message || 'Could not save settings.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section style={{ maxWidth: 620 }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||
{FIELDS.map((f) => {
|
||
// A rich field can't live inside a <label> (nested toolbar buttons +
|
||
// contenteditable), so it uses a plain <div> wrapper instead.
|
||
const Wrap = f.rich ? 'div' : 'label'
|
||
let field
|
||
if (f.rich) {
|
||
field = (
|
||
<Suspense fallback={<span className="spin" />}>
|
||
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
|
||
</Suspense>
|
||
)
|
||
} else if (f.options) {
|
||
field = (
|
||
<select value={values[f.key]} onChange={set(f.key)} className="select">
|
||
{f.options.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)
|
||
} else if (f.long) {
|
||
field = <textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
||
} else {
|
||
field = <input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
||
}
|
||
return (
|
||
<Wrap key={f.key} style={{ display: 'block' }}>
|
||
<span className="field-label">{f.label}</span>
|
||
{field}
|
||
{f.help && (
|
||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||
{f.help}
|
||
</span>
|
||
)}
|
||
</Wrap>
|
||
)
|
||
})}
|
||
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||
{busy ? 'Saving…' : 'Save changes'}
|
||
</button>
|
||
<button onClick={() => setValues(initial)} disabled={busy} className="pill">
|
||
Reset
|
||
</button>
|
||
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<EmailDelivery />
|
||
</section>
|
||
)
|
||
}
|