fix(admin): style the Teams admin screen with the site's own classes
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m34s

The Teams admin screen was written against four CSS classes that do not
exist anywhere in the project — `.table`, `.kv`, `.list` and `.notice` —
and against `.btn-ghost` / `.btn` used without the `.btn` box they depend
on. The result rendered as unstyled UA tables and bare browser buttons
sitting flush against unpadded panels, and looked nothing like the rest
of the admin panel.

Nothing here changes behaviour, data or routes; it is presentation only.

- Tables become `adm-table` / `adm-th` / `adm-td` inside `panel-flat`,
  the markup the other fourteen admin views use, and scroll rather than
  clip when a row is wider than the shell (a status badge and the action
  buttons are both nowrap by design, so a narrow viewport can always
  overflow one).
- Buttons take the full `btn btn-primary btn-sq` / `btn btn-ghost btn-sq`
  triplet. `.btn` carries the padding, border and radius; the variants
  carry only colour, so a bare `.btn-ghost` had none of the box and a
  bare `.btn` fell back to the UA's light button face.
- `.panel` supplies no padding, so every panel now sets it explicitly at
  22px, as ModulesAdmin and EmptyState already do.
- Headings become `h2.display`, and the in-page `<h1>Teams</h1>` goes
  away in favour of AdminLayout's topbar title — which needed
  `/admin/teams` adding to TITLES, the reason the bar read "ADMIN".
- Status pills use the existing `badge-pub` / `badge-moderator` /
  `badge-ban` / `badge-draft` modifiers. `.badge` alone declares no
  border, so the old inline `borderColor` was inert.
- The bridge and voice panels drop their private palette
  (`#e08b77` / `#8fbf7a` / `#e0b877`) for the site's
  `#d98b84` / `#7fd0a4` / `#e0b070`, and a literal `rgba(255,255,255,.12)`
  rule and a `borderRadius: 4` for `var(--line-soft)` and
  `var(--radius-input)`.

Walked live against the dev DB as an admin: sync panel, review queue, all
Teams, the forum ledger, the bridge draft form and its acknowledgement
dialog. Client 288/288, server 1162/1162, client build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
This commit is contained in:
2026-08-19 14:03:01 -05:00
parent 963d734dcc
commit 68f038f456
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' }}>
<table className="adm-table">
<thead> <thead>
<tr><th>Applies to</th><th>Events</th><th>Channel</th><th>State</th><th /></tr> <tr>
<th className="adm-th">Applies to</th>
<th className="adm-th">Events</th>
<th className="adm-th">Channel</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead> </thead>
<tbody> <tbody>
{config.rows.map((row) => ( {config.rows.map((row) => (
<tr key={rowKey(row)}> <tr key={rowKey(row)}>
<td> <td className="adm-td" style={{ color: 'var(--head)' }}>
{appliesToLabel(row)} {appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>} {isDefaultRow(row) && <span className="dim"> (default)</span>}
</td> </td>
<td className="sans" style={{ fontSize: '0.76rem' }}> <td className="adm-td">
{row.events.length === 0 {row.events.length === 0
? <span className="dim">none</span> ? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')} : row.events.map(eventLabel).join(', ')}
</td> </td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.channel_ref || <span className="dim">unset</span>}</td> <td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}> <td className="adm-td">
{row.enabled ? 'Enabled' : 'Disabled'} {row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && ( {row.members_ack && (
<span className="dim" style={{ display: 'block' }}> <span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
members-only destination confirmed members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''} {row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span> </span>
)} )}
</td> </td>
<td> <td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button> <button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button> <button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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' }}>
<table className="adm-table">
<thead> <thead>
<tr><th>Team</th><th>Members</th><th>Channel</th><th>State</th><th /></tr> <tr>
<th className="adm-th">Team</th>
<th className="adm-th">Members</th>
<th className="adm-th">Channel</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead> </thead>
<tbody> <tbody>
{config.rows.map((row) => ( {config.rows.map((row) => (
<tr key={row.teamId}> <tr key={row.teamId}>
<td>{row.teamName}</td> <td className="adm-td" style={{ color: 'var(--head)' }}>{row.teamName}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.memberCount}</td> <td className="adm-td">{row.memberCount}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}> <td className="adm-td dim">
{row.channelRef || <span className="dim">none</span>} {row.channelRef || <span className="dim">none</span>}
</td> </td>
<td className="sans" style={{ fontSize: '0.76rem' }}> <td className="adm-td">
{stateLabel(row.state)} {stateLabel(row.state)}
{removalCountdown(row) && ( {removalCountdown(row) && (
<span className="dim" style={{ display: 'block' }}>{removalCountdown(row)}</span> <span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
{removalCountdown(row)}
</span>
)} )}
{row.lastError && ( {row.lastError && (
<span style={{ display: 'block', color: '#e08b77' }}>{row.lastError}</span> <span style={{ display: 'block', color: '#d98b84', fontSize: '0.78rem', marginTop: 3 }}>
{row.lastError}
</span>
)} )}
</td> </td>
<td> <td className="adm-td" style={{ textAlign: 'right' }}>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button> <button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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,25 +107,37 @@ 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}>
<table className="adm-table">
<thead> <thead>
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr> <tr>
<th className="adm-th">Name</th>
<th className="adm-th">Matched</th>
<th className="adm-th">Members</th>
<th className="adm-th">Created</th>
<th className="adm-th" />
</tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.id}> <tr key={row.id}>
<td>{row.name}</td> <td className="adm-td" style={{ color: 'var(--head)' }}>{row.name}</td>
<td><Pill tone="bad">{row.hidden_term}</Pill></td> <td className="adm-td"><Pill tone="bad">{row.hidden_term}</Pill></td>
<td>{row.member_count}</td> <td className="adm-td">{row.member_count}</td>
<td>{dateTime(row.created_at)}</td> <td className="adm-td dim">{dateTime(row.created_at)}</td>
<td> <td className="adm-td" style={{ textAlign: 'right' }}>
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}> <button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(row.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')} {gateLabelFor(role, 'Publish')}
</button> </button>
</td> </td>
@@ -112,6 +145,7 @@ function ReviewQueue({ rows, role, onAct, busy }) {
))} ))}
</tbody> </tbody>
</table> </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}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Request</th>
<th className="adm-th">Requested</th>
<th className="adm-th">Reason</th>
{canDecide && <th className="adm-th" />}
</tr>
</thead>
<tbody>
{rows.map((row) => ( {rows.map((row) => (
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}> <tr key={row.id}>
<span>{describeRequest(row)}</span> <td className="adm-td" style={{ color: 'var(--head)' }}>{describeRequest(row)}</td>
<span className="muted">{dateTime(row.requested_at)}</span> <td className="adm-td dim">{dateTime(row.requested_at)}</td>
{row.reason && <span className="muted">{row.reason}</span>} <td className="adm-td dim">{row.reason ? `${row.reason}` : '—'}</td>
{canDecide && ( {canDecide && (
<> <td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}> <button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onDecide(row.id, 'approved')}
>
Approve Approve
</button> </button>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}> <button
type="button"
className="btn btn-ghost btn-sq"
style={{ marginLeft: 8 }}
disabled={busy}
onClick={() => onDecide(row.id, 'rejected')}
>
Reject Reject
</button> </button>
</> </td>
)} )}
</li> </tr>
))} ))}
</ul> </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}>
<table className="adm-table">
<thead> <thead>
<tr> <tr>
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th> <th className="adm-th">When</th>
<th className="adm-th">Action</th>
<th className="adm-th">Target</th>
<th className="adm-th">By</th>
<th className="adm-th">As</th>
<th className="adm-th">Reason</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => ( {rows.map((r) => (
<tr key={r.id}> <tr key={r.id}>
<td className="muted">{dateTime(r.created_at)}</td> <td className="adm-td dim">{dateTime(r.created_at)}</td>
<td>{r.action}</td> <td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
<td className="muted">{r.target_type} #{r.target_id}</td> <td className="adm-td dim">{r.target_type} #{r.target_id}</td>
<td>{r.actor_username || '—'}</td> <td className="adm-td">{r.actor_username || '—'}</td>
<td> <td className="adm-td">
{/* The distinction the whole ledger exists to preserve. */} {/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill> <Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td> </td>
<td className="muted">{r.reason || '—'}</td> <td className="adm-td dim">{r.reason || '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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,21 +444,27 @@ 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}>
<table className="adm-table">
<thead> <thead>
<tr> <tr>
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th> <th className="adm-th">Name</th>
<th>Roster confirmed</th><th /> <th className="adm-th">Status</th>
<th className="adm-th">Members</th>
<th className="adm-th">Linked</th>
<th className="adm-th">Online</th>
<th className="adm-th">Roster confirmed</th>
<th className="adm-th" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -383,6 +480,7 @@ export default function TeamsAdmin() {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section> </section>
</div> </div>