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" } };