improve web ui

This commit is contained in:
2026-06-26 05:43:34 +00:00
parent 00cf51d610
commit 803573b4ec
73 changed files with 3373 additions and 2847 deletions
+44 -41
View File
@@ -1,51 +1,54 @@
/// <reference types="vite/client" />
import type { QueryClient } from "@tanstack/react-query";
import {
createRootRouteWithContext,
HeadContent,
Outlet,
Scripts,
useRouterState,
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import '@fontsource-variable/geist'
import { AppShell } from '@/components/app-shell'
import appCss from '@/styles.css?url'
createRootRouteWithContext,
HeadContent,
Outlet,
Scripts,
useRouterState,
} from "@tanstack/react-router";
import "@fontsource-variable/geist";
import { AppShell } from "@/components/app-shell";
import appCss from "@/styles.css?url";
export interface RouterContext {
queryClient: QueryClient
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'color-scheme', content: 'dark light' },
{ title: 'punktfunk' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
component: RootComponent,
})
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "color-scheme", content: "dark light" },
{ title: "punktfunk" },
],
links: [{ rel: "stylesheet", href: appCss }],
}),
component: RootComponent,
});
function RootComponent() {
// The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({ select: (s) => s.location.pathname === '/login' })
return (
<html lang="en" className="dark">
<head>
<HeadContent />
</head>
<body className="min-h-screen">
{isLogin ? (
<Outlet />
) : (
<AppShell>
<Outlet />
</AppShell>
)}
<Scripts />
</body>
</html>
)
// The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({
select: (s) => s.location.pathname === "/login",
});
return (
<html lang="en" className="dark">
<head>
<HeadContent />
</head>
<body className="min-h-screen">
{isLogin ? (
<Outlet />
) : (
<AppShell>
<Outlet />
</AppShell>
)}
<Scripts />
</body>
</html>
);
}
+3 -89
View File
@@ -1,90 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import {
useListPairedClients,
useUnpairClient,
getListPairedClientsQueryKey,
} from '@/api/gen/clients/clients'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
import { createFileRoute } from "@tanstack/react-router";
import { SectionClients } from "@/sections/Clients";
export const Route = createFileRoute('/clients')({ component: ClientsPage })
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function ClientsPage() {
useLocale()
const qc = useQueryClient()
const clients = useListPairedClients()
const unpair = useUnpairClient()
const rows = clients.data ?? []
const onUnpair = (fingerprint: string) => {
if (!confirm(m.clients_unpair_confirm())) return
unpair.mutate(
{ fingerprint },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }) },
)
}
return (
<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>
)
}
export const Route = createFileRoute("/clients")({ component: SectionClients });
+3 -114
View File
@@ -1,115 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { useGetHostInfo, useListCompositors } from '@/api/gen/host/host'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
import { createFileRoute } from "@tanstack/react-router";
import { SectionHost } from "@/sections/Host";
export const Route = createFileRoute('/host')({ component: HostPage })
// Exported so Storybook can render the page directly (see src/stories). The
// route gen only needs the `Route` export; this extra one is harmless.
export function HostPage() {
useLocale()
const host = useGetHostInfo()
const compositors = useListCompositors()
const h = host.data
return (
<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>
)
}
export const Route = createFileRoute("/host")({ component: SectionHost });
+3 -138
View File
@@ -1,139 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { Video, Volume2, MonitorPlay, ZapOff, RefreshCw } from 'lucide-react'
import {
useGetStatus,
getGetStatusQueryKey,
} from '@/api/gen/host/host'
import { useStopSession, useRequestIdr } from '@/api/gen/session/session'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
import { createFileRoute } from "@tanstack/react-router";
import { SectionDashboard } from "@/sections/Dashboard";
export const Route = createFileRoute('/')({ component: Dashboard })
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function Dashboard() {
useLocale()
const qc = useQueryClient()
// Poll live status every 2s so the console tracks an active session.
const status = useGetStatus({ query: { refetchInterval: 2_000 } })
const stop = useStopSession()
const idr = useRequestIdr()
const invalidate = () => qc.invalidateQueries({ queryKey: getGetStatusQueryKey() })
const s = status.data
return (
<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>
)
}
export const Route = createFileRoute("/")({ component: SectionDashboard });
+3 -292
View File
@@ -1,293 +1,4 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2, X } from 'lucide-react'
import {
useGetLibrary,
useCreateCustomGame,
useUpdateCustomGame,
useDeleteCustomGame,
getGetLibraryQueryKey,
} from '@/api/gen/library/library'
import type { GameEntry } from '@/api/gen/model/gameEntry'
import type { CustomInput } from '@/api/gen/model/customInput'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
import { createFileRoute } from "@tanstack/react-router";
import { SectionLibrary } from "@/sections/Library";
export const Route = createFileRoute('/library')({ component: LibraryPage })
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
function customId(entry: GameEntry): string {
return entry.id.startsWith('custom:') ? entry.id.slice('custom:'.length) : entry.id
}
/** Editable form state for the add/edit custom-game form. */
interface FormState {
title: string
portrait: string
hero: string
header: string
command: string
}
const emptyForm: FormState = { title: '', portrait: '', hero: '', header: '', command: '' }
function formFrom(entry: GameEntry): FormState {
return {
title: entry.title,
portrait: entry.art.portrait ?? '',
hero: entry.art.hero ?? '',
header: entry.art.header ?? '',
command: entry.launch?.kind === 'command' ? entry.launch.value : '',
}
}
/** Map the form to the API body — only attach `launch` when a command was given. */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim()
return t ? t : undefined
}
const command = f.command.trim()
return {
title: f.title.trim(),
art: {
portrait: trim(f.portrait),
hero: trim(f.hero),
header: trim(f.header),
},
launch: command ? { kind: 'command', value: command } : null,
}
}
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function LibraryPage() {
useLocale()
const qc = useQueryClient()
const library = useGetLibrary()
const create = useCreateCustomGame()
const update = useUpdateCustomGame()
const remove = useDeleteCustomGame()
// null = form hidden; '' = adding a new entry; an id = editing that custom entry.
const [editing, setEditing] = useState<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>
)
}
export const Route = createFileRoute("/library")({ component: SectionLibrary });
+11 -82
View File
@@ -1,85 +1,14 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { BrandMark } from '@/components/brand-mark'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
import { createFileRoute } from "@tanstack/react-router";
import { SectionLogin } from "@/sections/Login";
export const Route = createFileRoute('/login')({
validateSearch: (s: Record<string, unknown>): { next?: string } => ({
next: typeof s.next === 'string' ? s.next : undefined,
}),
component: LoginPage,
})
export const Route = createFileRoute("/login")({
validateSearch: (s: Record<string, unknown>): { next?: string } => ({
next: typeof s.next === "string" ? s.next : undefined,
}),
component: RouteComponent,
});
function LoginPage() {
useLocale()
const router = useRouter()
const { next } = Route.useSearch()
const [password, setPassword] = useState('')
const [error, setError] = useState(false)
const [busy, setBusy] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setBusy(true)
setError(false)
try {
const res = await fetch('/_auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
setError(true)
setBusy(false)
return
}
// Full reload to the target so SSR re-runs WITH the new session cookie. Only a
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
const safe = next && next.startsWith('/') && !next.startsWith('//') ? next : '/'
window.location.href = safe
} catch {
setError(true)
setBusy(false)
}
void router
}
return (
<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>
)
function RouteComponent() {
const { next } = Route.useSearch();
return <SectionLogin next={next} />;
}
+2 -388
View File
@@ -1,390 +1,4 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import {
KeyRound,
CheckCircle2,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import {
useGetNativePairing,
useArmNativePairing,
useDisarmNativePairing,
useListNativeClients,
useUnpairNativeClient,
useListPendingDevices,
useApprovePendingDevice,
useDenyPendingDevice,
getGetNativePairingQueryKey,
getListNativeClientsQueryKey,
getListPendingDevicesQueryKey,
} from "@/api/gen/native/native";
import {
useGetPairingStatus,
useSubmitPairingPin,
getGetPairingStatusQueryKey,
} from "@/api/gen/pairing/pairing";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { QueryState } from "@/components/query-state";
import { m } from "@/paraglide/messages";
import { useLocale } from "@/lib/i18n";
import { SectionPairing } from "@/sections/Pairing";
export const Route = createFileRoute("/pairing")({ component: PairingPage });
/** Seconds → `m:ss`. */
function fmtTime(secs: number): string {
const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
function PairingPage() {
useLocale();
return (
<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>
);
}
export const Route = createFileRoute("/pairing")({ component: SectionPairing });
+5 -54
View File
@@ -1,55 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { LogOut } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { m } from '@/paraglide/messages'
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
import { createFileRoute } from "@tanstack/react-router";
import { SectionSettings } from "@/sections/Settings";
export const Route = createFileRoute('/settings')({ component: SettingsPage })
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function SettingsPage() {
const current = useLocale()
const onLogout = async () => {
await fetch('/_auth/logout', { method: 'POST' })
window.location.href = '/login'
}
return (
<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>
)
}
export const Route = createFileRoute("/settings")({
component: SectionSettings,
});