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
274 lines
10 KiB
JavaScript
274 lines
10 KiB
JavaScript
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 Team’s 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>
|
||
)
|
||
}
|