improve web ui
This commit is contained in:
+12
-12
@@ -1,15 +1,15 @@
|
|||||||
import type { StorybookConfig } from '@storybook/react-vite'
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
||||||
addons: [],
|
addons: [],
|
||||||
framework: {
|
framework: {
|
||||||
name: '@storybook/react-vite',
|
name: "@storybook/react-vite",
|
||||||
options: {
|
options: {
|
||||||
// Use the slim, Start/Nitro-free Vite config (see vite.storybook.config.ts).
|
// Use the slim, Start/Nitro-free Vite config (see vite.storybook.config.ts).
|
||||||
builder: { viteConfigPath: './vite.storybook.config.ts' },
|
builder: { viteConfigPath: "./vite.storybook.config.ts" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default config
|
export default config;
|
||||||
|
|||||||
+58
-58
@@ -1,69 +1,69 @@
|
|||||||
// Import the console's REAL stylesheet directly (rememed-style) — the @theme
|
// Import the console's REAL stylesheet directly (rememed-style) — the @theme
|
||||||
// blocks process because this is the literal entry Storybook's Vite pipeline sees.
|
// blocks process because this is the literal entry Storybook's Vite pipeline sees.
|
||||||
import '../src/styles.css'
|
import "../src/styles.css";
|
||||||
// The console loads its brand typeface separately (in __root.tsx); do the same
|
// The console loads its brand typeface separately (in __root.tsx); do the same
|
||||||
// here or every story falls back to system-ui and looks off.
|
// here or every story falls back to system-ui and looks off.
|
||||||
import '@fontsource-variable/geist'
|
import "@fontsource-variable/geist";
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from "react";
|
||||||
import { definePreview } from '@storybook/react-vite'
|
import { definePreview } from "@storybook/react-vite";
|
||||||
import { MaterialProvider, defaultMaterialTheme } from '@unom/ui/material'
|
import { MaterialProvider, defaultMaterialTheme } from "@unom/ui/material";
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
// React Query is present so any query-backed component mounts without a real
|
// React Query is present so any query-backed component mounts without a real
|
||||||
// host. Stories should feed mock data rather than fetch — retries are off so a
|
// host. Stories should feed mock data rather than fetch — retries are off so a
|
||||||
// stray request fails fast instead of hanging the canvas.
|
// stray request fails fast instead of hanging the canvas.
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
})
|
});
|
||||||
|
|
||||||
export default definePreview({
|
export default definePreview({
|
||||||
addons: [],
|
addons: [],
|
||||||
// The live console pins dark; default the canvas to dark too, with a toolbar
|
// The live console pins dark; default the canvas to dark too, with a toolbar
|
||||||
// switch to preview the light theme while designing.
|
// switch to preview the light theme while designing.
|
||||||
initialGlobals: { theme: 'dark' },
|
initialGlobals: { theme: "dark" },
|
||||||
globalTypes: {
|
globalTypes: {
|
||||||
theme: {
|
theme: {
|
||||||
description: 'Light/dark color scheme',
|
description: "Light/dark color scheme",
|
||||||
toolbar: {
|
toolbar: {
|
||||||
title: 'Theme',
|
title: "Theme",
|
||||||
icon: 'circlehollow',
|
icon: "circlehollow",
|
||||||
items: [
|
items: [
|
||||||
{ value: 'dark', icon: 'moon', title: 'Dark' },
|
{ value: "dark", icon: "moon", title: "Dark" },
|
||||||
{ value: 'light', icon: 'sun', title: 'Light' },
|
{ value: "light", icon: "sun", title: "Light" },
|
||||||
],
|
],
|
||||||
dynamicTitle: true,
|
dynamicTitle: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story, context) => {
|
(Story, context) => {
|
||||||
const dark = (context.globals.theme as string) !== 'light'
|
const dark = (context.globals.theme as string) !== "light";
|
||||||
// `layout: 'fullscreen'` stories (e.g. the AppShell) own their own padding;
|
// `layout: 'fullscreen'` stories (e.g. the AppShell) own their own padding;
|
||||||
// everything else gets a comfortable inset.
|
// everything else gets a comfortable inset.
|
||||||
const fullscreen = context.parameters.layout === 'fullscreen'
|
const fullscreen = context.parameters.layout === "fullscreen";
|
||||||
// Mirror `.dark` onto <html> so the body's token-driven background AND any
|
// Mirror `.dark` onto <html> so the body's token-driven background AND any
|
||||||
// portal-mounted content (radix dialogs, popovers) pick up the right
|
// portal-mounted content (radix dialogs, popovers) pick up the right
|
||||||
// palette — the console keys its whole token set off `html.dark`.
|
// palette — the console keys its whole token set off `html.dark`.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle('dark', dark)
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
}, [dark])
|
}, [dark]);
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MaterialProvider theme={defaultMaterialTheme}>
|
<MaterialProvider theme={defaultMaterialTheme}>
|
||||||
<div className={dark ? 'dark' : ''}>
|
<div className={dark ? "dark" : ""}>
|
||||||
<div
|
<div
|
||||||
className={`min-h-screen bg-background text-foreground ${fullscreen ? '' : 'p-6'}`}
|
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
||||||
>
|
>
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MaterialProvider>
|
</MaterialProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } },
|
controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } },
|
||||||
layout: 'padded',
|
layout: "padded",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.5.1",
|
||||||
"@inlang/paraglide-js": "^2.0.0",
|
"@inlang/paraglide-js": "^2.0.0",
|
||||||
"@storybook/react-vite": "^10.4.6",
|
"@storybook/react-vite": "^10.4.6",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -85,6 +86,24 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
|
"@biomejs/biome": ["@biomejs/biome@2.5.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="],
|
||||||
|
|
||||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
||||||
|
|
||||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
|
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
|
||||||
|
|||||||
+19
-19
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
"css": "src/styles.css",
|
"css": "src/styles.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-107
@@ -1,109 +1,109 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"app_name": "punktfunk",
|
"app_name": "punktfunk",
|
||||||
"app_tagline": "Verwaltungskonsole",
|
"app_tagline": "Verwaltungskonsole",
|
||||||
"nav_dashboard": "Übersicht",
|
"nav_dashboard": "Übersicht",
|
||||||
"nav_host": "Host",
|
"nav_host": "Host",
|
||||||
"nav_clients": "Gekoppelte Geräte",
|
"nav_clients": "Gekoppelte Geräte",
|
||||||
"nav_pairing": "Kopplung",
|
"nav_pairing": "Kopplung",
|
||||||
"nav_library": "Bibliothek",
|
"nav_library": "Bibliothek",
|
||||||
"nav_settings": "Einstellungen",
|
"nav_settings": "Einstellungen",
|
||||||
"status_title": "Live-Status",
|
"status_title": "Live-Status",
|
||||||
"status_video": "Video",
|
"status_video": "Video",
|
||||||
"status_audio": "Audio",
|
"status_audio": "Audio",
|
||||||
"status_streaming": "Aktiv",
|
"status_streaming": "Aktiv",
|
||||||
"status_idle": "Inaktiv",
|
"status_idle": "Inaktiv",
|
||||||
"status_session": "Sitzung",
|
"status_session": "Sitzung",
|
||||||
"status_no_session": "Keine aktive Sitzung",
|
"status_no_session": "Keine aktive Sitzung",
|
||||||
"status_paired_count": "Gekoppelte Geräte",
|
"status_paired_count": "Gekoppelte Geräte",
|
||||||
"status_pin_pending": "Kopplungs-PIN ausstehend",
|
"status_pin_pending": "Kopplungs-PIN ausstehend",
|
||||||
"stream_codec": "Codec",
|
"stream_codec": "Codec",
|
||||||
"stream_resolution": "Auflösung",
|
"stream_resolution": "Auflösung",
|
||||||
"stream_fps": "Bildrate",
|
"stream_fps": "Bildrate",
|
||||||
"stream_bitrate": "Bitrate",
|
"stream_bitrate": "Bitrate",
|
||||||
"action_stop_session": "Sitzung beenden",
|
"action_stop_session": "Sitzung beenden",
|
||||||
"action_request_idr": "Keyframe anfordern",
|
"action_request_idr": "Keyframe anfordern",
|
||||||
"action_unpair": "Entkoppeln",
|
"action_unpair": "Entkoppeln",
|
||||||
"host_identity": "Identität",
|
"host_identity": "Identität",
|
||||||
"host_hostname": "Hostname",
|
"host_hostname": "Hostname",
|
||||||
"host_local_ip": "Lokale IP",
|
"host_local_ip": "Lokale IP",
|
||||||
"host_version": "Version",
|
"host_version": "Version",
|
||||||
"host_abi": "ABI-Version",
|
"host_abi": "ABI-Version",
|
||||||
"host_codecs": "Codecs",
|
"host_codecs": "Codecs",
|
||||||
"host_ports": "Ports",
|
"host_ports": "Ports",
|
||||||
"host_uniqueid": "Eindeutige ID",
|
"host_uniqueid": "Eindeutige ID",
|
||||||
"host_compositors": "Compositoren",
|
"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.",
|
"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_available": "Verfügbar",
|
||||||
"compositor_unavailable": "Nicht verfügbar",
|
"compositor_unavailable": "Nicht verfügbar",
|
||||||
"compositor_default": "Standard",
|
"compositor_default": "Standard",
|
||||||
"clients_title": "Gekoppelte Geräte",
|
"clients_title": "Gekoppelte Geräte",
|
||||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
"clients_fingerprint": "Fingerabdruck",
|
"clients_fingerprint": "Fingerabdruck",
|
||||||
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||||
"pairing_title": "Kopplung",
|
"pairing_title": "Kopplung",
|
||||||
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
|
"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_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
|
||||||
"pairing_pin_label": "PIN",
|
"pairing_pin_label": "PIN",
|
||||||
"pairing_submit": "PIN bestätigen",
|
"pairing_submit": "PIN bestätigen",
|
||||||
"pairing_success": "Erfolgreich gekoppelt.",
|
"pairing_success": "Erfolgreich gekoppelt.",
|
||||||
"pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.",
|
"pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.",
|
||||||
"pairing_native_title": "Gerät koppeln",
|
"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_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_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_arm": "Gerät koppeln",
|
||||||
"pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:",
|
"pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:",
|
||||||
"pairing_native_expires": "Läuft ab in",
|
"pairing_native_expires": "Läuft ab in",
|
||||||
"pairing_native_cancel": "Abbrechen",
|
"pairing_native_cancel": "Abbrechen",
|
||||||
"pairing_native_devices": "Gekoppelte Geräte",
|
"pairing_native_devices": "Gekoppelte Geräte",
|
||||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
"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_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||||
"pairing_pending_title": "Warten auf Freigabe",
|
"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_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||||
"pairing_pending_approve": "Freigeben",
|
"pairing_pending_approve": "Freigeben",
|
||||||
"pairing_pending_deny": "Ablehnen",
|
"pairing_pending_deny": "Ablehnen",
|
||||||
"pairing_pending_name_prompt": "Gerät benennen:",
|
"pairing_pending_name_prompt": "Gerät benennen:",
|
||||||
"pairing_pending_age_just_now": "gerade eben",
|
"pairing_pending_age_just_now": "gerade eben",
|
||||||
"pairing_pending_age_secs": "vor {s}s",
|
"pairing_pending_age_secs": "vor {s}s",
|
||||||
"pairing_pending_age_mins": "vor {min} min",
|
"pairing_pending_age_mins": "vor {min} min",
|
||||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||||
"library_title": "Bibliothek",
|
"library_title": "Bibliothek",
|
||||||
"library_empty": "Noch keine Spiele gefunden.",
|
"library_empty": "Noch keine Spiele gefunden.",
|
||||||
"library_store_steam": "Steam",
|
"library_store_steam": "Steam",
|
||||||
"library_store_custom": "Eigene",
|
"library_store_custom": "Eigene",
|
||||||
"library_add_title": "Eigenes Spiel hinzufügen",
|
"library_add_title": "Eigenes Spiel hinzufügen",
|
||||||
"library_edit_title": "Eigenes Spiel bearbeiten",
|
"library_edit_title": "Eigenes Spiel bearbeiten",
|
||||||
"library_add_button": "Eigenes Spiel hinzufügen",
|
"library_add_button": "Eigenes Spiel hinzufügen",
|
||||||
"library_field_title": "Titel",
|
"library_field_title": "Titel",
|
||||||
"library_field_portrait": "Portrait-Bild-URL",
|
"library_field_portrait": "Portrait-Bild-URL",
|
||||||
"library_field_hero": "Hero-Bild-URL",
|
"library_field_hero": "Hero-Bild-URL",
|
||||||
"library_field_header": "Header-Bild-URL",
|
"library_field_header": "Header-Bild-URL",
|
||||||
"library_field_command": "Startbefehl",
|
"library_field_command": "Startbefehl",
|
||||||
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
|
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
|
||||||
"library_save": "Speichern",
|
"library_save": "Speichern",
|
||||||
"library_create": "Hinzufügen",
|
"library_create": "Hinzufügen",
|
||||||
"library_cancel": "Abbrechen",
|
"library_cancel": "Abbrechen",
|
||||||
"library_edit": "Bearbeiten",
|
"library_edit": "Bearbeiten",
|
||||||
"library_delete": "Löschen",
|
"library_delete": "Löschen",
|
||||||
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
|
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
|
||||||
"settings_title": "Einstellungen",
|
"settings_title": "Einstellungen",
|
||||||
"settings_token_label": "API-Token",
|
"settings_token_label": "API-Token",
|
||||||
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
||||||
"settings_language": "Sprache",
|
"settings_language": "Sprache",
|
||||||
"settings_save": "Speichern",
|
"settings_save": "Speichern",
|
||||||
"settings_saved": "Gespeichert.",
|
"settings_saved": "Gespeichert.",
|
||||||
"common_loading": "Wird geladen…",
|
"common_loading": "Wird geladen…",
|
||||||
"common_error": "Etwas ist schiefgelaufen.",
|
"common_error": "Etwas ist schiefgelaufen.",
|
||||||
"common_retry": "Erneut versuchen",
|
"common_retry": "Erneut versuchen",
|
||||||
"common_yes": "Ja",
|
"common_yes": "Ja",
|
||||||
"common_cancel": "Abbrechen",
|
"common_cancel": "Abbrechen",
|
||||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||||
"login_title": "Anmelden",
|
"login_title": "Anmelden",
|
||||||
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.",
|
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.",
|
||||||
"login_password": "Passwort",
|
"login_password": "Passwort",
|
||||||
"login_submit": "Anmelden",
|
"login_submit": "Anmelden",
|
||||||
"login_error": "Falsches Passwort.",
|
"login_error": "Falsches Passwort.",
|
||||||
"login_signing_in": "Anmeldung läuft…",
|
"login_signing_in": "Anmeldung läuft…",
|
||||||
"action_logout": "Abmelden"
|
"action_logout": "Abmelden"
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-107
@@ -1,109 +1,109 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"app_name": "punktfunk",
|
"app_name": "punktfunk",
|
||||||
"app_tagline": "management console",
|
"app_tagline": "management console",
|
||||||
"nav_dashboard": "Dashboard",
|
"nav_dashboard": "Dashboard",
|
||||||
"nav_host": "Host",
|
"nav_host": "Host",
|
||||||
"nav_clients": "Paired clients",
|
"nav_clients": "Paired clients",
|
||||||
"nav_pairing": "Pairing",
|
"nav_pairing": "Pairing",
|
||||||
"nav_library": "Library",
|
"nav_library": "Library",
|
||||||
"nav_settings": "Settings",
|
"nav_settings": "Settings",
|
||||||
"status_title": "Live status",
|
"status_title": "Live status",
|
||||||
"status_video": "Video",
|
"status_video": "Video",
|
||||||
"status_audio": "Audio",
|
"status_audio": "Audio",
|
||||||
"status_streaming": "Streaming",
|
"status_streaming": "Streaming",
|
||||||
"status_idle": "Idle",
|
"status_idle": "Idle",
|
||||||
"status_session": "Session",
|
"status_session": "Session",
|
||||||
"status_no_session": "No active session",
|
"status_no_session": "No active session",
|
||||||
"status_paired_count": "Paired clients",
|
"status_paired_count": "Paired clients",
|
||||||
"status_pin_pending": "Pairing PIN pending",
|
"status_pin_pending": "Pairing PIN pending",
|
||||||
"stream_codec": "Codec",
|
"stream_codec": "Codec",
|
||||||
"stream_resolution": "Resolution",
|
"stream_resolution": "Resolution",
|
||||||
"stream_fps": "Frame rate",
|
"stream_fps": "Frame rate",
|
||||||
"stream_bitrate": "Bitrate",
|
"stream_bitrate": "Bitrate",
|
||||||
"action_stop_session": "Stop session",
|
"action_stop_session": "Stop session",
|
||||||
"action_request_idr": "Request keyframe",
|
"action_request_idr": "Request keyframe",
|
||||||
"action_unpair": "Unpair",
|
"action_unpair": "Unpair",
|
||||||
"host_identity": "Identity",
|
"host_identity": "Identity",
|
||||||
"host_hostname": "Hostname",
|
"host_hostname": "Hostname",
|
||||||
"host_local_ip": "Local IP",
|
"host_local_ip": "Local IP",
|
||||||
"host_version": "Version",
|
"host_version": "Version",
|
||||||
"host_abi": "ABI version",
|
"host_abi": "ABI version",
|
||||||
"host_codecs": "Codecs",
|
"host_codecs": "Codecs",
|
||||||
"host_ports": "Ports",
|
"host_ports": "Ports",
|
||||||
"host_uniqueid": "Unique ID",
|
"host_uniqueid": "Unique ID",
|
||||||
"host_compositors": "Compositors",
|
"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.",
|
"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_available": "Available",
|
||||||
"compositor_unavailable": "Unavailable",
|
"compositor_unavailable": "Unavailable",
|
||||||
"compositor_default": "Default",
|
"compositor_default": "Default",
|
||||||
"clients_title": "Paired clients",
|
"clients_title": "Paired clients",
|
||||||
"clients_empty": "No paired clients yet.",
|
"clients_empty": "No paired clients yet.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
"clients_fingerprint": "Fingerprint",
|
"clients_fingerprint": "Fingerprint",
|
||||||
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
|
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
|
||||||
"pairing_title": "Pairing",
|
"pairing_title": "Pairing",
|
||||||
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
|
"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_waiting": "A client is waiting to pair. Enter the PIN it shows:",
|
||||||
"pairing_pin_label": "PIN",
|
"pairing_pin_label": "PIN",
|
||||||
"pairing_submit": "Submit PIN",
|
"pairing_submit": "Submit PIN",
|
||||||
"pairing_success": "Paired successfully.",
|
"pairing_success": "Paired successfully.",
|
||||||
"pairing_failed": "Pairing failed — check the PIN and try again.",
|
"pairing_failed": "Pairing failed — check the PIN and try again.",
|
||||||
"pairing_native_title": "Pair a device",
|
"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_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_disabled": "The native host isn't running. Start it with `serve --native` to pair punktfunk devices.",
|
||||||
"pairing_native_arm": "Pair a device",
|
"pairing_native_arm": "Pair a device",
|
||||||
"pairing_native_enter": "Enter this PIN on your device:",
|
"pairing_native_enter": "Enter this PIN on your device:",
|
||||||
"pairing_native_expires": "Expires in",
|
"pairing_native_expires": "Expires in",
|
||||||
"pairing_native_cancel": "Cancel",
|
"pairing_native_cancel": "Cancel",
|
||||||
"pairing_native_devices": "Paired devices",
|
"pairing_native_devices": "Paired devices",
|
||||||
"pairing_native_empty": "No devices paired yet.",
|
"pairing_native_empty": "No devices paired yet.",
|
||||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||||
"pairing_pending_title": "Waiting for approval",
|
"pairing_pending_title": "Waiting for approval",
|
||||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||||
"pairing_pending_approve": "Approve",
|
"pairing_pending_approve": "Approve",
|
||||||
"pairing_pending_deny": "Deny",
|
"pairing_pending_deny": "Deny",
|
||||||
"pairing_pending_name_prompt": "Name this device:",
|
"pairing_pending_name_prompt": "Name this device:",
|
||||||
"pairing_pending_age_just_now": "just now",
|
"pairing_pending_age_just_now": "just now",
|
||||||
"pairing_pending_age_secs": "{s}s ago",
|
"pairing_pending_age_secs": "{s}s ago",
|
||||||
"pairing_pending_age_mins": "{min} min ago",
|
"pairing_pending_age_mins": "{min} min ago",
|
||||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||||
"library_title": "Library",
|
"library_title": "Library",
|
||||||
"library_empty": "No games found yet.",
|
"library_empty": "No games found yet.",
|
||||||
"library_store_steam": "Steam",
|
"library_store_steam": "Steam",
|
||||||
"library_store_custom": "Custom",
|
"library_store_custom": "Custom",
|
||||||
"library_add_title": "Add a custom game",
|
"library_add_title": "Add a custom game",
|
||||||
"library_edit_title": "Edit custom game",
|
"library_edit_title": "Edit custom game",
|
||||||
"library_add_button": "Add custom game",
|
"library_add_button": "Add custom game",
|
||||||
"library_field_title": "Title",
|
"library_field_title": "Title",
|
||||||
"library_field_portrait": "Portrait art URL",
|
"library_field_portrait": "Portrait art URL",
|
||||||
"library_field_hero": "Hero art URL",
|
"library_field_hero": "Hero art URL",
|
||||||
"library_field_header": "Header art URL",
|
"library_field_header": "Header art URL",
|
||||||
"library_field_command": "Launch command",
|
"library_field_command": "Launch command",
|
||||||
"library_field_command_help": "Optional. The command the host runs to launch this title.",
|
"library_field_command_help": "Optional. The command the host runs to launch this title.",
|
||||||
"library_save": "Save",
|
"library_save": "Save",
|
||||||
"library_create": "Add",
|
"library_create": "Add",
|
||||||
"library_cancel": "Cancel",
|
"library_cancel": "Cancel",
|
||||||
"library_edit": "Edit",
|
"library_edit": "Edit",
|
||||||
"library_delete": "Delete",
|
"library_delete": "Delete",
|
||||||
"library_delete_confirm": "Delete this custom game? This can't be undone.",
|
"library_delete_confirm": "Delete this custom game? This can't be undone.",
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"settings_token_label": "API token",
|
"settings_token_label": "API token",
|
||||||
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
||||||
"settings_language": "Language",
|
"settings_language": "Language",
|
||||||
"settings_save": "Save",
|
"settings_save": "Save",
|
||||||
"settings_saved": "Saved.",
|
"settings_saved": "Saved.",
|
||||||
"common_loading": "Loading…",
|
"common_loading": "Loading…",
|
||||||
"common_error": "Something went wrong.",
|
"common_error": "Something went wrong.",
|
||||||
"common_retry": "Retry",
|
"common_retry": "Retry",
|
||||||
"common_yes": "Yes",
|
"common_yes": "Yes",
|
||||||
"common_cancel": "Cancel",
|
"common_cancel": "Cancel",
|
||||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||||
"login_title": "Sign in",
|
"login_title": "Sign in",
|
||||||
"login_subtitle": "Enter the management password to continue.",
|
"login_subtitle": "Enter the management password to continue.",
|
||||||
"login_password": "Password",
|
"login_password": "Password",
|
||||||
"login_submit": "Sign in",
|
"login_submit": "Sign in",
|
||||||
"login_error": "Wrong password.",
|
"login_error": "Wrong password.",
|
||||||
"login_signing_in": "Signing in…",
|
"login_signing_in": "Signing in…",
|
||||||
"action_logout": "Sign out"
|
"action_logout": "Sign out"
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-27
@@ -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.
|
// Generates a typed React Query client from the host's checked-in OpenAPI document.
|
||||||
// Regenerate after any management-API change: `pnpm api:gen` (the Rust side regenerates
|
// Regenerate after any management-API change: `pnpm api:gen` (the Rust side regenerates
|
||||||
// docs/api/openapi.json via `cargo run -p punktfunk-host -- openapi`).
|
// docs/api/openapi.json via `cargo run -p punktfunk-host -- openapi`).
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
punktfunk: {
|
punktfunk: {
|
||||||
input: {
|
input: {
|
||||||
target: '../docs/api/openapi.json',
|
target: "../docs/api/openapi.json",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
mode: 'tags-split',
|
mode: "tags-split",
|
||||||
target: './src/api/gen',
|
target: "./src/api/gen",
|
||||||
schemas: './src/api/gen/model',
|
schemas: "./src/api/gen/model",
|
||||||
client: 'react-query',
|
client: "react-query",
|
||||||
clean: true,
|
clean: true,
|
||||||
override: {
|
override: {
|
||||||
mutator: {
|
mutator: {
|
||||||
path: './src/api/fetcher.ts',
|
path: "./src/api/fetcher.ts",
|
||||||
name: 'apiFetch',
|
name: "apiFetch",
|
||||||
},
|
},
|
||||||
// The mutator returns the response BODY (it throws on HTTP errors), not a
|
// The mutator returns the response BODY (it throws on HTTP errors), not a
|
||||||
// `{status,data,headers}` envelope — so a query's `.data` is the typed payload.
|
// `{status,data,headers}` envelope — so a query's `.data` is the typed payload.
|
||||||
fetch: {
|
fetch: {
|
||||||
includeHttpResponseReturnType: false,
|
includeHttpResponseReturnType: false,
|
||||||
},
|
},
|
||||||
// No global query/mutation override: orval picks `useQuery` for GET and
|
// No global query/mutation override: orval picks `useQuery` for GET and
|
||||||
// `useMutation` for POST/DELETE by HTTP method, which is what the pages expect.
|
// `useMutation` for POST/DELETE by HTTP method, which is what the pages expect.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
+52
-51
@@ -1,53 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "punktfunk-web",
|
"name": "punktfunk-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
|
"description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "bun run codegen",
|
"prepare": "bun run codegen",
|
||||||
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide",
|
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide",
|
||||||
"predev": "orval --config orval.config.ts",
|
"predev": "orval --config orval.config.ts",
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000",
|
||||||
"prebuild": "orval --config orval.config.ts",
|
"prebuild": "orval --config orval.config.ts",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "bun run .output/server/index.mjs",
|
"start": "bun run .output/server/index.mjs",
|
||||||
"api:gen": "orval --config orval.config.ts",
|
"api:gen": "orval --config orval.config.ts",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.121.0",
|
"@tanstack/react-router": "^1.121.0",
|
||||||
"@tanstack/react-start": "^1.121.0",
|
"@tanstack/react-start": "^1.121.0",
|
||||||
"@unom/style": "^0.4.4",
|
"@unom/style": "^0.4.4",
|
||||||
"@unom/ui": "^0.8.16",
|
"@unom/ui": "^0.8.16",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"motion": "^12.40.0",
|
"motion": "^12.40.0",
|
||||||
"radix-ui": "^1.6.0",
|
"radix-ui": "^1.6.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inlang/paraglide-js": "^2.0.0",
|
"@biomejs/biome": "^2.5.1",
|
||||||
"@storybook/react-vite": "^10.4.6",
|
"@inlang/paraglide-js": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@storybook/react-vite": "^10.4.6",
|
||||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5",
|
"@types/react-dom": "^19.0.0",
|
||||||
"orval": "^8.16.0",
|
"@vitejs/plugin-react": "^5",
|
||||||
"storybook": "^10.4.6",
|
"orval": "^8.16.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"storybook": "^10.4.6",
|
||||||
"tw-animate-css": "^1.2.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0",
|
"tw-animate-css": "^1.2.0",
|
||||||
"vite": "^7.3.5",
|
"typescript": "^5.7.0",
|
||||||
"vite-tsconfig-paths": "^5.1.0"
|
"vite": "^7.3.5",
|
||||||
}
|
"vite-tsconfig-paths": "^5.1.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,41 @@
|
|||||||
// (pages, the /api proxy, everything) before routing. Unauthenticated requests are
|
// (pages, the /api proxy, everything) before routing. Unauthenticated requests are
|
||||||
// redirected to /login (page navigations) or rejected 401 (/api). Fails CLOSED if
|
// redirected to /login (page navigations) or rejected 401 (/api). Fails CLOSED if
|
||||||
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
|
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
|
||||||
import { defineEventHandler, getRequestURL, sendRedirect, setResponseStatus, useSession } from 'h3'
|
import {
|
||||||
import { isPublicPath, sessionConfig, uiPassword, type SessionData } from '../util/auth'
|
defineEventHandler,
|
||||||
|
getRequestURL,
|
||||||
|
sendRedirect,
|
||||||
|
setResponseStatus,
|
||||||
|
useSession,
|
||||||
|
} from "h3";
|
||||||
|
import {
|
||||||
|
isPublicPath,
|
||||||
|
sessionConfig,
|
||||||
|
uiPassword,
|
||||||
|
type SessionData,
|
||||||
|
} from "../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { pathname } = getRequestURL(event)
|
const { pathname } = getRequestURL(event);
|
||||||
if (isPublicPath(pathname)) return
|
if (isPublicPath(pathname)) return;
|
||||||
|
|
||||||
// Misconfigured: refuse everything rather than serve open on the LAN.
|
// Misconfigured: refuse everything rather than serve open on the LAN.
|
||||||
if (!uiPassword()) {
|
if (!uiPassword()) {
|
||||||
setResponseStatus(event, 503)
|
setResponseStatus(event, 503);
|
||||||
return { error: 'auth not configured: set PUNKTFUNK_UI_PASSWORD' }
|
return { error: "auth not configured: set PUNKTFUNK_UI_PASSWORD" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await useSession<SessionData>(event, sessionConfig())
|
const session = await useSession<SessionData>(event, sessionConfig());
|
||||||
if (session.data.authenticated) return // authenticated — let it through
|
if (session.data.authenticated) return; // authenticated — let it through
|
||||||
|
|
||||||
if (pathname.startsWith('/api')) {
|
if (pathname.startsWith("/api")) {
|
||||||
setResponseStatus(event, 401)
|
setResponseStatus(event, 401);
|
||||||
return { error: 'unauthorized' }
|
return { error: "unauthorized" };
|
||||||
}
|
}
|
||||||
// Page navigation → bounce to the login screen, remembering where they were headed.
|
// Page navigation → bounce to the login screen, remembering where they were headed.
|
||||||
return sendRedirect(event, `/login?next=${encodeURIComponent(pathname)}`, 302)
|
return sendRedirect(
|
||||||
})
|
event,
|
||||||
|
`/login?next=${encodeURIComponent(pathname)}`,
|
||||||
|
302,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an
|
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an
|
||||||
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
|
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
|
||||||
// can actually log in.
|
// can actually log in.
|
||||||
import { defineEventHandler, readBody, createError, useSession } from 'h3'
|
import { defineEventHandler, readBody, createError, useSession } from "h3";
|
||||||
import { sessionConfig, timingSafeEqual, uiPassword, type SessionData } from '../../util/auth'
|
import {
|
||||||
|
sessionConfig,
|
||||||
|
timingSafeEqual,
|
||||||
|
uiPassword,
|
||||||
|
type SessionData,
|
||||||
|
} from "../../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const expected = uiPassword()
|
const expected = uiPassword();
|
||||||
if (!expected) {
|
if (!expected) {
|
||||||
throw createError({ statusCode: 503, statusMessage: 'auth not configured' })
|
throw createError({
|
||||||
}
|
statusCode: 503,
|
||||||
const body = await readBody<{ password?: string }>(event)
|
statusMessage: "auth not configured",
|
||||||
const password = String(body?.password ?? '')
|
});
|
||||||
if (!timingSafeEqual(password, expected)) {
|
}
|
||||||
throw createError({ statusCode: 401, statusMessage: 'invalid password' })
|
const body = await readBody<{ password?: string }>(event);
|
||||||
}
|
const password = String(body?.password ?? "");
|
||||||
const session = await useSession<SessionData>(event, sessionConfig())
|
if (!timingSafeEqual(password, expected)) {
|
||||||
await session.update({ authenticated: true })
|
throw createError({ statusCode: 401, statusMessage: "invalid password" });
|
||||||
return { ok: true }
|
}
|
||||||
})
|
const session = await useSession<SessionData>(event, sessionConfig());
|
||||||
|
await session.update({ authenticated: true });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// POST /_auth/logout — clear the session cookie.
|
// POST /_auth/logout — clear the session cookie.
|
||||||
import { defineEventHandler, useSession } from 'h3'
|
import { defineEventHandler, useSession } from "h3";
|
||||||
import { sessionConfig, type SessionData } from '../../util/auth'
|
import { sessionConfig, type SessionData } from "../../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await useSession<SessionData>(event, sessionConfig())
|
const session = await useSession<SessionData>(event, sessionConfig());
|
||||||
await session.clear()
|
await session.clear();
|
||||||
return { ok: true }
|
return { ok: true };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -3,26 +3,34 @@
|
|||||||
// (the browser never sees it) and drop the browser's own cookies/auth from the upstream
|
// (the browser never sees it) and drop the browser's own cookies/auth from the upstream
|
||||||
// request, then proxy. The management API itself binds loopback only — this proxy is the
|
// request, then proxy. The management API itself binds loopback only — this proxy is the
|
||||||
// ONLY path to it from the LAN, and it's authenticated.
|
// ONLY path to it from the LAN, and it's authenticated.
|
||||||
import { defineEventHandler, getRequestURL, proxyRequest, setResponseStatus } from 'h3'
|
import {
|
||||||
import { mgmtToken, mgmtUrl } from '../../util/auth'
|
defineEventHandler,
|
||||||
|
getRequestURL,
|
||||||
|
proxyRequest,
|
||||||
|
setResponseStatus,
|
||||||
|
} from "h3";
|
||||||
|
import { mgmtToken, mgmtUrl } from "../../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { pathname, search } = getRequestURL(event)
|
const { pathname, search } = getRequestURL(event);
|
||||||
const target = `${mgmtUrl()}${pathname}${search}`
|
const target = `${mgmtUrl()}${pathname}${search}`;
|
||||||
const token = mgmtToken()
|
const token = mgmtToken();
|
||||||
// The mgmt API now requires a token always. Without one configured, forwarding an empty bearer
|
// The mgmt API now requires a token always. Without one configured, forwarding an empty bearer
|
||||||
// would just bounce as 401 — fail fast and legibly instead (the packaged service sources the
|
// would just bounce as 401 — fail fast and legibly instead (the packaged service sources the
|
||||||
// host's ~/.config/punktfunk/mgmt-token, so this only fires on a misconfigured/early-start deploy).
|
// host's ~/.config/punktfunk/mgmt-token, so this only fires on a misconfigured/early-start deploy).
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setResponseStatus(event, 503)
|
setResponseStatus(event, 503);
|
||||||
return { error: 'management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)' }
|
return {
|
||||||
}
|
error:
|
||||||
return proxyRequest(event, target, {
|
"management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)",
|
||||||
headers: {
|
};
|
||||||
// Overwrite, not append: the host-held token replaces anything the browser sent.
|
}
|
||||||
authorization: `Bearer ${token}`,
|
return proxyRequest(event, target, {
|
||||||
// Don't forward the session cookie to the management API.
|
headers: {
|
||||||
cookie: '',
|
// 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: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+45
-39
@@ -4,26 +4,29 @@
|
|||||||
//
|
//
|
||||||
// The management token never reaches the browser: server/routes/api/[...].ts injects it
|
// The management token never reaches the browser: server/routes/api/[...].ts injects it
|
||||||
// server-side when proxying to the loopback management API.
|
// server-side when proxying to the loopback management API.
|
||||||
import { createHash, timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'
|
import {
|
||||||
import type { SessionConfig } from 'h3'
|
createHash,
|
||||||
|
timingSafeEqual as nodeTimingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import type { SessionConfig } from "h3";
|
||||||
|
|
||||||
export const SESSION_NAME = 'pf_session'
|
export const SESSION_NAME = "pf_session";
|
||||||
|
|
||||||
/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */
|
/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */
|
||||||
export function uiPassword(): string {
|
export function uiPassword(): string {
|
||||||
return process.env.PUNKTFUNK_UI_PASSWORD ?? ''
|
return process.env.PUNKTFUNK_UI_PASSWORD ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The management API the proxy forwards to (loopback by default — never LAN-exposed). It serves
|
/** The management API the proxy forwards to (loopback by default — never LAN-exposed). It serves
|
||||||
* HTTPS with the host's self-signed identity cert, so the deployment also sets
|
* HTTPS with the host's self-signed identity cert, so the deployment also sets
|
||||||
* NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch — see .env.example. */
|
* NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch — see .env.example. */
|
||||||
export function mgmtUrl(): string {
|
export function mgmtUrl(): string {
|
||||||
return process.env.PUNKTFUNK_MGMT_URL ?? 'https://127.0.0.1:47990'
|
return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bearer token for the management API, injected server-side. */
|
/** Bearer token for the management API, injected server-side. */
|
||||||
export function mgmtToken(): string {
|
export function mgmtToken(): string {
|
||||||
return process.env.PUNKTFUNK_MGMT_TOKEN ?? ''
|
return process.env.PUNKTFUNK_MGMT_TOKEN ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,34 +35,37 @@ export function mgmtToken(): string {
|
|||||||
* (changing the password then invalidates existing sessions, which is fine).
|
* (changing the password then invalidates existing sessions, which is fine).
|
||||||
*/
|
*/
|
||||||
export function sessionConfig(): SessionConfig {
|
export function sessionConfig(): SessionConfig {
|
||||||
const secret = process.env.PUNKTFUNK_UI_SECRET
|
const secret = process.env.PUNKTFUNK_UI_SECRET;
|
||||||
const password = secret && secret.length >= 32
|
const password =
|
||||||
? secret
|
secret && secret.length >= 32
|
||||||
: createHash('sha256').update(`punktfunk-session-v1:${uiPassword()}`).digest('hex')
|
? secret
|
||||||
return {
|
: createHash("sha256")
|
||||||
name: SESSION_NAME,
|
.update(`punktfunk-session-v1:${uiPassword()}`)
|
||||||
password,
|
.digest("hex");
|
||||||
// Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron
|
return {
|
||||||
// seal TTL). 7 days for a single-user console.
|
name: SESSION_NAME,
|
||||||
maxAge: 60 * 60 * 24 * 7,
|
password,
|
||||||
cookie: {
|
// Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron
|
||||||
httpOnly: true,
|
// seal TTL). 7 days for a single-user console.
|
||||||
sameSite: 'lax',
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
path: '/',
|
cookie: {
|
||||||
// h3 defaults Secure to true, which browsers DROP over plain http:// (so login
|
httpOnly: true,
|
||||||
// silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS
|
sameSite: "lax",
|
||||||
// (set PUNKTFUNK_UI_SECURE=1 / =true then).
|
path: "/",
|
||||||
secure: /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ''),
|
// 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). */
|
/** Constant-time string comparison (avoids leaking the password via timing). */
|
||||||
export function timingSafeEqual(a: string, b: string): boolean {
|
export function timingSafeEqual(a: string, b: string): boolean {
|
||||||
const ab = Buffer.from(a)
|
const ab = Buffer.from(a);
|
||||||
const bb = Buffer.from(b)
|
const bb = Buffer.from(b);
|
||||||
if (ab.length !== bb.length) return false
|
if (ab.length !== bb.length) return false;
|
||||||
return nodeTimingSafeEqual(ab, bb)
|
return nodeTimingSafeEqual(ab, bb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paths reachable WITHOUT a session: the login page, the auth endpoints, and the build's
|
/** Paths reachable WITHOUT a session: the login page, the auth endpoints, and the build's
|
||||||
@@ -70,21 +76,21 @@ export function timingSafeEqual(a: string, b: string): boolean {
|
|||||||
* generic `*.json` allowlist would expose `/api/v1/openapi.json` (and any future
|
* generic `*.json` allowlist would expose `/api/v1/openapi.json` (and any future
|
||||||
* `.json`/`.png` management route) through the proxy unauthenticated. */
|
* `.json`/`.png` management route) through the proxy unauthenticated. */
|
||||||
export function isPublicPath(pathname: string): boolean {
|
export function isPublicPath(pathname: string): boolean {
|
||||||
if (pathname === '/api' || pathname.startsWith('/api/')) return false // always gated
|
if (pathname === "/api" || pathname.startsWith("/api/")) return false; // always gated
|
||||||
if (pathname === '/login') return true
|
if (pathname === "/login") return true;
|
||||||
if (pathname.startsWith('/_auth/')) return true
|
if (pathname.startsWith("/_auth/")) return true;
|
||||||
if (pathname.startsWith('/assets/')) return true
|
if (pathname.startsWith("/assets/")) return true;
|
||||||
if (pathname === '/favicon.ico' || pathname === '/robots.txt') return true
|
if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
|
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
|
||||||
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
|
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
|
||||||
export function safeNextPath(next: string | undefined): string {
|
export function safeNextPath(next: string | undefined): string {
|
||||||
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/'
|
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
|
||||||
return next
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
authenticated?: boolean
|
authenticated?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-27
@@ -9,43 +9,50 @@
|
|||||||
|
|
||||||
/** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */
|
/** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number;
|
||||||
data: unknown
|
data: unknown;
|
||||||
constructor(status: number, data: unknown, message?: string) {
|
constructor(status: number, data: unknown, message?: string) {
|
||||||
super(message ?? `API error ${status}`)
|
super(message ?? `API error ${status}`);
|
||||||
this.name = 'ApiError'
|
this.name = "ApiError";
|
||||||
this.status = status
|
this.status = status;
|
||||||
this.data = data
|
this.data = data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(
|
||||||
const headers = new Headers(options?.headers)
|
url: string,
|
||||||
headers.set('Accept', 'application/json')
|
options?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers = new Headers(options?.headers);
|
||||||
|
headers.set("Accept", "application/json");
|
||||||
|
|
||||||
const res = await fetch(url, { ...options, headers, credentials: 'same-origin' })
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
const text = await res.text()
|
const text = await res.text();
|
||||||
const body = text ? safeJson(text) : undefined
|
const body = text ? safeJson(text) : undefined;
|
||||||
if (res.status === 401) redirectToLogin()
|
if (res.status === 401) redirectToLogin();
|
||||||
if (!res.ok) throw new ApiError(res.status, body, res.statusText)
|
if (!res.ok) throw new ApiError(res.status, body, res.statusText);
|
||||||
return body as T
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** On lost session, send the user to the login screen, remembering where they were. */
|
/** On lost session, send the user to the login screen, remembering where they were. */
|
||||||
function redirectToLogin(): void {
|
function redirectToLogin(): void {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === "undefined") return;
|
||||||
if (window.location.pathname === '/login') return
|
if (window.location.pathname === "/login") return;
|
||||||
const next = encodeURIComponent(window.location.pathname)
|
const next = encodeURIComponent(window.location.pathname);
|
||||||
window.location.href = `/login?next=${next}`
|
window.location.href = `/login?next=${next}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeJson(text: string): unknown {
|
function safeJson(text: string): unknown {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text)
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
return text
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apiFetch
|
export default apiFetch;
|
||||||
|
|||||||
+144
-103
@@ -1,115 +1,156 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Link } from '@tanstack/react-router'
|
import {
|
||||||
import { Activity, Server, Users, KeyRound, LibraryBig, Settings } from 'lucide-react'
|
Activity,
|
||||||
import { BrandMark } from '@/components/brand-mark'
|
KeyRound,
|
||||||
import { Wordmark } from '@/components/wordmark'
|
LibraryBig,
|
||||||
import { m } from '@/paraglide/messages'
|
Server,
|
||||||
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
|
Settings,
|
||||||
import { cn } from '@/lib/utils'
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion, stagger } from "motion/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
|
import { Wordmark } from "@/components/wordmark";
|
||||||
|
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
const MLink = motion(Link);
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/', icon: Activity, label: () => m.nav_dashboard() },
|
{ to: "/", icon: Activity, label: () => m.nav_dashboard() },
|
||||||
{ to: '/host', icon: Server, label: () => m.nav_host() },
|
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
||||||
{ to: '/library', icon: LibraryBig, label: () => m.nav_library() },
|
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||||
{ to: '/clients', icon: Users, label: () => m.nav_clients() },
|
{ to: "/clients", icon: Users, label: () => m.nav_clients() },
|
||||||
{ to: '/pairing', icon: KeyRound, label: () => m.nav_pairing() },
|
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||||
{ to: '/settings', icon: Settings, label: () => m.nav_settings() },
|
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
|
// Staggered entrance for the sidebar nav: each item fans in from the left a beat
|
||||||
|
// after the previous. Per-item delays (rather than a parent stagger) keep every
|
||||||
|
// item independent, so none can be left mid-orchestration / invisible.
|
||||||
|
const NAV_ENTER_DELAY = 0.08;
|
||||||
|
const NAV_ENTER_STEP = 0.06;
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
// Read the locale so the whole shell re-renders on a language switch.
|
// Read the locale so the whole shell re-renders on a language switch.
|
||||||
useLocale()
|
useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Desktop sidebar (≥ sm). */}
|
{/* Desktop sidebar (≥ sm). */}
|
||||||
<aside className="hidden w-60 shrink-0 flex-col border-r bg-card/40 p-4 sm:flex">
|
<aside className="hidden w-60 shrink-0 flex-col border-r bg-card/40 p-4 sm:flex">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
aria-label="punktfunk"
|
aria-label="punktfunk"
|
||||||
className="mb-7 flex items-center gap-2 px-2 pt-1"
|
className="mb-7 flex items-center gap-2 px-2 pt-1"
|
||||||
>
|
>
|
||||||
<BrandMark className="size-7 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" />
|
<BrandMark className="size-7 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" />
|
||||||
<Wordmark className="h-4" />
|
<Wordmark className="h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex flex-col gap-1">
|
<motion.nav
|
||||||
{NAV.map(({ to, icon: Icon, label }) => (
|
animate="enter"
|
||||||
<Link
|
initial="from"
|
||||||
key={to}
|
transition={{
|
||||||
to={to}
|
delayChildren: stagger(0.1),
|
||||||
activeOptions={{ exact: to === '/' }}
|
}}
|
||||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
variants={{ enter: {}, from: {} }}
|
||||||
activeProps={{ className: 'bg-primary/15 text-foreground font-medium' }}
|
className="flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
{NAV.map(({ to, icon: Icon, label }, i) => (
|
||||||
{label()}
|
<MLink
|
||||||
</Link>
|
key={to}
|
||||||
))}
|
variants={{
|
||||||
</nav>
|
from: { opacity: 0, x: -20 },
|
||||||
<div className="mt-auto pt-4">
|
enter: { opacity: 1, x: 0 },
|
||||||
<LanguageSwitcher />
|
}}
|
||||||
</div>
|
whileHover={{ scale: 1.02 }}
|
||||||
</aside>
|
whileTap={{ scale: 0.98 }}
|
||||||
|
to={to}
|
||||||
|
activeOptions={{ exact: to === "/" }}
|
||||||
|
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
activeProps={{
|
||||||
|
className: "bg-primary/15 text-foreground font-medium",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Hover brightens: a brand-tinted wash layered OVER whatever the
|
||||||
|
link's background is (transparent or the active tint), so the
|
||||||
|
item gets lighter on hover — including the active one. */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
|
||||||
|
/>
|
||||||
|
<Icon className="relative size-4" />
|
||||||
|
<span className="relative">{label()}</span>
|
||||||
|
</MLink>
|
||||||
|
))}
|
||||||
|
</motion.nav>
|
||||||
|
<div className="mt-auto pt-4">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-x-hidden">
|
<div className="flex flex-1 flex-col overflow-x-hidden">
|
||||||
{/* Mobile top bar (< sm): brand + language. The sidebar is hidden here. */}
|
{/* Mobile top bar (< sm): brand + language. The sidebar is hidden here. */}
|
||||||
<header className="flex items-center gap-2 border-b bg-card/40 px-4 py-3 sm:hidden">
|
<header className="flex items-center gap-2 border-b bg-card/40 px-4 py-3 sm:hidden">
|
||||||
<BrandMark className="size-6" />
|
<BrandMark className="size-6" />
|
||||||
<Wordmark className="h-3.5" />
|
<Wordmark className="h-3.5" />
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* pb-24 leaves room for the fixed bottom nav on mobile. */}
|
{/* pb-24 leaves room for the fixed bottom nav on mobile. */}
|
||||||
<div className="mx-auto max-w-5xl p-6 pb-24 sm:p-10 sm:pb-10">{children}</div>
|
<div className="mx-auto max-w-5xl p-6 pb-24 sm:p-10 sm:pb-10">
|
||||||
</main>
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile bottom tab bar (< sm): the primary navigation on phones. */}
|
{/* Mobile bottom tab bar (< sm): the primary navigation on phones. */}
|
||||||
<nav
|
<nav
|
||||||
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card/95 backdrop-blur sm:hidden"
|
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card/95 backdrop-blur sm:hidden"
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||||
>
|
>
|
||||||
{NAV.map(({ to, icon: Icon, label }) => (
|
{NAV.map(({ to, icon: Icon, label }) => (
|
||||||
<Link
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
activeOptions={{ exact: to === '/' }}
|
activeOptions={{ exact: to === "/" }}
|
||||||
className="flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors"
|
className="flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors"
|
||||||
activeProps={{ className: 'text-[var(--brand-light)]' }}
|
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||||
>
|
>
|
||||||
<Icon className="size-5 shrink-0" />
|
<Icon className="size-5 shrink-0" />
|
||||||
{/* Fixed two-line-tall box so a 1- or 2-line label keeps every icon
|
{/* Fixed two-line-tall box so a 1- or 2-line label keeps every icon
|
||||||
at the same height (the labels vary by locale). */}
|
at the same height (the labels vary by locale). */}
|
||||||
<span className="flex h-7 w-full items-center justify-center text-center text-[10px] leading-tight">
|
<span className="flex h-7 w-full items-center justify-center text-center text-[10px] leading-tight">
|
||||||
{label()}
|
{label()}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LanguageSwitcher() {
|
function LanguageSwitcher() {
|
||||||
const current = useLocale()
|
const current = useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1" role="group" aria-label="Language">
|
<div className="flex gap-1" role="group" aria-label="Language">
|
||||||
{locales.map((l: Locale) => (
|
{locales.map((l: Locale) => (
|
||||||
<button
|
<button
|
||||||
key={l}
|
key={l}
|
||||||
onClick={() => changeLocale(l)}
|
onClick={() => changeLocale(l)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-2 py-1 text-xs uppercase transition-colors',
|
"rounded px-2 py-1 text-xs uppercase transition-colors",
|
||||||
l === current
|
l === current
|
||||||
? 'bg-primary/20 text-foreground font-medium'
|
? "bg-primary/20 text-foreground font-medium"
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{l}
|
{l}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,29 @@
|
|||||||
// verbatim with the marketing site + docs). Back-to-front: large light-violet
|
// verbatim with the marketing site + docs). Back-to-front: large light-violet
|
||||||
// circle, deep-violet circle, light highlight where they overlap.
|
// circle, deep-violet circle, light highlight where they overlap.
|
||||||
export function BrandMark({ className }: { className?: string }) {
|
export function BrandMark({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-label="punktfunk"
|
aria-label="punktfunk"
|
||||||
role="img"
|
role="img"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 1000 1000"
|
viewBox="0 0 1000 1000"
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
<title>punktfunk</title>
|
<title>punktfunk</title>
|
||||||
<path
|
<path
|
||||||
d="M403.037,791.672c107.586,0 194.41,-86.824 194.41,-194.41c0,-107.586 -86.824,-194.41 -194.41,-194.41c-107.586,0 -194.41,86.824 -194.41,194.41c0,107.586 86.824,194.41 194.41,194.41Z"
|
d="M403.037,791.672c107.586,0 194.41,-86.824 194.41,-194.41c0,-107.586 -86.824,-194.41 -194.41,-194.41c-107.586,0 -194.41,86.824 -194.41,194.41c0,107.586 86.824,194.41 194.41,194.41Z"
|
||||||
fill="#a79ff8"
|
fill="#a79ff8"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M735.276,540.321c76.075,-76.075 76.075,-198.862 0,-274.937c-76.075,-76.075 -198.862,-76.075 -274.937,0c-76.075,76.075 -76.075,198.862 0,274.937c76.075,76.075 198.862,76.075 274.937,0Z"
|
d="M735.276,540.321c76.075,-76.075 76.075,-198.862 0,-274.937c-76.075,-76.075 -198.862,-76.075 -274.937,0c-76.075,76.075 -76.075,198.862 0,274.937c76.075,76.075 198.862,76.075 274.937,0Z"
|
||||||
fill="#6c5bf3"
|
fill="#6c5bf3"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M647.84,590.737c-64.853,17.403 -136.871,0.597 -187.885,-50.416c-51.013,-51.013 -67.819,-123.032 -50.416,-187.885c64.853,-17.403 136.871,-0.597 187.885,50.416c51.013,51.013 67.819,123.032 50.416,187.885Z"
|
d="M647.84,590.737c-64.853,17.403 -136.871,0.597 -187.885,-50.416c-51.013,-51.013 -67.819,-123.032 -50.416,-187.885c64.853,-17.403 136.871,-0.597 187.885,50.416c51.013,51.013 67.819,123.032 50.416,187.885Z"
|
||||||
fill="#d2c9fb"
|
fill="#d2c9fb"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BrandMark
|
export default BrandMark;
|
||||||
|
|||||||
+10
-10
@@ -1,17 +1,17 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
import { BrandMark } from './brand-mark'
|
import { BrandMark } from "./brand-mark";
|
||||||
import { Wordmark } from './wordmark'
|
import { Wordmark } from "./wordmark";
|
||||||
|
|
||||||
// Full punktfunk lockup: the lens mark anchored to the top-left corner of the
|
// Full punktfunk lockup: the lens mark anchored to the top-left corner of the
|
||||||
// "funk" wordmark. Size the lockup with a width on the wrapper (e.g. `w-40`);
|
// "funk" wordmark. Size the lockup with a width on the wrapper (e.g. `w-40`);
|
||||||
// the mark scales as a fraction of that width.
|
// the mark scales as a fraction of that width.
|
||||||
export function Logo({ className }: { className?: string }) {
|
export function Logo({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative inline-block', className)}>
|
<div className={cn("relative inline-block", className)}>
|
||||||
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_rgba(108,91,243,0.45)]" />
|
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_rgba(108,91,243,0.45)]" />
|
||||||
<Wordmark className="block h-auto w-full" />
|
<Wordmark className="block h-auto w-full" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Logo
|
export default Logo;
|
||||||
|
|||||||
@@ -1,43 +1,53 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from "react";
|
||||||
import { ApiError } from '@/api/fetcher'
|
import { ApiError } from "@/api/fetcher";
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { m } from '@/paraglide/messages'
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
interface QueryStateProps {
|
interface QueryStateProps {
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
error: unknown
|
error: unknown;
|
||||||
refetch?: () => void
|
refetch?: () => void;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Uniform loading/error wrapper for a query-backed view. */
|
/** Uniform loading/error wrapper for a query-backed view. */
|
||||||
export function QueryState({ isLoading, error, refetch, children }: QueryStateProps) {
|
export function QueryState({
|
||||||
if (isLoading) {
|
isLoading,
|
||||||
return (
|
error,
|
||||||
<div
|
refetch,
|
||||||
role="status"
|
children,
|
||||||
className="flex min-h-40 flex-col items-center justify-center gap-3 text-sm text-muted-foreground"
|
}: QueryStateProps) {
|
||||||
>
|
if (isLoading) {
|
||||||
<Spinner className="size-8" />
|
return (
|
||||||
{m.common_loading()}
|
<div
|
||||||
</div>
|
role="status"
|
||||||
)
|
className="flex min-h-40 flex-col items-center justify-center gap-3 text-sm text-muted-foreground"
|
||||||
}
|
>
|
||||||
if (error) {
|
<Spinner className="size-8" />
|
||||||
const unauthorized = error instanceof ApiError && error.status === 401
|
{m.common_loading()}
|
||||||
return (
|
</div>
|
||||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
);
|
||||||
<p className="font-medium text-destructive">
|
}
|
||||||
{unauthorized ? m.common_unauthorized() : m.common_error()}
|
if (error) {
|
||||||
</p>
|
const unauthorized = error instanceof ApiError && error.status === 401;
|
||||||
{refetch && !unauthorized && (
|
return (
|
||||||
<Button variant="outline" size="sm" className="mt-3" onClick={() => refetch()}>
|
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||||
{m.common_retry()}
|
<p className="font-medium text-destructive">
|
||||||
</Button>
|
{unauthorized ? m.common_unauthorized() : m.common_error()}
|
||||||
)}
|
</p>
|
||||||
</div>
|
{refetch && !unauthorized && (
|
||||||
)
|
<Button
|
||||||
}
|
variant="outline"
|
||||||
return <>{children}</>
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
{m.common_retry()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
|
import { Children, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page content wrapper that animates in on mount — so the content fans up into
|
||||||
|
* place every time you navigate or load a route (the route remounts, this
|
||||||
|
* remounts). Each direct child is staggered a beat after the previous (the same
|
||||||
|
* on-mount-delay pattern the sidebar nav uses). Honours prefers-reduced-motion.
|
||||||
|
*/
|
||||||
|
export function Section({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)}>
|
||||||
|
{Children.map(children, (child, i) =>
|
||||||
|
reduce ? (
|
||||||
|
child
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
delay: 0.03 + i * 0.07,
|
||||||
|
duration: 0.42,
|
||||||
|
ease: [0.16, 1, 0.3, 1],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</motion.div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
import * as React from 'react'
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import type * as React from "react";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none',
|
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'border-transparent bg-primary text-primary-foreground',
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
destructive:
|
||||||
success: 'border-transparent bg-[var(--success)] text-white',
|
"border-transparent bg-destructive text-destructive-foreground",
|
||||||
outline: 'text-foreground',
|
success: "border-transparent bg-[var(--success)] text-white",
|
||||||
},
|
outline: "text-foreground",
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: 'default' },
|
},
|
||||||
},
|
defaultVariants: { variant: "default" },
|
||||||
)
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { ComponentProps } from 'react'
|
import { AnimatedButton, buttonVariants } from "@unom/ui/button";
|
||||||
import { AnimatedButton, buttonVariants } from '@unom/ui/button'
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
// The console's Button IS @unom/ui's animated button — pill shape, specular
|
// The console's Button IS @unom/ui's animated button — pill shape, specular
|
||||||
// material gloss + UI click/hover sounds (enabled via UnomProviders), driven by
|
// material gloss + UI click/hover sounds (enabled via UnomProviders), driven by
|
||||||
// the shared brand tokens. Same variant/size vocabulary the routes already use
|
// the shared brand tokens. Same variant/size vocabulary the routes already use
|
||||||
// (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon).
|
// (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon).
|
||||||
export type ButtonProps = ComponentProps<typeof AnimatedButton>
|
export type ButtonProps = ComponentProps<typeof AnimatedButton>;
|
||||||
|
|
||||||
export const Button = AnimatedButton
|
export const Button = AnimatedButton;
|
||||||
|
|
||||||
export { buttonVariants }
|
export { buttonVariants };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react'
|
import { AnimatedCard } from "@unom/ui/card";
|
||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from "react";
|
||||||
import { AnimatedCard } from '@unom/ui/card'
|
import * as React from "react";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// The console's Card IS @unom/ui's animated card — a `bg-neutral` (#1c1530)
|
// The console's Card IS @unom/ui's animated card — a `bg-neutral` (#1c1530)
|
||||||
// surface with a soft brand-violet ring, on-mount motion + material gloss
|
// surface with a soft brand-violet ring, on-mount motion + material gloss
|
||||||
@@ -9,56 +9,85 @@ import { cn } from '@/lib/utils'
|
|||||||
// API (CardHeader/Title/Description/Content/Footer own their own padding), so
|
// API (CardHeader/Title/Description/Content/Footer own their own padding), so
|
||||||
// the card defaults to `padding={false}` to avoid doubling it, and soften the
|
// the card defaults to `padding={false}` to avoid doubling it, and soften the
|
||||||
// 2px ring to a subtle 1px brand tint.
|
// 2px ring to a subtle 1px brand tint.
|
||||||
type CardProps = ComponentProps<typeof AnimatedCard>
|
type CardProps = ComponentProps<typeof AnimatedCard>;
|
||||||
|
|
||||||
const Card = ({ className, padding = false, children, ...props }: CardProps) => (
|
const Card = ({
|
||||||
<AnimatedCard
|
className,
|
||||||
padding={padding}
|
padding = false,
|
||||||
className={cn('ring-1 ring-accent/40', className)}
|
children,
|
||||||
{...props}
|
...props
|
||||||
>
|
}: CardProps) => (
|
||||||
{children}
|
<AnimatedCard
|
||||||
</AnimatedCard>
|
padding={padding}
|
||||||
)
|
className={cn("ring-1 ring-accent/40", className)}
|
||||||
Card.displayName = 'Card'
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AnimatedCard>
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardHeader = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLDivElement,
|
||||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
),
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<div
|
||||||
CardHeader.displayName = 'CardHeader'
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardTitle = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLDivElement,
|
||||||
<div
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
ref={ref}
|
>(({ className, ...props }, ref) => (
|
||||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
<div
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
),
|
{...props}
|
||||||
)
|
/>
|
||||||
CardTitle.displayName = 'CardTitle'
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardDescription = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLDivElement,
|
||||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
),
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<div
|
||||||
CardDescription.displayName = 'CardDescription'
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardContent = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLDivElement,
|
||||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
),
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
CardContent.displayName = 'CardContent'
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardFooter = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLDivElement,
|
||||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
),
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<div
|
||||||
CardFooter.displayName = 'CardFooter'
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// The console's Input IS @unom/ui's form input (shadcn-compatible tokens:
|
// The console's Input IS @unom/ui's form input (shadcn-compatible tokens:
|
||||||
// border-input / muted-foreground / ring, material gloss via UnomProviders).
|
// border-input / muted-foreground / ring, material gloss via UnomProviders).
|
||||||
export { InputText as Input } from '@unom/ui/form/input-text'
|
export { InputText as Input } from "@unom/ui/form/input-text";
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// The console's Label IS @unom/ui's form label (radix-backed, text-main).
|
// The console's Label IS @unom/ui's form label (radix-backed, text-main).
|
||||||
export { Label } from '@unom/ui/form/label'
|
export { Label } from "@unom/ui/form/label";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { motion, useReducedMotion, useTime, useTransform } from "motion/react";
|
||||||
import { motion, useReducedMotion, useTime, useTransform } from 'motion/react'
|
import { useEffect, useRef } from "react";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
|
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
|
||||||
// recreated from divs and animated as if orbiting on a path whose long axis points
|
// recreated from divs and animated as if orbiting on a path whose long axis points
|
||||||
@@ -13,76 +13,85 @@ import { cn } from '@/lib/utils'
|
|||||||
// both the scaling and the front/back swap. Honours prefers-reduced-motion.
|
// both the scaling and the front/back swap. Honours prefers-reduced-motion.
|
||||||
// Size via className (e.g. `size-8`); geometry derives from the box.
|
// Size via className (e.g. `size-8`); geometry derives from the box.
|
||||||
|
|
||||||
const DURATION_MS = 1600
|
const DURATION_MS = 1600;
|
||||||
const R_DEPTH = 0.34 // depth amplitude (fraction of box) → the size change
|
const R_DEPTH = 0.34; // depth amplitude (fraction of box) → the size change
|
||||||
const PERSP = 1.05 // perspective distance (fraction of box); smaller → stronger scaling
|
const PERSP = 1.05; // perspective distance (fraction of box); smaller → stronger scaling
|
||||||
const R_PLANE_FIXED = 0.12 // constant in-plane offset → the two never fully eclipse
|
const R_PLANE_FIXED = 0.12; // constant in-plane offset → the two never fully eclipse
|
||||||
const R_PLANE_SWAY = 0.05 // small in-plane breathing
|
const R_PLANE_SWAY = 0.05; // small in-plane breathing
|
||||||
const DIAG: readonly [number, number] = [-Math.SQRT1_2, Math.SQRT1_2] // lens axis (↙ light / ↗ deep)
|
const DIAG: readonly [number, number] = [-Math.SQRT1_2, Math.SQRT1_2]; // lens axis (↙ light / ↗ deep)
|
||||||
const LOBE_FRAC = 0.58 // circle diameter as a fraction of the box
|
const LOBE_FRAC = 0.58; // circle diameter as a fraction of the box
|
||||||
const REST = 0 // reduced-motion: park flat (widest lens, no depth) = the brand mark
|
const REST = 0; // reduced-motion: park flat (widest lens, no depth) = the brand mark
|
||||||
|
|
||||||
export function Spinner({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
export function Spinner({
|
||||||
const reduce = useReducedMotion()
|
className,
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
...props
|
||||||
const sizeRef = useRef(0)
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const time = useTime()
|
const reduce = useReducedMotion();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const sizeRef = useRef(0);
|
||||||
|
const time = useTime();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
sizeRef.current = el.clientWidth
|
sizeRef.current = el.clientWidth;
|
||||||
const ro = new ResizeObserver((entries) => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
const w = entries[0]?.contentRect.width
|
const w = entries[0]?.contentRect.width;
|
||||||
if (w) sizeRef.current = w
|
if (w) sizeRef.current = w;
|
||||||
})
|
});
|
||||||
ro.observe(el)
|
ro.observe(el);
|
||||||
return () => ro.disconnect()
|
return () => ro.disconnect();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const angleAt = (t: number) => (reduce ? REST : (t / DURATION_MS) * Math.PI * 2)
|
const angleAt = (t: number) =>
|
||||||
const depthAt = (t: number, side: number) => side * Math.sin(angleAt(t)) * R_DEPTH
|
reduce ? REST : (t / DURATION_MS) * Math.PI * 2;
|
||||||
|
const depthAt = (t: number, side: number) =>
|
||||||
|
side * Math.sin(angleAt(t)) * R_DEPTH;
|
||||||
|
|
||||||
const transformAt = (t: number, side: number) => {
|
const transformAt = (t: number, side: number) => {
|
||||||
const s = sizeRef.current
|
const s = sizeRef.current;
|
||||||
const angle = angleAt(t)
|
const angle = angleAt(t);
|
||||||
const z = side * Math.sin(angle) * R_DEPTH // world depth (toward viewer = +)
|
const z = side * Math.sin(angle) * R_DEPTH; // world depth (toward viewer = +)
|
||||||
const p = PERSP / (PERSP - z) // perspective: nearer → bigger, farther → smaller
|
const p = PERSP / (PERSP - z); // perspective: nearer → bigger, farther → smaller
|
||||||
const mag = (R_PLANE_FIXED + R_PLANE_SWAY * Math.cos(angle)) * side
|
const mag = (R_PLANE_FIXED + R_PLANE_SWAY * Math.cos(angle)) * side;
|
||||||
const x = mag * DIAG[0] * p * s
|
const x = mag * DIAG[0] * p * s;
|
||||||
const y = mag * DIAG[1] * p * s
|
const y = mag * DIAG[1] * p * s;
|
||||||
return `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${p})`
|
return `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${p})`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const tLight = useTransform(time, (t) => transformAt(t, 1))
|
const tLight = useTransform(time, (t) => transformAt(t, 1));
|
||||||
const tDeep = useTransform(time, (t) => transformAt(t, -1))
|
const tDeep = useTransform(time, (t) => transformAt(t, -1));
|
||||||
// z-index follows depth, so whichever circle is nearer is painted on top.
|
// z-index follows depth, so whichever circle is nearer is painted on top.
|
||||||
const zLight = useTransform(time, (t) => Math.round(depthAt(t, 1) * 1000))
|
const zLight = useTransform(time, (t) => Math.round(depthAt(t, 1) * 1000));
|
||||||
const zDeep = useTransform(time, (t) => Math.round(depthAt(t, -1) * 1000))
|
const zDeep = useTransform(time, (t) => Math.round(depthAt(t, -1) * 1000));
|
||||||
|
|
||||||
const lobe = (color: string): React.CSSProperties => ({
|
const lobe = (color: string): React.CSSProperties => ({
|
||||||
width: `${LOBE_FRAC * 100}%`,
|
width: `${LOBE_FRAC * 100}%`,
|
||||||
height: `${LOBE_FRAC * 100}%`,
|
height: `${LOBE_FRAC * 100}%`,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
mixBlendMode: 'screen',
|
mixBlendMode: "screen",
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading"
|
aria-label="Loading"
|
||||||
className={cn('relative inline-block size-6 isolate', className)}
|
className={cn("relative inline-block size-6 isolate", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute left-1/2 top-1/2 rounded-full"
|
className="absolute left-1/2 top-1/2 rounded-full"
|
||||||
style={{ ...lobe('var(--pf-brand-light)'), transform: tLight, zIndex: zLight }}
|
style={{
|
||||||
/>
|
...lobe("var(--pf-brand-light)"),
|
||||||
<motion.div
|
transform: tLight,
|
||||||
className="absolute left-1/2 top-1/2 rounded-full"
|
zIndex: zLight,
|
||||||
style={{ ...lobe('var(--pf-brand)'), transform: tDeep, zIndex: zDeep }}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
<motion.div
|
||||||
)
|
className="absolute left-1/2 top-1/2 rounded-full"
|
||||||
|
style={{ ...lobe("var(--pf-brand)"), transform: tDeep, zIndex: zDeep }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,80 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
const Table = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableElement,
|
||||||
<div className="relative w-full overflow-auto">
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
>(({ className, ...props }, ref) => (
|
||||||
</div>
|
<div className="relative w-full overflow-auto">
|
||||||
),
|
<table
|
||||||
)
|
ref={ref}
|
||||||
Table.displayName = 'Table'
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
const TableHeader = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
))
|
));
|
||||||
TableHeader.displayName = 'TableHeader'
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
const TableBody = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
<tbody
|
||||||
))
|
ref={ref}
|
||||||
TableBody.displayName = 'TableBody'
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
const TableRow = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableRowElement,
|
||||||
<tr
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
ref={ref}
|
>(({ className, ...props }, ref) => (
|
||||||
className={cn(
|
<tr
|
||||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
ref={ref}
|
||||||
className,
|
className={cn(
|
||||||
)}
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
{...props}
|
className,
|
||||||
/>
|
)}
|
||||||
),
|
{...props}
|
||||||
)
|
/>
|
||||||
TableRow.displayName = 'TableRow'
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
const TableHead = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableHead.displayName = 'TableHead'
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
const TableCell = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('p-2 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableCell.displayName = 'TableCell'
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }
|
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
|
||||||
|
|||||||
@@ -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
|
// The punktfunk "funk" wordmark — the real brand typo, vectorised from the
|
||||||
// marketing logo. currentColor so it recolours per surface; defaults to the
|
// marketing logo. currentColor so it recolours per surface; defaults to the
|
||||||
// light-violet lens highlight that reads on the dark console chrome. Size via
|
// light-violet lens highlight that reads on the dark console chrome. Size via
|
||||||
// height (e.g. `h-5`); width follows the viewBox.
|
// height (e.g. `h-5`); width follows the viewBox.
|
||||||
export function Wordmark({ className }: { className?: string }) {
|
export function Wordmark({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="punktfunk"
|
aria-label="punktfunk"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 579 136"
|
viewBox="0 0 579 136"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className={cn('w-auto text-highlight', className)}
|
className={cn("w-auto text-highlight", className)}
|
||||||
>
|
>
|
||||||
<title>punktfunk</title>
|
<title>punktfunk</title>
|
||||||
<path d="M16.782,16.051l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z" />
|
<path d="M16.782,16.051l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z" />
|
||||||
<path d="M131.785,16.051l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z" />
|
<path d="M131.785,16.051l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z" />
|
||||||
<path d="M271.575,15.943l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z" />
|
<path d="M271.575,15.943l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z" />
|
||||||
<path d="M420.91,15.943l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z" />
|
<path d="M420.91,15.943l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Wordmark
|
export default Wordmark;
|
||||||
|
|||||||
+11
-11
@@ -1,29 +1,29 @@
|
|||||||
// Thin reactive layer over Paraglide. Paraglide's `m.*` message functions and
|
// Thin reactive layer over Paraglide. Paraglide's `m.*` message functions and
|
||||||
// `setLocale`/`getLocale` are framework-agnostic; this hook re-renders React when the
|
// `setLocale`/`getLocale` are framework-agnostic; this hook re-renders React when the
|
||||||
// locale changes (Paraglide's localStorage strategy persists the choice across reloads).
|
// locale changes (Paraglide's localStorage strategy persists the choice across reloads).
|
||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from "react";
|
||||||
import { getLocale, setLocale, locales } from '@/paraglide/runtime'
|
import { getLocale, locales, setLocale } from "@/paraglide/runtime";
|
||||||
|
|
||||||
/** The available locales as a union (`'en' | 'de'`), derived from Paraglide's `locales`. */
|
/** The available locales as a union (`'en' | 'de'`), derived from Paraglide's `locales`. */
|
||||||
export type Locale = (typeof locales)[number]
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
const listeners = new Set<() => void>()
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
/** Switch locale and notify subscribers (Paraglide also persists it per its strategy). */
|
/** Switch locale and notify subscribers (Paraglide also persists it per its strategy). */
|
||||||
export function changeLocale(locale: Locale) {
|
export function changeLocale(locale: Locale) {
|
||||||
// `reload: false` keeps the SPA mounted; we re-render via the store below.
|
// `reload: false` keeps the SPA mounted; we re-render via the store below.
|
||||||
setLocale(locale, { reload: false })
|
setLocale(locale, { reload: false });
|
||||||
for (const l of listeners) l()
|
for (const l of listeners) l();
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(cb: () => void) {
|
function subscribe(cb: () => void) {
|
||||||
listeners.add(cb)
|
listeners.add(cb);
|
||||||
return () => listeners.delete(cb)
|
return () => listeners.delete(cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current locale, reactive — components using `m.*` should read this so they re-render. */
|
/** Current locale, reactive — components using `m.*` should read this so they re-render. */
|
||||||
export function useLocale(): Locale {
|
export function useLocale(): Locale {
|
||||||
return useSyncExternalStore(subscribe, getLocale, () => 'en' as Locale)
|
return useSyncExternalStore(subscribe, getLocale, () => "en" as Locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { locales }
|
export { locales };
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* The slice of a React Query result a presentational view needs: just enough to
|
||||||
|
* drive <QueryState> + render the data. A `UseQueryResult` satisfies it directly
|
||||||
|
* (so containers pass the query through), and stories can hand-build one without
|
||||||
|
* mocking the network.
|
||||||
|
*/
|
||||||
|
export interface Loadable<T> {
|
||||||
|
data?: T;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
refetch?: () => void;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx'
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
|
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-30
@@ -1,39 +1,53 @@
|
|||||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { QueryClient } from '@tanstack/react-query'
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routeTree } from './routeTree.gen'
|
import { ApiError } from "./api/fetcher";
|
||||||
import { ApiError } from './api/fetcher'
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 2_000,
|
staleTime: 2_000,
|
||||||
// Don't hammer the host on auth/validation errors; do retry transient 5xx once.
|
// Don't hammer the host on auth/validation errors; do retry transient 5xx once.
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false
|
if (
|
||||||
return failureCount < 1
|
error instanceof ApiError &&
|
||||||
},
|
error.status >= 400 &&
|
||||||
},
|
error.status < 500
|
||||||
},
|
)
|
||||||
})
|
return false;
|
||||||
|
return failureCount < 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return createTanStackRouter({
|
return createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: { queryClient },
|
context: { queryClient },
|
||||||
defaultPreload: 'intent',
|
defaultPreload: "intent",
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
Wrap: ({ children }) => <QueryProvider client={queryClient}>{children}</QueryProvider>,
|
Wrap: ({ children }) => (
|
||||||
})
|
<QueryProvider client={queryClient}>{children}</QueryProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local import kept below the function so the module reads top-down.
|
// Local import kept below the function so the module reads top-down.
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
function QueryProvider({ client, children }: { client: QueryClient; children: React.ReactNode }) {
|
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
function QueryProvider({
|
||||||
|
client,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
client: QueryClient;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof getRouter>
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-41
@@ -1,51 +1,54 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
HeadContent,
|
HeadContent,
|
||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
useRouterState,
|
useRouterState,
|
||||||
} from '@tanstack/react-router'
|
} from "@tanstack/react-router";
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import "@fontsource-variable/geist";
|
||||||
import '@fontsource-variable/geist'
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { AppShell } from '@/components/app-shell'
|
import appCss from "@/styles.css?url";
|
||||||
import appCss from '@/styles.css?url'
|
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ charSet: 'utf-8' },
|
{ charSet: "utf-8" },
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||||
{ name: 'color-scheme', content: 'dark light' },
|
{ name: "color-scheme", content: "dark light" },
|
||||||
{ title: 'punktfunk' },
|
{ title: "punktfunk" },
|
||||||
],
|
],
|
||||||
links: [{ rel: 'stylesheet', href: appCss }],
|
links: [{ rel: "stylesheet", href: appCss }],
|
||||||
}),
|
}),
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
// The login screen renders bare (no sidebar); everything else gets the app shell.
|
// The login screen renders bare (no sidebar); everything else gets the app shell.
|
||||||
const isLogin = useRouterState({ select: (s) => s.location.pathname === '/login' })
|
const isLogin = useRouterState({
|
||||||
return (
|
select: (s) => s.location.pathname === "/login",
|
||||||
<html lang="en" className="dark">
|
});
|
||||||
<head>
|
return (
|
||||||
<HeadContent />
|
<html lang="en" className="dark">
|
||||||
</head>
|
<head>
|
||||||
<body className="min-h-screen">
|
<HeadContent />
|
||||||
{isLogin ? (
|
</head>
|
||||||
<Outlet />
|
<body className="min-h-screen">
|
||||||
) : (
|
{isLogin ? (
|
||||||
<AppShell>
|
<Outlet />
|
||||||
<Outlet />
|
) : (
|
||||||
</AppShell>
|
<AppShell>
|
||||||
)}
|
<Outlet />
|
||||||
<Scripts />
|
</AppShell>
|
||||||
</body>
|
)}
|
||||||
</html>
|
<Scripts />
|
||||||
)
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { SectionClients } from "@/sections/Clients";
|
||||||
import { Trash2 } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
useListPairedClients,
|
|
||||||
useUnpairClient,
|
|
||||||
getListPairedClientsQueryKey,
|
|
||||||
} from '@/api/gen/clients/clients'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { m } from '@/paraglide/messages'
|
|
||||||
import { useLocale } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/clients')({ component: ClientsPage })
|
export const Route = createFileRoute("/clients")({ component: SectionClients });
|
||||||
|
|
||||||
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
|
|
||||||
export function ClientsPage() {
|
|
||||||
useLocale()
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const clients = useListPairedClients()
|
|
||||||
const unpair = useUnpairClient()
|
|
||||||
const rows = clients.data ?? []
|
|
||||||
|
|
||||||
const onUnpair = (fingerprint: string) => {
|
|
||||||
if (!confirm(m.clients_unpair_confirm())) return
|
|
||||||
unpair.mutate(
|
|
||||||
{ fingerprint },
|
|
||||||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">{m.clients_title()}</h1>
|
|
||||||
<QueryState isLoading={clients.isLoading} error={clients.error} refetch={clients.refetch}>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
|
||||||
{m.clients_empty()}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{m.clients_name()}</TableHead>
|
|
||||||
<TableHead>{m.clients_fingerprint()}</TableHead>
|
|
||||||
<TableHead className="w-12" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((c) => (
|
|
||||||
<TableRow key={c.fingerprint}>
|
|
||||||
<TableCell className="font-medium">{c.subject || '—'}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{c.fingerprint.slice(0, 16)}…
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.action_unpair()}
|
|
||||||
disabled={unpair.isPending}
|
|
||||||
onClick={() => onUnpair(c.fingerprint)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-114
@@ -1,115 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useGetHostInfo, useListCompositors } from '@/api/gen/host/host'
|
import { SectionHost } from "@/sections/Host";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { m } from '@/paraglide/messages'
|
|
||||||
import { useLocale } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/host')({ component: HostPage })
|
export const Route = createFileRoute("/host")({ component: SectionHost });
|
||||||
|
|
||||||
// Exported so Storybook can render the page directly (see src/stories). The
|
|
||||||
// route gen only needs the `Route` export; this extra one is harmless.
|
|
||||||
export function HostPage() {
|
|
||||||
useLocale()
|
|
||||||
const host = useGetHostInfo()
|
|
||||||
const compositors = useListCompositors()
|
|
||||||
const h = host.data
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
|
||||||
<QueryState isLoading={host.isLoading} error={host.error} refetch={host.refetch}>
|
|
||||||
{h && (
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.host_identity()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-1 gap-3">
|
|
||||||
<Row label={m.host_hostname()} value={h.hostname} />
|
|
||||||
<Row label={m.host_local_ip()} value={h.local_ip} mono />
|
|
||||||
<Row label={m.host_version()} value={`${h.app_version} (${h.version})`} />
|
|
||||||
<Row label={m.host_abi()} value={String(h.abi_version)} />
|
|
||||||
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.host_codecs()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
{h.codecs.map((c) => (
|
|
||||||
<Badge key={c} variant="secondary">
|
|
||||||
{c.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.host_ports()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
|
|
||||||
{Object.entries(h.ports).map(([k, v]) => (
|
|
||||||
<div key={k} className="flex justify-between">
|
|
||||||
<dt className="text-muted-foreground uppercase">{k}</dt>
|
|
||||||
<dd className="font-medium">{v as number}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.host_compositors()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">{m.host_compositors_help()}</p>
|
|
||||||
<QueryState
|
|
||||||
isLoading={compositors.isLoading}
|
|
||||||
error={compositors.error}
|
|
||||||
refetch={compositors.refetch}
|
|
||||||
>
|
|
||||||
<ul className="divide-y rounded-md border">
|
|
||||||
{compositors.data?.map((c) => (
|
|
||||||
<li key={c.id} className="flex items-center justify-between gap-4 px-4 py-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{c.label}</span>
|
|
||||||
{c.default && <Badge variant="secondary">{m.compositor_default()}</Badge>}
|
|
||||||
</div>
|
|
||||||
<code className="text-xs text-muted-foreground">{c.id}</code>
|
|
||||||
</div>
|
|
||||||
<Badge variant={c.available ? 'default' : 'outline'}>
|
|
||||||
{c.available ? m.compositor_available() : m.compositor_unavailable()}
|
|
||||||
</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</QueryState>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
|
||||||
<dt className="text-sm text-muted-foreground">{label}</dt>
|
|
||||||
<dd className={mono ? 'truncate font-mono text-xs' : 'font-medium'} title={value}>
|
|
||||||
{value}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-138
@@ -1,139 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { SectionDashboard } from "@/sections/Dashboard";
|
||||||
import { Video, Volume2, MonitorPlay, ZapOff, RefreshCw } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
useGetStatus,
|
|
||||||
getGetStatusQueryKey,
|
|
||||||
} from '@/api/gen/host/host'
|
|
||||||
import { useStopSession, useRequestIdr } from '@/api/gen/session/session'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { m } from '@/paraglide/messages'
|
|
||||||
import { useLocale } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({ component: Dashboard })
|
export const Route = createFileRoute("/")({ component: SectionDashboard });
|
||||||
|
|
||||||
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
|
|
||||||
export function Dashboard() {
|
|
||||||
useLocale()
|
|
||||||
const qc = useQueryClient()
|
|
||||||
// Poll live status every 2s so the console tracks an active session.
|
|
||||||
const status = useGetStatus({ query: { refetchInterval: 2_000 } })
|
|
||||||
const stop = useStopSession()
|
|
||||||
const idr = useRequestIdr()
|
|
||||||
|
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: getGetStatusQueryKey() })
|
|
||||||
const s = status.data
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
|
|
||||||
<QueryState isLoading={status.isLoading} error={status.error} refetch={status.refetch}>
|
|
||||||
{s && (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
icon={<Video className="size-4" />}
|
|
||||||
label={m.status_video()}
|
|
||||||
on={s.video_streaming}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Volume2 className="size-4" />}
|
|
||||||
label={m.status_audio()}
|
|
||||||
on={s.audio_streaming}
|
|
||||||
/>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
|
||||||
<span className="text-sm text-muted-foreground">{m.status_paired_count()}</span>
|
|
||||||
<span className="text-2xl font-semibold tabular-nums">{s.paired_clients}</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
|
||||||
<span className="text-sm text-muted-foreground">{m.status_pin_pending()}</span>
|
|
||||||
<Badge variant={s.pin_pending ? 'default' : 'outline'}>
|
|
||||||
{s.pin_pending ? '●' : '—'}
|
|
||||||
</Badge>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<MonitorPlay className="size-4" />
|
|
||||||
{m.status_session()}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!s.video_streaming || idr.isPending}
|
|
||||||
onClick={() => idr.mutate(undefined)}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
{m.action_request_idr()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={!s.session || stop.isPending}
|
|
||||||
onClick={() => stop.mutate(undefined, { onSuccess: invalidate })}
|
|
||||||
>
|
|
||||||
<ZapOff className="size-3.5" />
|
|
||||||
{m.action_stop_session()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{s.stream ? (
|
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
|
|
||||||
<Field label={m.stream_codec()} value={s.stream.codec.toUpperCase()} />
|
|
||||||
<Field
|
|
||||||
label={m.stream_resolution()}
|
|
||||||
value={`${s.stream.width}×${s.stream.height}`}
|
|
||||||
/>
|
|
||||||
<Field label={m.stream_fps()} value={`${s.stream.fps} fps`} />
|
|
||||||
<Field
|
|
||||||
label={m.stream_bitrate()}
|
|
||||||
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">{m.status_no_session()}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ icon, label, on }: { icon: React.ReactNode; label: string; on: boolean }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<Badge variant={on ? 'success' : 'outline'}>
|
|
||||||
{on ? m.status_streaming() : m.status_idle()}
|
|
||||||
</Badge>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
|
||||||
<dd className="mt-0.5 font-medium tabular-nums">{value}</dd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-292
@@ -1,293 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { SectionLibrary } from "@/sections/Library";
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Pencil, Plus, Trash2, X } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
useGetLibrary,
|
|
||||||
useCreateCustomGame,
|
|
||||||
useUpdateCustomGame,
|
|
||||||
useDeleteCustomGame,
|
|
||||||
getGetLibraryQueryKey,
|
|
||||||
} from '@/api/gen/library/library'
|
|
||||||
import type { GameEntry } from '@/api/gen/model/gameEntry'
|
|
||||||
import type { CustomInput } from '@/api/gen/model/customInput'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { m } from '@/paraglide/messages'
|
|
||||||
import { useLocale } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/library')({ component: LibraryPage })
|
export const Route = createFileRoute("/library")({ component: SectionLibrary });
|
||||||
|
|
||||||
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
|
|
||||||
function customId(entry: GameEntry): string {
|
|
||||||
return entry.id.startsWith('custom:') ? entry.id.slice('custom:'.length) : entry.id
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Editable form state for the add/edit custom-game form. */
|
|
||||||
interface FormState {
|
|
||||||
title: string
|
|
||||||
portrait: string
|
|
||||||
hero: string
|
|
||||||
header: string
|
|
||||||
command: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyForm: FormState = { title: '', portrait: '', hero: '', header: '', command: '' }
|
|
||||||
|
|
||||||
function formFrom(entry: GameEntry): FormState {
|
|
||||||
return {
|
|
||||||
title: entry.title,
|
|
||||||
portrait: entry.art.portrait ?? '',
|
|
||||||
hero: entry.art.hero ?? '',
|
|
||||||
header: entry.art.header ?? '',
|
|
||||||
command: entry.launch?.kind === 'command' ? entry.launch.value : '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map the form to the API body — only attach `launch` when a command was given. */
|
|
||||||
function toInput(f: FormState): CustomInput {
|
|
||||||
const trim = (s: string) => {
|
|
||||||
const t = s.trim()
|
|
||||||
return t ? t : undefined
|
|
||||||
}
|
|
||||||
const command = f.command.trim()
|
|
||||||
return {
|
|
||||||
title: f.title.trim(),
|
|
||||||
art: {
|
|
||||||
portrait: trim(f.portrait),
|
|
||||||
hero: trim(f.hero),
|
|
||||||
header: trim(f.header),
|
|
||||||
},
|
|
||||||
launch: command ? { kind: 'command', value: command } : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
|
|
||||||
export function LibraryPage() {
|
|
||||||
useLocale()
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const library = useGetLibrary()
|
|
||||||
const create = useCreateCustomGame()
|
|
||||||
const update = useUpdateCustomGame()
|
|
||||||
const remove = useDeleteCustomGame()
|
|
||||||
|
|
||||||
// null = form hidden; '' = adding a new entry; an id = editing that custom entry.
|
|
||||||
const [editing, setEditing] = useState<string | null>(null)
|
|
||||||
const [form, setForm] = useState<FormState>(emptyForm)
|
|
||||||
|
|
||||||
const games = library.data ?? []
|
|
||||||
|
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() })
|
|
||||||
|
|
||||||
const openAdd = () => {
|
|
||||||
setForm(emptyForm)
|
|
||||||
setEditing('')
|
|
||||||
}
|
|
||||||
const openEdit = (entry: GameEntry) => {
|
|
||||||
setForm(formFrom(entry))
|
|
||||||
setEditing(customId(entry))
|
|
||||||
}
|
|
||||||
const closeForm = () => setEditing(null)
|
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const data = toInput(form)
|
|
||||||
if (!data.title) return
|
|
||||||
if (editing) {
|
|
||||||
update.mutate({ id: editing, data }, { onSuccess: () => { invalidate(); closeForm() } })
|
|
||||||
} else {
|
|
||||||
create.mutate({ data }, { onSuccess: () => { invalidate(); closeForm() } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDelete = (entry: GameEntry) => {
|
|
||||||
if (!confirm(m.library_delete_confirm())) return
|
|
||||||
remove.mutate({ id: customId(entry) }, { onSuccess: invalidate })
|
|
||||||
}
|
|
||||||
|
|
||||||
const saving = create.isPending || update.isPending
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
|
|
||||||
{editing === null && (
|
|
||||||
<Button onClick={openAdd}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
{m.library_add_button()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editing !== null && (
|
|
||||||
<Card className="max-w-xl">
|
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle>{editing ? m.library_edit_title() : m.library_add_title()}</CardTitle>
|
|
||||||
<Button variant="ghost" size="icon" aria-label={m.library_cancel()} onClick={closeForm}>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-title"
|
|
||||||
required
|
|
||||||
value={form.title}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-portrait"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
value={form.portrait}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, portrait: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-hero"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
value={form.hero}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-header"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
value={form.header}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, header: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-command"
|
|
||||||
value={form.command}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, command: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">{m.library_field_command_help()}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" disabled={saving || !form.title.trim()}>
|
|
||||||
{editing ? m.library_save() : m.library_create()}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={closeForm}>
|
|
||||||
{m.library_cancel()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<QueryState isLoading={library.isLoading} error={library.error} refetch={library.refetch}>
|
|
||||||
{games.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
|
||||||
{m.library_empty()}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
||||||
{games.map((game) => (
|
|
||||||
<GameCard
|
|
||||||
key={game.id}
|
|
||||||
game={game}
|
|
||||||
onEdit={() => openEdit(game)}
|
|
||||||
onDelete={() => onDelete(game)}
|
|
||||||
deleting={remove.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameCardProps {
|
|
||||||
game: GameEntry
|
|
||||||
onEdit: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
deleting: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it falls back to the
|
|
||||||
* wide header, then to a text placeholder. Custom entries get edit/delete affordances.
|
|
||||||
*/
|
|
||||||
function GameCard({ game, onEdit, onDelete, deleting }: GameCardProps) {
|
|
||||||
const isCustom = game.store === 'custom'
|
|
||||||
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
|
|
||||||
const [failed, setFailed] = useState<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
const candidates = [game.art.portrait, game.art.header].filter(
|
|
||||||
(u): u is string => !!u && !failed[u],
|
|
||||||
)
|
|
||||||
const src = candidates[0]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="group relative overflow-hidden">
|
|
||||||
<div className="relative aspect-[2/3] bg-muted">
|
|
||||||
{src ? (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={game.title}
|
|
||||||
loading="lazy"
|
|
||||||
className="size-full object-cover"
|
|
||||||
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
|
|
||||||
{game.title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute left-2 top-2">
|
|
||||||
<Badge variant={isCustom ? 'secondary' : 'outline'} className="bg-background/80 backdrop-blur">
|
|
||||||
{isCustom ? m.library_store_custom() : m.library_store_steam()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{isCustom && (
|
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 bg-background/80 backdrop-blur"
|
|
||||||
aria-label={m.library_edit()}
|
|
||||||
onClick={onEdit}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 bg-background/80 backdrop-blur"
|
|
||||||
aria-label={m.library_delete()}
|
|
||||||
disabled={deleting}
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="truncate p-2 text-sm font-medium" title={game.title}>
|
|
||||||
{game.title}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-82
@@ -1,85 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
import { SectionLogin } from "@/sections/Login";
|
||||||
import { BrandMark } from '@/components/brand-mark'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { m } from '@/paraglide/messages'
|
|
||||||
import { useLocale } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/login')({
|
export const Route = createFileRoute("/login")({
|
||||||
validateSearch: (s: Record<string, unknown>): { next?: string } => ({
|
validateSearch: (s: Record<string, unknown>): { next?: string } => ({
|
||||||
next: typeof s.next === 'string' ? s.next : undefined,
|
next: typeof s.next === "string" ? s.next : undefined,
|
||||||
}),
|
}),
|
||||||
component: LoginPage,
|
component: RouteComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function LoginPage() {
|
function RouteComponent() {
|
||||||
useLocale()
|
const { next } = Route.useSearch();
|
||||||
const router = useRouter()
|
return <SectionLogin next={next} />;
|
||||||
const { next } = Route.useSearch()
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [error, setError] = useState(false)
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setBusy(true)
|
|
||||||
setError(false)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/_auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(true)
|
|
||||||
setBusy(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Full reload to the target so SSR re-runs WITH the new session cookie. Only a
|
|
||||||
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
|
|
||||||
const safe = next && next.startsWith('/') && !next.startsWith('//') ? next : '/'
|
|
||||||
window.location.href = safe
|
|
||||||
} catch {
|
|
||||||
setError(true)
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
void router
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center p-6">
|
|
||||||
<Card className="w-full max-w-sm">
|
|
||||||
<CardHeader className="items-center text-center">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<BrandMark className="size-6 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" />
|
|
||||||
<span className="font-semibold">{m.app_name()}</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle>{m.login_title()}</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="pw">{m.login_password()}</Label>
|
|
||||||
<Input
|
|
||||||
id="pw"
|
|
||||||
type="password"
|
|
||||||
autoFocus
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-sm text-destructive">{m.login_error()}</p>}
|
|
||||||
<Button type="submit" className="w-full" disabled={busy || !password}>
|
|
||||||
{busy ? m.login_signing_in() : m.login_submit()}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-388
@@ -1,390 +1,4 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { SectionPairing } from "@/sections/Pairing";
|
||||||
import {
|
|
||||||
KeyRound,
|
|
||||||
CheckCircle2,
|
|
||||||
Smartphone,
|
|
||||||
Timer,
|
|
||||||
Trash2,
|
|
||||||
UserPlus,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
useGetNativePairing,
|
|
||||||
useArmNativePairing,
|
|
||||||
useDisarmNativePairing,
|
|
||||||
useListNativeClients,
|
|
||||||
useUnpairNativeClient,
|
|
||||||
useListPendingDevices,
|
|
||||||
useApprovePendingDevice,
|
|
||||||
useDenyPendingDevice,
|
|
||||||
getGetNativePairingQueryKey,
|
|
||||||
getListNativeClientsQueryKey,
|
|
||||||
getListPendingDevicesQueryKey,
|
|
||||||
} from "@/api/gen/native/native";
|
|
||||||
import {
|
|
||||||
useGetPairingStatus,
|
|
||||||
useSubmitPairingPin,
|
|
||||||
getGetPairingStatusQueryKey,
|
|
||||||
} from "@/api/gen/pairing/pairing";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { QueryState } from "@/components/query-state";
|
|
||||||
import { m } from "@/paraglide/messages";
|
|
||||||
import { useLocale } from "@/lib/i18n";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/pairing")({ component: PairingPage });
|
export const Route = createFileRoute("/pairing")({ component: SectionPairing });
|
||||||
|
|
||||||
/** Seconds → `m:ss`. */
|
|
||||||
function fmtTime(secs: number): string {
|
|
||||||
const s = Math.max(0, Math.floor(secs));
|
|
||||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PairingPage() {
|
|
||||||
useLocale();
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
|
||||||
<PendingDevices />
|
|
||||||
<NativePairingCard />
|
|
||||||
<NativeDevices />
|
|
||||||
<MoonlightPairingCard />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seconds since a knock → a short relative label. */
|
|
||||||
function fmtAge(secs: number): string {
|
|
||||||
if (secs < 10) return m.pairing_pending_age_just_now();
|
|
||||||
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
|
|
||||||
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Devices awaiting delegated approval: an unpaired device that tried to connect shows up here,
|
|
||||||
* and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty
|
|
||||||
* (the common case); polls so a knock appears while the operator is looking at the page.
|
|
||||||
*/
|
|
||||||
function PendingDevices() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
|
||||||
const approve = useApprovePendingDevice();
|
|
||||||
const deny = useDenyPendingDevice();
|
|
||||||
const rows = pending.data ?? [];
|
|
||||||
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
|
|
||||||
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
|
|
||||||
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
|
|
||||||
if (rows.length === 0 && !pending.error) return null;
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
|
|
||||||
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
|
||||||
};
|
|
||||||
const onApprove = (id: number, currentName: string) => {
|
|
||||||
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
|
||||||
if (name == null) return; // operator cancelled
|
|
||||||
approve.mutate(
|
|
||||||
{ id, data: { name: name.trim() ? name.trim() : null } },
|
|
||||||
{ onSuccess: refresh },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
|
||||||
<UserPlus className="size-4" />
|
|
||||||
{m.pairing_pending_title()}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.pairing_pending_desc()}
|
|
||||||
</p>
|
|
||||||
<QueryState
|
|
||||||
isLoading={pending.isLoading}
|
|
||||||
error={pending.error}
|
|
||||||
refetch={pending.refetch}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((p) => (
|
|
||||||
<TableRow key={p.id}>
|
|
||||||
<TableCell className="font-medium">{p.name}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{p.fingerprint.slice(0, 16)}…
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{fmtAge(p.age_secs)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={approve.isPending || deny.isPending}
|
|
||||||
onClick={() => onApprove(p.id, p.name)}
|
|
||||||
>
|
|
||||||
{m.pairing_pending_approve()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={m.pairing_pending_deny()}
|
|
||||||
disabled={approve.isPending || deny.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
deny.mutate({ id: p.id }, { onSuccess: refresh })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
|
||||||
function NativePairingCard() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
// Poll fast while armed (live countdown), slow otherwise.
|
|
||||||
const status = useGetNativePairing({
|
|
||||||
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
|
||||||
});
|
|
||||||
const arm = useArmNativePairing();
|
|
||||||
const disarm = useDisarmNativePairing();
|
|
||||||
const d = status.data;
|
|
||||||
const refresh = () =>
|
|
||||||
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryState
|
|
||||||
isLoading={status.isLoading}
|
|
||||||
error={status.error}
|
|
||||||
refetch={status.refetch}
|
|
||||||
>
|
|
||||||
<Card className="max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Smartphone className="size-4" />
|
|
||||||
{m.pairing_native_title()}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{!d?.enabled ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.pairing_native_disabled()}
|
|
||||||
</p>
|
|
||||||
) : d.armed && d.pin ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm">{m.pairing_native_enter()}</p>
|
|
||||||
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
|
||||||
{d.pin}
|
|
||||||
</div>
|
|
||||||
{d.expires_in_secs != null && (
|
|
||||||
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Timer className="size-4" />
|
|
||||||
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
disabled={disarm.isPending}
|
|
||||||
onClick={() => disarm.mutate(undefined, { onSuccess: refresh })}
|
|
||||||
>
|
|
||||||
{m.pairing_native_cancel()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.pairing_native_desc()}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
disabled={arm.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
arm.mutate(
|
|
||||||
{ data: { ttl_secs: 120 } },
|
|
||||||
{ onSuccess: refresh },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<KeyRound className="size-4" />
|
|
||||||
{m.pairing_native_arm()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The paired native (punktfunk/1) devices, with unpair. */
|
|
||||||
function NativeDevices() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const clients = useListNativeClients();
|
|
||||||
const unpair = useUnpairNativeClient();
|
|
||||||
const rows = clients.data ?? [];
|
|
||||||
|
|
||||||
const onUnpair = (fingerprint: string) => {
|
|
||||||
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
|
||||||
unpair.mutate(
|
|
||||||
{ fingerprint },
|
|
||||||
{
|
|
||||||
onSuccess: () =>
|
|
||||||
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
|
||||||
<QueryState
|
|
||||||
isLoading={clients.isLoading}
|
|
||||||
error={clients.error}
|
|
||||||
refetch={clients.refetch}
|
|
||||||
>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
|
||||||
{m.pairing_native_empty()}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{m.clients_name()}</TableHead>
|
|
||||||
<TableHead>{m.clients_fingerprint()}</TableHead>
|
|
||||||
<TableHead className="w-12" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((c) => (
|
|
||||||
<TableRow key={c.fingerprint}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{c.name || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{c.fingerprint.slice(0, 16)}…
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.action_unpair()}
|
|
||||||
disabled={unpair.isPending}
|
|
||||||
onClick={() => onUnpair(c.fingerprint)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
|
||||||
function MoonlightPairingCard() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [pin, setPin] = useState("");
|
|
||||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
|
||||||
const submit = useSubmitPairingPin();
|
|
||||||
const pending = pairing.data?.pin_pending ?? false;
|
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
submit.mutate(
|
|
||||||
{ data: { pin } },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setPin("");
|
|
||||||
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryState
|
|
||||||
isLoading={pairing.isLoading}
|
|
||||||
error={pairing.error}
|
|
||||||
refetch={pairing.refetch}
|
|
||||||
>
|
|
||||||
<Card className="max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<KeyRound className="size-4" />
|
|
||||||
{m.pairing_moonlight_title()}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!pending ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
|
||||||
<p className="text-sm">{m.pairing_waiting()}</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
|
||||||
<Input
|
|
||||||
id="pin"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="off"
|
|
||||||
maxLength={8}
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
|
||||||
placeholder="0000"
|
|
||||||
className="font-mono text-lg tracking-widest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={pin.length < 4 || submit.isPending}
|
|
||||||
>
|
|
||||||
{m.pairing_submit()}
|
|
||||||
</Button>
|
|
||||||
{submit.isSuccess && (
|
|
||||||
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
{m.pairing_success()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{submit.isError && (
|
|
||||||
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,55 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { LogOut } from 'lucide-react'
|
import { SectionSettings } from "@/sections/Settings";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { m } from '@/paraglide/messages'
|
|
||||||
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
export const Route = createFileRoute("/settings")({
|
||||||
|
component: SectionSettings,
|
||||||
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
|
});
|
||||||
export function SettingsPage() {
|
|
||||||
const current = useLocale()
|
|
||||||
|
|
||||||
const onLogout = async () => {
|
|
||||||
await fetch('/_auth/logout', { method: 'POST' })
|
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
|
|
||||||
|
|
||||||
<Card className="max-w-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.settings_language()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex gap-2">
|
|
||||||
{locales.map((l: Locale) => (
|
|
||||||
<Button
|
|
||||||
key={l}
|
|
||||||
variant={l === current ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="uppercase"
|
|
||||||
onClick={() => changeLocale(l)}
|
|
||||||
>
|
|
||||||
{l}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="max-w-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.nav_settings()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button variant="outline" onClick={onLogout}>
|
|
||||||
<LogOut className="size-4" />
|
|
||||||
{m.action_logout()}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import {
|
||||||
|
getListPairedClientsQueryKey,
|
||||||
|
useListPairedClients,
|
||||||
|
useUnpairClient,
|
||||||
|
} from "@/api/gen/clients/clients";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
import { ClientsView } from "./view";
|
||||||
|
|
||||||
|
export const SectionClients: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const clients = useListPairedClients();
|
||||||
|
const unpair = useUnpairClient();
|
||||||
|
|
||||||
|
const onUnpair = (fingerprint: string) => {
|
||||||
|
if (!confirm(m.clients_unpair_confirm())) return;
|
||||||
|
unpair.mutate(
|
||||||
|
{ fingerprint },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientsView
|
||||||
|
clients={clients}
|
||||||
|
onUnpair={onUnpair}
|
||||||
|
isUnpairing={unpair.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import type { PairedClient } from "@/api/gen/model/pairedClient";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
|
import { Section } from "@/components/section";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { Loadable } from "@/lib/query";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
export const ClientsView: FC<{
|
||||||
|
clients: Loadable<PairedClient[]>;
|
||||||
|
onUnpair: (fingerprint: string) => void;
|
||||||
|
isUnpairing: boolean;
|
||||||
|
}> = ({ clients, onUnpair, isUnpairing }) => {
|
||||||
|
const rows = clients.data ?? [];
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<h1 className="text-2xl font-semibold">{m.clients_title()}</h1>
|
||||||
|
<QueryState
|
||||||
|
isLoading={clients.isLoading}
|
||||||
|
error={clients.error}
|
||||||
|
refetch={clients.refetch}
|
||||||
|
>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{m.clients_empty()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{m.clients_name()}</TableHead>
|
||||||
|
<TableHead>{m.clients_fingerprint()}</TableHead>
|
||||||
|
<TableHead className="w-12" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((c) => (
|
||||||
|
<TableRow key={c.fingerprint}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{c.subject || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{c.fingerprint.slice(0, 16)}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.action_unpair()}
|
||||||
|
disabled={isUnpairing}
|
||||||
|
onClick={() => onUnpair(c.fingerprint)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
|
||||||
|
import { useRequestIdr, useStopSession } from "@/api/gen/session/session";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { DashboardView } from "./view";
|
||||||
|
|
||||||
|
export const SectionDashboard: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
// Poll live status every 2s so the console tracks an active session.
|
||||||
|
const status = useGetStatus({ query: { refetchInterval: 2_000 } });
|
||||||
|
const stop = useStopSession();
|
||||||
|
const idr = useRequestIdr();
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardView
|
||||||
|
status={status}
|
||||||
|
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
|
||||||
|
onRequestIdr={() => idr.mutate(undefined)}
|
||||||
|
isStopping={stop.isPending}
|
||||||
|
isRequestingIdr={idr.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
|
||||||
|
import type { FC, ReactNode } from "react";
|
||||||
|
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
|
import { Section } from "@/components/section";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { Loadable } from "@/lib/query";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
export const DashboardView: FC<{
|
||||||
|
status: Loadable<RuntimeStatus>;
|
||||||
|
onStopSession: () => void;
|
||||||
|
onRequestIdr: () => void;
|
||||||
|
isStopping: boolean;
|
||||||
|
isRequestingIdr: boolean;
|
||||||
|
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
|
||||||
|
const s = status.data;
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
|
||||||
|
<QueryState
|
||||||
|
isLoading={status.isLoading}
|
||||||
|
error={status.error}
|
||||||
|
refetch={status.refetch}
|
||||||
|
>
|
||||||
|
{s && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
icon={<Video className="size-4" />}
|
||||||
|
label={m.status_video()}
|
||||||
|
on={s.video_streaming}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Volume2 className="size-4" />}
|
||||||
|
label={m.status_audio()}
|
||||||
|
on={s.audio_streaming}
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{m.status_paired_count()}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-semibold tabular-nums">
|
||||||
|
{s.paired_clients}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{m.status_pin_pending()}
|
||||||
|
</span>
|
||||||
|
<Badge variant={s.pin_pending ? "default" : "outline"}>
|
||||||
|
{s.pin_pending ? "●" : "—"}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MonitorPlay className="size-4" />
|
||||||
|
{m.status_session()}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!s.video_streaming || isRequestingIdr}
|
||||||
|
onClick={onRequestIdr}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
{m.action_request_idr()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={!s.session || isStopping}
|
||||||
|
onClick={onStopSession}
|
||||||
|
>
|
||||||
|
<ZapOff className="size-3.5" />
|
||||||
|
{m.action_stop_session()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{s.stream ? (
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
|
||||||
|
<Field
|
||||||
|
label={m.stream_codec()}
|
||||||
|
value={s.stream.codec.toUpperCase()}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={m.stream_resolution()}
|
||||||
|
value={`${s.stream.width}×${s.stream.height}`}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={m.stream_fps()}
|
||||||
|
value={`${s.stream.fps} fps`}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={m.stream_bitrate()}
|
||||||
|
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.status_no_session()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
on,
|
||||||
|
}) => (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Badge variant={on ? "success" : "outline"}>
|
||||||
|
{on ? m.status_streaming() : m.status_idle()}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Field: FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="mt-0.5 font-medium tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { HostView } from "./view";
|
||||||
|
|
||||||
|
export const SectionHost: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
const host = useGetHostInfo();
|
||||||
|
const compositors = useListCompositors();
|
||||||
|
|
||||||
|
return <HostView host={host} compositors={compositors} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
||||||
|
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
|
import { Section } from "@/components/section";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { Loadable } from "@/lib/query";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
export const HostView: FC<{
|
||||||
|
host: Loadable<HostInfo>;
|
||||||
|
compositors: Loadable<AvailableCompositor[]>;
|
||||||
|
}> = ({ host, compositors }) => {
|
||||||
|
const h = host.data;
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
||||||
|
|
||||||
|
<QueryState
|
||||||
|
isLoading={host.isLoading}
|
||||||
|
error={host.error}
|
||||||
|
refetch={host.refetch}
|
||||||
|
>
|
||||||
|
{h && (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.host_identity()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid grid-cols-1 gap-3">
|
||||||
|
<Row label={m.host_hostname()} value={h.hostname} />
|
||||||
|
<Row label={m.host_local_ip()} value={h.local_ip} mono />
|
||||||
|
<Row
|
||||||
|
label={m.host_version()}
|
||||||
|
value={`${h.app_version} (${h.version})`}
|
||||||
|
/>
|
||||||
|
<Row label={m.host_abi()} value={String(h.abi_version)} />
|
||||||
|
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.host_codecs()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
{h.codecs.map((c) => (
|
||||||
|
<Badge key={c} variant="secondary">
|
||||||
|
{c.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.host_ports()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
|
||||||
|
{Object.entries(h.ports).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between">
|
||||||
|
<dt className="text-muted-foreground uppercase">{k}</dt>
|
||||||
|
<dd className="font-medium">{v as number}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.host_compositors()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.host_compositors_help()}
|
||||||
|
</p>
|
||||||
|
<QueryState
|
||||||
|
isLoading={compositors.isLoading}
|
||||||
|
error={compositors.error}
|
||||||
|
refetch={compositors.refetch}
|
||||||
|
>
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{compositors.data?.map((c) => (
|
||||||
|
<li
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{c.label}</span>
|
||||||
|
{c.default && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{m.compositor_default()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<code className="text-xs text-muted-foreground">
|
||||||
|
{c.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<Badge variant={c.available ? "default" : "outline"}>
|
||||||
|
{c.available
|
||||||
|
? m.compositor_available()
|
||||||
|
: m.compositor_unavailable()}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row: FC<{ label: string; value: string; mono?: boolean }> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<dt className="text-sm text-muted-foreground">{label}</dt>
|
||||||
|
<dd
|
||||||
|
className={mono ? "truncate font-mono text-xs" : "font-medium"}
|
||||||
|
title={value}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import {
|
||||||
|
getGetLibraryQueryKey,
|
||||||
|
useCreateCustomGame,
|
||||||
|
useDeleteCustomGame,
|
||||||
|
useGetLibrary,
|
||||||
|
useUpdateCustomGame,
|
||||||
|
} from "@/api/gen/library/library";
|
||||||
|
import type { CustomInput } from "@/api/gen/model/customInput";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { LibraryView } from "./view";
|
||||||
|
|
||||||
|
export const SectionLibrary: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const library = useGetLibrary();
|
||||||
|
const create = useCreateCustomGame();
|
||||||
|
const update = useUpdateCustomGame();
|
||||||
|
const remove = useDeleteCustomGame();
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryView
|
||||||
|
library={library}
|
||||||
|
onCreate={(data: CustomInput) =>
|
||||||
|
create.mutateAsync({ data }).then(invalidate)
|
||||||
|
}
|
||||||
|
onUpdate={(id, data) => update.mutateAsync({ id, data }).then(invalidate)}
|
||||||
|
onDelete={(id) => remove.mutateAsync({ id }).then(invalidate)}
|
||||||
|
isSaving={create.isPending || update.isPending}
|
||||||
|
isDeleting={remove.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { type FC, useState } from "react";
|
||||||
|
import type { CustomInput } from "@/api/gen/model/customInput";
|
||||||
|
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
|
import { Section } from "@/components/section";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Loadable } from "@/lib/query";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
|
||||||
|
function customId(entry: GameEntry): string {
|
||||||
|
return entry.id.startsWith("custom:")
|
||||||
|
? entry.id.slice("custom:".length)
|
||||||
|
: entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
title: string;
|
||||||
|
portrait: string;
|
||||||
|
hero: string;
|
||||||
|
header: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: FormState = {
|
||||||
|
title: "",
|
||||||
|
portrait: "",
|
||||||
|
hero: "",
|
||||||
|
header: "",
|
||||||
|
command: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formFrom(entry: GameEntry): FormState {
|
||||||
|
return {
|
||||||
|
title: entry.title,
|
||||||
|
portrait: entry.art.portrait ?? "",
|
||||||
|
hero: entry.art.hero ?? "",
|
||||||
|
header: entry.art.header ?? "",
|
||||||
|
command: entry.launch?.kind === "command" ? entry.launch.value : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map the form to the API body — only attach `launch` when a command was given. */
|
||||||
|
function toInput(f: FormState): CustomInput {
|
||||||
|
const trim = (s: string) => {
|
||||||
|
const t = s.trim();
|
||||||
|
return t ? t : undefined;
|
||||||
|
};
|
||||||
|
const command = f.command.trim();
|
||||||
|
return {
|
||||||
|
title: f.title.trim(),
|
||||||
|
art: {
|
||||||
|
portrait: trim(f.portrait),
|
||||||
|
hero: trim(f.hero),
|
||||||
|
header: trim(f.header),
|
||||||
|
},
|
||||||
|
launch: command ? { kind: "command", value: command } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LibraryView: FC<{
|
||||||
|
library: Loadable<GameEntry[]>;
|
||||||
|
onCreate: (data: CustomInput) => Promise<unknown>;
|
||||||
|
onUpdate: (id: string, data: CustomInput) => Promise<unknown>;
|
||||||
|
onDelete: (id: string) => Promise<unknown>;
|
||||||
|
isSaving: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}> = ({ library, onCreate, onUpdate, onDelete, isSaving, isDeleting }) => {
|
||||||
|
// null = form hidden; "" = adding a new entry; an id = editing that custom entry.
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
|
||||||
|
const games = library.data ?? [];
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setEditing("");
|
||||||
|
};
|
||||||
|
const openEdit = (entry: GameEntry) => {
|
||||||
|
setForm(formFrom(entry));
|
||||||
|
setEditing(customId(entry));
|
||||||
|
};
|
||||||
|
const closeForm = () => setEditing(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = toInput(form);
|
||||||
|
if (!data.title) return;
|
||||||
|
await (editing ? onUpdate(editing, data) : onCreate(data));
|
||||||
|
closeForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (entry: GameEntry) => {
|
||||||
|
if (!confirm(m.library_delete_confirm())) return;
|
||||||
|
await onDelete(customId(entry));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
|
||||||
|
{editing === null && (
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{m.library_add_button()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing !== null && (
|
||||||
|
<Card className="max-w-xl">
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle>
|
||||||
|
{editing ? m.library_edit_title() : m.library_add_title()}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.library_cancel()}
|
||||||
|
onClick={closeForm}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-title"
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, title: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-portrait">
|
||||||
|
{m.library_field_portrait()}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-portrait"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
value={form.portrait}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, portrait: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-hero"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
value={form.hero}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, hero: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-header"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
value={form.header}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, header: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-command"
|
||||||
|
value={form.command}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, command: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{m.library_field_command_help()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={isSaving || !form.title.trim()}>
|
||||||
|
{editing ? m.library_save() : m.library_create()}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={closeForm}>
|
||||||
|
{m.library_cancel()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<QueryState
|
||||||
|
isLoading={library.isLoading}
|
||||||
|
error={library.error}
|
||||||
|
refetch={library.refetch}
|
||||||
|
>
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{m.library_empty()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{games.map((game) => (
|
||||||
|
<GameCard
|
||||||
|
key={game.id}
|
||||||
|
game={game}
|
||||||
|
onEdit={() => openEdit(game)}
|
||||||
|
onDelete={() => handleDelete(game)}
|
||||||
|
deleting={isDeleting}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GameCardProps {
|
||||||
|
game: GameEntry;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
deleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
|
||||||
|
* falls back to the wide header, then to a text placeholder. Custom entries get
|
||||||
|
* edit/delete affordances.
|
||||||
|
*/
|
||||||
|
const GameCard: FC<GameCardProps> = ({ game, onEdit, onDelete, deleting }) => {
|
||||||
|
const isCustom = game.store === "custom";
|
||||||
|
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
|
||||||
|
const [failed, setFailed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const candidates = [game.art.portrait, game.art.header].filter(
|
||||||
|
(u): u is string => !!u && !failed[u],
|
||||||
|
);
|
||||||
|
const src = candidates[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group relative overflow-hidden">
|
||||||
|
<div className="relative aspect-[2/3] bg-muted">
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={game.title}
|
||||||
|
loading="lazy"
|
||||||
|
className="size-full object-cover"
|
||||||
|
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
|
||||||
|
{game.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute left-2 top-2">
|
||||||
|
<Badge
|
||||||
|
variant={isCustom ? "secondary" : "outline"}
|
||||||
|
className="bg-background/80 backdrop-blur"
|
||||||
|
>
|
||||||
|
{isCustom ? m.library_store_custom() : m.library_store_steam()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{isCustom && (
|
||||||
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 bg-background/80 backdrop-blur"
|
||||||
|
aria-label={m.library_edit()}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 bg-background/80 backdrop-blur"
|
||||||
|
aria-label={m.library_delete()}
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate p-2 text-sm font-medium" title={game.title}>
|
||||||
|
{game.title}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { LoginView } from "./view";
|
||||||
|
|
||||||
|
export const SectionLogin: FC<{ next?: string }> = ({ next }) => {
|
||||||
|
useLocale();
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async (password: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/_auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(true);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Full reload to the target so SSR re-runs WITH the new session cookie. Only a
|
||||||
|
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
|
||||||
|
const safe =
|
||||||
|
next && next.startsWith("/") && !next.startsWith("//") ? next : "/";
|
||||||
|
window.location.href = safe;
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <LoginView onSubmit={onSubmit} error={error} busy={busy} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import Logo from "@/components/logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
export const LoginView: FC<{
|
||||||
|
onSubmit: (password: string) => void;
|
||||||
|
error: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
}> = ({ onSubmit, error, busy }) => {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex w-[80px] items-center gap-2">
|
||||||
|
<Logo />
|
||||||
|
</div>
|
||||||
|
<CardTitle>{m.login_title()}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(password);
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="pw">{m.login_password()}</Label>
|
||||||
|
<Input
|
||||||
|
id="pw"
|
||||||
|
type="password"
|
||||||
|
// biome-ignore lint/a11y/noAutofocus: the login screen is the sole focus target.
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{m.login_error()}</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={busy || !password}
|
||||||
|
>
|
||||||
|
{busy ? m.login_signing_in() : m.login_submit()}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
KeyRound,
|
||||||
|
Smartphone,
|
||||||
|
Timer,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { type FC, useState } from "react";
|
||||||
|
import {
|
||||||
|
getGetNativePairingQueryKey,
|
||||||
|
getListNativeClientsQueryKey,
|
||||||
|
getListPendingDevicesQueryKey,
|
||||||
|
useApprovePendingDevice,
|
||||||
|
useArmNativePairing,
|
||||||
|
useDenyPendingDevice,
|
||||||
|
useDisarmNativePairing,
|
||||||
|
useGetNativePairing,
|
||||||
|
useListNativeClients,
|
||||||
|
useListPendingDevices,
|
||||||
|
useUnpairNativeClient,
|
||||||
|
} from "@/api/gen/native/native";
|
||||||
|
import {
|
||||||
|
getGetPairingStatusQueryKey,
|
||||||
|
useGetPairingStatus,
|
||||||
|
useSubmitPairingPin,
|
||||||
|
} from "@/api/gen/pairing/pairing";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
|
import { Section } from "@/components/section";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
/** Seconds → `m:ss`. */
|
||||||
|
function fmtTime(secs: number): string {
|
||||||
|
const s = Math.max(0, Math.floor(secs));
|
||||||
|
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pairing composes four independent sub-cards, each its own little container
|
||||||
|
// (own query + mutations). They share the page's staggered entrance via <Section>.
|
||||||
|
export const SectionPairing: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
||||||
|
<PendingDevices />
|
||||||
|
<NativePairingCard />
|
||||||
|
<NativeDevices />
|
||||||
|
<MoonlightPairingCard />
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Seconds since a knock → a short relative label. */
|
||||||
|
function fmtAge(secs: number): string {
|
||||||
|
if (secs < 10) return m.pairing_pending_age_just_now();
|
||||||
|
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
|
||||||
|
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devices awaiting delegated approval: an unpaired device that tried to connect
|
||||||
|
* shows up here, and Approve pairs it on the spot — no PIN fetched out of band.
|
||||||
|
* Renders nothing while empty (the common case); polls so a knock appears while
|
||||||
|
* the operator is looking at the page.
|
||||||
|
*/
|
||||||
|
function PendingDevices() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
||||||
|
const approve = useApprovePendingDevice();
|
||||||
|
const deny = useDenyPendingDevice();
|
||||||
|
const rows = pending.data ?? [];
|
||||||
|
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
|
||||||
|
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
|
||||||
|
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
|
||||||
|
if (rows.length === 0 && !pending.error) return null;
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
|
||||||
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
||||||
|
};
|
||||||
|
const onApprove = (id: number, currentName: string) => {
|
||||||
|
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
||||||
|
if (name == null) return; // operator cancelled
|
||||||
|
approve.mutate(
|
||||||
|
{ id, data: { name: name.trim() ? name.trim() : null } },
|
||||||
|
{ onSuccess: refresh },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
{m.pairing_pending_title()}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_pending_desc()}
|
||||||
|
</p>
|
||||||
|
<QueryState
|
||||||
|
isLoading={pending.isLoading}
|
||||||
|
error={pending.error}
|
||||||
|
refetch={pending.refetch}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{p.fingerprint.slice(0, 16)}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{fmtAge(p.age_secs)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={approve.isPending || deny.isPending}
|
||||||
|
onClick={() => onApprove(p.id, p.name)}
|
||||||
|
>
|
||||||
|
{m.pairing_pending_approve()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={m.pairing_pending_deny()}
|
||||||
|
disabled={approve.isPending || deny.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
deny.mutate({ id: p.id }, { onSuccess: refresh })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
||||||
|
function NativePairingCard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
// Poll fast while armed (live countdown), slow otherwise.
|
||||||
|
const status = useGetNativePairing({
|
||||||
|
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
||||||
|
});
|
||||||
|
const arm = useArmNativePairing();
|
||||||
|
const disarm = useDisarmNativePairing();
|
||||||
|
const d = status.data;
|
||||||
|
const refresh = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryState
|
||||||
|
isLoading={status.isLoading}
|
||||||
|
error={status.error}
|
||||||
|
refetch={status.refetch}
|
||||||
|
>
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Smartphone className="size-4" />
|
||||||
|
{m.pairing_native_title()}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!d?.enabled ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_disabled()}
|
||||||
|
</p>
|
||||||
|
) : d.armed && d.pin ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm">{m.pairing_native_enter()}</p>
|
||||||
|
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
||||||
|
{d.pin}
|
||||||
|
</div>
|
||||||
|
{d.expires_in_secs != null && (
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Timer className="size-4" />
|
||||||
|
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={disarm.isPending}
|
||||||
|
onClick={() => disarm.mutate(undefined, { onSuccess: refresh })}
|
||||||
|
>
|
||||||
|
{m.pairing_native_cancel()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_desc()}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
disabled={arm.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
arm.mutate(
|
||||||
|
{ data: { ttl_secs: 120 } },
|
||||||
|
{ onSuccess: refresh },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{m.pairing_native_arm()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The paired native (punktfunk/1) devices, with unpair. */
|
||||||
|
function NativeDevices() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const clients = useListNativeClients();
|
||||||
|
const unpair = useUnpairNativeClient();
|
||||||
|
const rows = clients.data ?? [];
|
||||||
|
|
||||||
|
const onUnpair = (fingerprint: string) => {
|
||||||
|
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
||||||
|
unpair.mutate(
|
||||||
|
{ fingerprint },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
||||||
|
<QueryState
|
||||||
|
isLoading={clients.isLoading}
|
||||||
|
error={clients.error}
|
||||||
|
refetch={clients.refetch}
|
||||||
|
>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_empty()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{m.clients_name()}</TableHead>
|
||||||
|
<TableHead>{m.clients_fingerprint()}</TableHead>
|
||||||
|
<TableHead className="w-12" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((c) => (
|
||||||
|
<TableRow key={c.fingerprint}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{c.name || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{c.fingerprint.slice(0, 16)}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.action_unpair()}
|
||||||
|
disabled={unpair.isPending}
|
||||||
|
onClick={() => onUnpair(c.fingerprint)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
||||||
|
function MoonlightPairingCard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
||||||
|
const submit = useSubmitPairingPin();
|
||||||
|
const pending = pairing.data?.pin_pending ?? false;
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit.mutate(
|
||||||
|
{ data: { pin } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setPin("");
|
||||||
|
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryState
|
||||||
|
isLoading={pairing.isLoading}
|
||||||
|
error={pairing.error}
|
||||||
|
refetch={pairing.refetch}
|
||||||
|
>
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{m.pairing_moonlight_title()}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!pending ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<p className="text-sm">{m.pairing_waiting()}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
||||||
|
<Input
|
||||||
|
id="pin"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
maxLength={8}
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
||||||
|
placeholder="0000"
|
||||||
|
className="font-mono text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={pin.length < 4 || submit.isPending}
|
||||||
|
>
|
||||||
|
{m.pairing_submit()}
|
||||||
|
</Button>
|
||||||
|
{submit.isSuccess && (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
{m.pairing_success()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{submit.isError && (
|
||||||
|
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { Section } from "@/components/section";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
// Settings reads no API (just the locale + a logout button), so it's a single
|
||||||
|
// presentational section — no container/view split needed.
|
||||||
|
export const SectionSettings: FC = () => {
|
||||||
|
const current = useLocale();
|
||||||
|
|
||||||
|
const onLogout = async () => {
|
||||||
|
await fetch("/_auth/logout", { method: "POST" });
|
||||||
|
window.location.href = "/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
|
||||||
|
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.settings_language()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex gap-2">
|
||||||
|
{locales.map((l: Locale) => (
|
||||||
|
<Button
|
||||||
|
key={l}
|
||||||
|
variant={l === current ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="uppercase"
|
||||||
|
onClick={() => changeLocale(l)}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.nav_settings()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" onClick={onLogout}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
{m.action_logout()}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,62 +1,78 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import {
|
import {
|
||||||
createMemoryHistory,
|
createMemoryHistory,
|
||||||
createRootRoute,
|
createRootRoute,
|
||||||
createRoute,
|
createRoute,
|
||||||
createRouter,
|
createRouter,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
} from '@tanstack/react-router'
|
} from "@tanstack/react-router";
|
||||||
import { AppShell } from '@/components/app-shell'
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
|
||||||
// AppShell is built from TanStack Router <Link>s, so it needs a router context.
|
// AppShell is built from TanStack Router <Link>s, so it needs a router context.
|
||||||
// We stand up a throwaway in-memory router whose routes mirror the nav targets
|
// We stand up a throwaway in-memory router whose routes mirror the nav targets
|
||||||
// (so links resolve + the active highlight works) and render the shell from the
|
// (so links resolve + the active highlight works) and render the shell from the
|
||||||
// root route. No loaders/data — purely for designing the chrome offline.
|
// root route. No loaders/data — purely for designing the chrome offline.
|
||||||
function ShellHarness({ initialPath }: { initialPath: string }) {
|
function ShellHarness({ initialPath }: { initialPath: string }) {
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Placeholder content — swap routes from the sidebar to preview the active state.
|
Placeholder content — swap routes from the sidebar to preview the
|
||||||
</p>
|
active state.
|
||||||
</div>
|
</p>
|
||||||
</AppShell>
|
</div>
|
||||||
),
|
</AppShell>
|
||||||
})
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const navPaths = ['/', '/host', '/library', '/clients', '/pairing', '/settings']
|
const navPaths = [
|
||||||
const navRoutes = navPaths.map((path) =>
|
"/",
|
||||||
createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
|
"/host",
|
||||||
)
|
"/library",
|
||||||
// Splat so any other <Link> target still resolves without throwing.
|
"/clients",
|
||||||
const splat = createRoute({ getParentRoute: () => rootRoute, path: '$', component: () => null })
|
"/pairing",
|
||||||
|
"/settings",
|
||||||
|
];
|
||||||
|
const navRoutes = navPaths.map((path) =>
|
||||||
|
createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path,
|
||||||
|
component: () => null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Splat so any other <Link> target still resolves without throwing.
|
||||||
|
const splat = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "$",
|
||||||
|
component: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree: rootRoute.addChildren([...navRoutes, splat]),
|
routeTree: rootRoute.addChildren([...navRoutes, splat]),
|
||||||
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
||||||
})
|
});
|
||||||
|
|
||||||
return <RouterProvider router={router} />
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Shell/AppShell',
|
title: "Shell/AppShell",
|
||||||
component: AppShell,
|
component: AppShell,
|
||||||
parameters: { layout: 'fullscreen' },
|
parameters: { layout: "fullscreen" },
|
||||||
// AppShell requires `children`; the harness supplies the real content, so this
|
// AppShell requires `children`; the harness supplies the real content, so this
|
||||||
// placeholder just satisfies the arg type.
|
// placeholder just satisfies the arg type.
|
||||||
args: { children: null },
|
args: { children: null },
|
||||||
} satisfies Meta<typeof AppShell>
|
} satisfies Meta<typeof AppShell>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Dashboard: Story = {
|
export const Dashboard: Story = {
|
||||||
render: () => <ShellHarness initialPath="/" />,
|
render: () => <ShellHarness initialPath="/" />,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const HostActive: Story = {
|
export const HostActive: Story = {
|
||||||
render: () => <ShellHarness initialPath="/host" />,
|
render: () => <ShellHarness initialPath="/host" />,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,30 +1,36 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
const VARIANTS = ['default', 'secondary', 'success', 'destructive', 'outline'] as const
|
const VARIANTS = [
|
||||||
|
"default",
|
||||||
|
"secondary",
|
||||||
|
"success",
|
||||||
|
"destructive",
|
||||||
|
"outline",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'UI/Badge',
|
title: "UI/Badge",
|
||||||
component: Badge,
|
component: Badge,
|
||||||
args: { children: 'badge' },
|
args: { children: "badge" },
|
||||||
argTypes: {
|
argTypes: {
|
||||||
variant: { control: 'select', options: VARIANTS },
|
variant: { control: "select", options: VARIANTS },
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof Badge>
|
} satisfies Meta<typeof Badge>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Playground: Story = {}
|
export const Playground: Story = {};
|
||||||
|
|
||||||
export const All: Story = {
|
export const All: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{VARIANTS.map((variant) => (
|
{VARIANTS.map((variant) => (
|
||||||
<Badge key={variant} variant={variant}>
|
<Badge key={variant} variant={variant}>
|
||||||
{variant}
|
{variant}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { BrandMark } from '@/components/brand-mark'
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
import { Wordmark } from '@/components/wordmark'
|
import { Logo } from "@/components/logo";
|
||||||
import { Logo } from '@/components/logo'
|
import { Wordmark } from "@/components/wordmark";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Brand/Marks',
|
title: "Brand/Marks",
|
||||||
component: BrandMark,
|
component: BrandMark,
|
||||||
} satisfies Meta<typeof BrandMark>
|
} satisfies Meta<typeof BrandMark>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Mark: Story = {
|
export const Mark: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex items-end gap-6">
|
<div className="flex items-end gap-6">
|
||||||
<BrandMark className="size-8" />
|
<BrandMark className="size-8" />
|
||||||
<BrandMark className="size-12" />
|
<BrandMark className="size-12" />
|
||||||
<BrandMark className="size-20" />
|
<BrandMark className="size-20" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Word: Story = {
|
export const Word: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Wordmark className="h-4" />
|
<Wordmark className="h-4" />
|
||||||
<Wordmark className="h-6 text-foreground" />
|
<Wordmark className="h-6 text-foreground" />
|
||||||
<Wordmark className="h-8 text-primary" />
|
<Wordmark className="h-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Lockup: Story = {
|
export const Lockup: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="pl-8 pt-6">
|
<div className="pl-8 pt-6">
|
||||||
<Logo className="w-48" />
|
<Logo className="w-48" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,48 +1,55 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { Play } from 'lucide-react'
|
import { Play } from "lucide-react";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const VARIANTS = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const
|
const VARIANTS = [
|
||||||
const SIZES = ['default', 'sm', 'lg', 'icon'] as const
|
"default",
|
||||||
|
"secondary",
|
||||||
|
"outline",
|
||||||
|
"ghost",
|
||||||
|
"link",
|
||||||
|
"destructive",
|
||||||
|
] as const;
|
||||||
|
const SIZES = ["default", "sm", "lg", "icon"] as const;
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'UI/Button',
|
title: "UI/Button",
|
||||||
component: Button,
|
component: Button,
|
||||||
args: { children: 'Stream' },
|
args: { children: "Stream" },
|
||||||
argTypes: {
|
argTypes: {
|
||||||
variant: { control: 'select', options: VARIANTS },
|
variant: { control: "select", options: VARIANTS },
|
||||||
size: { control: 'select', options: SIZES },
|
size: { control: "select", options: SIZES },
|
||||||
disabled: { control: 'boolean' },
|
disabled: { control: "boolean" },
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof Button>
|
} satisfies Meta<typeof Button>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
/** Playground — drive variant/size/disabled from the Controls panel. */
|
/** Playground — drive variant/size/disabled from the Controls panel. */
|
||||||
export const Playground: Story = {}
|
export const Playground: Story = {};
|
||||||
|
|
||||||
export const Variants: Story = {
|
export const Variants: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{VARIANTS.map((variant) => (
|
{VARIANTS.map((variant) => (
|
||||||
<Button key={variant} variant={variant}>
|
<Button key={variant} variant={variant}>
|
||||||
{variant}
|
{variant}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Sizes: Story = {
|
export const Sizes: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button size="sm">Small</Button>
|
<Button size="sm">Small</Button>
|
||||||
<Button size="default">Default</Button>
|
<Button size="default">Default</Button>
|
||||||
<Button size="lg">Large</Button>
|
<Button size="lg">Large</Button>
|
||||||
<Button size="icon" aria-label="Play">
|
<Button size="icon" aria-label="Play">
|
||||||
<Play className="size-4" />
|
<Play className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from "@/components/ui/card";
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'UI/Card',
|
title: "UI/Card",
|
||||||
component: Card,
|
component: Card,
|
||||||
// Card requires `children`; every story supplies its own via `render`, so this
|
// Card requires `children`; every story supplies its own via `render`, so this
|
||||||
// is just a placeholder to satisfy the arg type.
|
// is just a placeholder to satisfy the arg type.
|
||||||
args: { children: null },
|
args: { children: null },
|
||||||
} satisfies Meta<typeof Card>
|
} satisfies Meta<typeof Card>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const HostCard: Story = {
|
export const HostCard: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Card className="max-w-sm">
|
<Card className="max-w-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>ENRICOS-DESKTOP</CardTitle>
|
<CardTitle>ENRICOS-DESKTOP</CardTitle>
|
||||||
<Badge variant="success">online</Badge>
|
<Badge variant="success">online</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>RTX 5070 Ti · NVENC · 5120×1440 @ 240</CardDescription>
|
<CardDescription>RTX 5070 Ti · NVENC · 5120×1440 @ 240</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
Paired 2 days ago. Last session 11 ms p50 capture→present.
|
Paired 2 days ago. Last session 11 ms p50 capture→present.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="gap-2">
|
<CardFooter className="gap-2">
|
||||||
<Button size="sm">Connect</Button>
|
<Button size="sm">Connect</Button>
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline">
|
||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { ClientsPage } from '@/routes/clients'
|
import { ClientsView } from "@/sections/Clients/view";
|
||||||
import { MockApi } from './lib/mock-api'
|
import { pairedClients } from "./lib/fixtures";
|
||||||
import { pairedClients } from './lib/fixtures'
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Pages/Clients',
|
title: "Pages/Clients",
|
||||||
component: ClientsPage,
|
component: ClientsView,
|
||||||
} satisfies Meta<typeof ClientsPage>
|
args: { onUnpair: () => {}, isUnpairing: false },
|
||||||
|
} satisfies Meta<typeof ClientsView>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Paired: Story = {
|
export const Paired: Story = {
|
||||||
render: () => (
|
args: { clients: { data: pairedClients, isLoading: false, error: null } },
|
||||||
<MockApi routes={{ '/api/v1/clients': pairedClients }}>
|
};
|
||||||
<ClientsPage />
|
|
||||||
</MockApi>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
render: () => (
|
args: { clients: { data: [], isLoading: false, error: null } },
|
||||||
<MockApi routes={{ '/api/v1/clients': [] }}>
|
};
|
||||||
<ClientsPage />
|
|
||||||
</MockApi>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { Dashboard } from '@/routes/index'
|
import { DashboardView } from "@/sections/Dashboard/view";
|
||||||
import { MockApi } from './lib/mock-api'
|
import { statusActive, statusIdle } from "./lib/fixtures";
|
||||||
import { statusActive, statusIdle } from './lib/fixtures'
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Pages/Dashboard',
|
title: "Pages/Dashboard",
|
||||||
component: Dashboard,
|
component: DashboardView,
|
||||||
} satisfies Meta<typeof Dashboard>
|
args: {
|
||||||
|
onStopSession: () => {},
|
||||||
|
onRequestIdr: () => {},
|
||||||
|
isStopping: false,
|
||||||
|
isRequestingIdr: false,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof DashboardView>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const ActiveSession: Story = {
|
export const ActiveSession: Story = {
|
||||||
render: () => (
|
args: { status: { data: statusActive, isLoading: false, error: null } },
|
||||||
<MockApi routes={{ '/api/v1/status': statusActive }}>
|
};
|
||||||
<Dashboard />
|
|
||||||
</MockApi>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Idle: Story = {
|
export const Idle: Story = {
|
||||||
render: () => (
|
args: { status: { data: statusIdle, isLoading: false, error: null } },
|
||||||
<MockApi routes={{ '/api/v1/status': statusIdle }}>
|
};
|
||||||
<Dashboard />
|
|
||||||
</MockApi>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { HostPage } from '@/routes/host'
|
import { HostView } from "@/sections/Host/view";
|
||||||
import { MockApi } from './lib/mock-api'
|
import { compositors, hostInfo } from "./lib/fixtures";
|
||||||
import { compositors, hostInfo } from './lib/fixtures'
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Pages/Host',
|
title: "Pages/Host",
|
||||||
component: HostPage,
|
component: HostView,
|
||||||
} satisfies Meta<typeof HostPage>
|
args: {
|
||||||
|
host: { data: hostInfo, isLoading: false, error: null },
|
||||||
|
compositors: { data: compositors, isLoading: false, error: null },
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof HostView>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {};
|
||||||
render: () => (
|
|
||||||
<MockApi routes={{ '/api/v1/host': hostInfo, '/api/v1/compositors': compositors }}>
|
export const Loading: Story = {
|
||||||
<HostPage />
|
args: {
|
||||||
</MockApi>
|
host: { data: undefined, isLoading: true, error: null },
|
||||||
),
|
compositors: { data: undefined, isLoading: true, error: null },
|
||||||
}
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'UI/Inputs',
|
title: "UI/Inputs",
|
||||||
component: Input,
|
component: Input,
|
||||||
} satisfies Meta<typeof Input>
|
} satisfies Meta<typeof Input>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Form: Story = {
|
export const Form: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="max-w-sm space-y-4">
|
<div className="max-w-sm space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="host">Host address</Label>
|
<Label htmlFor="host">Host address</Label>
|
||||||
<Input id="host" placeholder="192.168.1.173" />
|
<Input id="host" placeholder="192.168.1.173" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="pin">Pairing PIN</Label>
|
<Label htmlFor="pin">Pairing PIN</Label>
|
||||||
<Input id="pin" inputMode="numeric" maxLength={4} placeholder="0000" />
|
<Input id="pin" inputMode="numeric" maxLength={4} placeholder="0000" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="disabled">Disabled</Label>
|
<Label htmlFor="disabled">Disabled</Label>
|
||||||
<Input id="disabled" disabled placeholder="unavailable" />
|
<Input id="disabled" disabled placeholder="unavailable" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { LibraryPage } from '@/routes/library'
|
import { LibraryView } from "@/sections/Library/view";
|
||||||
import { MockApi } from './lib/mock-api'
|
import { library } from "./lib/fixtures";
|
||||||
import { library } from './lib/fixtures'
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Pages/Library',
|
title: "Pages/Library",
|
||||||
component: LibraryPage,
|
component: LibraryView,
|
||||||
} satisfies Meta<typeof LibraryPage>
|
args: {
|
||||||
|
onCreate: () => Promise.resolve(),
|
||||||
|
onUpdate: () => Promise.resolve(),
|
||||||
|
onDelete: () => Promise.resolve(),
|
||||||
|
isSaving: false,
|
||||||
|
isDeleting: false,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof LibraryView>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Populated: Story = {
|
export const Populated: Story = {
|
||||||
render: () => (
|
args: { library: { data: library, isLoading: false, error: null } },
|
||||||
<MockApi routes={{ '/api/v1/library': library }}>
|
};
|
||||||
<LibraryPage />
|
|
||||||
</MockApi>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
render: () => (
|
args: { library: { data: [], isLoading: false, error: null } },
|
||||||
<MockApi routes={{ '/api/v1/library': [] }}>
|
};
|
||||||
<LibraryPage />
|
|
||||||
</MockApi>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { LoginView } from "@/sections/Login/view";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Pages/Login",
|
||||||
|
component: LoginView,
|
||||||
|
parameters: { layout: "fullscreen" },
|
||||||
|
args: { onSubmit: () => {}, error: false, busy: false },
|
||||||
|
} satisfies Meta<typeof LoginView>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Error: Story = { args: { error: true } };
|
||||||
@@ -1,36 +1,42 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { QueryState } from '@/components/query-state'
|
import { ApiError } from "@/api/fetcher";
|
||||||
import { ApiError } from '@/api/fetcher'
|
import { QueryState } from "@/components/query-state";
|
||||||
|
|
||||||
// QueryState is the uniform loading/error wrapper every data-backed route uses —
|
// QueryState is the uniform loading/error wrapper every data-backed route uses —
|
||||||
// the most useful thing to design WITHOUT a running host, since its three states
|
// the most useful thing to design WITHOUT a running host, since its three states
|
||||||
// (loading spinner / error / unauthorized) never appear together live.
|
// (loading spinner / error / unauthorized) never appear together live.
|
||||||
const Loaded = () => (
|
const Loaded = () => (
|
||||||
<div className="rounded-lg border p-4 text-sm">Loaded content renders here.</div>
|
<div className="rounded-lg border p-4 text-sm">
|
||||||
)
|
Loaded content renders here.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Patterns/QueryState',
|
title: "Patterns/QueryState",
|
||||||
component: QueryState,
|
component: QueryState,
|
||||||
args: { children: <Loaded /> },
|
args: { children: <Loaded /> },
|
||||||
} satisfies Meta<typeof QueryState>
|
} satisfies Meta<typeof QueryState>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
args: { isLoading: true, error: null },
|
args: { isLoading: true, error: null },
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ErrorWithRetry: Story = {
|
export const ErrorWithRetry: Story = {
|
||||||
args: { isLoading: false, error: new Error('connection refused'), refetch: () => {} },
|
args: {
|
||||||
}
|
isLoading: false,
|
||||||
|
error: new Error("connection refused"),
|
||||||
|
refetch: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Unauthorized: Story = {
|
export const Unauthorized: Story = {
|
||||||
args: { isLoading: false, error: new ApiError(401, null) },
|
args: { isLoading: false, error: new ApiError(401, null) },
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Loaded_: Story = {
|
export const Loaded_: Story = {
|
||||||
name: 'Success',
|
name: "Success",
|
||||||
args: { isLoading: false, error: null },
|
args: { isLoading: false, error: null },
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { SettingsPage } from '@/routes/settings'
|
import { SectionSettings } from "@/sections/Settings";
|
||||||
|
|
||||||
// Settings reads no API (just the locale + a logout button), so it renders
|
|
||||||
// directly — no mock needed.
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Pages/Settings',
|
title: "Pages/Settings",
|
||||||
component: SettingsPage,
|
component: SectionSettings,
|
||||||
} satisfies Meta<typeof SettingsPage>
|
} satisfies Meta<typeof SectionSettings>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Default: Story = {}
|
export const Default: Story = {};
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'UI/Spinner',
|
title: "UI/Spinner",
|
||||||
component: Spinner,
|
component: Spinner,
|
||||||
} satisfies Meta<typeof Spinner>
|
} satisfies Meta<typeof Spinner>;
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Default: Story = {}
|
export const Default: Story = {};
|
||||||
|
|
||||||
export const Large: Story = {
|
export const Large: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex min-h-60 items-center justify-center">
|
<div className="flex min-h-60 items-center justify-center">
|
||||||
<Spinner className="size-40" />
|
<Spinner className="size-40" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Sizes: Story = {
|
export const Sizes: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
<Spinner className="size-6" />
|
<Spinner className="size-6" />
|
||||||
<Spinner className="size-10" />
|
<Spinner className="size-10" />
|
||||||
<Spinner className="size-10 text-primary" />
|
<Spinner className="size-10 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
+100
-73
@@ -1,87 +1,114 @@
|
|||||||
// Mock API payloads for the page stories — typed against the generated models so
|
// Mock API payloads for the page stories — typed against the generated models so
|
||||||
// they stay honest if the OpenAPI schema changes.
|
// they stay honest if the OpenAPI schema changes.
|
||||||
import type { AvailableCompositor } from '@/api/gen/model/availableCompositor'
|
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
||||||
import type { GameEntry } from '@/api/gen/model/gameEntry'
|
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||||
import type { HostInfo } from '@/api/gen/model/hostInfo'
|
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||||
import type { PairedClient } from '@/api/gen/model/pairedClient'
|
import type { PairedClient } from "@/api/gen/model/pairedClient";
|
||||||
import type { RuntimeStatus } from '@/api/gen/model/runtimeStatus'
|
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
||||||
|
|
||||||
export const hostInfo: HostInfo = {
|
export const hostInfo: HostInfo = {
|
||||||
abi_version: 2,
|
abi_version: 2,
|
||||||
app_version: '7.1.450.0',
|
app_version: "7.1.450.0",
|
||||||
codecs: ['h264', 'h265', 'av1'],
|
codecs: ["h264", "h265", "av1"],
|
||||||
gfe_version: '3.23.0.74',
|
gfe_version: "3.23.0.74",
|
||||||
hostname: 'ENRICOS-DESKTOP',
|
hostname: "ENRICOS-DESKTOP",
|
||||||
local_ip: '192.168.1.173',
|
local_ip: "192.168.1.173",
|
||||||
ports: {
|
ports: {
|
||||||
audio: 48000,
|
audio: 48000,
|
||||||
control: 47999,
|
control: 47999,
|
||||||
http: 47989,
|
http: 47989,
|
||||||
https: 47984,
|
https: 47984,
|
||||||
mgmt: 47990,
|
mgmt: 47990,
|
||||||
rtsp: 48010,
|
rtsp: 48010,
|
||||||
video: 47998,
|
video: 47998,
|
||||||
},
|
},
|
||||||
uniqueid: '0f8a1c3e9b7d4a62',
|
uniqueid: "0f8a1c3e9b7d4a62",
|
||||||
version: '0.2.0',
|
version: "0.2.0",
|
||||||
}
|
};
|
||||||
|
|
||||||
export const compositors: AvailableCompositor[] = [
|
export const compositors: AvailableCompositor[] = [
|
||||||
{ id: 'kwin', label: 'KWin (Plasma)', available: true, default: true },
|
{ id: "kwin", label: "KWin (Plasma)", available: true, default: true },
|
||||||
{ id: 'gamescope', label: 'gamescope', available: true, default: false },
|
{ id: "gamescope", label: "gamescope", available: true, default: false },
|
||||||
{ id: 'mutter', label: 'Mutter (GNOME)', available: false, default: false },
|
{ id: "mutter", label: "Mutter (GNOME)", available: false, default: false },
|
||||||
{ id: 'wlroots', label: 'Sway / wlroots', available: false, default: false },
|
{ id: "wlroots", label: "Sway / wlroots", available: false, default: false },
|
||||||
]
|
];
|
||||||
|
|
||||||
export const statusActive: RuntimeStatus = {
|
export const statusActive: RuntimeStatus = {
|
||||||
video_streaming: true,
|
video_streaming: true,
|
||||||
audio_streaming: true,
|
audio_streaming: true,
|
||||||
paired_clients: 3,
|
paired_clients: 3,
|
||||||
pin_pending: false,
|
pin_pending: false,
|
||||||
session: { width: 5120, height: 1440, fps: 240 },
|
session: { width: 5120, height: 1440, fps: 240 },
|
||||||
stream: {
|
stream: {
|
||||||
codec: 'h265',
|
codec: "h265",
|
||||||
width: 5120,
|
width: 5120,
|
||||||
height: 1440,
|
height: 1440,
|
||||||
fps: 240,
|
fps: 240,
|
||||||
bitrate_kbps: 150_000,
|
bitrate_kbps: 150_000,
|
||||||
min_fec: 5,
|
min_fec: 5,
|
||||||
packet_size: 1392,
|
packet_size: 1392,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const statusIdle: RuntimeStatus = {
|
export const statusIdle: RuntimeStatus = {
|
||||||
video_streaming: false,
|
video_streaming: false,
|
||||||
audio_streaming: false,
|
audio_streaming: false,
|
||||||
paired_clients: 1,
|
paired_clients: 1,
|
||||||
pin_pending: true,
|
pin_pending: true,
|
||||||
session: null,
|
session: null,
|
||||||
stream: null,
|
stream: null,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const pairedClients: PairedClient[] = [
|
export const pairedClients: PairedClient[] = [
|
||||||
{
|
{
|
||||||
fingerprint: 'a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00',
|
fingerprint:
|
||||||
subject: 'enricos-macbook',
|
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
||||||
not_before_unix: 1_718_000_000,
|
subject: "enricos-macbook",
|
||||||
not_after_unix: 2_030_000_000,
|
not_before_unix: 1_718_000_000,
|
||||||
},
|
not_after_unix: 2_030_000_000,
|
||||||
{
|
},
|
||||||
fingerprint: 'ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1',
|
{
|
||||||
subject: 'living-room-tv',
|
fingerprint:
|
||||||
not_before_unix: 1_718_500_000,
|
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
|
||||||
not_after_unix: 2_030_000_000,
|
subject: "living-room-tv",
|
||||||
},
|
not_before_unix: 1_718_500_000,
|
||||||
{
|
not_after_unix: 2_030_000_000,
|
||||||
fingerprint: '0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff',
|
},
|
||||||
subject: null,
|
{
|
||||||
},
|
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[] = [
|
export const library: GameEntry[] = [
|
||||||
{ id: 'steam:1245620', store: 'steam', title: 'Elden Ring', art: noArt, launch: null },
|
{
|
||||||
{ id: 'steam:1086940', store: 'steam', title: "Baldur's Gate 3", art: noArt, launch: null },
|
id: "steam:1245620",
|
||||||
{ id: 'steam:413150', store: 'steam', title: 'Stardew Valley', art: noArt, launch: null },
|
store: "steam",
|
||||||
{ id: 'custom:retroarch', store: 'custom', title: 'RetroArch', art: noArt, launch: null },
|
title: "Elden Ring",
|
||||||
]
|
art: noArt,
|
||||||
|
launch: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "steam:1086940",
|
||||||
|
store: "steam",
|
||||||
|
title: "Baldur's Gate 3",
|
||||||
|
art: noArt,
|
||||||
|
launch: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "steam:413150",
|
||||||
|
store: "steam",
|
||||||
|
title: "Stardew Valley",
|
||||||
|
art: noArt,
|
||||||
|
launch: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "custom:retroarch",
|
||||||
|
store: "custom",
|
||||||
|
title: "RetroArch",
|
||||||
|
art: noArt,
|
||||||
|
launch: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
/** Map of API pathname (e.g. `/api/v1/host`) → JSON body to return for a GET. */
|
|
||||||
export type MockRoutes = Record<string, unknown>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a data-backed page WITHOUT a running host by stubbing `window.fetch`
|
|
||||||
* for the lifetime of the story: matched pathnames return their mock JSON (200),
|
|
||||||
* everything else returns `{}` (200) so mutations + polling never error. The
|
|
||||||
* real orval/React-Query hooks run unchanged, so loading/success transitions and
|
|
||||||
* `refetchInterval` behave exactly as in the app. Each story gets a fresh,
|
|
||||||
* isolated QueryClient (retries off).
|
|
||||||
*/
|
|
||||||
export function MockApi({ routes, children }: { routes: MockRoutes; children: ReactNode }) {
|
|
||||||
// Read the latest routes inside the stub without re-installing it.
|
|
||||||
const routesRef = useRef(routes)
|
|
||||||
routesRef.current = routes
|
|
||||||
const [stubbed, setStubbed] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const real = window.fetch
|
|
||||||
const stub = (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
|
||||||
const path = new URL(url, window.location.origin).pathname
|
|
||||||
const data = path in routesRef.current ? routesRef.current[path] : {}
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify(data ?? null), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
window.fetch = stub as typeof window.fetch
|
|
||||||
setStubbed(true)
|
|
||||||
return () => {
|
|
||||||
window.fetch = real
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [queryClient] = useState(
|
|
||||||
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Hold the first render until the stub is installed, so the page's initial
|
|
||||||
// query resolves against the mock rather than racing a real (failing) request.
|
|
||||||
if (!stubbed) return null
|
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
}
|
|
||||||
+160
-136
@@ -1,13 +1,13 @@
|
|||||||
@import 'tailwindcss';
|
@import "tailwindcss";
|
||||||
@import 'tw-animate-css';
|
@import "tw-animate-css";
|
||||||
@import './timing-functions.css';
|
@import "./timing-functions.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
/* Pull @unom/ui's compiled component classes (bg-neutral,
|
/* Pull @unom/ui's compiled component classes (bg-neutral,
|
||||||
rounded-card, p-padding-card, ring-accent, h-input-height, material…) into the
|
rounded-card, p-padding-card, ring-accent, h-input-height, material…) into the
|
||||||
Tailwind 4 scan so their utilities aren't purged. */
|
Tailwind 4 scan so their utilities aren't purged. */
|
||||||
@source '../node_modules/@unom/ui/dist/**/*.{js,mjs}';
|
@source "../node_modules/@unom/ui/dist/**/*.{js,mjs}";
|
||||||
|
|
||||||
/* ── punktfunk brand · violet product chrome ────────────────────────────────
|
/* ── punktfunk brand · violet product chrome ────────────────────────────────
|
||||||
Two themes on one violet identity: LIGHT (lavender docs surface — the same
|
Two themes on one violet identity: LIGHT (lavender docs surface — the same
|
||||||
@@ -25,169 +25,193 @@
|
|||||||
(--main, --neutral*, --error → other tokens) live in :root only — CSS resolves
|
(--main, --neutral*, --error → other tokens) live in :root only — CSS resolves
|
||||||
var() per-theme at use time, so .dark overrides just the raw values. */
|
var() per-theme at use time, so .dark overrides just the raw values. */
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
|
||||||
/* Brand — the violet lens mark (from the punktfunk app icon). Theme-independent. */
|
/* Brand — the violet lens mark (from the punktfunk app icon). Theme-independent. */
|
||||||
--pf-brand: #6c5bf3; /* deep violet — primary on light */
|
--pf-brand: #6c5bf3; /* deep violet — primary on light */
|
||||||
--pf-brand-light: #a79ff8; /* light violet — primary on dark */
|
--pf-brand-light: #a79ff8; /* light violet — primary on dark */
|
||||||
--pf-highlight: #d2c9fb; /* lens highlight */
|
--pf-highlight: #d2c9fb; /* lens highlight */
|
||||||
|
|
||||||
/* Surfaces — light · lavender (white bg, faint-violet cards/borders). */
|
/* Surfaces — light · lavender (white bg, faint-violet cards/borders). */
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #1b1430;
|
--foreground: #1b1430;
|
||||||
--card: #f6f2ff;
|
--card: #f6f2ff;
|
||||||
--card-foreground: #1b1430;
|
--card-foreground: #1b1430;
|
||||||
--popover: #ffffff;
|
--popover: #ffffff;
|
||||||
--popover-foreground: #1b1430;
|
--popover-foreground: #1b1430;
|
||||||
--muted: #f1ecfd;
|
--muted: #f1ecfd;
|
||||||
--muted-foreground: #6f6a86;
|
--muted-foreground: #6f6a86;
|
||||||
--secondary: #ece6fb;
|
--secondary: #ece6fb;
|
||||||
--secondary-foreground: #1b1430;
|
--secondary-foreground: #1b1430;
|
||||||
/* shadcn `accent` = subtle hover surface; also @unom/ui's card ring colour,
|
/* 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). */
|
so we tint it toward the brand violet (the same in both themes). */
|
||||||
--accent: var(--pf-brand);
|
--accent: var(--pf-brand);
|
||||||
--accent-foreground: #ffffff;
|
--accent-foreground: #ffffff;
|
||||||
--border: #e4dcf7;
|
--border: #e4dcf7;
|
||||||
--input: #e4dcf7;
|
--input: #e4dcf7;
|
||||||
--ring: var(--pf-brand);
|
--ring: var(--pf-brand);
|
||||||
|
|
||||||
/* Primary = the brand (buttons, active nav, default badges). */
|
/* Primary = the brand (buttons, active nav, default badges). */
|
||||||
--primary: var(--pf-brand);
|
--primary: var(--pf-brand);
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
--success: oklch(0.6 0.14 160);
|
--success: oklch(0.6 0.14 160);
|
||||||
--destructive: oklch(0.55 0.22 18);
|
--destructive: oklch(0.55 0.22 18);
|
||||||
--destructive-foreground: #ffffff;
|
--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. */
|
These are indirections — they follow the raw tokens above per-theme. */
|
||||||
--main: var(--foreground);
|
--main: var(--foreground);
|
||||||
--brand: var(--pf-brand);
|
--brand: var(--pf-brand);
|
||||||
--brand-light: var(--pf-brand-light);
|
--brand-light: var(--pf-brand-light);
|
||||||
--highlight: var(--pf-highlight);
|
--highlight: var(--pf-highlight);
|
||||||
--neutral: var(--card); /* @unom card default surface (bg-neutral) */
|
--neutral: var(--card); /* @unom card default surface (bg-neutral) */
|
||||||
--neutral-accent: var(--secondary); /* accent / nested surface (bg-neutral-accent) */
|
--neutral-accent: var(
|
||||||
--neutral-highlight: var(--border);
|
--secondary
|
||||||
--error: var(--destructive);
|
); /* 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-display: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
/* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */
|
/* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */
|
||||||
--radius-card-min: var(--radius);
|
--radius-card-min: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark · the violet-tinted app-icon chrome. Overrides only the raw values —
|
/* Dark · the violet-tinted app-icon chrome. Overrides only the raw values —
|
||||||
the indirection tokens in :root resolve to these automatically. */
|
the indirection tokens in :root resolve to these automatically. */
|
||||||
.dark {
|
.dark {
|
||||||
--background: #141019;
|
--background: #141019;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: #1c1530;
|
--card: #1c1530;
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: #1c1530;
|
--popover: #1c1530;
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--muted: #1f1830;
|
--muted: #1f1830;
|
||||||
--muted-foreground: oklch(0.728 0.03 286);
|
--muted-foreground: oklch(0.728 0.03 286);
|
||||||
--secondary: #241c3d;
|
--secondary: #241c3d;
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--border: #2a2148;
|
--border: #2a2148;
|
||||||
--input: #2a2148;
|
--input: #2a2148;
|
||||||
--ring: var(--pf-brand-light);
|
--ring: var(--pf-brand-light);
|
||||||
|
|
||||||
/* Lighter violet reads better against the dark surface. */
|
/* Lighter violet reads better against the dark surface. */
|
||||||
--primary: var(--pf-brand-light);
|
--primary: var(--pf-brand-light);
|
||||||
--primary-foreground: #141019;
|
--primary-foreground: #141019;
|
||||||
|
|
||||||
--success: oklch(0.7 0.15 160);
|
--success: oklch(0.7 0.15 160);
|
||||||
--destructive: oklch(0.62 0.21 18);
|
--destructive: oklch(0.62 0.21 18);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
|
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
|
||||||
and @unom/ui's, resolved to one set of values. */
|
and @unom/ui's, resolved to one set of values. */
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--radius-button: 9999px;
|
--radius-button: 9999px;
|
||||||
--radius-card: calc(var(--radius) * 2);
|
--radius-card: calc(var(--radius) * 2);
|
||||||
--radius-main: calc(var(--radius) * 2);
|
--radius-main: calc(var(--radius) * 2);
|
||||||
|
|
||||||
--spacing-input-height: 3rem;
|
--spacing-input-height: 3rem;
|
||||||
--spacing-padding-card: 1.25rem;
|
--spacing-padding-card: 1.25rem;
|
||||||
--spacing-card: 1.5rem;
|
--spacing-card: 1.5rem;
|
||||||
--spacing-main: 15px;
|
--spacing-main: 15px;
|
||||||
|
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-display: var(--font-display);
|
--font-display: var(--font-display);
|
||||||
|
|
||||||
/* shadcn-style colour tokens. */
|
/* shadcn-style colour tokens. */
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-success: var(--success);
|
--color-success: var(--success);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
/* @unom/ui colour tokens. */
|
/* @unom/ui colour tokens. */
|
||||||
--color-main: var(--main);
|
--color-main: var(--main);
|
||||||
--color-brand: var(--brand);
|
--color-brand: var(--brand);
|
||||||
--color-brand-light: var(--brand-light);
|
--color-brand-light: var(--brand-light);
|
||||||
--color-neutral: var(--neutral);
|
--color-neutral: var(--neutral);
|
||||||
--color-neutral-accent: var(--neutral-accent);
|
--color-neutral-accent: var(--neutral-accent);
|
||||||
--color-neutral-highlight: var(--neutral-highlight);
|
--color-neutral-highlight: var(--neutral-highlight);
|
||||||
--color-highlight: var(--highlight);
|
--color-highlight: var(--highlight);
|
||||||
--color-error: var(--error);
|
--color-error: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accordion / collapsible keyframes @unom/ui's interactive surfaces animate to. */
|
/* Accordion / collapsible keyframes @unom/ui's interactive surfaces animate to. */
|
||||||
@theme {
|
@theme {
|
||||||
--animate-accordion-down: accordion-down 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-accordion-up: accordion-up 0.4s var(--ease-out-quart);
|
||||||
--animate-collapsible-down: collapsible-down 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-collapsible-up: collapsible-up 0.4s var(--ease-out-quart);
|
||||||
|
|
||||||
@keyframes accordion-down {
|
@keyframes accordion-down {
|
||||||
from { height: 0; }
|
from {
|
||||||
to { height: var(--radix-accordion-content-height); }
|
height: 0;
|
||||||
}
|
}
|
||||||
@keyframes accordion-up {
|
to {
|
||||||
from { height: var(--radix-accordion-content-height); }
|
height: var(--radix-accordion-content-height);
|
||||||
to { height: 0; }
|
}
|
||||||
}
|
}
|
||||||
@keyframes collapsible-down {
|
@keyframes accordion-up {
|
||||||
from { height: 0; opacity: 0; }
|
from {
|
||||||
to { height: var(--radix-collapsible-content-height); opacity: 1; }
|
height: var(--radix-accordion-content-height);
|
||||||
}
|
}
|
||||||
@keyframes collapsible-up {
|
to {
|
||||||
from { height: var(--radix-collapsible-content-height); opacity: 1; }
|
height: 0;
|
||||||
to { height: 0; opacity: 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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
font-feature-settings:
|
||||||
}
|
"rlig" 1,
|
||||||
|
"calt" 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/* Penner easing tokens — shared with the punktfunk marketing site + @unom/ui.
|
/* Penner easing tokens — shared with the punktfunk marketing site + @unom/ui.
|
||||||
@unom/ui's accordion/collapsible/material animations resolve these by name. */
|
@unom/ui's accordion/collapsible/material animations resolve these by name. */
|
||||||
@theme {
|
@theme {
|
||||||
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
|
--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-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-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-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-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-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-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-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-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-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-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-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
|
||||||
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
--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-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 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-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
|
||||||
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
|
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
|
||||||
--ease-in-out-expo: cubic-bezier(1, 0, 0, 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-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-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-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-22
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": false,
|
"checkJs": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "server", "vite.config.ts", "orval.config.ts"]
|
"include": ["src", "server", "vite.config.ts", "orval.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-56
@@ -1,65 +1,65 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin'
|
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
|
||||||
import viteReact from '@vitejs/plugin-react'
|
import viteReact from "@vitejs/plugin-react";
|
||||||
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
import viteTsConfigPaths from "vite-tsconfig-paths";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { paraglideVitePlugin } from '@inlang/paraglide-js'
|
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||||
|
|
||||||
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
|
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
|
||||||
// because the TanStack Nitro plugin doesn't auto-scan a server/ dir.
|
// because the TanStack Nitro plugin doesn't auto-scan a server/ dir.
|
||||||
const serverDir = fileURLToPath(new URL('./server', import.meta.url))
|
const serverDir = fileURLToPath(new URL("./server", import.meta.url));
|
||||||
|
|
||||||
// The management API the console drives. The browser always talks same-origin (/api/...):
|
// The management API the console drives. The browser always talks same-origin (/api/...):
|
||||||
// in `vite dev` the dev server proxies it (below); in the built Bun/Nitro server a Nitro
|
// in `vite dev` the dev server proxies it (below); in the built Bun/Nitro server a Nitro
|
||||||
// route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL.
|
// route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL.
|
||||||
const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? 'https://127.0.0.1:47990'
|
const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
// `secure: false`: the host serves its own self-signed identity cert on loopback.
|
// `secure: false`: the host serves its own self-signed identity cert on loopback.
|
||||||
'/api': { target: MGMT_URL, changeOrigin: true, secure: false },
|
"/api": { target: MGMT_URL, changeOrigin: true, secure: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
viteTsConfigPaths({ projects: ['./tsconfig.json'] }),
|
viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
paraglideVitePlugin({
|
paraglideVitePlugin({
|
||||||
project: './project.inlang',
|
project: "./project.inlang",
|
||||||
outdir: './src/paraglide',
|
outdir: "./src/paraglide",
|
||||||
strategy: ['localStorage', 'preferredLanguage', 'baseLocale'],
|
strategy: ["localStorage", "preferredLanguage", "baseLocale"],
|
||||||
}),
|
}),
|
||||||
// Full SSR on the TanStack Start runtime (the management console's data queries run
|
// Full SSR on the TanStack Start runtime (the management console's data queries run
|
||||||
// client-side after hydration — React Query doesn't fetch during SSR — so the server
|
// client-side after hydration — React Query doesn't fetch during SSR — so the server
|
||||||
// renders a data-free shell that hydrates in the browser).
|
// renders a data-free shell that hydrates in the browser).
|
||||||
tanstackStart(),
|
tanstackStart(),
|
||||||
// Nitro v2 is the deployment target: the `bun` preset bundles a Bun-runnable server to
|
// 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
|
// .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
|
// 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
|
// 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.
|
// routeRule, so the proxy runs behind the login gate and reads env at runtime.
|
||||||
nitroV2Plugin({
|
nitroV2Plugin({
|
||||||
// node-server (not bun): a STANDALONE node HTTP server (`node .output/server/index.mjs`
|
// 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
|
// listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web
|
||||||
// .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with
|
// .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with
|
||||||
// bun; only the runtime target changes. (dev `vite dev` is unaffected.)
|
// bun; only the runtime target changes. (dev `vite dev` is unaffected.)
|
||||||
preset: 'node-server',
|
preset: "node-server",
|
||||||
// BUNDLE every dependency into the server output (no externalized node_modules). Three wins:
|
// BUNDLE every dependency into the server output (no externalized node_modules). Three wins:
|
||||||
// (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep
|
// (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep
|
||||||
// tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) it makes the
|
// tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) it makes the
|
||||||
// output a self-contained graph `bun build --compile` can fold into ONE native binary (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
|
// 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
|
// bare external imports (`srvx`, `seroval`…) bun couldn't resolve at runtime — the reason we
|
||||||
// used to need node. node still runs the same self-contained output for the Linux .deb.
|
// used to need node. node still runs the same self-contained output for the Linux .deb.
|
||||||
noExternals: true,
|
noExternals: true,
|
||||||
compatibilityDate: '2026-06-10',
|
compatibilityDate: "2026-06-10",
|
||||||
// Scan server/{middleware,routes} for the auth gate + the /api proxy.
|
// Scan server/{middleware,routes} for the auth gate + the /api proxy.
|
||||||
scanDirs: [serverDir],
|
scanDirs: [serverDir],
|
||||||
}),
|
}),
|
||||||
// Must come AFTER tanstackStart — provides the React JSX transform + Refresh runtime
|
// 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).
|
// that Start's dev mode requires (omitting it leaves the client JS unable to load).
|
||||||
viteReact(),
|
viteReact(),
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import viteReact from '@vitejs/plugin-react'
|
import viteReact from "@vitejs/plugin-react";
|
||||||
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
import viteTsConfigPaths from "vite-tsconfig-paths";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { paraglideVitePlugin } from '@inlang/paraglide-js'
|
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||||
|
|
||||||
// Storybook builds the components in isolation — WITHOUT the TanStack Start /
|
// Storybook builds the components in isolation — WITHOUT the TanStack Start /
|
||||||
// Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the
|
// Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the
|
||||||
// React transform, and Paraglide.
|
// React transform, and Paraglide.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
viteTsConfigPaths({ projects: ['./tsconfig.json'] }),
|
viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
viteReact(),
|
viteReact(),
|
||||||
paraglideVitePlugin({
|
paraglideVitePlugin({
|
||||||
project: './project.inlang',
|
project: "./project.inlang",
|
||||||
outdir: './src/paraglide',
|
outdir: "./src/paraglide",
|
||||||
strategy: ['localStorage', 'preferredLanguage', 'baseLocale'],
|
strategy: ["localStorage", "preferredLanguage", "baseLocale"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user