Merge pull request 'fix(admin): style the Teams admin screen with the site's own classes' (#162) from fix/teams-admin-theming into main
All checks were successful
sync-project-tree / sync (push) Successful in 10s
Build container images / build (push) Successful in 2m3s
Build container images / deploy (push) Successful in 48s
SonarQube / analysis (push) Successful in 5m20s

Reviewed-on: #162
This commit is contained in:
2026-08-19 19:35:00 +00:00
4 changed files with 345 additions and 218 deletions

View File

@@ -146,6 +146,7 @@ const TITLES = {
'/admin/moderation': 'Moderation', '/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals', '/admin/moderation/appeals': 'Appeals',
'/admin/moderation/reports': 'Reports', '/admin/moderation/reports': 'Reports',
'/admin/teams': 'Teams',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance', '/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation', '/admin/navigation': 'Navigation',

View File

@@ -20,6 +20,9 @@ import {
// gate is PRESENTED: the sentence an operator agrees to, and the fact that // gate is PRESENTED: the sentence an operator agrees to, and the fact that
// agreeing is a deliberate act rather than a checkbox they tab past. // agreeing is a deliberate act rather than a checkbox they tab past.
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
const ACK_TEXT = [ const ACK_TEXT = [
'Forum posts and announcements are visible only to a Teams members. This site cannot see who can' 'Forum posts and announcements are visible only to a Teams members. This site cannot see who can'
+ ' read a channel on another platform, so it cannot check that for you.', + ' read a channel on another platform, so it cannot check that for you.',
@@ -54,9 +57,9 @@ export default function TeamIntegrations() {
if (!config) { if (!config) {
return ( return (
<section style={{ marginTop: 34, maxWidth: 760 }}> <section className="panel" style={PANEL}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2> <h2 className="display" style={HEADING}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>} {error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
</section> </section>
) )
} }
@@ -115,67 +118,75 @@ export default function TeamIntegrations() {
} }
return ( return (
<section style={{ marginTop: 34, maxWidth: 760 }}> <section className="panel" style={PANEL}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2> <h2 className="display" style={HEADING}>Notification bridge</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}> <p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
override it for individual Teams. A message is sent once and not retried the bridge is a override it for individual Teams. A message is sent once and not retried the bridge is a
courtesy, and nothing on the site depends on it arriving. courtesy, and nothing on the site depends on it arriving.
</p> </p>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>} {error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>} {notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
{config.rows.length === 0 && !draft && ( {config.rows.length === 0 && !draft && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured no Team events leave the site.</p> <p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured no Team events leave the site.</p>
)} )}
{config.rows.length > 0 && ( {config.rows.length > 0 && (
<table className="table"> <div className="panel-flat" style={{ overflowX: 'auto' }}>
<thead> <table className="adm-table">
<tr><th>Applies to</th><th>Events</th><th>Channel</th><th>State</th><th /></tr> <thead>
</thead> <tr>
<tbody> <th className="adm-th">Applies to</th>
{config.rows.map((row) => ( <th className="adm-th">Events</th>
<tr key={rowKey(row)}> <th className="adm-th">Channel</th>
<td> <th className="adm-th">State</th>
{appliesToLabel(row)} <th className="adm-th" />
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block' }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {config.rows.map((row) => (
<tr key={rowKey(row)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="adm-td">
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="adm-td">
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
{!draft && ( {!draft && (
<div style={{ marginTop: 12 }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
{!hasDefault && ( {!hasDefault && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(null))}> <button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(null))}>
Set a default for all Teams Set a default for all Teams
</button> </button>
)} )}
{available.length > 0 && ( {available.length > 0 && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(available[0].id))}> <button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(available[0].id))}>
Add a per-Team override Add a per-Team override
</button> </button>
)} )}
@@ -183,7 +194,7 @@ export default function TeamIntegrations() {
)} )}
{draft && ( {draft && (
<div style={{ marginTop: 18, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}> <div style={{ marginTop: 18, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
<label style={{ display: 'block', marginBottom: 12 }}> <label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Applies to</span> <span className="field-label">Applies to</span>
<select <select
@@ -246,34 +257,34 @@ export default function TeamIntegrations() {
{draft.membersAck && ( {draft.membersAck && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}> <p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
You have confirmed this channel is restricted to the Teams members.{' '} You have confirmed this channel is restricted to the Teams members.{' '}
<button type="button" className="btn-ghost" onClick={() => setDraft({ ...draft, membersAck: false })}> <button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft({ ...draft, membersAck: false })}>
Withdraw Withdraw
</button> </button>
</p> </p>
)} )}
<div style={{ marginTop: 16 }}> <div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button> <button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button> <button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
</div> </div>
</div> </div>
)} )}
{dialog && ( {dialog && (
<div style={{ marginTop: 18, border: '1px solid #e0b877', padding: 14, borderRadius: 4 }}> <div style={{ marginTop: 18, border: '1px solid #e0b070', padding: 16, borderRadius: 'var(--radius-input)' }}>
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destinations audience</h3> <h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destinations audience</h3>
{ACK_TEXT.map((line) => ( {ACK_TEXT.map((line) => (
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p> <p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
))} ))}
<button <button
type="button" type="button"
className="btn" className="btn btn-primary btn-sq"
disabled={busy} disabled={busy}
onClick={() => persist({ ...dialog, membersAck: true })} onClick={() => persist({ ...dialog, membersAck: true })}
> >
I confirm the channel is members-only I confirm the channel is members-only
</button> </button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDialog(null)}>Cancel</button> <button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
</div> </div>
)} )}
</section> </section>

View File

@@ -19,6 +19,9 @@ import {
// lacks Manage Roles otherwise has a screen full of controls that cannot work, // lacks Manage Roles otherwise has a screen full of controls that cannot work,
// and finds out one Team at a time from a column of identical errors. // and finds out one Team at a time from a column of identical errors.
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
export default function TeamVoice() { export default function TeamVoice() {
const [config, setConfig] = useState(null) const [config, setConfig] = useState(null)
const [draft, setDraft] = useState(null) const [draft, setDraft] = useState(null)
@@ -50,9 +53,9 @@ export default function TeamVoice() {
if (!config || !draft) { if (!config || !draft) {
return ( return (
<section style={{ marginTop: 34, maxWidth: 760 }}> <section className="panel" style={PANEL}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2> <h2 className="display" style={HEADING}>Voice channels</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>} {error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
</section> </section>
) )
} }
@@ -120,8 +123,8 @@ export default function TeamVoice() {
} }
return ( return (
<section style={{ marginTop: 34, maxWidth: 760 }}> <section className="panel" style={PANEL}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2> <h2 className="display" style={HEADING}>Voice channels</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}> <p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Give each Team a {config.platform} voice channel of its own. Access is granted with a role per Give each Team a {config.platform} voice channel of its own. Access is granted with a role per
Team, so members of a Team can see and join their channel and nobody else can. Members need a Team, so members of a Team can see and join their channel and nobody else can. Members need a
@@ -129,7 +132,7 @@ export default function TeamVoice() {
</p> </p>
{blocked && ( {blocked && (
<p className="sans" style={{ color: '#e0b877', fontSize: '0.82rem' }}> <p className="sans" style={{ color: '#e0b070', fontSize: '0.82rem' }}>
{blocked} Voice channels cannot be switched on until that is fixed. {blocked} Voice channels cannot be switched on until that is fixed.
</p> </p>
)} )}
@@ -145,12 +148,12 @@ export default function TeamVoice() {
</p> </p>
)} )}
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>} {error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>} {notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p> <p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
<div style={{ marginTop: 14, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}> <div style={{ marginTop: 14, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}> <label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
<input <input
type="checkbox" type="checkbox"
@@ -210,39 +213,53 @@ export default function TeamVoice() {
</span> </span>
</label> </label>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button type="button" className="btn-ghost" disabled={busy} onClick={runPass}>Sync now</button> <button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={runPass}>Sync now</button>
</div>
</div> </div>
{config.rows.length > 0 && ( {config.rows.length > 0 && (
<table className="table" style={{ marginTop: 18 }}> <div className="panel-flat" style={{ marginTop: 18, overflowX: 'auto' }}>
<thead> <table className="adm-table">
<tr><th>Team</th><th>Members</th><th>Channel</th><th>State</th><th /></tr> <thead>
</thead> <tr>
<tbody> <th className="adm-th">Team</th>
{config.rows.map((row) => ( <th className="adm-th">Members</th>
<tr key={row.teamId}> <th className="adm-th">Channel</th>
<td>{row.teamName}</td> <th className="adm-th">State</th>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.memberCount}</td> <th className="adm-th" />
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.channelRef || <span className="dim">none</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{stateLabel(row.state)}
{removalCountdown(row) && (
<span className="dim" style={{ display: 'block' }}>{removalCountdown(row)}</span>
)}
{row.lastError && (
<span style={{ display: 'block', color: '#e08b77' }}>{row.lastError}</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {config.rows.map((row) => (
<tr key={row.teamId}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.teamName}</td>
<td className="adm-td">{row.memberCount}</td>
<td className="adm-td dim">
{row.channelRef || <span className="dim">none</span>}
</td>
<td className="adm-td">
{stateLabel(row.state)}
{removalCountdown(row) && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
{removalCountdown(row)}
</span>
)}
{row.lastError && (
<span style={{ display: 'block', color: '#d98b84', fontSize: '0.78rem', marginTop: 3 }}>
{row.lastError}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
{config.lastPass && config.lastPass.at && ( {config.lastPass && config.lastPass.at && (

View File

@@ -24,17 +24,24 @@ import TeamVoice from './TeamVoice.jsx'
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is // Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
// plain JS and has tests; this file renders it. // plain JS and has tests; this file renders it.
const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' } // Tones map onto the badge modifiers the rest of the admin panel already uses,
// rather than onto inline colours. `.badge` on its own carries no border or
// background — those live on the modifier — so a bare `className="badge"` with an
// inline `borderColor` renders borderless, which is what this screen used to do.
const TONE_BADGE = { ok: 'badge-pub', warn: 'badge-moderator', bad: 'badge-ban', idle: 'badge-draft' }
// The same three tones as text, for the places a badge would be wrong (a verbatim
// error line). House palette — the values every other admin view uses.
const TONE_TEXT = { ok: '#7fd0a4', warn: '#e0b070', bad: '#d98b84', idle: 'var(--muted)' }
const PANEL = { padding: 22, marginBottom: 22 }
const HEADING = { margin: '0 0 12px', fontSize: '1.2rem', color: 'var(--head)' }
const KV_VALUE = { margin: 0, fontSize: '0.88rem', color: 'var(--text)' }
const SCROLLER = { overflowX: 'auto' }
const BLURB = { margin: '0 0 14px', color: 'var(--muted)', fontSize: '0.85rem', lineHeight: 1.6 }
function Pill({ tone, children }) { function Pill({ tone, children }) {
return ( return <span className={`badge ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
<span
className="badge"
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
{children}
</span>
)
} }
// ── Sync state ───────────────────────────────────────────────────────────── // ── Sync state ─────────────────────────────────────────────────────────────
@@ -42,34 +49,48 @@ function Pill({ tone, children }) {
function SyncPanel({ sync, syncState, onResync, busy }) { function SyncPanel({ sync, syncState, onResync, busy }) {
const freshness = freshnessOf(sync) const freshness = freshnessOf(sync)
return ( return (
<section className="panel" style={{ marginBottom: '1.5rem' }}> <section className="panel" style={PANEL}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>Sync</h2> <h2 className="display" style={{ ...HEADING, margin: 0 }}>Sync</h2>
<Pill tone={freshness.tone}>{freshness.label}</Pill> <Pill tone={freshness.tone}>{freshness.label}</Pill>
<button type="button" className="btn" onClick={onResync} disabled={busy || !sync.configured}> <button
type="button"
className="btn btn-ghost btn-sq"
onClick={onResync}
disabled={busy || !sync.configured}
>
{busy ? 'Resyncing…' : 'Resync now'} {busy ? 'Resyncing…' : 'Resync now'}
</button> </button>
</div> </div>
<p className="muted" style={{ marginTop: '.5rem' }}>{freshness.detail}</p> <p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.85rem' }}>{freshness.detail}</p>
{syncState && ( {syncState && (
<dl className="kv" style={{ marginTop: '.75rem' }}> <dl
<dt>Module</dt><dd>{syncState.moduleId}</dd> style={{
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd> display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '9px 20px',
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd> margin: '16px 0 0', alignItems: 'baseline',
<dt>Consecutive failures</dt><dd>{syncState.consecutiveFailures}</dd> }}
>
<dt className="field-label" style={{ margin: 0 }}>Module</dt>
<dd className="sans" style={KV_VALUE}>{syncState.moduleId}</dd>
<dt className="field-label" style={{ margin: 0 }}>Last attempt</dt>
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
<dt className="field-label" style={{ margin: 0 }}>Last success</dt>
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
<dt className="field-label" style={{ margin: 0 }}>Consecutive failures</dt>
<dd className="sans" style={KV_VALUE}>{syncState.consecutiveFailures}</dd>
{syncState.lastError && ( {syncState.lastError && (
<> <>
{/* Verbatim. An operator debugging a stale projection needs what the {/* Verbatim. An operator debugging a stale projection needs what the
provider actually said, not a friendlier paraphrase of it. */} provider actually said, not a friendlier paraphrase of it. */}
<dt>Last error</dt> <dt className="field-label" style={{ margin: 0 }}>Last error</dt>
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd> <dd className="sans" style={{ ...KV_VALUE, color: TONE_TEXT.bad }}>{syncState.lastError}</dd>
</> </>
)} )}
{syncState.pendingEmptySince && ( {syncState.pendingEmptySince && (
<> <>
<dt>Empty answer held</dt> <dt className="field-label" style={{ margin: 0 }}>Empty answer held</dt>
<dd> <dd className="sans" style={KV_VALUE}>
since {dateTime(syncState.pendingEmptySince)} an authoritative but empty list is since {dateTime(syncState.pendingEmptySince)} an authoritative but empty list is
applied only if the next answer agrees. applied only if the next answer agrees.
</dd> </dd>
@@ -86,32 +107,45 @@ function SyncPanel({ sync, syncState, onResync, busy }) {
function ReviewQueue({ rows, role, onAct, busy }) { function ReviewQueue({ rows, role, onAct, busy }) {
if (!rows.length) return null if (!rows.length) return null
return ( return (
<section className="panel" style={{ marginBottom: '1.5rem' }}> <section className="panel" style={PANEL}>
<h2>Names to review</h2> <h2 className="display" style={HEADING}>Names to review</h2>
<p className="muted"> <p className="sans" style={BLURB}>
These Teams are hidden from every public surface because their name matched a reserved term. These Teams are hidden from every public surface because their name matched a reserved term.
They work normally for their own members. {GATED_NOTE} They work normally for their own members. {GATED_NOTE}
</p> </p>
<table className="table"> <div className="panel-flat" style={SCROLLER}>
<thead> <table className="adm-table">
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr> <thead>
</thead> <tr>
<tbody> <th className="adm-th">Name</th>
{rows.map((row) => ( <th className="adm-th">Matched</th>
<tr key={row.id}> <th className="adm-th">Members</th>
<td>{row.name}</td> <th className="adm-th">Created</th>
<td><Pill tone="bad">{row.hidden_term}</Pill></td> <th className="adm-th" />
<td>{row.member_count}</td>
<td>{dateTime(row.created_at)}</td>
<td>
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {rows.map((row) => (
<tr key={row.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.name}</td>
<td className="adm-td"><Pill tone="bad">{row.hidden_term}</Pill></td>
<td className="adm-td">{row.member_count}</td>
<td className="adm-td dim">{dateTime(row.created_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(row.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section> </section>
) )
} }
@@ -122,32 +156,55 @@ function RequestQueue({ rows, role, onDecide, busy }) {
if (!rows.length) return null if (!rows.length) return null
const canDecide = role === 'admin' const canDecide = role === 'admin'
return ( return (
<section className="panel" style={{ marginBottom: '1.5rem' }}> <section className="panel" style={PANEL}>
<h2>Awaiting approval</h2> <h2 className="display" style={HEADING}>Awaiting approval</h2>
<p className="muted"> <p className="sans" style={BLURB}>
{canDecide {canDecide
? 'Approving publishes the name; rejecting keeps the record and changes nothing.' ? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
: 'Only an admin can decide these. Your own requests stay here until one does.'} : 'Only an admin can decide these. Your own requests stay here until one does.'}
</p> </p>
<ul className="list"> <div className="panel-flat" style={SCROLLER}>
{rows.map((row) => ( <table className="adm-table">
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}> <thead>
<span>{describeRequest(row)}</span> <tr>
<span className="muted">{dateTime(row.requested_at)}</span> <th className="adm-th">Request</th>
{row.reason && <span className="muted">{row.reason}</span>} <th className="adm-th">Requested</th>
{canDecide && ( <th className="adm-th">Reason</th>
<> {canDecide && <th className="adm-th" />}
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}> </tr>
Approve </thead>
</button> <tbody>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}> {rows.map((row) => (
Reject <tr key={row.id}>
</button> <td className="adm-td" style={{ color: 'var(--head)' }}>{describeRequest(row)}</td>
</> <td className="adm-td dim">{dateTime(row.requested_at)}</td>
)} <td className="adm-td dim">{row.reason ? `${row.reason}` : '—'}</td>
</li> {canDecide && (
))} <td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
</ul> <button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onDecide(row.id, 'approved')}
>
Approve
</button>
<button
type="button"
className="btn btn-ghost btn-sq"
style={{ marginLeft: 8 }}
disabled={busy}
onClick={() => onDecide(row.id, 'rejected')}
>
Reject
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</section> </section>
) )
} }
@@ -158,32 +215,47 @@ function TeamRow({ team, role, onAct, busy, onLedger }) {
const status = statusOf(team) const status = statusOf(team)
return ( return (
<tr> <tr>
<td> <td className="adm-td" style={{ color: 'var(--head)' }}>
{team.displayName} {team.displayName}
{team.displayNameOverride && ( {team.displayNameOverride && (
<div className="muted" style={{ fontSize: '.85em' }}> <div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
shown instead of {team.name} shown instead of {team.name}
</div> </div>
)} )}
</td> </td>
<td><Pill tone={status.tone}>{status.label}</Pill></td> <td className="adm-td"><Pill tone={status.tone}>{status.label}</Pill></td>
<td>{team.memberCount}</td> <td className="adm-td">{team.memberCount}</td>
<td>{team.linkedCount}</td> <td className="adm-td">{team.linkedCount}</td>
<td>{team.onlineCount}</td> <td className="adm-td">{team.onlineCount}</td>
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td> <td className="adm-td dim">{dateTime(team.rosterSyncedAt) || 'never'}</td>
<td> <td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{team.status === 'active' && (team.hidden {team.status === 'active' && (team.hidden
? ( ? (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'unhide')}> <button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')} {gateLabelFor(role, 'Publish')}
</button> </button>
) )
: ( : (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'hide')}> <button
type="button"
className="btn btn-ghost btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'hide')}
>
Hide Hide
</button> </button>
))} ))}
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}> <button
type="button"
className="btn btn-ghost btn-sq"
onClick={() => onLedger(team)}
style={{ marginLeft: 8 }}
>
Forum log Forum log
</button> </button>
</td> </td>
@@ -225,37 +297,51 @@ function ForumLedger({ team, onClose }) {
}, [team.id]) }, [team.id])
return ( return (
<section className="panel"> <section className="panel" style={PANEL}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}> <header
<h2>Forum log {team.displayName}</h2> style={{
<button type="button" className="btn" onClick={onClose}>Close</button> display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: 14, flexWrap: 'wrap', marginBottom: 12,
}}
>
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Forum log {team.displayName}</h2>
<button type="button" className="btn btn-ghost btn-sq" onClick={onClose}>Close</button>
</header> </header>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!rows && !error && <Loading />} {!rows && !error && <Loading />}
{rows && rows.length === 0 && <p className="muted">Nothing has been moderated in this forum.</p>} {rows && rows.length === 0 && (
<p className="sans" style={{ ...BLURB, margin: 0 }}>Nothing has been moderated in this forum.</p>
)}
{rows && rows.length > 0 && ( {rows && rows.length > 0 && (
<table className="table"> <div className="panel-flat" style={SCROLLER}>
<thead> <table className="adm-table">
<tr> <thead>
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th> <tr>
</tr> <th className="adm-th">When</th>
</thead> <th className="adm-th">Action</th>
<tbody> <th className="adm-th">Target</th>
{rows.map((r) => ( <th className="adm-th">By</th>
<tr key={r.id}> <th className="adm-th">As</th>
<td className="muted">{dateTime(r.created_at)}</td> <th className="adm-th">Reason</th>
<td>{r.action}</td>
<td className="muted">{r.target_type} #{r.target_id}</td>
<td>{r.actor_username || '—'}</td>
<td>
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="muted">{r.reason || '—'}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {rows.map((r) => (
<tr key={r.id}>
<td className="adm-td dim">{dateTime(r.created_at)}</td>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
<td className="adm-td">{r.actor_username || '—'}</td>
<td className="adm-td">
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="adm-td dim">{r.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</section> </section>
) )
@@ -337,9 +423,14 @@ export default function TeamsAdmin() {
return ( return (
<div> <div>
<h1>Teams</h1> {/* No page <h1>: AdminLayout's topbar already titles the page, as it does for
every other admin screen. This one used to render its own, which is why
"Teams" appeared twice — once in Cinzel in the bar and once in the body
in whatever the UA picked for an unstyled heading. */}
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{notice && <p className="notice">{notice}</p>} {notice && (
<div className="note sans" style={{ fontSize: '0.85rem', marginBottom: 22 }}>{notice}</div>
)}
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />} {ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
@@ -353,36 +444,43 @@ export default function TeamsAdmin() {
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} /> <ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} /> <RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
<section className="panel"> <section className="panel" style={PANEL}>
<h2>All Teams</h2> <h2 className="display" style={HEADING}>All Teams</h2>
{!data.teams.length && ( {!data.teams.length && (
<p className="muted"> <p className="sans" style={{ ...BLURB, margin: 0 }}>
{data.configured {data.configured
? 'No Teams in the projection yet.' ? 'No Teams in the projection yet.'
: 'No installed module supplies Teams, so there is nothing to show.'} : 'No installed module supplies Teams, so there is nothing to show.'}
</p> </p>
)} )}
{data.teams.length > 0 && ( {data.teams.length > 0 && (
<table className="table"> <div className="panel-flat" style={SCROLLER}>
<thead> <table className="adm-table">
<tr> <thead>
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th> <tr>
<th>Roster confirmed</th><th /> <th className="adm-th">Name</th>
</tr> <th className="adm-th">Status</th>
</thead> <th className="adm-th">Members</th>
<tbody> <th className="adm-th">Linked</th>
{data.teams.map((team) => ( <th className="adm-th">Online</th>
<TeamRow <th className="adm-th">Roster confirmed</th>
key={team.id} <th className="adm-th" />
team={team} </tr>
role={role} </thead>
onAct={act} <tbody>
busy={busy} {data.teams.map((team) => (
onLedger={setLedgerTeam} <TeamRow
/> key={team.id}
))} team={team}
</tbody> role={role}
</table> onAct={act}
busy={busy}
onLedger={setLedgerTeam}
/>
))}
</tbody>
</table>
</div>
)} )}
</section> </section>
</div> </div>