Files
punktfunk/web/src/sections/Displays/SessionGameCard.tsx
T
enricobuehler 3be7d1d4f8 feat(web): the console asks its own questions
Follow-up to a85e8452, closing the three items that sweep flagged and left.

SIXTEEN BROWSER DIALOGS, GONE. Every destructive action in an otherwise fully
branded console handed off to `window.confirm` — a grey OS box with the page's
URL in it, no brand, no red on a delete, and untouchable by any story or
screenshot, which is part of why it survived this long.

They are replaced by one promise-based surface (components/dialogs.tsx) rather
than a dialog per call site. The native calls were EXPRESSIONS — `if
(!confirm(…)) return;` — threaded through mutation handlers; rewriting each into
"hold the pending action in state, render a dialog, run it from onConfirm" would
have put dialog machinery in every section file and turned each linear handler
inside out. Returning a promise keeps them the shape they already were, and it
is what let the navigation guard come along too: TanStack's `shouldBlockFn`
accepts `Promise<boolean>`. `beforeunload` necessarily stays native — a reload
is the browser's dialog to draw, and it will not wait on ours.

No warning copy was rewritten. Each message was SPLIT at its existing sentence
boundary: the question becomes the dialog's title, the consequence its body,
and "Continue?" is dropped where the affirmative button now carries the verb
("Delete", "Uninstall", "Unpair", "Stop every session"). 16 new keys, en and de
in parity at 629.

Verified by driving the real dialogs in a headless browser — all seven contract
checks pass, including the two that would be invisible until they bit: Escape
SETTLES the promise (an unsettled one would hang a mutation handler forever with
no error), and a cancelled prompt resolves null rather than "", so a caller can
still tell "backed out" from "cleared the field".

FOUR OF THE SEVEN NUMERIC FIELDS became InputNumber; three deliberately did not,
and now say why in place. The layout X/Y pair had a real defect: a screen left
of the origin has a negative coordinate, and `Number("-") || 0` rewrote the lone
minus sign to "0" before the digits could be typed. Measured on the built page:
the field can now be emptied to retype instead of snapping to its floor, and 900
in a 1..=16 field clamps to 16. The three left alone cannot take it — the grace
seconds field writes to the HOST on blur (InputNumber commits while typing, so
its clamp would race the apply), and the library's year/players are OPTIONAL,
where `value: number` has no way to say "unset" and would invent a year for
every entry without one.

The select's highlighted row moves off @unom/ui's neutral grey onto the brand
wash the nav and the preset cards already use.

The Displays story earned its keep immediately: adding `useDialogs` to that page
broke it in Storybook, because the provider was mounted in __root and nowhere
else. It belongs beside the other app-level providers in .storybook/preview.
2026-08-07 22:56:46 +02:00

262 lines
9.1 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { GameOnSessionEnd, SessionSettings } from "@/api/gen/model";
import {
getGetSessionSettingsQueryKey,
useGetSessionSettings,
useSetSessionSettings,
} from "@/api/gen/session/session";
import { QueryState } from "@/components/query-state";
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 { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const END_POLICIES: GameOnSessionEnd[] = ["keep", "on_quit", "always"];
/**
* Whether a launched game and its streaming session share a fate
* (design/session-game-lifetime.md), next to the display keep-alive policy because the two interact:
* a kept display and a kept game are separate decisions with separate timers, and `keep_alive:
* forever` outranks the game policy for the display itself.
*
* Every axis saves on change (like the display policy above) — there is no Save button to miss.
*/
export const SessionGameCard: FC = () => {
const qc = useQueryClient();
const q = useGetSessionSettings();
const save = useSetSessionSettings();
const server = q.data?.settings;
// Which axes this build acts on. An EMPTY list means the build enforces nothing — the contract
// says so outright ("Empty on a platform with no launch path (macOS), so the console can say so
// instead of offering a switch that does nothing"), and this card's own comment promises the
// controls are "shown disabled rather than hidden".
//
// The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the
// platform the flag exists for, every control stayed live: clicking one PUT the setting and
// toasted success for an axis the host would never act on. Absent (an older host that never
// sent the field) still means "assume it acts" — that is the compatible reading, and it is a
// different case from present-and-empty.
const enforced = q.data?.enforced;
const acts = (field: string) => !enforced || enforced.includes(field);
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
// are discrete and go straight to the host.
const [grace, setGrace] = useState("");
useEffect(() => {
if (server) setGrace(String(server.disconnect_grace_seconds ?? 300));
}, [server]);
const apply = (patch: Partial<SessionSettings>) => {
if (!server) return;
save.mutate(
{ data: { ...server, ...patch } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetSessionSettingsQueryKey() });
toast.success(m.session_game_saved());
},
},
);
};
const busy = save.isPending;
const error = save.error instanceof ApiError ? save.error.message : undefined;
return (
<Card>
<CardHeader>
<CardTitle>{m.session_game_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.session_game_help()}
</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{server && (
<div className="space-y-6">
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
group
>
<div className="flex flex-wrap gap-2">
<Choice
selected={server.session_on_game_exit === true}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: true })}
>
{m.session_game_on_exit_end()}
</Choice>
<Choice
selected={server.session_on_game_exit === false}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: false })}
>
{m.session_game_on_exit_keep()}
</Choice>
</div>
</Field>
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
group
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
<Choice
key={p}
selected={(server.game_on_session_end ?? "keep") === p}
disabled={busy || !acts("game_on_session_end")}
onClick={() => apply({ game_on_session_end: p })}
>
{END_POLICY_LABEL[p]()}
</Choice>
))}
</div>
{(server.game_on_session_end ?? "keep") === "always" && (
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_always_warning()}
</p>
)}
{/* Shown for every option, including "leave it running": on a nested
gamescope launch the game IS inside the streamed display, so the
display's own keep-alive outranks anything chosen here — verified
on glass (.41), where a deliberate stop ended the game under
`keep`. Worded so a non-gamescope host reads it and moves on. */}
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_nested_note()}
</p>
</Field>
{(server.game_on_session_end ?? "keep") === "always" && (
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
htmlFor="session-grace-seconds"
>
<div className="flex items-center gap-2">
{/* Deliberately NOT `InputNumber`, unlike the numeric fields on
the policy card next door. This one writes to the HOST on
blur, and InputNumber commits while you type — so its own
blur-time clamp would race the apply below, which still
closes over the pre-clamp value. The host is the authority
here regardless: it clamps to 10..=86400 on write and
answers with what it actually stored. */}
<Input
id="session-grace-seconds"
type="number"
min={10}
max={86400}
className="w-28"
value={grace}
disabled={busy || !acts("disconnect_grace_seconds")}
onChange={(e) => setGrace(e.target.value)}
onBlur={() => {
const n = Number(grace);
if (!Number.isFinite(n)) {
setGrace(
String(server.disconnect_grace_seconds ?? 300),
);
return;
}
// The host clamps to 10..=86400 and returns what it stored, so
// a nonsense number is corrected rather than rejected.
if (n !== server.disconnect_grace_seconds) {
apply({ disconnect_grace_seconds: n });
}
}}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</Field>
)}
{/* Present-and-empty is the "this build acts on none of it" signal; ABSENT
is an older host that never sent the field, where claiming inertness
would be a guess. Same distinction `acts()` makes above. */}
{enforced?.length === 0 && (
<Badge variant="outline">{m.session_game_inert()}</Badge>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
keep: () => m.session_game_end_keep(),
on_quit: () => m.session_game_end_on_quit(),
always: () => m.session_game_end_always(),
};
/**
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
*
* A bare `<Label>` beside an `<input>` with no `id` labels nothing at all — the grace input was
* announced as an unnamed spin button. Mirrors the same fix in DisplayCard's `Field`; the two stay
* separate on purpose (this card's axes are its own).
*/
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
htmlFor?: string;
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const body = (
<>
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</>
);
return group ? (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</fieldset>
) : (
<div className="space-y-3">{body}</div>
);
};
const Choice: FC<{
selected: boolean;
disabled: boolean;
onClick: () => void;
children: ReactNode;
}> = ({ selected, disabled, onClick, children }) => (
<Button
type="button"
variant={selected ? "default" : "outline"}
size="sm"
disabled={disabled}
aria-pressed={selected}
className={cn(disabled && "opacity-60")}
onClick={onClick}
>
{children}
</Button>
);