fix(admin): style the Teams admin screen with the site's own classes
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:
@@ -146,6 +146,7 @@ const TITLES = {
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/moderation/appeals': 'Appeals',
|
||||
'/admin/moderation/reports': 'Reports',
|
||||
'/admin/teams': 'Teams',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
// 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.
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
|
||||
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
|
||||
const ACK_TEXT = [
|
||||
'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can'
|
||||
+ ' read a channel on another platform, so it cannot check that for you.',
|
||||
@@ -54,9 +57,9 @@ export default function TeamIntegrations() {
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -115,67 +118,75 @@ export default function TeamIntegrations() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
|
||||
{config.rows.length === 0 && !draft && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured — no Team events leave the site.</p>
|
||||
)}
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<table className="table">
|
||||
<div className="panel-flat" style={{ overflowX: 'auto' }}>
|
||||
<table className="adm-table">
|
||||
<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>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={rowKey(row)}>
|
||||
<td>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{appliesToLabel(row)}
|
||||
{isDefaultRow(row) && <span className="dim"> (default)</span>}
|
||||
</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>
|
||||
<td className="adm-td">
|
||||
{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' }}>
|
||||
<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' }}>
|
||||
<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>
|
||||
<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 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 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{!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
|
||||
</button>
|
||||
)}
|
||||
{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
|
||||
</button>
|
||||
)}
|
||||
@@ -183,7 +194,7 @@ export default function TeamIntegrations() {
|
||||
)}
|
||||
|
||||
{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 }}>
|
||||
<span className="field-label">Applies to</span>
|
||||
<select
|
||||
@@ -246,34 +257,34 @@ export default function TeamIntegrations() {
|
||||
{draft.membersAck && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
|
||||
You have confirmed this channel is restricted to the Team’s 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
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<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={() => { setDraft(null); setError('') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 destination’s audience</h3>
|
||||
{ACK_TEXT.map((line) => (
|
||||
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => persist({ ...dialog, membersAck: true })}
|
||||
>
|
||||
I confirm the channel is members-only
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
// 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.
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
|
||||
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
|
||||
export default function TeamVoice() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
@@ -50,9 +53,9 @@ export default function TeamVoice() {
|
||||
|
||||
if (!config || !draft) {
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Voice channels</h2>
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -120,8 +123,8 @@ export default function TeamVoice() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Voice channels</h2>
|
||||
<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
|
||||
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>
|
||||
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
@@ -145,12 +148,12 @@ export default function TeamVoice() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</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>
|
||||
|
||||
<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' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -210,39 +213,53 @@ export default function TeamVoice() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={runPass}>Sync now</button>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<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>
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<table className="table" style={{ marginTop: 18 }}>
|
||||
<div className="panel-flat" style={{ marginTop: 18, overflowX: 'auto' }}>
|
||||
<table className="adm-table">
|
||||
<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>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={row.teamId}>
|
||||
<td>{row.teamName}</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.memberCount}</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>
|
||||
<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="sans" style={{ fontSize: '0.76rem' }}>
|
||||
<td className="adm-td">
|
||||
{stateLabel(row.state)}
|
||||
{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 && (
|
||||
<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>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||||
<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 && (
|
||||
|
||||
@@ -24,17 +24,24 @@ import TeamVoice from './TeamVoice.jsx'
|
||||
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
|
||||
// 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 }) {
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
return <span className={`badge ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
|
||||
}
|
||||
|
||||
// ── Sync state ─────────────────────────────────────────────────────────────
|
||||
@@ -42,34 +49,48 @@ function Pill({ tone, children }) {
|
||||
function SyncPanel({ sync, syncState, onResync, busy }) {
|
||||
const freshness = freshnessOf(sync)
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}>
|
||||
<h2 style={{ margin: 0 }}>Sync</h2>
|
||||
<section className="panel" style={PANEL}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Sync</h2>
|
||||
<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'}
|
||||
</button>
|
||||
</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 && (
|
||||
<dl className="kv" style={{ marginTop: '.75rem' }}>
|
||||
<dt>Module</dt><dd>{syncState.moduleId}</dd>
|
||||
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
|
||||
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
|
||||
<dt>Consecutive failures</dt><dd>{syncState.consecutiveFailures}</dd>
|
||||
<dl
|
||||
style={{
|
||||
display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '9px 20px',
|
||||
margin: '16px 0 0', alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
<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 && (
|
||||
<>
|
||||
{/* Verbatim. An operator debugging a stale projection needs what the
|
||||
provider actually said, not a friendlier paraphrase of it. */}
|
||||
<dt>Last error</dt>
|
||||
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Last error</dt>
|
||||
<dd className="sans" style={{ ...KV_VALUE, color: TONE_TEXT.bad }}>{syncState.lastError}</dd>
|
||||
</>
|
||||
)}
|
||||
{syncState.pendingEmptySince && (
|
||||
<>
|
||||
<dt>Empty answer held</dt>
|
||||
<dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Empty answer held</dt>
|
||||
<dd className="sans" style={KV_VALUE}>
|
||||
since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is
|
||||
applied only if the next answer agrees.
|
||||
</dd>
|
||||
@@ -86,25 +107,37 @@ function SyncPanel({ sync, syncState, onResync, busy }) {
|
||||
function ReviewQueue({ rows, role, onAct, busy }) {
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2>Names to review</h2>
|
||||
<p className="muted">
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Names to review</h2>
|
||||
<p className="sans" style={BLURB}>
|
||||
These Teams are hidden from every public surface because their name matched a reserved term.
|
||||
They work normally for their own members. {GATED_NOTE}
|
||||
</p>
|
||||
<table className="table">
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<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>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name}</td>
|
||||
<td><Pill tone="bad">{row.hidden_term}</Pill></td>
|
||||
<td>{row.member_count}</td>
|
||||
<td>{dateTime(row.created_at)}</td>
|
||||
<td>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
|
||||
<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>
|
||||
@@ -112,6 +145,7 @@ function ReviewQueue({ rows, role, onAct, busy }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -122,32 +156,55 @@ function RequestQueue({ rows, role, onDecide, busy }) {
|
||||
if (!rows.length) return null
|
||||
const canDecide = role === 'admin'
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2>Awaiting approval</h2>
|
||||
<p className="muted">
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Awaiting approval</h2>
|
||||
<p className="sans" style={BLURB}>
|
||||
{canDecide
|
||||
? '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.'}
|
||||
</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) => (
|
||||
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span>{describeRequest(row)}</span>
|
||||
<span className="muted">{dateTime(row.requested_at)}</span>
|
||||
{row.reason && <span className="muted">“{row.reason}”</span>}
|
||||
<tr key={row.id}>
|
||||
<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>
|
||||
{canDecide && (
|
||||
<>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(row.id, 'approved')}
|
||||
>
|
||||
Approve
|
||||
</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
|
||||
</button>
|
||||
</>
|
||||
</td>
|
||||
)}
|
||||
</li>
|
||||
</tr>
|
||||
))}
|
||||
</ul>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -158,32 +215,47 @@ function TeamRow({ team, role, onAct, busy, onLedger }) {
|
||||
const status = statusOf(team)
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{team.displayName}
|
||||
{team.displayNameOverride && (
|
||||
<div className="muted" style={{ fontSize: '.85em' }}>
|
||||
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
shown instead of “{team.name}”
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td><Pill tone={status.tone}>{status.label}</Pill></td>
|
||||
<td>{team.memberCount}</td>
|
||||
<td>{team.linkedCount}</td>
|
||||
<td>{team.onlineCount}</td>
|
||||
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td>
|
||||
<td>
|
||||
<td className="adm-td"><Pill tone={status.tone}>{status.label}</Pill></td>
|
||||
<td className="adm-td">{team.memberCount}</td>
|
||||
<td className="adm-td">{team.linkedCount}</td>
|
||||
<td className="adm-td">{team.onlineCount}</td>
|
||||
<td className="adm-td dim">{dateTime(team.rosterSyncedAt) || 'never'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{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')}
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</td>
|
||||
@@ -225,37 +297,51 @@ function ForumLedger({ team, onClose }) {
|
||||
}, [team.id])
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h2>Forum log — {team.displayName}</h2>
|
||||
<button type="button" className="btn" onClick={onClose}>Close</button>
|
||||
<section className="panel" style={PANEL}>
|
||||
<header
|
||||
style={{
|
||||
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>
|
||||
{error && <ErrorState message={error} />}
|
||||
{!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 && (
|
||||
<table className="table">
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="muted">{dateTime(r.created_at)}</td>
|
||||
<td>{r.action}</td>
|
||||
<td className="muted">{r.target_type} #{r.target_id}</td>
|
||||
<td>{r.actor_username || '—'}</td>
|
||||
<td>
|
||||
<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="muted">{r.reason || '—'}</td>
|
||||
<td className="adm-td dim">{r.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
@@ -337,9 +423,14 @@ export default function TeamsAdmin() {
|
||||
|
||||
return (
|
||||
<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} />}
|
||||
{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)} />}
|
||||
|
||||
@@ -353,21 +444,27 @@ export default function TeamsAdmin() {
|
||||
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
||||
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
||||
|
||||
<section className="panel">
|
||||
<h2>All Teams</h2>
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>All Teams</h2>
|
||||
{!data.teams.length && (
|
||||
<p className="muted">
|
||||
<p className="sans" style={{ ...BLURB, margin: 0 }}>
|
||||
{data.configured
|
||||
? 'No Teams in the projection yet.'
|
||||
: 'No installed module supplies Teams, so there is nothing to show.'}
|
||||
</p>
|
||||
)}
|
||||
{data.teams.length > 0 && (
|
||||
<table className="table">
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th>
|
||||
<th>Roster confirmed</th><th />
|
||||
<th className="adm-th">Name</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -383,6 +480,7 @@ export default function TeamsAdmin() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user