Files
website/client/src/routes/admin/views/TeamVoice.jsx
wtclaude 68f038f456
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
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
2026-08-19 14:03:01 -05:00

274 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import {
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
parseStaffRoles, formatStaffRoles, statusSummary,
} from '../../../lib/teamVoice.js'
// Team voice channels (TEAMS.md §7.3, phase 9).
//
// Named for the Team concern and placed under Teams beside the notification
// bridge, for the reason that panel gives: phase 10 replaces "Discord" with
// whatever the capability registry declares, and what should change then is what
// fills this panel rather than where an operator goes to find it.
//
// **The preflight is the first thing on the page, not a diagnostic.** §7.3
// assumed the bot could manage channels and roles; nothing in this project has
// ever checked, because the operator invites the bot by hand and no invite URL
// with a permission integer exists anywhere in the tree. An operator whose bot
// 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)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError('')
try {
const cfg = await api.admin.teamVoice()
setConfig(cfg)
setDraft({
enabled: cfg.settings.enabled,
minMembers: cfg.settings.minMembers,
graceDays: cfg.settings.graceDays,
staffRoles: formatStaffRoles(cfg.settings.staffRoles),
})
} catch (err) {
// A moderator never reaches this panel — the admin nav does not render it —
// so a 403 means the role changed underneath an open tab.
setError(err.status === 403
? 'Only an admin can configure Team voice channels.'
: (err.message || 'Could not load the voice configuration.'))
}
}, [])
useEffect(() => { load() }, [load])
if (!config || !draft) {
return (
<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>
)
}
const blocked = enableBlockedReason(config.preflight)
const headroom = roleHeadroom(config.preflight)
async function save() {
const { roles, invalid } = parseStaffRoles(draft.staffRoles)
if (invalid.length > 0) {
setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`)
return
}
setBusy(true)
setError('')
setNotice('')
try {
await api.admin.saveTeamVoice({
enabled: draft.enabled,
minMembers: Number(draft.minMembers),
graceDays: Number(draft.graceDays),
staffRoles: roles,
})
setNotice('Saved.')
await load()
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
async function runPass() {
setBusy(true)
setError('')
setNotice('')
try {
const result = await api.admin.teamVoicePass()
// A pass that refused says why, and that is the useful answer far more often
// than a count is — "stale projection" and "synced 0" look identical in a
// summary and mean completely different things.
setNotice(result.ran
? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.`
: `Nothing was done: ${result.reason}`)
await load()
} catch (err) {
setError(err.message || 'Could not run a pass.')
} finally {
setBusy(false)
}
}
async function remove(row) {
setBusy(true)
setError('')
try {
await api.admin.removeTeamVoice(row.teamId)
setNotice('Removed.')
await load()
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<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
linked {config.platform} account and must be in the guild.
</p>
{blocked && (
<p className="sans" style={{ color: '#e0b070', fontSize: '0.82rem' }}>
{blocked} Voice channels cannot be switched on until that is fixed.
</p>
)}
{headroom && (
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
{headroom.used} of {headroom.cap} {config.platform} roles used in this guild
{headroom.exhausted
? ' — no room for another Team.'
: headroom.tight
? ` — room for about ${headroom.free} more Teams.`
: '.'}
</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 var(--line-soft)', paddingTop: 16 }}>
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
<input
type="checkbox"
checked={draft.enabled}
disabled={busy || (!!blocked && !draft.enabled)}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
/>
{' '}Provision voice channels for Teams
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Minimum members</span>
<input
className="input"
type="number"
min="1"
max="10000"
value={draft.minMembers}
disabled={busy}
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Every active member counts, whether or not they have linked an account.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Grace window (days)</span>
<input
className="input"
type="number"
min="0"
max="90"
value={draft.graceDays}
disabled={busy}
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
window keeps the same channel; zero removes it on the next pass.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Staff roles</span>
<input
className="input"
type="text"
value={draft.staffRoles}
disabled={busy}
placeholder="role id, role id"
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Roles that can see and join every Teams channel. Guild administrators already can, so this
is for staff who are not administrators. Leave empty if there are none.
</span>
</label>
<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 && (
<div className="panel-flat" style={{ marginTop: 18, overflowX: 'auto' }}>
<table className="adm-table">
<thead>
<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 className="adm-td" style={{ color: 'var(--head)' }}>{row.teamName}</td>
<td className="adm-td">{row.memberCount}</td>
<td className="adm-td dim">
{row.channelRef || <span className="dim">none</span>}
</td>
<td className="adm-td">
{stateLabel(row.state)}
{removalCountdown(row) && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
{removalCountdown(row)}
</span>
)}
{row.lastError && (
<span style={{ display: 'block', color: '#d98b84', fontSize: '0.78rem', marginTop: 3 }}>
{row.lastError}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{config.lastPass && config.lastPass.at && (
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
Last pass {new Date(config.lastPass.at).toLocaleString()}
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
</p>
)}
</section>
)
}