"use client"; import { useMemo, useState } from "react"; import { c2api } from "@/lib/c2api"; import { type DiscordAdminOverview, type DiscordStream, type RadioBot, SNOWFLAKE, guildLabel, parseC2Error, shortId, streamHealth, } from "@/lib/discord"; import { Button } from "@/components/ui/Button"; import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui"; // Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go // through the same routes the Discord bot uses (POST /discord/streams, // POST /discord/guilds/{id}/stop), which accept a platform-admin token. // Remove is DELETE /discord/streams/{id} (admin): c2-core stops the channel // through desired state first, then deletes it. type Invite = { name: string; invite_url: string | null }[]; function AddChannelForm({ overview, bots, onDone, onCancel, }: { overview: DiscordAdminOverview; bots: RadioBot[]; onDone: () => void; onCancel: () => void; }) { // Only servers that act for an org can carry radio (start_stream refuses the rest). const guilds = overview.guilds.filter((g) => g.acting_org_id); const [guildId, setGuildId] = useState(guilds[0]?.guild_id ?? ""); const [channelId, setChannelId] = useState(""); const [channelName, setChannelName] = useState(""); const [mode, setMode] = useState<"persistent" | "adhoc">("persistent"); const [systemId, setSystemId] = useState(""); const [botId, setBotId] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState<{ message: string; invite: Invite } | null>(null); const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null; const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org); const usableBots = bots.filter((b) => b.valid !== false && (b.org_id == null || b.org_id === org)); async function submit(e: React.FormEvent) { e.preventDefault(); if (!SNOWFLAKE.test(channelId.trim())) { setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [] }); return; } setSaving(true); setError(null); try { await c2api.startDiscordStream({ guild_id: guildId, channel_id: channelId.trim(), system_id: systemId, mode, channel_name: channelName.trim() || undefined, preferred_token_id: botId || undefined, }); onDone(); } catch (err) { setError(parseC2Error(err)); } finally { setSaving(false); } } if (!guilds.length) { return (
No Discord server acts for an organization yet. Link one under Servers first.
); } return (
setChannelId(e.target.value)} required inputMode="numeric" className={`${INPUT} font-mono`} /> setChannelName(e.target.value)} className={INPUT} placeholder="County Dispatch" /> {error && (
{error.message}
)}
); } function source(s: DiscordStream) { const sys = s.system_name || s.system_id || "—"; return s.node_name ? `${sys} · ${s.node_name}` : sys; } export function StreamsPanel({ overview, bots, onChanged, }: { overview: DiscordAdminOverview; bots: RadioBot[]; onChanged: () => Promise | void; }) { const [adding, setAdding] = useState(false); const [busy, setBusy] = useState(null); const [rowError, setRowError] = useState<{ id: string; message: string; invite: Invite } | null>(null); const guildsById = useMemo(() => new Map(overview.guilds.map((g) => [g.guild_id, g])), [overview.guilds]); async function act(s: DiscordStream, fn: () => Promise) { setBusy(s.stream_id); setRowError(null); try { await fn(); await onChanged(); } catch (err) { setRowError({ id: s.stream_id, ...parseC2Error(err) }); } finally { setBusy(null); } } return ( setAdding((v) => !v)}>+ Add radio channel } note={ <> Every talkgroup the node follows is played. A per-channel talkgroup filter is a later phase (design-canvas/discord-system-notes.md, phase 2). A channel that stays desired-on without connecting for 2 minutes turns red here. } > {adding && ( setAdding(false)} onDone={async () => { setAdding(false); await onChanged(); }} /> )} {overview.streams.length === 0 ? (

No radio channels yet. Start one here, or run /join in a linked Discord server.

) : ( {overview.streams.map((s) => { const { health, label } = streamHealth(s); const g = guildsById.get(s.guild_id); const err = rowError?.id === s.stream_id ? rowError : null; const isOn = s.desired === "on"; return ( ); })}
{s.channel_name ? `🔊 ${s.channel_name}` : `channel ${shortId(s.channel_id)}`}
{g ? guildLabel(g) : guildLabel({ guild_id: s.guild_id, guild_name: s.guild_name })}
{source(s)} All talkgroups {s.mode === "adhoc" ? "ad-hoc" : s.mode ?? "—"} {label} {isOn && s.actual?.error && (
{s.actual.error} {s.actual.code && [{s.actual.code}]}
)} {!isOn && s.stop_reason &&
{s.stop_reason}
} {err && (
{err.message}
)}
{s.token_name ?? —} {isOn ? ( ) : ( )}
)}
); }