From 68f038f456d08d0854861f0f893594d33483e3e9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 14:03:01 -0500 Subject: [PATCH] fix(admin): style the Teams admin screen with the site's own classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `

Teams

` 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 Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz --- client/src/routes/admin/AdminLayout.jsx | 1 + .../routes/admin/views/TeamIntegrations.jsx | 113 +++--- client/src/routes/admin/views/TeamVoice.jsx | 93 +++-- client/src/routes/admin/views/TeamsAdmin.jsx | 356 +++++++++++------- 4 files changed, 345 insertions(+), 218 deletions(-) diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index db4def1..55b5f2c 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -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', diff --git a/client/src/routes/admin/views/TeamIntegrations.jsx b/client/src/routes/admin/views/TeamIntegrations.jsx index 5f89e69..107690c 100644 --- a/client/src/routes/admin/views/TeamIntegrations.jsx +++ b/client/src/routes/admin/views/TeamIntegrations.jsx @@ -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 ( -
-

Notification bridge

- {error &&

{error}

} +
+

Notification bridge

+ {error &&

{error}

}
) } @@ -115,67 +118,75 @@ export default function TeamIntegrations() { } return ( -
-

Notification bridge

+
+

Notification bridge

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.

- {error &&

{error}

} - {notice &&

{notice}

} + {error &&

{error}

} + {notice &&

{notice}

} {config.rows.length === 0 && !draft && (

Nothing configured — no Team events leave the site.

)} {config.rows.length > 0 && ( - - - - - - {config.rows.map((row) => ( - - - - - - +
+
Applies toEventsChannelState
- {appliesToLabel(row)} - {isDefaultRow(row) && (default)} - - {row.events.length === 0 - ? none - : row.events.map(eventLabel).join(', ')} - {row.channel_ref || unset} - {row.enabled ? 'Enabled' : 'Disabled'} - {row.members_ack && ( - - members-only destination confirmed - {row.members_ack_username ? ` by ${row.members_ack_username}` : ''} - - )} - - - -
+ + + + + + + - ))} - -
Applies toEventsChannelState
+ + + {config.rows.map((row) => ( + + + {appliesToLabel(row)} + {isDefaultRow(row) && (default)} + + + {row.events.length === 0 + ? none + : row.events.map(eventLabel).join(', ')} + + {row.channel_ref || unset} + + {row.enabled ? 'Enabled' : 'Disabled'} + {row.members_ack && ( + + members-only destination confirmed + {row.members_ack_username ? ` by ${row.members_ack_username}` : ''} + + )} + + + + + + + ))} + + + )} {!draft && ( -
+
{!hasDefault && ( - )} {available.length > 0 && ( - )} @@ -183,7 +194,7 @@ export default function TeamIntegrations() { )} {draft && ( -
+
- - +
+ + +
{config.rows.length > 0 && ( - - - - - - {config.rows.map((row) => ( - - - - - - +
+
TeamMembersChannelState
{row.teamName}{row.memberCount} - {row.channelRef || none} - - {stateLabel(row.state)} - {removalCountdown(row) && ( - {removalCountdown(row)} - )} - {row.lastError && ( - {row.lastError} - )} - - -
+ + + + + + + - ))} - -
TeamMembersChannelState
+ + + {config.rows.map((row) => ( + + {row.teamName} + {row.memberCount} + + {row.channelRef || none} + + + {stateLabel(row.state)} + {removalCountdown(row) && ( + + {removalCountdown(row)} + + )} + {row.lastError && ( + + {row.lastError} + + )} + + + + + + ))} + + +
)} {config.lastPass && config.lastPass.at && ( diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx index 85f8f9a..ef8a8ff 100644 --- a/client/src/routes/admin/views/TeamsAdmin.jsx +++ b/client/src/routes/admin/views/TeamsAdmin.jsx @@ -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 ( - - {children} - - ) + return {children} } // ── Sync state ───────────────────────────────────────────────────────────── @@ -42,34 +49,48 @@ function Pill({ tone, children }) { function SyncPanel({ sync, syncState, onResync, busy }) { const freshness = freshnessOf(sync) return ( -
-
-

Sync

+
+
+

Sync

{freshness.label} -
-

{freshness.detail}

+

{freshness.detail}

{syncState && ( -
-
Module
{syncState.moduleId}
-
Last attempt
{dateTime(syncState.lastAttemptAt) || 'never'}
-
Last success
{dateTime(syncState.lastSuccessAt) || 'never'}
-
Consecutive failures
{syncState.consecutiveFailures}
+
+
Module
+
{syncState.moduleId}
+
Last attempt
+
{dateTime(syncState.lastAttemptAt) || 'never'}
+
Last success
+
{dateTime(syncState.lastSuccessAt) || 'never'}
+
Consecutive failures
+
{syncState.consecutiveFailures}
{syncState.lastError && ( <> {/* Verbatim. An operator debugging a stale projection needs what the provider actually said, not a friendlier paraphrase of it. */} -
Last error
-
{syncState.lastError}
+
Last error
+
{syncState.lastError}
)} {syncState.pendingEmptySince && ( <> -
Empty answer held
-
+
Empty answer held
+
since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is applied only if the next answer agrees.
@@ -86,32 +107,45 @@ function SyncPanel({ sync, syncState, onResync, busy }) { function ReviewQueue({ rows, role, onAct, busy }) { if (!rows.length) return null return ( -
-

Names to review

-

+

+

Names to review

+

These Teams are hidden from every public surface because their name matched a reserved term. They work normally for their own members. {GATED_NOTE}

- - - - - - {rows.map((row) => ( - - - - - - +
+
NameMatchedMembersCreated
{row.name}{row.hidden_term}{row.member_count}{dateTime(row.created_at)} - -
+ + + + + + + - ))} - -
NameMatchedMembersCreated
+ + + {rows.map((row) => ( + + {row.name} + {row.hidden_term} + {row.member_count} + {dateTime(row.created_at)} + + + + + ))} + + +
) } @@ -122,32 +156,55 @@ function RequestQueue({ rows, role, onDecide, busy }) { if (!rows.length) return null const canDecide = role === 'admin' return ( -
-

Awaiting approval

-

+

+

Awaiting approval

+

{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.'}

-
    - {rows.map((row) => ( -
  • - {describeRequest(row)} - {dateTime(row.requested_at)} - {row.reason && “{row.reason}”} - {canDecide && ( - <> - - - - )} -
  • - ))} -
+
+ + + + + + + {canDecide && + + + {rows.map((row) => ( + + + + + {canDecide && ( + + )} + + ))} + +
RequestRequestedReason} +
{describeRequest(row)}{dateTime(row.requested_at)}{row.reason ? `“${row.reason}”` : '—'} + + +
+
) } @@ -158,32 +215,47 @@ function TeamRow({ team, role, onAct, busy, onLedger }) { const status = statusOf(team) return ( - + {team.displayName} {team.displayNameOverride && ( -
+
shown instead of “{team.name}”
)} - {status.label} - {team.memberCount} - {team.linkedCount} - {team.onlineCount} - {dateTime(team.rosterSyncedAt) || 'never'} - + {status.label} + {team.memberCount} + {team.linkedCount} + {team.onlineCount} + {dateTime(team.rosterSyncedAt) || 'never'} + {team.status === 'active' && (team.hidden ? ( - ) : ( - ))} - @@ -225,37 +297,51 @@ function ForumLedger({ team, onClose }) { }, [team.id]) return ( -
-
-

Forum log — {team.displayName}

- +
+
+

Forum log — {team.displayName}

+
{error && } {!rows && !error && } - {rows && rows.length === 0 &&

Nothing has been moderated in this forum.

} + {rows && rows.length === 0 && ( +

Nothing has been moderated in this forum.

+ )} {rows && rows.length > 0 && ( - - - - - - - - {rows.map((r) => ( - - - - - - - +
+
WhenActionTargetByAsReason
{dateTime(r.created_at)}{r.action}{r.target_type} #{r.target_id}{r.actor_username || '—'} - {/* The distinction the whole ledger exists to preserve. */} - {r.actor_role} - {r.reason || '—'}
+ + + + + + + + - ))} - -
WhenActionTargetByAsReason
+ + + {rows.map((r) => ( + + {dateTime(r.created_at)} + {r.action} + {r.target_type} #{r.target_id} + {r.actor_username || '—'} + + {/* The distinction the whole ledger exists to preserve. */} + {r.actor_role} + + {r.reason || '—'} + + ))} + + +
)}
) @@ -337,9 +423,14 @@ export default function TeamsAdmin() { return (
-

Teams

+ {/* No page

: 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 && } - {notice &&

{notice}

} + {notice && ( +
{notice}
+ )} {ledgerTeam && setLedgerTeam(null)} />} @@ -353,36 +444,43 @@ export default function TeamsAdmin() { -
-

All Teams

+
+

All Teams

{!data.teams.length && ( -

+

{data.configured ? 'No Teams in the projection yet.' : 'No installed module supplies Teams, so there is nothing to show.'}

)} {data.teams.length > 0 && ( - - - - - - - - {data.teams.map((team) => ( - - ))} - -
NameStatusMembersLinkedOnlineRoster confirmed -
+
+ + + + + + + + + + + + + {data.teams.map((team) => ( + + ))} + +
NameStatusMembersLinkedOnlineRoster confirmed +
+
)}