From 803573b4ec2400d812a9b5f274bf1d7c55c2619c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 05:43:34 +0000 Subject: [PATCH] improve web ui --- web/.storybook/main.ts | 24 +- web/.storybook/preview.tsx | 116 ++++---- web/biome.json | 45 +++ web/bun.lock | 19 ++ web/components.json | 38 +-- web/messages/de.json | 214 +++++++------- web/messages/en.json | 214 +++++++------- web/orval.config.ts | 54 ++-- web/package.json | 103 +++---- web/server/middleware/auth.ts | 51 ++-- web/server/routes/_auth/login.post.ts | 38 ++- web/server/routes/_auth/logout.post.ts | 12 +- web/server/routes/api/[...].ts | 50 ++-- web/server/util/auth.ts | 84 +++--- web/src/api/fetcher.ts | 61 ++-- web/src/components/app-shell.tsx | 247 +++++++++------- web/src/components/brand-mark.tsx | 48 +-- web/src/components/logo.tsx | 20 +- web/src/components/query-state.tsx | 84 +++--- web/src/components/section.tsx | 40 +++ web/src/components/ui/badge.tsx | 45 +-- web/src/components/ui/button.tsx | 10 +- web/src/components/ui/card.tsx | 129 ++++---- web/src/components/ui/input.tsx | 2 +- web/src/components/ui/label.tsx | 2 +- web/src/components/ui/spinner.tsx | 145 ++++----- web/src/components/ui/table.tsx | 120 ++++---- web/src/components/wordmark.tsx | 36 +-- web/src/lib/i18n.ts | 22 +- web/src/lib/query.ts | 12 + web/src/lib/utils.ts | 6 +- web/src/router.tsx | 74 +++-- web/src/routes/__root.tsx | 85 +++--- web/src/routes/clients.tsx | 92 +----- web/src/routes/host.tsx | 117 +------- web/src/routes/index.tsx | 141 +-------- web/src/routes/library.tsx | 295 +------------------ web/src/routes/login.tsx | 93 +----- web/src/routes/pairing.tsx | 390 +----------------------- web/src/routes/settings.tsx | 59 +--- web/src/sections/Clients/index.tsx | 36 +++ web/src/sections/Clients/view.tsx | 80 +++++ web/src/sections/Dashboard/index.tsx | 28 ++ web/src/sections/Dashboard/view.tsx | 147 ++++++++++ web/src/sections/Host/index.tsx | 12 + web/src/sections/Host/view.tsx | 138 +++++++++ web/src/sections/Library/index.tsx | 37 +++ web/src/sections/Library/view.tsx | 311 ++++++++++++++++++++ web/src/sections/Login/index.tsx | 36 +++ web/src/sections/Login/view.tsx | 60 ++++ web/src/sections/Pairing/index.tsx | 391 +++++++++++++++++++++++++ web/src/sections/Settings/index.tsx | 55 ++++ web/src/stories/AppShell.stories.tsx | 104 ++++--- web/src/stories/Badge.stories.tsx | 52 ++-- web/src/stories/Brand.stories.tsx | 62 ++-- web/src/stories/Button.stories.tsx | 83 +++--- web/src/stories/Card.stories.tsx | 78 ++--- web/src/stories/Clients.stories.tsx | 34 +-- web/src/stories/Dashboard.stories.tsx | 39 ++- web/src/stories/Host.stories.tsx | 36 ++- web/src/stories/Inputs.stories.tsx | 50 ++-- web/src/stories/Library.stories.tsx | 40 ++- web/src/stories/Login.stories.tsx | 16 + web/src/stories/QueryState.stories.tsx | 46 +-- web/src/stories/Settings.stories.tsx | 18 +- web/src/stories/Spinner.stories.tsx | 46 +-- web/src/stories/lib/fixtures.ts | 173 ++++++----- web/src/stories/lib/mock-api.tsx | 49 ---- web/src/styles.css | 296 ++++++++++--------- web/src/timing-functions.css | 42 +-- web/tsconfig.json | 44 +-- web/vite.config.ts | 112 +++---- web/vite.storybook.config.ts | 32 +- 73 files changed, 3373 insertions(+), 2847 deletions(-) create mode 100644 web/biome.json create mode 100644 web/src/components/section.tsx create mode 100644 web/src/lib/query.ts create mode 100644 web/src/sections/Clients/index.tsx create mode 100644 web/src/sections/Clients/view.tsx create mode 100644 web/src/sections/Dashboard/index.tsx create mode 100644 web/src/sections/Dashboard/view.tsx create mode 100644 web/src/sections/Host/index.tsx create mode 100644 web/src/sections/Host/view.tsx create mode 100644 web/src/sections/Library/index.tsx create mode 100644 web/src/sections/Library/view.tsx create mode 100644 web/src/sections/Login/index.tsx create mode 100644 web/src/sections/Login/view.tsx create mode 100644 web/src/sections/Pairing/index.tsx create mode 100644 web/src/sections/Settings/index.tsx create mode 100644 web/src/stories/Login.stories.tsx delete mode 100644 web/src/stories/lib/mock-api.tsx diff --git a/web/.storybook/main.ts b/web/.storybook/main.ts index bc7e6e2..4377b2a 100644 --- a/web/.storybook/main.ts +++ b/web/.storybook/main.ts @@ -1,15 +1,15 @@ -import type { StorybookConfig } from '@storybook/react-vite' +import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx)'], - addons: [], - framework: { - name: '@storybook/react-vite', - options: { - // Use the slim, Start/Nitro-free Vite config (see vite.storybook.config.ts). - builder: { viteConfigPath: './vite.storybook.config.ts' }, - }, - }, -} + stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: [], + framework: { + name: "@storybook/react-vite", + options: { + // Use the slim, Start/Nitro-free Vite config (see vite.storybook.config.ts). + builder: { viteConfigPath: "./vite.storybook.config.ts" }, + }, + }, +}; -export default config +export default config; diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index df6cfac..9fa2e10 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -1,69 +1,69 @@ // Import the console's REAL stylesheet directly (rememed-style) — the @theme // 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 // here or every story falls back to system-ui and looks off. -import '@fontsource-variable/geist' -import { useEffect } from 'react' -import { definePreview } from '@storybook/react-vite' -import { MaterialProvider, defaultMaterialTheme } from '@unom/ui/material' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import "@fontsource-variable/geist"; +import { useEffect } from "react"; +import { definePreview } from "@storybook/react-vite"; +import { MaterialProvider, defaultMaterialTheme } from "@unom/ui/material"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // 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 // stray request fails fast instead of hanging the canvas. const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, -}) + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, +}); export default definePreview({ - addons: [], - // The live console pins dark; default the canvas to dark too, with a toolbar - // switch to preview the light theme while designing. - initialGlobals: { theme: 'dark' }, - globalTypes: { - theme: { - description: 'Light/dark color scheme', - toolbar: { - title: 'Theme', - icon: 'circlehollow', - items: [ - { value: 'dark', icon: 'moon', title: 'Dark' }, - { value: 'light', icon: 'sun', title: 'Light' }, - ], - dynamicTitle: true, - }, - }, - }, - decorators: [ - (Story, context) => { - const dark = (context.globals.theme as string) !== 'light' - // `layout: 'fullscreen'` stories (e.g. the AppShell) own their own padding; - // everything else gets a comfortable inset. - const fullscreen = context.parameters.layout === 'fullscreen' - // Mirror `.dark` onto so the body's token-driven background AND any - // portal-mounted content (radix dialogs, popovers) pick up the right - // palette — the console keys its whole token set off `html.dark`. - useEffect(() => { - document.documentElement.classList.toggle('dark', dark) - }, [dark]) - return ( - - -
-
- -
-
-
-
- ) - }, - ], - parameters: { - controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } }, - layout: 'padded', - }, -}) + addons: [], + // The live console pins dark; default the canvas to dark too, with a toolbar + // switch to preview the light theme while designing. + initialGlobals: { theme: "dark" }, + globalTypes: { + theme: { + description: "Light/dark color scheme", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "dark", icon: "moon", title: "Dark" }, + { value: "light", icon: "sun", title: "Light" }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story, context) => { + const dark = (context.globals.theme as string) !== "light"; + // `layout: 'fullscreen'` stories (e.g. the AppShell) own their own padding; + // everything else gets a comfortable inset. + const fullscreen = context.parameters.layout === "fullscreen"; + // Mirror `.dark` onto so the body's token-driven background AND any + // portal-mounted content (radix dialogs, popovers) pick up the right + // palette — the console keys its whole token set off `html.dark`. + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + }, [dark]); + return ( + + +
+
+ +
+
+
+
+ ); + }, + ], + parameters: { + controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } }, + layout: "padded", + }, +}); diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000..889ab27 --- /dev/null +++ b/web/biome.json @@ -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" + } + } +} \ No newline at end of file diff --git a/web/bun.lock b/web/bun.lock index 0ee3422..7f0789e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -22,6 +22,7 @@ "zod": "^4.4.3", }, "devDependencies": { + "@biomejs/biome": "^2.5.1", "@inlang/paraglide-js": "^2.0.0", "@storybook/react-vite": "^10.4.6", "@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=="], + "@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=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], diff --git a/web/components.json b/web/components.json index d327bf4..31e4e8a 100644 --- a/web/components.json +++ b/web/components.json @@ -1,21 +1,21 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/styles.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - } + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } } diff --git a/web/messages/de.json b/web/messages/de.json index 85ac3bc..9b48867 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -1,109 +1,109 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format", - "app_name": "punktfunk", - "app_tagline": "Verwaltungskonsole", - "nav_dashboard": "Übersicht", - "nav_host": "Host", - "nav_clients": "Gekoppelte Geräte", - "nav_pairing": "Kopplung", - "nav_library": "Bibliothek", - "nav_settings": "Einstellungen", - "status_title": "Live-Status", - "status_video": "Video", - "status_audio": "Audio", - "status_streaming": "Aktiv", - "status_idle": "Inaktiv", - "status_session": "Sitzung", - "status_no_session": "Keine aktive Sitzung", - "status_paired_count": "Gekoppelte Geräte", - "status_pin_pending": "Kopplungs-PIN ausstehend", - "stream_codec": "Codec", - "stream_resolution": "Auflösung", - "stream_fps": "Bildrate", - "stream_bitrate": "Bitrate", - "action_stop_session": "Sitzung beenden", - "action_request_idr": "Keyframe anfordern", - "action_unpair": "Entkoppeln", - "host_identity": "Identität", - "host_hostname": "Hostname", - "host_local_ip": "Lokale IP", - "host_version": "Version", - "host_abi": "ABI-Version", - "host_codecs": "Codecs", - "host_ports": "Ports", - "host_uniqueid": "Eindeutige ID", - "host_compositors": "Compositoren", - "host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.", - "compositor_available": "Verfügbar", - "compositor_unavailable": "Nicht verfügbar", - "compositor_default": "Standard", - "clients_title": "Gekoppelte Geräte", - "clients_empty": "Noch keine gekoppelten Geräte.", - "clients_name": "Name", - "clients_fingerprint": "Fingerabdruck", - "clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", - "pairing_title": "Kopplung", - "pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.", - "pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:", - "pairing_pin_label": "PIN", - "pairing_submit": "PIN bestätigen", - "pairing_success": "Erfolgreich gekoppelt.", - "pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.", - "pairing_native_title": "Gerät koppeln", - "pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner punktfunk-App ein, um dieses Gerät zu koppeln.", - "pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um punktfunk-Geräte zu koppeln.", - "pairing_native_arm": "Gerät koppeln", - "pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:", - "pairing_native_expires": "Läuft ab in", - "pairing_native_cancel": "Abbrechen", - "pairing_native_devices": "Gekoppelte Geräte", - "pairing_native_empty": "Noch keine Geräte gekoppelt.", - "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", - "pairing_pending_title": "Warten auf Freigabe", - "pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.", - "pairing_pending_approve": "Freigeben", - "pairing_pending_deny": "Ablehnen", - "pairing_pending_name_prompt": "Gerät benennen:", - "pairing_pending_age_just_now": "gerade eben", - "pairing_pending_age_secs": "vor {s}s", - "pairing_pending_age_mins": "vor {min} min", - "pairing_moonlight_title": "Moonlight-Kopplung (GameStream)", - "library_title": "Bibliothek", - "library_empty": "Noch keine Spiele gefunden.", - "library_store_steam": "Steam", - "library_store_custom": "Eigene", - "library_add_title": "Eigenes Spiel hinzufügen", - "library_edit_title": "Eigenes Spiel bearbeiten", - "library_add_button": "Eigenes Spiel hinzufügen", - "library_field_title": "Titel", - "library_field_portrait": "Portrait-Bild-URL", - "library_field_hero": "Hero-Bild-URL", - "library_field_header": "Header-Bild-URL", - "library_field_command": "Startbefehl", - "library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", - "library_save": "Speichern", - "library_create": "Hinzufügen", - "library_cancel": "Abbrechen", - "library_edit": "Bearbeiten", - "library_delete": "Löschen", - "library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.", - "settings_title": "Einstellungen", - "settings_token_label": "API-Token", - "settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.", - "settings_language": "Sprache", - "settings_save": "Speichern", - "settings_saved": "Gespeichert.", - "common_loading": "Wird geladen…", - "common_error": "Etwas ist schiefgelaufen.", - "common_retry": "Erneut versuchen", - "common_yes": "Ja", - "common_cancel": "Abbrechen", - "common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…", - "login_title": "Anmelden", - "login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.", - "login_password": "Passwort", - "login_submit": "Anmelden", - "login_error": "Falsches Passwort.", - "login_signing_in": "Anmeldung läuft…", - "action_logout": "Abmelden" + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "punktfunk", + "app_tagline": "Verwaltungskonsole", + "nav_dashboard": "Übersicht", + "nav_host": "Host", + "nav_clients": "Gekoppelte Geräte", + "nav_pairing": "Kopplung", + "nav_library": "Bibliothek", + "nav_settings": "Einstellungen", + "status_title": "Live-Status", + "status_video": "Video", + "status_audio": "Audio", + "status_streaming": "Aktiv", + "status_idle": "Inaktiv", + "status_session": "Sitzung", + "status_no_session": "Keine aktive Sitzung", + "status_paired_count": "Gekoppelte Geräte", + "status_pin_pending": "Kopplungs-PIN ausstehend", + "stream_codec": "Codec", + "stream_resolution": "Auflösung", + "stream_fps": "Bildrate", + "stream_bitrate": "Bitrate", + "action_stop_session": "Sitzung beenden", + "action_request_idr": "Keyframe anfordern", + "action_unpair": "Entkoppeln", + "host_identity": "Identität", + "host_hostname": "Hostname", + "host_local_ip": "Lokale IP", + "host_version": "Version", + "host_abi": "ABI-Version", + "host_codecs": "Codecs", + "host_ports": "Ports", + "host_uniqueid": "Eindeutige ID", + "host_compositors": "Compositoren", + "host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.", + "compositor_available": "Verfügbar", + "compositor_unavailable": "Nicht verfügbar", + "compositor_default": "Standard", + "clients_title": "Gekoppelte Geräte", + "clients_empty": "Noch keine gekoppelten Geräte.", + "clients_name": "Name", + "clients_fingerprint": "Fingerabdruck", + "clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", + "pairing_title": "Kopplung", + "pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.", + "pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:", + "pairing_pin_label": "PIN", + "pairing_submit": "PIN bestätigen", + "pairing_success": "Erfolgreich gekoppelt.", + "pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.", + "pairing_native_title": "Gerät koppeln", + "pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner punktfunk-App ein, um dieses Gerät zu koppeln.", + "pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um punktfunk-Geräte zu koppeln.", + "pairing_native_arm": "Gerät koppeln", + "pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:", + "pairing_native_expires": "Läuft ab in", + "pairing_native_cancel": "Abbrechen", + "pairing_native_devices": "Gekoppelte Geräte", + "pairing_native_empty": "Noch keine Geräte gekoppelt.", + "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", + "pairing_pending_title": "Warten auf Freigabe", + "pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.", + "pairing_pending_approve": "Freigeben", + "pairing_pending_deny": "Ablehnen", + "pairing_pending_name_prompt": "Gerät benennen:", + "pairing_pending_age_just_now": "gerade eben", + "pairing_pending_age_secs": "vor {s}s", + "pairing_pending_age_mins": "vor {min} min", + "pairing_moonlight_title": "Moonlight-Kopplung (GameStream)", + "library_title": "Bibliothek", + "library_empty": "Noch keine Spiele gefunden.", + "library_store_steam": "Steam", + "library_store_custom": "Eigene", + "library_add_title": "Eigenes Spiel hinzufügen", + "library_edit_title": "Eigenes Spiel bearbeiten", + "library_add_button": "Eigenes Spiel hinzufügen", + "library_field_title": "Titel", + "library_field_portrait": "Portrait-Bild-URL", + "library_field_hero": "Hero-Bild-URL", + "library_field_header": "Header-Bild-URL", + "library_field_command": "Startbefehl", + "library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", + "library_save": "Speichern", + "library_create": "Hinzufügen", + "library_cancel": "Abbrechen", + "library_edit": "Bearbeiten", + "library_delete": "Löschen", + "library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.", + "settings_title": "Einstellungen", + "settings_token_label": "API-Token", + "settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.", + "settings_language": "Sprache", + "settings_save": "Speichern", + "settings_saved": "Gespeichert.", + "common_loading": "Wird geladen…", + "common_error": "Etwas ist schiefgelaufen.", + "common_retry": "Erneut versuchen", + "common_yes": "Ja", + "common_cancel": "Abbrechen", + "common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…", + "login_title": "Anmelden", + "login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.", + "login_password": "Passwort", + "login_submit": "Anmelden", + "login_error": "Falsches Passwort.", + "login_signing_in": "Anmeldung läuft…", + "action_logout": "Abmelden" } diff --git a/web/messages/en.json b/web/messages/en.json index 40f5f32..1a84172 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1,109 +1,109 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format", - "app_name": "punktfunk", - "app_tagline": "management console", - "nav_dashboard": "Dashboard", - "nav_host": "Host", - "nav_clients": "Paired clients", - "nav_pairing": "Pairing", - "nav_library": "Library", - "nav_settings": "Settings", - "status_title": "Live status", - "status_video": "Video", - "status_audio": "Audio", - "status_streaming": "Streaming", - "status_idle": "Idle", - "status_session": "Session", - "status_no_session": "No active session", - "status_paired_count": "Paired clients", - "status_pin_pending": "Pairing PIN pending", - "stream_codec": "Codec", - "stream_resolution": "Resolution", - "stream_fps": "Frame rate", - "stream_bitrate": "Bitrate", - "action_stop_session": "Stop session", - "action_request_idr": "Request keyframe", - "action_unpair": "Unpair", - "host_identity": "Identity", - "host_hostname": "Hostname", - "host_local_ip": "Local IP", - "host_version": "Version", - "host_abi": "ABI version", - "host_codecs": "Codecs", - "host_ports": "Ports", - "host_uniqueid": "Unique ID", - "host_compositors": "Compositors", - "host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.", - "compositor_available": "Available", - "compositor_unavailable": "Unavailable", - "compositor_default": "Default", - "clients_title": "Paired clients", - "clients_empty": "No paired clients yet.", - "clients_name": "Name", - "clients_fingerprint": "Fingerprint", - "clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.", - "pairing_title": "Pairing", - "pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.", - "pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:", - "pairing_pin_label": "PIN", - "pairing_submit": "Submit PIN", - "pairing_success": "Paired successfully.", - "pairing_failed": "Pairing failed — check the PIN and try again.", - "pairing_native_title": "Pair a device", - "pairing_native_desc": "Show a one-time PIN here, then enter it in your punktfunk app to pair this device.", - "pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair punktfunk devices.", - "pairing_native_arm": "Pair a device", - "pairing_native_enter": "Enter this PIN on your device:", - "pairing_native_expires": "Expires in", - "pairing_native_cancel": "Cancel", - "pairing_native_devices": "Paired devices", - "pairing_native_empty": "No devices paired yet.", - "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.", - "pairing_pending_title": "Waiting for approval", - "pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.", - "pairing_pending_approve": "Approve", - "pairing_pending_deny": "Deny", - "pairing_pending_name_prompt": "Name this device:", - "pairing_pending_age_just_now": "just now", - "pairing_pending_age_secs": "{s}s ago", - "pairing_pending_age_mins": "{min} min ago", - "pairing_moonlight_title": "Moonlight (GameStream) pairing", - "library_title": "Library", - "library_empty": "No games found yet.", - "library_store_steam": "Steam", - "library_store_custom": "Custom", - "library_add_title": "Add a custom game", - "library_edit_title": "Edit custom game", - "library_add_button": "Add custom game", - "library_field_title": "Title", - "library_field_portrait": "Portrait art URL", - "library_field_hero": "Hero art URL", - "library_field_header": "Header art URL", - "library_field_command": "Launch command", - "library_field_command_help": "Optional. The command the host runs to launch this title.", - "library_save": "Save", - "library_create": "Add", - "library_cancel": "Cancel", - "library_edit": "Edit", - "library_delete": "Delete", - "library_delete_confirm": "Delete this custom game? This can't be undone.", - "settings_title": "Settings", - "settings_token_label": "API token", - "settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.", - "settings_language": "Language", - "settings_save": "Save", - "settings_saved": "Saved.", - "common_loading": "Loading…", - "common_error": "Something went wrong.", - "common_retry": "Retry", - "common_yes": "Yes", - "common_cancel": "Cancel", - "common_unauthorized": "Session expired — redirecting to sign in…", - "login_title": "Sign in", - "login_subtitle": "Enter the management password to continue.", - "login_password": "Password", - "login_submit": "Sign in", - "login_error": "Wrong password.", - "login_signing_in": "Signing in…", - "action_logout": "Sign out" + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "punktfunk", + "app_tagline": "management console", + "nav_dashboard": "Dashboard", + "nav_host": "Host", + "nav_clients": "Paired clients", + "nav_pairing": "Pairing", + "nav_library": "Library", + "nav_settings": "Settings", + "status_title": "Live status", + "status_video": "Video", + "status_audio": "Audio", + "status_streaming": "Streaming", + "status_idle": "Idle", + "status_session": "Session", + "status_no_session": "No active session", + "status_paired_count": "Paired clients", + "status_pin_pending": "Pairing PIN pending", + "stream_codec": "Codec", + "stream_resolution": "Resolution", + "stream_fps": "Frame rate", + "stream_bitrate": "Bitrate", + "action_stop_session": "Stop session", + "action_request_idr": "Request keyframe", + "action_unpair": "Unpair", + "host_identity": "Identity", + "host_hostname": "Hostname", + "host_local_ip": "Local IP", + "host_version": "Version", + "host_abi": "ABI version", + "host_codecs": "Codecs", + "host_ports": "Ports", + "host_uniqueid": "Unique ID", + "host_compositors": "Compositors", + "host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.", + "compositor_available": "Available", + "compositor_unavailable": "Unavailable", + "compositor_default": "Default", + "clients_title": "Paired clients", + "clients_empty": "No paired clients yet.", + "clients_name": "Name", + "clients_fingerprint": "Fingerprint", + "clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.", + "pairing_title": "Pairing", + "pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.", + "pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:", + "pairing_pin_label": "PIN", + "pairing_submit": "Submit PIN", + "pairing_success": "Paired successfully.", + "pairing_failed": "Pairing failed — check the PIN and try again.", + "pairing_native_title": "Pair a device", + "pairing_native_desc": "Show a one-time PIN here, then enter it in your punktfunk app to pair this device.", + "pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair punktfunk devices.", + "pairing_native_arm": "Pair a device", + "pairing_native_enter": "Enter this PIN on your device:", + "pairing_native_expires": "Expires in", + "pairing_native_cancel": "Cancel", + "pairing_native_devices": "Paired devices", + "pairing_native_empty": "No devices paired yet.", + "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.", + "pairing_pending_title": "Waiting for approval", + "pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.", + "pairing_pending_approve": "Approve", + "pairing_pending_deny": "Deny", + "pairing_pending_name_prompt": "Name this device:", + "pairing_pending_age_just_now": "just now", + "pairing_pending_age_secs": "{s}s ago", + "pairing_pending_age_mins": "{min} min ago", + "pairing_moonlight_title": "Moonlight (GameStream) pairing", + "library_title": "Library", + "library_empty": "No games found yet.", + "library_store_steam": "Steam", + "library_store_custom": "Custom", + "library_add_title": "Add a custom game", + "library_edit_title": "Edit custom game", + "library_add_button": "Add custom game", + "library_field_title": "Title", + "library_field_portrait": "Portrait art URL", + "library_field_hero": "Hero art URL", + "library_field_header": "Header art URL", + "library_field_command": "Launch command", + "library_field_command_help": "Optional. The command the host runs to launch this title.", + "library_save": "Save", + "library_create": "Add", + "library_cancel": "Cancel", + "library_edit": "Edit", + "library_delete": "Delete", + "library_delete_confirm": "Delete this custom game? This can't be undone.", + "settings_title": "Settings", + "settings_token_label": "API token", + "settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.", + "settings_language": "Language", + "settings_save": "Save", + "settings_saved": "Saved.", + "common_loading": "Loading…", + "common_error": "Something went wrong.", + "common_retry": "Retry", + "common_yes": "Yes", + "common_cancel": "Cancel", + "common_unauthorized": "Session expired — redirecting to sign in…", + "login_title": "Sign in", + "login_subtitle": "Enter the management password to continue.", + "login_password": "Password", + "login_submit": "Sign in", + "login_error": "Wrong password.", + "login_signing_in": "Signing in…", + "action_logout": "Sign out" } diff --git a/web/orval.config.ts b/web/orval.config.ts index 40a73ee..e076fb6 100644 --- a/web/orval.config.ts +++ b/web/orval.config.ts @@ -1,32 +1,32 @@ -import { defineConfig } from 'orval' +import { defineConfig } from "orval"; // 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 // docs/api/openapi.json via `cargo run -p punktfunk-host -- openapi`). export default defineConfig({ - punktfunk: { - input: { - target: '../docs/api/openapi.json', - }, - output: { - mode: 'tags-split', - target: './src/api/gen', - schemas: './src/api/gen/model', - client: 'react-query', - clean: true, - override: { - mutator: { - path: './src/api/fetcher.ts', - name: 'apiFetch', - }, - // 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. - fetch: { - includeHttpResponseReturnType: false, - }, - // No global query/mutation override: orval picks `useQuery` for GET and - // `useMutation` for POST/DELETE by HTTP method, which is what the pages expect. - }, - }, - }, -}) + punktfunk: { + input: { + target: "../docs/api/openapi.json", + }, + output: { + mode: "tags-split", + target: "./src/api/gen", + schemas: "./src/api/gen/model", + client: "react-query", + clean: true, + override: { + mutator: { + path: "./src/api/fetcher.ts", + name: "apiFetch", + }, + // 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. + fetch: { + includeHttpResponseReturnType: false, + }, + // No global query/mutation override: orval picks `useQuery` for GET and + // `useMutation` for POST/DELETE by HTTP method, which is what the pages expect. + }, + }, + }, +}); diff --git a/web/package.json b/web/package.json index 5a47abd..faf08b5 100644 --- a/web/package.json +++ b/web/package.json @@ -1,53 +1,54 @@ { - "name": "punktfunk-web", - "private": true, - "type": "module", - "description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n", - "scripts": { - "prepare": "bun run codegen", - "codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide", - "predev": "orval --config orval.config.ts", - "dev": "vite dev --port 3000", - "prebuild": "orval --config orval.config.ts", - "build": "vite build", - "start": "bun run .output/server/index.mjs", - "api:gen": "orval --config orval.config.ts", - "lint": "tsc --noEmit", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" - }, - "dependencies": { - "@fontsource-variable/geist": "^5.2.9", - "@tanstack/react-query": "^5.62.0", - "@tanstack/react-router": "^1.121.0", - "@tanstack/react-start": "^1.121.0", - "@unom/style": "^0.4.4", - "@unom/ui": "^0.8.16", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^0.469.0", - "motion": "^12.40.0", - "radix-ui": "^1.6.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "tailwind-merge": "^2.6.0", - "zod": "^4.4.3" - }, - "devDependencies": { - "@inlang/paraglide-js": "^2.0.0", - "@storybook/react-vite": "^10.4.6", - "@tailwindcss/vite": "^4.0.0", - "@tanstack/nitro-v2-vite-plugin": "^1.155.0", - "@types/node": "^22.10.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^5", - "orval": "^8.16.0", - "storybook": "^10.4.6", - "tailwindcss": "^4.0.0", - "tw-animate-css": "^1.2.0", - "typescript": "^5.7.0", - "vite": "^7.3.5", - "vite-tsconfig-paths": "^5.1.0" - } + "name": "punktfunk-web", + "private": true, + "type": "module", + "description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n", + "scripts": { + "prepare": "bun run codegen", + "codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide", + "predev": "orval --config orval.config.ts", + "dev": "vite dev --port 3000", + "prebuild": "orval --config orval.config.ts", + "build": "vite build", + "start": "bun run .output/server/index.mjs", + "api:gen": "orval --config orval.config.ts", + "lint": "tsc --noEmit", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-router": "^1.121.0", + "@tanstack/react-start": "^1.121.0", + "@unom/style": "^0.4.4", + "@unom/ui": "^0.8.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "motion": "^12.40.0", + "radix-ui": "^1.6.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.6.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.1", + "@inlang/paraglide-js": "^2.0.0", + "@storybook/react-vite": "^10.4.6", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/nitro-v2-vite-plugin": "^1.155.0", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5", + "orval": "^8.16.0", + "storybook": "^10.4.6", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.2.0", + "typescript": "^5.7.0", + "vite": "^7.3.5", + "vite-tsconfig-paths": "^5.1.0" + } } diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts index c6c2690..9f2e532 100644 --- a/web/server/middleware/auth.ts +++ b/web/server/middleware/auth.ts @@ -2,26 +2,41 @@ // (pages, the /api proxy, everything) before routing. Unauthenticated requests are // 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. -import { defineEventHandler, getRequestURL, sendRedirect, setResponseStatus, useSession } from 'h3' -import { isPublicPath, sessionConfig, uiPassword, type SessionData } from '../util/auth' +import { + defineEventHandler, + getRequestURL, + sendRedirect, + setResponseStatus, + useSession, +} from "h3"; +import { + isPublicPath, + sessionConfig, + uiPassword, + type SessionData, +} from "../util/auth"; export default defineEventHandler(async (event) => { - const { pathname } = getRequestURL(event) - if (isPublicPath(pathname)) return + const { pathname } = getRequestURL(event); + if (isPublicPath(pathname)) return; - // Misconfigured: refuse everything rather than serve open on the LAN. - if (!uiPassword()) { - setResponseStatus(event, 503) - return { error: 'auth not configured: set PUNKTFUNK_UI_PASSWORD' } - } + // Misconfigured: refuse everything rather than serve open on the LAN. + if (!uiPassword()) { + setResponseStatus(event, 503); + return { error: "auth not configured: set PUNKTFUNK_UI_PASSWORD" }; + } - const session = await useSession(event, sessionConfig()) - if (session.data.authenticated) return // authenticated — let it through + const session = await useSession(event, sessionConfig()); + if (session.data.authenticated) return; // authenticated — let it through - if (pathname.startsWith('/api')) { - setResponseStatus(event, 401) - return { error: 'unauthorized' } - } - // Page navigation → bounce to the login screen, remembering where they were headed. - return sendRedirect(event, `/login?next=${encodeURIComponent(pathname)}`, 302) -}) + if (pathname.startsWith("/api")) { + setResponseStatus(event, 401); + return { error: "unauthorized" }; + } + // Page navigation → bounce to the login screen, remembering where they were headed. + return sendRedirect( + event, + `/login?next=${encodeURIComponent(pathname)}`, + 302, + ); +}); diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts index 0767345..c146ed1 100644 --- a/web/server/routes/_auth/login.post.ts +++ b/web/server/routes/_auth/login.post.ts @@ -1,20 +1,28 @@ // POST /_auth/login {password} — verify the shared password (constant-time), then seal an // authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user // can actually log in. -import { defineEventHandler, readBody, createError, useSession } from 'h3' -import { sessionConfig, timingSafeEqual, uiPassword, type SessionData } from '../../util/auth' +import { defineEventHandler, readBody, createError, useSession } from "h3"; +import { + sessionConfig, + timingSafeEqual, + uiPassword, + type SessionData, +} from "../../util/auth"; export default defineEventHandler(async (event) => { - const expected = uiPassword() - if (!expected) { - throw createError({ statusCode: 503, statusMessage: 'auth not configured' }) - } - const body = await readBody<{ password?: string }>(event) - const password = String(body?.password ?? '') - if (!timingSafeEqual(password, expected)) { - throw createError({ statusCode: 401, statusMessage: 'invalid password' }) - } - const session = await useSession(event, sessionConfig()) - await session.update({ authenticated: true }) - return { ok: true } -}) + const expected = uiPassword(); + if (!expected) { + throw createError({ + statusCode: 503, + statusMessage: "auth not configured", + }); + } + const body = await readBody<{ password?: string }>(event); + const password = String(body?.password ?? ""); + if (!timingSafeEqual(password, expected)) { + throw createError({ statusCode: 401, statusMessage: "invalid password" }); + } + const session = await useSession(event, sessionConfig()); + await session.update({ authenticated: true }); + return { ok: true }; +}); diff --git a/web/server/routes/_auth/logout.post.ts b/web/server/routes/_auth/logout.post.ts index 42cbb7c..dc2ddcf 100644 --- a/web/server/routes/_auth/logout.post.ts +++ b/web/server/routes/_auth/logout.post.ts @@ -1,9 +1,9 @@ // POST /_auth/logout — clear the session cookie. -import { defineEventHandler, useSession } from 'h3' -import { sessionConfig, type SessionData } from '../../util/auth' +import { defineEventHandler, useSession } from "h3"; +import { sessionConfig, type SessionData } from "../../util/auth"; export default defineEventHandler(async (event) => { - const session = await useSession(event, sessionConfig()) - await session.clear() - return { ok: true } -}) + const session = await useSession(event, sessionConfig()); + await session.clear(); + return { ok: true }; +}); diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts index 22d66bd..0f4c7f4 100644 --- a/web/server/routes/api/[...].ts +++ b/web/server/routes/api/[...].ts @@ -3,26 +3,34 @@ // (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 // ONLY path to it from the LAN, and it's authenticated. -import { defineEventHandler, getRequestURL, proxyRequest, setResponseStatus } from 'h3' -import { mgmtToken, mgmtUrl } from '../../util/auth' +import { + defineEventHandler, + getRequestURL, + proxyRequest, + setResponseStatus, +} from "h3"; +import { mgmtToken, mgmtUrl } from "../../util/auth"; export default defineEventHandler((event) => { - const { pathname, search } = getRequestURL(event) - const target = `${mgmtUrl()}${pathname}${search}` - const token = mgmtToken() - // 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 - // host's ~/.config/punktfunk/mgmt-token, so this only fires on a misconfigured/early-start deploy). - if (!token) { - setResponseStatus(event, 503) - return { error: 'management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)' } - } - return proxyRequest(event, target, { - headers: { - // Overwrite, not append: the host-held token replaces anything the browser sent. - authorization: `Bearer ${token}`, - // Don't forward the session cookie to the management API. - cookie: '', - }, - }) -}) + const { pathname, search } = getRequestURL(event); + const target = `${mgmtUrl()}${pathname}${search}`; + const token = mgmtToken(); + // 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 + // host's ~/.config/punktfunk/mgmt-token, so this only fires on a misconfigured/early-start deploy). + if (!token) { + setResponseStatus(event, 503); + return { + error: + "management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)", + }; + } + return proxyRequest(event, target, { + headers: { + // Overwrite, not append: the host-held token replaces anything the browser sent. + authorization: `Bearer ${token}`, + // Don't forward the session cookie to the management API. + cookie: "", + }, + }); +}); diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index f28ab7b..dc5d32c 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -4,26 +4,29 @@ // // The management token never reaches the browser: server/routes/api/[...].ts injects it // server-side when proxying to the loopback management API. -import { createHash, timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto' -import type { SessionConfig } from 'h3' +import { + 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). */ 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 * 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. */ 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. */ export function mgmtToken(): string { - return process.env.PUNKTFUNK_MGMT_TOKEN ?? '' + return process.env.PUNKTFUNK_MGMT_TOKEN ?? ""; } /** @@ -32,34 +35,37 @@ export function mgmtToken(): string { * (changing the password then invalidates existing sessions, which is fine). */ export function sessionConfig(): SessionConfig { - const secret = process.env.PUNKTFUNK_UI_SECRET - const password = secret && secret.length >= 32 - ? secret - : createHash('sha256').update(`punktfunk-session-v1:${uiPassword()}`).digest('hex') - return { - name: SESSION_NAME, - password, - // Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron - // seal TTL). 7 days for a single-user console. - maxAge: 60 * 60 * 24 * 7, - cookie: { - httpOnly: true, - sameSite: 'lax', - path: '/', - // 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 - // (set PUNKTFUNK_UI_SECURE=1 / =true then). - secure: /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ''), - }, - } + const secret = process.env.PUNKTFUNK_UI_SECRET; + const password = + secret && secret.length >= 32 + ? secret + : createHash("sha256") + .update(`punktfunk-session-v1:${uiPassword()}`) + .digest("hex"); + return { + name: SESSION_NAME, + password, + // Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron + // seal TTL). 7 days for a single-user console. + maxAge: 60 * 60 * 24 * 7, + cookie: { + httpOnly: true, + sameSite: "lax", + path: "/", + // 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 + // (set PUNKTFUNK_UI_SECURE=1 / =true then). + secure: /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""), + }, + }; } /** Constant-time string comparison (avoids leaking the password via timing). */ export function timingSafeEqual(a: string, b: string): boolean { - const ab = Buffer.from(a) - const bb = Buffer.from(b) - if (ab.length !== bb.length) return false - return nodeTimingSafeEqual(ab, bb) + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return nodeTimingSafeEqual(ab, bb); } /** 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 * `.json`/`.png` management route) through the proxy unauthenticated. */ export function isPublicPath(pathname: string): boolean { - if (pathname === '/api' || pathname.startsWith('/api/')) return false // always gated - if (pathname === '/login') return true - if (pathname.startsWith('/_auth/')) return true - if (pathname.startsWith('/assets/')) return true - if (pathname === '/favicon.ico' || pathname === '/robots.txt') return true - return false + if (pathname === "/api" || pathname.startsWith("/api/")) return false; // always gated + if (pathname === "/login") return true; + if (pathname.startsWith("/_auth/")) return true; + if (pathname.startsWith("/assets/")) return true; + if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true; + return false; } /** Validate a post-login redirect target: a same-origin path only. Rejects protocol- * relative (`//evil.com`) and absolute URLs to prevent an open redirect. */ export function safeNextPath(next: string | undefined): string { - if (!next || !next.startsWith('/') || next.startsWith('//')) return '/' - return next + if (!next || !next.startsWith("/") || next.startsWith("//")) return "/"; + return next; } export interface SessionData { - authenticated?: boolean + authenticated?: boolean; } diff --git a/web/src/api/fetcher.ts b/web/src/api/fetcher.ts index 2e223e0..a53bcf5 100644 --- a/web/src/api/fetcher.ts +++ b/web/src/api/fetcher.ts @@ -9,43 +9,50 @@ /** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */ export class ApiError extends Error { - status: number - data: unknown - constructor(status: number, data: unknown, message?: string) { - super(message ?? `API error ${status}`) - this.name = 'ApiError' - this.status = status - this.data = data - } + status: number; + data: unknown; + constructor(status: number, data: unknown, message?: string) { + super(message ?? `API error ${status}`); + this.name = "ApiError"; + this.status = status; + this.data = data; + } } -export async function apiFetch(url: string, options?: RequestInit): Promise { - const headers = new Headers(options?.headers) - headers.set('Accept', 'application/json') +export async function apiFetch( + url: string, + options?: RequestInit, +): Promise { + 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 body = text ? safeJson(text) : undefined - if (res.status === 401) redirectToLogin() - if (!res.ok) throw new ApiError(res.status, body, res.statusText) - return body as T + const text = await res.text(); + const body = text ? safeJson(text) : undefined; + if (res.status === 401) redirectToLogin(); + if (!res.ok) throw new ApiError(res.status, body, res.statusText); + return body as T; } /** On lost session, send the user to the login screen, remembering where they were. */ function redirectToLogin(): void { - if (typeof window === 'undefined') return - if (window.location.pathname === '/login') return - const next = encodeURIComponent(window.location.pathname) - window.location.href = `/login?next=${next}` + if (typeof window === "undefined") return; + if (window.location.pathname === "/login") return; + const next = encodeURIComponent(window.location.pathname); + window.location.href = `/login?next=${next}`; } function safeJson(text: string): unknown { - try { - return JSON.parse(text) - } catch { - return text - } + try { + return JSON.parse(text); + } catch { + return text; + } } -export default apiFetch +export default apiFetch; diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 68b5c60..05c2841 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -1,115 +1,156 @@ -import type { ReactNode } from 'react' -import { Link } from '@tanstack/react-router' -import { Activity, Server, Users, KeyRound, LibraryBig, Settings } from 'lucide-react' -import { BrandMark } from '@/components/brand-mark' -import { Wordmark } from '@/components/wordmark' -import { m } from '@/paraglide/messages' -import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n' -import { cn } from '@/lib/utils' +import { Link } from "@tanstack/react-router"; +import { + Activity, + KeyRound, + LibraryBig, + Server, + Settings, + 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 = [ - { to: '/', icon: Activity, label: () => m.nav_dashboard() }, - { to: '/host', icon: Server, label: () => m.nav_host() }, - { to: '/library', icon: LibraryBig, label: () => m.nav_library() }, - { to: '/clients', icon: Users, label: () => m.nav_clients() }, - { to: '/pairing', icon: KeyRound, label: () => m.nav_pairing() }, - { to: '/settings', icon: Settings, label: () => m.nav_settings() }, -] as const + { to: "/", icon: Activity, label: () => m.nav_dashboard() }, + { to: "/host", icon: Server, label: () => m.nav_host() }, + { to: "/library", icon: LibraryBig, label: () => m.nav_library() }, + { to: "/clients", icon: Users, label: () => m.nav_clients() }, + { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, + { to: "/settings", icon: Settings, label: () => m.nav_settings() }, +] as const; + +// Staggered entrance for the sidebar nav: each item fans in from the left a beat +// after the previous. Per-item delays (rather than a parent stagger) keep every +// item independent, so none can be left mid-orchestration / invisible. +const NAV_ENTER_DELAY = 0.08; +const NAV_ENTER_STEP = 0.06; export function AppShell({ children }: { children: ReactNode }) { - // Read the locale so the whole shell re-renders on a language switch. - useLocale() - return ( -
- {/* Desktop sidebar (≥ sm). */} - + // Read the locale so the whole shell re-renders on a language switch. + useLocale(); + return ( +
+ {/* Desktop sidebar (≥ sm). */} + -
- {/* Mobile top bar (< sm): brand + language. The sidebar is hidden here. */} -
- - -
- -
-
+
+ {/* Mobile top bar (< sm): brand + language. The sidebar is hidden here. */} +
+ + +
+ +
+
-
- {/* pb-24 leaves room for the fixed bottom nav on mobile. */} -
{children}
-
-
+
+ {/* pb-24 leaves room for the fixed bottom nav on mobile. */} +
+ {children} +
+
+
- {/* Mobile bottom tab bar (< sm): the primary navigation on phones. */} -
- ) + + {label()} + + + ))} + +
+ ); } function LanguageSwitcher() { - const current = useLocale() - return ( -
- {locales.map((l: Locale) => ( - - ))} -
- ) + const current = useLocale(); + return ( +
+ {locales.map((l: Locale) => ( + + ))} +
+ ); } diff --git a/web/src/components/brand-mark.tsx b/web/src/components/brand-mark.tsx index 7244969..0627b8e 100644 --- a/web/src/components/brand-mark.tsx +++ b/web/src/components/brand-mark.tsx @@ -3,29 +3,29 @@ // verbatim with the marketing site + docs). Back-to-front: large light-violet // circle, deep-violet circle, light highlight where they overlap. export function BrandMark({ className }: { className?: string }) { - return ( - - punktfunk - - - - - ) + return ( + + punktfunk + + + + + ); } -export default BrandMark +export default BrandMark; diff --git a/web/src/components/logo.tsx b/web/src/components/logo.tsx index d72fca7..41b5158 100644 --- a/web/src/components/logo.tsx +++ b/web/src/components/logo.tsx @@ -1,17 +1,17 @@ -import { cn } from '@/lib/utils' -import { BrandMark } from './brand-mark' -import { Wordmark } from './wordmark' +import { cn } from "@/lib/utils"; +import { BrandMark } from "./brand-mark"; +import { Wordmark } from "./wordmark"; // 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`); // the mark scales as a fraction of that width. export function Logo({ className }: { className?: string }) { - return ( -
- - -
- ) + return ( +
+ + +
+ ); } -export default Logo +export default Logo; diff --git a/web/src/components/query-state.tsx b/web/src/components/query-state.tsx index 7c5168d..bc9322d 100644 --- a/web/src/components/query-state.tsx +++ b/web/src/components/query-state.tsx @@ -1,43 +1,53 @@ -import type { ReactNode } from 'react' -import { ApiError } from '@/api/fetcher' -import { Spinner } from '@/components/ui/spinner' -import { Button } from '@/components/ui/button' -import { m } from '@/paraglide/messages' +import type { ReactNode } from "react"; +import { ApiError } from "@/api/fetcher"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { m } from "@/paraglide/messages"; interface QueryStateProps { - isLoading: boolean - error: unknown - refetch?: () => void - children: ReactNode + isLoading: boolean; + error: unknown; + refetch?: () => void; + children: ReactNode; } /** Uniform loading/error wrapper for a query-backed view. */ -export function QueryState({ isLoading, error, refetch, children }: QueryStateProps) { - if (isLoading) { - return ( -
- - {m.common_loading()} -
- ) - } - if (error) { - const unauthorized = error instanceof ApiError && error.status === 401 - return ( -
-

- {unauthorized ? m.common_unauthorized() : m.common_error()} -

- {refetch && !unauthorized && ( - - )} -
- ) - } - return <>{children} +export function QueryState({ + isLoading, + error, + refetch, + children, +}: QueryStateProps) { + if (isLoading) { + return ( +
+ + {m.common_loading()} +
+ ); + } + if (error) { + const unauthorized = error instanceof ApiError && error.status === 401; + return ( +
+

+ {unauthorized ? m.common_unauthorized() : m.common_error()} +

+ {refetch && !unauthorized && ( + + )} +
+ ); + } + return <>{children}; } diff --git a/web/src/components/section.tsx b/web/src/components/section.tsx new file mode 100644 index 0000000..7741a2a --- /dev/null +++ b/web/src/components/section.tsx @@ -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 ( +
+ {Children.map(children, (child, i) => + reduce ? ( + child + ) : ( + + {child} + + ), + )} +
+ ); +} diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 2cb0717..4bf9f77 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -1,29 +1,32 @@ -import * as React from 'react' -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '@/lib/utils' +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; const badgeVariants = cva( - 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none', - { - variants: { - variant: { - default: 'border-transparent bg-primary text-primary-foreground', - secondary: 'border-transparent bg-secondary text-secondary-foreground', - destructive: 'border-transparent bg-destructive text-destructive-foreground', - success: 'border-transparent bg-[var(--success)] text-white', - outline: 'text-foreground', - }, - }, - defaultVariants: { variant: 'default' }, - }, -) + "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + destructive: + "border-transparent bg-destructive text-destructive-foreground", + success: "border-transparent bg-[var(--success)] text-white", + outline: "text-foreground", + }, + }, + defaultVariants: { variant: "default" }, + }, +); export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, + VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { - return
+ return ( +
+ ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index fc54aec..a1bfb11 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -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 // material gloss + UI click/hover sounds (enabled via UnomProviders), driven by // the shared brand tokens. Same variant/size vocabulary the routes already use // (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon). -export type ButtonProps = ComponentProps +export type ButtonProps = ComponentProps; -export const Button = AnimatedButton +export const Button = AnimatedButton; -export { buttonVariants } +export { buttonVariants }; diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx index 4634cbe..0a8c44e 100644 --- a/web/src/components/ui/card.tsx +++ b/web/src/components/ui/card.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' -import type { ComponentProps } from 'react' -import { AnimatedCard } from '@unom/ui/card' -import { cn } from '@/lib/utils' +import { AnimatedCard } from "@unom/ui/card"; +import type { ComponentProps } from "react"; +import * as React from "react"; +import { cn } from "@/lib/utils"; // 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 @@ -9,56 +9,85 @@ import { cn } from '@/lib/utils' // API (CardHeader/Title/Description/Content/Footer own their own padding), so // the card defaults to `padding={false}` to avoid doubling it, and soften the // 2px ring to a subtle 1px brand tint. -type CardProps = ComponentProps +type CardProps = ComponentProps; -const Card = ({ className, padding = false, children, ...props }: CardProps) => ( - - {children} - -) -Card.displayName = 'Card' +const Card = ({ + className, + padding = false, + children, + ...props +}: CardProps) => ( + + {children} + +); +Card.displayName = "Card"; -const CardHeader = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -) -CardHeader.displayName = 'CardHeader' +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; -const CardTitle = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -) -CardTitle.displayName = 'CardTitle' +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = "CardTitle"; -const CardDescription = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -) -CardDescription.displayName = 'CardDescription' +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = "CardDescription"; -const CardContent = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -) -CardContent.displayName = 'CardContent' +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; -const CardFooter = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -) -CardFooter.displayName = 'CardFooter' +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +export { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +}; diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx index 70a0d0a..41a8422 100644 --- a/web/src/components/ui/input.tsx +++ b/web/src/components/ui/input.tsx @@ -1,3 +1,3 @@ // The console's Input IS @unom/ui's form input (shadcn-compatible tokens: // 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"; diff --git a/web/src/components/ui/label.tsx b/web/src/components/ui/label.tsx index 0b1853c..f2b3781 100644 --- a/web/src/components/ui/label.tsx +++ b/web/src/components/ui/label.tsx @@ -1,2 +1,2 @@ // 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"; diff --git a/web/src/components/ui/spinner.tsx b/web/src/components/ui/spinner.tsx index e98aef8..290cdee 100644 --- a/web/src/components/ui/spinner.tsx +++ b/web/src/components/ui/spinner.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef } from 'react' -import { motion, useReducedMotion, useTime, useTransform } from 'motion/react' -import { cn } from '@/lib/utils' +import { motion, useReducedMotion, useTime, useTransform } from "motion/react"; +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; // 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 @@ -13,76 +13,85 @@ import { cn } from '@/lib/utils' // both the scaling and the front/back swap. Honours prefers-reduced-motion. // Size via className (e.g. `size-8`); geometry derives from the box. -const DURATION_MS = 1600 -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 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 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 REST = 0 // reduced-motion: park flat (widest lens, no depth) = the brand mark +const DURATION_MS = 1600; +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 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 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 REST = 0; // reduced-motion: park flat (widest lens, no depth) = the brand mark -export function Spinner({ className, ...props }: React.HTMLAttributes) { - const reduce = useReducedMotion() - const ref = useRef(null) - const sizeRef = useRef(0) - const time = useTime() +export function Spinner({ + className, + ...props +}: React.HTMLAttributes) { + const reduce = useReducedMotion(); + const ref = useRef(null); + const sizeRef = useRef(0); + const time = useTime(); - useEffect(() => { - const el = ref.current - if (!el) return - sizeRef.current = el.clientWidth - const ro = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect.width - if (w) sizeRef.current = w - }) - ro.observe(el) - return () => ro.disconnect() - }, []) + useEffect(() => { + const el = ref.current; + if (!el) return; + sizeRef.current = el.clientWidth; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w) sizeRef.current = w; + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); - const angleAt = (t: number) => (reduce ? REST : (t / DURATION_MS) * Math.PI * 2) - const depthAt = (t: number, side: number) => side * Math.sin(angleAt(t)) * R_DEPTH + const angleAt = (t: number) => + 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 s = sizeRef.current - const angle = angleAt(t) - const z = side * Math.sin(angle) * R_DEPTH // world depth (toward viewer = +) - const p = PERSP / (PERSP - z) // perspective: nearer → bigger, farther → smaller - const mag = (R_PLANE_FIXED + R_PLANE_SWAY * Math.cos(angle)) * side - const x = mag * DIAG[0] * p * s - const y = mag * DIAG[1] * p * s - return `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${p})` - } + const transformAt = (t: number, side: number) => { + const s = sizeRef.current; + const angle = angleAt(t); + const z = side * Math.sin(angle) * R_DEPTH; // world depth (toward viewer = +) + const p = PERSP / (PERSP - z); // perspective: nearer → bigger, farther → smaller + const mag = (R_PLANE_FIXED + R_PLANE_SWAY * Math.cos(angle)) * side; + const x = mag * DIAG[0] * p * s; + const y = mag * DIAG[1] * p * s; + return `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${p})`; + }; - const tLight = 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. - const zLight = useTransform(time, (t) => Math.round(depthAt(t, 1) * 1000)) - const zDeep = useTransform(time, (t) => Math.round(depthAt(t, -1) * 1000)) + const tLight = 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. + const zLight = 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 => ({ - width: `${LOBE_FRAC * 100}%`, - height: `${LOBE_FRAC * 100}%`, - backgroundColor: color, - mixBlendMode: 'screen', - }) + const lobe = (color: string): React.CSSProperties => ({ + width: `${LOBE_FRAC * 100}%`, + height: `${LOBE_FRAC * 100}%`, + backgroundColor: color, + mixBlendMode: "screen", + }); - return ( -
- - -
- ) + return ( +
+ + +
+ ); } diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx index feeae7c..5e1fe71 100644 --- a/web/src/components/ui/table.tsx +++ b/web/src/components/ui/table.tsx @@ -1,70 +1,80 @@ -import * as React from 'react' -import { cn } from '@/lib/utils' +import * as React from "react"; +import { cn } from "@/lib/utils"; -const Table = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- - - ), -) -Table.displayName = 'Table' +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+
+ +)); +Table.displayName = "Table"; const TableHeader = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableHeader.displayName = 'TableHeader' + +)); +TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableBody.displayName = 'TableBody' + +)); +TableBody.displayName = "TableBody"; -const TableRow = React.forwardRef>( - ({ className, ...props }, ref) => ( - - ), -) -TableRow.displayName = 'TableRow' +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes + HTMLTableCellElement, + React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -TableHead.displayName = 'TableHead' + +)); +TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< - HTMLTableCellElement, - React.TdHTMLAttributes + HTMLTableCellElement, + React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableCell.displayName = 'TableCell' + +)); +TableCell.displayName = "TableCell"; -export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } +export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow }; diff --git a/web/src/components/wordmark.tsx b/web/src/components/wordmark.tsx index 4753bd6..b080277 100644 --- a/web/src/components/wordmark.tsx +++ b/web/src/components/wordmark.tsx @@ -1,26 +1,26 @@ -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils"; // The punktfunk "funk" wordmark — the real brand typo, vectorised from the // marketing logo. currentColor so it recolours per surface; defaults to the // light-violet lens highlight that reads on the dark console chrome. Size via // height (e.g. `h-5`); width follows the viewBox. export function Wordmark({ className }: { className?: string }) { - return ( - - punktfunk - - - - - - ) + return ( + + punktfunk + + + + + + ); } -export default Wordmark +export default Wordmark; diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index df51328..766ca49 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -1,29 +1,29 @@ // Thin reactive layer over Paraglide. Paraglide's `m.*` message functions and // `setLocale`/`getLocale` are framework-agnostic; this hook re-renders React when the // locale changes (Paraglide's localStorage strategy persists the choice across reloads). -import { useSyncExternalStore } from 'react' -import { getLocale, setLocale, locales } from '@/paraglide/runtime' +import { useSyncExternalStore } from "react"; +import { getLocale, locales, setLocale } from "@/paraglide/runtime"; /** 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). */ export function changeLocale(locale: Locale) { - // `reload: false` keeps the SPA mounted; we re-render via the store below. - setLocale(locale, { reload: false }) - for (const l of listeners) l() + // `reload: false` keeps the SPA mounted; we re-render via the store below. + setLocale(locale, { reload: false }); + for (const l of listeners) l(); } function subscribe(cb: () => void) { - listeners.add(cb) - return () => listeners.delete(cb) + listeners.add(cb); + return () => listeners.delete(cb); } /** Current locale, reactive — components using `m.*` should read this so they re-render. */ export function useLocale(): Locale { - return useSyncExternalStore(subscribe, getLocale, () => 'en' as Locale) + return useSyncExternalStore(subscribe, getLocale, () => "en" as Locale); } -export { locales } +export { locales }; diff --git a/web/src/lib/query.ts b/web/src/lib/query.ts new file mode 100644 index 0000000..96e347d --- /dev/null +++ b/web/src/lib/query.ts @@ -0,0 +1,12 @@ +/** + * The slice of a React Query result a presentational view needs: just enough to + * drive + 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 { + data?: T; + isLoading: boolean; + error: unknown; + refetch?: () => void; +} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 23b49eb..1d1c67a 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,7 +1,7 @@ -import { clsx, type ClassValue } from 'clsx' -import { twMerge } from 'tailwind-merge' +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; /** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */ export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/web/src/router.tsx b/web/src/router.tsx index 48d9956..fe21453 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,39 +1,53 @@ -import { createRouter as createTanStackRouter } from '@tanstack/react-router' -import { QueryClient } from '@tanstack/react-query' -import { routeTree } from './routeTree.gen' -import { ApiError } from './api/fetcher' +import { QueryClient } from "@tanstack/react-query"; +import { createRouter as createTanStackRouter } from "@tanstack/react-router"; +import { ApiError } from "./api/fetcher"; +import { routeTree } from "./routeTree.gen"; export function getRouter() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 2_000, - // Don't hammer the host on auth/validation errors; do retry transient 5xx once. - retry: (failureCount, error) => { - if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false - return failureCount < 1 - }, - }, - }, - }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 2_000, + // Don't hammer the host on auth/validation errors; do retry transient 5xx once. + retry: (failureCount, error) => { + if ( + error instanceof ApiError && + error.status >= 400 && + error.status < 500 + ) + return false; + return failureCount < 1; + }, + }, + }, + }); - return createTanStackRouter({ - routeTree, - context: { queryClient }, - defaultPreload: 'intent', - scrollRestoration: true, - Wrap: ({ children }) => {children}, - }) + return createTanStackRouter({ + routeTree, + context: { queryClient }, + defaultPreload: "intent", + scrollRestoration: true, + Wrap: ({ children }) => ( + {children} + ), + }); } // Local import kept below the function so the module reads top-down. -import { QueryClientProvider } from '@tanstack/react-query' -function QueryProvider({ client, children }: { client: QueryClient; children: React.ReactNode }) { - return {children} +import { QueryClientProvider } from "@tanstack/react-query"; + +function QueryProvider({ + client, + children, +}: { + client: QueryClient; + children: React.ReactNode; +}) { + return {children}; } -declare module '@tanstack/react-router' { - interface Register { - router: ReturnType - } +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } } diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 501cd51..a024425 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,51 +1,54 @@ /// + +import type { QueryClient } from "@tanstack/react-query"; import { - createRootRouteWithContext, - HeadContent, - Outlet, - Scripts, - useRouterState, -} from '@tanstack/react-router' -import type { QueryClient } from '@tanstack/react-query' -import '@fontsource-variable/geist' -import { AppShell } from '@/components/app-shell' -import appCss from '@/styles.css?url' + createRootRouteWithContext, + HeadContent, + Outlet, + Scripts, + useRouterState, +} from "@tanstack/react-router"; +import "@fontsource-variable/geist"; +import { AppShell } from "@/components/app-shell"; +import appCss from "@/styles.css?url"; export interface RouterContext { - queryClient: QueryClient + queryClient: QueryClient; } export const Route = createRootRouteWithContext()({ - head: () => ({ - meta: [ - { charSet: 'utf-8' }, - { name: 'viewport', content: 'width=device-width, initial-scale=1' }, - { name: 'color-scheme', content: 'dark light' }, - { title: 'punktfunk' }, - ], - links: [{ rel: 'stylesheet', href: appCss }], - }), - component: RootComponent, -}) + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { name: "color-scheme", content: "dark light" }, + { title: "punktfunk" }, + ], + links: [{ rel: "stylesheet", href: appCss }], + }), + component: RootComponent, +}); function RootComponent() { - // The login screen renders bare (no sidebar); everything else gets the app shell. - const isLogin = useRouterState({ select: (s) => s.location.pathname === '/login' }) - return ( - - - - - - {isLogin ? ( - - ) : ( - - - - )} - - - - ) + // The login screen renders bare (no sidebar); everything else gets the app shell. + const isLogin = useRouterState({ + select: (s) => s.location.pathname === "/login", + }); + return ( + + + + + + {isLogin ? ( + + ) : ( + + + + )} + + + + ); } diff --git a/web/src/routes/clients.tsx b/web/src/routes/clients.tsx index 6f8dcf4..73580c0 100644 --- a/web/src/routes/clients.tsx +++ b/web/src/routes/clients.tsx @@ -1,90 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useQueryClient } from '@tanstack/react-query' -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' +import { createFileRoute } from "@tanstack/react-router"; +import { SectionClients } from "@/sections/Clients"; -export const Route = createFileRoute('/clients')({ component: ClientsPage }) - -// 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 ( -
-

{m.clients_title()}

- - {rows.length === 0 ? ( - - - {m.clients_empty()} - - - ) : ( - - - - - - {m.clients_name()} - {m.clients_fingerprint()} - - - - - {rows.map((c) => ( - - {c.subject || '—'} - - {c.fingerprint.slice(0, 16)}… - - - - - - ))} - -
-
-
- )} -
-
- ) -} +export const Route = createFileRoute("/clients")({ component: SectionClients }); diff --git a/web/src/routes/host.tsx b/web/src/routes/host.tsx index 74d00f4..ba8d56e 100644 --- a/web/src/routes/host.tsx +++ b/web/src/routes/host.tsx @@ -1,115 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useGetHostInfo, useListCompositors } from '@/api/gen/host/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' +import { createFileRoute } from "@tanstack/react-router"; +import { SectionHost } from "@/sections/Host"; -export const Route = createFileRoute('/host')({ component: HostPage }) - -// 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 ( -
-

{m.nav_host()}

- - {h && ( -
- - - {m.host_identity()} - - -
- - - - - -
-
-
-
- - - {m.host_codecs()} - - - {h.codecs.map((c) => ( - - {c.toUpperCase()} - - ))} - - - - - {m.host_ports()} - - -
- {Object.entries(h.ports).map(([k, v]) => ( -
-
{k}
-
{v as number}
-
- ))} -
-
-
-
-
- )} -
- - - - {m.host_compositors()} - - -

{m.host_compositors_help()}

- -
    - {compositors.data?.map((c) => ( -
  • -
    -
    - {c.label} - {c.default && {m.compositor_default()}} -
    - {c.id} -
    - - {c.available ? m.compositor_available() : m.compositor_unavailable()} - -
  • - ))} -
-
-
-
-
- ) -} - -function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
-
{label}
-
- {value} -
-
- ) -} +export const Route = createFileRoute("/host")({ component: SectionHost }); diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 1b2c656..6a46b8f 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,139 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useQueryClient } from '@tanstack/react-query' -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' +import { createFileRoute } from "@tanstack/react-router"; +import { SectionDashboard } from "@/sections/Dashboard"; -export const Route = createFileRoute('/')({ component: Dashboard }) - -// 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 ( -
-

{m.status_title()}

- - {s && ( - <> -
- } - label={m.status_video()} - on={s.video_streaming} - /> - } - label={m.status_audio()} - on={s.audio_streaming} - /> - - - {m.status_paired_count()} - {s.paired_clients} - - - - - {m.status_pin_pending()} - - {s.pin_pending ? '●' : '—'} - - - -
- - - - - - {m.status_session()} - -
- - -
-
- - {s.stream ? ( -
- - - - -
- ) : ( -

{m.status_no_session()}

- )} -
-
- - )} -
-
- ) -} - -function StatCard({ icon, label, on }: { icon: React.ReactNode; label: string; on: boolean }) { - return ( - - - - {icon} - {label} - - - {on ? m.status_streaming() : m.status_idle()} - - - - ) -} - -function Field({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ) -} +export const Route = createFileRoute("/")({ component: SectionDashboard }); diff --git a/web/src/routes/library.tsx b/web/src/routes/library.tsx index c182f93..d864ec2 100644 --- a/web/src/routes/library.tsx +++ b/web/src/routes/library.tsx @@ -1,293 +1,4 @@ -import { useState } from 'react' -import { createFileRoute } from '@tanstack/react-router' -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' +import { createFileRoute } from "@tanstack/react-router"; +import { SectionLibrary } from "@/sections/Library"; -export const Route = createFileRoute('/library')({ component: LibraryPage }) - -/** 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(null) - const [form, setForm] = useState(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 ( -
-
-

{m.library_title()}

- {editing === null && ( - - )} -
- - {editing !== null && ( - - - {editing ? m.library_edit_title() : m.library_add_title()} - - - -
-
- - setForm((f) => ({ ...f, title: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, portrait: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, hero: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, header: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, command: e.target.value }))} - /> -

{m.library_field_command_help()}

-
-
- - -
-
-
-
- )} - - - {games.length === 0 ? ( - - - {m.library_empty()} - - - ) : ( -
- {games.map((game) => ( - openEdit(game)} - onDelete={() => onDelete(game)} - deleting={remove.isPending} - /> - ))} -
- )} -
-
- ) -} - -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 can step down portrait → header → placeholder. - const [failed, setFailed] = useState>({}) - - const candidates = [game.art.portrait, game.art.header].filter( - (u): u is string => !!u && !failed[u], - ) - const src = candidates[0] - - return ( - -
- {src ? ( - {game.title} setFailed((prev) => ({ ...prev, [src]: true }))} - /> - ) : ( -
- {game.title} -
- )} -
- - {isCustom ? m.library_store_custom() : m.library_store_steam()} - -
- {isCustom && ( -
- - -
- )} -
-
- {game.title} -
-
- ) -} +export const Route = createFileRoute("/library")({ component: SectionLibrary }); diff --git a/web/src/routes/login.tsx b/web/src/routes/login.tsx index 11d2051..b60cd44 100644 --- a/web/src/routes/login.tsx +++ b/web/src/routes/login.tsx @@ -1,85 +1,14 @@ -import { useState } from 'react' -import { createFileRoute, useRouter } from '@tanstack/react-router' -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' +import { createFileRoute } from "@tanstack/react-router"; +import { SectionLogin } from "@/sections/Login"; -export const Route = createFileRoute('/login')({ - validateSearch: (s: Record): { next?: string } => ({ - next: typeof s.next === 'string' ? s.next : undefined, - }), - component: LoginPage, -}) +export const Route = createFileRoute("/login")({ + validateSearch: (s: Record): { next?: string } => ({ + next: typeof s.next === "string" ? s.next : undefined, + }), + component: RouteComponent, +}); -function LoginPage() { - useLocale() - const router = useRouter() - 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 ( -
- - -
- - {m.app_name()} -
- {m.login_title()} -

{m.login_subtitle()}

-
- -
-
- - setPassword(e.target.value)} - /> -
- {error &&

{m.login_error()}

} - -
-
-
-
- ) +function RouteComponent() { + const { next } = Route.useSearch(); + return ; } diff --git a/web/src/routes/pairing.tsx b/web/src/routes/pairing.tsx index ef7c45a..37ed35b 100644 --- a/web/src/routes/pairing.tsx +++ b/web/src/routes/pairing.tsx @@ -1,390 +1,4 @@ -import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { useQueryClient } from "@tanstack/react-query"; -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"; +import { SectionPairing } from "@/sections/Pairing"; -export const Route = createFileRoute("/pairing")({ component: PairingPage }); - -/** 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 ( -
-

{m.pairing_title()}

- - - - -
- ); -} - -/** Seconds since a knock → a short relative label. */ -function fmtAge(secs: number): string { - if (secs < 10) return m.pairing_pending_age_just_now(); - if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) }); - return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) }); -} - -/** - * Devices awaiting delegated approval: an unpaired device that tried to connect shows up here, - * and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty - * (the common case); polls so a knock appears while the operator is looking at the page. - */ -function PendingDevices() { - const qc = useQueryClient(); - const 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 ( -
-

- - {m.pairing_pending_title()} -

-

- {m.pairing_pending_desc()} -

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

- {m.pairing_native_disabled()} -

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

{m.pairing_native_enter()}

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

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

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

- {m.pairing_native_desc()} -

- - - )} -
-
-
- ); -} - -/** The paired native (punktfunk/1) devices, with unpair. */ -function NativeDevices() { - const qc = useQueryClient(); - const clients = useListNativeClients(); - const unpair = useUnpairNativeClient(); - const rows = clients.data ?? []; - - const onUnpair = (fingerprint: string) => { - if (!confirm(m.pairing_native_unpair_confirm())) return; - unpair.mutate( - { fingerprint }, - { - onSuccess: () => - qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }), - }, - ); - }; - - return ( -
-

{m.pairing_native_devices()}

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

{m.pairing_idle()}

- ) : ( -
-

{m.pairing_waiting()}

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

- - {m.pairing_success()} -

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

{m.pairing_failed()}

- )} -
- )} -
-
-
- ); -} +export const Route = createFileRoute("/pairing")({ component: SectionPairing }); diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index 12af77d..75a0b74 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -1,55 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router' -import { LogOut } from 'lucide-react' -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' +import { createFileRoute } from "@tanstack/react-router"; +import { SectionSettings } from "@/sections/Settings"; -export const Route = createFileRoute('/settings')({ component: SettingsPage }) - -// 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 ( -
-

{m.settings_title()}

- - - - {m.settings_language()} - - - {locales.map((l: Locale) => ( - - ))} - - - - - - {m.nav_settings()} - - - - - -
- ) -} +export const Route = createFileRoute("/settings")({ + component: SectionSettings, +}); diff --git a/web/src/sections/Clients/index.tsx b/web/src/sections/Clients/index.tsx new file mode 100644 index 0000000..4038e64 --- /dev/null +++ b/web/src/sections/Clients/index.tsx @@ -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 ( + + ); +}; diff --git a/web/src/sections/Clients/view.tsx b/web/src/sections/Clients/view.tsx new file mode 100644 index 0000000..af07ba3 --- /dev/null +++ b/web/src/sections/Clients/view.tsx @@ -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; + onUnpair: (fingerprint: string) => void; + isUnpairing: boolean; +}> = ({ clients, onUnpair, isUnpairing }) => { + const rows = clients.data ?? []; + return ( +
+

{m.clients_title()}

+ + {rows.length === 0 ? ( + + + {m.clients_empty()} + + + ) : ( + + + + + + {m.clients_name()} + {m.clients_fingerprint()} + + + + + {rows.map((c) => ( + + + {c.subject || "—"} + + + {c.fingerprint.slice(0, 16)}… + + + + + + ))} + +
+
+
+ )} +
+
+ ); +}; diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx new file mode 100644 index 0000000..b069d16 --- /dev/null +++ b/web/src/sections/Dashboard/index.tsx @@ -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 ( + stop.mutate(undefined, { onSuccess: invalidate })} + onRequestIdr={() => idr.mutate(undefined)} + isStopping={stop.isPending} + isRequestingIdr={idr.isPending} + /> + ); +}; diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx new file mode 100644 index 0000000..3f65452 --- /dev/null +++ b/web/src/sections/Dashboard/view.tsx @@ -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; + onStopSession: () => void; + onRequestIdr: () => void; + isStopping: boolean; + isRequestingIdr: boolean; +}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => { + const s = status.data; + return ( +
+

{m.status_title()}

+ + {s && ( +
+
+ } + label={m.status_video()} + on={s.video_streaming} + /> + } + label={m.status_audio()} + on={s.audio_streaming} + /> + + + + {m.status_paired_count()} + + + {s.paired_clients} + + + + + + + {m.status_pin_pending()} + + + {s.pin_pending ? "●" : "—"} + + + +
+ + + + + + {m.status_session()} + +
+ + +
+
+ + {s.stream ? ( +
+ + + + +
+ ) : ( +

+ {m.status_no_session()} +

+ )} +
+
+
+ )} +
+
+ ); +}; + +const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({ + icon, + label, + on, +}) => ( + + + + {icon} + {label} + + + {on ? m.status_streaming() : m.status_idle()} + + + +); + +const Field: FC<{ label: string; value: string }> = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); diff --git a/web/src/sections/Host/index.tsx b/web/src/sections/Host/index.tsx new file mode 100644 index 0000000..6d50b03 --- /dev/null +++ b/web/src/sections/Host/index.tsx @@ -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 ; +}; diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx new file mode 100644 index 0000000..050ad72 --- /dev/null +++ b/web/src/sections/Host/view.tsx @@ -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; + compositors: Loadable; +}> = ({ host, compositors }) => { + const h = host.data; + return ( +
+

{m.nav_host()}

+ + + {h && ( +
+ + + {m.host_identity()} + + +
+ + + + + +
+
+
+
+ + + {m.host_codecs()} + + + {h.codecs.map((c) => ( + + {c.toUpperCase()} + + ))} + + + + + {m.host_ports()} + + +
+ {Object.entries(h.ports).map(([k, v]) => ( +
+
{k}
+
{v as number}
+
+ ))} +
+
+
+
+
+ )} +
+ + + + {m.host_compositors()} + + +

+ {m.host_compositors_help()} +

+ +
    + {compositors.data?.map((c) => ( +
  • +
    +
    + {c.label} + {c.default && ( + + {m.compositor_default()} + + )} +
    + + {c.id} + +
    + + {c.available + ? m.compositor_available() + : m.compositor_unavailable()} + +
  • + ))} +
+
+
+
+
+ ); +}; + +const Row: FC<{ label: string; value: string; mono?: boolean }> = ({ + label, + value, + mono, +}) => ( +
+
{label}
+
+ {value} +
+
+); diff --git a/web/src/sections/Library/index.tsx b/web/src/sections/Library/index.tsx new file mode 100644 index 0000000..5e8cdc1 --- /dev/null +++ b/web/src/sections/Library/index.tsx @@ -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 ( + + 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} + /> + ); +}; diff --git a/web/src/sections/Library/view.tsx b/web/src/sections/Library/view.tsx new file mode 100644 index 0000000..7f0cb7b --- /dev/null +++ b/web/src/sections/Library/view.tsx @@ -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; + onCreate: (data: CustomInput) => Promise; + onUpdate: (id: string, data: CustomInput) => Promise; + onDelete: (id: string) => Promise; + 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(null); + const [form, setForm] = useState(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 ( +
+
+

{m.library_title()}

+ {editing === null && ( + + )} +
+ + {editing !== null && ( + + + + {editing ? m.library_edit_title() : m.library_add_title()} + + + + +
+
+ + + setForm((f) => ({ ...f, title: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, portrait: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, hero: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, header: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, command: e.target.value })) + } + /> +

+ {m.library_field_command_help()} +

+
+
+ + +
+
+
+
+ )} + + + {games.length === 0 ? ( + + + {m.library_empty()} + + + ) : ( +
+ {games.map((game) => ( + openEdit(game)} + onDelete={() => handleDelete(game)} + deleting={isDeleting} + /> + ))} +
+ )} +
+
+ ); +}; + +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 = ({ game, onEdit, onDelete, deleting }) => { + const isCustom = game.store === "custom"; + // Track which sources have failed so the can step down portrait → header → placeholder. + const [failed, setFailed] = useState>({}); + + const candidates = [game.art.portrait, game.art.header].filter( + (u): u is string => !!u && !failed[u], + ); + const src = candidates[0]; + + return ( + +
+ {src ? ( + {game.title} setFailed((prev) => ({ ...prev, [src]: true }))} + /> + ) : ( +
+ {game.title} +
+ )} +
+ + {isCustom ? m.library_store_custom() : m.library_store_steam()} + +
+ {isCustom && ( +
+ + +
+ )} +
+
+ {game.title} +
+
+ ); +}; diff --git a/web/src/sections/Login/index.tsx b/web/src/sections/Login/index.tsx new file mode 100644 index 0000000..21b5069 --- /dev/null +++ b/web/src/sections/Login/index.tsx @@ -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 ; +}; diff --git a/web/src/sections/Login/view.tsx b/web/src/sections/Login/view.tsx new file mode 100644 index 0000000..145148c --- /dev/null +++ b/web/src/sections/Login/view.tsx @@ -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 ( +
+ + +
+ +
+ {m.login_title()} +

{m.login_subtitle()}

+
+ +
{ + e.preventDefault(); + onSubmit(password); + }} + className="space-y-4" + > +
+ + setPassword(e.target.value)} + /> +
+ {error && ( +

{m.login_error()}

+ )} + +
+
+
+
+ ); +}; diff --git a/web/src/sections/Pairing/index.tsx b/web/src/sections/Pairing/index.tsx new file mode 100644 index 0000000..b730aff --- /dev/null +++ b/web/src/sections/Pairing/index.tsx @@ -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
. +export const SectionPairing: FC = () => { + useLocale(); + return ( +
+

{m.pairing_title()}

+ + + + +
+ ); +}; + +/** Seconds since a knock → a short relative label. */ +function fmtAge(secs: number): string { + if (secs < 10) return m.pairing_pending_age_just_now(); + if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) }); + return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) }); +} + +/** + * Devices awaiting delegated approval: an unpaired device that tried to connect + * shows up here, and Approve pairs it on the spot — no PIN fetched out of band. + * Renders nothing while empty (the common case); polls so a knock appears while + * the operator is looking at the page. + */ +function PendingDevices() { + const qc = useQueryClient(); + const 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 ( +
+

+ + {m.pairing_pending_title()} +

+

+ {m.pairing_pending_desc()} +

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

+ {m.pairing_native_disabled()} +

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

{m.pairing_native_enter()}

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

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

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

+ {m.pairing_native_desc()} +

+ + + )} +
+
+
+ ); +} + +/** The paired native (punktfunk/1) devices, with unpair. */ +function NativeDevices() { + const qc = useQueryClient(); + const clients = useListNativeClients(); + const unpair = useUnpairNativeClient(); + const rows = clients.data ?? []; + + const onUnpair = (fingerprint: string) => { + if (!confirm(m.pairing_native_unpair_confirm())) return; + unpair.mutate( + { fingerprint }, + { + onSuccess: () => + qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }), + }, + ); + }; + + return ( +
+

{m.pairing_native_devices()}

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

{m.pairing_idle()}

+ ) : ( +
+

{m.pairing_waiting()}

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

+ + {m.pairing_success()} +

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

{m.pairing_failed()}

+ )} +
+ )} +
+
+
+ ); +} diff --git a/web/src/sections/Settings/index.tsx b/web/src/sections/Settings/index.tsx new file mode 100644 index 0000000..4d7ec22 --- /dev/null +++ b/web/src/sections/Settings/index.tsx @@ -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 ( +
+

{m.settings_title()}

+ + + + {m.settings_language()} + + + {locales.map((l: Locale) => ( + + ))} + + + + + + {m.nav_settings()} + + + + + +
+ ); +}; diff --git a/web/src/stories/AppShell.stories.tsx b/web/src/stories/AppShell.stories.tsx index 7e6f14d..d8c1667 100644 --- a/web/src/stories/AppShell.stories.tsx +++ b/web/src/stories/AppShell.stories.tsx @@ -1,62 +1,78 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' +import type { Meta, StoryObj } from "@storybook/react-vite"; import { - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - RouterProvider, -} from '@tanstack/react-router' -import { AppShell } from '@/components/app-shell' + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { AppShell } from "@/components/app-shell"; // AppShell is built from TanStack Router s, so it needs a router context. // We stand up a throwaway in-memory router whose routes mirror the nav targets // (so links resolve + the active highlight works) and render the shell from the // root route. No loaders/data — purely for designing the chrome offline. function ShellHarness({ initialPath }: { initialPath: string }) { - const rootRoute = createRootRoute({ - component: () => ( - -
-

Dashboard

-

- Placeholder content — swap routes from the sidebar to preview the active state. -

-
-
- ), - }) + const rootRoute = createRootRoute({ + component: () => ( + +
+

Dashboard

+

+ Placeholder content — swap routes from the sidebar to preview the + active state. +

+
+
+ ), + }); - const navPaths = ['/', '/host', '/library', '/clients', '/pairing', '/settings'] - const navRoutes = navPaths.map((path) => - createRoute({ getParentRoute: () => rootRoute, path, component: () => null }), - ) - // Splat so any other target still resolves without throwing. - const splat = createRoute({ getParentRoute: () => rootRoute, path: '$', component: () => null }) + const navPaths = [ + "/", + "/host", + "/library", + "/clients", + "/pairing", + "/settings", + ]; + const navRoutes = navPaths.map((path) => + createRoute({ + getParentRoute: () => rootRoute, + path, + component: () => null, + }), + ); + // Splat so any other target still resolves without throwing. + const splat = createRoute({ + getParentRoute: () => rootRoute, + path: "$", + component: () => null, + }); - const router = createRouter({ - routeTree: rootRoute.addChildren([...navRoutes, splat]), - history: createMemoryHistory({ initialEntries: [initialPath] }), - }) + const router = createRouter({ + routeTree: rootRoute.addChildren([...navRoutes, splat]), + history: createMemoryHistory({ initialEntries: [initialPath] }), + }); - return + return ; } const meta = { - title: 'Shell/AppShell', - component: AppShell, - parameters: { layout: 'fullscreen' }, - // AppShell requires `children`; the harness supplies the real content, so this - // placeholder just satisfies the arg type. - args: { children: null }, -} satisfies Meta + title: "Shell/AppShell", + component: AppShell, + parameters: { layout: "fullscreen" }, + // AppShell requires `children`; the harness supplies the real content, so this + // placeholder just satisfies the arg type. + args: { children: null }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const Dashboard: Story = { - render: () => , -} + render: () => , +}; export const HostActive: Story = { - render: () => , -} + render: () => , +}; diff --git a/web/src/stories/Badge.stories.tsx b/web/src/stories/Badge.stories.tsx index 0c91b84..33733e8 100644 --- a/web/src/stories/Badge.stories.tsx +++ b/web/src/stories/Badge.stories.tsx @@ -1,30 +1,36 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { Badge } from '@/components/ui/badge' +import type { Meta, StoryObj } from "@storybook/react-vite"; +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 = { - title: 'UI/Badge', - component: Badge, - args: { children: 'badge' }, - argTypes: { - variant: { control: 'select', options: VARIANTS }, - }, -} satisfies Meta + title: "UI/Badge", + component: Badge, + args: { children: "badge" }, + argTypes: { + variant: { control: "select", options: VARIANTS }, + }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; -export const Playground: Story = {} +export const Playground: Story = {}; export const All: Story = { - render: () => ( -
- {VARIANTS.map((variant) => ( - - {variant} - - ))} -
- ), -} + render: () => ( +
+ {VARIANTS.map((variant) => ( + + {variant} + + ))} +
+ ), +}; diff --git a/web/src/stories/Brand.stories.tsx b/web/src/stories/Brand.stories.tsx index 7d0addf..2a235cb 100644 --- a/web/src/stories/Brand.stories.tsx +++ b/web/src/stories/Brand.stories.tsx @@ -1,40 +1,40 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { BrandMark } from '@/components/brand-mark' -import { Wordmark } from '@/components/wordmark' -import { Logo } from '@/components/logo' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BrandMark } from "@/components/brand-mark"; +import { Logo } from "@/components/logo"; +import { Wordmark } from "@/components/wordmark"; const meta = { - title: 'Brand/Marks', - component: BrandMark, -} satisfies Meta + title: "Brand/Marks", + component: BrandMark, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const Mark: Story = { - render: () => ( -
- - - -
- ), -} + render: () => ( +
+ + + +
+ ), +}; export const Word: Story = { - render: () => ( -
- - - -
- ), -} + render: () => ( +
+ + + +
+ ), +}; export const Lockup: Story = { - render: () => ( -
- -
- ), -} + render: () => ( +
+ +
+ ), +}; diff --git a/web/src/stories/Button.stories.tsx b/web/src/stories/Button.stories.tsx index d17620b..e09921f 100644 --- a/web/src/stories/Button.stories.tsx +++ b/web/src/stories/Button.stories.tsx @@ -1,48 +1,55 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { Play } from 'lucide-react' -import { Button } from '@/components/ui/button' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Play } from "lucide-react"; +import { Button } from "@/components/ui/button"; -const VARIANTS = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const -const SIZES = ['default', 'sm', 'lg', 'icon'] as const +const VARIANTS = [ + "default", + "secondary", + "outline", + "ghost", + "link", + "destructive", +] as const; +const SIZES = ["default", "sm", "lg", "icon"] as const; const meta = { - title: 'UI/Button', - component: Button, - args: { children: 'Stream' }, - argTypes: { - variant: { control: 'select', options: VARIANTS }, - size: { control: 'select', options: SIZES }, - disabled: { control: 'boolean' }, - }, -} satisfies Meta + title: "UI/Button", + component: Button, + args: { children: "Stream" }, + argTypes: { + variant: { control: "select", options: VARIANTS }, + size: { control: "select", options: SIZES }, + disabled: { control: "boolean" }, + }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; /** Playground — drive variant/size/disabled from the Controls panel. */ -export const Playground: Story = {} +export const Playground: Story = {}; export const Variants: Story = { - render: () => ( -
- {VARIANTS.map((variant) => ( - - ))} -
- ), -} + render: () => ( +
+ {VARIANTS.map((variant) => ( + + ))} +
+ ), +}; export const Sizes: Story = { - render: () => ( -
- - - - -
- ), -} + render: () => ( +
+ + + + +
+ ), +}; diff --git a/web/src/stories/Card.stories.tsx b/web/src/stories/Card.stories.tsx index 8e649ab..21b79db 100644 --- a/web/src/stories/Card.stories.tsx +++ b/web/src/stories/Card.stories.tsx @@ -1,45 +1,45 @@ -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 { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; const meta = { - title: 'UI/Card', - component: Card, - // Card requires `children`; every story supplies its own via `render`, so this - // is just a placeholder to satisfy the arg type. - args: { children: null }, -} satisfies Meta + title: "UI/Card", + component: Card, + // Card requires `children`; every story supplies its own via `render`, so this + // is just a placeholder to satisfy the arg type. + args: { children: null }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const HostCard: Story = { - render: () => ( - - -
- ENRICOS-DESKTOP - online -
- RTX 5070 Ti · NVENC · 5120×1440 @ 240 -
- - Paired 2 days ago. Last session 11 ms p50 capture→present. - - - - - -
- ), -} + render: () => ( + + +
+ ENRICOS-DESKTOP + online +
+ RTX 5070 Ti · NVENC · 5120×1440 @ 240 +
+ + Paired 2 days ago. Last session 11 ms p50 capture→present. + + + + + +
+ ), +}; diff --git a/web/src/stories/Clients.stories.tsx b/web/src/stories/Clients.stories.tsx index e2e45d8..ae2c601 100644 --- a/web/src/stories/Clients.stories.tsx +++ b/web/src/stories/Clients.stories.tsx @@ -1,28 +1,20 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { ClientsPage } from '@/routes/clients' -import { MockApi } from './lib/mock-api' -import { pairedClients } from './lib/fixtures' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ClientsView } from "@/sections/Clients/view"; +import { pairedClients } from "./lib/fixtures"; const meta = { - title: 'Pages/Clients', - component: ClientsPage, -} satisfies Meta + title: "Pages/Clients", + component: ClientsView, + args: { onUnpair: () => {}, isUnpairing: false }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const Paired: Story = { - render: () => ( - - - - ), -} + args: { clients: { data: pairedClients, isLoading: false, error: null } }, +}; export const Empty: Story = { - render: () => ( - - - - ), -} + args: { clients: { data: [], isLoading: false, error: null } }, +}; diff --git a/web/src/stories/Dashboard.stories.tsx b/web/src/stories/Dashboard.stories.tsx index 4eb9ce8..54b35a8 100644 --- a/web/src/stories/Dashboard.stories.tsx +++ b/web/src/stories/Dashboard.stories.tsx @@ -1,28 +1,25 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { Dashboard } from '@/routes/index' -import { MockApi } from './lib/mock-api' -import { statusActive, statusIdle } from './lib/fixtures' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DashboardView } from "@/sections/Dashboard/view"; +import { statusActive, statusIdle } from "./lib/fixtures"; const meta = { - title: 'Pages/Dashboard', - component: Dashboard, -} satisfies Meta + title: "Pages/Dashboard", + component: DashboardView, + args: { + onStopSession: () => {}, + onRequestIdr: () => {}, + isStopping: false, + isRequestingIdr: false, + }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const ActiveSession: Story = { - render: () => ( - - - - ), -} + args: { status: { data: statusActive, isLoading: false, error: null } }, +}; export const Idle: Story = { - render: () => ( - - - - ), -} + args: { status: { data: statusIdle, isLoading: false, error: null } }, +}; diff --git a/web/src/stories/Host.stories.tsx b/web/src/stories/Host.stories.tsx index b0935c8..2a19527 100644 --- a/web/src/stories/Host.stories.tsx +++ b/web/src/stories/Host.stories.tsx @@ -1,20 +1,24 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { HostPage } from '@/routes/host' -import { MockApi } from './lib/mock-api' -import { compositors, hostInfo } from './lib/fixtures' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HostView } from "@/sections/Host/view"; +import { compositors, hostInfo } from "./lib/fixtures"; const meta = { - title: 'Pages/Host', - component: HostPage, -} satisfies Meta + title: "Pages/Host", + component: HostView, + args: { + host: { data: hostInfo, isLoading: false, error: null }, + compositors: { data: compositors, isLoading: false, error: null }, + }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; -export const Default: Story = { - render: () => ( - - - - ), -} +export const Default: Story = {}; + +export const Loading: Story = { + args: { + host: { data: undefined, isLoading: true, error: null }, + compositors: { data: undefined, isLoading: true, error: null }, + }, +}; diff --git a/web/src/stories/Inputs.stories.tsx b/web/src/stories/Inputs.stories.tsx index cc6d3f7..4c2064c 100644 --- a/web/src/stories/Inputs.stories.tsx +++ b/web/src/stories/Inputs.stories.tsx @@ -1,30 +1,30 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; const meta = { - title: 'UI/Inputs', - component: Input, -} satisfies Meta + title: "UI/Inputs", + component: Input, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const Form: Story = { - render: () => ( -
-
- - -
-
- - -
-
- - -
-
- ), -} + render: () => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ), +}; diff --git a/web/src/stories/Library.stories.tsx b/web/src/stories/Library.stories.tsx index fe4dcec..f659971 100644 --- a/web/src/stories/Library.stories.tsx +++ b/web/src/stories/Library.stories.tsx @@ -1,28 +1,26 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { LibraryPage } from '@/routes/library' -import { MockApi } from './lib/mock-api' -import { library } from './lib/fixtures' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LibraryView } from "@/sections/Library/view"; +import { library } from "./lib/fixtures"; const meta = { - title: 'Pages/Library', - component: LibraryPage, -} satisfies Meta + title: "Pages/Library", + component: LibraryView, + args: { + onCreate: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + isSaving: false, + isDeleting: false, + }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const Populated: Story = { - render: () => ( - - - - ), -} + args: { library: { data: library, isLoading: false, error: null } }, +}; export const Empty: Story = { - render: () => ( - - - - ), -} + args: { library: { data: [], isLoading: false, error: null } }, +}; diff --git a/web/src/stories/Login.stories.tsx b/web/src/stories/Login.stories.tsx new file mode 100644 index 0000000..de9c65b --- /dev/null +++ b/web/src/stories/Login.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Error: Story = { args: { error: true } }; diff --git a/web/src/stories/QueryState.stories.tsx b/web/src/stories/QueryState.stories.tsx index 7791d51..d8826d3 100644 --- a/web/src/stories/QueryState.stories.tsx +++ b/web/src/stories/QueryState.stories.tsx @@ -1,36 +1,42 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { QueryState } from '@/components/query-state' -import { ApiError } from '@/api/fetcher' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ApiError } from "@/api/fetcher"; +import { QueryState } from "@/components/query-state"; // 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 // (loading spinner / error / unauthorized) never appear together live. const Loaded = () => ( -
Loaded content renders here.
-) +
+ Loaded content renders here. +
+); const meta = { - title: 'Patterns/QueryState', - component: QueryState, - args: { children: }, -} satisfies Meta + title: "Patterns/QueryState", + component: QueryState, + args: { children: }, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; export const Loading: Story = { - args: { isLoading: true, error: null }, -} + args: { isLoading: true, error: null }, +}; 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 = { - args: { isLoading: false, error: new ApiError(401, null) }, -} + args: { isLoading: false, error: new ApiError(401, null) }, +}; export const Loaded_: Story = { - name: 'Success', - args: { isLoading: false, error: null }, -} + name: "Success", + args: { isLoading: false, error: null }, +}; diff --git a/web/src/stories/Settings.stories.tsx b/web/src/stories/Settings.stories.tsx index 5b6f66a..311b6c1 100644 --- a/web/src/stories/Settings.stories.tsx +++ b/web/src/stories/Settings.stories.tsx @@ -1,14 +1,12 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { SettingsPage } from '@/routes/settings' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SectionSettings } from "@/sections/Settings"; -// Settings reads no API (just the locale + a logout button), so it renders -// directly — no mock needed. const meta = { - title: 'Pages/Settings', - component: SettingsPage, -} satisfies Meta + title: "Pages/Settings", + component: SectionSettings, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; -export const Default: Story = {} +export const Default: Story = {}; diff --git a/web/src/stories/Spinner.stories.tsx b/web/src/stories/Spinner.stories.tsx index 670a767..4971195 100644 --- a/web/src/stories/Spinner.stories.tsx +++ b/web/src/stories/Spinner.stories.tsx @@ -1,31 +1,31 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { Spinner } from '@/components/ui/spinner' +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Spinner } from "@/components/ui/spinner"; const meta = { - title: 'UI/Spinner', - component: Spinner, -} satisfies Meta + title: "UI/Spinner", + component: Spinner, +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; -export const Default: Story = {} +export const Default: Story = {}; export const Large: Story = { - render: () => ( -
- -
- ), -} + render: () => ( +
+ +
+ ), +}; export const Sizes: Story = { - render: () => ( -
- - - - -
- ), -} + render: () => ( +
+ + + + +
+ ), +}; diff --git a/web/src/stories/lib/fixtures.ts b/web/src/stories/lib/fixtures.ts index 55e5789..ed9451d 100644 --- a/web/src/stories/lib/fixtures.ts +++ b/web/src/stories/lib/fixtures.ts @@ -1,87 +1,114 @@ // Mock API payloads for the page stories — typed against the generated models so // they stay honest if the OpenAPI schema changes. -import type { AvailableCompositor } from '@/api/gen/model/availableCompositor' -import type { GameEntry } from '@/api/gen/model/gameEntry' -import type { HostInfo } from '@/api/gen/model/hostInfo' -import type { PairedClient } from '@/api/gen/model/pairedClient' -import type { RuntimeStatus } from '@/api/gen/model/runtimeStatus' +import type { AvailableCompositor } from "@/api/gen/model/availableCompositor"; +import type { GameEntry } from "@/api/gen/model/gameEntry"; +import type { HostInfo } from "@/api/gen/model/hostInfo"; +import type { PairedClient } from "@/api/gen/model/pairedClient"; +import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus"; export const hostInfo: HostInfo = { - abi_version: 2, - app_version: '7.1.450.0', - codecs: ['h264', 'h265', 'av1'], - gfe_version: '3.23.0.74', - hostname: 'ENRICOS-DESKTOP', - local_ip: '192.168.1.173', - ports: { - audio: 48000, - control: 47999, - http: 47989, - https: 47984, - mgmt: 47990, - rtsp: 48010, - video: 47998, - }, - uniqueid: '0f8a1c3e9b7d4a62', - version: '0.2.0', -} + abi_version: 2, + app_version: "7.1.450.0", + codecs: ["h264", "h265", "av1"], + gfe_version: "3.23.0.74", + hostname: "ENRICOS-DESKTOP", + local_ip: "192.168.1.173", + ports: { + audio: 48000, + control: 47999, + http: 47989, + https: 47984, + mgmt: 47990, + rtsp: 48010, + video: 47998, + }, + uniqueid: "0f8a1c3e9b7d4a62", + version: "0.2.0", +}; export const compositors: AvailableCompositor[] = [ - { id: 'kwin', label: 'KWin (Plasma)', available: true, default: true }, - { id: 'gamescope', label: 'gamescope', available: true, default: false }, - { id: 'mutter', label: 'Mutter (GNOME)', available: false, default: false }, - { id: 'wlroots', label: 'Sway / wlroots', available: false, default: false }, -] + { id: "kwin", label: "KWin (Plasma)", available: true, default: true }, + { id: "gamescope", label: "gamescope", available: true, default: false }, + { id: "mutter", label: "Mutter (GNOME)", available: false, default: false }, + { id: "wlroots", label: "Sway / wlroots", available: false, default: false }, +]; export const statusActive: RuntimeStatus = { - video_streaming: true, - audio_streaming: true, - paired_clients: 3, - pin_pending: false, - session: { width: 5120, height: 1440, fps: 240 }, - stream: { - codec: 'h265', - width: 5120, - height: 1440, - fps: 240, - bitrate_kbps: 150_000, - min_fec: 5, - packet_size: 1392, - }, -} + video_streaming: true, + audio_streaming: true, + paired_clients: 3, + pin_pending: false, + session: { width: 5120, height: 1440, fps: 240 }, + stream: { + codec: "h265", + width: 5120, + height: 1440, + fps: 240, + bitrate_kbps: 150_000, + min_fec: 5, + packet_size: 1392, + }, +}; export const statusIdle: RuntimeStatus = { - video_streaming: false, - audio_streaming: false, - paired_clients: 1, - pin_pending: true, - session: null, - stream: null, -} + video_streaming: false, + audio_streaming: false, + paired_clients: 1, + pin_pending: true, + session: null, + stream: null, +}; export const pairedClients: PairedClient[] = [ - { - fingerprint: 'a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00', - subject: 'enricos-macbook', - not_before_unix: 1_718_000_000, - not_after_unix: 2_030_000_000, - }, - { - fingerprint: 'ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1', - subject: 'living-room-tv', - not_before_unix: 1_718_500_000, - not_after_unix: 2_030_000_000, - }, - { - fingerprint: '0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff', - subject: null, - }, -] + { + fingerprint: + "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00", + subject: "enricos-macbook", + not_before_unix: 1_718_000_000, + not_after_unix: 2_030_000_000, + }, + { + fingerprint: + "ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1", + subject: "living-room-tv", + not_before_unix: 1_718_500_000, + not_after_unix: 2_030_000_000, + }, + { + fingerprint: + "0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff", + 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[] = [ - { 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:413150', store: 'steam', title: 'Stardew Valley', art: noArt, launch: null }, - { id: 'custom:retroarch', store: 'custom', title: 'RetroArch', art: noArt, launch: null }, -] + { + 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:413150", + store: "steam", + title: "Stardew Valley", + art: noArt, + launch: null, + }, + { + id: "custom:retroarch", + store: "custom", + title: "RetroArch", + art: noArt, + launch: null, + }, +]; diff --git a/web/src/stories/lib/mock-api.tsx b/web/src/stories/lib/mock-api.tsx deleted file mode 100644 index b9fe094..0000000 --- a/web/src/stories/lib/mock-api.tsx +++ /dev/null @@ -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 - -/** - * 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 => { - 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 {children} -} diff --git a/web/src/styles.css b/web/src/styles.css index 852e66d..d5f05d5 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,13 +1,13 @@ -@import 'tailwindcss'; -@import 'tw-animate-css'; -@import './timing-functions.css'; +@import "tailwindcss"; +@import "tw-animate-css"; +@import "./timing-functions.css"; @custom-variant dark (&:is(.dark *)); /* Pull @unom/ui's compiled component classes (bg-neutral, rounded-card, p-padding-card, ring-accent, h-input-height, material…) into the 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 ──────────────────────────────── Two themes on one violet identity: LIGHT (lavender docs surface — the same @@ -25,169 +25,193 @@ (--main, --neutral*, --error → other tokens) live in :root only — CSS resolves var() per-theme at use time, so .dark overrides just the raw values. */ :root { - --radius: 0.625rem; + --radius: 0.625rem; - /* Brand — the violet lens mark (from the punktfunk app icon). Theme-independent. */ - --pf-brand: #6c5bf3; /* deep violet — primary on light */ - --pf-brand-light: #a79ff8; /* light violet — primary on dark */ - --pf-highlight: #d2c9fb; /* lens highlight */ + /* Brand — the violet lens mark (from the punktfunk app icon). Theme-independent. */ + --pf-brand: #6c5bf3; /* deep violet — primary on light */ + --pf-brand-light: #a79ff8; /* light violet — primary on dark */ + --pf-highlight: #d2c9fb; /* lens highlight */ - /* Surfaces — light · lavender (white bg, faint-violet cards/borders). */ - --background: #ffffff; - --foreground: #1b1430; - --card: #f6f2ff; - --card-foreground: #1b1430; - --popover: #ffffff; - --popover-foreground: #1b1430; - --muted: #f1ecfd; - --muted-foreground: #6f6a86; - --secondary: #ece6fb; - --secondary-foreground: #1b1430; - /* shadcn `accent` = subtle hover surface; also @unom/ui's card ring colour, + /* Surfaces — light · lavender (white bg, faint-violet cards/borders). */ + --background: #ffffff; + --foreground: #1b1430; + --card: #f6f2ff; + --card-foreground: #1b1430; + --popover: #ffffff; + --popover-foreground: #1b1430; + --muted: #f1ecfd; + --muted-foreground: #6f6a86; + --secondary: #ece6fb; + --secondary-foreground: #1b1430; + /* shadcn `accent` = subtle hover surface; also @unom/ui's card ring colour, so we tint it toward the brand violet (the same in both themes). */ - --accent: var(--pf-brand); - --accent-foreground: #ffffff; - --border: #e4dcf7; - --input: #e4dcf7; - --ring: var(--pf-brand); + --accent: var(--pf-brand); + --accent-foreground: #ffffff; + --border: #e4dcf7; + --input: #e4dcf7; + --ring: var(--pf-brand); - /* Primary = the brand (buttons, active nav, default badges). */ - --primary: var(--pf-brand); - --primary-foreground: #ffffff; + /* Primary = the brand (buttons, active nav, default badges). */ + --primary: var(--pf-brand); + --primary-foreground: #ffffff; - --success: oklch(0.6 0.14 160); - --destructive: oklch(0.55 0.22 18); - --destructive-foreground: #ffffff; + --success: oklch(0.6 0.14 160); + --destructive: oklch(0.55 0.22 18); + --destructive-foreground: #ffffff; - /* ── @unom/ui semantic token contract (its components read these names). ── + /* ── @unom/ui semantic token contract (its components read these names). ── These are indirections — they follow the raw tokens above per-theme. */ - --main: var(--foreground); - --brand: var(--pf-brand); - --brand-light: var(--pf-brand-light); - --highlight: var(--pf-highlight); - --neutral: var(--card); /* @unom card default surface (bg-neutral) */ - --neutral-accent: var(--secondary); /* accent / nested surface (bg-neutral-accent) */ - --neutral-highlight: var(--border); - --error: var(--destructive); + --main: var(--foreground); + --brand: var(--pf-brand); + --brand-light: var(--pf-brand-light); + --highlight: var(--pf-highlight); + --neutral: var(--card); /* @unom card default surface (bg-neutral) */ + --neutral-accent: var( + --secondary + ); /* accent / nested surface (bg-neutral-accent) */ + --neutral-highlight: var(--border); + --error: var(--destructive); - --font-display: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; - --font-sans: '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; - /* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */ - --radius-card-min: var(--radius); + /* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */ + --radius-card-min: var(--radius); } /* Dark · the violet-tinted app-icon chrome. Overrides only the raw values — the indirection tokens in :root resolve to these automatically. */ .dark { - --background: #141019; - --foreground: oklch(0.985 0 0); - --card: #1c1530; - --card-foreground: oklch(0.985 0 0); - --popover: #1c1530; - --popover-foreground: oklch(0.985 0 0); - --muted: #1f1830; - --muted-foreground: oklch(0.728 0.03 286); - --secondary: #241c3d; - --secondary-foreground: oklch(0.985 0 0); - --border: #2a2148; - --input: #2a2148; - --ring: var(--pf-brand-light); + --background: #141019; + --foreground: oklch(0.985 0 0); + --card: #1c1530; + --card-foreground: oklch(0.985 0 0); + --popover: #1c1530; + --popover-foreground: oklch(0.985 0 0); + --muted: #1f1830; + --muted-foreground: oklch(0.728 0.03 286); + --secondary: #241c3d; + --secondary-foreground: oklch(0.985 0 0); + --border: #2a2148; + --input: #2a2148; + --ring: var(--pf-brand-light); - /* Lighter violet reads better against the dark surface. */ - --primary: var(--pf-brand-light); - --primary-foreground: #141019; + /* Lighter violet reads better against the dark surface. */ + --primary: var(--pf-brand-light); + --primary-foreground: #141019; - --success: oklch(0.7 0.15 160); - --destructive: oklch(0.62 0.21 18); - --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.7 0.15 160); + --destructive: oklch(0.62 0.21 18); + --destructive-foreground: oklch(0.985 0 0); } /* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary and @unom/ui's, resolved to one set of values. */ @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --radius-button: 9999px; - --radius-card: calc(var(--radius) * 2); - --radius-main: calc(var(--radius) * 2); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-button: 9999px; + --radius-card: calc(var(--radius) * 2); + --radius-main: calc(var(--radius) * 2); - --spacing-input-height: 3rem; - --spacing-padding-card: 1.25rem; - --spacing-card: 1.5rem; - --spacing-main: 15px; + --spacing-input-height: 3rem; + --spacing-padding-card: 1.25rem; + --spacing-card: 1.5rem; + --spacing-main: 15px; - --font-sans: var(--font-sans); - --font-display: var(--font-display); + --font-sans: var(--font-sans); + --font-display: var(--font-display); - /* shadcn-style colour tokens. */ - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); + /* shadcn-style colour tokens. */ + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); - /* @unom/ui colour tokens. */ - --color-main: var(--main); - --color-brand: var(--brand); - --color-brand-light: var(--brand-light); - --color-neutral: var(--neutral); - --color-neutral-accent: var(--neutral-accent); - --color-neutral-highlight: var(--neutral-highlight); - --color-highlight: var(--highlight); - --color-error: var(--error); + /* @unom/ui colour tokens. */ + --color-main: var(--main); + --color-brand: var(--brand); + --color-brand-light: var(--brand-light); + --color-neutral: var(--neutral); + --color-neutral-accent: var(--neutral-accent); + --color-neutral-highlight: var(--neutral-highlight); + --color-highlight: var(--highlight); + --color-error: var(--error); } /* Accordion / collapsible keyframes @unom/ui's interactive surfaces animate to. */ @theme { - --animate-accordion-down: accordion-down 0.4s var(--ease-out-quart); - --animate-accordion-up: accordion-up 0.4s var(--ease-out-quart); - --animate-collapsible-down: collapsible-down 0.4s var(--ease-out-quart); - --animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart); + --animate-accordion-down: accordion-down 0.4s var(--ease-out-quart); + --animate-accordion-up: accordion-up 0.4s var(--ease-out-quart); + --animate-collapsible-down: collapsible-down 0.4s var(--ease-out-quart); + --animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart); - @keyframes accordion-down { - from { height: 0; } - to { height: var(--radix-accordion-content-height); } - } - @keyframes accordion-up { - from { height: var(--radix-accordion-content-height); } - to { height: 0; } - } - @keyframes collapsible-down { - from { height: 0; opacity: 0; } - to { height: var(--radix-collapsible-content-height); opacity: 1; } - } - @keyframes collapsible-up { - from { height: var(--radix-collapsible-content-height); opacity: 1; } - to { height: 0; opacity: 0; } - } + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } + @keyframes collapsible-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--radix-collapsible-content-height); + opacity: 1; + } + } + @keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } + } } @layer base { - * { - border-color: var(--border); - } - body { - background-color: var(--background); - color: var(--foreground); - font-family: var(--font-sans); - font-feature-settings: 'rlig' 1, 'calt' 1; - } + * { + border-color: var(--border); + } + body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-feature-settings: + "rlig" 1, + "calt" 1; + } } diff --git a/web/src/timing-functions.css b/web/src/timing-functions.css index ad46466..a323d43 100644 --- a/web/src/timing-functions.css +++ b/web/src/timing-functions.css @@ -1,25 +1,25 @@ /* Penner easing tokens — shared with the punktfunk marketing site + @unom/ui. @unom/ui's accordion/collapsible/material animations resolve these by name. */ @theme { - --ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715); - --ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1); - --ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95); - --ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53); - --ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94); - --ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955); - --ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19); - --ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1); - --ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1); - --ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22); - --ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1); - --ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1); - --ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06); - --ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); - --ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1); - --ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035); - --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); - --ease-in-out-expo: cubic-bezier(1, 0, 0, 1); - --ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335); - --ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1); - --ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86); + --ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715); + --ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1); + --ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95); + --ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53); + --ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94); + --ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955); + --ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19); + --ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1); + --ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1); + --ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22); + --ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1); + --ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1); + --ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06); + --ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1); + --ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035); + --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); + --ease-in-out-expo: cubic-bezier(1, 0, 0, 1); + --ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335); + --ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1); + --ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86); } diff --git a/web/tsconfig.json b/web/tsconfig.json index 0682137..656e187 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,24 +1,24 @@ { - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "verbatimModuleSyntax": true, - "noUncheckedIndexedAccess": true, - "resolveJsonModule": true, - "isolatedModules": true, - "allowJs": true, - "checkJs": false, - "noEmit": true, - "types": ["vite/client"], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src", "server", "vite.config.ts", "orval.config.ts"] + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "server", "vite.config.ts", "orval.config.ts"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index c608d4a..a2b4376 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,65 +1,65 @@ -import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vite' -import { tanstackStart } from '@tanstack/react-start/plugin/vite' -import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' -import viteReact from '@vitejs/plugin-react' -import viteTsConfigPaths from 'vite-tsconfig-paths' -import tailwindcss from '@tailwindcss/vite' -import { paraglideVitePlugin } from '@inlang/paraglide-js' +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin"; +import viteReact from "@vitejs/plugin-react"; +import viteTsConfigPaths from "vite-tsconfig-paths"; +import tailwindcss from "@tailwindcss/vite"; +import { paraglideVitePlugin } from "@inlang/paraglide-js"; // 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. -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/...): // 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. -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({ - server: { - proxy: { - // `secure: false`: the host serves its own self-signed identity cert on loopback. - '/api': { target: MGMT_URL, changeOrigin: true, secure: false }, - }, - }, - plugins: [ - viteTsConfigPaths({ projects: ['./tsconfig.json'] }), - tailwindcss(), - paraglideVitePlugin({ - project: './project.inlang', - outdir: './src/paraglide', - strategy: ['localStorage', 'preferredLanguage', 'baseLocale'], - }), - // 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 - // renders a data-free shell that hydrates in the browser). - tanstackStart(), - // Nitro v2 is the deployment target: the `bun` preset bundles a Bun-runnable server to - // .output/ (`bun run .output/server/index.mjs`). Auth + the /api proxy live in the - // scanned `server/` dir (middleware/auth.ts gates every request; routes/api/[...].ts - // proxies to the management host injecting the bearer token server-side) — NOT a static - // routeRule, so the proxy runs behind the login gate and reads env at runtime. - nitroV2Plugin({ - // node-server (not bun): a STANDALONE node HTTP server (`node .output/server/index.mjs` - // listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web - // .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with - // bun; only the runtime target changes. (dev `vite dev` is unaffected.) - preset: 'node-server', - // BUNDLE every dependency into the server output (no externalized node_modules). Three wins: - // (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep - // tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) it makes the - // output a self-contained graph `bun build --compile` can fold into ONE native binary (the - // Windows installer ships that instead of node + a node_modules forest); (3) it removes the - // 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. - noExternals: true, - compatibilityDate: '2026-06-10', - // Scan server/{middleware,routes} for the auth gate + the /api proxy. - scanDirs: [serverDir], - }), - // Must come AFTER tanstackStart — provides the React JSX transform + Refresh runtime - // that Start's dev mode requires (omitting it leaves the client JS unable to load). - viteReact(), - ], -}) + server: { + proxy: { + // `secure: false`: the host serves its own self-signed identity cert on loopback. + "/api": { target: MGMT_URL, changeOrigin: true, secure: false }, + }, + }, + plugins: [ + viteTsConfigPaths({ projects: ["./tsconfig.json"] }), + tailwindcss(), + paraglideVitePlugin({ + project: "./project.inlang", + outdir: "./src/paraglide", + strategy: ["localStorage", "preferredLanguage", "baseLocale"], + }), + // 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 + // renders a data-free shell that hydrates in the browser). + tanstackStart(), + // Nitro v2 is the deployment target: the `bun` preset bundles a Bun-runnable server to + // .output/ (`bun run .output/server/index.mjs`). Auth + the /api proxy live in the + // scanned `server/` dir (middleware/auth.ts gates every request; routes/api/[...].ts + // proxies to the management host injecting the bearer token server-side) — NOT a static + // routeRule, so the proxy runs behind the login gate and reads env at runtime. + nitroV2Plugin({ + // node-server (not bun): a STANDALONE node HTTP server (`node .output/server/index.mjs` + // listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web + // .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with + // bun; only the runtime target changes. (dev `vite dev` is unaffected.) + preset: "node-server", + // BUNDLE every dependency into the server output (no externalized node_modules). Three wins: + // (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep + // tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) it makes the + // output a self-contained graph `bun build --compile` can fold into ONE native binary (the + // Windows installer ships that instead of node + a node_modules forest); (3) it removes the + // 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. + noExternals: true, + compatibilityDate: "2026-06-10", + // Scan server/{middleware,routes} for the auth gate + the /api proxy. + scanDirs: [serverDir], + }), + // Must come AFTER tanstackStart — provides the React JSX transform + Refresh runtime + // that Start's dev mode requires (omitting it leaves the client JS unable to load). + viteReact(), + ], +}); diff --git a/web/vite.storybook.config.ts b/web/vite.storybook.config.ts index 844ee86..064375f 100644 --- a/web/vite.storybook.config.ts +++ b/web/vite.storybook.config.ts @@ -1,21 +1,21 @@ -import { defineConfig } from 'vite' -import viteReact from '@vitejs/plugin-react' -import viteTsConfigPaths from 'vite-tsconfig-paths' -import tailwindcss from '@tailwindcss/vite' -import { paraglideVitePlugin } from '@inlang/paraglide-js' +import { defineConfig } from "vite"; +import viteReact from "@vitejs/plugin-react"; +import viteTsConfigPaths from "vite-tsconfig-paths"; +import tailwindcss from "@tailwindcss/vite"; +import { paraglideVitePlugin } from "@inlang/paraglide-js"; // Storybook builds the components in isolation — WITHOUT the TanStack Start / // Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the // React transform, and Paraglide. export default defineConfig({ - plugins: [ - viteTsConfigPaths({ projects: ['./tsconfig.json'] }), - tailwindcss(), - viteReact(), - paraglideVitePlugin({ - project: './project.inlang', - outdir: './src/paraglide', - strategy: ['localStorage', 'preferredLanguage', 'baseLocale'], - }), - ], -}) + plugins: [ + viteTsConfigPaths({ projects: ["./tsconfig.json"] }), + tailwindcss(), + viteReact(), + paraglideVitePlugin({ + project: "./project.inlang", + outdir: "./src/paraglide", + strategy: ["localStorage", "preferredLanguage", "baseLocale"], + }), + ], +});