"use client"; import { useState } from "react"; import { c2api } from "@/lib/c2api"; import { type DiscordAdminOverview, type DiscordGuild, SNOWFLAKE, fmtWhen, guildLabel, parseC2Error } from "@/lib/discord"; import { Button } from "@/components/ui/Button"; import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui"; // Linked Discord servers = discord_guilds docs, plus any server that has had a // radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID). // Linking here is PUT /discord/guilds/{id}/link (platform admin, support). // Org owners link their own from Settings → Organization: "Connect a Discord // server" (a radio bot's OAuth invite, no DRB server bot needed) or a // /drb link code (needs the server bot) — server-26#213 item 1, #225. // The Bots column says which bots are in each server: the DRB server bot is // optional (server-26#225 S8), one radio bot is enough to stream. // Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then // deleted, and the server is unlinked with no fallback org. function LinkForm({ overview, guild, onDone, onCancel, }: { overview: DiscordAdminOverview; guild?: DiscordGuild; onDone: () => void; onCancel: () => void; }) { const [guildId, setGuildId] = useState(guild?.guild_id ?? ""); const [label, setLabel] = useState(guild?.guild_name ?? ""); const [orgId, setOrgId] = useState(guild?.org_id ?? guild?.acting_org_id ?? ""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function submit(e: React.FormEvent) { e.preventDefault(); if (!SNOWFLAKE.test(guildId.trim())) { setError("Server ID is the 17–20 digit number from Discord (right-click the server icon → Copy Server ID)."); return; } const actingNow = guild?.acting_org_id ?? null; if (guild && guild.live_streams > 0 && actingNow && actingNow !== orgId) { const n = guild.live_streams; if (!confirm(`${guildLabel(guild)} has ${n} live radio channel${n === 1 ? "" : "s"} on the current organization's nodes. Moving it stops ${n === 1 ? "it" : "them"}. Continue?`)) return; } setSaving(true); setError(null); try { await c2api.linkDiscordGuild(guildId.trim(), { org_id: orgId, guild_name: label.trim() || undefined }); onDone(); } catch (err) { setError(parseC2Error(err).message); } finally { setSaving(false); } } return (
setGuildId(e.target.value)} required readOnly={!!guild} inputMode="numeric" className={`${INPUT} font-mono ${guild ? "opacity-70" : ""}`} /> setLabel(e.target.value)} className={INPUT} placeholder="Fire Co. 7" /> {error &&

{error}

}
); } // Which bots are in the server. The DRB server bot is optional (server-26#225): // "not there" is only a problem when no radio bot is either. function BotsCell({ guild }: { guild: DiscordGuild }) { const radio = guild.radio_bots ?? []; const main = guild.main_bot_present; return ( <>
{radio.length > 0 ? ( {radio.length} radio bot{radio.length === 1 ? "" : "s"} ) : ( no radio bot )}
DRB server bot: {main === true ? "present" : main === false ? "not there" : "never seen"} {guild.directory_source === "radio_bot" && " · channels via radio bot"}
); } type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] }; function RemoveForm({ guild, onDone, onCancel, }: { guild: DiscordGuild; onDone: (r: RemoveResult) => void; onCancel: () => void; }) { const [leaveBots, setLeaveBots] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const n = guild.live_streams; async function submit() { setSaving(true); setError(null); try { const r = await c2api.removeDiscordGuild(guild.guild_id, leaveBots); onDone({ guild: guildLabel(guild), streams: r.streams_removed.length, bots: r.radio_bots }); } catch (err) { setError(parseC2Error(err).message); } finally { setSaving(false); } } return (

Remove {guildLabel(guild)} from DRB?{" "} {n > 0 ? `Its ${n} live radio channel${n === 1 ? " is" : "s are"} stopped first, then every radio channel in it is deleted.` : "Every radio channel in it is deleted."}{" "} It is unlinked and will not fall back to the default organization; linking it again brings it back.

The DRB server bot, if it is in the Discord server, stays until someone kicks it there.

{error &&

{error}

}
); } export function ServersPanel({ overview, onChanged, }: { overview: DiscordAdminOverview; onChanged: () => Promise | void; }) { // "new" = blank link form; a guild id = editing that row's link. const [editing, setEditing] = useState(null); const [removing, setRemoving] = useState(null); const [removed, setRemoved] = useState(null); const fallback = overview.limits.unlinked_guild_org_id; const fallbackName = overview.limits.unlinked_guild_org_name || fallback; const max = overview.limits.guild_max_streams; const done = async () => { setEditing(null); await onChanged(); }; return ( setEditing(editing === "new" ? null : "new")}>+ Link a server} note={ fallback ? ( <> Unlinked servers act for {fallbackName} (DISCORD_UNLINKED_GUILD_ORG_ID). Any server the DRB server bot is invited to can use that org's systems until it is linked here (a radio bot alone never puts an unlinked server here). Set the variable to an empty string once every real server is linked. ) : ( <>Unlinked servers can't use radio commands until they are linked here. ) } > {editing === "new" && setEditing(null)} />} {removed && (
Removed {removed.guild}: {removed.streams} radio channel{removed.streams === 1 ? "" : "s"} deleted. {removed.bots.length > 0 && ( <> {" "}Radio bots:{" "} {removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}. )}{" "} The DRB server bot, if it was there, stays in that Discord server until someone kicks it.
)} {overview.guilds.length === 0 ? (

No Discord servers yet. Link one here, or an organization owner connects one under Settings → Organization.

) : ( {overview.guilds.flatMap((g) => { const row = ( ); if (removing === g.guild_id) { return [ row, , ]; } if (editing !== g.guild_id) return [row]; return [ row, , ]; })}
{guildLabel(g)}
{g.guild_id}
{g.acting_org_name || g.acting_org_id || nobody} {!g.linked && g.acting_org_id &&
fallback, not linked
}
{g.live_streams} / {max} {g.linked ? fmtWhen(g.linked_at) : —} {g.linked ? ( Linked ) : g.acting_org_id ? ( Unlinked · fallback org ) : ( Unlinked )}
setRemoving(null)} onDone={async (r) => { setRemoving(null); setRemoved(r); await onChanged(); }} />
setEditing(null)} />
)}
); }