"use client"; // Per-bot voice-channel permission status (server-26#225 S4). Shared by the // node-page picker (NodeDiscordRadio) and the admin Add form (StreamsPanel), // both fed by c2-core routers/discord_checks.py. Start is disabled only on a // DEFINITE refusal ("Needs fix", "Not a voice channel"); "Couldn't check" // fails open — a Discord outage must never block a start. import { useEffect, useState } from "react"; import { agoText, botLine, deniedSentence, fixSentence, type ChannelVerdict, type DiscordInvite, type VoiceCheckChannel, } from "@/lib/discordPicker"; const PILL = "inline-flex items-center gap-1 rounded px-1.5 py-px text-[11px] font-medium whitespace-nowrap border"; const BADGE: Record = { ready: { label: "Ready", cls: "border-green-500/40 text-green-500" }, fix: { label: "Needs fix", cls: "border-sev-major/50 text-sev-major" }, not_voice: { label: "Not a voice channel", cls: "border-sev-major/50 text-sev-major" }, busy: { label: "Bots busy", cls: "border-sev-moderate/50 text-sev-moderate", title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.", }, no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." }, unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." }, }; /** Ready / Needs fix / Couldn't check / …, plus "Limit N" when the channel has a user limit. */ export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | null; loading?: boolean }) { if (!verdict) { return loading ? Checking… : null; } const b = BADGE[verdict.kind]; // Name the missing permission right on the badge: "Needs fix · can't Speak". const label = verdict.kind === "fix" && verdict.bot.missing.length ? `${b.label} · can't ${verdict.bot.missing.join(", ")}` : b.label; const title = verdict.kind === "fix" ? `${verdict.bot.name}: ${verdict.bot.reason ?? "missing permission"}` : b.title; return ( {label} {verdict.limit > 0 && ( Limit {verdict.limit} )} ); } /** The sentence under a "Needs fix" / "Not a voice channel" channel. */ export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) { if (verdict.kind === "not_voice") { return That is not a voice channel. Radio can only play in a voice channel.; } if (verdict.kind !== "fix") return null; return ( {deniedSentence(verdict.bot, channelName)} {fixSentence(verdict.bot)} ); } const TONE: Record<"ok" | "bad" | "warn" | "muted", string> = { ok: "text-green-500", bad: "text-sev-major", warn: "text-sev-moderate", muted: "text-ink-muted", }; const MARK: Record<"ok" | "bad" | "warn" | "muted", string> = { ok: "✓", bad: "✕", warn: "•", muted: "?" }; /** Expandable per-bot detail for one channel. */ export function BotDetails({ channel, channelName }: { channel: VoiceCheckChannel | null | undefined; channelName: string | null }) { if (!channel || channel.bots.length === 0) return null; return (
Radio bots in this server ({channel.bots.length})
); } /** "Permissions checked 40s ago · Re-check". Ticks on its own so the age stays honest. */ export function CheckFooter({ checkedAt, busy, error, disabled, limited, invite, onRecheck, }: { checkedAt: string | null; busy: boolean; error: string | null; disabled?: boolean; /** A Re-check was rate limited server-side (once per 10 s); the cached answer is shown. */ limited?: boolean; invite?: DiscordInvite[]; onRecheck: () => void; }) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const t = setInterval(() => setNow(Date.now()), 10_000); return () => clearInterval(t); }, []); const ago = agoText(checkedAt, now); if (disabled) { return
Permission check is off — DRB tries any free radio bot.
; } return (
{busy ? "Checking permissions…" : ago ? `Permissions checked ${ago}` : "Permissions not checked yet"} · {limited && !busy && ( Re-check runs at most once every 10 seconds — this is the last result. Try again in a moment. )} {error && Couldn't check permissions: {error}} {invite && invite.length > 0 && ( Add another radio bot to this server: {invite.map((i) => ( Invite {i.name} ))} )}
); }