The forum had nowhere to live. TEAMS.md 3.1 gave it a CORE page, and phase 3 deleted every core Team page — Teams is a contract primitive and core does not own the word for one. So the forum follows the activity feed: module-uo declares a second place on its guild page and core fills it. TWO slots rather than one, because a slot holds one component and the first fill wins. Stacking the feed and the forum into a single fill would take from the module the ability to place core's two contributions separately on its own page, which is the whole point of the module owning it. The panel navigates by SEARCH PARAM (?thread=12) rather than by route. A thread has to be linkable and core cannot mount a route for one — the route belongs to the module's page — so a search param gives a shareable URL under whatever path the module chose, with the back button intact and no core route anywhere in it. That is why the fill is one component holding both a list view and a detail view. Post bodies arrive already rendered by the server under the current image policy, which is why they are set as HTML here rather than sanitised again: the body was cleaned on write with the forum's own profile, and any <img> in it was emitted by core's own renderer with a fixed attribute set. A client-side sanitiser would have to strip exactly the tag core just decided to add. The published image mode is read only to decide which composer to draw — never what renders. The composer puts an uploaded file's URL into the body as TEXT, not as a tag. The author never writes markup, which is what keeps the operator's policy enforceable. The admin panel carries both settings, the always-on help text, and the confirmation dialog with its two checkboxes and one recorded acknowledgement — plus the three additions the org lead settled: attribution and staff removal, the warning that disabling later does not delete existing files, and who "users" actually means. A stale acknowledgement raises a banner and freezes the settings; it does not turn uploads off. Co-Authored-By: Claude <noreply@anthropic.com>
153 lines
5.4 KiB
JavaScript
153 lines
5.4 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'
|
|
import TeamForumSettings from './TeamForumSettings.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>
|
|
|
|
<TeamForumSettings />
|
|
|
|
<EmailDelivery />
|
|
</section>
|
|
)
|
|
}
|