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/app-shell.tsx b/web/src/components/app-shell.tsx index a501cb76..1f78a4ef 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -12,11 +12,12 @@ import { Settings, Workflow, } from "lucide-react"; -import { motion, stagger } from "motion/react"; +import { motion } from "motion/react"; import { type ReactNode, useState } from "react"; import { useHostEvents } from "@/api/events"; import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins"; import { BrandMark } from "@/components/brand-mark"; +import { Stagger, staggerProps } from "@/components/stagger"; import { Wordmark } from "@/components/wordmark"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; import { cn } from "@/lib/utils"; @@ -72,10 +73,7 @@ export function AppShell({ children }: { children: ReactNode }) { {NAV.map(({ to, icon: Icon, label }) => ( @@ -148,13 +146,7 @@ function PluginNavSection() { // stagger nor the variants and plugin entries simply appeared. They arrive asynchronously // (and a fresh install adds one to a nav that is already on screen), which is exactly when // the animation earns its keep. - + ); })} - + ); } 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/stagger.tsx b/web/src/components/stagger.tsx new file mode 100644 index 00000000..ac4ac0aa --- /dev/null +++ b/web/src/components/stagger.tsx @@ -0,0 +1,55 @@ +import { type HTMLMotionProps, motion, stagger } from "motion/react"; +import type { FC } from "react"; + +/** The house cadence, in seconds between siblings. */ +export const STAGGER_GAP = 0.1; + +/** + * The stagger-container contract as plain props — for the places that need a specific element + * (`motion.nav`) rather than the `` div below. + * + * The empty `enter`/`from` variants are not a placeholder: a motion element only PROPAGATES a + * variant it names, so a container that defines none stops the cascade dead and its children never + * animate at all. + */ +export const staggerProps = (gap: number = STAGGER_GAP) => ({ + variants: { enter: {}, from: {} }, + transition: { delayChildren: stagger(gap) }, +}); + +/** + * The console's on-mount cadence: siblings arrive one after another, not all on the same frame. + * + * WHY THIS EXISTS AS A COMPONENT. Every animated primitive here (`Card`, `Button`, `Checkbox` — all + * `@unom/ui`) is a motion element whose `from`/`enter` variants are INHERITED from the nearest + * motion ancestor, and it is that ancestor which owns the timing. `@unom/ui`'s `
` sets + * `delayChildren: stagger(...)`, which is why a page whose cards are direct descendants of the + * Section staggers for free. + * + * The trap is that an `` is ALSO a motion element, and it sets no `delayChildren` — so + * a grid of cards nested inside a card becomes its own timing group and every tile lands at once. + * Nothing in the types catches that; it only shows up in a browser, beside a page that does it + * right. Wrapping the grid re-establishes the cadence. + * + * `root` is for a container with no animating motion ancestor to inherit from (the sidebar nav): it + * drives `from → enter` itself. Inside a `
` or a card, leave it off — supplying + * `initial`/`animate` there would run the group on its own clock instead of the page's. + */ +export const Stagger: FC< + HTMLMotionProps<"div"> & { + /** Seconds between siblings. */ + gap?: number; + /** Drive the enter animation instead of inheriting it — see above. */ + root?: boolean; + } +> = ({ gap, root = false, transition, ...props }) => { + const base = staggerProps(gap); + return ( + + ); +}; diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..3df5ef40 --- /dev/null +++ b/web/src/components/ui/checkbox.tsx @@ -0,0 +1,7 @@ +// The console's Checkbox IS @unom/ui's radix checkbox — shadcn-compatible tokens (border-input, +// data-checked:bg-primary), the tick drawn as an animated path, plus the shared toggle sound. +// +// It is `checked`/`onCheckedChange` (radix), NOT `checked`/`onChange` — a raw `` swapped in here silently loses the brand entirely, which is exactly how two of +// them survived in the library forms. +export { Checkbox } from "@unom/ui/form/checkbox"; diff --git a/web/src/components/ui/input-number.tsx b/web/src/components/ui/input-number.tsx new file mode 100644 index 00000000..dc525e37 --- /dev/null +++ b/web/src/components/ui/input-number.tsx @@ -0,0 +1,16 @@ +// The console's numeric field IS @unom/ui's form input-number — the Input with a number's rules +// layered on: it keeps a local draft while you type (so the field can be EMPTY on the way to a new +// value), commits only a finite in-range number, and clamps to `min`/`max` on blur. +// +// That is not cosmetic. The hand-rolled shape it replaces — +// +// set({ timeout_s: Number(e.target.value) || 30 })} /> +// +// — has two defects the shared component doesn't: clearing the field snaps it to the fallback +// instead of letting you retype, and `min`/`max` are decoration (an ``'s range is only +// enforced by form validation, which a controlled field like this never runs) — so 900 went +// straight into a timeout capped at 600. +// +// `onChange` hands you a `number`, not an event. +export { InputNumber } from "@unom/ui/form/input-number"; diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 00000000..77f5659c --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -0,0 +1,72 @@ +// The console's Select IS @unom/ui's radix select, with the same two corrections the Tabs wrapper +// needs — @unom/ui's palette names don't all mean the same thing in this app's token set: +// +// • `text-secondary`, which the trigger uses for BOTH the placeholder and the chevron, is a +// *text* colour upstream. Here `--secondary` is a SURFACE (#241c3d dark / #ece6fb light), so the +// chevron rendered at near-zero contrast against the card it sits on — a select that looked like +// a plain box with no affordance at all. `text-muted-foreground` is this app's "quiet text". +// • `border-main` is the foreground colour — a near-white 1px border in dark, which would make a +// select shout next to the `border-input` used by every Input beside it. +// +// The trigger also defaults to `w-full` (upstream is `w-fit`) and to the Input's `rounded-md`: in +// this console a select is a form field in a stacked column, never an inline chip. +// +// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's +// tokens once, rather than restyling it at every call site. +import { + Select, + SelectContent, + SelectGroup, + SelectItem as SelectItemBase, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger as SelectTriggerBase, + SelectValue, +} from "@unom/ui/form/select"; +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; + +const SelectTrigger = ({ + className, + ...props +}: ComponentProps) => ( + +); +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, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/web/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx new file mode 100644 index 00000000..40df7fa6 --- /dev/null +++ b/web/src/components/ui/textarea.tsx @@ -0,0 +1,4 @@ +// The console's Textarea IS @unom/ui's form textarea — the Input's twin (same `border-input` / +// `placeholder:text-muted-foreground` / focus ring tokens, material gloss via UnomProviders), so a +// multi-line field sits beside a single-line one without either looking borrowed. +export { Textarea } from "@unom/ui/form/textarea"; 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/HookForm.tsx b/web/src/sections/Automation/HookForm.tsx index f7197ae5..51a384e4 100644 --- a/web/src/sections/Automation/HookForm.tsx +++ b/web/src/sections/Automation/HookForm.tsx @@ -1,7 +1,7 @@ -import { Checkbox } from "@unom/ui/form/checkbox"; import { type FC, useEffect, useState } from "react"; import type { HookEntry } from "@/api/gen/model/hookEntry"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -11,7 +11,15 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { InputNumber } from "@/components/ui/input-number"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { m } from "@/paraglide/messages"; /** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts. @@ -104,18 +112,21 @@ export const HookForm: FC<{
- + {/* The event kinds are IDENTIFIERS, not prose — deliberately not routed through + i18n, and shown in the host's own `domain.event` spelling so what you pick here + reads the same as what you'd type into the SSE `?kinds=` filter. */} +

{m.automation_field_on_help()}

@@ -222,14 +233,11 @@ export const HookForm: FC<{ - - set({ debounce_ms: Number(e.target.value) || 0 }) - } + onChange={(debounce_ms) => set({ debounce_ms })} />
{kind === "run" && ( @@ -237,15 +245,14 @@ export const HookForm: FC<{ - - set({ timeout_s: Number(e.target.value) || 30 }) - } + onChange={(timeout_s) => set({ timeout_s })} /> )} 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 5692f0c0..8c28170b 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -1,6 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; import { useBlocker } from "@tanstack/react-router"; -import { Button } from "@unom/ui/button"; import { toast } from "@unom/ui/toast"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { @@ -37,10 +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"; @@ -54,6 +56,7 @@ import { m } from "@/paraglide/messages"; */ export const DisplaySection: FC = () => { const qc = useQueryClient(); + const { confirm } = useDialogs(); const q = useGetDisplaySettings(); const save = useSetDisplaySettings(); @@ -163,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, }); @@ -233,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", @@ -243,7 +261,11 @@ const PRESET_ORDER = [ "custom", ] as const; -const DisplayForm: FC<{ +/** + * The policy form itself — pure, so Storybook can render it (with the `` wrapper the page puts + * around it, which is also its motion parent) without a host answering `/display/settings`. + */ +export const DisplayForm: FC<{ draft: DisplayPolicy; setDraft: (p: DisplayPolicy) => void; presets: { id: string; summary: string; fields: EffectivePolicy }[]; @@ -279,6 +301,7 @@ const DisplayForm: FC<{ error, }) => { const qc = useQueryClient(); + const { confirm, promptText } = useDialogs(); const createPreset = useCreateCustomPreset(); const updatePreset = useUpdateCustomPreset(); const deletePreset = useDeleteCustomPreset(); @@ -309,11 +332,10 @@ 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; @@ -344,8 +366,8 @@ 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", @@ -372,8 +394,13 @@ 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( { @@ -386,8 +413,14 @@ 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( { @@ -413,8 +446,13 @@ 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 }); }; @@ -431,7 +469,11 @@ const DisplayForm: FC<{ -
+ {/* The preset tiles are cards nested INSIDE this page's config card, so their motion + parent is that card — which sets no `delayChildren` and therefore landed all six + on the same frame, unlike every other card grid in the console. `Stagger` gives + the group its own cadence back (see components/stagger.tsx). */} + {PRESET_ORDER.map((id) => { const p = presets.find((x) => x.id === id); const fields = id === "custom" ? undefined : p?.fields; @@ -506,7 +548,7 @@ const DisplayForm: FC<{ ); })} -
+ {/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but @@ -527,7 +569,7 @@ const DisplayForm: FC<{ {customPresets.length > 0 && ( -
+ {customPresets.map((p) => ( removePreset(p)} /> ))} -
+ )} {presetError && (

@@ -609,16 +651,14 @@ const DisplayForm: FC<{ {ka.mode === "duration" && (

- { - const n = Math.max(0, Number(e.target.value) || 0); + onChange={(n) => { setKeepSecs(n); setDraft({ ...draft, @@ -682,23 +722,17 @@ 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 })} /> @@ -1199,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 0ee9755a..a51e12ce 100644 --- a/web/src/sections/Displays/SessionGameCard.tsx +++ b/web/src/sections/Displays/SessionGameCard.tsx @@ -1,5 +1,4 @@ import { useQueryClient } from "@tanstack/react-query"; -import { Button } from "@unom/ui/button"; import { toast } from "@unom/ui/toast"; import { type FC, type ReactNode, useEffect, useState } from "react"; import { ApiError } from "@/api/fetcher"; @@ -11,6 +10,7 @@ import { } from "@/api/gen/session/session"; import { QueryState } from "@/components/query-state"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -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. */}
- - setForm((f) => ({ ...f, isLauncher: e.target.checked })) + onCheckedChange={(next) => + setForm((f) => ({ ...f, isLauncher: next === true })) } /> @@ -362,6 +362,12 @@ export const GameForm: FC<{ onChange={set("publisher")} />
+ {/* 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) { @@ -108,13 +116,9 @@ export const LibraryGrid: FC<{

{m.library_launchers_title()}

- + {launchers.map(card)} - +
)} {all.length === 0 ? ( @@ -136,13 +140,9 @@ export const LibraryGrid: FC<{ ) : ( games.length > 0 && (
- + {games.map(card)} - +
) )} diff --git a/web/src/sections/Library/SourceSettings.tsx b/web/src/sections/Library/SourceSettings.tsx index 2ff5dd7a..2a001258 100644 --- a/web/src/sections/Library/SourceSettings.tsx +++ b/web/src/sections/Library/SourceSettings.tsx @@ -2,6 +2,7 @@ import { toast } from "@unom/ui/toast"; import { type FC, useEffect, useState } from "react"; import type { ScannerInfo } from "@/api/gen/model/scannerInfo"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -10,7 +11,15 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; import { m } from "@/paraglide/messages"; /** @@ -152,7 +161,8 @@ const renderable = (node: JsonSchemaNode): boolean => { if (n.enum) return true; if (n.type === "boolean" || n.type === "string") return true; if (n.type === "number" || n.type === "integer") return true; - if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true; + if (n.type === "array" && flatten(n.items ?? {}).type === "string") + return true; if (n.type === "object" && n.properties) { return Object.values(n.properties).every(renderable); } @@ -173,7 +183,8 @@ const ConfigForm: FC<{ // Fall back to the JSON editor when there is no schema, or any field is a shape the generic // form can't express (a non-enum union, a $ref). Partial rendering would be worse than none: // a field silently missing from the form is a setting the operator cannot change. - const canRender = props !== undefined && Object.values(props).every(renderable); + const canRender = + props !== undefined && Object.values(props).every(renderable); if (!canRender) { return ( @@ -181,8 +192,8 @@ const ConfigForm: FC<{

{m.library_source_settings_json_hint()}

-