feat(teams): phase 4 — the forum access model, announcements and the operator's controls #153

Merged
whitlocktech merged 6 commits from feature/teams-phase4-forum-access into edge 2026-08-18 14:18:11 +00:00
5 changed files with 669 additions and 0 deletions
Showing only changes of commit cbb7339a3a - Show all commits

View File

@@ -152,6 +152,26 @@ export const api = {
if (opts.offset != null) qs.set('offset', String(opts.offset)) if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
}, },
// The Team FORUM, under /player because a participant may be a plain player and
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
// core's: only core resolves whether this viewer is inside the Team, and the
// member/guest split is a security boundary. The module renders the PLACE.
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
teamForumPost: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
teamForumModerate: (slug, id, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
teamForumUpload: (slug, file) => {
const fd = new FormData()
fd.append('image', file)
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
},
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
teamGrantAdd: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
teamGrantRevoke: (slug, userId) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
wikiTags: () => req('/public/wiki/tags'), wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`), wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link // CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -284,6 +304,13 @@ export const api = {
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }), req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
clearTeamLeaderOverride: (id, memberKey) => clearTeamLeaderOverride: (id, memberKey) =>
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }), req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
teamForumSettings: () => req('/admin/teams/forum/settings'),
teamForumUploads: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.deleted) qs.set('deleted', '1')
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
},
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
teamReviewQueue: () => req('/admin/teams/review'), teamReviewQueue: () => req('/admin/teams/review'),
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`), teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
decideTeamRequest: (id, status, note) => decideTeamRequest: (id, status, note) =>

View File

@@ -5,6 +5,7 @@ import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js' import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js' import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx' import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import TeamForumPanel from './modules/TeamForumPanel.jsx'
import './styles/theme.css' import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates. // Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -75,6 +76,14 @@ declareSlot('player.invite.accepted')
// unfilled slot rendering nothing. // unfilled slot rendering nothing.
fillModuleSlot('uo.guild.detail', TeamActivityFeed) fillModuleSlot('uo.guild.detail', TeamActivityFeed)
// The forum is core's for the same reason and goes in a SECOND place the module
// declares, rather than joining the feed in the first: a slot takes one component
// (first fill wins), and stacking two unrelated panels into one fill would make
// the module unable to place them separately on its own page. It also keeps the
// two independent — a deployment with the forum switched off renders the feed
// exactly as before.
fillModuleSlot('uo.guild.forum', TeamForumPanel)
// Render on DOMContentLoaded rather than immediately, and that is the one line // Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes. // of core's boot the module system changes.
// //

View File

@@ -0,0 +1,382 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
// Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
//
// **Why the forum is core's content on a module's page.** Everything that decides
// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
// member/guest distinction — and none of it is a module's to reimplement. But
// core does not own the word for a Team, so it publishes no Team page: the module
// that says "guild" owns the page and declares a place on it, and core fills the
// place. Same direction as the activity feed, same reason.
//
// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
// thread needs to be linkable, and core cannot mount a route for it — the route
// belongs to the module's page. `?thread=12` gives a shareable URL that works
// under whatever path the module chose, with no route of core's anywhere in it,
// and the browser's back button behaves. That is the whole reason this component
// holds a list view and a detail view rather than being two components.
//
// **The image mode is published so this can draw the right composer — never to
// decide what renders.** Post bodies arrive already rendered by the server under
// the current policy (§5.5.3); the mode is read here only to show or hide an
// upload control that would otherwise 404. If the two ever disagree, the server
// is right.
//
// Like the feed, everything here degrades to rendering nothing. A 404 from the
// thread list is the ordinary case — the forum is switched off, or this viewer
// has no access — and putting an error box on a page core does not own would be
// core reporting its own absence as a defect on someone else's surface.
export default function TeamForumPanel({ externalId, moduleId }) {
const { user } = useAuth()
const { settings } = useSite()
const [params, setParams] = useSearchParams()
const [team, setTeam] = useState(null)
const [state, setState] = useState({ loading: true, forum: null })
const [thread, setThread] = useState(null)
const [composing, setComposing] = useState(false)
const openThreadId = params.get('thread')
const imageMode = settings?.teams_forum_images || 'disabled'
const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
const loadThreads = useCallback(async (slug) => {
try {
setState({ loading: false, forum: await api.teamForumThreads(slug) })
} catch {
setState({ loading: false, forum: null })
}
}, [])
useEffect(() => {
let active = true
// An anonymous visitor has no forum by definition — every route is behind
// requireAuth — so skip the two calls rather than provoking a 401 per page.
if (!externalId || !moduleId || !user || !forumsEnabled) {
setState({ loading: false, forum: null })
return undefined
}
// The module names the Team its own way; core resolves that to a slug. Same
// two-call shape as the activity feed, and for the same reason: a module
// never has to hold core's identifiers.
api.teamByExternalId(moduleId, externalId)
.then(async (found) => {
if (!active) return
setTeam(found)
await loadThreads(found.slug)
})
.catch(() => { if (active) setState({ loading: false, forum: null }) })
return () => { active = false }
}, [externalId, moduleId, user, forumsEnabled, loadThreads])
useEffect(() => {
let active = true
if (!team || !openThreadId) {
setThread(null)
return undefined
}
api.teamForumThread(team.slug, openThreadId)
.then((t) => { if (active) setThread(t) })
.catch(() => { if (active) setThread(null) })
return () => { active = false }
}, [team, openThreadId])
const openThread = (id) => {
const next = new URLSearchParams(params)
if (id == null) next.delete('thread')
else next.set('thread', String(id))
setParams(next)
}
const { loading, forum } = state
if (loading || !forum) return null
if (openThreadId && thread) {
return (
<ThreadView
thread={thread}
canModerate={forum.canModerate}
onBack={() => openThread(null)}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
openThread(null)
}}
/>
)
}
return (
<section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Announcements
</h2>
{forum.canPost && !composing && (
<button type="button" className="btn-ghost sans" onClick={() => setComposing(true)}>
Post an announcement
</button>
)}
</header>
{composing && (
<Composer
slug={team.slug}
imageMode={imageMode}
onCancel={() => setComposing(false)}
onPosted={async () => {
setComposing(false)
await loadThreads(team.slug)
}}
/>
)}
{forum.threads.length === 0 && !composing && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been announced here yet.
</p>
)}
{forum.canModerate && <GuestManager slug={team.slug} />}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
{forum.threads.map((t) => (
<li key={t.id}>
<button
type="button"
className="sans"
onClick={() => openThread(t.id)}
style={{
background: 'none', border: 0, padding: 0, cursor: 'pointer',
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
}}
>
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
<strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{t.author}
{t.status === 'hidden' && ' · hidden'}
</span>
</button>
</li>
))}
</ul>
</section>
)
}
/**
* The leader's grant control — §2.5 path 3, exercised by a leader rather than by
* staff.
*
* Worth being explicit about what this admits someone to and what it does not: a
* grant may name ANY account, including one with no linked game character, and it
* writes nothing but the grants ledger. A guest here never appears on the roster,
* never counts towards the Team's membership, and never becomes eligible for a
* Discord role — an integration cannot verify that an unlinked account is a real
* game member, so it must not hand that account a privilege somewhere
* impersonation has consequences.
*
* A leader is capped; staff are not. The cap is shown rather than only enforced,
* because a leader who hits a limit they were never told about reads it as a bug.
*/
function GuestManager({ slug }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null)
const [username, setUsername] = useState('')
const [error, setError] = useState(null)
const load = useCallback(async () => {
try {
setData(await api.teamGrantList(slug))
} catch {
setData(null)
}
}, [slug])
useEffect(() => { if (open) load() }, [open, load])
const add = async (event) => {
event.preventDefault()
setError(null)
try {
await api.teamGrantAdd(slug, { username })
setUsername('')
await load()
} catch (err) {
setError(err.message || 'Could not grant access')
}
}
const revoke = async (userId) => {
setError(null)
try {
await api.teamGrantRevoke(slug, userId)
await load()
} catch (err) {
setError(err.message || 'Could not revoke that')
}
}
if (!open) {
return (
<button type="button" className="btn-ghost sans" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
Forum guests
</button>
)
}
return (
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
<button type="button" className="btn-ghost sans" onClick={() => setOpen(false)}>Close</button>
</header>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
Guests read and post in this forum without being members of the Team. They do not appear on the
roster and are not counted as members.
{data?.cap ? ` Up to ${data.cap} at a time.` : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
{(data?.guests || []).map((g) => (
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
<span>{g.username}</span>
<button type="button" className="btn-ghost sans" onClick={() => revoke(g.userId)}>Remove</button>
</li>
))}
{data && data.guests.length === 0 && (
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
)}
</ul>
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
<input
className="sans"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Account name"
maxLength={32}
required
/>
<button type="submit" className="btn sans">Add</button>
</form>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
</section>
)
}
function ThreadView({ thread, canModerate, onBack, onModerate }) {
return (
<section style={{ marginTop: 26 }}>
<button type="button" className="btn-ghost sans" onClick={onBack} style={{ marginBottom: 10 }}>
All announcements
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title}
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.author}
{thread.authorDeleted && ' (account removed)'}
</p>
{thread.posts.map((post) => (
<article key={post.id} style={{ marginBottom: 16 }}>
{/*
Rendered server-side under the operator's image policy, which is why
this is dangerouslySetInnerHTML and not a sanitizer call here. The body
was sanitised on write with the forum's own profile — one in which
`img` is never allowed — 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.
*/}
{/* eslint-disable-next-line react/no-danger */}
<div className="serif" dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
))}
{canModerate && (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</div>
)}
</section>
)
}
function Composer({ slug, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumPost(slug, { type: 'announcement', title, body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
const attach = async (event) => {
const file = event.target.files?.[0]
if (!file) return
try {
const { url } = await api.teamForumUpload(slug, file)
// The URL goes into the BODY as text, not as an <img> tag. The author never
// writes markup here — core decides at render time whether a URL becomes a
// picture, which is what makes the operator's image policy enforceable
// rather than decorative.
setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
} catch (err) {
setError(err.message || 'Could not upload that')
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
<input
className="sans"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
maxLength={200}
required
/>
<textarea
className="sans"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your announcement. Paste an image URL on its own line to share a picture."
rows={6}
required
/>
{imageMode === 'uploads' && (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn sans" disabled={busy}>Post</button>
<button type="button" className="btn-ghost sans" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}

View File

@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx' import EmailDelivery from './EmailDelivery.jsx'
import TeamForumSettings from './TeamForumSettings.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor). // Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx')) const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
</div> </div>
</div> </div>
<TeamForumSettings />
<EmailDelivery /> <EmailDelivery />
</section> </section>
) )

View File

@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// The operator's two Team-forum controls (TEAMS.md §5.5), and the acknowledgement.
//
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
// same reason EmailDelivery is its own: one of these settings has a server-side
// PRECONDITION and a confirmation flow, and a control with a precondition inside a
// generic list of key/value inputs is one whose behaviour nobody reading that list
// would predict.
//
// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
// 'uploads'` with 400 unless the same request carries the acknowledgement version,
// and it does so whether or not this dialog was ever rendered. What is here is how
// the gate is PRESENTED — the wording an operator agrees to, and the recording of
// which version they agreed to.
// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
// explains what the setting is, which is a different job from the confirmation.
const HELP_TEXT = [
'Image uploads are disabled by default.',
'Enabling uploads allows users to store files on infrastructure that you control.',
'By enabling this feature, you acknowledge that you are responsible for:',
]
const HELP_BULLETS = [
'Moderating uploaded content',
'Managing storage and backups',
'Complying with applicable laws and regulations',
'Establishing policies for your community',
]
const HELP_TAIL = [
'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
+ ' is stored on your own infrastructure.',
// Addition 1 — the reassuring counterpart, and the reason the attribution table
// in §5.5.4 exists at all.
'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
// Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
// not the same as game membership, so this genuinely surprises.
'Anyone with access to a team forum can upload, including members granted access manually who have'
+ ' no linked game account.',
]
// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
// stored in that mode — but the operator's server is still doing the displaying.
const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitors browser directly from the'
+ ' site hosting them. That site can see your visitors IP addresses, and you do not control whether'
+ ' the image changes or disappears.'
// §5.5.5(b). Shown only when changing the mode TO uploads.
const DIALOG_CHECKS = [
'I understand that uploaded files will be stored on infrastructure that I control.',
'I understand that I am responsible for community moderation policies on this installation.',
]
// Addition 2 — the expectation gap most likely to bite. An operator who turns
// uploads off because of a problem will assume the problem goes with it.
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
+ ' already uploaded — remove those from the forum moderation tools.'
const MODES = [
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
]
export default function TeamForumSettings() {
const { refresh: refreshSite } = useSite()
const [state, setState] = useState(null)
const [enabled, setEnabled] = useState(false)
const [mode, setMode] = useState('disabled')
const [dialog, setDialog] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
const load = async () => {
try {
const s = await api.admin.teamForumSettings()
setState(s)
setEnabled(s.enabled)
setMode(s.imageMode)
} catch {
setError('Could not load forum settings.')
}
}
useEffect(() => { load() }, [])
if (!state) return null
const stale = state.acknowledgement?.stale
async function persist(next, acknowledge) {
setBusy(true)
setError('')
try {
await api.admin.updateSettings({
teams_forums_enabled: next.enabled ? '1' : '0',
teams_forum_images: next.mode,
...(acknowledge ? { acknowledge } : {}),
})
setSaved(true)
await load()
await refreshSite()
} catch (err) {
setError(err.message || 'Could not save forum settings.')
} finally {
setBusy(false)
}
}
// Moving TO uploads asks first; every other change saves directly. A stale
// acknowledgement also routes through the dialog, because re-acknowledging is
// the only thing that unfreezes these settings.
function save() {
setSaved(false)
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
setDialog({ enabled, mode })
return
}
if (stale) {
setDialog({ enabled, mode })
return
}
persist({ enabled, mode })
}
return (
<section style={{ marginTop: 34, maxWidth: 620 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
{stale && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
The image-upload notice has changed since it was accepted
{state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
Uploads keep working, but no forum setting can be saved until it is acknowledged again.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Off by default. Switching forums off hides them completely every forum route answers not
found but deletes nothing: threads, posts, access grants and notification preferences all
survive and come back exactly as they were.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Images in forum posts</span>
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</label>
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
<ul style={{ margin: '0 0 6px 18px' }}>
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
</ul>
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
{mode !== 'disabled' && (
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
)}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save forum settings'}
</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>
{dialog && (
<UploadsDialog
version={state.acknowledgement.version}
onCancel={() => { setDialog(null); setMode(state.imageMode); setEnabled(state.enabled) }}
onConfirm={async (version) => {
setDialog(null)
await persist(dialog, version)
}}
/>
)}
</section>
)
}
/**
* Two checkboxes, one recorded acknowledgement.
*
* `Enable uploads` stays disabled until both are ticked, but the request carries a
* single version and the stored value is the text VERSION. Recording two booleans
* would add nothing — there is no reachable state where an operator consented to
* one clause and not the other and proceeded anyway — while the version answers
* the question that actually matters later: which text did they agree to?
*/
function UploadsDialog({ version, onCancel, onConfirm }) {
const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
const all = checks.every(Boolean)
return (
<div
role="dialog"
aria-modal="true"
aria-label="Enable image uploads"
style={{
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
}}
>
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
Image uploads are currently disabled.
</p>
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
Enabling uploads will allow users to store files on your server.
</p>
{DIALOG_CHECKS.map((text, i) => (
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
<input
type="checkbox"
checked={checks[i]}
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
style={{ marginRight: 8 }}
/>
{text}
</label>
))}
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={!all}
onClick={() => onConfirm(version)}
>
Enable uploads
</button>
</div>
</div>
)
}