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
+15 -3
View File
@@ -1,7 +1,8 @@
# punktfunk web — management console (Nitro/Node server) configuration.
# Copy to `.env` (gitignored) or set these in the environment of `node .output/server/index.mjs`.
# punktfunk web — management console (Nitro server on bun) configuration.
# Copy to `.env` (gitignored) or set these in the environment of `bun .output/server/index.mjs`.
# NOTE: on a packaged install (the punktfunk-web .deb) you edit NOTHING — the systemd --user units
# auto-wire these from the host's ~/.config/punktfunk/{mgmt-token,web-password}. See web.env.example.
# auto-wire these from the host's ~/.config/punktfunk/{mgmt-token,web-password,cert.pem,key.pem}.
# See web.env.example.
# REQUIRED in production: the shared login password for the console. The built Nitro
# server fails CLOSED (503 on every request) if this is unset, so a LAN-exposed server
@@ -27,6 +28,17 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions).
# PUNKTFUNK_UI_SECRET=
# TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert
# native clients already pin). Point these at the host's PEM files; BOTH set ⇒ HTTPS. Unset ⇒ plain
# HTTP (local dev only). (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't
# speak HTTP/3/QUIC against this self-signed, no-SAN host cert.)
PUNKTFUNK_UI_TLS_CERT=/home/you/.config/punktfunk/cert.pem
PUNKTFUNK_UI_TLS_KEY=/home/you/.config/punktfunk/key.pem
# REQUIRED when serving over TLS: mark the session cookie Secure (browsers drop a Secure cookie over
# plain http://, so it is OFF by default; turn it ON whenever PUNKTFUNK_UI_TLS_* is set).
PUNKTFUNK_UI_SECURE=1
# The Bun server binds these (standard Nitro env):
# PORT=3000
# HOST=0.0.0.0
+10 -7
View File
@@ -4,10 +4,11 @@ import "../src/styles.css";
// The console loads its brand typeface separately (in __root.tsx); do the same
// here or every story falls back to system-ui and looks off.
import "@fontsource-variable/geist";
import { useEffect } from "react";
import { definePreview } from "@storybook/react-vite";
import { MaterialProvider, defaultMaterialTheme } from "@unom/ui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defaultMaterialTheme, MaterialProvider } from "@unom/ui/material";
import Section from "@unom/ui/section";
import { useEffect } from "react";
// React Query is present so any query-backed component mounts without a real
// host. Stories should feed mock data rather than fetch — retries are off so a
@@ -51,11 +52,13 @@ export default definePreview({
<QueryClientProvider client={queryClient}>
<MaterialProvider theme={defaultMaterialTheme}>
<div className={dark ? "dark" : ""}>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
<Section maxWidth={false}>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</Section>
</div>
</MaterialProvider>
</QueryClientProvider>
+23 -8
View File
@@ -40,19 +40,30 @@ If the host runs with `--mgmt-token`, set it under **Settings → API token** (s
## Build & run (Nitro + Bun)
The console runs on **bun** (`Bun.serve` is a Bun API — node can't run it): Nitro's `bun` preset
plus a custom entry (`nitro-entry/bun-https.mjs`) that calls `Bun.serve({ tls })`, so it serves
**HTTPS (HTTP/1.1 over TLS)** with the **host's own identity cert** (the cert native clients already
pin). One trust anchor across the data plane, the mgmt API, and this console. (No HTTP/2 — `Bun.serve`
has no h2 server — and no HTTP/3, which a browser won't speak against this self-signed, no-SAN host
cert; a browser-trusted, SAN-matching cert + a fronting server would be needed, out of scope for a
LAN console.)
```sh
bun run build # → .output/ (Nitro server, `bun` preset, + .output/public assets)
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
PORT=3000 HOST=0.0.0.0 \
PUNKTFUNK_UI_PASSWORD=PUNKTFUNK_MGMT_TOKEN=\
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \
PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
bun run start # = bun run .output/server/index.mjs
# (the mgmt API is HTTPS w/ the host's self-signed cert on loopback → the proxy's fetch needs
# NODE_TLS_REJECT_UNAUTHORIZED=0; it makes no other outbound TLS calls. See .env.example.)
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
# NODE_TLS_REJECT_UNAUTHORIZED=0 is only for the proxy's loopback fetch to the host's self-signed
# mgmt cert; the console makes no other outbound TLS calls. See .env.example.
bun run lint # tsc --noEmit
```
The built **Nitro Bun server** SSR-renders the app and is the only thing exposed on the LAN.
Run it on the same box as the host; it serves the console on `:3000` (or `$PORT`).
The built **Nitro bun server** SSR-renders the app and is the only thing exposed on the LAN.
Run it on the same box as the host; it serves the console over HTTPS on `:3000` (or `$PORT`).
## Auth (backend-for-frontend)
@@ -62,10 +73,14 @@ Single-user, login-gated. Config via env (see `.env.example`):
**sealed session cookie** (h3 `useSession`, AES-GCM). `server/middleware/auth.ts` gates
*every* request — pages redirect to `/login`, `/api` returns 401 — and **fails closed**
(503) if `PUNKTFUNK_UI_PASSWORD` is unset, so a misconfigured LAN server admits no one.
- The **management API stays loopback-only + token** — never LAN-exposed. The web server
- The **bearer-token admin surface of the management API is loopback-only** — the host honors a
bearer token only from a loopback peer, so the admin API is never LAN-exposed. The web server
holds `PUNKTFUNK_MGMT_TOKEN` server-side and injects it when proxying `/api/**`
`PUNKTFUNK_MGMT_URL` (`server/routes/api/[...].ts`). **The token never reaches the
browser**; the browser only ever holds the session cookie.
`PUNKTFUNK_MGMT_URL` (loopback; `server/routes/api/[...].ts`). **The token never reaches the
browser**; the browser only ever holds the session cookie. (The host *also* binds the
**read-only** surface — host status + the game library — to the LAN so paired native clients can
fetch it directly over mTLS; that path uses client certs, not the token, and never touches this
console.)
So: `browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API`.
Run the host with a matching token: `cargo run -rp punktfunk-host -- serve` +
+19 -9
View File
@@ -1,15 +1,13 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": {
"enabled": false,
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**"
]
"includes": ["**"]
},
"css": {
"parser": {
@@ -30,7 +28,7 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"preset": "recommended",
"suspicious": {
"noUnknownAtRules": "off",
"noArrayIndexKey": "off"
@@ -41,5 +39,17 @@
"formatter": {
"quoteStyle": "double"
}
}
}
},
"overrides": [
{
"includes": ["server/**", "nitro-entry/**"],
"linter": {
"rules": {
"correctness": {
"useHookAtTopLevel": "off"
}
}
}
}
]
}
+5 -1
View File
@@ -59,6 +59,9 @@
"pairing_native_devices": "Gekoppelte Geräte",
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
"pairing_protocol": "Protokoll",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
"pairing_pending_title": "Warten auf Freigabe",
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
"pairing_pending_approve": "Freigeben",
@@ -100,7 +103,8 @@
"common_cancel": "Abbrechen",
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
"login_title": "Anmelden",
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.",
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
"login_docs_link": "Besuche die Dokumentation",
"login_password": "Passwort",
"login_submit": "Anmelden",
"login_error": "Falsches Passwort.",
+5 -1
View File
@@ -59,6 +59,9 @@
"pairing_native_devices": "Paired devices",
"pairing_native_empty": "No devices paired yet.",
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
"pairing_protocol": "Protocol",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
"pairing_pending_title": "Waiting for approval",
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
"pairing_pending_approve": "Approve",
@@ -100,7 +103,8 @@
"common_cancel": "Cancel",
"common_unauthorized": "Session expired — redirecting to sign in…",
"login_title": "Sign in",
"login_subtitle": "Enter the management password to continue.",
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
"login_docs_link": "Visit the documentation",
"login_password": "Password",
"login_submit": "Sign in",
"login_error": "Wrong password.",
+69
View File
@@ -0,0 +1,69 @@
// Custom Nitro server entry for the punktfunk web console.
//
// It is the stock Nitro `bun` preset entry
// (node_modules/nitropack/dist/presets/bun/runtime/bun.mjs) plus **TLS**, so the console is served
// over **HTTPS (HTTP/1.1 over TLS)** using the HOST's own identity cert (the cert native clients
// already pin). One trust anchor across the data plane, the management API, and this console. Wired
// in via `entry:` in vite.config.ts on top of Nitro's `bun` preset (which bundles the handler in).
//
// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and
// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error,
// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint
// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
//
// Env (set by the launchers / the systemd unit — see web.env.example):
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
// Unset ⇒ plain HTTP (local dev only).
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
import "#nitro-internal-pollyfills";
import wsAdapter from "crossws/adapters/bun";
import { useNitroApp } from "nitropack/runtime";
import { startScheduleRunner } from "nitropack/runtime/internal";
const nitroApp = useNitroApp();
const ws = import.meta._websocket
? wsAdapter(nitroApp.h3App.websocket)
: undefined;
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
const tls =
certPath && keyPath
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
: undefined;
const server = Bun.serve({
port: process.env.NITRO_PORT || process.env.PORT || 3000,
host: process.env.NITRO_HOST || process.env.HOST,
idleTimeout:
Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || undefined,
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
tls,
websocket: import.meta._websocket ? ws.websocket : undefined,
async fetch(req, server) {
if (import.meta._websocket && req.headers.get("upgrade") === "websocket") {
return ws.handleUpgrade(req, server);
}
const url = new URL(req.url);
let body;
if (req.body) {
body = await req.arrayBuffer();
}
return nitroApp.localFetch(url.pathname + url.search, {
host: url.hostname,
protocol: url.protocol,
headers: req.headers,
method: req.method,
redirect: req.redirect,
body,
});
},
});
console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
if (import.meta._tasks) {
startScheduleRunner();
}
+1 -1
View File
@@ -11,9 +11,9 @@ import {
} from "h3";
import {
isPublicPath,
type SessionData,
sessionConfig,
uiPassword,
type SessionData,
} from "../util/auth";
export default defineEventHandler(async (event) => {
+2 -2
View File
@@ -1,12 +1,12 @@
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
// can actually log in.
import { defineEventHandler, readBody, createError, useSession } from "h3";
import { createError, defineEventHandler, readBody, useSession } from "h3";
import {
type SessionData,
sessionConfig,
timingSafeEqual,
uiPassword,
type SessionData,
} from "../../util/auth";
export default defineEventHandler(async (event) => {
+1 -1
View File
@@ -1,6 +1,6 @@
// POST /_auth/logout — clear the session cookie.
import { defineEventHandler, useSession } from "h3";
import { sessionConfig, type SessionData } from "../../util/auth";
import { type SessionData, sessionConfig } from "../../util/auth";
export default defineEventHandler(async (event) => {
const session = await useSession<SessionData>(event, sessionConfig());
+1 -1
View File
@@ -87,7 +87,7 @@ export function isPublicPath(pathname: string): boolean {
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
export function safeNextPath(next: string | undefined): string {
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
if (!next?.startsWith("/") || next.startsWith("//")) return "/";
return next;
}
+4 -10
View File
@@ -6,7 +6,6 @@ import {
LibraryBig,
Server,
Settings,
Users,
} from "lucide-react";
import { motion, stagger } from "motion/react";
import type { ReactNode } from "react";
@@ -23,17 +22,10 @@ const NAV = [
{ to: "/host", icon: Server, label: () => m.nav_host() },
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/clients", icon: Users, label: () => m.nav_clients() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const;
// Staggered entrance for the sidebar nav: each item fans in from the left a beat
// after the previous. Per-item delays (rather than a parent stagger) keep every
// item independent, so none can be left mid-orchestration / invisible.
const NAV_ENTER_DELAY = 0.08;
const NAV_ENTER_STEP = 0.06;
export function AppShell({ children }: { children: ReactNode }) {
// Read the locale so the whole shell re-renders on a language switch.
useLocale();
@@ -58,7 +50,7 @@ export function AppShell({ children }: { children: ReactNode }) {
variants={{ enter: {}, from: {} }}
className="flex flex-col gap-1"
>
{NAV.map(({ to, icon: Icon, label }, i) => (
{NAV.map(({ to, icon: Icon, label }) => (
<MLink
key={to}
variants={{
@@ -103,7 +95,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<main className="flex-1">
{/* pb-24 leaves room for the fixed bottom nav on mobile. */}
<div className="mx-auto max-w-5xl p-6 pb-24 sm:p-10 sm:pb-10">
<div className="mx-auto max-w-[1700px] p-6 pb-24 sm:p-10 sm:pb-10">
{children}
</div>
</main>
@@ -138,10 +130,12 @@ export function AppShell({ children }: { children: ReactNode }) {
function LanguageSwitcher() {
const current = useLocale();
return (
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
<div className="flex gap-1" role="group" aria-label="Language">
{locales.map((l: Locale) => (
<button
key={l}
type="button"
onClick={() => changeLocale(l)}
className={cn(
"rounded px-2 py-1 text-xs uppercase transition-colors",
-40
View File
@@ -1,40 +0,0 @@
import { motion, useReducedMotion } from "motion/react";
import { Children, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Page content wrapper that animates in on mount — so the content fans up into
* place every time you navigate or load a route (the route remounts, this
* remounts). Each direct child is staggered a beat after the previous (the same
* on-mount-delay pattern the sidebar nav uses). Honours prefers-reduced-motion.
*/
export function Section({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
return (
<div className={cn("flex flex-col gap-6", className)}>
{Children.map(children, (child, i) =>
reduce ? (
child
) : (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: 0.03 + i * 0.07,
duration: 0.42,
ease: [0.16, 1, 0.3, 1],
}}
>
{child}
</motion.div>
),
)}
</div>
);
}
+5 -2
View File
@@ -57,7 +57,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 px-card text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
@@ -71,7 +71,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
className={cn(
"p-card py-2 align-middle [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
+8
View File
@@ -1,7 +1,15 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { m } from "@/paraglide/messages";
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Seconds since a knock → a short relative label. */
export 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) });
}
-4
View File
@@ -1,4 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionClients } from "@/sections/Clients";
export const Route = createFileRoute("/clients")({ component: SectionClients });
-36
View File
@@ -1,36 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { ClientsView } from "./view";
export const SectionClients: FC = () => {
useLocale();
const qc = useQueryClient();
const clients = useListPairedClients();
const unpair = useUnpairClient();
const onUnpair = (fingerprint: string) => {
if (!confirm(m.clients_unpair_confirm())) return;
unpair.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
},
);
};
return (
<ClientsView
clients={clients}
onUnpair={onUnpair}
isUnpairing={unpair.isPending}
/>
);
};
-80
View File
@@ -1,80 +0,0 @@
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import type { PairedClient } from "@/api/gen/model/pairedClient";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
export const ClientsView: FC<{
clients: Loadable<PairedClient[]>;
onUnpair: (fingerprint: string) => void;
isUnpairing: boolean;
}> = ({ clients, onUnpair, isUnpairing }) => {
const rows = clients.data ?? [];
return (
<Section>
<h1 className="text-2xl font-semibold">{m.clients_title()}</h1>
<QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.clients_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.subject || "—"}
</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>
</Section>
);
};
+98 -96
View File
@@ -1,8 +1,8 @@
import Section from "@unom/ui/section";
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
import type { FC, ReactNode } from "react";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -18,105 +18,107 @@ export const DashboardView: FC<{
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
const s = status.data;
return (
<Section>
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
{s && (
<div className="flex flex-col gap-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Video className="size-4" />}
label={m.status_video()}
on={s.video_streaming}
/>
<StatCard
icon={<Volume2 className="size-4" />}
label={m.status_audio()}
on={s.audio_streaming}
/>
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
{s && (
<div className="flex flex-col gap-card">
<div className="grid gap-card sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Video className="size-4" />}
label={m.status_video()}
on={s.video_streaming}
/>
<StatCard
icon={<Volume2 className="size-4" />}
label={m.status_audio()}
on={s.audio_streaming}
/>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">
{m.status_paired_count()}
</span>
<span className="text-2xl font-semibold tabular-nums">
{s.paired_clients}
</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
<Badge variant={s.pin_pending ? "default" : "outline"}>
{s.pin_pending ? "●" : "—"}
</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">
{m.status_paired_count()}
</span>
<span className="text-2xl font-semibold tabular-nums">
{s.paired_clients}
</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
<Badge variant={s.pin_pending ? "default" : "outline"}>
{s.pin_pending ? "●" : "—"}
</Badge>
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<MonitorPlay className="size-4" />
{m.status_session()}
</CardTitle>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!s.video_streaming || isRequestingIdr}
onClick={onRequestIdr}
>
<RefreshCw className="size-3.5" />
{m.action_request_idr()}
</Button>
<Button
variant="destructive"
size="sm"
disabled={!s.session || isStopping}
onClick={onStopSession}
>
<ZapOff className="size-3.5" />
{m.action_stop_session()}
</Button>
</div>
</CardHeader>
<CardContent>
{s.stream ? (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<Field
label={m.stream_codec()}
value={s.stream.codec.toUpperCase()}
/>
<Field
label={m.stream_resolution()}
value={`${s.stream.width}×${s.stream.height}`}
/>
<Field
label={m.stream_fps()}
value={`${s.stream.fps} fps`}
/>
<Field
label={m.stream_bitrate()}
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
/>
</dl>
) : (
<p className="text-sm text-muted-foreground">
{m.status_no_session()}
</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<MonitorPlay className="size-4" />
{m.status_session()}
</CardTitle>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!s.video_streaming || isRequestingIdr}
onClick={onRequestIdr}
>
<RefreshCw className="size-3.5" />
{m.action_request_idr()}
</Button>
<Button
variant="destructive"
size="sm"
disabled={!s.session || isStopping}
onClick={onStopSession}
>
<ZapOff className="size-3.5" />
{m.action_stop_session()}
</Button>
</div>
</CardHeader>
<CardContent>
{s.stream ? (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<Field
label={m.stream_codec()}
value={s.stream.codec.toUpperCase()}
/>
<Field
label={m.stream_resolution()}
value={`${s.stream.width}×${s.stream.height}`}
/>
<Field
label={m.stream_fps()}
value={`${s.stream.fps} fps`}
/>
<Field
label={m.stream_bitrate()}
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
/>
</dl>
) : (
<p className="text-sm text-muted-foreground">
{m.status_no_session()}
</p>
)}
</CardContent>
</Card>
</div>
)}
</QueryState>
)}
</QueryState>
</div>
</Section>
);
};
+97 -93
View File
@@ -1,8 +1,8 @@
import Section from "@unom/ui/section";
import type { FC } from "react";
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
@@ -14,109 +14,113 @@ export const HostView: FC<{
}> = ({ host, compositors }) => {
const h = host.data;
return (
<Section>
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
<QueryState
isLoading={host.isLoading}
error={host.error}
refetch={host.refetch}
>
{h && (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{m.host_identity()}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-3">
<Row label={m.host_hostname()} value={h.hostname} />
<Row label={m.host_local_ip()} value={h.local_ip} mono />
<Row
label={m.host_version()}
value={`${h.app_version} (${h.version})`}
/>
<Row label={m.host_abi()} value={String(h.abi_version)} />
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
</dl>
</CardContent>
</Card>
<div className="space-y-4">
<QueryState
isLoading={host.isLoading}
error={host.error}
refetch={host.refetch}
>
{h && (
<div className="grid gap-card lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{m.host_codecs()}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{h.codecs.map((c) => (
<Badge key={c} variant="secondary">
{c.toUpperCase()}
</Badge>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.host_ports()}</CardTitle>
<CardTitle>{m.host_identity()}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
{Object.entries(h.ports).map(([k, v]) => (
<div key={k} className="flex justify-between">
<dt className="text-muted-foreground uppercase">{k}</dt>
<dd className="font-medium">{v as number}</dd>
</div>
))}
<dl className="grid grid-cols-1 gap-3">
<Row label={m.host_hostname()} value={h.hostname} />
<Row label={m.host_local_ip()} value={h.local_ip} mono />
<Row
label={m.host_version()}
value={`${h.app_version} (${h.version})`}
/>
<Row label={m.host_abi()} value={String(h.abi_version)} />
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
</dl>
</CardContent>
</Card>
<div className="space-y-card">
<Card>
<CardHeader>
<CardTitle>{m.host_codecs()}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{h.codecs.map((c) => (
<Badge key={c} variant="secondary">
{c.toUpperCase()}
</Badge>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.host_ports()}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
{Object.entries(h.ports).map(([k, v]) => (
<div key={k} className="flex justify-between">
<dt className="text-muted-foreground uppercase">
{k}
</dt>
<dd className="font-medium">{v as number}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
</div>
</div>
</div>
)}
</QueryState>
)}
</QueryState>
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{m.host_compositors_help()}
</p>
<QueryState
isLoading={compositors.isLoading}
error={compositors.error}
refetch={compositors.refetch}
>
<ul className="divide-y rounded-md border">
{compositors.data?.map((c) => (
<li
key={c.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{c.label}</span>
{c.default && (
<Badge variant="secondary">
{m.compositor_default()}
</Badge>
)}
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{m.host_compositors_help()}
</p>
<QueryState
isLoading={compositors.isLoading}
error={compositors.error}
refetch={compositors.refetch}
>
<ul className="divide-y rounded-md border">
{compositors.data?.map((c) => (
<li
key={c.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{c.label}</span>
{c.default && (
<Badge variant="secondary">
{m.compositor_default()}
</Badge>
)}
</div>
<code className="text-xs text-muted-foreground">
{c.id}
</code>
</div>
<code className="text-xs text-muted-foreground">
{c.id}
</code>
</div>
<Badge variant={c.available ? "default" : "outline"}>
{c.available
? m.compositor_available()
: m.compositor_unavailable()}
</Badge>
</li>
))}
</ul>
</QueryState>
</CardContent>
</Card>
<Badge variant={c.available ? "default" : "outline"}>
{c.available
? m.compositor_available()
: m.compositor_unavailable()}
</Badge>
</li>
))}
</ul>
</QueryState>
</CardContent>
</Card>
</div>
</Section>
);
};
+108
View File
@@ -0,0 +1,108 @@
import { Pencil, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* Display label for a store badge. Steam and custom keep their localized strings; every other store
* (lutris, heroic, epic, …) is a proper noun shown capitalized, so new providers surface correctly
* without a translation per store.
*/
function storeLabel(store: string): string {
switch (store) {
case "custom":
return m.library_store_custom();
case "steam":
return m.library_store_steam();
default:
return store.charAt(0).toUpperCase() + store.slice(1);
}
}
export interface GameCardProps {
game: GameEntry;
onEdit: () => void;
onDelete: () => void;
deleting: boolean;
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
* falls back to the wide header, then to a text placeholder. Custom entries get
* edit/delete affordances.
*/
export const GameCard: FC<GameCardProps> = ({
game,
onEdit,
onDelete,
deleting,
}) => {
const isCustom = game.store === "custom";
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({});
const candidates = [game.art.portrait, game.art.header].filter(
(u): u is string => !!u && !failed[u],
);
const src = candidates[0];
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className="size-full object-cover"
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
{game.title}
</div>
)}
<div className="absolute left-2 top-2">
<Badge
variant={isCustom ? "secondary" : "outline"}
className="bg-background/80 backdrop-blur"
>
{storeLabel(game.store)}
</Badge>
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
)}
</div>
<div
className="truncate px-card pb-card pt-4 text-sm font-medium"
title={game.title}
>
{game.title}
</div>
</Card>
);
};
+205
View File
@@ -0,0 +1,205 @@
import { useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import { type FC, type FormEvent, useState } from "react";
import {
getGetLibraryQueryKey,
useCreateCustomGame,
useUpdateCustomGame,
} from "@/api/gen/library/library";
import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry";
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 { m } from "@/paraglide/messages";
import { customId } from "./helpers";
interface FormState {
title: string;
portrait: string;
hero: string;
header: string;
command: string;
}
const emptyForm: FormState = {
title: "",
portrait: "",
hero: "",
header: "",
command: "",
};
function formFrom(entry: GameEntry): FormState {
return {
title: entry.title,
portrait: entry.art.portrait ?? "",
hero: entry.art.hero ?? "",
header: entry.art.header ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
};
}
/** Map the form to the API body — only attach `launch` when a command was given. */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim();
return t ? t : undefined;
};
const command = f.command.trim();
return {
title: f.title.trim(),
art: {
portrait: trim(f.portrait),
hero: trim(f.hero),
header: trim(f.header),
},
launch: command ? { kind: "command", value: command } : null,
};
}
/** What the form targets: an existing custom entry to edit, or "new" for a fresh add. */
export type FormTarget = GameEntry | "new";
/**
* Container: the add/edit form — owns the create + update mutations and derives the
* initial field state from the target. Kept entirely separate from the overview grid
* (own file, own queries) so the two concerns don't share a component.
*/
export const GameFormSection: FC<{
target: FormTarget;
onClose: () => void;
}> = ({ target, onClose }) => {
const qc = useQueryClient();
const create = useCreateCustomGame();
const update = useUpdateCustomGame();
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
const onSubmit = async (data: CustomInput) => {
if (target === "new") await create.mutateAsync({ data }).then(invalidate);
else
await update.mutateAsync({ id: customId(target), data }).then(invalidate);
onClose();
};
return (
<GameForm
initial={target === "new" ? emptyForm : formFrom(target)}
mode={target === "new" ? "add" : "edit"}
onSubmit={onSubmit}
onCancel={onClose}
isSaving={create.isPending || update.isPending}
/>
);
};
/**
* The add/edit form card. Owns only its own field state (re-seeded per mount — the
* parent keys it by target); reports a ready-to-send `CustomInput` on submit.
*/
export const GameForm: FC<{
initial: FormState;
mode: "add" | "edit";
onSubmit: (data: CustomInput) => void;
onCancel: () => void;
isSaving: boolean;
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
const [form, setForm] = useState<FormState>(initial);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const data = toInput(form);
if (!data.title) return;
onSubmit(data);
};
return (
<Card className="max-w-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>
{mode === "edit" ? m.library_edit_title() : m.library_add_title()}
</CardTitle>
<Button
variant="ghost"
size="icon"
aria-label={m.library_cancel()}
onClick={onCancel}
>
<X className="size-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
<Input
id="lib-title"
required
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label>
<Input
id="lib-portrait"
type="url"
inputMode="url"
value={form.portrait}
onChange={(e) =>
setForm((f) => ({ ...f, portrait: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
<Input
id="lib-hero"
type="url"
inputMode="url"
value={form.hero}
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
<Input
id="lib-header"
type="url"
inputMode="url"
value={form.header}
onChange={(e) =>
setForm((f) => ({ ...f, header: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) =>
setForm((f) => ({ ...f, command: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
{m.library_field_command_help()}
</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{mode === "edit" ? m.library_save() : m.library_create()}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{m.library_cancel()}
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
+87
View File
@@ -0,0 +1,87 @@
import { useQueryClient } from "@tanstack/react-query";
import { motion, stagger } from "motion/react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
useGetLibrary,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state";
import { Card, CardContent } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { GameCard } from "./GameCard";
import { customId } from "./helpers";
/**
* Container: the library OVERVIEW — owns the listing query and per-card delete.
* Editing is escalated to the parent (it opens the separate add/edit form), so
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
onEdit,
}) => {
const qc = useQueryClient();
const library = useGetLibrary();
const remove = useDeleteCustomGame();
const onDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
await remove
.mutateAsync({ id: customId(entry) })
.then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }));
};
return (
<LibraryGrid
library={library}
onEdit={onEdit}
onDelete={onDelete}
isDeleting={remove.isPending}
/>
);
};
/** The poster grid (with empty + loading/error states). */
export const LibraryGrid: FC<{
library: Loadable<GameEntry[]>;
onEdit: (entry: GameEntry) => void;
onDelete: (entry: GameEntry) => void;
isDeleting: boolean;
}> = ({ library, onEdit, onDelete, isDeleting }) => {
const games = library.data ?? [];
return (
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{games.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.library_empty()}
</CardContent>
</Card>
) : (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={isDeleting}
/>
))}
</motion.div>
</div>
)}
</QueryState>
);
};
+8
View File
@@ -0,0 +1,8 @@
import type { GameEntry } from "@/api/gen/model/gameEntry";
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
export function customId(entry: GameEntry): string {
return entry.id.startsWith("custom:")
? entry.id.slice("custom:".length)
: entry.id;
}
+36 -29
View File
@@ -1,37 +1,44 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useCreateCustomGame,
useDeleteCustomGame,
useGetLibrary,
useUpdateCustomGame,
} from "@/api/gen/library/library";
import type { CustomInput } from "@/api/gen/model/customInput";
import Section from "@unom/ui/section";
import { Plus } from "lucide-react";
import { type FC, useState } from "react";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { LibraryView } from "./view";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
// shared "is the form open, and for what" UI state; the grid and form each own their own data.
export const SectionLibrary: FC = () => {
useLocale();
const qc = useQueryClient();
const library = useGetLibrary();
const create = useCreateCustomGame();
const update = useUpdateCustomGame();
const remove = useDeleteCustomGame();
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
// by the target re-seeds its fields when switching add → edit (or between entries).
const [target, setTarget] = useState<FormTarget | null>(null);
return (
<LibraryView
library={library}
onCreate={(data: CustomInput) =>
create.mutateAsync({ data }).then(invalidate)
}
onUpdate={(id, data) => update.mutateAsync({ id, data }).then(invalidate)}
onDelete={(id) => remove.mutateAsync({ id }).then(invalidate)}
isSaving={create.isPending || update.isPending}
isDeleting={remove.isPending}
/>
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
{target === null && (
<Button onClick={() => setTarget("new")}>
<Plus className="size-4" />
{m.library_add_button()}
</Button>
)}
</div>
{target !== null && (
<GameFormSection
key={target === "new" ? "new" : target.id}
target={target}
onClose={() => setTarget(null)}
/>
)}
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
</div>
</Section>
);
};
-327
View File
@@ -1,327 +0,0 @@
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { type FC, useState } from "react";
import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
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";
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
function customId(entry: GameEntry): string {
return entry.id.startsWith("custom:")
? entry.id.slice("custom:".length)
: entry.id;
}
/**
* Display label for a store badge. Steam and custom keep their localized strings; every other store
* (lutris, heroic, epic, …) is a proper noun shown capitalized, so new providers surface correctly
* without a translation per store.
*/
function storeLabel(store: string): string {
switch (store) {
case "custom":
return m.library_store_custom();
case "steam":
return m.library_store_steam();
default:
return store.charAt(0).toUpperCase() + store.slice(1);
}
}
interface FormState {
title: string;
portrait: string;
hero: string;
header: string;
command: string;
}
const emptyForm: FormState = {
title: "",
portrait: "",
hero: "",
header: "",
command: "",
};
function formFrom(entry: GameEntry): FormState {
return {
title: entry.title,
portrait: entry.art.portrait ?? "",
hero: entry.art.hero ?? "",
header: entry.art.header ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
};
}
/** Map the form to the API body — only attach `launch` when a command was given. */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim();
return t ? t : undefined;
};
const command = f.command.trim();
return {
title: f.title.trim(),
art: {
portrait: trim(f.portrait),
hero: trim(f.hero),
header: trim(f.header),
},
launch: command ? { kind: "command", value: command } : null,
};
}
export const LibraryView: FC<{
library: Loadable<GameEntry[]>;
onCreate: (data: CustomInput) => Promise<unknown>;
onUpdate: (id: string, data: CustomInput) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
isSaving: boolean;
isDeleting: boolean;
}> = ({ library, onCreate, onUpdate, onDelete, isSaving, isDeleting }) => {
// null = form hidden; "" = adding a new entry; an id = editing that custom entry.
const [editing, setEditing] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const games = library.data ?? [];
const openAdd = () => {
setForm(emptyForm);
setEditing("");
};
const openEdit = (entry: GameEntry) => {
setForm(formFrom(entry));
setEditing(customId(entry));
};
const closeForm = () => setEditing(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data = toInput(form);
if (!data.title) return;
await (editing ? onUpdate(editing, data) : onCreate(data));
closeForm();
};
const handleDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
await onDelete(customId(entry));
};
return (
<Section>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
{editing === null && (
<Button onClick={openAdd}>
<Plus className="size-4" />
{m.library_add_button()}
</Button>
)}
</div>
{editing !== null && (
<Card className="max-w-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>
{editing ? m.library_edit_title() : m.library_add_title()}
</CardTitle>
<Button
variant="ghost"
size="icon"
aria-label={m.library_cancel()}
onClick={closeForm}
>
<X className="size-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
<Input
id="lib-title"
required
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-portrait">
{m.library_field_portrait()}
</Label>
<Input
id="lib-portrait"
type="url"
inputMode="url"
value={form.portrait}
onChange={(e) =>
setForm((f) => ({ ...f, portrait: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
<Input
id="lib-hero"
type="url"
inputMode="url"
value={form.hero}
onChange={(e) =>
setForm((f) => ({ ...f, hero: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
<Input
id="lib-header"
type="url"
inputMode="url"
value={form.header}
onChange={(e) =>
setForm((f) => ({ ...f, header: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) =>
setForm((f) => ({ ...f, command: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
{m.library_field_command_help()}
</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{editing ? m.library_save() : m.library_create()}
</Button>
<Button type="button" variant="outline" onClick={closeForm}>
{m.library_cancel()}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{games.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.library_empty()}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => openEdit(game)}
onDelete={() => handleDelete(game)}
deleting={isDeleting}
/>
))}
</div>
)}
</QueryState>
</Section>
);
};
interface GameCardProps {
game: GameEntry;
onEdit: () => void;
onDelete: () => void;
deleting: boolean;
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
* falls back to the wide header, then to a text placeholder. Custom entries get
* edit/delete affordances.
*/
const GameCard: FC<GameCardProps> = ({ game, onEdit, onDelete, deleting }) => {
const isCustom = game.store === "custom";
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({});
const candidates = [game.art.portrait, game.art.header].filter(
(u): u is string => !!u && !failed[u],
);
const src = candidates[0];
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className="size-full object-cover"
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
{game.title}
</div>
)}
<div className="absolute left-2 top-2">
<Badge
variant={isCustom ? "secondary" : "outline"}
className="bg-background/80 backdrop-blur"
>
{storeLabel(game.store)}
</Badge>
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
)}
</div>
<div className="truncate p-2 text-sm font-medium" title={game.title}>
{game.title}
</div>
</Card>
);
};
+1 -2
View File
@@ -23,8 +23,7 @@ export const SectionLogin: FC<{ next?: string }> = ({ next }) => {
}
// Full reload to the target so SSR re-runs WITH the new session cookie. Only a
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
const safe =
next && next.startsWith("/") && !next.startsWith("//") ? next : "/";
const safe = next?.startsWith("/") && !next.startsWith("//") ? next : "/";
window.location.href = safe;
} catch {
setError(true);
+24 -9
View File
@@ -1,3 +1,5 @@
import { ease } from "@unom/style";
import { motion } from "motion/react";
import { type FC, useState } from "react";
import Logo from "@/components/logo";
import { Button } from "@/components/ui/button";
@@ -13,14 +15,28 @@ export const LoginView: FC<{
}> = ({ onSubmit, error, busy }) => {
const [password, setPassword] = useState("");
return (
<div className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex w-[80px] items-center gap-2">
<Logo />
</div>
<CardTitle>{m.login_title()}</CardTitle>
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
<div className="flex flex-col min-h-screen items-center justify-center p-6">
<motion.div
transition={ease.quint(0.9).out}
variants={{ enter: { scale: 1, y: 0 }, from: { scale: 0, y: 100 } }}
className="mb-8 flex w-[120px]"
>
<Logo />
</motion.div>
<Card className="w-full max-w-sm h-fit grow-0">
<CardHeader className="items-start text-left">
<CardTitle className="text-xl">{m.login_title()}</CardTitle>
<p className="text-sm text-muted-foreground">
{m.login_subtitle()}{" "}
<a
href="https://docs.punktfunk.unom.io/docs/forgot-password"
target="_blank"
rel="noreferrer"
className="underline underline-offset-4 hover:text-foreground"
>
{m.login_docs_link()}
</a>
</p>
</CardHeader>
<CardContent>
<form
@@ -35,7 +51,6 @@ export const LoginView: FC<{
<Input
id="pw"
type="password"
// biome-ignore lint/a11y/noAutofocus: the login screen is the sole focus target.
autoFocus
autoComplete="current-password"
value={password}
@@ -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>
);
};
+34 -32
View File
@@ -1,6 +1,6 @@
import Section from "@unom/ui/section";
import { LogOut } from "lucide-react";
import type { FC } from "react";
import { Section } from "@/components/section";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
@@ -17,39 +17,41 @@ export const SectionSettings: FC = () => {
};
return (
<Section>
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.settings_language()}</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
{locales.map((l: Locale) => (
<Button
key={l}
variant={l === current ? "default" : "outline"}
size="sm"
className="uppercase"
onClick={() => changeLocale(l)}
>
{l}
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.settings_language()}</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
{locales.map((l: Locale) => (
<Button
key={l}
variant={l === current ? "default" : "outline"}
size="sm"
className="uppercase"
onClick={() => changeLocale(l)}
>
{l}
</Button>
))}
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.nav_settings()}</CardTitle>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={onLogout}>
<LogOut className="size-4" />
{m.action_logout()}
</Button>
))}
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.nav_settings()}</CardTitle>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={onLogout}>
<LogOut className="size-4" />
{m.action_logout()}
</Button>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
</Section>
);
};
+117
View File
@@ -0,0 +1,117 @@
import { useQueryClient } from "@tanstack/react-query";
import { Circle, Square } from "lucide-react";
import type { FC } from "react";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
import {
getStatsCaptureStatusQueryKey,
getStatsRecordingsListQueryKey,
useStatsCaptureStart,
useStatsCaptureStatus,
useStatsCaptureStop,
} from "@/api/gen/stats/stats";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
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";
import { fmtDuration, kindLabel, Stat } from "./helpers";
/**
* Container: arm/disarm the capture. Owns the polled status query plus start/stop; stopping also
* refreshes the recordings list (owned by the Recordings subsection — invalidated here by key).
*/
export const CaptureControlSection: FC = () => {
const qc = useQueryClient();
const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } });
const start = useStatsCaptureStart();
const stop = useStatsCaptureStop();
const refreshStatus = () =>
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus });
const onStop = () =>
stop.mutate(undefined, {
onSuccess: () => {
refreshStatus();
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
},
});
return (
<CaptureControlCard
status={status}
onStart={onStart}
onStop={onStop}
isStarting={start.isPending}
isStopping={stop.isPending}
/>
);
};
/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */
export const CaptureControlCard: FC<{
status: Loadable<StatsStatus>;
onStart: () => void;
onStop: () => void;
isStarting: boolean;
isStopping: boolean;
}> = ({ status, onStart, onStop, isStarting, isStopping }) => {
const s = status.data;
const armed = s?.armed ?? false;
const elapsed = armed && s ? Date.now() - s.started_unix_ms : 0;
return (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>{m.stats_capture_title()}</span>
{armed ? (
<Badge variant="destructive" className="gap-1.5">
<Circle className="size-2.5 animate-pulse fill-current" />
{m.stats_recording()}
</Badge>
) : (
<Badge variant="outline">{m.stats_idle()}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{m.stats_capture_desc()}
</p>
{armed && s && (
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-sm tabular-nums">
<Stat label={m.stats_elapsed()} value={fmtDuration(elapsed)} />
<Stat label={m.stats_samples()} value={String(s.sample_count)} />
{s.kind && (
<Stat label={m.stats_kind()} value={kindLabel(s.kind)} />
)}
</dl>
)}
<div className="flex gap-2">
{armed ? (
<Button
variant="destructive"
disabled={isStopping}
onClick={onStop}
>
<Square className="size-4" />
{m.stats_stop()}
</Button>
) : (
<Button disabled={isStarting} onClick={onStart}>
<Circle className="size-4 fill-current" />
{m.stats_start()}
</Button>
)}
</div>
</CardContent>
</Card>
</QueryState>
);
};
+82
View File
@@ -0,0 +1,82 @@
import { X } from "lucide-react";
import type { FC } from "react";
import type { Capture } from "@/api/gen/model/capture";
import { useStatsRecordingGet } from "@/api/gen/stats/stats";
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";
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
import { ChartBlock } from "./helpers";
/** Container: the full graph set for the selected recording — fetched by id. */
export const DetailSection: FC<{ id: string; onClose: () => void }> = ({
id,
onClose,
}) => {
const detail = useStatsRecordingGet(id, { query: { enabled: !!id } });
return <DetailCard detail={detail} onClose={onClose} />;
};
/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */
export const DetailCard: FC<{
detail: Loadable<Capture>;
onClose: () => void;
}> = ({ detail, onClose }) => {
const cap = detail.data;
const samples = cap?.samples ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>
{m.stats_detail_title()}
{cap && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
{cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "}
{cap.meta.codec.toUpperCase()}
</span>
)}
</span>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_close()}
onClick={onClose}
>
<X className="size-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<QueryState
isLoading={detail.isLoading}
error={detail.error}
refetch={detail.refetch}
>
{samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{m.stats_no_samples()}
</p>
) : (
<div className="space-y-8">
<ChartBlock
title={m.stats_latency_title()}
desc={m.stats_latency_desc()}
>
<LatencyChart samples={samples} toggle />
</ChartBlock>
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
<ChartBlock title={m.stats_health_title()}>
<HealthChart samples={samples} />
</ChartBlock>
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
+57
View File
@@ -0,0 +1,57 @@
import type { FC } from "react";
import type { Capture } from "@/api/gen/model/capture";
import {
useStatsCaptureLive,
useStatsCaptureStatus,
} from "@/api/gen/stats/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { LatencyChart, ThroughputChart } from "./charts";
import { ChartBlock } from "./helpers";
/**
* Container: the live graphs. Self-gates on the capture being armed — it shares the status query
* (same key) with the control card, and only fetches the in-progress capture while armed (it 404s
* when idle). Renders nothing when no capture is running.
*/
export const LiveSection: FC = () => {
const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } });
const armed = status.data?.armed ?? false;
const live = useStatsCaptureLive({
query: { refetchInterval: 2_000, enabled: armed },
});
if (!armed) return null;
return <LiveCard live={live} />;
};
/** Live graphs while a capture is armed: latency stack + throughput. */
export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
const samples = live.data?.samples ?? [];
return (
<Card>
<CardHeader>
<CardTitle>{m.stats_live_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
{samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{m.stats_live_waiting()}
</p>
) : (
<>
<ChartBlock
title={m.stats_latency_title()}
desc={m.stats_latency_desc()}
>
<LatencyChart samples={samples} />
</ChartBlock>
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
</>
)}
</CardContent>
</Card>
);
};
+207
View File
@@ -0,0 +1,207 @@
import { useQueryClient } from "@tanstack/react-query";
import { Download, Eye, Trash2 } from "lucide-react";
import type { FC } from "react";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
import {
getStatsRecordingsListQueryKey,
statsRecordingGet,
useStatsRecordingDelete,
useStatsRecordingsList,
} from "@/api/gen/stats/stats";
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 type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { fmtDuration, fmtTimestamp, kindLabel } from "./helpers";
/**
* Container: the saved recordings. Owns the list query, delete, and the JSON export. Selection is
* the parent's UI state (it also drives the detail card), passed through here for row highlight +
* to clear it when the selected recording is deleted.
*/
export const RecordingsSection: FC<{
selectedId: string | null;
onSelect: (id: string | null) => void;
}> = ({ selectedId, onSelect }) => {
const qc = useQueryClient();
const recordings = useStatsRecordingsList();
const del = useStatsRecordingDelete();
const onDelete = (id: string) => {
if (!confirm(m.stats_delete_confirm())) return;
del.mutate(
{ id },
{
onSuccess: () => {
if (selectedId === id) onSelect(null);
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
},
},
);
};
// Export the full Capture JSON via a one-off GET → blob download.
const onDownload = async (id: string) => {
try {
const cap = await statsRecordingGet(id);
const blob = new Blob([JSON.stringify(cap, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${id}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
// Best-effort export; the recording GET surfaces its own errors via the detail view.
}
};
return (
<RecordingsCard
recordings={recordings}
selectedId={selectedId}
onSelect={onSelect}
onDownload={onDownload}
onDelete={onDelete}
isDeleting={del.isPending}
/>
);
};
/** Saved recordings, with View / Download / Delete row actions. */
export const RecordingsCard: FC<{
recordings: Loadable<CaptureMeta[]>;
selectedId: string | null;
onSelect: (id: string | null) => void;
onDownload: (id: string) => void;
onDelete: (id: string) => void;
isDeleting: boolean;
}> = ({
recordings,
selectedId,
onSelect,
onDownload,
onDelete,
isDeleting,
}) => {
const rows = recordings.data ?? [];
return (
<Card>
<CardHeader>
<h2 className="text-lg font-medium">{m.stats_recordings_title()}</h2>
</CardHeader>
<QueryState
isLoading={recordings.isLoading}
error={recordings.error}
refetch={recordings.refetch}
>
{rows.length === 0 ? (
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.stats_recordings_empty()}
</CardContent>
) : (
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.stats_col_time()}</TableHead>
<TableHead>{m.stats_col_kind()}</TableHead>
<TableHead>{m.stats_col_resolution()}</TableHead>
<TableHead>{m.stats_col_codec()}</TableHead>
<TableHead className="text-right">
{m.stats_col_duration()}
</TableHead>
<TableHead className="text-right">
{m.stats_col_samples()}
</TableHead>
<TableHead className="w-32" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow
key={r.id}
data-state={selectedId === r.id ? "selected" : undefined}
>
<TableCell className="whitespace-nowrap font-medium">
{fmtTimestamp(r.started_unix_ms)}
</TableCell>
<TableCell>
<Badge
variant={
r.kind === "gamestream" ? "secondary" : "default"
}
>
{kindLabel(r.kind)}
</Badge>
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{r.width}×{r.height}@{r.fps}
</TableCell>
<TableCell className="uppercase text-muted-foreground">
{r.codec}
</TableCell>
<TableCell className="text-right tabular-nums">
{fmtDuration(r.duration_ms)}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.sample_count}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={m.stats_view()}
title={m.stats_view()}
onClick={() =>
onSelect(selectedId === r.id ? null : r.id)
}
>
<Eye className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_download()}
title={m.stats_download()}
onClick={() => onDownload(r.id)}
>
<Download className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_delete()}
title={m.stats_delete()}
disabled={isDeleting}
onClick={() => onDelete(r.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
)}
</QueryState>
</Card>
);
};
+43
View File
@@ -0,0 +1,43 @@
import type { FC, ReactNode } from "react";
import { m } from "@/paraglide/messages";
/** ms → `m:ss`. */
export function fmtDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
export function fmtTimestamp(unixMs: number): string {
if (!unixMs) return "—";
return new Date(unixMs).toLocaleString();
}
export function kindLabel(kind: string): string {
if (kind === "gamestream") return m.stats_kind_gamestream();
if (kind === "native") return m.stats_kind_native();
return kind;
}
export const Stat: FC<{ label: string; value: string }> = ({
label,
value,
}) => (
<div className="flex flex-col">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
);
export const ChartBlock: FC<{
title: string;
desc?: string;
children: ReactNode;
}> = ({ title, desc, children }) => (
<div className="space-y-2">
<div>
<h3 className="text-sm font-medium">{title}</h3>
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
</div>
{children}
</div>
);
+18 -95
View File
@@ -1,108 +1,31 @@
import { useQueryClient } from "@tanstack/react-query";
import { type FC, useState } from "react";
import {
getStatsCaptureStatusQueryKey,
getStatsRecordingsListQueryKey,
statsRecordingGet,
useStatsCaptureLive,
useStatsCaptureStart,
useStatsCaptureStatus,
useStatsCaptureStop,
useStatsRecordingDelete,
useStatsRecordingGet,
useStatsRecordingsList,
} from "@/api/gen/stats/stats";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { CaptureControlSection } from "./CaptureControl";
import { DetailSection } from "./Detail";
import { LiveSection } from "./LiveCard";
import { RecordingsSection } from "./Recordings";
import { StatsView } from "./view";
// Performance = four independent, self-contained cards (control · live · recordings · detail), each
// owning its own queries + mutations in its own file. This container holds only the shared UI state
// — which recording is selected — that links the recordings table to the detail card. The layout
// lives in StatsView so the live page and the Storybook story arrange the cards identically.
export const SectionStats: FC = () => {
useLocale();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
// Poll the capture status (drives the control card + whether the live chart shows).
const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } });
const armed = status.data?.armed ?? false;
// Live in-progress capture — only fetched while armed (404s when idle).
const live = useStatsCaptureLive({
query: { refetchInterval: 2_000, enabled: armed },
});
const recordings = useStatsRecordingsList();
// Selected recording detail — only fetched once a row is chosen.
const detail = useStatsRecordingGet(selectedId ?? "", {
query: { enabled: !!selectedId },
});
const start = useStatsCaptureStart();
const stop = useStatsCaptureStop();
const del = useStatsRecordingDelete();
const refreshStatus = () =>
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
const refreshRecordings = () =>
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus });
const onStop = () =>
stop.mutate(undefined, {
onSuccess: () => {
refreshStatus();
refreshRecordings();
},
});
const onDelete = (id: string) => {
if (!confirm(m.stats_delete_confirm())) return;
del.mutate(
{ id },
{
onSuccess: () => {
if (selectedId === id) setSelectedId(null);
refreshRecordings();
},
},
);
};
// Export the full Capture JSON via a one-off GET → blob download.
const onDownload = async (id: string) => {
try {
const cap = await statsRecordingGet(id);
const blob = new Blob([JSON.stringify(cap, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${id}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
// Best-effort export; the recording GET surfaces its own errors via the detail view.
}
};
return (
<StatsView
status={status}
live={live}
recordings={recordings}
detail={detail}
selectedId={selectedId}
onStart={onStart}
onStop={onStop}
onSelect={setSelectedId}
onDownload={onDownload}
onDelete={onDelete}
isStarting={start.isPending}
isStopping={stop.isPending}
isDeleting={del.isPending}
control={<CaptureControlSection />}
live={<LiveSection />}
recordings={
<RecordingsSection selectedId={selectedId} onSelect={setSelectedId} />
}
detail={
selectedId ? (
<DetailSection id={selectedId} onClose={() => setSelectedId(null)} />
) : null
}
/>
);
};
+21 -390
View File
@@ -1,399 +1,30 @@
import { Circle, Download, Eye, Square, Trash2, X } from "lucide-react";
import type { FC } from "react";
import type { Capture } from "@/api/gen/model/capture";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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";
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
/** ms → `m:ss`. */
function fmtDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
function fmtTimestamp(unixMs: number): string {
if (!unixMs) return "—";
return new Date(unixMs).toLocaleString();
}
function kindLabel(kind: string): string {
if (kind === "gamestream") return m.stats_kind_gamestream();
if (kind === "native") return m.stats_kind_native();
return kind;
}
export interface StatsViewProps {
status: Loadable<StatsStatus>;
live: Loadable<Capture>;
recordings: Loadable<CaptureMeta[]>;
detail: Loadable<Capture>;
selectedId: string | null;
onStart: () => void;
onStop: () => void;
onSelect: (id: string | null) => void;
onDownload: (id: string) => void;
onDelete: (id: string) => void;
isStarting: boolean;
isStopping: boolean;
isDeleting: boolean;
}
export const StatsView: FC<StatsViewProps> = (props) => {
const armed = props.status.data?.armed ?? false;
return (
<Section>
/**
* The Performance page LAYOUT — the single source of how the cards stack. 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. `live`
* and `detail` are nullable slots — the page passes them only when armed / a recording is selected.
*/
export const StatsView: FC<{
control: ReactNode;
live: ReactNode;
recordings: ReactNode;
detail: ReactNode;
}> = ({ control, live, recordings, detail }) => (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.stats_title()}</h1>
<p className="text-sm text-muted-foreground">{m.stats_subtitle()}</p>
</div>
<CaptureControlCard
status={props.status}
onStart={props.onStart}
onStop={props.onStop}
isStarting={props.isStarting}
isStopping={props.isStopping}
/>
{armed && <LiveCard live={props.live} />}
<RecordingsCard
recordings={props.recordings}
selectedId={props.selectedId}
onSelect={props.onSelect}
onDownload={props.onDownload}
onDelete={props.onDelete}
isDeleting={props.isDeleting}
/>
{props.selectedId && (
<DetailCard
detail={props.detail}
onClose={() => props.onSelect(null)}
/>
)}
</Section>
);
};
/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */
const CaptureControlCard: FC<{
status: Loadable<StatsStatus>;
onStart: () => void;
onStop: () => void;
isStarting: boolean;
isStopping: boolean;
}> = ({ status, onStart, onStop, isStarting, isStopping }) => {
const s = status.data;
const armed = s?.armed ?? false;
const elapsed = armed && s ? Date.now() - s.started_unix_ms : 0;
return (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>{m.stats_capture_title()}</span>
{armed ? (
<Badge variant="destructive" className="gap-1.5">
<Circle className="size-2.5 animate-pulse fill-current" />
{m.stats_recording()}
</Badge>
) : (
<Badge variant="outline">{m.stats_idle()}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{m.stats_capture_desc()}
</p>
{armed && s && (
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-sm tabular-nums">
<Stat label={m.stats_elapsed()} value={fmtDuration(elapsed)} />
<Stat label={m.stats_samples()} value={String(s.sample_count)} />
{s.kind && (
<Stat label={m.stats_kind()} value={kindLabel(s.kind)} />
)}
</dl>
)}
<div className="flex gap-2">
{armed ? (
<Button
variant="destructive"
disabled={isStopping}
onClick={onStop}
>
<Square className="size-4" />
{m.stats_stop()}
</Button>
) : (
<Button disabled={isStarting} onClick={onStart}>
<Circle className="size-4 fill-current" />
{m.stats_start()}
</Button>
)}
</div>
</CardContent>
</Card>
</QueryState>
);
};
const Stat: FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="flex flex-col">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
);
/** Live graphs while a capture is armed: latency stack + throughput. */
const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
const samples = live.data?.samples ?? [];
return (
<Card>
<CardHeader>
<CardTitle>{m.stats_live_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
{samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{m.stats_live_waiting()}
</p>
) : (
<>
<ChartBlock
title={m.stats_latency_title()}
desc={m.stats_latency_desc()}
>
<LatencyChart samples={samples} />
</ChartBlock>
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
</>
)}
</CardContent>
</Card>
);
};
/** Saved recordings, with View / Download / Delete row actions. */
const RecordingsCard: FC<{
recordings: Loadable<CaptureMeta[]>;
selectedId: string | null;
onSelect: (id: string | null) => void;
onDownload: (id: string) => void;
onDelete: (id: string) => void;
isDeleting: boolean;
}> = ({
recordings,
selectedId,
onSelect,
onDownload,
onDelete,
isDeleting,
}) => {
const rows = recordings.data ?? [];
return (
<div className="space-y-2">
<h2 className="text-lg font-medium">{m.stats_recordings_title()}</h2>
<QueryState
isLoading={recordings.isLoading}
error={recordings.error}
refetch={recordings.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.stats_recordings_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.stats_col_time()}</TableHead>
<TableHead>{m.stats_col_kind()}</TableHead>
<TableHead>{m.stats_col_resolution()}</TableHead>
<TableHead>{m.stats_col_codec()}</TableHead>
<TableHead className="text-right">
{m.stats_col_duration()}
</TableHead>
<TableHead className="text-right">
{m.stats_col_samples()}
</TableHead>
<TableHead className="w-32" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow
key={r.id}
data-state={selectedId === r.id ? "selected" : undefined}
>
<TableCell className="whitespace-nowrap font-medium">
{fmtTimestamp(r.started_unix_ms)}
</TableCell>
<TableCell>
<Badge
variant={
r.kind === "gamestream" ? "secondary" : "default"
}
>
{kindLabel(r.kind)}
</Badge>
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{r.width}×{r.height}@{r.fps}
</TableCell>
<TableCell className="uppercase text-muted-foreground">
{r.codec}
</TableCell>
<TableCell className="text-right tabular-nums">
{fmtDuration(r.duration_ms)}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.sample_count}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={m.stats_view()}
title={m.stats_view()}
onClick={() =>
onSelect(selectedId === r.id ? null : r.id)
}
>
<Eye className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_download()}
title={m.stats_download()}
onClick={() => onDownload(r.id)}
>
<Download className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_delete()}
title={m.stats_delete()}
disabled={isDeleting}
onClick={() => onDelete(r.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
{control}
{live}
{recordings}
{detail}
</div>
);
};
/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */
const DetailCard: FC<{ detail: Loadable<Capture>; onClose: () => void }> = ({
detail,
onClose,
}) => {
const cap = detail.data;
const samples = cap?.samples ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>
{m.stats_detail_title()}
{cap && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
{cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "}
{cap.meta.codec.toUpperCase()}
</span>
)}
</span>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_close()}
onClick={onClose}
>
<X className="size-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<QueryState
isLoading={detail.isLoading}
error={detail.error}
refetch={detail.refetch}
>
{samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{m.stats_no_samples()}
</p>
) : (
<div className="space-y-8">
<ChartBlock
title={m.stats_latency_title()}
desc={m.stats_latency_desc()}
>
<LatencyChart samples={samples} toggle />
</ChartBlock>
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
<ChartBlock title={m.stats_health_title()}>
<HealthChart samples={samples} />
</ChartBlock>
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
const ChartBlock: FC<{
title: string;
desc?: string;
children: React.ReactNode;
}> = ({ title, desc, children }) => (
<div className="space-y-2">
<div>
<h3 className="text-sm font-medium">{title}</h3>
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
</div>
{children}
</div>
</Section>
);
+1 -8
View File
@@ -27,14 +27,7 @@ function ShellHarness({ initialPath }: { initialPath: string }) {
),
});
const navPaths = [
"/",
"/host",
"/library",
"/clients",
"/pairing",
"/settings",
];
const navPaths = ["/", "/host", "/library", "/pairing", "/settings"];
const navRoutes = navPaths.map((path) =>
createRoute({
getParentRoute: () => rootRoute,
-20
View File
@@ -1,20 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ClientsView } from "@/sections/Clients/view";
import { pairedClients } from "./lib/fixtures";
const meta = {
title: "Pages/Clients",
component: ClientsView,
args: { onUnpair: () => {}, isUnpairing: false },
} satisfies Meta<typeof ClientsView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Paired: Story = {
args: { clients: { data: pairedClients, isLoading: false, error: null } },
};
export const Empty: Story = {
args: { clients: { data: [], isLoading: false, error: null } },
};
+45 -13
View File
@@ -1,26 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LibraryView } from "@/sections/Library/view";
import { GameForm } from "@/sections/Library/GameForm";
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
import { library } from "./lib/fixtures";
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
const emptyForm = {
title: "",
portrait: "",
hero: "",
header: "",
command: "",
};
// The overview grid and the add/edit form are separate components now, so the stories
// render each on its own (no combined page view).
const meta = {
title: "Pages/Library",
component: LibraryView,
args: {
onCreate: () => Promise.resolve(),
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
isSaving: false,
isDeleting: false,
},
} satisfies Meta<typeof LibraryView>;
parameters: { layout: "padded" },
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj;
export const Populated: Story = {
args: { library: { data: library, isLoading: false, error: null } },
render: () => (
<LibraryGrid
library={{ data: library, ...idle }}
onEdit={noop}
onDelete={noop}
isDeleting={false}
/>
),
};
export const Empty: Story = {
args: { library: { data: [], isLoading: false, error: null } },
render: () => (
<LibraryGrid
library={{ data: [], ...idle }}
onEdit={noop}
onDelete={noop}
isDeleting={false}
/>
),
};
export const AddForm: Story = {
render: () => (
<GameForm
initial={emptyForm}
mode="add"
onSubmit={noop}
onCancel={noop}
isSaving={false}
/>
),
};
+1 -1
View File
@@ -13,4 +13,4 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Error: Story = { args: { error: true } };
export const ErrorState: Story = { args: { error: true } };
+59 -23
View File
@@ -1,8 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MoonlightPairing } from "@/sections/Pairing/MoonlightPairingCard";
import { NativePairingCard } from "@/sections/Pairing/NativePairingCard";
import { PairedDevices } from "@/sections/Pairing/PairedDevices";
import { PendingDevices } from "@/sections/Pairing/PendingDevices";
import { PairingView } from "@/sections/Pairing/view";
import {
nativeClients,
nativePairArmed,
pairedClients,
pairingIdle,
pendingDevices,
} from "./lib/fixtures";
@@ -10,39 +15,70 @@ import {
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
// Renders the REAL page layout (PairingView) — the same component index.tsx uses. The live page
// fills its slots with the self-contained containers; here we fill them with the pure cards + mock
// state, so there's no duplicated composition to drift.
const meta = {
title: "Pages/Pairing",
component: PairingView,
parameters: { layout: "padded" },
args: {
onApprove: noop,
onDeny: noop,
pendingBusy: false,
onArm: noop,
onDisarm: noop,
isArming: false,
isDisarming: false,
onUnpair: noop,
isUnpairing: false,
pin: "",
onPinChange: noop,
onSubmitPin: noop,
isSubmittingPin: false,
pinSuccess: false,
pinError: false,
},
} satisfies Meta<typeof PairingView>;
export default meta;
type Story = StoryObj<typeof meta>;
// The marketing state: a PIN armed for a phone, one device knocking for delegated
// approval, two already-paired native clients.
// The marketing state: one device knocking for delegated approval, a PIN armed for a phone, the
// consolidated paired-devices list (native + Moonlight), idle Moonlight pairing.
export const Armed: Story = {
args: {
pending: { data: pendingDevices, ...idle },
native: { data: nativePairArmed, ...idle },
clients: { data: nativeClients, ...idle },
moonlight: { data: pairingIdle, ...idle },
pending: (
<PendingDevices
pending={{ data: pendingDevices, ...idle }}
onApprove={noop}
onDeny={noop}
busy={false}
/>
),
native: (
<NativePairingCard
status={{ data: nativePairArmed, ...idle }}
onArm={noop}
onDisarm={noop}
isArming={false}
isDisarming={false}
/>
),
moonlight: (
<MoonlightPairing
pairing={{ data: pairingIdle, ...idle }}
pin=""
onPinChange={noop}
onSubmit={noop}
isSubmitting={false}
isSuccess={false}
isError={false}
/>
),
paired: (
<PairedDevices
rows={[
...nativeClients.map((c) => ({
protocol: "native" as const,
fingerprint: c.fingerprint,
name: c.name,
})),
...pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
})),
]}
isLoading={false}
error={null}
refetch={noop}
onUnpair={noop}
isUnpairing={false}
/>
),
},
};
+52 -23
View File
@@ -1,48 +1,77 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CaptureControlCard } from "@/sections/Stats/CaptureControl";
import { DetailCard } from "@/sections/Stats/Detail";
import { RecordingsCard } from "@/sections/Stats/Recordings";
import { StatsView } from "@/sections/Stats/view";
import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures";
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
// Renders the REAL page layout (StatsView) — the same component index.tsx uses — with the pure
// cards + mock state in its slots, so there's no duplicated composition to drift.
const meta = {
title: "Pages/Stats",
component: StatsView,
parameters: { layout: "padded" },
args: {
onStart: noop,
onStop: noop,
onSelect: noop,
onDownload: noop,
onDelete: noop,
isStarting: false,
isStopping: false,
isDeleting: false,
},
} satisfies Meta<typeof StatsView>;
export default meta;
type Story = StoryObj<typeof meta>;
// A finished run open in the detail view: recordings table populated and the full
// graph set (latency stack · throughput · loss/FEC) rendered from a deterministic
// fixture series — no live host or capture needed.
// A finished run open in the detail view: recordings table populated and the full graph set
// (latency stack · throughput · loss/FEC) rendered from a deterministic fixture series — no live
// host or capture needed.
export const Recording: Story = {
args: {
status: { data: statsStatusIdle, ...idle },
live: { data: undefined, ...idle },
recordings: { data: captureMetas, ...idle },
detail: { data: captureDetail, ...idle },
selectedId: captureMetas[0]?.id ?? null,
control: (
<CaptureControlCard
status={{ data: statsStatusIdle, ...idle }}
onStart={noop}
onStop={noop}
isStarting={false}
isStopping={false}
/>
),
live: null,
recordings: (
<RecordingsCard
recordings={{ data: captureMetas, ...idle }}
selectedId={captureMetas[0]?.id ?? null}
onSelect={noop}
onDownload={noop}
onDelete={noop}
isDeleting={false}
/>
),
detail: (
<DetailCard detail={{ data: captureDetail, ...idle }} onClose={noop} />
),
},
};
export const Empty: Story = {
args: {
status: { data: statsStatusIdle, ...idle },
live: { data: undefined, ...idle },
recordings: { data: [], ...idle },
detail: { data: undefined, ...idle },
selectedId: null,
control: (
<CaptureControlCard
status={{ data: statsStatusIdle, ...idle }}
onStart={noop}
onStop={noop}
isStarting={false}
isStopping={false}
/>
),
live: null,
recordings: (
<RecordingsCard
recordings={{ data: [], ...idle }}
selectedId={null}
onSelect={noop}
onDownload={noop}
onDelete={noop}
isDeleting={false}
/>
),
detail: null,
},
};
+7
View File
@@ -216,6 +216,13 @@ export const pendingDevices: PendingDevice[] = [
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
age_secs: 8,
},
{
id: 2,
name: "Mac Mini",
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
age_secs: 30,
},
];
export const nativeClients: NativeClient[] = [
+17 -11
View File
@@ -1,11 +1,11 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import tailwindcss from "@tailwindcss/vite";
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths";
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
// because the TanStack Nitro plugin doesn't auto-scan a server/ dir.
@@ -41,11 +41,17 @@ export default defineConfig({
// proxies to the management host injecting the bearer token server-side) — NOT a static
// routeRule, so the proxy runs behind the login gate and reads env at runtime.
nitroV2Plugin({
// node-server (not bun): a STANDALONE node HTTP server (`node .output/server/index.mjs`
// listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web
// .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with
// bun; only the runtime target changes. (dev `vite dev` is unaffected.)
preset: "node-server",
// bun + a CUSTOM entry: Nitro's `bun` preset bundles the handler, and `entry` swaps the
// stock self-listening entry for ours (`nitro-entry/bun-https.mjs`), which calls
// `Bun.serve({ tls })` so the console is served over HTTPS (HTTP/1.1 over TLS) with the
// host's own identity cert. (No HTTP/2 — Bun.serve has no h2 server — and no HTTP/3, which a
// browser won't speak against this self-signed, no-SAN host cert.) Bun is the runtime
// everywhere now — the Windows installer already bundles it, and the punktfunk-web .deb
// vendors it (it can't be `node`: `Bun.serve` is a bun API). (dev `vite dev` is unaffected.)
preset: "bun",
entry: fileURLToPath(
new URL("./nitro-entry/bun-https.mjs", import.meta.url),
),
// BUNDLE every dependency into the server output (no externalized node_modules). Three wins:
// (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep
// tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) the output is a
+4 -4
View File
@@ -1,8 +1,8 @@
import { defineConfig } from "vite";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import tailwindcss from "@tailwindcss/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths";
// Storybook builds the components in isolation — WITHOUT the TanStack Start /
// Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the
+21 -13
View File
@@ -2,25 +2,32 @@
rem punktfunk web console launcher - DEV layout (in-repo tree). The PunktfunkWeb scheduled task
rem (boot trigger, SYSTEM, restart-on-failure) runs this at startup. It sources the host's mgmt bearer
rem token + the console login password from %ProgramData%\punktfunk\, points the /api proxy at the
rem host's loopback HTTPS mgmt API, and runs the self-contained (no-node_modules) Nitro server on :3000.
rem %~dp0 = <repo>\web\ .
rem host's loopback HTTPS mgmt API, and serves the self-contained (no-node_modules) Nitro console over
rem HTTPS (HTTP/1.1 over TLS) on :3000 with the host's identity cert. %~dp0 = <repo>\web\ .
rem
rem DEV vs the installed launcher (scripts\windows\web-run.cmd): the dev host service runs from
rem target\release (not the installed {app} tree), so this runs the in-repo web\.output with the
rem system node instead of {app}\bun\bun.exe + {app}\web\.output. Rebuild after a web change with
rem `bun run build` in web\ ; no edit needed here.
rem target\release (not the installed {app} tree), so this runs the in-repo web\.output. The console
rem now runs on bun (the Nitro `bun` preset + Bun.serve TLS entry), so set BUN
rem below to your bun.exe. Rebuild after a web change with `bun run build` in web\ ; no edit needed.
setlocal EnableExtensions
set "PFDATA=%ProgramData%\punktfunk"
set "TOKENFILE=%PFDATA%\mgmt-token"
set "PWFILE=%PFDATA%\web-password"
set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token on first run. Until it exists the proxy has no credential,
rem so fail and let the task's restart-on-failure retry (mirrors the installed launcher / Linux unit).
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the
rem installed launcher / Linux unit) rather than silently serving plain HTTP.
if not exist "%TOKENFILE%" (
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
exit /b 1
)
if not exist "%CERTFILE%" (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
exit /b 1
)
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
rem Split on the first '=' and import each into the environment.
@@ -32,15 +39,16 @@ set "PORT=3000"
set "HOST=0.0.0.0"
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
set "PUNKTFUNK_UI_SECURE=1"
set "NODE=C:\Users\Public\node-v22.11.0-win-x64\node.exe"
rem Bun runtime (override BUN if yours lives elsewhere / is on PATH as just `bun`).
if not defined BUN set "BUN=bun.exe"
set "SERVER=%~dp0.output\server\index.mjs"
if not exist "%NODE%" (
echo [punktfunk-web] node runtime missing at "%NODE%".
exit /b 1
)
if not exist "%SERVER%" (
echo [punktfunk-web] built server missing at "%SERVER%" - build it: cd web ^&^& bun run build
exit /b 1
)
"%NODE%" "%SERVER%"
"%BUN%" "%SERVER%"
+14 -4
View File
@@ -3,18 +3,28 @@
# On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire
# everything automatically —
# punktfunk-web.service sets PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990, NODE_TLS_REJECT_UNAUTHORIZED=0,
# PORT=3000, HOST=0.0.0.0, and sources:
# PORT=3000, HOST=0.0.0.0, the PUNKTFUNK_UI_TLS_* cert paths + PUNKTFUNK_UI_SECURE=1, and sources:
# ~/.config/punktfunk/mgmt-token (written by the host's `serve` — the shared bearer token)
# ~/.config/punktfunk/web-password (written by punktfunk-web-init — the console login password)
# ~/.config/punktfunk/{cert,key}.pem (the host identity — the console serves HTTPS with it)
#
# This file documents the variables for a MANUAL deploy (running `node .output/server/index.mjs`
# yourself). The mgmt API is HTTPS with the host's self-signed loopback cert, so the proxy needs
# NODE_TLS_REJECT_UNAUTHORIZED=0 (its only outbound TLS hop is that loopback connection).
# This file documents the variables for a MANUAL deploy (running `bun .output/server/index.mjs`
# yourself — the console runs on bun: `Bun.serve` is a Bun API, node can't run it). The mgmt API is
# HTTPS with the host's self-signed loopback cert, so the proxy needs NODE_TLS_REJECT_UNAUTHORIZED=0
# (its only outbound TLS hop is that loopback connection).
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
NODE_TLS_REJECT_UNAUTHORIZED=0
PORT=3000
HOST=0.0.0.0
# Serve the console over HTTPS (HTTP/1.1 over TLS) with the host's own identity cert. BOTH paths
# set ⇒ HTTPS. (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't speak
# HTTP/3/QUIC against this self-signed, no-SAN host cert — so HTTP/1.1 over TLS is what's offered.)
PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
# Mark the session cookie Secure (required once served over TLS):
PUNKTFUNK_UI_SECURE=1
# Match the host's ~/.config/punktfunk/mgmt-token (auto-generated by the host if unset):
PUNKTFUNK_MGMT_TOKEN=