feat(web): consolidate paired devices, self-contained sections, docs + lint
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m51s
android / android (push) Successful in 6m21s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 8m6s
release / apple (push) Successful in 8m17s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
apple / screenshots (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 22s

Web console
- Pairing/Library/Stats refactored into self-contained subsections that each own
  their own queries + mutations; a shared slot-based layout (view.tsx) is filled by
  the live page (containers) and Storybook (pure cards + fixtures) so the layout can't
  drift.
- All paired devices in one list on Pairing with a protocol column (punktfunk/1 +
  Moonlight), routing each unpair to the right endpoint; the redundant Clients page is
  removed.
- Library: overview grid split from the add/edit form into separate files.
- Login screen links out to the docs.

Docs
- "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows)
  plus a new "Forgot your Password?" troubleshooting page, linked from the login screen.
- Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the
  Bun entry, launchers, systemd units, and packaging.

Tooling
- Biome now respects .gitignore (stops linting generated code), config migrated to
  2.5.1; all lint issues fixed cleanly.

Also includes this branch's in-progress host, Apple client, packaging, and CI changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:05:22 +02:00
parent e1bc9fda22
commit ba39b08e09
86 changed files with 2726 additions and 2019 deletions
@@ -0,0 +1,126 @@
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, KeyRound } from "lucide-react";
import { type FC, useState } from "react";
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import {
getGetPairingStatusQueryKey,
useGetPairingStatus,
useSubmitPairingPin,
} from "@/api/gen/pairing/pairing";
import { QueryState } from "@/components/query-state";
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 type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
/** Container: GameStream/Moonlight pairing — poll status, own the PIN entry, submit it. */
export const MoonlightPairingSection: FC = () => {
const qc = useQueryClient();
const [pin, setPin] = useState("");
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin();
const onSubmit = () =>
submit.mutate(
{ data: { pin } },
{
onSuccess: () => {
setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
},
},
);
return (
<MoonlightPairing
pairing={pairing}
pin={pin}
onPinChange={setPin}
onSubmit={onSubmit}
isSubmitting={submit.isPending}
isSuccess={submit.isSuccess}
isError={submit.isError}
/>
);
};
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
export const MoonlightPairing: FC<{
pairing: Loadable<PairingStatus>;
pin: string;
onPinChange: (v: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
isSuccess: boolean;
isError: boolean;
}> = ({
pairing,
pin,
onPinChange,
onSubmit,
isSubmitting,
isSuccess,
isError,
}) => {
const pending = pairing.data?.pin_pending ?? false;
return (
<QueryState
isLoading={pairing.isLoading}
error={pairing.error}
refetch={pairing.refetch}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4" />
{m.pairing_moonlight_title()}
</CardTitle>
</CardHeader>
<CardContent>
{!pending ? (
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-4"
>
<p className="text-sm">{m.pairing_waiting()}</p>
<div className="space-y-2">
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
<Input
id="pin"
inputMode="numeric"
autoComplete="off"
maxLength={8}
value={pin}
onChange={(e) =>
onPinChange(e.target.value.replace(/\D/g, ""))
}
placeholder="0000"
className="font-mono text-lg tracking-widest"
/>
</div>
<Button type="submit" disabled={pin.length < 4 || isSubmitting}>
{m.pairing_submit()}
</Button>
{isSuccess && (
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
<CheckCircle2 className="size-4" />
{m.pairing_success()}
</p>
)}
{isError && (
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
)}
</form>
)}
</CardContent>
</Card>
</QueryState>
);
};
@@ -0,0 +1,115 @@
import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, Smartphone, Timer } from "lucide-react";
import type { FC } from "react";
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
import {
getGetNativePairingQueryKey,
useArmNativePairing,
useDisarmNativePairing,
useGetNativePairing,
} from "@/api/gen/native/native";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
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")}`;
}
/**
* Container: native (punktfunk/1) pairing — arm a window, poll fast while armed
* for the live countdown, slow otherwise.
*/
export const NativePairingSection: FC = () => {
const qc = useQueryClient();
const native = useGetNativePairing({
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
});
const arm = useArmNativePairing();
const disarm = useDisarmNativePairing();
const refresh = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
const onArm = () =>
arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh });
const onDisarm = () => disarm.mutate(undefined, { onSuccess: refresh });
return (
<NativePairingCard
status={native}
onArm={onArm}
onDisarm={onDisarm}
isArming={arm.isPending}
isDisarming={disarm.isPending}
/>
);
};
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
export const NativePairingCard: FC<{
status: Loadable<NativePairStatus>;
onArm: () => void;
onDisarm: () => void;
isArming: boolean;
isDisarming: boolean;
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
const d = status.data;
return (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{m.pairing_native_title()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!d?.enabled ? (
<p className="text-sm text-muted-foreground">
{m.pairing_native_disabled()}
</p>
) : d.armed && d.pin ? (
<div className="space-y-3">
<p className="text-sm">{m.pairing_native_enter()}</p>
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
{d.pin}
</div>
{d.expires_in_secs != null && (
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Timer className="size-4" />
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
</p>
)}
<Button
variant="outline"
className="w-full"
disabled={isDisarming}
onClick={onDisarm}
>
{m.pairing_native_cancel()}
</Button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
{m.pairing_native_desc()}
</p>
<Button disabled={isArming} onClick={onArm}>
<KeyRound className="size-4" />
{m.pairing_native_arm()}
</Button>
</>
)}
</CardContent>
</Card>
</QueryState>
);
};
+169
View File
@@ -0,0 +1,169 @@
import { useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
import {
getListNativeClientsQueryKey,
useListNativeClients,
useUnpairNativeClient,
} from "@/api/gen/native/native";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { m } from "@/paraglide/messages";
/** The two pairing protocols a device can be paired over. */
export type PairedProtocol = "native" | "moonlight";
/** One paired device, normalized across the native + Moonlight lists. */
export interface PairedRow {
protocol: PairedProtocol;
fingerprint: string;
/** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */
name: string;
}
/**
* Container: ALL paired devices in one list. Merges the native (punktfunk/1) clients and the
* GameStream/Moonlight clients — two separate host endpoints — into a single table tagged by
* protocol, and routes each unpair back to the right endpoint.
*/
export const PairedDevicesSection: FC = () => {
const qc = useQueryClient();
const native = useListNativeClients();
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
const unpairMoonlight = useUnpairClient();
const rows: PairedRow[] = [
...(native.data ?? []).map(
(c): PairedRow => ({
protocol: "native",
fingerprint: c.fingerprint,
name: c.name,
}),
),
...(moonlight.data ?? []).map(
(c): PairedRow => ({
protocol: "moonlight",
fingerprint: c.fingerprint,
name: c.subject ?? "",
}),
),
];
const onUnpair = (protocol: PairedProtocol, fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
if (protocol === "native") {
unpairNative.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
},
);
} else {
unpairMoonlight.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
},
);
}
};
return (
<PairedDevices
rows={rows}
isLoading={native.isLoading || moonlight.isLoading}
error={native.error ?? moonlight.error}
refetch={() => {
native.refetch();
moonlight.refetch();
}}
onUnpair={onUnpair}
isUnpairing={unpairNative.isPending || unpairMoonlight.isPending}
/>
);
};
/** All paired devices (native + Moonlight) in one table, differentiated by a protocol badge. */
export const PairedDevices: FC<{
rows: PairedRow[];
isLoading: boolean;
error: unknown;
refetch: () => void;
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
isUnpairing: boolean;
}> = ({ rows, isLoading, error, refetch, onUnpair, isUnpairing }) => (
<Card>
<CardHeader>
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
</CardHeader>
<CardContent className="p-6">
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
{rows.length === 0 ? (
m.pairing_native_empty()
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.pairing_protocol()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={`${r.protocol}:${r.fingerprint}`}>
<TableCell className="font-medium">{r.name || "—"}</TableCell>
<TableCell>
<Badge
variant={
r.protocol === "native" ? "default" : "secondary"
}
>
{r.protocol === "native"
? m.pairing_protocol_native()
: m.pairing_protocol_moonlight()}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{r.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={isUnpairing}
onClick={() => onUnpair(r.protocol, r.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
);
+131
View File
@@ -0,0 +1,131 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { UserPlus, X } from "lucide-react";
import type { FC } from "react";
import type { PendingDevice } from "@/api/gen/model";
import {
getListNativeClientsQueryKey,
getListPendingDevicesQueryKey,
useApprovePendingDevice,
useDenyPendingDevice,
useListPendingDevices,
} from "@/api/gen/native/native";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { fmtAge } from "@/lib/utils";
import { m } from "@/paraglide/messages";
/**
* Container: devices awaiting delegated approval. Polls so a knock appears while
* looking; approving pairs the device, so it also refreshes the paired-clients
* list (owned by the PairedDevices subsection — invalidated here by query key).
*/
export const PendingDevicesSection: FC = () => {
const qc = useQueryClient();
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
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 },
);
};
const onDeny = (id: number) => deny.mutate({ id }, { onSuccess: refresh });
return (
<PendingDevices
pending={pending}
onApprove={onApprove}
onDeny={onDeny}
busy={approve.isPending || deny.isPending}
/>
);
};
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect
* shows up here, and Approve pairs it on the spot. Renders nothing while empty
* (the common case) unless there's an error to surface.
*/
export const PendingDevices: FC<{
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void;
onDeny: (id: number) => void;
busy: boolean;
}> = ({ pending, onApprove, onDeny, busy }) => {
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;
return (
<Card>
<CardContent className="p-0">
<CardHeader>
<CardTitle>
<h2 className="flex items-center gap-2 text-lg font-medium">
<UserPlus className="size-4" />
{m.pairing_pending_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.pairing_pending_desc()}
</p>
</CardTitle>
</CardHeader>
<QueryState
isLoading={pending.isLoading}
error={pending.error}
refetch={pending.refetch}
>
<Table>
<TableBody>
{rows.map((p) => (
<TableRow className="h-18" key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{p.fingerprint.slice(0, 16)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtAge(p.age_secs)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={busy}
onClick={() => onApprove(p.id, p.name)}
>
{m.pairing_pending_approve()}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={m.pairing_pending_deny()}
disabled={busy}
onClick={() => onDeny(p.id)}
>
<X className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</QueryState>
</CardContent>
</Card>
);
};
+13 -108
View File
@@ -1,118 +1,23 @@
import { useQueryClient } from "@tanstack/react-query";
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 type { FC } from "react";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { MoonlightPairingSection } from "./MoonlightPairingCard";
import { NativePairingSection } from "./NativePairingCard";
import { PairedDevicesSection } from "./PairedDevices";
import { PendingDevicesSection } from "./PendingDevices";
import { PairingView } from "./view";
// Container: owns the four sub-cards' queries + mutations and hands a plain props
// surface to PairingView. (The presentational split mirrors Dashboard/Clients/Stats
// and lets Storybook render the page with mock state — no live host.)
// Pairing composes four independent, self-contained sub-cards. Each subsection owns its own
// queries + mutations (in its own file, next to its presentational card). The arrangement lives in
// PairingView so the live page (these containers) and the Storybook story (pure cards + mock state)
// fill the same slots — the layout is defined once and can't drift.
export const SectionPairing: FC = () => {
useLocale();
const qc = useQueryClient();
const [pin, setPin] = useState("");
// Devices awaiting delegated approval — polls so a knock appears while looking.
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
// Native (punktfunk/1) pairing: poll fast while armed (live countdown), slow otherwise.
const native = useGetNativePairing({
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
});
const arm = useArmNativePairing();
const disarm = useDisarmNativePairing();
const clients = useListNativeClients();
const unpair = useUnpairNativeClient();
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin();
const refreshPending = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const refreshNative = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
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: refreshPending },
);
};
const onDeny = (id: number) =>
deny.mutate({ id }, { onSuccess: refreshPending });
const onArm = () =>
arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refreshNative });
const onDisarm = () => disarm.mutate(undefined, { onSuccess: refreshNative });
const onUnpair = (fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
unpair.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
},
);
};
const onSubmitPin = () =>
submit.mutate(
{ data: { pin } },
{
onSuccess: () => {
setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
},
},
);
return (
<PairingView
pending={pending}
onApprove={onApprove}
onDeny={onDeny}
pendingBusy={approve.isPending || deny.isPending}
native={native}
onArm={onArm}
onDisarm={onDisarm}
isArming={arm.isPending}
isDisarming={disarm.isPending}
clients={clients}
onUnpair={onUnpair}
isUnpairing={unpair.isPending}
moonlight={pairing}
pin={pin}
onPinChange={setPin}
onSubmitPin={onSubmitPin}
isSubmittingPin={submit.isPending}
pinSuccess={submit.isSuccess}
pinError={submit.isError}
pending={<PendingDevicesSection />}
native={<NativePairingSection />}
moonlight={<MoonlightPairingSection />}
paired={<PairedDevicesSection />}
/>
);
};
+23 -382
View File
@@ -1,387 +1,28 @@
import {
CheckCircle2,
KeyRound,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import type { FC } from "react";
import type { NativeClient } from "@/api/gen/model/nativeClient";
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
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 type { Loadable } from "@/lib/query";
import Section from "@unom/ui/section";
import type { FC, ReactNode } from "react";
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")}`;
}
/**
* The Pairing page LAYOUT — the single source of how the four sub-cards are arranged. Both the live
* page (`index.tsx`, slots = the self-contained `*Section` containers) and Storybook (slots = the
* pure cards with mock state) fill these slots, so the arrangement can never drift between them.
*/
export const PairingView: FC<{
pending: ReactNode;
native: ReactNode;
moonlight: ReactNode;
paired: ReactNode;
}> = ({ pending, native, moonlight, paired }) => (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
/** 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) });
}
export interface PairingViewProps {
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void;
onDeny: (id: number) => void;
pendingBusy: boolean;
native: Loadable<NativePairStatus>;
onArm: () => void;
onDisarm: () => void;
isArming: boolean;
isDisarming: boolean;
clients: Loadable<NativeClient[]>;
onUnpair: (fingerprint: string) => void;
isUnpairing: boolean;
moonlight: Loadable<PairingStatus>;
pin: string;
onPinChange: (v: string) => void;
onSubmitPin: () => void;
isSubmittingPin: boolean;
pinSuccess: boolean;
pinError: boolean;
}
// Pairing composes four independent sub-cards. This is the pure presentational
// surface (mirrors every other page's view.tsx); the container in index.tsx wires
// the queries + mutations. Stories feed mock state so no live host is needed.
export const PairingView: FC<PairingViewProps> = (props) => (
<Section>
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
<PendingDevicesCard
pending={props.pending}
onApprove={props.onApprove}
onDeny={props.onDeny}
busy={props.pendingBusy}
/>
<NativePairingCard
status={props.native}
onArm={props.onArm}
onDisarm={props.onDisarm}
isArming={props.isArming}
isDisarming={props.isDisarming}
/>
<NativeDevicesCard
clients={props.clients}
onUnpair={props.onUnpair}
isUnpairing={props.isUnpairing}
/>
<MoonlightPairingCard
pairing={props.moonlight}
pin={props.pin}
onPinChange={props.onPinChange}
onSubmit={props.onSubmitPin}
isSubmitting={props.isSubmittingPin}
isSuccess={props.pinSuccess}
isError={props.pinError}
/>
{pending}
<div className="lg:grid lg:grid-cols-2 flex flex-col gap-card">
{native}
{moonlight}
</div>
{paired}
</div>
</Section>
);
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect
* shows up here, and Approve pairs it on the spot. Renders nothing while empty
* (the common case) unless there's an error to surface.
*/
const PendingDevicesCard: FC<{
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void;
onDeny: (id: number) => void;
busy: boolean;
}> = ({ pending, onApprove, onDeny, busy }) => {
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;
return (
<div className="space-y-2">
<h2 className="flex items-center gap-2 text-lg font-medium">
<UserPlus className="size-4" />
{m.pairing_pending_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.pairing_pending_desc()}
</p>
<QueryState
isLoading={pending.isLoading}
error={pending.error}
refetch={pending.refetch}
>
<Card>
<CardContent className="p-0">
<Table>
<TableBody>
{rows.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{p.fingerprint.slice(0, 16)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtAge(p.age_secs)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={busy}
onClick={() => onApprove(p.id, p.name)}
>
{m.pairing_pending_approve()}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={m.pairing_pending_deny()}
disabled={busy}
onClick={() => onDeny(p.id)}
>
<X className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</QueryState>
</div>
);
};
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
const NativePairingCard: FC<{
status: Loadable<NativePairStatus>;
onArm: () => void;
onDisarm: () => void;
isArming: boolean;
isDisarming: boolean;
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
const d = status.data;
return (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{m.pairing_native_title()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!d?.enabled ? (
<p className="text-sm text-muted-foreground">
{m.pairing_native_disabled()}
</p>
) : d.armed && d.pin ? (
<div className="space-y-3">
<p className="text-sm">{m.pairing_native_enter()}</p>
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
{d.pin}
</div>
{d.expires_in_secs != null && (
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Timer className="size-4" />
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
</p>
)}
<Button
variant="outline"
className="w-full"
disabled={isDisarming}
onClick={onDisarm}
>
{m.pairing_native_cancel()}
</Button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
{m.pairing_native_desc()}
</p>
<Button disabled={isArming} onClick={onArm}>
<KeyRound className="size-4" />
{m.pairing_native_arm()}
</Button>
</>
)}
</CardContent>
</Card>
</QueryState>
);
};
/** The paired native (punktfunk/1) devices, with unpair. */
const NativeDevicesCard: FC<{
clients: Loadable<NativeClient[]>;
onUnpair: (fingerprint: string) => void;
isUnpairing: boolean;
}> = ({ clients, onUnpair, isUnpairing }) => {
const rows = clients.data ?? [];
return (
<div className="space-y-2">
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
<QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-sm text-muted-foreground">
{m.pairing_native_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((c) => (
<TableRow key={c.fingerprint}>
<TableCell className="font-medium">
{c.name || "—"}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={isUnpairing}
onClick={() => onUnpair(c.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
</div>
);
};
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
const MoonlightPairingCard: FC<{
pairing: Loadable<PairingStatus>;
pin: string;
onPinChange: (v: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
isSuccess: boolean;
isError: boolean;
}> = ({
pairing,
pin,
onPinChange,
onSubmit,
isSubmitting,
isSuccess,
isError,
}) => {
const pending = pairing.data?.pin_pending ?? false;
return (
<QueryState
isLoading={pairing.isLoading}
error={pairing.error}
refetch={pairing.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4" />
{m.pairing_moonlight_title()}
</CardTitle>
</CardHeader>
<CardContent>
{!pending ? (
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-4"
>
<p className="text-sm">{m.pairing_waiting()}</p>
<div className="space-y-2">
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
<Input
id="pin"
inputMode="numeric"
autoComplete="off"
maxLength={8}
value={pin}
onChange={(e) =>
onPinChange(e.target.value.replace(/\D/g, ""))
}
placeholder="0000"
className="font-mono text-lg tracking-widest"
/>
</div>
<Button type="submit" disabled={pin.length < 4 || isSubmitting}>
{m.pairing_submit()}
</Button>
{isSuccess && (
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
<CheckCircle2 className="size-4" />
{m.pairing_success()}
</p>
)}
{isError && (
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
)}
</form>
)}
</CardContent>
</Card>
</QueryState>
);
};