From 3be7d1d4f8f6a57016c379bb394bc34b2e58d107 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 22:56:46 +0200 Subject: [PATCH] feat(web): the console asks its own questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. `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. --- web/.storybook/preview.tsx | 25 ++- web/messages/de.json | 33 ++- web/messages/en.json | 33 ++- web/src/components/dialogs.tsx | 209 ++++++++++++++++++ web/src/components/ui/select.tsx | 17 +- web/src/routes/__root.tsx | 18 +- web/src/sections/Automation/index.tsx | 11 +- web/src/sections/Dashboard/index.tsx | 36 +-- web/src/sections/Displays/DisplayCard.tsx | 100 +++++---- web/src/sections/Displays/SessionGameCard.tsx | 7 + web/src/sections/Library/GameForm.tsx | 6 + web/src/sections/Library/LibraryGrid.tsx | 10 +- web/src/sections/Library/Sources.tsx | 10 +- web/src/sections/Pairing/PairedDevices.tsx | 12 +- web/src/sections/Pairing/PendingDevices.tsx | 11 +- web/src/sections/Stats/Recordings.tsx | 12 +- web/src/sections/Store/Sources.tsx | 10 +- web/src/sections/Store/index.tsx | 13 +- web/src/stories/Dialogs.stories.tsx | 84 +++++++ 19 files changed, 557 insertions(+), 100 deletions(-) create mode 100644 web/src/components/dialogs.tsx create mode 100644 web/src/stories/Dialogs.stories.tsx diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index 1b270589..bb8398d4 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { defaultMaterialTheme, MaterialProvider } from "@unom/ui/material"; import Section from "@unom/ui/section"; import { useEffect } from "react"; +import { DialogsProvider } from "../src/components/dialogs"; // React Query is present so any query-backed component mounts without a real // host. Stories should feed mock data rather than fetch — retries are off so a @@ -51,15 +52,21 @@ export default definePreview({ return ( -
-
-
- -
-
-
+ {/* The console's confirm/prompt, same as __root mounts. Any section that can + delete or discard something calls `useDialogs()`, which THROWS without + this — so it belongs beside the other app-level providers rather than in + each story's decorators. */} + +
+
+
+ +
+
+
+
); diff --git a/web/messages/de.json b/web/messages/de.json index 56f2cb49..0af6c264 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -225,6 +225,7 @@ "display_layout_manual": "Manuell", "display_preset_custom_label": "Eigene Voreinstellungen", "display_preset_save_as": "Als Voreinstellung speichern…", + "display_preset_save_title": "Als Voreinstellung speichern", "display_preset_name": "Name der Voreinstellung", "display_preset_edit": "Umbenennen", "display_preset_update": "Auf aktuelle Einstellungen aktualisieren", @@ -254,7 +255,8 @@ "pairing_native_cancel": "Abbrechen", "pairing_native_devices": "Gekoppelte Geräte", "pairing_native_empty": "Noch keine Geräte gekoppelt.", - "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", + "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln?", + "pairing_native_unpair_body": "Es muss sich erneut koppeln, um zu verbinden.", "pairing_protocol": "Protokoll", "pairing_protocol_native": "punktfunk/1", "pairing_protocol_moonlight": "Moonlight", @@ -262,7 +264,8 @@ "pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.", "pairing_pending_approve": "Freigeben", "pairing_pending_deny": "Ablehnen", - "pairing_pending_name_prompt": "Gerät benennen:", + "pairing_pending_name_prompt": "Gerät benennen", + "pairing_pending_name_title": "Dieses Gerät zulassen", "pairing_pending_age_just_now": "gerade eben", "pairing_pending_age_secs": "vor {s}s", "pairing_pending_age_mins": "vor {min} min", @@ -324,7 +327,8 @@ "library_provider_filter": "Nur diese zeigen", "library_provider_show_all": "Alle zeigen", "library_provider_purge": "Einträge dieses Anbieters entfernen", - "library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.", + "library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen?", + "library_provider_purge_body": "Das entfernt sie nur aus der Bibliothek.", "library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.", "library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.", "library_save": "Speichern", @@ -332,13 +336,18 @@ "library_cancel": "Abbrechen", "library_edit": "Bearbeiten", "library_delete": "Löschen", - "library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.", + "library_delete_confirm": "Dieses eigene Spiel löschen?", + "library_delete_body": "Das kann nicht rückgängig gemacht werden.", "settings_title": "Einstellungen", "settings_language": "Sprache", "common_loading": "Wird geladen…", "common_error": "Etwas ist schiefgelaufen.", "common_retry": "Erneut versuchen", "common_cancel": "Abbrechen", + "common_confirm": "Bestätigen", + "common_save": "Speichern", + "common_discard": "Verwerfen", + "common_remove": "Entfernen", "common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…", "login_title": "Anmelden", "login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?", @@ -412,7 +421,8 @@ "stats_view": "Ansehen", "stats_download": "Herunterladen", "stats_delete": "Löschen", - "stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.", + "stats_delete_confirm": "Diese Aufnahme löschen?", + "stats_delete_body": "Das kann nicht rückgängig gemacht werden.", "stats_detail_title": "Aufnahme-Details", "stats_close": "Schließen", "stats_no_samples": "Diese Aufnahme enthält keine Proben.", @@ -462,7 +472,8 @@ "store_stopped": "Läuft nicht", "store_version_unknown": "Version unbekannt", "store_uninstall": "Deinstallieren", - "store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.", + "store_uninstall_confirm": "{title} deinstallieren?", + "store_uninstall_body": "Du kannst es jederzeit wieder aus dem Katalog installieren.", "store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.", "store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.", "store_sources_title": "Katalogquellen", @@ -477,7 +488,8 @@ "store_source_fetched": "zuletzt geladen {when}", "store_source_never": "nie", "store_source_remove": "Quelle entfernen", - "store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.", + "store_source_remove_confirm": "Die Quelle „{name}“ entfernen?", + "store_source_remove_body": "Bereits daraus installierte Plugins bleiben installiert.", "store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.", "store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.", "store_add_source_title": "Katalogquelle hinzufügen", @@ -536,8 +548,11 @@ "games_state_exited": "Beendet", "games_state_grace": "Wartet auf Client", "games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt", - "games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele. Fortfahren?", - "action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese. Fortfahren?", + "games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele.", + "games_end_all_waiting_title": "Alle {count} wartenden Spiele beenden?", + "action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese.", + "action_stop_session_all_title": "Jede laufende Sitzung beenden?", + "action_stop_session_all": "Alle Sitzungen beenden", "games_end_now": "Jetzt beenden", "session_game_title": "Wenn ein Spiel oder eine Sitzung endet", "session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.", diff --git a/web/messages/en.json b/web/messages/en.json index da6286ae..87da9002 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -225,6 +225,7 @@ "display_layout_manual": "Manual", "display_preset_custom_label": "Custom presets", "display_preset_save_as": "Save as preset…", + "display_preset_save_title": "Save as preset", "display_preset_name": "Preset name", "display_preset_edit": "Rename", "display_preset_update": "Update to current settings", @@ -254,7 +255,8 @@ "pairing_native_cancel": "Cancel", "pairing_native_devices": "Paired devices", "pairing_native_empty": "No devices paired yet.", - "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.", + "pairing_native_unpair_confirm": "Unpair this device?", + "pairing_native_unpair_body": "It will need to pair again to connect.", "pairing_protocol": "Protocol", "pairing_protocol_native": "punktfunk/1", "pairing_protocol_moonlight": "Moonlight", @@ -262,7 +264,8 @@ "pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.", "pairing_pending_approve": "Approve", "pairing_pending_deny": "Deny", - "pairing_pending_name_prompt": "Name this device:", + "pairing_pending_name_prompt": "Name this device", + "pairing_pending_name_title": "Approve this device", "pairing_pending_age_just_now": "just now", "pairing_pending_age_secs": "{s}s ago", "pairing_pending_age_mins": "{min} min ago", @@ -324,7 +327,8 @@ "library_provider_filter": "Show only these", "library_provider_show_all": "Show all", "library_provider_purge": "Remove this provider's entries", - "library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.", + "library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”?", + "library_provider_purge_body": "This only removes them from the library.", "library_provider_purged": "Removed the entries synced by “{provider}”.", "library_provider_purge_failed": "Could not remove this provider's entries.", "library_save": "Save", @@ -332,13 +336,18 @@ "library_cancel": "Cancel", "library_edit": "Edit", "library_delete": "Delete", - "library_delete_confirm": "Delete this custom game? This can't be undone.", + "library_delete_confirm": "Delete this custom game?", + "library_delete_body": "This can't be undone.", "settings_title": "Settings", "settings_language": "Language", "common_loading": "Loading…", "common_error": "Something went wrong.", "common_retry": "Retry", "common_cancel": "Cancel", + "common_confirm": "Confirm", + "common_save": "Save", + "common_discard": "Discard", + "common_remove": "Remove", "common_unauthorized": "Session expired — redirecting to sign in…", "login_title": "Sign in", "login_subtitle": "Enter the management password to continue. Don't know what to do?", @@ -412,7 +421,8 @@ "stats_view": "View", "stats_download": "Download", "stats_delete": "Delete", - "stats_delete_confirm": "Delete this recording? This can't be undone.", + "stats_delete_confirm": "Delete this recording?", + "stats_delete_body": "This can't be undone.", "stats_detail_title": "Recording detail", "stats_close": "Close", "stats_no_samples": "This recording has no samples.", @@ -462,7 +472,8 @@ "store_stopped": "Not running", "store_version_unknown": "version unknown", "store_uninstall": "Uninstall", - "store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.", + "store_uninstall_confirm": "Uninstall {title}?", + "store_uninstall_body": "You can install it again from the catalog.", "store_uninstall_failed": "Could not start the removal.", "store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.", "store_sources_title": "Catalog sources", @@ -477,7 +488,8 @@ "store_source_fetched": "last fetched {when}", "store_source_never": "never", "store_source_remove": "Remove source", - "store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.", + "store_source_remove_confirm": "Remove the source “{name}”?", + "store_source_remove_body": "Plugins already installed from it stay installed.", "store_source_remove_failed": "Could not remove the source.", "store_source_builtin_locked": "The built-in unom catalog can't be removed.", "store_add_source_title": "Add a catalog source", @@ -536,8 +548,11 @@ "games_state_exited": "Ended", "games_state_grace": "Waiting for client", "games_closing_in": "Its client is gone — closing in {time} unless it comes back", - "games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close. Continue?", - "action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one. Continue?", + "games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close.", + "games_end_all_waiting_title": "End all {count} waiting games?", + "action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one.", + "action_stop_session_all_title": "Stop every live session?", + "action_stop_session_all": "Stop every session", "games_end_now": "End now", "session_game_title": "When a game or a session ends", "session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.", diff --git a/web/src/components/dialogs.tsx b/web/src/components/dialogs.tsx new file mode 100644 index 00000000..edde5cff --- /dev/null +++ b/web/src/components/dialogs.tsx @@ -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`. 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; + /** Resolves the entered text, or null if the operator backed out — same contract as `prompt`. */ + promptText: (options: PromptOptions) => Promise; +}; + +type Pending = + | { kind: "confirm"; options: ConfirmOptions; settle: (ok: boolean) => void } + | { + kind: "prompt"; + options: PromptOptions; + settle: (text: string | null) => void; + }; + +const DialogsContext = createContext(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(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( + () => ({ + confirm: (options) => + new Promise((resolve) => + setPending((prev) => { + cancel(prev); + return { kind: "confirm", options, settle: resolve }; + }), + ), + promptText: (options) => + new Promise((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 ( + + {children} + { + if (!open) cancelled(); + }} + > + {pending && options && ( + + + {options.title} + {/* 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 ? ( + {options.description} + ) : ( + + {options.title} + + )} + + + {pending.kind === "prompt" && ( +
+ + setDraft(e.target.value)} + onKeyDown={(e) => { + // Enter submits — the reflex `prompt()` trained everyone into. + if (e.key === "Enter") { + e.preventDefault(); + close(draft); + } + }} + /> +
+ )} + + + + + +
+ )} +
+
+ ); +}; + +/** + * 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 (__root)", + ); + return ctx; +}; diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx index 9e33889e..77f5659c 100644 --- a/web/src/components/ui/select.tsx +++ b/web/src/components/ui/select.tsx @@ -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) => ( + +); +SelectItem.displayName = "SelectItem"; + export { Select, SelectContent, diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index df63434a..c5625bf4 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -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. */} - {isLogin ? ( - - ) : ( - + {/* 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. */} + + {isLogin ? ( - - )} + ) : ( + + + + )} + {/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */} diff --git a/web/src/sections/Automation/index.tsx b/web/src/sections/Automation/index.tsx index 297329ff..5762b9ef 100644 --- a/web/src/sections/Automation/index.tsx +++ b/web/src/sections/Automation/index.tsx @@ -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)); }; diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx index 2e697bcd..15f8662d 100644 --- a/web/src/sections/Dashboard/index.tsx +++ b/web/src/sections/Dashboard/index.tsx @@ -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 => { 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 ( { - if (!confirmStopAll()) return; + onStopSession={async () => { + if (!(await confirmStopAll())) return; stop.mutate(undefined, { onSuccess: invalidate, onError: failed(m.action_stop_failed()), diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 12a3c264..8c28170b 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -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<{ {ka.mode === "duration" && (
- { - const n = Math.max(0, Number(e.target.value) || 0); + onChange={(n) => { setKeepSecs(n); setDraft({ ...draft, @@ -691,23 +722,17 @@ export const DisplayForm: FC<{ /> - - setDraft({ - ...draft, - max_displays: Math.min( - 16, - Math.max(1, Number(e.target.value) || 1), - ), - }) - } + onChange={(max_displays) => setDraft({ ...draft, max_displays })} /> @@ -1208,28 +1233,27 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ - - setXY(slot, "x", Math.trunc(Number(e.target.value) || 0)) - } + onChange={(n) => setXY(slot, "x", Math.trunc(n))} /> - - setXY(slot, "y", Math.trunc(Number(e.target.value) || 0)) - } + onChange={(n) => setXY(slot, "y", Math.trunc(n))} />
); diff --git a/web/src/sections/Displays/SessionGameCard.tsx b/web/src/sections/Displays/SessionGameCard.tsx index 92779d15..a51e12ce 100644 --- a/web/src/sections/Displays/SessionGameCard.tsx +++ b/web/src/sections/Displays/SessionGameCard.tsx @@ -142,6 +142,13 @@ export const SessionGameCard: FC = () => { htmlFor="session-grace-seconds" >
+ {/* 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. */}
+ {/* 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. */}
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) { diff --git a/web/src/sections/Library/Sources.tsx b/web/src/sections/Library/Sources.tsx index e4dbb7a4..f54129d5 100644 --- a/web/src/sections/Library/Sources.tsx +++ b/web/src/sections/Library/Sources.tsx @@ -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() }); diff --git a/web/src/sections/Pairing/PairedDevices.tsx b/web/src/sections/Pairing/PairedDevices.tsx index 61821381..90af41db 100644 --- a/web/src/sections/Pairing/PairedDevices.tsx +++ b/web/src/sections/Pairing/PairedDevices.tsx @@ -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 }, diff --git a/web/src/sections/Pairing/PendingDevices.tsx b/web/src/sections/Pairing/PendingDevices.tsx index 97e9f878..82ca1e14 100644 --- a/web/src/sections/Pairing/PendingDevices.tsx +++ b/web/src/sections/Pairing/PendingDevices.tsx @@ -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 } }, diff --git a/web/src/sections/Stats/Recordings.tsx b/web/src/sections/Stats/Recordings.tsx index cf7a4358..15779623 100644 --- a/web/src/sections/Stats/Recordings.tsx +++ b/web/src/sections/Stats/Recordings.tsx @@ -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 }, { diff --git a/web/src/sections/Store/Sources.tsx b/web/src/sections/Store/Sources.tsx index bff01c68..02478cd5 100644 --- a/web/src/sections/Store/Sources.tsx +++ b/web/src/sections/Store/Sources.tsx @@ -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) { diff --git a/web/src/sections/Store/index.tsx b/web/src/sections/Store/index.tsx index 7570895a..92ada35c 100644 --- a/web/src/sections/Store/index.tsx +++ b/web/src/sections/Store/index.tsx @@ -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("browse"); // The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state. const [target, setTarget] = useState(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); diff --git a/web/src/stories/Dialogs.stories.tsx b/web/src/stories/Dialogs.stories.tsx new file mode 100644 index 00000000..dae72c50 --- /dev/null +++ b/web/src/stories/Dialogs.stories.tsx @@ -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("—"); + + 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 ( +
+ +

answered: {answer}

+
+ ); +}; + +// 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; + +export default meta; +type Story = StoryObj; + +/** 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" } };