"use client"; // Settings → Organization → "Linked Discord servers" (server-26#213 item 1). // Matches design-canvas/discord-system-mockup.html's Servers panel, minus the // plan / incident-forum / roles / limits columns: none of those exist // server-side yet, and a column with nothing behind it is a dead surface. import { useCallback, useEffect, useRef, useState } from "react"; import { c2api } from "@/lib/c2api"; import { Card, CardHeader } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; type Guild = { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string }; type Code = { code: string; expires_at: string; command: string }; const POLL_MS = 5000; function shortId(id: string) { return id.length > 8 ? `${id.slice(0, 2)}…${id.slice(-4)}` : id; } function fmtDate(iso: string | null) { if (!iso) return "—"; return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } function fmtLeft(ms: number) { const s = Math.max(0, Math.ceil(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isAdmin: boolean }) { const [guilds, setGuilds] = useState(null); const [restricted, setRestricted] = useState(true); const [loadError, setLoadError] = useState(null); const [code, setCode] = useState(null); const [justLinked, setJustLinked] = useState(null); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); const [copied, setCopied] = useState(false); const [now, setNow] = useState(() => Date.now()); const known = useRef | null>(null); const load = useCallback(async () => { try { const res = await c2api.listLinkedDiscordGuilds(); setRestricted(res.unlinked_guilds_restricted); setLoadError(null); setGuilds(res.guilds); return res.guilds; } catch (err) { setLoadError(err instanceof Error ? err.message : "Could not load Discord servers."); return null; } }, []); useEffect(() => { if (!canEdit) return; load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); }); }, [canEdit, load]); const msLeft = code ? new Date(code.expires_at).getTime() - now : 0; const live = !!code && msLeft > 0; // While a code is live: tick the countdown, and poll the list so the page // notices the moment someone runs /drb link in Discord. useEffect(() => { if (!live) return; const tick = setInterval(() => setNow(Date.now()), 1000); const poll = setInterval(async () => { const g = await load(); if (!g) return; const prev = known.current; const added = prev ? g.find((x) => !prev.has(x.guild_id)) : undefined; known.current = new Set(g.map((x) => x.guild_id)); if (added) { setCode(null); setJustLinked(added.guild_name || `guild ${shortId(added.guild_id)}`); } }, POLL_MS); return () => { clearInterval(tick); clearInterval(poll); }; }, [live, load]); async function generate() { setBusy(true); setActionError(null); setJustLinked(null); setCopied(false); try { const res = await c2api.createDiscordLinkCode(); setNow(Date.now()); setCode(res); } catch (err) { setActionError(err instanceof Error ? err.message : "Could not generate a code."); } finally { setBusy(false); } } async function unlink(g: Guild) { const name = g.guild_name || `guild ${shortId(g.guild_id)}`; if (!window.confirm(`Unlink ${name}? Its radio channels stop, and it can't use DRB until it is linked again.`)) { return; } setActionError(null); try { await c2api.unlinkDiscordGuild(g.guild_id); const next = await load(); if (next) known.current = new Set(next.map((x) => x.guild_id)); } catch (err) { setActionError(err instanceof Error ? err.message : "Could not unlink that server."); } } function copy() { if (!code) return; navigator.clipboard?.writeText(code.command).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); } return (
A link code is run as /drb link by someone with Manage Server in that Discord server.} action={ } />
{(code || justLinked || actionError) && (
{actionError &&

{actionError}

} {justLinked &&

Linked {justLinked}. Its /join, /leave and /status now act for this organization.

} {code && live && (
{code.code} single use · expires in {fmtLeft(msLeft)}
{code.command}

Run it in the Discord server you want to link. That server can then use this organization's systems and radio channels — nothing from any other organization. Generating a new code cancels this one.

)} {code && !live &&

That code expired. Generate a new one.

}
)}
{guilds === null && !loadError && ( )} {loadError && ( )} {guilds?.length === 0 && ( )} {guilds?.map((g) => ( ))}
Server Linked Status
Loading…
{loadError}
No Discord servers linked yet.
{g.guild_name || "Unnamed server"}
guild {shortId(g.guild_id)}
{fmtDate(g.linked_at)} Linked {canEdit && ( )}
{restricted ? ( <>Unlinked servers see only /drb link and /help. ) : isAdmin ? ( <>Platform setting: unlinked servers still act for the default organization (DISCORD_UNLINKED_GUILD_ORG_ID) until linked. ) : null} Every command and autocomplete is scoped to the linked organization — no server sees another organization's nodes, systems or bots.
); }