feat(web): CI screenshot capture for the mgmt console
Marketing/store screenshots of the console, captured from the built Storybook with headless Chromium (web/tools/screenshots.mjs) — every Pages/* + Shell/* story rendered at 1440x900@2x. The page stories render from fixtures, so no live mgmt API, login, or GPU is needed (the web analogue of apple.yml's screenshots job). Gated to stable release tags in a standalone best-effort workflow; PNGs upload as a 30-day artifact, not committed. - Add Stats + Pairing stories (the two pages that lacked them) with stats/pairing fixtures typed against the generated models. - Extract a pure PairingView (index.tsx -> view.tsx), matching the Dashboard/Clients/Stats split, so the page renders host-free from mock state instead of racing its polling queries. Container wiring is behaviour-identical. - Playwright driver + a chromium-capable tag-gated job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -4,6 +4,7 @@ node_modules
|
|||||||
.nitro
|
.nitro
|
||||||
dist
|
dist
|
||||||
storybook-static
|
storybook-static
|
||||||
|
screenshots
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Generated, not committed — regenerated by codegen (see package.json scripts):
|
# Generated, not committed — regenerated by codegen (see package.json scripts):
|
||||||
|
|||||||
+14
-1
@@ -33,6 +33,7 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5",
|
"@vitejs/plugin-react": "^5",
|
||||||
"orval": "^8.16.0",
|
"orval": "^8.16.0",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"storybook": "^10.4.6",
|
"storybook": "^10.4.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tw-animate-css": "^1.2.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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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/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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|||||||
+4
-1
@@ -14,7 +14,9 @@
|
|||||||
"api:gen": "orval --config orval.config.ts",
|
"api:gen": "orval --config orval.config.ts",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"storybook": "storybook dev -p 6006",
|
"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": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5",
|
"@vitejs/plugin-react": "^5",
|
||||||
"orval": "^8.16.0",
|
"orval": "^8.16.0",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"storybook": "^10.4.6",
|
"storybook": "^10.4.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tw-animate-css": "^1.2.0",
|
"tw-animate-css": "^1.2.0",
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
KeyRound,
|
|
||||||
Smartphone,
|
|
||||||
Timer,
|
|
||||||
Trash2,
|
|
||||||
UserPlus,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getGetNativePairingQueryKey,
|
getGetNativePairingQueryKey,
|
||||||
@@ -27,224 +18,57 @@ import {
|
|||||||
useGetPairingStatus,
|
useGetPairingStatus,
|
||||||
useSubmitPairingPin,
|
useSubmitPairingPin,
|
||||||
} from "@/api/gen/pairing/pairing";
|
} 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 { useLocale } from "@/lib/i18n";
|
||||||
import { m } from "@/paraglide/messages";
|
import { m } from "@/paraglide/messages";
|
||||||
|
import { PairingView } from "./view";
|
||||||
|
|
||||||
/** Seconds → `m:ss`. */
|
// Container: owns the four sub-cards' queries + mutations and hands a plain props
|
||||||
function fmtTime(secs: number): string {
|
// surface to PairingView. (The presentational split mirrors Dashboard/Clients/Stats
|
||||||
const s = Math.max(0, Math.floor(secs));
|
// and lets Storybook render the page with mock state — no live host.)
|
||||||
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 <Section>.
|
|
||||||
export const SectionPairing: FC = () => {
|
export const SectionPairing: FC = () => {
|
||||||
useLocale();
|
useLocale();
|
||||||
return (
|
|
||||||
<Section>
|
|
||||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
|
||||||
<PendingDevices />
|
|
||||||
<NativePairingCard />
|
|
||||||
<NativeDevices />
|
|
||||||
<MoonlightPairingCard />
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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 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 pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
||||||
const approve = useApprovePendingDevice();
|
const approve = useApprovePendingDevice();
|
||||||
const deny = useDenyPendingDevice();
|
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: getListPendingDevicesQueryKey() });
|
||||||
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
||||||
};
|
};
|
||||||
|
const refreshNative = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
|
||||||
|
|
||||||
const onApprove = (id: number, currentName: string) => {
|
const onApprove = (id: number, currentName: string) => {
|
||||||
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
||||||
if (name == null) return; // operator cancelled
|
if (name == null) return; // operator cancelled
|
||||||
approve.mutate(
|
approve.mutate(
|
||||||
{ id, data: { name: name.trim() ? name.trim() : null } },
|
{ id, data: { name: name.trim() ? name.trim() : null } },
|
||||||
{ onSuccess: refresh },
|
{ onSuccess: refreshPending },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const onDeny = (id: number) =>
|
||||||
|
deny.mutate({ id }, { onSuccess: refreshPending });
|
||||||
|
|
||||||
return (
|
const onArm = () =>
|
||||||
<div className="space-y-2">
|
arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refreshNative });
|
||||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
const onDisarm = () => disarm.mutate(undefined, { onSuccess: refreshNative });
|
||||||
<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={approve.isPending || deny.isPending}
|
|
||||||
onClick={() => onApprove(p.id, p.name)}
|
|
||||||
>
|
|
||||||
{m.pairing_pending_approve()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={m.pairing_pending_deny()}
|
|
||||||
disabled={approve.isPending || deny.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
deny.mutate({ id: p.id }, { onSuccess: refresh })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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. */
|
|
||||||
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 (
|
|
||||||
<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={disarm.isPending}
|
|
||||||
onClick={() => disarm.mutate(undefined, { onSuccess: refresh })}
|
|
||||||
>
|
|
||||||
{m.pairing_native_cancel()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.pairing_native_desc()}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
disabled={arm.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
arm.mutate(
|
|
||||||
{ data: { ttl_secs: 120 } },
|
|
||||||
{ onSuccess: refresh },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<KeyRound className="size-4" />
|
|
||||||
{m.pairing_native_arm()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The paired native (punktfunk/1) devices, with unpair. */
|
|
||||||
function NativeDevices() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const clients = useListNativeClients();
|
|
||||||
const unpair = useUnpairNativeClient();
|
|
||||||
const rows = clients.data ?? [];
|
|
||||||
|
|
||||||
const onUnpair = (fingerprint: string) => {
|
const onUnpair = (fingerprint: string) => {
|
||||||
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
||||||
@@ -257,73 +81,7 @@ function NativeDevices() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const onSubmitPin = () =>
|
||||||
<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={unpair.isPending}
|
|
||||||
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. */
|
|
||||||
function MoonlightPairingCard() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [pin, setPin] = useState("");
|
|
||||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
|
||||||
const submit = useSubmitPairingPin();
|
|
||||||
const pending = pairing.data?.pin_pending ?? false;
|
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
submit.mutate(
|
submit.mutate(
|
||||||
{ data: { pin } },
|
{ data: { pin } },
|
||||||
{
|
{
|
||||||
@@ -333,59 +91,28 @@ function MoonlightPairingCard() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryState
|
<PairingView
|
||||||
isLoading={pairing.isLoading}
|
pending={pending}
|
||||||
error={pairing.error}
|
onApprove={onApprove}
|
||||||
refetch={pairing.refetch}
|
onDeny={onDeny}
|
||||||
>
|
pendingBusy={approve.isPending || deny.isPending}
|
||||||
<Card className="max-w-md">
|
native={native}
|
||||||
<CardHeader>
|
onArm={onArm}
|
||||||
<CardTitle className="flex items-center gap-2">
|
onDisarm={onDisarm}
|
||||||
<KeyRound className="size-4" />
|
isArming={arm.isPending}
|
||||||
{m.pairing_moonlight_title()}
|
isDisarming={disarm.isPending}
|
||||||
</CardTitle>
|
clients={clients}
|
||||||
</CardHeader>
|
onUnpair={onUnpair}
|
||||||
<CardContent>
|
isUnpairing={unpair.isPending}
|
||||||
{!pending ? (
|
moonlight={pairing}
|
||||||
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
pin={pin}
|
||||||
) : (
|
onPinChange={setPin}
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
onSubmitPin={onSubmitPin}
|
||||||
<p className="text-sm">{m.pairing_waiting()}</p>
|
isSubmittingPin={submit.isPending}
|
||||||
<div className="space-y-2">
|
pinSuccess={submit.isSuccess}
|
||||||
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
pinError={submit.isError}
|
||||||
<Input
|
/>
|
||||||
id="pin"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="off"
|
|
||||||
maxLength={8}
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
|
||||||
placeholder="0000"
|
|
||||||
className="font-mono text-lg tracking-widest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={pin.length < 4 || submit.isPending}
|
|
||||||
>
|
|
||||||
{m.pairing_submit()}
|
|
||||||
</Button>
|
|
||||||
{submit.isSuccess && (
|
|
||||||
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
{m.pairing_success()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{submit.isError && (
|
|
||||||
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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<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}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<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.
|
||||||
|
export const Armed: Story = {
|
||||||
|
args: {
|
||||||
|
pending: { data: pendingDevices, ...idle },
|
||||||
|
native: { data: nativePairArmed, ...idle },
|
||||||
|
clients: { data: nativeClients, ...idle },
|
||||||
|
moonlight: { data: pairingIdle, ...idle },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<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.
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
// Mock API payloads for the page stories — typed against the generated models so
|
// Mock API payloads for the page stories — typed against the generated models so
|
||||||
// they stay honest if the OpenAPI schema changes.
|
// they stay honest if the OpenAPI schema changes.
|
||||||
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
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 { GameEntry } from "@/api/gen/model/gameEntry";
|
||||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
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 { 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 { 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 = {
|
export const hostInfo: HostInfo = {
|
||||||
abi_version: 2,
|
abi_version: 2,
|
||||||
@@ -112,3 +120,115 @@ export const library: GameEntry[] = [
|
|||||||
launch: null,
|
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<string, number> = {
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -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/<story-id>.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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user