improve web ui

This commit is contained in:
2026-06-26 05:43:34 +00:00
parent 00cf51d610
commit 803573b4ec
73 changed files with 3373 additions and 2847 deletions
+6 -6
View File
@@ -1,15 +1,15 @@
import type { StorybookConfig } from '@storybook/react-vite' import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'], stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [], addons: [],
framework: { framework: {
name: '@storybook/react-vite', name: "@storybook/react-vite",
options: { options: {
// Use the slim, Start/Nitro-free Vite config (see vite.storybook.config.ts). // Use the slim, Start/Nitro-free Vite config (see vite.storybook.config.ts).
builder: { viteConfigPath: './vite.storybook.config.ts' }, builder: { viteConfigPath: "./vite.storybook.config.ts" },
}, },
}, },
} };
export default config export default config;
+22 -22
View File
@@ -1,35 +1,35 @@
// Import the console's REAL stylesheet directly (rememed-style) — the @theme // Import the console's REAL stylesheet directly (rememed-style) — the @theme
// blocks process because this is the literal entry Storybook's Vite pipeline sees. // blocks process because this is the literal entry Storybook's Vite pipeline sees.
import '../src/styles.css' import "../src/styles.css";
// The console loads its brand typeface separately (in __root.tsx); do the same // The console loads its brand typeface separately (in __root.tsx); do the same
// here or every story falls back to system-ui and looks off. // here or every story falls back to system-ui and looks off.
import '@fontsource-variable/geist' import "@fontsource-variable/geist";
import { useEffect } from 'react' import { useEffect } from "react";
import { definePreview } from '@storybook/react-vite' import { definePreview } from "@storybook/react-vite";
import { MaterialProvider, defaultMaterialTheme } from '@unom/ui/material' import { MaterialProvider, defaultMaterialTheme } from "@unom/ui/material";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// React Query is present so any query-backed component mounts without a real // React Query is present so any query-backed component mounts without a real
// host. Stories should feed mock data rather than fetch — retries are off so a // host. Stories should feed mock data rather than fetch — retries are off so a
// stray request fails fast instead of hanging the canvas. // stray request fails fast instead of hanging the canvas.
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
}) });
export default definePreview({ export default definePreview({
addons: [], addons: [],
// The live console pins dark; default the canvas to dark too, with a toolbar // The live console pins dark; default the canvas to dark too, with a toolbar
// switch to preview the light theme while designing. // switch to preview the light theme while designing.
initialGlobals: { theme: 'dark' }, initialGlobals: { theme: "dark" },
globalTypes: { globalTypes: {
theme: { theme: {
description: 'Light/dark color scheme', description: "Light/dark color scheme",
toolbar: { toolbar: {
title: 'Theme', title: "Theme",
icon: 'circlehollow', icon: "circlehollow",
items: [ items: [
{ value: 'dark', icon: 'moon', title: 'Dark' }, { value: "dark", icon: "moon", title: "Dark" },
{ value: 'light', icon: 'sun', title: 'Light' }, { value: "light", icon: "sun", title: "Light" },
], ],
dynamicTitle: true, dynamicTitle: true,
}, },
@@ -37,33 +37,33 @@ export default definePreview({
}, },
decorators: [ decorators: [
(Story, context) => { (Story, context) => {
const dark = (context.globals.theme as string) !== 'light' const dark = (context.globals.theme as string) !== "light";
// `layout: 'fullscreen'` stories (e.g. the AppShell) own their own padding; // `layout: 'fullscreen'` stories (e.g. the AppShell) own their own padding;
// everything else gets a comfortable inset. // everything else gets a comfortable inset.
const fullscreen = context.parameters.layout === 'fullscreen' const fullscreen = context.parameters.layout === "fullscreen";
// Mirror `.dark` onto <html> so the body's token-driven background AND any // Mirror `.dark` onto <html> so the body's token-driven background AND any
// portal-mounted content (radix dialogs, popovers) pick up the right // portal-mounted content (radix dialogs, popovers) pick up the right
// palette — the console keys its whole token set off `html.dark`. // palette — the console keys its whole token set off `html.dark`.
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle('dark', dark) document.documentElement.classList.toggle("dark", dark);
}, [dark]) }, [dark]);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MaterialProvider theme={defaultMaterialTheme}> <MaterialProvider theme={defaultMaterialTheme}>
<div className={dark ? 'dark' : ''}> <div className={dark ? "dark" : ""}>
<div <div
className={`min-h-screen bg-background text-foreground ${fullscreen ? '' : 'p-6'}`} className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
> >
<Story /> <Story />
</div> </div>
</div> </div>
</MaterialProvider> </MaterialProvider>
</QueryClientProvider> </QueryClientProvider>
) );
}, },
], ],
parameters: { parameters: {
controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } },
layout: 'padded', layout: "padded",
}, },
}) });
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"includes": [
"**"
]
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off",
"noArrayIndexKey": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+19
View File
@@ -22,6 +22,7 @@
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.1",
"@inlang/paraglide-js": "^2.0.0", "@inlang/paraglide-js": "^2.0.0",
"@storybook/react-vite": "^10.4.6", "@storybook/react-vite": "^10.4.6",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -85,6 +86,24 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@biomejs/biome": ["@biomejs/biome@2.5.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="],
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
+9 -9
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'orval' import { defineConfig } from "orval";
// Generates a typed React Query client from the host's checked-in OpenAPI document. // Generates a typed React Query client from the host's checked-in OpenAPI document.
// Regenerate after any management-API change: `pnpm api:gen` (the Rust side regenerates // Regenerate after any management-API change: `pnpm api:gen` (the Rust side regenerates
@@ -6,18 +6,18 @@ import { defineConfig } from 'orval'
export default defineConfig({ export default defineConfig({
punktfunk: { punktfunk: {
input: { input: {
target: '../docs/api/openapi.json', target: "../docs/api/openapi.json",
}, },
output: { output: {
mode: 'tags-split', mode: "tags-split",
target: './src/api/gen', target: "./src/api/gen",
schemas: './src/api/gen/model', schemas: "./src/api/gen/model",
client: 'react-query', client: "react-query",
clean: true, clean: true,
override: { override: {
mutator: { mutator: {
path: './src/api/fetcher.ts', path: "./src/api/fetcher.ts",
name: 'apiFetch', name: "apiFetch",
}, },
// The mutator returns the response BODY (it throws on HTTP errors), not a // The mutator returns the response BODY (it throws on HTTP errors), not a
// `{status,data,headers}` envelope — so a query's `.data` is the typed payload. // `{status,data,headers}` envelope — so a query's `.data` is the typed payload.
@@ -29,4 +29,4 @@ export default defineConfig({
}, },
}, },
}, },
}) });
+1
View File
@@ -34,6 +34,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.1",
"@inlang/paraglide-js": "^2.0.0", "@inlang/paraglide-js": "^2.0.0",
"@storybook/react-vite": "^10.4.6", "@storybook/react-vite": "^10.4.6",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
+28 -13
View File
@@ -2,26 +2,41 @@
// (pages, the /api proxy, everything) before routing. Unauthenticated requests are // (pages, the /api proxy, everything) before routing. Unauthenticated requests are
// redirected to /login (page navigations) or rejected 401 (/api). Fails CLOSED if // redirected to /login (page navigations) or rejected 401 (/api). Fails CLOSED if
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one. // PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
import { defineEventHandler, getRequestURL, sendRedirect, setResponseStatus, useSession } from 'h3' import {
import { isPublicPath, sessionConfig, uiPassword, type SessionData } from '../util/auth' defineEventHandler,
getRequestURL,
sendRedirect,
setResponseStatus,
useSession,
} from "h3";
import {
isPublicPath,
sessionConfig,
uiPassword,
type SessionData,
} from "../util/auth";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { pathname } = getRequestURL(event) const { pathname } = getRequestURL(event);
if (isPublicPath(pathname)) return if (isPublicPath(pathname)) return;
// Misconfigured: refuse everything rather than serve open on the LAN. // Misconfigured: refuse everything rather than serve open on the LAN.
if (!uiPassword()) { if (!uiPassword()) {
setResponseStatus(event, 503) setResponseStatus(event, 503);
return { error: 'auth not configured: set PUNKTFUNK_UI_PASSWORD' } return { error: "auth not configured: set PUNKTFUNK_UI_PASSWORD" };
} }
const session = await useSession<SessionData>(event, sessionConfig()) const session = await useSession<SessionData>(event, sessionConfig());
if (session.data.authenticated) return // authenticated — let it through if (session.data.authenticated) return; // authenticated — let it through
if (pathname.startsWith('/api')) { if (pathname.startsWith("/api")) {
setResponseStatus(event, 401) setResponseStatus(event, 401);
return { error: 'unauthorized' } return { error: "unauthorized" };
} }
// Page navigation → bounce to the login screen, remembering where they were headed. // Page navigation → bounce to the login screen, remembering where they were headed.
return sendRedirect(event, `/login?next=${encodeURIComponent(pathname)}`, 302) return sendRedirect(
}) event,
`/login?next=${encodeURIComponent(pathname)}`,
302,
);
});
+19 -11
View File
@@ -1,20 +1,28 @@
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an // POST /_auth/login {password} — verify the shared password (constant-time), then seal an
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user // authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
// can actually log in. // can actually log in.
import { defineEventHandler, readBody, createError, useSession } from 'h3' import { defineEventHandler, readBody, createError, useSession } from "h3";
import { sessionConfig, timingSafeEqual, uiPassword, type SessionData } from '../../util/auth' import {
sessionConfig,
timingSafeEqual,
uiPassword,
type SessionData,
} from "../../util/auth";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const expected = uiPassword() const expected = uiPassword();
if (!expected) { if (!expected) {
throw createError({ statusCode: 503, statusMessage: 'auth not configured' }) throw createError({
statusCode: 503,
statusMessage: "auth not configured",
});
} }
const body = await readBody<{ password?: string }>(event) const body = await readBody<{ password?: string }>(event);
const password = String(body?.password ?? '') const password = String(body?.password ?? "");
if (!timingSafeEqual(password, expected)) { if (!timingSafeEqual(password, expected)) {
throw createError({ statusCode: 401, statusMessage: 'invalid password' }) throw createError({ statusCode: 401, statusMessage: "invalid password" });
} }
const session = await useSession<SessionData>(event, sessionConfig()) const session = await useSession<SessionData>(event, sessionConfig());
await session.update({ authenticated: true }) await session.update({ authenticated: true });
return { ok: true } return { ok: true };
}) });
+6 -6
View File
@@ -1,9 +1,9 @@
// POST /_auth/logout — clear the session cookie. // POST /_auth/logout — clear the session cookie.
import { defineEventHandler, useSession } from 'h3' import { defineEventHandler, useSession } from "h3";
import { sessionConfig, type SessionData } from '../../util/auth' import { sessionConfig, type SessionData } from "../../util/auth";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const session = await useSession<SessionData>(event, sessionConfig()) const session = await useSession<SessionData>(event, sessionConfig());
await session.clear() await session.clear();
return { ok: true } return { ok: true };
}) });
+18 -10
View File
@@ -3,26 +3,34 @@
// (the browser never sees it) and drop the browser's own cookies/auth from the upstream // (the browser never sees it) and drop the browser's own cookies/auth from the upstream
// request, then proxy. The management API itself binds loopback only — this proxy is the // request, then proxy. The management API itself binds loopback only — this proxy is the
// ONLY path to it from the LAN, and it's authenticated. // ONLY path to it from the LAN, and it's authenticated.
import { defineEventHandler, getRequestURL, proxyRequest, setResponseStatus } from 'h3' import {
import { mgmtToken, mgmtUrl } from '../../util/auth' defineEventHandler,
getRequestURL,
proxyRequest,
setResponseStatus,
} from "h3";
import { mgmtToken, mgmtUrl } from "../../util/auth";
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event) const { pathname, search } = getRequestURL(event);
const target = `${mgmtUrl()}${pathname}${search}` const target = `${mgmtUrl()}${pathname}${search}`;
const token = mgmtToken() const token = mgmtToken();
// The mgmt API now requires a token always. Without one configured, forwarding an empty bearer // The mgmt API now requires a token always. Without one configured, forwarding an empty bearer
// would just bounce as 401 — fail fast and legibly instead (the packaged service sources the // would just bounce as 401 — fail fast and legibly instead (the packaged service sources the
// host's ~/.config/punktfunk/mgmt-token, so this only fires on a misconfigured/early-start deploy). // host's ~/.config/punktfunk/mgmt-token, so this only fires on a misconfigured/early-start deploy).
if (!token) { if (!token) {
setResponseStatus(event, 503) setResponseStatus(event, 503);
return { error: 'management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)' } return {
error:
"management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)",
};
} }
return proxyRequest(event, target, { return proxyRequest(event, target, {
headers: { headers: {
// Overwrite, not append: the host-held token replaces anything the browser sent. // Overwrite, not append: the host-held token replaces anything the browser sent.
authorization: `Bearer ${token}`, authorization: `Bearer ${token}`,
// Don't forward the session cookie to the management API. // Don't forward the session cookie to the management API.
cookie: '', cookie: "",
}, },
}) });
}) });
+32 -26
View File
@@ -4,26 +4,29 @@
// //
// The management token never reaches the browser: server/routes/api/[...].ts injects it // The management token never reaches the browser: server/routes/api/[...].ts injects it
// server-side when proxying to the loopback management API. // server-side when proxying to the loopback management API.
import { createHash, timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto' import {
import type { SessionConfig } from 'h3' createHash,
timingSafeEqual as nodeTimingSafeEqual,
} from "node:crypto";
import type { SessionConfig } from "h3";
export const SESSION_NAME = 'pf_session' export const SESSION_NAME = "pf_session";
/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */ /** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */
export function uiPassword(): string { export function uiPassword(): string {
return process.env.PUNKTFUNK_UI_PASSWORD ?? '' return process.env.PUNKTFUNK_UI_PASSWORD ?? "";
} }
/** The management API the proxy forwards to (loopback by default — never LAN-exposed). It serves /** The management API the proxy forwards to (loopback by default — never LAN-exposed). It serves
* HTTPS with the host's self-signed identity cert, so the deployment also sets * HTTPS with the host's self-signed identity cert, so the deployment also sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch — see .env.example. */ * NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch — see .env.example. */
export function mgmtUrl(): string { export function mgmtUrl(): string {
return process.env.PUNKTFUNK_MGMT_URL ?? 'https://127.0.0.1:47990' return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
} }
/** Bearer token for the management API, injected server-side. */ /** Bearer token for the management API, injected server-side. */
export function mgmtToken(): string { export function mgmtToken(): string {
return process.env.PUNKTFUNK_MGMT_TOKEN ?? '' return process.env.PUNKTFUNK_MGMT_TOKEN ?? "";
} }
/** /**
@@ -32,10 +35,13 @@ export function mgmtToken(): string {
* (changing the password then invalidates existing sessions, which is fine). * (changing the password then invalidates existing sessions, which is fine).
*/ */
export function sessionConfig(): SessionConfig { export function sessionConfig(): SessionConfig {
const secret = process.env.PUNKTFUNK_UI_SECRET const secret = process.env.PUNKTFUNK_UI_SECRET;
const password = secret && secret.length >= 32 const password =
secret && secret.length >= 32
? secret ? secret
: createHash('sha256').update(`punktfunk-session-v1:${uiPassword()}`).digest('hex') : createHash("sha256")
.update(`punktfunk-session-v1:${uiPassword()}`)
.digest("hex");
return { return {
name: SESSION_NAME, name: SESSION_NAME,
password, password,
@@ -44,22 +50,22 @@ export function sessionConfig(): SessionConfig {
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
cookie: { cookie: {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: "lax",
path: '/', path: "/",
// h3 defaults Secure to true, which browsers DROP over plain http:// (so login // h3 defaults Secure to true, which browsers DROP over plain http:// (so login
// silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS // silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS
// (set PUNKTFUNK_UI_SECURE=1 / =true then). // (set PUNKTFUNK_UI_SECURE=1 / =true then).
secure: /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ''), secure: /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""),
}, },
} };
} }
/** Constant-time string comparison (avoids leaking the password via timing). */ /** Constant-time string comparison (avoids leaking the password via timing). */
export function timingSafeEqual(a: string, b: string): boolean { export function timingSafeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a) const ab = Buffer.from(a);
const bb = Buffer.from(b) const bb = Buffer.from(b);
if (ab.length !== bb.length) return false if (ab.length !== bb.length) return false;
return nodeTimingSafeEqual(ab, bb) return nodeTimingSafeEqual(ab, bb);
} }
/** Paths reachable WITHOUT a session: the login page, the auth endpoints, and the build's /** Paths reachable WITHOUT a session: the login page, the auth endpoints, and the build's
@@ -70,21 +76,21 @@ export function timingSafeEqual(a: string, b: string): boolean {
* generic `*.json` allowlist would expose `/api/v1/openapi.json` (and any future * generic `*.json` allowlist would expose `/api/v1/openapi.json` (and any future
* `.json`/`.png` management route) through the proxy unauthenticated. */ * `.json`/`.png` management route) through the proxy unauthenticated. */
export function isPublicPath(pathname: string): boolean { export function isPublicPath(pathname: string): boolean {
if (pathname === '/api' || pathname.startsWith('/api/')) return false // always gated if (pathname === "/api" || pathname.startsWith("/api/")) return false; // always gated
if (pathname === '/login') return true if (pathname === "/login") return true;
if (pathname.startsWith('/_auth/')) return true if (pathname.startsWith("/_auth/")) return true;
if (pathname.startsWith('/assets/')) return true if (pathname.startsWith("/assets/")) return true;
if (pathname === '/favicon.ico' || pathname === '/robots.txt') return true if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
return false return false;
} }
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol- /** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */ * relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
export function safeNextPath(next: string | undefined): string { export function safeNextPath(next: string | undefined): string {
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/' if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
return next return next;
} }
export interface SessionData { export interface SessionData {
authenticated?: boolean authenticated?: boolean;
} }
+29 -22
View File
@@ -9,43 +9,50 @@
/** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */ /** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */
export class ApiError extends Error { export class ApiError extends Error {
status: number status: number;
data: unknown data: unknown;
constructor(status: number, data: unknown, message?: string) { constructor(status: number, data: unknown, message?: string) {
super(message ?? `API error ${status}`) super(message ?? `API error ${status}`);
this.name = 'ApiError' this.name = "ApiError";
this.status = status this.status = status;
this.data = data this.data = data;
} }
} }
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> { export async function apiFetch<T>(
const headers = new Headers(options?.headers) url: string,
headers.set('Accept', 'application/json') options?: RequestInit,
): Promise<T> {
const headers = new Headers(options?.headers);
headers.set("Accept", "application/json");
const res = await fetch(url, { ...options, headers, credentials: 'same-origin' }) const res = await fetch(url, {
...options,
headers,
credentials: "same-origin",
});
const text = await res.text() const text = await res.text();
const body = text ? safeJson(text) : undefined const body = text ? safeJson(text) : undefined;
if (res.status === 401) redirectToLogin() if (res.status === 401) redirectToLogin();
if (!res.ok) throw new ApiError(res.status, body, res.statusText) if (!res.ok) throw new ApiError(res.status, body, res.statusText);
return body as T return body as T;
} }
/** On lost session, send the user to the login screen, remembering where they were. */ /** On lost session, send the user to the login screen, remembering where they were. */
function redirectToLogin(): void { function redirectToLogin(): void {
if (typeof window === 'undefined') return if (typeof window === "undefined") return;
if (window.location.pathname === '/login') return if (window.location.pathname === "/login") return;
const next = encodeURIComponent(window.location.pathname) const next = encodeURIComponent(window.location.pathname);
window.location.href = `/login?next=${next}` window.location.href = `/login?next=${next}`;
} }
function safeJson(text: string): unknown { function safeJson(text: string): unknown {
try { try {
return JSON.parse(text) return JSON.parse(text);
} catch { } catch {
return text return text;
} }
} }
export default apiFetch export default apiFetch;
+79 -38
View File
@@ -1,24 +1,40 @@
import type { ReactNode } from 'react' import { Link } from "@tanstack/react-router";
import { Link } from '@tanstack/react-router' import {
import { Activity, Server, Users, KeyRound, LibraryBig, Settings } from 'lucide-react' Activity,
import { BrandMark } from '@/components/brand-mark' KeyRound,
import { Wordmark } from '@/components/wordmark' LibraryBig,
import { m } from '@/paraglide/messages' Server,
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n' Settings,
import { cn } from '@/lib/utils' Users,
} from "lucide-react";
import { motion, stagger } from "motion/react";
import type { ReactNode } from "react";
import { BrandMark } from "@/components/brand-mark";
import { Wordmark } from "@/components/wordmark";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const MLink = motion(Link);
const NAV = [ const NAV = [
{ to: '/', icon: Activity, label: () => m.nav_dashboard() }, { to: "/", icon: Activity, label: () => m.nav_dashboard() },
{ to: '/host', icon: Server, label: () => m.nav_host() }, { to: "/host", icon: Server, label: () => m.nav_host() },
{ to: '/library', icon: LibraryBig, label: () => m.nav_library() }, { to: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: '/clients', icon: Users, label: () => m.nav_clients() }, { to: "/clients", icon: Users, label: () => m.nav_clients() },
{ to: '/pairing', icon: KeyRound, label: () => m.nav_pairing() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: '/settings', icon: Settings, label: () => m.nav_settings() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const ] as const;
// Staggered entrance for the sidebar nav: each item fans in from the left a beat
// after the previous. Per-item delays (rather than a parent stagger) keep every
// item independent, so none can be left mid-orchestration / invisible.
const NAV_ENTER_DELAY = 0.08;
const NAV_ENTER_STEP = 0.06;
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
// Read the locale so the whole shell re-renders on a language switch. // Read the locale so the whole shell re-renders on a language switch.
useLocale() useLocale();
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Desktop sidebar (≥ sm). */} {/* Desktop sidebar (≥ sm). */}
@@ -31,20 +47,43 @@ export function AppShell({ children }: { children: ReactNode }) {
<BrandMark className="size-7 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" /> <BrandMark className="size-7 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" />
<Wordmark className="h-4" /> <Wordmark className="h-4" />
</Link> </Link>
<nav className="flex flex-col gap-1"> <motion.nav
{NAV.map(({ to, icon: Icon, label }) => ( animate="enter"
<Link initial="from"
key={to} transition={{
to={to} delayChildren: stagger(0.1),
activeOptions={{ exact: to === '/' }} }}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" variants={{ enter: {}, from: {} }}
activeProps={{ className: 'bg-primary/15 text-foreground font-medium' }} className="flex flex-col gap-1"
> >
<Icon className="size-4" /> {NAV.map(({ to, icon: Icon, label }, i) => (
{label()} <MLink
</Link> key={to}
variants={{
from: { opacity: 0, x: -20 },
enter: { opacity: 1, x: 0 },
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
to={to}
activeOptions={{ exact: to === "/" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
}}
>
{/* Hover brightens: a brand-tinted wash layered OVER whatever the
link's background is (transparent or the active tint), so the
item gets lighter on hover — including the active one. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative">{label()}</span>
</MLink>
))} ))}
</nav> </motion.nav>
<div className="mt-auto pt-4"> <div className="mt-auto pt-4">
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
@@ -62,22 +101,24 @@ export function AppShell({ children }: { children: ReactNode }) {
<main className="flex-1"> <main className="flex-1">
{/* pb-24 leaves room for the fixed bottom nav on mobile. */} {/* pb-24 leaves room for the fixed bottom nav on mobile. */}
<div className="mx-auto max-w-5xl p-6 pb-24 sm:p-10 sm:pb-10">{children}</div> <div className="mx-auto max-w-5xl p-6 pb-24 sm:p-10 sm:pb-10">
{children}
</div>
</main> </main>
</div> </div>
{/* Mobile bottom tab bar (< sm): the primary navigation on phones. */} {/* Mobile bottom tab bar (< sm): the primary navigation on phones. */}
<nav <nav
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card/95 backdrop-blur sm:hidden" className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card/95 backdrop-blur sm:hidden"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }} style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
> >
{NAV.map(({ to, icon: Icon, label }) => ( {NAV.map(({ to, icon: Icon, label }) => (
<Link <Link
key={to} key={to}
to={to} to={to}
activeOptions={{ exact: to === '/' }} activeOptions={{ exact: to === "/" }}
className="flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors" className="flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors"
activeProps={{ className: 'text-[var(--brand-light)]' }} activeProps={{ className: "text-[var(--brand-light)]" }}
> >
<Icon className="size-5 shrink-0" /> <Icon className="size-5 shrink-0" />
{/* Fixed two-line-tall box so a 1- or 2-line label keeps every icon {/* Fixed two-line-tall box so a 1- or 2-line label keeps every icon
@@ -89,11 +130,11 @@ export function AppShell({ children }: { children: ReactNode }) {
))} ))}
</nav> </nav>
</div> </div>
) );
} }
function LanguageSwitcher() { function LanguageSwitcher() {
const current = useLocale() const current = useLocale();
return ( return (
<div className="flex gap-1" role="group" aria-label="Language"> <div className="flex gap-1" role="group" aria-label="Language">
{locales.map((l: Locale) => ( {locales.map((l: Locale) => (
@@ -101,15 +142,15 @@ function LanguageSwitcher() {
key={l} key={l}
onClick={() => changeLocale(l)} onClick={() => changeLocale(l)}
className={cn( className={cn(
'rounded px-2 py-1 text-xs uppercase transition-colors', "rounded px-2 py-1 text-xs uppercase transition-colors",
l === current l === current
? 'bg-primary/20 text-foreground font-medium' ? "bg-primary/20 text-foreground font-medium"
: 'text-muted-foreground hover:text-foreground', : "text-muted-foreground hover:text-foreground",
)} )}
> >
{l} {l}
</button> </button>
))} ))}
</div> </div>
) );
} }
+2 -2
View File
@@ -25,7 +25,7 @@ export function BrandMark({ className }: { className?: string }) {
fill="#d2c9fb" fill="#d2c9fb"
/> />
</svg> </svg>
) );
} }
export default BrandMark export default BrandMark;
+6 -6
View File
@@ -1,17 +1,17 @@
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { BrandMark } from './brand-mark' import { BrandMark } from "./brand-mark";
import { Wordmark } from './wordmark' import { Wordmark } from "./wordmark";
// Full punktfunk lockup: the lens mark anchored to the top-left corner of the // Full punktfunk lockup: the lens mark anchored to the top-left corner of the
// "funk" wordmark. Size the lockup with a width on the wrapper (e.g. `w-40`); // "funk" wordmark. Size the lockup with a width on the wrapper (e.g. `w-40`);
// the mark scales as a fraction of that width. // the mark scales as a fraction of that width.
export function Logo({ className }: { className?: string }) { export function Logo({ className }: { className?: string }) {
return ( return (
<div className={cn('relative inline-block', className)}> <div className={cn("relative inline-block", className)}>
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_rgba(108,91,243,0.45)]" /> <BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_rgba(108,91,243,0.45)]" />
<Wordmark className="block h-auto w-full" /> <Wordmark className="block h-auto w-full" />
</div> </div>
) );
} }
export default Logo export default Logo;
+25 -15
View File
@@ -1,18 +1,23 @@
import type { ReactNode } from 'react' import type { ReactNode } from "react";
import { ApiError } from '@/api/fetcher' import { ApiError } from "@/api/fetcher";
import { Spinner } from '@/components/ui/spinner' import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button' import { Spinner } from "@/components/ui/spinner";
import { m } from '@/paraglide/messages' import { m } from "@/paraglide/messages";
interface QueryStateProps { interface QueryStateProps {
isLoading: boolean isLoading: boolean;
error: unknown error: unknown;
refetch?: () => void refetch?: () => void;
children: ReactNode children: ReactNode;
} }
/** Uniform loading/error wrapper for a query-backed view. */ /** Uniform loading/error wrapper for a query-backed view. */
export function QueryState({ isLoading, error, refetch, children }: QueryStateProps) { export function QueryState({
isLoading,
error,
refetch,
children,
}: QueryStateProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div <div
@@ -22,22 +27,27 @@ export function QueryState({ isLoading, error, refetch, children }: QueryStatePr
<Spinner className="size-8" /> <Spinner className="size-8" />
{m.common_loading()} {m.common_loading()}
</div> </div>
) );
} }
if (error) { if (error) {
const unauthorized = error instanceof ApiError && error.status === 401 const unauthorized = error instanceof ApiError && error.status === 401;
return ( return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm"> <div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-destructive"> <p className="font-medium text-destructive">
{unauthorized ? m.common_unauthorized() : m.common_error()} {unauthorized ? m.common_unauthorized() : m.common_error()}
</p> </p>
{refetch && !unauthorized && ( {refetch && !unauthorized && (
<Button variant="outline" size="sm" className="mt-3" onClick={() => refetch()}> <Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => refetch()}
>
{m.common_retry()} {m.common_retry()}
</Button> </Button>
)} )}
</div> </div>
) );
} }
return <>{children}</> return <>{children}</>;
} }
+40
View File
@@ -0,0 +1,40 @@
import { motion, useReducedMotion } from "motion/react";
import { Children, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Page content wrapper that animates in on mount — so the content fans up into
* place every time you navigate or load a route (the route remounts, this
* remounts). Each direct child is staggered a beat after the previous (the same
* on-mount-delay pattern the sidebar nav uses). Honours prefers-reduced-motion.
*/
export function Section({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
return (
<div className={cn("flex flex-col gap-6", className)}>
{Children.map(children, (child, i) =>
reduce ? (
child
) : (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: 0.03 + i * 0.07,
duration: 0.42,
ease: [0.16, 1, 0.3, 1],
}}
>
{child}
</motion.div>
),
)}
</div>
);
}
+16 -13
View File
@@ -1,29 +1,32 @@
import * as React from 'react' import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from 'class-variance-authority' import type * as React from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none', "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none",
{ {
variants: { variants: {
variant: { variant: {
default: 'border-transparent bg-primary text-primary-foreground', default: "border-transparent bg-primary text-primary-foreground",
secondary: 'border-transparent bg-secondary text-secondary-foreground', secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: 'border-transparent bg-destructive text-destructive-foreground', destructive:
success: 'border-transparent bg-[var(--success)] text-white', "border-transparent bg-destructive text-destructive-foreground",
outline: 'text-foreground', success: "border-transparent bg-[var(--success)] text-white",
outline: "text-foreground",
}, },
}, },
defaultVariants: { variant: 'default' }, defaultVariants: { variant: "default" },
}, },
) );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} /> return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
+5 -5
View File
@@ -1,12 +1,12 @@
import type { ComponentProps } from 'react' import { AnimatedButton, buttonVariants } from "@unom/ui/button";
import { AnimatedButton, buttonVariants } from '@unom/ui/button' import type { ComponentProps } from "react";
// The console's Button IS @unom/ui's animated button — pill shape, specular // The console's Button IS @unom/ui's animated button — pill shape, specular
// material gloss + UI click/hover sounds (enabled via UnomProviders), driven by // material gloss + UI click/hover sounds (enabled via UnomProviders), driven by
// the shared brand tokens. Same variant/size vocabulary the routes already use // the shared brand tokens. Same variant/size vocabulary the routes already use
// (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon). // (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon).
export type ButtonProps = ComponentProps<typeof AnimatedButton> export type ButtonProps = ComponentProps<typeof AnimatedButton>;
export const Button = AnimatedButton export const Button = AnimatedButton;
export { buttonVariants } export { buttonVariants };
+70 -41
View File
@@ -1,7 +1,7 @@
import * as React from 'react' import { AnimatedCard } from "@unom/ui/card";
import type { ComponentProps } from 'react' import type { ComponentProps } from "react";
import { AnimatedCard } from '@unom/ui/card' import * as React from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
// The console's Card IS @unom/ui's animated card — a `bg-neutral` (#1c1530) // The console's Card IS @unom/ui's animated card — a `bg-neutral` (#1c1530)
// surface with a soft brand-violet ring, on-mount motion + material gloss // surface with a soft brand-violet ring, on-mount motion + material gloss
@@ -9,56 +9,85 @@ import { cn } from '@/lib/utils'
// API (CardHeader/Title/Description/Content/Footer own their own padding), so // API (CardHeader/Title/Description/Content/Footer own their own padding), so
// the card defaults to `padding={false}` to avoid doubling it, and soften the // the card defaults to `padding={false}` to avoid doubling it, and soften the
// 2px ring to a subtle 1px brand tint. // 2px ring to a subtle 1px brand tint.
type CardProps = ComponentProps<typeof AnimatedCard> type CardProps = ComponentProps<typeof AnimatedCard>;
const Card = ({ className, padding = false, children, ...props }: CardProps) => ( const Card = ({
className,
padding = false,
children,
...props
}: CardProps) => (
<AnimatedCard <AnimatedCard
padding={padding} padding={padding}
className={cn('ring-1 ring-accent/40', className)} className={cn("ring-1 ring-accent/40", className)}
{...props} {...props}
> >
{children} {children}
</AnimatedCard> </AnimatedCard>
) );
Card.displayName = 'Card' Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> React.HTMLAttributes<HTMLDivElement>
), >(({ className, ...props }, ref) => (
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
), ));
) CardHeader.displayName = "CardHeader";
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardTitle = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> React.HTMLAttributes<HTMLDivElement>
), >(({ className, ...props }, ref) => (
) <div
CardDescription.displayName = 'CardDescription' ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardDescription = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> React.HTMLAttributes<HTMLDivElement>
), >(({ className, ...props }, ref) => (
) <div
CardContent.displayName = 'CardContent' ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} /> React.HTMLAttributes<HTMLDivElement>
), >(({ className, ...props }, ref) => (
) <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
CardFooter.displayName = 'CardFooter' ));
CardContent.displayName = "CardContent";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+1 -1
View File
@@ -1,3 +1,3 @@
// The console's Input IS @unom/ui's form input (shadcn-compatible tokens: // The console's Input IS @unom/ui's form input (shadcn-compatible tokens:
// border-input / muted-foreground / ring, material gloss via UnomProviders). // border-input / muted-foreground / ring, material gloss via UnomProviders).
export { InputText as Input } from '@unom/ui/form/input-text' export { InputText as Input } from "@unom/ui/form/input-text";
+1 -1
View File
@@ -1,2 +1,2 @@
// The console's Label IS @unom/ui's form label (radix-backed, text-main). // The console's Label IS @unom/ui's form label (radix-backed, text-main).
export { Label } from '@unom/ui/form/label' export { Label } from "@unom/ui/form/label";
+55 -46
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react' import { motion, useReducedMotion, useTime, useTransform } from "motion/react";
import { motion, useReducedMotion, useTime, useTransform } from 'motion/react' import { useEffect, useRef } from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
// The punktfunk lens, alive. The two overlapping circles of the brand mark are // The punktfunk lens, alive. The two overlapping circles of the brand mark are
// recreated from divs and animated as if orbiting on a path whose long axis points // recreated from divs and animated as if orbiting on a path whose long axis points
@@ -13,76 +13,85 @@ import { cn } from '@/lib/utils'
// both the scaling and the front/back swap. Honours prefers-reduced-motion. // both the scaling and the front/back swap. Honours prefers-reduced-motion.
// Size via className (e.g. `size-8`); geometry derives from the box. // Size via className (e.g. `size-8`); geometry derives from the box.
const DURATION_MS = 1600 const DURATION_MS = 1600;
const R_DEPTH = 0.34 // depth amplitude (fraction of box) → the size change const R_DEPTH = 0.34; // depth amplitude (fraction of box) → the size change
const PERSP = 1.05 // perspective distance (fraction of box); smaller → stronger scaling const PERSP = 1.05; // perspective distance (fraction of box); smaller → stronger scaling
const R_PLANE_FIXED = 0.12 // constant in-plane offset → the two never fully eclipse const R_PLANE_FIXED = 0.12; // constant in-plane offset → the two never fully eclipse
const R_PLANE_SWAY = 0.05 // small in-plane breathing const R_PLANE_SWAY = 0.05; // small in-plane breathing
const DIAG: readonly [number, number] = [-Math.SQRT1_2, Math.SQRT1_2] // lens axis (↙ light / ↗ deep) const DIAG: readonly [number, number] = [-Math.SQRT1_2, Math.SQRT1_2]; // lens axis (↙ light / ↗ deep)
const LOBE_FRAC = 0.58 // circle diameter as a fraction of the box const LOBE_FRAC = 0.58; // circle diameter as a fraction of the box
const REST = 0 // reduced-motion: park flat (widest lens, no depth) = the brand mark const REST = 0; // reduced-motion: park flat (widest lens, no depth) = the brand mark
export function Spinner({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { export function Spinner({
const reduce = useReducedMotion() className,
const ref = useRef<HTMLDivElement>(null) ...props
const sizeRef = useRef(0) }: React.HTMLAttributes<HTMLDivElement>) {
const time = useTime() const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const sizeRef = useRef(0);
const time = useTime();
useEffect(() => { useEffect(() => {
const el = ref.current const el = ref.current;
if (!el) return if (!el) return;
sizeRef.current = el.clientWidth sizeRef.current = el.clientWidth;
const ro = new ResizeObserver((entries) => { const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width const w = entries[0]?.contentRect.width;
if (w) sizeRef.current = w if (w) sizeRef.current = w;
}) });
ro.observe(el) ro.observe(el);
return () => ro.disconnect() return () => ro.disconnect();
}, []) }, []);
const angleAt = (t: number) => (reduce ? REST : (t / DURATION_MS) * Math.PI * 2) const angleAt = (t: number) =>
const depthAt = (t: number, side: number) => side * Math.sin(angleAt(t)) * R_DEPTH reduce ? REST : (t / DURATION_MS) * Math.PI * 2;
const depthAt = (t: number, side: number) =>
side * Math.sin(angleAt(t)) * R_DEPTH;
const transformAt = (t: number, side: number) => { const transformAt = (t: number, side: number) => {
const s = sizeRef.current const s = sizeRef.current;
const angle = angleAt(t) const angle = angleAt(t);
const z = side * Math.sin(angle) * R_DEPTH // world depth (toward viewer = +) const z = side * Math.sin(angle) * R_DEPTH; // world depth (toward viewer = +)
const p = PERSP / (PERSP - z) // perspective: nearer → bigger, farther → smaller const p = PERSP / (PERSP - z); // perspective: nearer → bigger, farther → smaller
const mag = (R_PLANE_FIXED + R_PLANE_SWAY * Math.cos(angle)) * side const mag = (R_PLANE_FIXED + R_PLANE_SWAY * Math.cos(angle)) * side;
const x = mag * DIAG[0] * p * s const x = mag * DIAG[0] * p * s;
const y = mag * DIAG[1] * p * s const y = mag * DIAG[1] * p * s;
return `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${p})` return `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${p})`;
} };
const tLight = useTransform(time, (t) => transformAt(t, 1)) const tLight = useTransform(time, (t) => transformAt(t, 1));
const tDeep = useTransform(time, (t) => transformAt(t, -1)) const tDeep = useTransform(time, (t) => transformAt(t, -1));
// z-index follows depth, so whichever circle is nearer is painted on top. // z-index follows depth, so whichever circle is nearer is painted on top.
const zLight = useTransform(time, (t) => Math.round(depthAt(t, 1) * 1000)) const zLight = useTransform(time, (t) => Math.round(depthAt(t, 1) * 1000));
const zDeep = useTransform(time, (t) => Math.round(depthAt(t, -1) * 1000)) const zDeep = useTransform(time, (t) => Math.round(depthAt(t, -1) * 1000));
const lobe = (color: string): React.CSSProperties => ({ const lobe = (color: string): React.CSSProperties => ({
width: `${LOBE_FRAC * 100}%`, width: `${LOBE_FRAC * 100}%`,
height: `${LOBE_FRAC * 100}%`, height: `${LOBE_FRAC * 100}%`,
backgroundColor: color, backgroundColor: color,
mixBlendMode: 'screen', mixBlendMode: "screen",
}) });
return ( return (
<div <div
ref={ref} ref={ref}
role="status" role="status"
aria-label="Loading" aria-label="Loading"
className={cn('relative inline-block size-6 isolate', className)} className={cn("relative inline-block size-6 isolate", className)}
{...props} {...props}
> >
<motion.div <motion.div
className="absolute left-1/2 top-1/2 rounded-full" className="absolute left-1/2 top-1/2 rounded-full"
style={{ ...lobe('var(--pf-brand-light)'), transform: tLight, zIndex: zLight }} style={{
...lobe("var(--pf-brand-light)"),
transform: tLight,
zIndex: zLight,
}}
/> />
<motion.div <motion.div
className="absolute left-1/2 top-1/2 rounded-full" className="absolute left-1/2 top-1/2 rounded-full"
style={{ ...lobe('var(--pf-brand)'), transform: tDeep, zIndex: zDeep }} style={{ ...lobe("var(--pf-brand)"), transform: tDeep, zIndex: zDeep }}
/> />
</div> </div>
) );
} }
+37 -27
View File
@@ -1,44 +1,54 @@
import * as React from 'react' import * as React from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>( const Table = React.forwardRef<
({ className, ...props }, ref) => ( HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} /> <table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div> </div>
), ));
) Table.displayName = "Table";
Table.displayName = 'Table'
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} /> <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
)) ));
TableHeader.displayName = 'TableHeader' TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} /> <tbody
)) ref={ref}
TableBody.displayName = 'TableBody' className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>( const TableRow = React.forwardRef<
({ className, ...props }, ref) => ( HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className, className,
)} )}
{...props} {...props}
/> />
), ));
) TableRow.displayName = "TableRow";
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -47,13 +57,13 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
'h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className, className,
)} )}
{...props} {...props}
/> />
)) ));
TableHead.displayName = 'TableHead' TableHead.displayName = "TableHead";
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -61,10 +71,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn('p-2 align-middle [&:has([role=checkbox])]:pr-0', className)} className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props} {...props}
/> />
)) ));
TableCell.displayName = 'TableCell' TableCell.displayName = "TableCell";
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
+4 -4
View File
@@ -1,4 +1,4 @@
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
// The punktfunk "funk" wordmark — the real brand typo, vectorised from the // The punktfunk "funk" wordmark — the real brand typo, vectorised from the
// marketing logo. currentColor so it recolours per surface; defaults to the // marketing logo. currentColor so it recolours per surface; defaults to the
@@ -12,7 +12,7 @@ export function Wordmark({ className }: { className?: string }) {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 579 136" viewBox="0 0 579 136"
fill="currentColor" fill="currentColor"
className={cn('w-auto text-highlight', className)} className={cn("w-auto text-highlight", className)}
> >
<title>punktfunk</title> <title>punktfunk</title>
<path d="M16.782,16.051l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z" /> <path d="M16.782,16.051l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z" />
@@ -20,7 +20,7 @@ export function Wordmark({ className }: { className?: string }) {
<path d="M271.575,15.943l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z" /> <path d="M271.575,15.943l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z" />
<path d="M420.91,15.943l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z" /> <path d="M420.91,15.943l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z" />
</svg> </svg>
) );
} }
export default Wordmark export default Wordmark;
+10 -10
View File
@@ -1,29 +1,29 @@
// Thin reactive layer over Paraglide. Paraglide's `m.*` message functions and // Thin reactive layer over Paraglide. Paraglide's `m.*` message functions and
// `setLocale`/`getLocale` are framework-agnostic; this hook re-renders React when the // `setLocale`/`getLocale` are framework-agnostic; this hook re-renders React when the
// locale changes (Paraglide's localStorage strategy persists the choice across reloads). // locale changes (Paraglide's localStorage strategy persists the choice across reloads).
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from "react";
import { getLocale, setLocale, locales } from '@/paraglide/runtime' import { getLocale, locales, setLocale } from "@/paraglide/runtime";
/** The available locales as a union (`'en' | 'de'`), derived from Paraglide's `locales`. */ /** The available locales as a union (`'en' | 'de'`), derived from Paraglide's `locales`. */
export type Locale = (typeof locales)[number] export type Locale = (typeof locales)[number];
const listeners = new Set<() => void>() const listeners = new Set<() => void>();
/** Switch locale and notify subscribers (Paraglide also persists it per its strategy). */ /** Switch locale and notify subscribers (Paraglide also persists it per its strategy). */
export function changeLocale(locale: Locale) { export function changeLocale(locale: Locale) {
// `reload: false` keeps the SPA mounted; we re-render via the store below. // `reload: false` keeps the SPA mounted; we re-render via the store below.
setLocale(locale, { reload: false }) setLocale(locale, { reload: false });
for (const l of listeners) l() for (const l of listeners) l();
} }
function subscribe(cb: () => void) { function subscribe(cb: () => void) {
listeners.add(cb) listeners.add(cb);
return () => listeners.delete(cb) return () => listeners.delete(cb);
} }
/** Current locale, reactive — components using `m.*` should read this so they re-render. */ /** Current locale, reactive — components using `m.*` should read this so they re-render. */
export function useLocale(): Locale { export function useLocale(): Locale {
return useSyncExternalStore(subscribe, getLocale, () => 'en' as Locale) return useSyncExternalStore(subscribe, getLocale, () => "en" as Locale);
} }
export { locales } export { locales };
+12
View File
@@ -0,0 +1,12 @@
/**
* The slice of a React Query result a presentational view needs: just enough to
* drive <QueryState> + render the data. A `UseQueryResult` satisfies it directly
* (so containers pass the query through), and stories can hand-build one without
* mocking the network.
*/
export interface Loadable<T> {
data?: T;
isLoading: boolean;
error: unknown;
refetch?: () => void;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { clsx, type ClassValue } from 'clsx' import { type ClassValue, clsx } from "clsx";
import { twMerge } from 'tailwind-merge' import { twMerge } from "tailwind-merge";
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */ /** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+29 -15
View File
@@ -1,7 +1,7 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { QueryClient } from "@tanstack/react-query";
import { QueryClient } from '@tanstack/react-query' import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from './routeTree.gen' import { ApiError } from "./api/fetcher";
import { ApiError } from './api/fetcher' import { routeTree } from "./routeTree.gen";
export function getRouter() { export function getRouter() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -10,30 +10,44 @@ export function getRouter() {
staleTime: 2_000, staleTime: 2_000,
// Don't hammer the host on auth/validation errors; do retry transient 5xx once. // Don't hammer the host on auth/validation errors; do retry transient 5xx once.
retry: (failureCount, error) => { retry: (failureCount, error) => {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false if (
return failureCount < 1 error instanceof ApiError &&
error.status >= 400 &&
error.status < 500
)
return false;
return failureCount < 1;
}, },
}, },
}, },
}) });
return createTanStackRouter({ return createTanStackRouter({
routeTree, routeTree,
context: { queryClient }, context: { queryClient },
defaultPreload: 'intent', defaultPreload: "intent",
scrollRestoration: true, scrollRestoration: true,
Wrap: ({ children }) => <QueryProvider client={queryClient}>{children}</QueryProvider>, Wrap: ({ children }) => (
}) <QueryProvider client={queryClient}>{children}</QueryProvider>
),
});
} }
// Local import kept below the function so the module reads top-down. // Local import kept below the function so the module reads top-down.
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from "@tanstack/react-query";
function QueryProvider({ client, children }: { client: QueryClient; children: React.ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider> function QueryProvider({
client,
children,
}: {
client: QueryClient;
children: React.ReactNode;
}) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
} }
declare module '@tanstack/react-router' { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: ReturnType<typeof getRouter> router: ReturnType<typeof getRouter>;
} }
} }
+17 -14
View File
@@ -1,36 +1,39 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import type { QueryClient } from "@tanstack/react-query";
import { import {
createRootRouteWithContext, createRootRouteWithContext,
HeadContent, HeadContent,
Outlet, Outlet,
Scripts, Scripts,
useRouterState, useRouterState,
} from '@tanstack/react-router' } from "@tanstack/react-router";
import type { QueryClient } from '@tanstack/react-query' import "@fontsource-variable/geist";
import '@fontsource-variable/geist' import { AppShell } from "@/components/app-shell";
import { AppShell } from '@/components/app-shell' import appCss from "@/styles.css?url";
import appCss from '@/styles.css?url'
export interface RouterContext { export interface RouterContext {
queryClient: QueryClient queryClient: QueryClient;
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({ head: () => ({
meta: [ meta: [
{ charSet: 'utf-8' }, { charSet: "utf-8" },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }, { name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: 'color-scheme', content: 'dark light' }, { name: "color-scheme", content: "dark light" },
{ title: 'punktfunk' }, { title: "punktfunk" },
], ],
links: [{ rel: 'stylesheet', href: appCss }], links: [{ rel: "stylesheet", href: appCss }],
}), }),
component: RootComponent, component: RootComponent,
}) });
function RootComponent() { function RootComponent() {
// The login screen renders bare (no sidebar); everything else gets the app shell. // The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({ select: (s) => s.location.pathname === '/login' }) const isLogin = useRouterState({
select: (s) => s.location.pathname === "/login",
});
return ( return (
<html lang="en" className="dark"> <html lang="en" className="dark">
<head> <head>
@@ -47,5 +50,5 @@ function RootComponent() {
<Scripts /> <Scripts />
</body> </body>
</html> </html>
) );
} }
+3 -89
View File
@@ -1,90 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { useQueryClient } from '@tanstack/react-query' import { SectionClients } from "@/sections/Clients";
import { Trash2 } from 'lucide-react'
import {
useListPairedClients,
useUnpairClient,
getListPairedClientsQueryKey,
} from '@/api/gen/clients/clients'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/clients')({ component: ClientsPage }) export const Route = createFileRoute("/clients")({ component: SectionClients });
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function ClientsPage() {
useLocale()
const qc = useQueryClient()
const clients = useListPairedClients()
const unpair = useUnpairClient()
const rows = clients.data ?? []
const onUnpair = (fingerprint: string) => {
if (!confirm(m.clients_unpair_confirm())) return
unpair.mutate(
{ fingerprint },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }) },
)
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.clients_title()}</h1>
<QueryState isLoading={clients.isLoading} error={clients.error} refetch={clients.refetch}>
{rows.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.clients_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((c) => (
<TableRow key={c.fingerprint}>
<TableCell className="font-medium">{c.subject || '—'}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={unpair.isPending}
onClick={() => onUnpair(c.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
</div>
)
}
+3 -114
View File
@@ -1,115 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { useGetHostInfo, useListCompositors } from '@/api/gen/host/host' import { SectionHost } from "@/sections/Host";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/host')({ component: HostPage }) export const Route = createFileRoute("/host")({ component: SectionHost });
// Exported so Storybook can render the page directly (see src/stories). The
// route gen only needs the `Route` export; this extra one is harmless.
export function HostPage() {
useLocale()
const host = useGetHostInfo()
const compositors = useListCompositors()
const h = host.data
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
<QueryState isLoading={host.isLoading} error={host.error} refetch={host.refetch}>
{h && (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{m.host_identity()}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-3">
<Row label={m.host_hostname()} value={h.hostname} />
<Row label={m.host_local_ip()} value={h.local_ip} mono />
<Row label={m.host_version()} value={`${h.app_version} (${h.version})`} />
<Row label={m.host_abi()} value={String(h.abi_version)} />
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
</dl>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{m.host_codecs()}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{h.codecs.map((c) => (
<Badge key={c} variant="secondary">
{c.toUpperCase()}
</Badge>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.host_ports()}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
{Object.entries(h.ports).map(([k, v]) => (
<div key={k} className="flex justify-between">
<dt className="text-muted-foreground uppercase">{k}</dt>
<dd className="font-medium">{v as number}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
</div>
</div>
)}
</QueryState>
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_compositors_help()}</p>
<QueryState
isLoading={compositors.isLoading}
error={compositors.error}
refetch={compositors.refetch}
>
<ul className="divide-y rounded-md border">
{compositors.data?.map((c) => (
<li key={c.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{c.label}</span>
{c.default && <Badge variant="secondary">{m.compositor_default()}</Badge>}
</div>
<code className="text-xs text-muted-foreground">{c.id}</code>
</div>
<Badge variant={c.available ? 'default' : 'outline'}>
{c.available ? m.compositor_available() : m.compositor_unavailable()}
</Badge>
</li>
))}
</ul>
</QueryState>
</CardContent>
</Card>
</div>
)
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className={mono ? 'truncate font-mono text-xs' : 'font-medium'} title={value}>
{value}
</dd>
</div>
)
}
+3 -138
View File
@@ -1,139 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { useQueryClient } from '@tanstack/react-query' import { SectionDashboard } from "@/sections/Dashboard";
import { Video, Volume2, MonitorPlay, ZapOff, RefreshCw } from 'lucide-react'
import {
useGetStatus,
getGetStatusQueryKey,
} from '@/api/gen/host/host'
import { useStopSession, useRequestIdr } from '@/api/gen/session/session'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/')({ component: Dashboard }) export const Route = createFileRoute("/")({ component: SectionDashboard });
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function Dashboard() {
useLocale()
const qc = useQueryClient()
// Poll live status every 2s so the console tracks an active session.
const status = useGetStatus({ query: { refetchInterval: 2_000 } })
const stop = useStopSession()
const idr = useRequestIdr()
const invalidate = () => qc.invalidateQueries({ queryKey: getGetStatusQueryKey() })
const s = status.data
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
<QueryState isLoading={status.isLoading} error={status.error} refetch={status.refetch}>
{s && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Video className="size-4" />}
label={m.status_video()}
on={s.video_streaming}
/>
<StatCard
icon={<Volume2 className="size-4" />}
label={m.status_audio()}
on={s.audio_streaming}
/>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">{m.status_paired_count()}</span>
<span className="text-2xl font-semibold tabular-nums">{s.paired_clients}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">{m.status_pin_pending()}</span>
<Badge variant={s.pin_pending ? 'default' : 'outline'}>
{s.pin_pending ? '●' : '—'}
</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<MonitorPlay className="size-4" />
{m.status_session()}
</CardTitle>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!s.video_streaming || idr.isPending}
onClick={() => idr.mutate(undefined)}
>
<RefreshCw className="size-3.5" />
{m.action_request_idr()}
</Button>
<Button
variant="destructive"
size="sm"
disabled={!s.session || stop.isPending}
onClick={() => stop.mutate(undefined, { onSuccess: invalidate })}
>
<ZapOff className="size-3.5" />
{m.action_stop_session()}
</Button>
</div>
</CardHeader>
<CardContent>
{s.stream ? (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<Field label={m.stream_codec()} value={s.stream.codec.toUpperCase()} />
<Field
label={m.stream_resolution()}
value={`${s.stream.width}×${s.stream.height}`}
/>
<Field label={m.stream_fps()} value={`${s.stream.fps} fps`} />
<Field
label={m.stream_bitrate()}
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
/>
</dl>
) : (
<p className="text-sm text-muted-foreground">{m.status_no_session()}</p>
)}
</CardContent>
</Card>
</>
)}
</QueryState>
</div>
)
}
function StatCard({ icon, label, on }: { icon: React.ReactNode; label: string; on: boolean }) {
return (
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
</span>
<Badge variant={on ? 'success' : 'outline'}>
{on ? m.status_streaming() : m.status_idle()}
</Badge>
</CardContent>
</Card>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5 font-medium tabular-nums">{value}</dd>
</div>
)
}
+3 -292
View File
@@ -1,293 +1,4 @@
import { useState } from 'react' import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from '@tanstack/react-router' import { SectionLibrary } from "@/sections/Library";
import { useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2, X } from 'lucide-react'
import {
useGetLibrary,
useCreateCustomGame,
useUpdateCustomGame,
useDeleteCustomGame,
getGetLibraryQueryKey,
} from '@/api/gen/library/library'
import type { GameEntry } from '@/api/gen/model/gameEntry'
import type { CustomInput } from '@/api/gen/model/customInput'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/library')({ component: LibraryPage }) export const Route = createFileRoute("/library")({ component: SectionLibrary });
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
function customId(entry: GameEntry): string {
return entry.id.startsWith('custom:') ? entry.id.slice('custom:'.length) : entry.id
}
/** Editable form state for the add/edit custom-game form. */
interface FormState {
title: string
portrait: string
hero: string
header: string
command: string
}
const emptyForm: FormState = { title: '', portrait: '', hero: '', header: '', command: '' }
function formFrom(entry: GameEntry): FormState {
return {
title: entry.title,
portrait: entry.art.portrait ?? '',
hero: entry.art.hero ?? '',
header: entry.art.header ?? '',
command: entry.launch?.kind === 'command' ? entry.launch.value : '',
}
}
/** Map the form to the API body — only attach `launch` when a command was given. */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim()
return t ? t : undefined
}
const command = f.command.trim()
return {
title: f.title.trim(),
art: {
portrait: trim(f.portrait),
hero: trim(f.hero),
header: trim(f.header),
},
launch: command ? { kind: 'command', value: command } : null,
}
}
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function LibraryPage() {
useLocale()
const qc = useQueryClient()
const library = useGetLibrary()
const create = useCreateCustomGame()
const update = useUpdateCustomGame()
const remove = useDeleteCustomGame()
// null = form hidden; '' = adding a new entry; an id = editing that custom entry.
const [editing, setEditing] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const games = library.data ?? []
const invalidate = () => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() })
const openAdd = () => {
setForm(emptyForm)
setEditing('')
}
const openEdit = (entry: GameEntry) => {
setForm(formFrom(entry))
setEditing(customId(entry))
}
const closeForm = () => setEditing(null)
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
const data = toInput(form)
if (!data.title) return
if (editing) {
update.mutate({ id: editing, data }, { onSuccess: () => { invalidate(); closeForm() } })
} else {
create.mutate({ data }, { onSuccess: () => { invalidate(); closeForm() } })
}
}
const onDelete = (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return
remove.mutate({ id: customId(entry) }, { onSuccess: invalidate })
}
const saving = create.isPending || update.isPending
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
{editing === null && (
<Button onClick={openAdd}>
<Plus className="size-4" />
{m.library_add_button()}
</Button>
)}
</div>
{editing !== null && (
<Card className="max-w-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>{editing ? m.library_edit_title() : m.library_add_title()}</CardTitle>
<Button variant="ghost" size="icon" aria-label={m.library_cancel()} onClick={closeForm}>
<X className="size-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
<Input
id="lib-title"
required
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label>
<Input
id="lib-portrait"
type="url"
inputMode="url"
value={form.portrait}
onChange={(e) => setForm((f) => ({ ...f, portrait: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
<Input
id="lib-hero"
type="url"
inputMode="url"
value={form.hero}
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
<Input
id="lib-header"
type="url"
inputMode="url"
value={form.header}
onChange={(e) => setForm((f) => ({ ...f, header: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) => setForm((f) => ({ ...f, command: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">{m.library_field_command_help()}</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={saving || !form.title.trim()}>
{editing ? m.library_save() : m.library_create()}
</Button>
<Button type="button" variant="outline" onClick={closeForm}>
{m.library_cancel()}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<QueryState isLoading={library.isLoading} error={library.error} refetch={library.refetch}>
{games.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.library_empty()}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => openEdit(game)}
onDelete={() => onDelete(game)}
deleting={remove.isPending}
/>
))}
</div>
)}
</QueryState>
</div>
)
}
interface GameCardProps {
game: GameEntry
onEdit: () => void
onDelete: () => void
deleting: boolean
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it falls back to the
* wide header, then to a text placeholder. Custom entries get edit/delete affordances.
*/
function GameCard({ game, onEdit, onDelete, deleting }: GameCardProps) {
const isCustom = game.store === 'custom'
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({})
const candidates = [game.art.portrait, game.art.header].filter(
(u): u is string => !!u && !failed[u],
)
const src = candidates[0]
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className="size-full object-cover"
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
{game.title}
</div>
)}
<div className="absolute left-2 top-2">
<Badge variant={isCustom ? 'secondary' : 'outline'} className="bg-background/80 backdrop-blur">
{isCustom ? m.library_store_custom() : m.library_store_steam()}
</Badge>
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
)}
</div>
<div className="truncate p-2 text-sm font-medium" title={game.title}>
{game.title}
</div>
</Card>
)
}
+9 -80
View File
@@ -1,85 +1,14 @@
import { useState } from 'react' import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, useRouter } from '@tanstack/react-router' import { SectionLogin } from "@/sections/Login";
import { BrandMark } from '@/components/brand-mark'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/login')({ export const Route = createFileRoute("/login")({
validateSearch: (s: Record<string, unknown>): { next?: string } => ({ validateSearch: (s: Record<string, unknown>): { next?: string } => ({
next: typeof s.next === 'string' ? s.next : undefined, next: typeof s.next === "string" ? s.next : undefined,
}), }),
component: LoginPage, component: RouteComponent,
}) });
function LoginPage() { function RouteComponent() {
useLocale() const { next } = Route.useSearch();
const router = useRouter() return <SectionLogin next={next} />;
const { next } = Route.useSearch()
const [password, setPassword] = useState('')
const [error, setError] = useState(false)
const [busy, setBusy] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setBusy(true)
setError(false)
try {
const res = await fetch('/_auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
setError(true)
setBusy(false)
return
}
// Full reload to the target so SSR re-runs WITH the new session cookie. Only a
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
const safe = next && next.startsWith('/') && !next.startsWith('//') ? next : '/'
window.location.href = safe
} catch {
setError(true)
setBusy(false)
}
void router
}
return (
<div className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex items-center gap-2">
<BrandMark className="size-6 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" />
<span className="font-semibold">{m.app_name()}</span>
</div>
<CardTitle>{m.login_title()}</CardTitle>
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pw">{m.login_password()}</Label>
<Input
id="pw"
type="password"
autoFocus
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{m.login_error()}</p>}
<Button type="submit" className="w-full" disabled={busy || !password}>
{busy ? m.login_signing_in() : m.login_submit()}
</Button>
</form>
</CardContent>
</Card>
</div>
)
} }
+2 -388
View File
@@ -1,390 +1,4 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query"; import { SectionPairing } from "@/sections/Pairing";
import {
KeyRound,
CheckCircle2,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import {
useGetNativePairing,
useArmNativePairing,
useDisarmNativePairing,
useListNativeClients,
useUnpairNativeClient,
useListPendingDevices,
useApprovePendingDevice,
useDenyPendingDevice,
getGetNativePairingQueryKey,
getListNativeClientsQueryKey,
getListPendingDevicesQueryKey,
} from "@/api/gen/native/native";
import {
useGetPairingStatus,
useSubmitPairingPin,
getGetPairingStatusQueryKey,
} from "@/api/gen/pairing/pairing";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { QueryState } from "@/components/query-state";
import { m } from "@/paraglide/messages";
import { useLocale } from "@/lib/i18n";
export const Route = createFileRoute("/pairing")({ component: PairingPage }); export const Route = createFileRoute("/pairing")({ component: SectionPairing });
/** 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")}`;
}
function PairingPage() {
useLocale();
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
<PendingDevices />
<NativePairingCard />
<NativeDevices />
<MoonlightPairingCard />
</div>
);
}
/** Seconds since a knock → a short relative label. */
function fmtAge(secs: number): string {
if (secs < 10) return m.pairing_pending_age_just_now();
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
}
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect shows up here,
* and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty
* (the common case); polls so a knock appears while the operator is looking at the page.
*/
function PendingDevices() {
const qc = useQueryClient();
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
const rows = pending.data ?? [];
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
if (rows.length === 0 && !pending.error) return null;
const refresh = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
{ onSuccess: refresh },
);
};
return (
<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={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) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
unpair.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
},
);
};
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={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(
{ data: { pin } },
{
onSuccess: () => {
setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
},
},
);
};
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={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) => 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>
);
}
+5 -54
View File
@@ -1,55 +1,6 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { LogOut } from 'lucide-react' import { SectionSettings } from "@/sections/Settings";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { m } from '@/paraglide/messages'
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
export const Route = createFileRoute('/settings')({ component: SettingsPage }) export const Route = createFileRoute("/settings")({
component: SectionSettings,
// Exported for Storybook (see src/stories) — harmless alongside `Route`. });
export function SettingsPage() {
const current = useLocale()
const onLogout = async () => {
await fetch('/_auth/logout', { method: 'POST' })
window.location.href = '/login'
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.settings_language()}</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
{locales.map((l: Locale) => (
<Button
key={l}
variant={l === current ? 'default' : 'outline'}
size="sm"
className="uppercase"
onClick={() => changeLocale(l)}
>
{l}
</Button>
))}
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.nav_settings()}</CardTitle>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={onLogout}>
<LogOut className="size-4" />
{m.action_logout()}
</Button>
</CardContent>
</Card>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { ClientsView } from "./view";
export const SectionClients: FC = () => {
useLocale();
const qc = useQueryClient();
const clients = useListPairedClients();
const unpair = useUnpairClient();
const onUnpair = (fingerprint: string) => {
if (!confirm(m.clients_unpair_confirm())) return;
unpair.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
},
);
};
return (
<ClientsView
clients={clients}
onUnpair={onUnpair}
isUnpairing={unpair.isPending}
/>
);
};
+80
View File
@@ -0,0 +1,80 @@
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import type { PairedClient } from "@/api/gen/model/pairedClient";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
export const ClientsView: FC<{
clients: Loadable<PairedClient[]>;
onUnpair: (fingerprint: string) => void;
isUnpairing: boolean;
}> = ({ clients, onUnpair, isUnpairing }) => {
const rows = clients.data ?? [];
return (
<Section>
<h1 className="text-2xl font-semibold">{m.clients_title()}</h1>
<QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.clients_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((c) => (
<TableRow key={c.fingerprint}>
<TableCell className="font-medium">
{c.subject || "—"}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={isUnpairing}
onClick={() => onUnpair(c.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
</Section>
);
};
+28
View File
@@ -0,0 +1,28 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
import { useRequestIdr, useStopSession } from "@/api/gen/session/session";
import { useLocale } from "@/lib/i18n";
import { DashboardView } from "./view";
export const SectionDashboard: FC = () => {
useLocale();
const qc = useQueryClient();
// Poll live status every 2s so the console tracks an active session.
const status = useGetStatus({ query: { refetchInterval: 2_000 } });
const stop = useStopSession();
const idr = useRequestIdr();
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
return (
<DashboardView
status={status}
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
onRequestIdr={() => idr.mutate(undefined)}
isStopping={stop.isPending}
isRequestingIdr={idr.isPending}
/>
);
};
+147
View File
@@ -0,0 +1,147 @@
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
import type { FC, ReactNode } from "react";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
export const DashboardView: FC<{
status: Loadable<RuntimeStatus>;
onStopSession: () => void;
onRequestIdr: () => void;
isStopping: boolean;
isRequestingIdr: boolean;
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
const s = status.data;
return (
<Section>
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
{s && (
<div className="flex flex-col gap-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Video className="size-4" />}
label={m.status_video()}
on={s.video_streaming}
/>
<StatCard
icon={<Volume2 className="size-4" />}
label={m.status_audio()}
on={s.audio_streaming}
/>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">
{m.status_paired_count()}
</span>
<span className="text-2xl font-semibold tabular-nums">
{s.paired_clients}
</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
<Badge variant={s.pin_pending ? "default" : "outline"}>
{s.pin_pending ? "●" : "—"}
</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<MonitorPlay className="size-4" />
{m.status_session()}
</CardTitle>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!s.video_streaming || isRequestingIdr}
onClick={onRequestIdr}
>
<RefreshCw className="size-3.5" />
{m.action_request_idr()}
</Button>
<Button
variant="destructive"
size="sm"
disabled={!s.session || isStopping}
onClick={onStopSession}
>
<ZapOff className="size-3.5" />
{m.action_stop_session()}
</Button>
</div>
</CardHeader>
<CardContent>
{s.stream ? (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<Field
label={m.stream_codec()}
value={s.stream.codec.toUpperCase()}
/>
<Field
label={m.stream_resolution()}
value={`${s.stream.width}×${s.stream.height}`}
/>
<Field
label={m.stream_fps()}
value={`${s.stream.fps} fps`}
/>
<Field
label={m.stream_bitrate()}
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
/>
</dl>
) : (
<p className="text-sm text-muted-foreground">
{m.status_no_session()}
</p>
)}
</CardContent>
</Card>
</div>
)}
</QueryState>
</Section>
);
};
const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
icon,
label,
on,
}) => (
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
</span>
<Badge variant={on ? "success" : "outline"}>
{on ? m.status_streaming() : m.status_idle()}
</Badge>
</CardContent>
</Card>
);
const Field: FC<{ label: string; value: string }> = ({ label, value }) => (
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5 font-medium tabular-nums">{value}</dd>
</div>
);
+12
View File
@@ -0,0 +1,12 @@
import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { HostView } from "./view";
export const SectionHost: FC = () => {
useLocale();
const host = useGetHostInfo();
const compositors = useListCompositors();
return <HostView host={host} compositors={compositors} />;
};
+138
View File
@@ -0,0 +1,138 @@
import type { FC } from "react";
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
export const HostView: FC<{
host: Loadable<HostInfo>;
compositors: Loadable<AvailableCompositor[]>;
}> = ({ host, compositors }) => {
const h = host.data;
return (
<Section>
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
<QueryState
isLoading={host.isLoading}
error={host.error}
refetch={host.refetch}
>
{h && (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{m.host_identity()}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-3">
<Row label={m.host_hostname()} value={h.hostname} />
<Row label={m.host_local_ip()} value={h.local_ip} mono />
<Row
label={m.host_version()}
value={`${h.app_version} (${h.version})`}
/>
<Row label={m.host_abi()} value={String(h.abi_version)} />
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
</dl>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{m.host_codecs()}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{h.codecs.map((c) => (
<Badge key={c} variant="secondary">
{c.toUpperCase()}
</Badge>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.host_ports()}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
{Object.entries(h.ports).map(([k, v]) => (
<div key={k} className="flex justify-between">
<dt className="text-muted-foreground uppercase">{k}</dt>
<dd className="font-medium">{v as number}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
</div>
</div>
)}
</QueryState>
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{m.host_compositors_help()}
</p>
<QueryState
isLoading={compositors.isLoading}
error={compositors.error}
refetch={compositors.refetch}
>
<ul className="divide-y rounded-md border">
{compositors.data?.map((c) => (
<li
key={c.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{c.label}</span>
{c.default && (
<Badge variant="secondary">
{m.compositor_default()}
</Badge>
)}
</div>
<code className="text-xs text-muted-foreground">
{c.id}
</code>
</div>
<Badge variant={c.available ? "default" : "outline"}>
{c.available
? m.compositor_available()
: m.compositor_unavailable()}
</Badge>
</li>
))}
</ul>
</QueryState>
</CardContent>
</Card>
</Section>
);
};
const Row: FC<{ label: string; value: string; mono?: boolean }> = ({
label,
value,
mono,
}) => (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd
className={mono ? "truncate font-mono text-xs" : "font-medium"}
title={value}
>
{value}
</dd>
</div>
);
+37
View File
@@ -0,0 +1,37 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useCreateCustomGame,
useDeleteCustomGame,
useGetLibrary,
useUpdateCustomGame,
} from "@/api/gen/library/library";
import type { CustomInput } from "@/api/gen/model/customInput";
import { useLocale } from "@/lib/i18n";
import { LibraryView } from "./view";
export const SectionLibrary: FC = () => {
useLocale();
const qc = useQueryClient();
const library = useGetLibrary();
const create = useCreateCustomGame();
const update = useUpdateCustomGame();
const remove = useDeleteCustomGame();
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
return (
<LibraryView
library={library}
onCreate={(data: CustomInput) =>
create.mutateAsync({ data }).then(invalidate)
}
onUpdate={(id, data) => update.mutateAsync({ id, data }).then(invalidate)}
onDelete={(id) => remove.mutateAsync({ id }).then(invalidate)}
isSaving={create.isPending || update.isPending}
isDeleting={remove.isPending}
/>
);
};
+311
View File
@@ -0,0 +1,311 @@
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { type FC, useState } from "react";
import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
function customId(entry: GameEntry): string {
return entry.id.startsWith("custom:")
? entry.id.slice("custom:".length)
: entry.id;
}
interface FormState {
title: string;
portrait: string;
hero: string;
header: string;
command: string;
}
const emptyForm: FormState = {
title: "",
portrait: "",
hero: "",
header: "",
command: "",
};
function formFrom(entry: GameEntry): FormState {
return {
title: entry.title,
portrait: entry.art.portrait ?? "",
hero: entry.art.hero ?? "",
header: entry.art.header ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
};
}
/** Map the form to the API body — only attach `launch` when a command was given. */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim();
return t ? t : undefined;
};
const command = f.command.trim();
return {
title: f.title.trim(),
art: {
portrait: trim(f.portrait),
hero: trim(f.hero),
header: trim(f.header),
},
launch: command ? { kind: "command", value: command } : null,
};
}
export const LibraryView: FC<{
library: Loadable<GameEntry[]>;
onCreate: (data: CustomInput) => Promise<unknown>;
onUpdate: (id: string, data: CustomInput) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
isSaving: boolean;
isDeleting: boolean;
}> = ({ library, onCreate, onUpdate, onDelete, isSaving, isDeleting }) => {
// null = form hidden; "" = adding a new entry; an id = editing that custom entry.
const [editing, setEditing] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const games = library.data ?? [];
const openAdd = () => {
setForm(emptyForm);
setEditing("");
};
const openEdit = (entry: GameEntry) => {
setForm(formFrom(entry));
setEditing(customId(entry));
};
const closeForm = () => setEditing(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data = toInput(form);
if (!data.title) return;
await (editing ? onUpdate(editing, data) : onCreate(data));
closeForm();
};
const handleDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
await onDelete(customId(entry));
};
return (
<Section>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
{editing === null && (
<Button onClick={openAdd}>
<Plus className="size-4" />
{m.library_add_button()}
</Button>
)}
</div>
{editing !== null && (
<Card className="max-w-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>
{editing ? m.library_edit_title() : m.library_add_title()}
</CardTitle>
<Button
variant="ghost"
size="icon"
aria-label={m.library_cancel()}
onClick={closeForm}
>
<X className="size-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
<Input
id="lib-title"
required
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-portrait">
{m.library_field_portrait()}
</Label>
<Input
id="lib-portrait"
type="url"
inputMode="url"
value={form.portrait}
onChange={(e) =>
setForm((f) => ({ ...f, portrait: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
<Input
id="lib-hero"
type="url"
inputMode="url"
value={form.hero}
onChange={(e) =>
setForm((f) => ({ ...f, hero: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
<Input
id="lib-header"
type="url"
inputMode="url"
value={form.header}
onChange={(e) =>
setForm((f) => ({ ...f, header: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) =>
setForm((f) => ({ ...f, command: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
{m.library_field_command_help()}
</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{editing ? m.library_save() : m.library_create()}
</Button>
<Button type="button" variant="outline" onClick={closeForm}>
{m.library_cancel()}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{games.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.library_empty()}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => openEdit(game)}
onDelete={() => handleDelete(game)}
deleting={isDeleting}
/>
))}
</div>
)}
</QueryState>
</Section>
);
};
interface GameCardProps {
game: GameEntry;
onEdit: () => void;
onDelete: () => void;
deleting: boolean;
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
* falls back to the wide header, then to a text placeholder. Custom entries get
* edit/delete affordances.
*/
const GameCard: FC<GameCardProps> = ({ game, onEdit, onDelete, deleting }) => {
const isCustom = game.store === "custom";
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({});
const candidates = [game.art.portrait, game.art.header].filter(
(u): u is string => !!u && !failed[u],
);
const src = candidates[0];
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className="size-full object-cover"
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
{game.title}
</div>
)}
<div className="absolute left-2 top-2">
<Badge
variant={isCustom ? "secondary" : "outline"}
className="bg-background/80 backdrop-blur"
>
{isCustom ? m.library_store_custom() : m.library_store_steam()}
</Badge>
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
)}
</div>
<div className="truncate p-2 text-sm font-medium" title={game.title}>
{game.title}
</div>
</Card>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { type FC, useState } from "react";
import { useLocale } from "@/lib/i18n";
import { LoginView } from "./view";
export const SectionLogin: FC<{ next?: string }> = ({ next }) => {
useLocale();
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const onSubmit = async (password: string) => {
setBusy(true);
setError(false);
try {
const res = await fetch("/_auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) {
setError(true);
setBusy(false);
return;
}
// Full reload to the target so SSR re-runs WITH the new session cookie. Only a
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
const safe =
next && next.startsWith("/") && !next.startsWith("//") ? next : "/";
window.location.href = safe;
} catch {
setError(true);
setBusy(false);
}
};
return <LoginView onSubmit={onSubmit} error={error} busy={busy} />;
};
+60
View File
@@ -0,0 +1,60 @@
import { type FC, useState } from "react";
import Logo from "@/components/logo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
export const LoginView: FC<{
onSubmit: (password: string) => void;
error: boolean;
busy: boolean;
}> = ({ onSubmit, error, busy }) => {
const [password, setPassword] = useState("");
return (
<div className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex w-[80px] items-center gap-2">
<Logo />
</div>
<CardTitle>{m.login_title()}</CardTitle>
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(password);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="pw">{m.login_password()}</Label>
<Input
id="pw"
type="password"
// biome-ignore lint/a11y/noAutofocus: the login screen is the sole focus target.
autoFocus
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p className="text-sm text-destructive">{m.login_error()}</p>
)}
<Button
type="submit"
className="w-full"
disabled={busy || !password}
>
{busy ? m.login_signing_in() : m.login_submit()}
</Button>
</form>
</CardContent>
</Card>
</div>
);
};
+391
View File
@@ -0,0 +1,391 @@
import { useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
KeyRound,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import { type FC, useState } from "react";
import {
getGetNativePairingQueryKey,
getListNativeClientsQueryKey,
getListPendingDevicesQueryKey,
useApprovePendingDevice,
useArmNativePairing,
useDenyPendingDevice,
useDisarmNativePairing,
useGetNativePairing,
useListNativeClients,
useListPendingDevices,
useUnpairNativeClient,
} from "@/api/gen/native/native";
import {
getGetPairingStatusQueryKey,
useGetPairingStatus,
useSubmitPairingPin,
} from "@/api/gen/pairing/pairing";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
/** Seconds → `m:ss`. */
function fmtTime(secs: number): string {
const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
// Pairing composes four independent sub-cards, each its own little container
// (own query + mutations). They share the page's staggered entrance via <Section>.
export const SectionPairing: FC = () => {
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 pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
const rows = pending.data ?? [];
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
if (rows.length === 0 && !pending.error) return null;
const refresh = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
{ onSuccess: refresh },
);
};
return (
<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={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) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
unpair.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
},
);
};
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={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(
{ data: { pin } },
{
onSuccess: () => {
setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
},
},
);
};
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={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) => 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>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { LogOut } from "lucide-react";
import type { FC } from "react";
import { Section } from "@/components/section";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
// Settings reads no API (just the locale + a logout button), so it's a single
// presentational section — no container/view split needed.
export const SectionSettings: FC = () => {
const current = useLocale();
const onLogout = async () => {
await fetch("/_auth/logout", { method: "POST" });
window.location.href = "/login";
};
return (
<Section>
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.settings_language()}</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
{locales.map((l: Locale) => (
<Button
key={l}
variant={l === current ? "default" : "outline"}
size="sm"
className="uppercase"
onClick={() => changeLocale(l)}
>
{l}
</Button>
))}
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.nav_settings()}</CardTitle>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={onLogout}>
<LogOut className="size-4" />
{m.action_logout()}
</Button>
</CardContent>
</Card>
</Section>
);
};
+34 -18
View File
@@ -1,12 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { import {
createMemoryHistory, createMemoryHistory,
createRootRoute, createRootRoute,
createRoute, createRoute,
createRouter, createRouter,
RouterProvider, RouterProvider,
} from '@tanstack/react-router' } from "@tanstack/react-router";
import { AppShell } from '@/components/app-shell' import { AppShell } from "@/components/app-shell";
// AppShell is built from TanStack Router <Link>s, so it needs a router context. // AppShell is built from TanStack Router <Link>s, so it needs a router context.
// We stand up a throwaway in-memory router whose routes mirror the nav targets // We stand up a throwaway in-memory router whose routes mirror the nav targets
@@ -19,44 +19,60 @@ function ShellHarness({ initialPath }: { initialPath: string }) {
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Placeholder content swap routes from the sidebar to preview the active state. Placeholder content swap routes from the sidebar to preview the
active state.
</p> </p>
</div> </div>
</AppShell> </AppShell>
), ),
}) });
const navPaths = ['/', '/host', '/library', '/clients', '/pairing', '/settings'] const navPaths = [
"/",
"/host",
"/library",
"/clients",
"/pairing",
"/settings",
];
const navRoutes = navPaths.map((path) => const navRoutes = navPaths.map((path) =>
createRoute({ getParentRoute: () => rootRoute, path, component: () => null }), createRoute({
) getParentRoute: () => rootRoute,
path,
component: () => null,
}),
);
// Splat so any other <Link> target still resolves without throwing. // Splat so any other <Link> target still resolves without throwing.
const splat = createRoute({ getParentRoute: () => rootRoute, path: '$', component: () => null }) const splat = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({ const router = createRouter({
routeTree: rootRoute.addChildren([...navRoutes, splat]), routeTree: rootRoute.addChildren([...navRoutes, splat]),
history: createMemoryHistory({ initialEntries: [initialPath] }), history: createMemoryHistory({ initialEntries: [initialPath] }),
}) });
return <RouterProvider router={router} /> return <RouterProvider router={router} />;
} }
const meta = { const meta = {
title: 'Shell/AppShell', title: "Shell/AppShell",
component: AppShell, component: AppShell,
parameters: { layout: 'fullscreen' }, parameters: { layout: "fullscreen" },
// AppShell requires `children`; the harness supplies the real content, so this // AppShell requires `children`; the harness supplies the real content, so this
// placeholder just satisfies the arg type. // placeholder just satisfies the arg type.
args: { children: null }, args: { children: null },
} satisfies Meta<typeof AppShell> } satisfies Meta<typeof AppShell>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Dashboard: Story = { export const Dashboard: Story = {
render: () => <ShellHarness initialPath="/" />, render: () => <ShellHarness initialPath="/" />,
} };
export const HostActive: Story = { export const HostActive: Story = {
render: () => <ShellHarness initialPath="/host" />, render: () => <ShellHarness initialPath="/host" />,
} };
+17 -11
View File
@@ -1,21 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
const VARIANTS = ['default', 'secondary', 'success', 'destructive', 'outline'] as const const VARIANTS = [
"default",
"secondary",
"success",
"destructive",
"outline",
] as const;
const meta = { const meta = {
title: 'UI/Badge', title: "UI/Badge",
component: Badge, component: Badge,
args: { children: 'badge' }, args: { children: "badge" },
argTypes: { argTypes: {
variant: { control: 'select', options: VARIANTS }, variant: { control: "select", options: VARIANTS },
}, },
} satisfies Meta<typeof Badge> } satisfies Meta<typeof Badge>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Playground: Story = {} export const Playground: Story = {};
export const All: Story = { export const All: Story = {
render: () => ( render: () => (
@@ -27,4 +33,4 @@ export const All: Story = {
))} ))}
</div> </div>
), ),
} };
+11 -11
View File
@@ -1,15 +1,15 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { BrandMark } from '@/components/brand-mark' import { BrandMark } from "@/components/brand-mark";
import { Wordmark } from '@/components/wordmark' import { Logo } from "@/components/logo";
import { Logo } from '@/components/logo' import { Wordmark } from "@/components/wordmark";
const meta = { const meta = {
title: 'Brand/Marks', title: "Brand/Marks",
component: BrandMark, component: BrandMark,
} satisfies Meta<typeof BrandMark> } satisfies Meta<typeof BrandMark>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Mark: Story = { export const Mark: Story = {
render: () => ( render: () => (
@@ -19,7 +19,7 @@ export const Mark: Story = {
<BrandMark className="size-20" /> <BrandMark className="size-20" />
</div> </div>
), ),
} };
export const Word: Story = { export const Word: Story = {
render: () => ( render: () => (
@@ -29,7 +29,7 @@ export const Word: Story = {
<Wordmark className="h-8 text-primary" /> <Wordmark className="h-8 text-primary" />
</div> </div>
), ),
} };
export const Lockup: Story = { export const Lockup: Story = {
render: () => ( render: () => (
@@ -37,4 +37,4 @@ export const Lockup: Story = {
<Logo className="w-48" /> <Logo className="w-48" />
</div> </div>
), ),
} };
+23 -16
View File
@@ -1,26 +1,33 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { Play } from 'lucide-react' import { Play } from "lucide-react";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
const VARIANTS = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const const VARIANTS = [
const SIZES = ['default', 'sm', 'lg', 'icon'] as const "default",
"secondary",
"outline",
"ghost",
"link",
"destructive",
] as const;
const SIZES = ["default", "sm", "lg", "icon"] as const;
const meta = { const meta = {
title: 'UI/Button', title: "UI/Button",
component: Button, component: Button,
args: { children: 'Stream' }, args: { children: "Stream" },
argTypes: { argTypes: {
variant: { control: 'select', options: VARIANTS }, variant: { control: "select", options: VARIANTS },
size: { control: 'select', options: SIZES }, size: { control: "select", options: SIZES },
disabled: { control: 'boolean' }, disabled: { control: "boolean" },
}, },
} satisfies Meta<typeof Button> } satisfies Meta<typeof Button>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
/** Playground — drive variant/size/disabled from the Controls panel. */ /** Playground — drive variant/size/disabled from the Controls panel. */
export const Playground: Story = {} export const Playground: Story = {};
export const Variants: Story = { export const Variants: Story = {
render: () => ( render: () => (
@@ -32,7 +39,7 @@ export const Variants: Story = {
))} ))}
</div> </div>
), ),
} };
export const Sizes: Story = { export const Sizes: Story = {
render: () => ( render: () => (
@@ -45,4 +52,4 @@ export const Sizes: Story = {
</Button> </Button>
</div> </div>
), ),
} };
+9 -9
View File
@@ -1,4 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -6,20 +8,18 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from "@/components/ui/card";
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const meta = { const meta = {
title: 'UI/Card', title: "UI/Card",
component: Card, component: Card,
// Card requires `children`; every story supplies its own via `render`, so this // Card requires `children`; every story supplies its own via `render`, so this
// is just a placeholder to satisfy the arg type. // is just a placeholder to satisfy the arg type.
args: { children: null }, args: { children: null },
} satisfies Meta<typeof Card> } satisfies Meta<typeof Card>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const HostCard: Story = { export const HostCard: Story = {
render: () => ( render: () => (
@@ -42,4 +42,4 @@ export const HostCard: Story = {
</CardFooter> </CardFooter>
</Card> </Card>
), ),
} };
+13 -21
View File
@@ -1,28 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { ClientsPage } from '@/routes/clients' import { ClientsView } from "@/sections/Clients/view";
import { MockApi } from './lib/mock-api' import { pairedClients } from "./lib/fixtures";
import { pairedClients } from './lib/fixtures'
const meta = { const meta = {
title: 'Pages/Clients', title: "Pages/Clients",
component: ClientsPage, component: ClientsView,
} satisfies Meta<typeof ClientsPage> args: { onUnpair: () => {}, isUnpairing: false },
} satisfies Meta<typeof ClientsView>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Paired: Story = { export const Paired: Story = {
render: () => ( args: { clients: { data: pairedClients, isLoading: false, error: null } },
<MockApi routes={{ '/api/v1/clients': pairedClients }}> };
<ClientsPage />
</MockApi>
),
}
export const Empty: Story = { export const Empty: Story = {
render: () => ( args: { clients: { data: [], isLoading: false, error: null } },
<MockApi routes={{ '/api/v1/clients': [] }}> };
<ClientsPage />
</MockApi>
),
}
+18 -21
View File
@@ -1,28 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { Dashboard } from '@/routes/index' import { DashboardView } from "@/sections/Dashboard/view";
import { MockApi } from './lib/mock-api' import { statusActive, statusIdle } from "./lib/fixtures";
import { statusActive, statusIdle } from './lib/fixtures'
const meta = { const meta = {
title: 'Pages/Dashboard', title: "Pages/Dashboard",
component: Dashboard, component: DashboardView,
} satisfies Meta<typeof Dashboard> args: {
onStopSession: () => {},
onRequestIdr: () => {},
isStopping: false,
isRequestingIdr: false,
},
} satisfies Meta<typeof DashboardView>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const ActiveSession: Story = { export const ActiveSession: Story = {
render: () => ( args: { status: { data: statusActive, isLoading: false, error: null } },
<MockApi routes={{ '/api/v1/status': statusActive }}> };
<Dashboard />
</MockApi>
),
}
export const Idle: Story = { export const Idle: Story = {
render: () => ( args: { status: { data: statusIdle, isLoading: false, error: null } },
<MockApi routes={{ '/api/v1/status': statusIdle }}> };
<Dashboard />
</MockApi>
),
}
+20 -16
View File
@@ -1,20 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { HostPage } from '@/routes/host' import { HostView } from "@/sections/Host/view";
import { MockApi } from './lib/mock-api' import { compositors, hostInfo } from "./lib/fixtures";
import { compositors, hostInfo } from './lib/fixtures'
const meta = { const meta = {
title: 'Pages/Host', title: "Pages/Host",
component: HostPage, component: HostView,
} satisfies Meta<typeof HostPage> args: {
host: { data: hostInfo, isLoading: false, error: null },
compositors: { data: compositors, isLoading: false, error: null },
},
} satisfies Meta<typeof HostView>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {};
render: () => (
<MockApi routes={{ '/api/v1/host': hostInfo, '/api/v1/compositors': compositors }}> export const Loading: Story = {
<HostPage /> args: {
</MockApi> host: { data: undefined, isLoading: true, error: null },
), compositors: { data: undefined, isLoading: true, error: null },
} },
};
+8 -8
View File
@@ -1,14 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
const meta = { const meta = {
title: 'UI/Inputs', title: "UI/Inputs",
component: Input, component: Input,
} satisfies Meta<typeof Input> } satisfies Meta<typeof Input>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Form: Story = { export const Form: Story = {
render: () => ( render: () => (
@@ -27,4 +27,4 @@ export const Form: Story = {
</div> </div>
</div> </div>
), ),
} };
+19 -21
View File
@@ -1,28 +1,26 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { LibraryPage } from '@/routes/library' import { LibraryView } from "@/sections/Library/view";
import { MockApi } from './lib/mock-api' import { library } from "./lib/fixtures";
import { library } from './lib/fixtures'
const meta = { const meta = {
title: 'Pages/Library', title: "Pages/Library",
component: LibraryPage, component: LibraryView,
} satisfies Meta<typeof LibraryPage> args: {
onCreate: () => Promise.resolve(),
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
isSaving: false,
isDeleting: false,
},
} satisfies Meta<typeof LibraryView>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Populated: Story = { export const Populated: Story = {
render: () => ( args: { library: { data: library, isLoading: false, error: null } },
<MockApi routes={{ '/api/v1/library': library }}> };
<LibraryPage />
</MockApi>
),
}
export const Empty: Story = { export const Empty: Story = {
render: () => ( args: { library: { data: [], isLoading: false, error: null } },
<MockApi routes={{ '/api/v1/library': [] }}> };
<LibraryPage />
</MockApi>
),
}
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LoginView } from "@/sections/Login/view";
const meta = {
title: "Pages/Login",
component: LoginView,
parameters: { layout: "fullscreen" },
args: { onSubmit: () => {}, error: false, busy: false },
} satisfies Meta<typeof LoginView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Error: Story = { args: { error: true } };
+21 -15
View File
@@ -1,36 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryState } from '@/components/query-state' import { ApiError } from "@/api/fetcher";
import { ApiError } from '@/api/fetcher' import { QueryState } from "@/components/query-state";
// QueryState is the uniform loading/error wrapper every data-backed route uses — // QueryState is the uniform loading/error wrapper every data-backed route uses —
// the most useful thing to design WITHOUT a running host, since its three states // the most useful thing to design WITHOUT a running host, since its three states
// (loading spinner / error / unauthorized) never appear together live. // (loading spinner / error / unauthorized) never appear together live.
const Loaded = () => ( const Loaded = () => (
<div className="rounded-lg border p-4 text-sm">Loaded content renders here.</div> <div className="rounded-lg border p-4 text-sm">
) Loaded content renders here.
</div>
);
const meta = { const meta = {
title: 'Patterns/QueryState', title: "Patterns/QueryState",
component: QueryState, component: QueryState,
args: { children: <Loaded /> }, args: { children: <Loaded /> },
} satisfies Meta<typeof QueryState> } satisfies Meta<typeof QueryState>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Loading: Story = { export const Loading: Story = {
args: { isLoading: true, error: null }, args: { isLoading: true, error: null },
} };
export const ErrorWithRetry: Story = { export const ErrorWithRetry: Story = {
args: { isLoading: false, error: new Error('connection refused'), refetch: () => {} }, args: {
} isLoading: false,
error: new Error("connection refused"),
refetch: () => {},
},
};
export const Unauthorized: Story = { export const Unauthorized: Story = {
args: { isLoading: false, error: new ApiError(401, null) }, args: { isLoading: false, error: new ApiError(401, null) },
} };
export const Loaded_: Story = { export const Loaded_: Story = {
name: 'Success', name: "Success",
args: { isLoading: false, error: null }, args: { isLoading: false, error: null },
} };
+8 -10
View File
@@ -1,14 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { SettingsPage } from '@/routes/settings' import { SectionSettings } from "@/sections/Settings";
// Settings reads no API (just the locale + a logout button), so it renders
// directly — no mock needed.
const meta = { const meta = {
title: 'Pages/Settings', title: "Pages/Settings",
component: SettingsPage, component: SectionSettings,
} satisfies Meta<typeof SettingsPage> } satisfies Meta<typeof SectionSettings>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Default: Story = {} export const Default: Story = {};
+9 -9
View File
@@ -1,15 +1,15 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from "@storybook/react-vite";
import { Spinner } from '@/components/ui/spinner' import { Spinner } from "@/components/ui/spinner";
const meta = { const meta = {
title: 'UI/Spinner', title: "UI/Spinner",
component: Spinner, component: Spinner,
} satisfies Meta<typeof Spinner> } satisfies Meta<typeof Spinner>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Default: Story = {} export const Default: Story = {};
export const Large: Story = { export const Large: Story = {
render: () => ( render: () => (
@@ -17,7 +17,7 @@ export const Large: Story = {
<Spinner className="size-40" /> <Spinner className="size-40" />
</div> </div>
), ),
} };
export const Sizes: Story = { export const Sizes: Story = {
render: () => ( render: () => (
@@ -28,4 +28,4 @@ export const Sizes: Story = {
<Spinner className="size-10 text-primary" /> <Spinner className="size-10 text-primary" />
</div> </div>
), ),
} };
+60 -33
View File
@@ -1,18 +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 { 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 { PairedClient } from '@/api/gen/model/pairedClient' import type { PairedClient } from "@/api/gen/model/pairedClient";
import type { RuntimeStatus } from '@/api/gen/model/runtimeStatus' import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
export const hostInfo: HostInfo = { export const hostInfo: HostInfo = {
abi_version: 2, abi_version: 2,
app_version: '7.1.450.0', app_version: "7.1.450.0",
codecs: ['h264', 'h265', 'av1'], codecs: ["h264", "h265", "av1"],
gfe_version: '3.23.0.74', gfe_version: "3.23.0.74",
hostname: 'ENRICOS-DESKTOP', hostname: "ENRICOS-DESKTOP",
local_ip: '192.168.1.173', local_ip: "192.168.1.173",
ports: { ports: {
audio: 48000, audio: 48000,
control: 47999, control: 47999,
@@ -22,16 +22,16 @@ export const hostInfo: HostInfo = {
rtsp: 48010, rtsp: 48010,
video: 47998, video: 47998,
}, },
uniqueid: '0f8a1c3e9b7d4a62', uniqueid: "0f8a1c3e9b7d4a62",
version: '0.2.0', version: "0.2.0",
} };
export const compositors: AvailableCompositor[] = [ export const compositors: AvailableCompositor[] = [
{ id: 'kwin', label: 'KWin (Plasma)', available: true, default: true }, { id: "kwin", label: "KWin (Plasma)", available: true, default: true },
{ id: 'gamescope', label: 'gamescope', available: true, default: false }, { id: "gamescope", label: "gamescope", available: true, default: false },
{ id: 'mutter', label: 'Mutter (GNOME)', available: false, default: false }, { id: "mutter", label: "Mutter (GNOME)", available: false, default: false },
{ id: 'wlroots', label: 'Sway / wlroots', available: false, default: false }, { id: "wlroots", label: "Sway / wlroots", available: false, default: false },
] ];
export const statusActive: RuntimeStatus = { export const statusActive: RuntimeStatus = {
video_streaming: true, video_streaming: true,
@@ -40,7 +40,7 @@ export const statusActive: RuntimeStatus = {
pin_pending: false, pin_pending: false,
session: { width: 5120, height: 1440, fps: 240 }, session: { width: 5120, height: 1440, fps: 240 },
stream: { stream: {
codec: 'h265', codec: "h265",
width: 5120, width: 5120,
height: 1440, height: 1440,
fps: 240, fps: 240,
@@ -48,7 +48,7 @@ export const statusActive: RuntimeStatus = {
min_fec: 5, min_fec: 5,
packet_size: 1392, packet_size: 1392,
}, },
} };
export const statusIdle: RuntimeStatus = { export const statusIdle: RuntimeStatus = {
video_streaming: false, video_streaming: false,
@@ -57,31 +57,58 @@ export const statusIdle: RuntimeStatus = {
pin_pending: true, pin_pending: true,
session: null, session: null,
stream: null, stream: null,
} };
export const pairedClients: PairedClient[] = [ export const pairedClients: PairedClient[] = [
{ {
fingerprint: 'a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00', fingerprint:
subject: 'enricos-macbook', "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
subject: "enricos-macbook",
not_before_unix: 1_718_000_000, not_before_unix: 1_718_000_000,
not_after_unix: 2_030_000_000, not_after_unix: 2_030_000_000,
}, },
{ {
fingerprint: 'ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1', fingerprint:
subject: 'living-room-tv', "ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
subject: "living-room-tv",
not_before_unix: 1_718_500_000, not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000, not_after_unix: 2_030_000_000,
}, },
{ {
fingerprint: '0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff', fingerprint:
"0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff",
subject: null, subject: null,
}, },
] ];
const noArt = { header: null, hero: null, logo: null, portrait: null } const noArt = { header: null, hero: null, logo: null, portrait: null };
export const library: GameEntry[] = [ export const library: GameEntry[] = [
{ id: 'steam:1245620', store: 'steam', title: 'Elden Ring', art: noArt, launch: null }, {
{ id: 'steam:1086940', store: 'steam', title: "Baldur's Gate 3", art: noArt, launch: null }, id: "steam:1245620",
{ id: 'steam:413150', store: 'steam', title: 'Stardew Valley', art: noArt, launch: null }, store: "steam",
{ id: 'custom:retroarch', store: 'custom', title: 'RetroArch', art: noArt, launch: null }, title: "Elden Ring",
] art: noArt,
launch: null,
},
{
id: "steam:1086940",
store: "steam",
title: "Baldur's Gate 3",
art: noArt,
launch: null,
},
{
id: "steam:413150",
store: "steam",
title: "Stardew Valley",
art: noArt,
launch: null,
},
{
id: "custom:retroarch",
store: "custom",
title: "RetroArch",
art: noArt,
launch: null,
},
];
-49
View File
@@ -1,49 +0,0 @@
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
/** Map of API pathname (e.g. `/api/v1/host`) → JSON body to return for a GET. */
export type MockRoutes = Record<string, unknown>
/**
* Renders a data-backed page WITHOUT a running host by stubbing `window.fetch`
* for the lifetime of the story: matched pathnames return their mock JSON (200),
* everything else returns `{}` (200) so mutations + polling never error. The
* real orval/React-Query hooks run unchanged, so loading/success transitions and
* `refetchInterval` behave exactly as in the app. Each story gets a fresh,
* isolated QueryClient (retries off).
*/
export function MockApi({ routes, children }: { routes: MockRoutes; children: ReactNode }) {
// Read the latest routes inside the stub without re-installing it.
const routesRef = useRef(routes)
routesRef.current = routes
const [stubbed, setStubbed] = useState(false)
useEffect(() => {
const real = window.fetch
const stub = (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const path = new URL(url, window.location.origin).pathname
const data = path in routesRef.current ? routesRef.current[path] : {}
return Promise.resolve(
new Response(JSON.stringify(data ?? null), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
}
window.fetch = stub as typeof window.fetch
setStubbed(true)
return () => {
window.fetch = real
}
}, [])
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
)
// Hold the first render until the stub is installed, so the page's initial
// query resolves against the mock rather than racing a real (failing) request.
if (!stubbed) return null
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
+40 -16
View File
@@ -1,13 +1,13 @@
@import 'tailwindcss'; @import "tailwindcss";
@import 'tw-animate-css'; @import "tw-animate-css";
@import './timing-functions.css'; @import "./timing-functions.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* Pull @unom/ui's compiled component classes (bg-neutral, /* Pull @unom/ui's compiled component classes (bg-neutral,
rounded-card, p-padding-card, ring-accent, h-input-height, material…) into the rounded-card, p-padding-card, ring-accent, h-input-height, material…) into the
Tailwind 4 scan so their utilities aren't purged. */ Tailwind 4 scan so their utilities aren't purged. */
@source '../node_modules/@unom/ui/dist/**/*.{js,mjs}'; @source "../node_modules/@unom/ui/dist/**/*.{js,mjs}";
/* ── punktfunk brand · violet product chrome ──────────────────────────────── /* ── punktfunk brand · violet product chrome ────────────────────────────────
Two themes on one violet identity: LIGHT (lavender docs surface — the same Two themes on one violet identity: LIGHT (lavender docs surface — the same
@@ -66,12 +66,14 @@
--brand-light: var(--pf-brand-light); --brand-light: var(--pf-brand-light);
--highlight: var(--pf-highlight); --highlight: var(--pf-highlight);
--neutral: var(--card); /* @unom card default surface (bg-neutral) */ --neutral: var(--card); /* @unom card default surface (bg-neutral) */
--neutral-accent: var(--secondary); /* accent / nested surface (bg-neutral-accent) */ --neutral-accent: var(
--secondary
); /* accent / nested surface (bg-neutral-accent) */
--neutral-highlight: var(--border); --neutral-highlight: var(--border);
--error: var(--destructive); --error: var(--destructive);
--font-display: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; --font-display: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; --font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
/* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */ /* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */
--radius-card-min: var(--radius); --radius-card-min: var(--radius);
@@ -163,20 +165,40 @@
--animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart); --animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart);
@keyframes accordion-down { @keyframes accordion-down {
from { height: 0; } from {
to { height: var(--radix-accordion-content-height); } height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
} }
@keyframes accordion-up { @keyframes accordion-up {
from { height: var(--radix-accordion-content-height); } from {
to { height: 0; } height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
} }
@keyframes collapsible-down { @keyframes collapsible-down {
from { height: 0; opacity: 0; } from {
to { height: var(--radix-collapsible-content-height); opacity: 1; } height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
} }
@keyframes collapsible-up { @keyframes collapsible-up {
from { height: var(--radix-collapsible-content-height); opacity: 1; } from {
to { height: 0; opacity: 0; } height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
} }
} }
@@ -188,6 +210,8 @@
background-color: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
font-feature-settings: 'rlig' 1, 'calt' 1; font-feature-settings:
"rlig" 1,
"calt" 1;
} }
} }
+18 -18
View File
@@ -1,35 +1,35 @@
import { fileURLToPath } from 'node:url' import { fileURLToPath } from "node:url";
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
import viteReact from '@vitejs/plugin-react' import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from 'vite-tsconfig-paths' import viteTsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from '@tailwindcss/vite' import tailwindcss from "@tailwindcss/vite";
import { paraglideVitePlugin } from '@inlang/paraglide-js' import { paraglideVitePlugin } from "@inlang/paraglide-js";
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir // Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
// because the TanStack Nitro plugin doesn't auto-scan a server/ dir. // because the TanStack Nitro plugin doesn't auto-scan a server/ dir.
const serverDir = fileURLToPath(new URL('./server', import.meta.url)) const serverDir = fileURLToPath(new URL("./server", import.meta.url));
// The management API the console drives. The browser always talks same-origin (/api/...): // The management API the console drives. The browser always talks same-origin (/api/...):
// in `vite dev` the dev server proxies it (below); in the built Bun/Nitro server a Nitro // in `vite dev` the dev server proxies it (below); in the built Bun/Nitro server a Nitro
// route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL. // route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL.
const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? 'https://127.0.0.1:47990' const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
export default defineConfig({ export default defineConfig({
server: { server: {
proxy: { proxy: {
// `secure: false`: the host serves its own self-signed identity cert on loopback. // `secure: false`: the host serves its own self-signed identity cert on loopback.
'/api': { target: MGMT_URL, changeOrigin: true, secure: false }, "/api": { target: MGMT_URL, changeOrigin: true, secure: false },
}, },
}, },
plugins: [ plugins: [
viteTsConfigPaths({ projects: ['./tsconfig.json'] }), viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
tailwindcss(), tailwindcss(),
paraglideVitePlugin({ paraglideVitePlugin({
project: './project.inlang', project: "./project.inlang",
outdir: './src/paraglide', outdir: "./src/paraglide",
strategy: ['localStorage', 'preferredLanguage', 'baseLocale'], strategy: ["localStorage", "preferredLanguage", "baseLocale"],
}), }),
// Full SSR on the TanStack Start runtime (the management console's data queries run // Full SSR on the TanStack Start runtime (the management console's data queries run
// client-side after hydration — React Query doesn't fetch during SSR — so the server // client-side after hydration — React Query doesn't fetch during SSR — so the server
@@ -45,7 +45,7 @@ export default defineConfig({
// listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web // listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web
// .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with // .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with
// bun; only the runtime target changes. (dev `vite dev` is unaffected.) // bun; only the runtime target changes. (dev `vite dev` is unaffected.)
preset: 'node-server', preset: "node-server",
// BUNDLE every dependency into the server output (no externalized node_modules). Three wins: // BUNDLE every dependency into the server output (no externalized node_modules). Three wins:
// (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep // (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep
// tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) it makes the // tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) it makes the
@@ -54,7 +54,7 @@ export default defineConfig({
// bare external imports (`srvx`, `seroval`…) bun couldn't resolve at runtime — the reason we // bare external imports (`srvx`, `seroval`…) bun couldn't resolve at runtime — the reason we
// used to need node. node still runs the same self-contained output for the Linux .deb. // used to need node. node still runs the same self-contained output for the Linux .deb.
noExternals: true, noExternals: true,
compatibilityDate: '2026-06-10', compatibilityDate: "2026-06-10",
// Scan server/{middleware,routes} for the auth gate + the /api proxy. // Scan server/{middleware,routes} for the auth gate + the /api proxy.
scanDirs: [serverDir], scanDirs: [serverDir],
}), }),
@@ -62,4 +62,4 @@ export default defineConfig({
// that Start's dev mode requires (omitting it leaves the client JS unable to load). // that Start's dev mode requires (omitting it leaves the client JS unable to load).
viteReact(), viteReact(),
], ],
}) });
+10 -10
View File
@@ -1,21 +1,21 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import viteReact from '@vitejs/plugin-react' import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from 'vite-tsconfig-paths' import viteTsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from '@tailwindcss/vite' import tailwindcss from "@tailwindcss/vite";
import { paraglideVitePlugin } from '@inlang/paraglide-js' import { paraglideVitePlugin } from "@inlang/paraglide-js";
// Storybook builds the components in isolation — WITHOUT the TanStack Start / // Storybook builds the components in isolation — WITHOUT the TanStack Start /
// Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the // Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the
// React transform, and Paraglide. // React transform, and Paraglide.
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
viteTsConfigPaths({ projects: ['./tsconfig.json'] }), viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
tailwindcss(), tailwindcss(),
viteReact(), viteReact(),
paraglideVitePlugin({ paraglideVitePlugin({
project: './project.inlang', project: "./project.inlang",
outdir: './src/paraglide', outdir: "./src/paraglide",
strategy: ['localStorage', 'preferredLanguage', 'baseLocale'], strategy: ["localStorage", "preferredLanguage", "baseLocale"],
}), }),
], ],
}) });