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 +
+
)}