improve web ui
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user