35 files and 5,332 lines out — twelve public pages, seven admin views, two player views, eight components, the two `data/` leaves and the three `lib/` ones, plus the two tests that came with them. §2.7.1's estimate of 51 files / ~3,700 lines was measured differently and is corrected in the docs PR. The seams core keeps, each smaller than what it replaced: Nine rows leave the public header and six leave the admin sidebar, and both lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already handled a module page's heading, so the six TITLES entries and the `/admin/characters` branch of `sectionTitle` simply go. `/player` had `PlayerCharacters` as its index — a UO page — and rather than name a replacement or invent a landing screen it now resolves to the first row of the portal nav this viewer can reach (`firstDestinationFor`, beside `allowedPathsFor` and reading the BASE nav for the same reason: an override is presentation and where everybody lands is behaviour). With the module installed that is still Characters, so a player's first screen after signing in does not change. Deliberately generic and deliberately not in the portal layout — the admin index is the same question with a hardcoded answer, and if the two logged-in areas ever become one this is what serves both. `game_account_signup` goes with the rest of core's UO prose: the mode list, the derived public flag, the validation and a Site Settings field whose help text named Bridge.cfg. The row itself is untouched and module-uo reads it through ctx.settings — the data stays, the semantics move. KNOWN BREAK, accepted by the org lead: the shipped Android app reads `gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a `= false` default so nothing crashes; the app silently stops offering game-account creation until it reads the module's `/public/shard/features` instead. Out of scope here, recorded in the Android plan, and it lands well before this workstream's cutover reaches `main`. 620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged; routes.guards unchanged. The OpenAPI spec loses exactly one property, and only because it was hand-written in swagger.js — regeneration alone would have left the spec documenting a field core no longer returns. Co-Authored-By: Claude <noreply@anthropic.com>
150 lines
5.3 KiB
JavaScript
150 lines
5.3 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',
|
|
},
|
|
]
|
|
|
|
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>
|
|
)
|
|
}
|