feat(web): the console asks its own questions
ci / bun-nix (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / rust (pull_request) Successful in 6m32s

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.
This commit is contained in:
2026-08-07 22:56:46 +02:00
parent a85e845255
commit 3be7d1d4f8
19 changed files with 557 additions and 100 deletions
+209
View File
@@ -0,0 +1,209 @@
import {
createContext,
type FC,
type ReactNode,
useContext,
useMemo,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/**
* `window.confirm` / `window.prompt`, replaced by the console's own Dialog.
*
* WHY A PROVIDER AND NOT A COMPONENT PER SITE. The native calls this replaces are *expressions* —
* `if (!confirm(…)) return;` — sixteen of them, threaded through mutation handlers and one router
* navigation guard. Rewriting each into "hold the pending action in state, render a dialog, run the
* action from its onConfirm" would have put a piece of dialog machinery in every section file and
* turned each linear handler inside out. Handing back a PROMISE keeps the call sites the shape they
* already are:
*
* if (!(await confirm({ title: … }))) return;
*
* which is also why the navigation guard could come along: TanStack's `shouldBlockFn` accepts
* `Promise<boolean>`. The one native prompt that necessarily stays is `beforeunload` — a reload or
* a tab close is the browser's dialog to draw, not ours.
*/
export type ConfirmOptions = {
/** The question. Short — it is a heading. */
title: string;
/** What the operator is not being told by the title alone: the consequence. */
description?: string;
/** Affirmative label. Use the same verb as the control that opened the dialog. */
confirmLabel?: string;
/** Paint the affirmative button red — anything that destroys, removes, or interrupts. */
destructive?: boolean;
};
export type PromptOptions = {
title: string;
description?: string;
confirmLabel?: string;
/** Field label. */
label: string;
defaultValue?: string;
placeholder?: string;
};
type Dialogs = {
/** Resolves true only if the operator confirmed; false on cancel, Esc, or overlay click. */
confirm: (options: ConfirmOptions) => Promise<boolean>;
/** Resolves the entered text, or null if the operator backed out — same contract as `prompt`. */
promptText: (options: PromptOptions) => Promise<string | null>;
};
type Pending =
| { kind: "confirm"; options: ConfirmOptions; settle: (ok: boolean) => void }
| {
kind: "prompt";
options: PromptOptions;
settle: (text: string | null) => void;
};
const DialogsContext = createContext<Dialogs | null>(null);
/** Settle a request negatively. Must switch on `kind` — the two `settle`s take different types. */
const cancel = (p: Pending | null) => {
if (!p) return;
if (p.kind === "confirm") p.settle(false);
else p.settle(null);
};
export const DialogsProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [pending, setPending] = useState<Pending | null>(null);
const [draft, setDraft] = useState("");
// `setPending` takes the updater form so a second ask while one is already open cannot strand the
// first promise unresolved — the caller would await forever. Two at once shouldn't happen (the
// open dialog is modal), but the navigation guard can fire from outside the page's own UI.
const api = useMemo<Dialogs>(
() => ({
confirm: (options) =>
new Promise<boolean>((resolve) =>
setPending((prev) => {
cancel(prev);
return { kind: "confirm", options, settle: resolve };
}),
),
promptText: (options) =>
new Promise<string | null>((resolve) => {
setDraft(options.defaultValue ?? "");
setPending((prev) => {
cancel(prev);
return { kind: "prompt", options, settle: resolve };
});
}),
}),
[],
);
/** Resolve and close. Every exit — button, Esc, overlay — goes through here exactly once. */
const close = (value: boolean | string | null) => {
if (!pending) return;
if (pending.kind === "confirm") pending.settle(value === true);
else pending.settle(typeof value === "string" ? value : null);
setPending(null);
};
const cancelled = () => close(pending?.kind === "prompt" ? null : false);
const options = pending?.options;
return (
<DialogsContext.Provider value={api}>
{children}
<Dialog
open={pending !== null}
onOpenChange={(open) => {
if (!open) cancelled();
}}
>
{pending && options && (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{options.title}</DialogTitle>
{/* Radix warns when a dialog has no description; render the element only when
there is one to say, and tell it so explicitly otherwise. */}
{options.description ? (
<DialogDescription>{options.description}</DialogDescription>
) : (
<DialogDescription className="sr-only">
{options.title}
</DialogDescription>
)}
</DialogHeader>
{pending.kind === "prompt" && (
<div className="space-y-2">
<Label htmlFor="dialog-prompt">{pending.options.label}</Label>
<Input
id="dialog-prompt"
// Autofocus is right here and nowhere else: a modal text prompt exists
// to be typed into, and it took the focus automatically as a `prompt()`.
autoFocus
autoComplete="off"
value={draft}
placeholder={pending.options.placeholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter submits — the reflex `prompt()` trained everyone into.
if (e.key === "Enter") {
e.preventDefault();
close(draft);
}
}}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={cancelled}>
{m.common_cancel()}
</Button>
<Button
variant={
pending.kind === "confirm" && pending.options.destructive
? "destructive"
: "default"
}
onClick={() => close(pending.kind === "prompt" ? draft : true)}
>
{options.confirmLabel ??
(pending.kind === "prompt"
? m.common_save()
: m.common_confirm())}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</DialogsContext.Provider>
);
};
/**
* The console's `confirm` / `prompt`. Both return a promise, so a handler reads top to bottom:
*
* const onDelete = async () => {
* if (!(await confirm({ title: m.x(), confirmLabel: m.y(), destructive: true }))) return;
* await remove.mutateAsync(…);
* };
*/
export const useDialogs = (): Dialogs => {
const ctx = useContext(DialogsContext);
if (!ctx)
throw new Error(
"useDialogs must be used inside <DialogsProvider> (__root)",
);
return ctx;
};
+16 -1
View File
@@ -17,7 +17,7 @@ import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItem as SelectItemBase,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
@@ -43,6 +43,21 @@ const SelectTrigger = ({
);
SelectTrigger.displayName = "SelectTrigger";
// The highlighted row. Upstream paints it `bg-main/25` — a neutral grey wash, since `--main` is the
// foreground colour. Everywhere else in this console the "this is the one" wash is brand violet
// (`bg-primary/15` on the nav's hover and active states, `ring-primary` on a chosen preset card), so
// a grey row is the odd one out the moment a select sits next to any of them.
const SelectItem = ({
className,
...props
}: ComponentProps<typeof SelectItemBase>) => (
<SelectItemBase
className={cn("focus:bg-primary/15 focus:text-foreground", className)}
{...props}
/>
);
SelectItem.displayName = "SelectItem";
export {
Select,
SelectContent,
+12 -6
View File
@@ -13,6 +13,7 @@ import { Toaster } from "@unom/ui/toast";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { AppShell } from "@/components/app-shell";
import { DialogsProvider } from "@/components/dialogs";
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
import appCss from "@/styles.css?url";
@@ -68,13 +69,18 @@ function RootComponent() {
animated at full strength even for someone whose OS asks for less. "user" honours
the OS setting. */}
<MotionConfig reducedMotion="user">
{isLogin ? (
<Outlet />
) : (
<AppShell>
{/* The console's own confirm/prompt, in place of the browser's grey boxes. Mounted
at the root because the navigation guard on the Displays page asks for one
while LEAVING that page — see components/dialogs.tsx. */}
<DialogsProvider>
{isLogin ? (
<Outlet />
</AppShell>
)}
) : (
<AppShell>
<Outlet />
</AppShell>
)}
</DialogsProvider>
</MotionConfig>
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
<Toaster />
+9 -2
View File
@@ -6,6 +6,7 @@ import { ApiError } from "@/api/fetcher";
import { useGetHooks } from "@/api/gen/hooks/hooks";
import type { HookEntry } from "@/api/gen/model/hookEntry";
import { hookAction, hookFilterSummary, useSaveHooks } from "@/api/hooks";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,6 +37,7 @@ import { HookForm } from "./HookForm";
*/
export const SectionAutomation: FC = () => {
useLocale();
const { confirm } = useDialogs();
const query = useGetHooks();
const save = useSaveHooks();
@@ -70,8 +72,13 @@ export const SectionAutomation: FC = () => {
setEditing(null);
};
const remove = (index: number) => {
if (!confirm(m.automation_delete_confirm())) return;
const remove = async (index: number) => {
const ok = await confirm({
title: m.automation_delete_confirm(),
confirmLabel: m.automation_delete(),
destructive: true,
});
if (!ok) return;
setHooks((prev) => (prev ?? []).filter((_, i) => i !== index));
};
+23 -13
View File
@@ -9,6 +9,7 @@ import {
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
import { useDialogs } from "@/components/dialogs";
import { apiErrorMessage } from "@/lib/errors";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -17,6 +18,7 @@ import { DashboardView } from "./view";
export const SectionDashboard: FC = () => {
useLocale();
const qc = useQueryClient();
const { confirm } = useDialogs();
// Session/game transitions arrive on the event stream now (api/events.ts invalidates this key),
// so the timer only has to cover what events cannot: the live stream numbers — codec, resolution,
// fps, bitrate — which change continuously while something is streaming. Idle, it is a slow
@@ -61,23 +63,26 @@ export const SectionDashboard: FC = () => {
* - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace
* row for an operator-typed command carries no `app_id` — so that row ended all of them.
*/
const onEndGame = (game: ActiveGame) => {
const onEndGame = async (game: ActiveGame) => {
const games = status.data?.games ?? [];
if (game.state === "grace") {
const waiting = games.filter((g) => g.state === "grace").length;
if (
!game.app_id &&
waiting > 1 &&
!confirm(m.games_end_all_waiting_confirm({ count: waiting }))
)
return;
if (!game.app_id && waiting > 1) {
const ok = await confirm({
title: m.games_end_all_waiting_title({ count: waiting }),
description: m.games_end_all_waiting_confirm({ count: waiting }),
confirmLabel: m.games_end_now(),
destructive: true,
});
if (!ok) return;
}
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate, onError: failed(m.games_end_failed()) },
);
return;
}
if (!confirmStopAll()) return;
if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
@@ -86,18 +91,23 @@ export const SectionDashboard: FC = () => {
/** Shared by "End now" on a live row and the card's own Stop-session button: with more than one
* session live, stopping is not a per-client action and the operator has to know that. */
const confirmStopAll = (): boolean => {
const confirmStopAll = (): Promise<boolean> => {
const active = status.data?.active_sessions ?? 0;
if (active <= 1) return true;
return confirm(m.action_stop_session_all_confirm({ count: active }));
if (active <= 1) return Promise.resolve(true);
return confirm({
title: m.action_stop_session_all_title(),
description: m.action_stop_session_all_confirm({ count: active }),
confirmLabel: m.action_stop_session_all(),
destructive: true,
});
};
return (
<DashboardView
status={status}
library={library.data}
onStopSession={() => {
if (!confirmStopAll()) return;
onStopSession={async () => {
if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
+62 -38
View File
@@ -36,12 +36,13 @@ import type {
Preset,
Topology,
} from "@/api/gen/model";
import { type ConfirmOptions, useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
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 { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
@@ -55,6 +56,7 @@ import { m } from "@/paraglide/messages";
*/
export const DisplaySection: FC = () => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const q = useGetDisplaySettings();
const save = useSetDisplaySettings();
@@ -164,8 +166,12 @@ export const DisplaySection: FC = () => {
// clicking "Host" in the sidebar is a client-side route change the browser never hears about,
// so the draft vanished with no prompt at all. The router's blocker covers in-app navigation
// AND still arms `beforeunload` for the reload case, so it replaces the listener outright.
//
// `shouldBlockFn` may return a promise, which is what lets this ask with the console's own
// dialog rather than the browser's. `enableBeforeUnload` stays native by necessity: a reload or
// a tab close is the browser's dialog to draw, and it will not wait on ours.
useBlocker({
shouldBlockFn: () => !confirm(m.display_discard_confirm()),
shouldBlockFn: async () => !(await confirm(discardPrompt())),
enableBeforeUnload: () => dirty,
disabled: !dirty,
});
@@ -234,6 +240,17 @@ export const DisplaySection: FC = () => {
);
};
/**
* The gate on anything that would throw unsaved Custom fields away — asked from three places (a
* preset click, applying a saved preset, and leaving the page), so it is written once. A function
* rather than a constant because the message is resolved per locale, at call time.
*/
const discardPrompt = (): ConfirmOptions => ({
title: m.display_discard_confirm(),
confirmLabel: m.common_discard(),
destructive: true,
});
/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */
const PRESET_ORDER = [
"default",
@@ -284,6 +301,7 @@ export const DisplayForm: FC<{
error,
}) => {
const qc = useQueryClient();
const { confirm, promptText } = useDialogs();
const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset();
const deletePreset = useDeleteCustomPreset();
@@ -314,11 +332,10 @@ export const DisplayForm: FC<{
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
// effective behavior (nothing changes until you Save).
const pickPreset = (id: string) => {
const pickPreset = async (id: string) => {
// A preset click overwrites the whole policy, so hand-edits that were never saved would
// vanish without a word — the same failure as not finding the Save button, one click later.
if (dirty && id !== "custom" && !confirm(m.display_discard_confirm()))
return;
if (dirty && id !== "custom" && !(await confirm(discardPrompt()))) return;
// Already hand-editing: re-seeding would fill in defaults for fields the stored policy
// leaves unset and flag "unsaved changes" for a click that changed nothing.
if (id === "custom" && isCustom) return;
@@ -349,8 +366,8 @@ export const DisplayForm: FC<{
// Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the
// one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…).
const applyCustomPreset = (p: CustomPreset) => {
if (dirty && !confirm(m.display_discard_confirm())) return;
const applyCustomPreset = async (p: CustomPreset) => {
if (dirty && !(await confirm(discardPrompt()))) return;
apply({
version: 1,
preset: "custom",
@@ -377,8 +394,13 @@ export const DisplayForm: FC<{
const anyCustomSelected = customPresets.some(customSelected);
// Save the currently-in-force behavior (built-in OR hand-edited) as a new named preset.
const saveAsPreset = () => {
const name = prompt(m.display_preset_name())?.trim();
const saveAsPreset = async () => {
const name = (
await promptText({
title: m.display_preset_save_title(),
label: m.display_preset_name(),
})
)?.trim();
if (!name) return; // cancelled or empty
createPreset.mutate(
{
@@ -391,8 +413,14 @@ export const DisplayForm: FC<{
{ onSuccess: invalidateSettings },
);
};
const renamePreset = (p: CustomPreset) => {
const name = prompt(m.display_preset_name(), p.name)?.trim();
const renamePreset = async (p: CustomPreset) => {
const name = (
await promptText({
title: m.display_preset_edit(),
label: m.display_preset_name(),
defaultValue: p.name,
})
)?.trim();
if (!name) return;
updatePreset.mutate(
{
@@ -418,8 +446,13 @@ export const DisplayForm: FC<{
},
{ onSuccess: invalidateSettings },
);
const removePreset = (p: CustomPreset) => {
if (!confirm(m.display_preset_delete_confirm())) return;
const removePreset = async (p: CustomPreset) => {
const ok = await confirm({
title: m.display_preset_delete_confirm(),
confirmLabel: m.display_preset_delete(),
destructive: true,
});
if (!ok) return;
deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings });
};
@@ -618,16 +651,14 @@ export const DisplayForm: FC<{
</Button>
{ka.mode === "duration" && (
<div className="flex items-center gap-2">
<Input
<InputNumber
id="display-keep-alive-seconds"
aria-label={m.display_keep_alive_seconds()}
type="number"
min={0}
className="w-24"
value={ka.seconds}
disabled={busy}
onChange={(e) => {
const n = Math.max(0, Number(e.target.value) || 0);
onChange={(n) => {
setKeepSecs(n);
setDraft({
...draft,
@@ -691,23 +722,17 @@ export const DisplayForm: FC<{
/>
<Field label={m.display_max()} htmlFor="display-max">
<Input
{/* 1..=16 is the host's own clamp on write. InputNumber holds the field to it
and — unlike the arithmetic this replaces — lets it be EMPTY on the way to
a new number instead of snapping to 1 the moment you clear it. */}
<InputNumber
id="display-max"
type="number"
min={1}
max={16}
className="w-24"
value={draft.max_displays ?? 4}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
max_displays: Math.min(
16,
Math.max(1, Number(e.target.value) || 1),
),
})
}
onChange={(max_displays) => setDraft({ ...draft, max_displays })}
/>
</Field>
@@ -1208,28 +1233,27 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
X
</Label>
<Input
{/* A screen left of or above the origin has a NEGATIVE coordinate, and the
arithmetic this replaces made one almost untypable: `Number("-") || 0`
is 0, so the lone minus sign was rewritten to "0" before the digits
could be typed. InputNumber holds "-" as an incomplete draft instead.
`Math.trunc` stays — positions are whole pixels. */}
<InputNumber
id={`disp-x-${slot}`}
type="number"
className="w-24"
value={p.x}
disabled={saveLayout.isPending}
onChange={(e) =>
setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))
}
onChange={(n) => setXY(slot, "x", Math.trunc(n))}
/>
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
Y
</Label>
<Input
<InputNumber
id={`disp-y-${slot}`}
type="number"
className="w-24"
value={p.y}
disabled={saveLayout.isPending}
onChange={(e) =>
setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))
}
onChange={(n) => setXY(slot, "y", Math.trunc(n))}
/>
</div>
);
@@ -142,6 +142,13 @@ export const SessionGameCard: FC = () => {
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"
+6
View File
@@ -362,6 +362,12 @@ export const GameForm: FC<{
onChange={set("publisher")}
/>
</div>
{/* These two stay `type="number"` over a STRING field rather than becoming
`InputNumber` like the policy card's numbers: both are OPTIONAL metadata
where empty means "don't send it" (see `int()` above), and InputNumber's
contract is `value: number` — it cannot express "unset", so adopting it
would invent a year for every entry that hasn't got one. The type here
only asks for a numeric keypad. */}
<div className="grid grid-cols-2 gap-4">
<Field
id="releaseYear"
+9 -1
View File
@@ -7,6 +7,7 @@ import {
useGetLibrary,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Card, CardContent } from "@/components/ui/card";
@@ -29,6 +30,7 @@ export const LibraryGridSection: FC<{
onEntries?: (entries: GameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
@@ -53,7 +55,13 @@ export const LibraryGridSection: FC<{
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
const ok = await confirm({
title: m.library_delete_confirm(),
description: m.library_delete_body(),
confirmLabel: m.library_delete(),
destructive: true,
});
if (!ok) return;
try {
await remove.mutateAsync({ id: customId(entry) });
} catch (e) {
+9 -1
View File
@@ -16,6 +16,7 @@ import {
useInstallPlugin,
useStoreCatalog,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -42,6 +43,7 @@ export const SourcesSection: FC<{
onFilter: (provider: string | null) => void;
}> = ({ activeFilter, onFilter }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const purge = useDeleteProviderEntries();
@@ -68,7 +70,13 @@ export const SourcesSection: FC<{
const onPurge = async (source: ScannerInfo) => {
const provider = source.provider ?? source.id;
const count = source.entries ?? 0;
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
const ok = await confirm({
title: m.library_provider_purge_confirm({ provider, count }),
description: m.library_provider_purge_body(),
confirmLabel: m.common_remove(),
destructive: true,
});
if (!ok) return;
try {
await purge.mutateAsync({ provider });
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
+10 -2
View File
@@ -11,6 +11,7 @@ import {
useListNativeClients,
useUnpairNativeClient,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -43,6 +44,7 @@ export interface PairedRow {
*/
export const PairedDevicesSection: FC = () => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const native = useListNativeClients();
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
@@ -65,8 +67,14 @@ export const PairedDevicesSection: FC = () => {
),
];
const onUnpair = (protocol: PairedProtocol, fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
const onUnpair = async (protocol: PairedProtocol, fingerprint: string) => {
const ok = await confirm({
title: m.pairing_native_unpair_confirm(),
description: m.pairing_native_unpair_body(),
confirmLabel: m.action_unpair(),
destructive: true,
});
if (!ok) return;
if (protocol === "native") {
unpairNative.mutate(
{ fingerprint },
+9 -2
View File
@@ -9,6 +9,7 @@ import {
useDenyPendingDevice,
useListPendingDevices,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -27,6 +28,7 @@ export const PendingDevicesSection: FC = () => {
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
// the rows carry an age that should not visibly lag.
const { promptText } = useDialogs();
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
@@ -35,8 +37,13 @@ export const PendingDevicesSection: FC = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
const onApprove = async (id: number, currentName: string) => {
const name = await promptText({
title: m.pairing_pending_name_title(),
label: m.pairing_pending_name_prompt(),
defaultValue: currentName,
confirmLabel: m.pairing_pending_approve(),
});
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
+10 -2
View File
@@ -9,6 +9,7 @@ import {
useStatsRecordingDelete,
useStatsRecordingsList,
} from "@/api/gen/stats/stats";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,11 +37,18 @@ export const RecordingsSection: FC<{
onSelect: (id: string | null) => void;
}> = ({ selectedId, onSelect }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const recordings = useStatsRecordingsList();
const del = useStatsRecordingDelete();
const onDelete = (id: string) => {
if (!confirm(m.stats_delete_confirm())) return;
const onDelete = async (id: string) => {
const ok = await confirm({
title: m.stats_delete_confirm(),
description: m.stats_delete_body(),
confirmLabel: m.stats_delete(),
destructive: true,
});
if (!ok) return;
del.mutate(
{ id },
{
+9 -1
View File
@@ -17,6 +17,7 @@ import {
useSetSource,
useStoreSources,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -49,6 +50,7 @@ const fmtFetched = (secs: number): string =>
* what trusting a third-party catalog means before anything is written to the host.
*/
export const SourcesTab: FC = () => {
const { confirm } = useDialogs();
const sources = useStoreSources();
const refresh = useRefreshCatalog();
const save = useSetSource();
@@ -82,7 +84,13 @@ export const SourcesTab: FC = () => {
};
const onRemove = async (source: StoreSource) => {
if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return;
const ok = await confirm({
title: m.store_source_remove_confirm({ name: source.name }),
description: m.store_source_remove_body(),
confirmLabel: m.store_source_remove(),
destructive: true,
});
if (!ok) return;
try {
await remove.mutateAsync(source.name);
} catch (e) {
+9 -4
View File
@@ -12,6 +12,7 @@ import {
useStoreJobs,
useUninstallPlugin,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -31,6 +32,7 @@ type StoreTab = "browse" | "installed" | "sources";
*/
export const SectionStore: FC = () => {
useLocale();
const { confirm } = useDialogs();
const [tab, setTab] = useState<StoreTab>("browse");
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
@@ -122,10 +124,13 @@ export const SectionStore: FC = () => {
};
const onUninstall = async (plugin: InstalledPlugin) => {
if (
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
)
return;
const ok = await confirm({
title: m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }),
description: m.store_uninstall_body(),
confirmLabel: m.store_uninstall(),
destructive: true,
});
if (!ok) return;
try {
const { job } = await uninstall.mutateAsync(plugin.pkg);
setJobId(job);
+84
View File
@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useState } from "react";
import { useDialogs } from "@/components/dialogs";
import { Button } from "@/components/ui/button";
import { m } from "@/paraglide/messages";
/**
* The console's confirm/prompt, in place of `window.confirm` / `window.prompt`.
*
* These replaced sixteen native calls. The native ones could not be storied at all a browser
* dialog is chrome, outside the page and outside any screenshot which is part of why they went
* unnoticed for so long in an otherwise fully-branded console.
*
* `open` fires the dialog on mount so the screenshot harness catches it without an interaction.
*/
const Demo = ({
open,
kind,
}: {
open: boolean;
kind: "destructive" | "plain" | "prompt";
}) => {
const { confirm, promptText } = useDialogs();
const [answer, setAnswer] = useState<string>("—");
const ask = async () => {
if (kind === "prompt") {
const name = await promptText({
title: m.display_preset_save_title(),
label: m.display_preset_name(),
defaultValue: "Couch (TV only)",
});
setAnswer(name ?? "cancelled");
return;
}
const ok = await confirm(
kind === "destructive"
? {
title: m.library_delete_confirm(),
description: m.library_delete_body(),
confirmLabel: m.library_delete(),
destructive: true,
}
: {
title: m.display_discard_confirm(),
confirmLabel: m.common_discard(),
},
);
setAnswer(String(ok));
};
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once, on mount, for the shot
useEffect(() => {
if (open) void ask();
}, [open]);
return (
<div className="space-y-4">
<Button onClick={ask}>Ask</Button>
<p className="text-sm text-muted-foreground">answered: {answer}</p>
</div>
);
};
// No `DialogsProvider` decorator here on purpose: `.storybook/preview.tsx` mounts it for every
// story, exactly as `__root` does for every route. A second one would work but would quietly render
// a second dialog host.
const meta = {
title: "UI/Dialogs",
component: Demo,
args: { open: true, kind: "destructive" },
} satisfies Meta<typeof Demo>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Anything that destroys or removes: red affirmative, and the consequence spelled out under it. */
export const Destructive: Story = {};
/** A plain choice — no red, because nothing is lost that the operator did not already choose. */
export const Plain: Story = { args: { kind: "plain" } };
/** The prompt: a real labelled field, autofocused, Enter to submit. */
export const Prompt: Story = { args: { kind: "prompt" } };