import { useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, KeyRound, Smartphone, Timer, Trash2, UserPlus, X, } from "lucide-react"; import { type FC, useState } from "react"; import { getGetNativePairingQueryKey, getListNativeClientsQueryKey, getListPendingDevicesQueryKey, useApprovePendingDevice, useArmNativePairing, useDenyPendingDevice, useDisarmNativePairing, useGetNativePairing, useListNativeClients, useListPendingDevices, useUnpairNativeClient, } from "@/api/gen/native/native"; import { getGetPairingStatusQueryKey, useGetPairingStatus, useSubmitPairingPin, } from "@/api/gen/pairing/pairing"; import { QueryState } from "@/components/query-state"; import { Section } from "@/components/section"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { useLocale } from "@/lib/i18n"; import { m } from "@/paraglide/messages"; /** Seconds → `m:ss`. */ function fmtTime(secs: number): string { const s = Math.max(0, Math.floor(secs)); return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; } // Pairing composes four independent sub-cards, each its own little container // (own query + mutations). They share the page's staggered entrance via
. export const SectionPairing: FC = () => { useLocale(); return (

{m.pairing_title()}

); }; /** Seconds since a knock → a short relative label. */ function fmtAge(secs: number): string { if (secs < 10) return m.pairing_pending_age_just_now(); if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) }); return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) }); } /** * Devices awaiting delegated approval: an unpaired device that tried to connect * shows up here, and Approve pairs it on the spot — no PIN fetched out of band. * Renders nothing while empty (the common case); polls so a knock appears while * the operator is looking at the page. */ function PendingDevices() { const qc = useQueryClient(); const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } }); const approve = useApprovePendingDevice(); const deny = useDenyPendingDevice(); const rows = pending.data ?? []; // Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow // a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other // section. (A 401 is handled globally by the fetcher's redirect-to-login.) if (rows.length === 0 && !pending.error) return null; const refresh = () => { qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() }); qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); }; const onApprove = (id: number, currentName: string) => { const name = prompt(m.pairing_pending_name_prompt(), currentName); if (name == null) return; // operator cancelled approve.mutate( { id, data: { name: name.trim() ? name.trim() : null } }, { onSuccess: refresh }, ); }; return (

{m.pairing_pending_title()}

{m.pairing_pending_desc()}

{rows.map((p) => ( {p.name} {p.fingerprint.slice(0, 16)}… {fmtAge(p.age_secs)}
))}
); } /** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */ function NativePairingCard() { const qc = useQueryClient(); // Poll fast while armed (live countdown), slow otherwise. const status = useGetNativePairing({ query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) }, }); const arm = useArmNativePairing(); const disarm = useDisarmNativePairing(); const d = status.data; const refresh = () => qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }); return ( {m.pairing_native_title()} {!d?.enabled ? (

{m.pairing_native_disabled()}

) : d.armed && d.pin ? (

{m.pairing_native_enter()}

{d.pin}
{d.expires_in_secs != null && (

{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}

)}
) : ( <>

{m.pairing_native_desc()}

)}
); } /** The paired native (punktfunk/1) devices, with unpair. */ function NativeDevices() { const qc = useQueryClient(); const clients = useListNativeClients(); const unpair = useUnpairNativeClient(); const rows = clients.data ?? []; const onUnpair = (fingerprint: string) => { if (!confirm(m.pairing_native_unpair_confirm())) return; unpair.mutate( { fingerprint }, { onSuccess: () => qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }), }, ); }; return (

{m.pairing_native_devices()}

{rows.length === 0 ? ( {m.pairing_native_empty()} ) : ( {m.clients_name()} {m.clients_fingerprint()} {rows.map((c) => ( {c.name || "—"} {c.fingerprint.slice(0, 16)}… ))}
)}
); } /** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */ function MoonlightPairingCard() { const qc = useQueryClient(); const [pin, setPin] = useState(""); const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }); const submit = useSubmitPairingPin(); const pending = pairing.data?.pin_pending ?? false; const onSubmit = (e: React.FormEvent) => { e.preventDefault(); submit.mutate( { data: { pin } }, { onSuccess: () => { setPin(""); qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() }); }, }, ); }; return ( {m.pairing_moonlight_title()} {!pending ? (

{m.pairing_idle()}

) : (

{m.pairing_waiting()}

setPin(e.target.value.replace(/\D/g, ""))} placeholder="0000" className="font-mono text-lg tracking-widest" />
{submit.isSuccess && (

{m.pairing_success()}

)} {submit.isError && (

{m.pairing_failed()}

)}
)}
); }