diff --git a/.gitea/workflows/web-screenshots.yml b/.gitea/workflows/web-screenshots.yml new file mode 100644 index 0000000..d9f10ff --- /dev/null +++ b/.gitea/workflows/web-screenshots.yml @@ -0,0 +1,53 @@ +# Management-console screenshots for the app/marketing listings. Captured from the +# built Storybook with headless Chromium (web/tools/screenshots.mjs) — the page +# stories render from fixtures, so no live mgmt API, login, or GPU is needed. This +# is the web analogue of apple.yml's `screenshots` job, but gated to STABLE RELEASE +# tags only (the console has no release workflow of its own — it ships inside the +# host packaging). Best-effort: a standalone workflow, so a failure here reds +# nothing else. PNGs land as a 30-day artifact; they are not committed or published. +name: web-screenshots + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + screenshots: + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + container: + image: oven/bun:1 + timeout-minutes: 30 + defaults: + run: + working-directory: web + steps: + # oven/bun ships neither git nor a real node (the driver runs under node), and + # the slim Debian base lacks a CA bundle — without it actions/checkout's HTTPS + # fetch dies with "Problem with the SSL CA cert" (same as ci.yml's web job). + - name: Install git + node + CA certs + working-directory: / + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs + - uses: actions/checkout@v4 + # --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen + # explicitly since build-storybook has no prebuild hook of its own. + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + - name: Generate API client + i18n messages + run: bun run codegen + # Pulls the matching Chromium build + the apt libs it needs (root in-container). + - name: Install Chromium + run: bunx playwright install --with-deps chromium + - name: Build Storybook + run: bun run build-storybook + - name: Capture screenshots + run: bun run screenshots + - name: Upload screenshots + if: always() + # v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip. + uses: actions/upload-artifact@v3 + with: + name: punktfunk-web-console-screenshots + path: web/screenshots + retention-days: 30 diff --git a/web/.gitignore b/web/.gitignore index 552053b..26f6003 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -4,6 +4,7 @@ node_modules .nitro dist storybook-static +screenshots *.local # Generated, not committed — regenerated by codegen (see package.json scripts): diff --git a/web/bun.lock b/web/bun.lock index 5d6897d..59ddec0 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -33,6 +33,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5", "orval": "^8.16.0", + "playwright": "^1.61.1", "storybook": "^10.4.6", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.2.0", @@ -1389,7 +1390,7 @@ "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1837,6 +1838,10 @@ "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -2427,6 +2432,8 @@ "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "rollup-plugin-visualizer/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "sass/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -2445,6 +2452,8 @@ "tsdown/hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "unctx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -2463,6 +2472,8 @@ "vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -2507,6 +2518,8 @@ "rollup-plugin-visualizer/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "sass/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "sass/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/web/package.json b/web/package.json index 21c7546..33f5949 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,9 @@ "api:gen": "orval --config orval.config.ts", "lint": "tsc --noEmit", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "screenshots": "node tools/screenshots.mjs", + "screenshots:build": "bun run build-storybook && node tools/screenshots.mjs" }, "dependencies": { "@fontsource-variable/geist": "^5.2.9", @@ -45,6 +47,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5", "orval": "^8.16.0", + "playwright": "^1.61.1", "storybook": "^10.4.6", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.2.0", diff --git a/web/src/sections/Pairing/index.tsx b/web/src/sections/Pairing/index.tsx index b730aff..ceb0ae9 100644 --- a/web/src/sections/Pairing/index.tsx +++ b/web/src/sections/Pairing/index.tsx @@ -1,13 +1,4 @@ import { useQueryClient } from "@tanstack/react-query"; -import { - CheckCircle2, - KeyRound, - Smartphone, - Timer, - Trash2, - UserPlus, - X, -} from "lucide-react"; import { type FC, useState } from "react"; import { getGetNativePairingQueryKey, @@ -27,224 +18,57 @@ import { useGetPairingStatus, useSubmitPairingPin, } from "@/api/gen/pairing/pairing"; -import { QueryState } from "@/components/query-state"; -import { Section } from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { useLocale } from "@/lib/i18n"; import { m } from "@/paraglide/messages"; +import { PairingView } from "./view"; -/** Seconds → `m:ss`. */ -function fmtTime(secs: number): string { - const s = Math.max(0, Math.floor(secs)); - return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; -} - -// Pairing composes four independent sub-cards, each its own little container -// (own query + mutations). They share the page's staggered entrance via
. +// 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.) export const SectionPairing: FC = () => { useLocale(); - return ( -
-

{m.pairing_title()}

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

- - {m.pairing_pending_title()} -

-

- {m.pairing_pending_desc()} -

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

- {m.pairing_native_disabled()} -

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

{m.pairing_native_enter()}

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

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

- )} - -
- ) : ( - <> -

- {m.pairing_native_desc()} -

- - - )} -
-
-
- ); -} - -/** The paired native (punktfunk/1) devices, with unpair. */ -function NativeDevices() { - const qc = useQueryClient(); - const clients = useListNativeClients(); - const unpair = useUnpairNativeClient(); - const rows = clients.data ?? []; + const 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; @@ -257,73 +81,7 @@ function NativeDevices() { ); }; - return ( -
-

{m.pairing_native_devices()}

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

{m.pairing_idle()}

- ) : ( -
-

{m.pairing_waiting()}

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

- - {m.pairing_success()} -

- )} - {submit.isError && ( -

{m.pairing_failed()}

- )} -
- )} -
-
-
+ ); -} +}; diff --git a/web/src/sections/Pairing/view.tsx b/web/src/sections/Pairing/view.tsx new file mode 100644 index 0000000..91c6fd5 --- /dev/null +++ b/web/src/sections/Pairing/view.tsx @@ -0,0 +1,387 @@ +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 { 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")}`; +} + +/** 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; + onApprove: (id: number, currentName: string) => void; + onDeny: (id: number) => void; + pendingBusy: boolean; + + native: Loadable; + onArm: () => void; + onDisarm: () => void; + isArming: boolean; + isDisarming: boolean; + + clients: Loadable; + onUnpair: (fingerprint: string) => void; + isUnpairing: boolean; + + moonlight: Loadable; + 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 = (props) => ( +
+

{m.pairing_title()}

+ + + + +
+); + +/** + * 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; + 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 ( +
+

+ + {m.pairing_pending_title()} +

+

+ {m.pairing_pending_desc()} +

+ + + + + + {rows.map((p) => ( + + {p.name} + + {p.fingerprint.slice(0, 16)}… + + + {fmtAge(p.age_secs)} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+
+ ); +}; + +/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */ +const NativePairingCard: FC<{ + status: Loadable; + onArm: () => void; + onDisarm: () => void; + isArming: boolean; + isDisarming: boolean; +}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => { + const d = status.data; + return ( + + + + + + {m.pairing_native_title()} + + + + {!d?.enabled ? ( +

+ {m.pairing_native_disabled()} +

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

{m.pairing_native_enter()}

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

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

+ )} + +
+ ) : ( + <> +

+ {m.pairing_native_desc()} +

+ + + )} +
+
+
+ ); +}; + +/** The paired native (punktfunk/1) devices, with unpair. */ +const NativeDevicesCard: FC<{ + clients: Loadable; + onUnpair: (fingerprint: string) => void; + isUnpairing: boolean; +}> = ({ clients, onUnpair, isUnpairing }) => { + const rows = clients.data ?? []; + return ( +
+

{m.pairing_native_devices()}

+ + {rows.length === 0 ? ( + + + {m.pairing_native_empty()} + + + ) : ( + + + + + + {m.clients_name()} + {m.clients_fingerprint()} + + + + + {rows.map((c) => ( + + + {c.name || "—"} + + + {c.fingerprint.slice(0, 16)}… + + + + + + ))} + +
+
+
+ )} +
+
+ ); +}; + +/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */ +const MoonlightPairingCard: FC<{ + pairing: Loadable; + 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 ( + + + + + + {m.pairing_moonlight_title()} + + + + {!pending ? ( +

{m.pairing_idle()}

+ ) : ( +
{ + e.preventDefault(); + onSubmit(); + }} + className="space-y-4" + > +

{m.pairing_waiting()}

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

+ + {m.pairing_success()} +

+ )} + {isError && ( +

{m.pairing_failed()}

+ )} +
+ )} +
+
+
+ ); +}; diff --git a/web/src/stories/Pairing.stories.tsx b/web/src/stories/Pairing.stories.tsx new file mode 100644 index 0000000..7a5a06f --- /dev/null +++ b/web/src/stories/Pairing.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PairingView } from "@/sections/Pairing/view"; +import { + nativeClients, + nativePairArmed, + pairingIdle, + pendingDevices, +} from "./lib/fixtures"; + +const noop = () => {}; +const idle = { isLoading: false, error: null, refetch: noop }; + +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; + +export default meta; +type Story = StoryObj; + +// The marketing state: a PIN armed for a phone, one device knocking for delegated +// approval, two already-paired native clients. +export const Armed: Story = { + args: { + pending: { data: pendingDevices, ...idle }, + native: { data: nativePairArmed, ...idle }, + clients: { data: nativeClients, ...idle }, + moonlight: { data: pairingIdle, ...idle }, + }, +}; diff --git a/web/src/stories/Stats.stories.tsx b/web/src/stories/Stats.stories.tsx new file mode 100644 index 0000000..651b55c --- /dev/null +++ b/web/src/stories/Stats.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StatsView } from "@/sections/Stats/view"; +import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures"; + +const noop = () => {}; +const idle = { isLoading: false, error: null, refetch: noop }; + +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; + +export default meta; +type Story = StoryObj; + +// 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, + }, +}; + +export const Empty: Story = { + args: { + status: { data: statsStatusIdle, ...idle }, + live: { data: undefined, ...idle }, + recordings: { data: [], ...idle }, + detail: { data: undefined, ...idle }, + selectedId: null, + }, +}; diff --git a/web/src/stories/lib/fixtures.ts b/web/src/stories/lib/fixtures.ts index ed9451d..8196167 100644 --- a/web/src/stories/lib/fixtures.ts +++ b/web/src/stories/lib/fixtures.ts @@ -1,10 +1,18 @@ // Mock API payloads for the page stories — typed against the generated models so // they stay honest if the OpenAPI schema changes. import type { AvailableCompositor } from "@/api/gen/model/availableCompositor"; +import type { Capture } from "@/api/gen/model/capture"; +import type { CaptureMeta } from "@/api/gen/model/captureMeta"; import type { GameEntry } from "@/api/gen/model/gameEntry"; import type { HostInfo } from "@/api/gen/model/hostInfo"; +import type { NativeClient } from "@/api/gen/model/nativeClient"; +import type { NativePairStatus } from "@/api/gen/model/nativePairStatus"; import type { PairedClient } from "@/api/gen/model/pairedClient"; +import type { PairingStatus } from "@/api/gen/model/pairingStatus"; +import type { PendingDevice } from "@/api/gen/model/pendingDevice"; import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus"; +import type { StatsSample } from "@/api/gen/model/statsSample"; +import type { StatsStatus } from "@/api/gen/model/statsStatus"; export const hostInfo: HostInfo = { abi_version: 2, @@ -112,3 +120,115 @@ export const library: GameEntry[] = [ launch: null, }, ]; + +// --- Performance (stats) page ------------------------------------------------ + +export const statsStatusIdle: StatsStatus = { + armed: false, + kind: "native", + sample_count: 0, + started_unix_ms: 0, +}; + +// A native-path pipeline: capture → submit → encode → send. Deterministic (no +// Math.random) so the screenshot is byte-stable across CI runs; a gentle sine +// gives the charts a realistic shape without a live capture. +const STAGE_BASE_US: Record = { + capture: 320, + submit: 90, + encode: 760, + send: 140, +}; +const STAGE_ORDER = ["capture", "submit", "encode", "send"]; + +function buildSamples(n: number): StatsSample[] { + const out: StatsSample[] = []; + for (let i = 0; i < n; i++) { + const wobble = Math.sin(i / 4); + out.push({ + t_ms: i * 1000, + session_id: 1, + fps: 240, + repeat_fps: i % 3 === 0 ? 2 : 1, + mbps: 920 + wobble * 55, + bitrate_kbps: 150_000, + frames_dropped: i % 17 === 0 ? 1 : 0, + packets_dropped: i % 9 === 0 ? 2 : 0, + send_dropped: 0, + fec_recovered: i % 5 === 0 ? 3 : 1, + stages: STAGE_ORDER.map((name) => { + const base = STAGE_BASE_US[name] ?? 100; + const p50 = Math.round(base + wobble * base * 0.15); + return { name, p50_us: p50, p99_us: Math.round(p50 * 1.8) }; + }), + }); + } + return out; +} + +export const captureMetas: CaptureMeta[] = [ + { + id: "cap-20260628-2041", + client: "enricos-macbook", + kind: "native", + codec: "h265", + width: 5120, + height: 1440, + fps: 240, + duration_ms: 92_000, + sample_count: 92, + started_unix_ms: 1_782_415_260_000, + }, + { + id: "cap-20260628-1903", + client: "living-room-tv", + kind: "gamestream", + codec: "av1", + width: 3840, + height: 2160, + fps: 120, + duration_ms: 240_000, + sample_count: 240, + started_unix_ms: 1_782_409_380_000, + }, +]; + +export const captureDetail: Capture = { + meta: captureMetas[0] as CaptureMeta, + samples: buildSamples(60), +}; + +// --- Pairing page ------------------------------------------------------------ + +export const nativePairArmed: NativePairStatus = { + enabled: true, + armed: true, + pin: "4827", + expires_in_secs: 98, + paired_clients: 2, +}; + +export const pendingDevices: PendingDevice[] = [ + { + id: 1, + name: "studio-deck", + fingerprint: + "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", + age_secs: 8, + }, +]; + +export const nativeClients: NativeClient[] = [ + { + name: "enricos-macbook", + fingerprint: + "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00", + }, + { + name: "living-room-tv", + fingerprint: + "ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1", + }, +]; + +export const pairingIdle: PairingStatus = { pin_pending: false }; diff --git a/web/tools/screenshots.mjs b/web/tools/screenshots.mjs new file mode 100644 index 0000000..8a190cc --- /dev/null +++ b/web/tools/screenshots.mjs @@ -0,0 +1,154 @@ +// Capture marketing/console screenshots from the built Storybook. +// +// Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one "scene" per +// story, a mock-populated REAL view, captured by the platform's own renderer — +// here headless Chromium over `storybook-static`. No display, GPU, login, or live +// mgmt backend: the page stories render entirely from fixtures (src/stories/lib). +// +// bun run build-storybook # produce ./storybook-static +// node tools/screenshots.mjs # → ./screenshots/.png +// +// Env knobs: OUT (output dir), STORYBOOK_STATIC (input dir), SETTLE (ms after the +// page looks ready, default 600), WIDTH/HEIGHT/SCALE (viewport, default 1440x900@2x), +// ONLY (comma-separated story-id substring filter). + +import { existsSync } from "node:fs"; +import { mkdir, readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, join, normalize, resolve } from "node:path"; +import { chromium } from "playwright"; + +const ROOT = resolve(process.env.STORYBOOK_STATIC ?? "storybook-static"); +const OUT = resolve(process.env.OUT ?? "screenshots"); +const SETTLE = Number(process.env.SETTLE ?? 600); +const WIDTH = Number(process.env.WIDTH ?? 1440); +const HEIGHT = Number(process.env.HEIGHT ?? 900); +const SCALE = Number(process.env.SCALE ?? 2); +const ONLY = (process.env.ONLY ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +// Only the page-level + shell stories make sense as console screenshots — skip the +// component-library stories (Button, Badge, …). +const TITLE_PREFIXES = ["Pages/", "Shell/"]; + +const MIME = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json", + ".ico": "image/x-icon", +}; + +function staticServer(rootDir) { + return createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + let path = decodeURIComponent(url.pathname); + if (path.endsWith("/")) path += "index.html"; + // Contain the path to rootDir (no traversal). + const filePath = normalize(join(rootDir, path)); + if (!filePath.startsWith(rootDir)) { + res.writeHead(403).end(); + return; + } + const body = await readFile(filePath); + res.writeHead(200, { + "content-type": MIME[extname(filePath)] ?? "application/octet-stream", + }); + res.end(body); + } catch { + res.writeHead(404).end(); + } + }); +} + +async function listStories(rootDir) { + const indexPath = join(rootDir, "index.json"); + if (!existsSync(indexPath)) { + throw new Error( + `${indexPath} not found — run \`bun run build-storybook\` first`, + ); + } + const index = JSON.parse(await readFile(indexPath, "utf8")); + const entries = Object.values(index.entries ?? index.stories ?? {}); + return entries + .filter((e) => e.type === "story" || e.type === undefined) + .filter((e) => TITLE_PREFIXES.some((p) => (e.title ?? "").startsWith(p))) + .filter((e) => ONLY.length === 0 || ONLY.some((f) => e.id.includes(f))) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +async function main() { + if (!existsSync(ROOT)) { + throw new Error( + `${ROOT} not found — run \`bun run build-storybook\` first`, + ); + } + const stories = await listStories(ROOT); + if (stories.length === 0) + throw new Error("no Pages/* or Shell/* stories found"); + await mkdir(OUT, { recursive: true }); + + const server = staticServer(ROOT); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = server.address().port; + + const browser = await chromium.launch({ + args: ["--force-color-profile=srgb"], + }); + const context = await browser.newContext({ + viewport: { width: WIDTH, height: HEIGHT }, + deviceScaleFactor: SCALE, + colorScheme: "dark", + }); + + let ok = 0; + for (const story of stories) { + const page = await context.newPage(); + const url = `http://127.0.0.1:${port}/iframe.html?id=${encodeURIComponent( + story.id, + )}&viewMode=story`; + try { + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + // Story root mounted with real content. + await page.waitForSelector("#storybook-root > *", { timeout: 20_000 }); + // Web fonts settled (else text reflows / falls back in the shot). + await page.evaluate(() => document.fonts.ready); + // Recharts mounts behind a client-only guard — wait for the SVG if present. + await page + .locator(".recharts-surface") + .first() + .waitFor({ timeout: 4_000 }) + .catch(() => {}); + await page.waitForTimeout(SETTLE); + const file = join(OUT, `${story.id}.png`); + await page.screenshot({ path: file }); + console.log(`✓ ${story.id} → ${file}`); + ok++; + } catch (e) { + console.warn(`✗ ${story.id}: ${e.message}`); + } finally { + await page.close(); + } + } + + await browser.close(); + await new Promise((r) => server.close(r)); + console.log(`\n${ok}/${stories.length} stories captured → ${OUT}`); + if (ok === 0) process.exit(1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});