Merge pull request 'The console stops borrowing the browser's controls — and its preset tiles stop landing all at once' (#100) from worktree-web-console-sweep into main
ci / bun-nix (push) Successful in 32s
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
ci / rust-arm64 (push) Successful in 2m28s
windows-host / package (push) Failing after 1m31s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
deb / build-publish-client-arm64 (push) Successful in 4m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m27s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish (push) Successful in 5m37s
ci / rust (push) Successful in 7m27s
arch / build-publish (push) Successful in 8m48s
docker / deploy-docs (push) Failing after 6m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m22s
ci / bun-nix (push) Successful in 32s
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
ci / rust-arm64 (push) Successful in 2m28s
windows-host / package (push) Failing after 1m31s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
deb / build-publish-client-arm64 (push) Successful in 4m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m27s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish (push) Successful in 5m37s
ci / rust (push) Successful in 7m27s
arch / build-publish (push) Successful in 8m48s
docker / deploy-docs (push) Failing after 6m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m22s
Reviewed-on: #100
This commit was merged in pull request #100.
This commit is contained in:
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MaterialProvider theme={defaultMaterialTheme}>
|
||||
<div className={dark ? "dark" : ""}>
|
||||
<Section maxWidth={false}>
|
||||
<div
|
||||
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<DialogsProvider>
|
||||
<div className={dark ? "dark" : ""}>
|
||||
<Section maxWidth={false}>
|
||||
<div
|
||||
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</DialogsProvider>
|
||||
</MaterialProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
+24
-9
@@ -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.",
|
||||
|
||||
+24
-9
@@ -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.",
|
||||
|
||||
@@ -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 }) {
|
||||
<motion.nav
|
||||
animate="enter"
|
||||
initial="from"
|
||||
transition={{
|
||||
delayChildren: stagger(0.1),
|
||||
}}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
{...staggerProps()}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{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.
|
||||
<motion.div
|
||||
animate="enter"
|
||||
initial="from"
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="mt-6 flex flex-col gap-1"
|
||||
>
|
||||
<Stagger root className="mt-6 flex flex-col gap-1">
|
||||
<motion.p
|
||||
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
|
||||
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
|
||||
@@ -193,7 +185,7 @@ function PluginNavSection() {
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</Stagger>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* `window.confirm` / `window.prompt`, replaced by the console's own Dialog.
|
||||
*
|
||||
* WHY A PROVIDER AND NOT A COMPONENT PER SITE. The native calls this replaces are *expressions* —
|
||||
* `if (!confirm(…)) return;` — sixteen of them, threaded through mutation handlers and one router
|
||||
* navigation guard. Rewriting each into "hold the pending action in state, render a dialog, run the
|
||||
* action from its onConfirm" would have put a piece of dialog machinery in every section file and
|
||||
* turned each linear handler inside out. Handing back a PROMISE keeps the call sites the shape they
|
||||
* already are:
|
||||
*
|
||||
* if (!(await confirm({ title: … }))) return;
|
||||
*
|
||||
* which is also why the navigation guard could come along: TanStack's `shouldBlockFn` accepts
|
||||
* `Promise<boolean>`. The one native prompt that necessarily stays is `beforeunload` — a reload or
|
||||
* a tab close is the browser's dialog to draw, not ours.
|
||||
*/
|
||||
export type ConfirmOptions = {
|
||||
/** The question. Short — it is a heading. */
|
||||
title: string;
|
||||
/** What the operator is not being told by the title alone: the consequence. */
|
||||
description?: string;
|
||||
/** Affirmative label. Use the same verb as the control that opened the dialog. */
|
||||
confirmLabel?: string;
|
||||
/** Paint the affirmative button red — anything that destroys, removes, or interrupts. */
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
export type PromptOptions = {
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel?: string;
|
||||
/** Field label. */
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type Dialogs = {
|
||||
/** Resolves true only if the operator confirmed; false on cancel, Esc, or overlay click. */
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
/** Resolves the entered text, or null if the operator backed out — same contract as `prompt`. */
|
||||
promptText: (options: PromptOptions) => Promise<string | null>;
|
||||
};
|
||||
|
||||
type Pending =
|
||||
| { kind: "confirm"; options: ConfirmOptions; settle: (ok: boolean) => void }
|
||||
| {
|
||||
kind: "prompt";
|
||||
options: PromptOptions;
|
||||
settle: (text: string | null) => void;
|
||||
};
|
||||
|
||||
const DialogsContext = createContext<Dialogs | null>(null);
|
||||
|
||||
/** Settle a request negatively. Must switch on `kind` — the two `settle`s take different types. */
|
||||
const cancel = (p: Pending | null) => {
|
||||
if (!p) return;
|
||||
if (p.kind === "confirm") p.settle(false);
|
||||
else p.settle(null);
|
||||
};
|
||||
|
||||
export const DialogsProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
// `setPending` takes the updater form so a second ask while one is already open cannot strand the
|
||||
// first promise unresolved — the caller would await forever. Two at once shouldn't happen (the
|
||||
// open dialog is modal), but the navigation guard can fire from outside the page's own UI.
|
||||
const api = useMemo<Dialogs>(
|
||||
() => ({
|
||||
confirm: (options) =>
|
||||
new Promise<boolean>((resolve) =>
|
||||
setPending((prev) => {
|
||||
cancel(prev);
|
||||
return { kind: "confirm", options, settle: resolve };
|
||||
}),
|
||||
),
|
||||
promptText: (options) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
setDraft(options.defaultValue ?? "");
|
||||
setPending((prev) => {
|
||||
cancel(prev);
|
||||
return { kind: "prompt", options, settle: resolve };
|
||||
});
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/** Resolve and close. Every exit — button, Esc, overlay — goes through here exactly once. */
|
||||
const close = (value: boolean | string | null) => {
|
||||
if (!pending) return;
|
||||
if (pending.kind === "confirm") pending.settle(value === true);
|
||||
else pending.settle(typeof value === "string" ? value : null);
|
||||
setPending(null);
|
||||
};
|
||||
|
||||
const cancelled = () => close(pending?.kind === "prompt" ? null : false);
|
||||
const options = pending?.options;
|
||||
|
||||
return (
|
||||
<DialogsContext.Provider value={api}>
|
||||
{children}
|
||||
<Dialog
|
||||
open={pending !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) cancelled();
|
||||
}}
|
||||
>
|
||||
{pending && options && (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{options.title}</DialogTitle>
|
||||
{/* Radix warns when a dialog has no description; render the element only when
|
||||
there is one to say, and tell it so explicitly otherwise. */}
|
||||
{options.description ? (
|
||||
<DialogDescription>{options.description}</DialogDescription>
|
||||
) : (
|
||||
<DialogDescription className="sr-only">
|
||||
{options.title}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{pending.kind === "prompt" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dialog-prompt">{pending.options.label}</Label>
|
||||
<Input
|
||||
id="dialog-prompt"
|
||||
// Autofocus is right here and nowhere else: a modal text prompt exists
|
||||
// to be typed into, and it took the focus automatically as a `prompt()`.
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
placeholder={pending.options.placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter submits — the reflex `prompt()` trained everyone into.
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
close(draft);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={cancelled}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
pending.kind === "confirm" && pending.options.destructive
|
||||
? "destructive"
|
||||
: "default"
|
||||
}
|
||||
onClick={() => close(pending.kind === "prompt" ? draft : true)}
|
||||
>
|
||||
{options.confirmLabel ??
|
||||
(pending.kind === "prompt"
|
||||
? m.common_save()
|
||||
: m.common_confirm())}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
</DialogsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The console's `confirm` / `prompt`. Both return a promise, so a handler reads top to bottom:
|
||||
*
|
||||
* const onDelete = async () => {
|
||||
* if (!(await confirm({ title: m.x(), confirmLabel: m.y(), destructive: true }))) return;
|
||||
* await remove.mutateAsync(…);
|
||||
* };
|
||||
*/
|
||||
export const useDialogs = (): Dialogs => {
|
||||
const ctx = useContext(DialogsContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useDialogs must be used inside <DialogsProvider> (__root)",
|
||||
);
|
||||
return ctx;
|
||||
};
|
||||
@@ -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 `<Stagger>` 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 `<Section>` sets
|
||||
* `delayChildren: stagger(...)`, which is why a page whose cards are direct descendants of the
|
||||
* Section staggers for free.
|
||||
*
|
||||
* The trap is that an `<AnimatedCard>` 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 `<Section>` 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 (
|
||||
<motion.div
|
||||
{...(root ? { initial: "from", animate: "enter" } : {})}
|
||||
variants={base.variants}
|
||||
transition={{ ...base.transition, ...transition }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 `<input
|
||||
// type="checkbox">` 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";
|
||||
@@ -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 —
|
||||
//
|
||||
// <Input type="number" min={1} max={600}
|
||||
// onChange={(e) => 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 `<input>`'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";
|
||||
@@ -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<typeof SelectTriggerBase>) => (
|
||||
<SelectTriggerBase
|
||||
className={cn(
|
||||
"w-full rounded-md border-input data-placeholder:text-muted-foreground",
|
||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SelectTrigger.displayName = "SelectTrigger";
|
||||
|
||||
// The highlighted row. Upstream paints it `bg-main/25` — a neutral grey wash, since `--main` is the
|
||||
// foreground colour. Everywhere else in this console the "this is the one" wash is brand violet
|
||||
// (`bg-primary/15` on the nav's hover and active states, `ring-primary` on a chosen preset card), so
|
||||
// a grey row is the odd one out the moment a select sits next to any of them.
|
||||
const SelectItem = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectItemBase>) => (
|
||||
<SelectItemBase
|
||||
className={cn("focus:bg-primary/15 focus:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SelectItem.displayName = "SelectItem";
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -13,6 +13,7 @@ import { Toaster } from "@unom/ui/toast";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { DialogsProvider } from "@/components/dialogs";
|
||||
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
|
||||
import appCss from "@/styles.css?url";
|
||||
|
||||
@@ -68,13 +69,18 @@ function RootComponent() {
|
||||
animated at full strength even for someone whose OS asks for less. "user" honours
|
||||
the OS setting. */}
|
||||
<MotionConfig reducedMotion="user">
|
||||
{isLogin ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<AppShell>
|
||||
{/* The console's own confirm/prompt, in place of the browser's grey boxes. Mounted
|
||||
at the root because the navigation guard on the Displays page asks for one
|
||||
while LEAVING that page — see components/dialogs.tsx. */}
|
||||
<DialogsProvider>
|
||||
{isLogin ? (
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)}
|
||||
) : (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)}
|
||||
</DialogsProvider>
|
||||
</MotionConfig>
|
||||
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
|
||||
<Toaster />
|
||||
|
||||
@@ -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<{
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
|
||||
<select
|
||||
id="hook-on"
|
||||
value={draft.on}
|
||||
onChange={(e) => set({ on: e.target.value })}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{EVENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* 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. */}
|
||||
<Select value={draft.on} onValueChange={(on) => set({ on })}>
|
||||
<SelectTrigger id="hook-on">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EVENT_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{k}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.automation_field_on_help()}
|
||||
</p>
|
||||
@@ -222,14 +233,11 @@ export const HookForm: FC<{
|
||||
<Label htmlFor="hook-debounce">
|
||||
{m.automation_field_debounce()}
|
||||
</Label>
|
||||
<Input
|
||||
<InputNumber
|
||||
id="hook-debounce"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.debounce_ms ?? 0}
|
||||
onChange={(e) =>
|
||||
set({ debounce_ms: Number(e.target.value) || 0 })
|
||||
}
|
||||
onChange={(debounce_ms) => set({ debounce_ms })}
|
||||
/>
|
||||
</div>
|
||||
{kind === "run" && (
|
||||
@@ -237,15 +245,14 @@ export const HookForm: FC<{
|
||||
<Label htmlFor="hook-timeout">
|
||||
{m.automation_field_timeout()}
|
||||
</Label>
|
||||
<Input
|
||||
{/* 600 is the host's own ceiling — the old field let 900 through, and the
|
||||
hook then failed at run time rather than at the point of typing it. */}
|
||||
<InputNumber
|
||||
id="hook-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={draft.timeout_s ?? 30}
|
||||
onChange={(e) =>
|
||||
set({ timeout_s: Number(e.target.value) || 30 })
|
||||
}
|
||||
onChange={(timeout_s) => set({ timeout_s })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useRequestIdr,
|
||||
useStopSession,
|
||||
} from "@/api/gen/session/session";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
@@ -17,6 +18,7 @@ import { DashboardView } from "./view";
|
||||
export const SectionDashboard: FC = () => {
|
||||
useLocale();
|
||||
const qc = useQueryClient();
|
||||
const { confirm } = useDialogs();
|
||||
// Session/game transitions arrive on the event stream now (api/events.ts invalidates this key),
|
||||
// so the timer only has to cover what events cannot: the live stream numbers — codec, resolution,
|
||||
// fps, bitrate — which change continuously while something is streaming. Idle, it is a slow
|
||||
@@ -61,23 +63,26 @@ export const SectionDashboard: FC = () => {
|
||||
* - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace
|
||||
* row for an operator-typed command carries no `app_id` — so that row ended all of them.
|
||||
*/
|
||||
const onEndGame = (game: ActiveGame) => {
|
||||
const onEndGame = async (game: ActiveGame) => {
|
||||
const games = status.data?.games ?? [];
|
||||
if (game.state === "grace") {
|
||||
const waiting = games.filter((g) => g.state === "grace").length;
|
||||
if (
|
||||
!game.app_id &&
|
||||
waiting > 1 &&
|
||||
!confirm(m.games_end_all_waiting_confirm({ count: waiting }))
|
||||
)
|
||||
return;
|
||||
if (!game.app_id && waiting > 1) {
|
||||
const ok = await confirm({
|
||||
title: m.games_end_all_waiting_title({ count: waiting }),
|
||||
description: m.games_end_all_waiting_confirm({ count: waiting }),
|
||||
confirmLabel: m.games_end_now(),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
endGame.mutate(
|
||||
{ data: { app_id: game.app_id ?? null } },
|
||||
{ onSuccess: invalidate, onError: failed(m.games_end_failed()) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirmStopAll()) return;
|
||||
if (!(await confirmStopAll())) return;
|
||||
stop.mutate(undefined, {
|
||||
onSuccess: invalidate,
|
||||
onError: failed(m.action_stop_failed()),
|
||||
@@ -86,18 +91,23 @@ export const SectionDashboard: FC = () => {
|
||||
|
||||
/** Shared by "End now" on a live row and the card's own Stop-session button: with more than one
|
||||
* session live, stopping is not a per-client action and the operator has to know that. */
|
||||
const confirmStopAll = (): boolean => {
|
||||
const confirmStopAll = (): Promise<boolean> => {
|
||||
const active = status.data?.active_sessions ?? 0;
|
||||
if (active <= 1) return true;
|
||||
return confirm(m.action_stop_session_all_confirm({ count: active }));
|
||||
if (active <= 1) return Promise.resolve(true);
|
||||
return confirm({
|
||||
title: m.action_stop_session_all_title(),
|
||||
description: m.action_stop_session_all_confirm({ count: active }),
|
||||
confirmLabel: m.action_stop_session_all(),
|
||||
destructive: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardView
|
||||
status={status}
|
||||
library={library.data}
|
||||
onStopSession={() => {
|
||||
if (!confirmStopAll()) return;
|
||||
onStopSession={async () => {
|
||||
if (!(await confirmStopAll())) return;
|
||||
stop.mutate(undefined, {
|
||||
onSuccess: invalidate,
|
||||
onError: failed(m.action_stop_failed()),
|
||||
|
||||
@@ -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 `<Card>` 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<{
|
||||
<Label className="mb-1 block text-base font-semibold">
|
||||
{m.display_preset()}
|
||||
</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{/* 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). */}
|
||||
<Stagger className="grid gap-3 sm:grid-cols-2">
|
||||
{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<{
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stagger>
|
||||
</div>
|
||||
|
||||
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but
|
||||
@@ -527,7 +569,7 @@ const DisplayForm: FC<{
|
||||
</Button>
|
||||
</div>
|
||||
{customPresets.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Stagger className="grid gap-3 sm:grid-cols-2">
|
||||
{customPresets.map((p) => (
|
||||
<CustomPresetCard
|
||||
key={p.id}
|
||||
@@ -540,7 +582,7 @@ const DisplayForm: FC<{
|
||||
onDelete={() => removePreset(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Stagger>
|
||||
)}
|
||||
{presetError && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||||
@@ -609,16 +651,14 @@ const DisplayForm: FC<{
|
||||
</Button>
|
||||
{ka.mode === "duration" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
<InputNumber
|
||||
id="display-keep-alive-seconds"
|
||||
aria-label={m.display_keep_alive_seconds()}
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={ka.seconds}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const n = Math.max(0, Number(e.target.value) || 0);
|
||||
onChange={(n) => {
|
||||
setKeepSecs(n);
|
||||
setDraft({
|
||||
...draft,
|
||||
@@ -682,23 +722,17 @@ const DisplayForm: FC<{
|
||||
/>
|
||||
|
||||
<Field label={m.display_max()} htmlFor="display-max">
|
||||
<Input
|
||||
{/* 1..=16 is the host's own clamp on write. InputNumber holds the field to it
|
||||
and — unlike the arithmetic this replaces — lets it be EMPTY on the way to
|
||||
a new number instead of snapping to 1 the moment you clear it. */}
|
||||
<InputNumber
|
||||
id="display-max"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
className="w-24"
|
||||
value={draft.max_displays ?? 4}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
max_displays: Math.min(
|
||||
16,
|
||||
Math.max(1, Number(e.target.value) || 1),
|
||||
),
|
||||
})
|
||||
}
|
||||
onChange={(max_displays) => setDraft({ ...draft, max_displays })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1199,28 +1233,27 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
|
||||
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
|
||||
X
|
||||
</Label>
|
||||
<Input
|
||||
{/* A screen left of or above the origin has a NEGATIVE coordinate, and the
|
||||
arithmetic this replaces made one almost untypable: `Number("-") || 0`
|
||||
is 0, so the lone minus sign was rewritten to "0" before the digits
|
||||
could be typed. InputNumber holds "-" as an incomplete draft instead.
|
||||
`Math.trunc` stays — positions are whole pixels. */}
|
||||
<InputNumber
|
||||
id={`disp-x-${slot}`}
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={p.x}
|
||||
disabled={saveLayout.isPending}
|
||||
onChange={(e) =>
|
||||
setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))
|
||||
}
|
||||
onChange={(n) => setXY(slot, "x", Math.trunc(n))}
|
||||
/>
|
||||
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
|
||||
Y
|
||||
</Label>
|
||||
<Input
|
||||
<InputNumber
|
||||
id={`disp-y-${slot}`}
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={p.y}
|
||||
disabled={saveLayout.isPending}
|
||||
onChange={(e) =>
|
||||
setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))
|
||||
}
|
||||
onChange={(n) => setXY(slot, "y", Math.trunc(n))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Deliberately NOT `InputNumber`, unlike the numeric fields on
|
||||
the policy card next door. This one writes to the HOST on
|
||||
blur, and InputNumber commits while you type — so its own
|
||||
blur-time clamp would race the apply below, which still
|
||||
closes over the pre-clamp value. The host is the authority
|
||||
here regardless: it clamps to 10..=86400 on write and
|
||||
answers with what it actually stored. */}
|
||||
<Input
|
||||
id="session-grace-seconds"
|
||||
type="number"
|
||||
|
||||
@@ -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 } from "react";
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
import type { GpuState } from "@/api/gen/model";
|
||||
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 { apiErrorMessage } from "@/lib/errors";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
|
||||
@@ -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, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "@/api/gen/update/update";
|
||||
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 {
|
||||
Dialog,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CustomInput } from "@/api/gen/model/customInput";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
@@ -313,12 +314,11 @@ export const GameForm: FC<{
|
||||
"Heroic" or "Lutris" tile without installing that source's plugin. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
<Checkbox
|
||||
id="lib-isLauncher"
|
||||
type="checkbox"
|
||||
checked={form.isLauncher}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, isLauncher: e.target.checked }))
|
||||
onCheckedChange={(next) =>
|
||||
setForm((f) => ({ ...f, isLauncher: next === true }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
|
||||
@@ -362,6 +362,12 @@ export const GameForm: FC<{
|
||||
onChange={set("publisher")}
|
||||
/>
|
||||
</div>
|
||||
{/* These two stay `type="number"` over a STRING field rather than becoming
|
||||
`InputNumber` like the policy card's numbers: both are OPTIONAL metadata
|
||||
where empty means "don't send it" (see `int()` above), and InputNumber's
|
||||
contract is `value: number` — it cannot express "unset", so adopting it
|
||||
would invent a year for every entry that hasn't got one. The type here
|
||||
only asks for a numeric keypad. */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
id="releaseYear"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import { type FC, useEffect, useMemo } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
@@ -8,7 +7,9 @@ import {
|
||||
useGetLibrary,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Stagger } from "@/components/stagger";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
@@ -29,6 +30,7 @@ export const LibraryGridSection: FC<{
|
||||
onEntries?: (entries: GameEntry[]) => void;
|
||||
}> = ({ onEdit, providerFilter, onEntries }) => {
|
||||
const qc = useQueryClient();
|
||||
const { confirm } = useDialogs();
|
||||
const library = useGetLibrary();
|
||||
const all = library.data;
|
||||
useEffect(() => {
|
||||
@@ -53,7 +55,13 @@ export const LibraryGridSection: FC<{
|
||||
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
|
||||
// of them — the card just stayed put as if nothing had been clicked.
|
||||
const onDelete = async (entry: GameEntry) => {
|
||||
if (!confirm(m.library_delete_confirm())) return;
|
||||
const ok = await confirm({
|
||||
title: m.library_delete_confirm(),
|
||||
description: m.library_delete_body(),
|
||||
confirmLabel: m.library_delete(),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await remove.mutateAsync({ id: customId(entry) });
|
||||
} catch (e) {
|
||||
@@ -108,13 +116,9 @@ export const LibraryGrid: FC<{
|
||||
<p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||
{m.library_launchers_title()}
|
||||
</p>
|
||||
<motion.div
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
||||
>
|
||||
<Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
|
||||
{launchers.map(card)}
|
||||
</motion.div>
|
||||
</Stagger>
|
||||
</div>
|
||||
)}
|
||||
{all.length === 0 ? (
|
||||
@@ -136,13 +140,9 @@ export const LibraryGrid: FC<{
|
||||
) : (
|
||||
games.length > 0 && (
|
||||
<div className="@container">
|
||||
<motion.div
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
||||
>
|
||||
<Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
|
||||
{games.map(card)}
|
||||
</motion.div>
|
||||
</Stagger>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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<{
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.library_source_settings_json_hint()}
|
||||
</p>
|
||||
<textarea
|
||||
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
<Textarea
|
||||
className="h-64 font-mono text-xs"
|
||||
value={raw}
|
||||
onChange={(e) => onRaw(e.target.value)}
|
||||
spellCheck={false}
|
||||
@@ -255,18 +266,21 @@ const Field: FC<{
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<select
|
||||
id={id}
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
<Select
|
||||
value={String(value ?? node.default ?? node.enum[0])}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
{node.enum.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id={id} size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{node.enum.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground">{node.description}</p>
|
||||
)}
|
||||
@@ -279,11 +293,10 @@ const Field: FC<{
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
<Checkbox
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
onCheckedChange={(next) => onChange(next === true)}
|
||||
/>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
@@ -300,9 +313,9 @@ const Field: FC<{
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id={id}
|
||||
className="h-24 w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
className="h-24 font-mono text-xs"
|
||||
value={list.join("\n")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { UserPlus, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { PendingDevice } from "@/api/gen/model";
|
||||
@@ -10,7 +9,9 @@ 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";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Checkbox } from "@unom/ui/form/checkbox";
|
||||
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import type { StoreEntry } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useStoreJobs,
|
||||
useUninstallPlugin,
|
||||
} from "@/api/store";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
@@ -31,6 +32,7 @@ type StoreTab = "browse" | "installed" | "sources";
|
||||
*/
|
||||
export const SectionStore: FC = () => {
|
||||
useLocale();
|
||||
const { confirm } = useDialogs();
|
||||
const [tab, setTab] = useState<StoreTab>("browse");
|
||||
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
|
||||
const [target, setTarget] = useState<StoreEntry | null>(null);
|
||||
@@ -122,10 +124,13 @@ export const SectionStore: FC = () => {
|
||||
};
|
||||
|
||||
const onUninstall = async (plugin: InstalledPlugin) => {
|
||||
if (
|
||||
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
|
||||
)
|
||||
return;
|
||||
const ok = await confirm({
|
||||
title: m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }),
|
||||
description: m.store_uninstall_body(),
|
||||
confirmLabel: m.store_uninstall(),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const { job } = await uninstall.mutateAsync(plugin.pkg);
|
||||
setJobId(job);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* The console's confirm/prompt, in place of `window.confirm` / `window.prompt`.
|
||||
*
|
||||
* These replaced sixteen native calls. The native ones could not be storied at all — a browser
|
||||
* dialog is chrome, outside the page and outside any screenshot — which is part of why they went
|
||||
* unnoticed for so long in an otherwise fully-branded console.
|
||||
*
|
||||
* `open` fires the dialog on mount so the screenshot harness catches it without an interaction.
|
||||
*/
|
||||
const Demo = ({
|
||||
open,
|
||||
kind,
|
||||
}: {
|
||||
open: boolean;
|
||||
kind: "destructive" | "plain" | "prompt";
|
||||
}) => {
|
||||
const { confirm, promptText } = useDialogs();
|
||||
const [answer, setAnswer] = useState<string>("—");
|
||||
|
||||
const ask = async () => {
|
||||
if (kind === "prompt") {
|
||||
const name = await promptText({
|
||||
title: m.display_preset_save_title(),
|
||||
label: m.display_preset_name(),
|
||||
defaultValue: "Couch (TV only)",
|
||||
});
|
||||
setAnswer(name ?? "cancelled");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(
|
||||
kind === "destructive"
|
||||
? {
|
||||
title: m.library_delete_confirm(),
|
||||
description: m.library_delete_body(),
|
||||
confirmLabel: m.library_delete(),
|
||||
destructive: true,
|
||||
}
|
||||
: {
|
||||
title: m.display_discard_confirm(),
|
||||
confirmLabel: m.common_discard(),
|
||||
},
|
||||
);
|
||||
setAnswer(String(ok));
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once, on mount, for the shot
|
||||
useEffect(() => {
|
||||
if (open) void ask();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button onClick={ask}>Ask</Button>
|
||||
<p className="text-sm text-muted-foreground">answered: {answer}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// No `DialogsProvider` decorator here on purpose: `.storybook/preview.tsx` mounts it for every
|
||||
// story, exactly as `__root` does for every route. A second one would work but would quietly render
|
||||
// a second dialog host.
|
||||
const meta = {
|
||||
title: "UI/Dialogs",
|
||||
component: Demo,
|
||||
args: { open: true, kind: "destructive" },
|
||||
} satisfies Meta<typeof Demo>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Anything that destroys or removes: red affirmative, and the consequence spelled out under it. */
|
||||
export const Destructive: Story = {};
|
||||
|
||||
/** A plain choice — no red, because nothing is lost that the operator did not already choose. */
|
||||
export const Plain: Story = { args: { kind: "plain" } };
|
||||
|
||||
/** The prompt: a real labelled field, autofocused, Enter to submit. */
|
||||
export const Prompt: Story = { args: { kind: "prompt" } };
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { DisplayForm } from "@/sections/Displays/DisplayCard";
|
||||
import {
|
||||
displayCustomPresets,
|
||||
displayEffective,
|
||||
displayPolicy,
|
||||
displayPresets,
|
||||
} from "./lib/fixtures";
|
||||
|
||||
/**
|
||||
* The **Virtual displays** policy form — the console's largest configuration surface, and until now
|
||||
* the only page with no story at all. That gap is why a real regression shipped unseen: the preset
|
||||
* tiles are cards nested INSIDE the page's config card, so their motion parent is that card rather
|
||||
* than the page's `<Section>`, and a card sets no `delayChildren` — every tile landed on the same
|
||||
* frame while every other grid in the console staggered. It is invisible in a diff and invisible to
|
||||
* `tsc`; only a rendered page shows it.
|
||||
*
|
||||
* So the `<Card>` wrapper below is NOT decoration. It reproduces the page's motion nesting, which is
|
||||
* the thing under test — dropping it would make the story pass for the wrong reason.
|
||||
*/
|
||||
const Harness = ({ seed }: { seed: DisplayPolicy }) => {
|
||||
const [draft, setDraft] = useState<DisplayPolicy>(seed);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.display_config_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<DisplayForm
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
presets={displayPresets}
|
||||
customPresets={displayCustomPresets}
|
||||
serverEffective={displayEffective}
|
||||
serverCaptureMonitor={() => null}
|
||||
apply={setDraft}
|
||||
applyAxis={(patch) => setDraft({ ...draft, ...patch })}
|
||||
saveDraft={() => {}}
|
||||
busy={false}
|
||||
dirty={false}
|
||||
revert={() => {}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Displays",
|
||||
component: Harness,
|
||||
args: { seed: displayPolicy },
|
||||
} satisfies Meta<typeof Harness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A host sitting on a built-in preset — the tiles, and the operator's saved bundles below them. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** "Custom" reveals every axis by hand: the long form under the tiles. */
|
||||
export const CustomFields: Story = {
|
||||
args: { seed: { ...displayPolicy, preset: "custom" } },
|
||||
};
|
||||
|
||||
/** A fresh host has saved no bundles of its own — the custom rail collapses to just "Save as". */
|
||||
export const NoCustomPresets: Story = {
|
||||
args: { seed: displayPolicy },
|
||||
render: (args) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.display_config_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<DisplayForm
|
||||
draft={args.seed}
|
||||
setDraft={() => {}}
|
||||
presets={displayPresets}
|
||||
customPresets={[]}
|
||||
serverEffective={displayEffective}
|
||||
serverCaptureMonitor={() => null}
|
||||
apply={() => {}}
|
||||
applyAxis={() => {}}
|
||||
saveDraft={() => {}}
|
||||
busy={false}
|
||||
dirty={false}
|
||||
revert={() => {}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const meta = {
|
||||
title: "UI/Inputs",
|
||||
@@ -28,3 +37,65 @@ export const Form: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* The non-text controls, side by side with an Input — the comparison that matters, because the
|
||||
* failure mode these wrappers exist to prevent is a control that looks borrowed from another app.
|
||||
* A raw `<select>` / `<textarea>` / `<input type="checkbox">` renders in the browser's own chrome
|
||||
* and ignores the brand tokens entirely; each of the three had survived that way somewhere in the
|
||||
* console until this story existed to show them together.
|
||||
*/
|
||||
export const Controls: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-sm space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ctl-event">Event</Label>
|
||||
<Select defaultValue="session.started">
|
||||
<SelectTrigger id="ctl-event">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="session.started">session.started</SelectItem>
|
||||
<SelectItem value="session.ended">session.ended</SelectItem>
|
||||
<SelectItem value="game.running">game.running</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ctl-cmd">Command</Label>
|
||||
<Input id="ctl-cmd" placeholder="/usr/local/bin/on-stream.sh" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ctl-paths">Extra library folders</Label>
|
||||
<Textarea
|
||||
id="ctl-paths"
|
||||
className="h-24 font-mono text-xs"
|
||||
placeholder={"/mnt/games\n/mnt/roms"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="ctl-launcher" defaultChecked />
|
||||
<Label htmlFor="ctl-launcher">This entry opens a launcher</Label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** The select with its list open — the surface, the tick, and the hover highlight. */
|
||||
export const SelectOpen: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-sm">
|
||||
<Select defaultValue="session.started" open>
|
||||
<SelectTrigger aria-label="Event">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="client.connected">client.connected</SelectItem>
|
||||
<SelectItem value="session.started">session.started</SelectItem>
|
||||
<SelectItem value="session.ended">session.ended</SelectItem>
|
||||
<SelectItem value="game.running">game.running</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
||||
import type { Capture } from "@/api/gen/model/capture";
|
||||
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
|
||||
import type { CustomPreset } from "@/api/gen/model/customPreset";
|
||||
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
|
||||
import type { EffectivePolicy } from "@/api/gen/model/effectivePolicy";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||
import type { NativeClient } from "@/api/gen/model/nativeClient";
|
||||
@@ -297,3 +300,96 @@ export const nativeClients: NativeClient[] = [
|
||||
];
|
||||
|
||||
export const pairingIdle: PairingStatus = { pin_pending: false };
|
||||
|
||||
/** The six axes a preset expands to — the baseline the built-ins vary from. */
|
||||
const policyFields = (
|
||||
over: Partial<EffectivePolicy> = {},
|
||||
): EffectivePolicy => ({
|
||||
identity: "per-client",
|
||||
keep_alive: { mode: "duration", seconds: 300 },
|
||||
layout: { mode: "auto-row", positions: {} },
|
||||
max_displays: 4,
|
||||
mode_conflict: "separate",
|
||||
topology: "auto",
|
||||
...over,
|
||||
});
|
||||
|
||||
/**
|
||||
* The built-in presets as `GET /display/settings` returns them. Summaries are the host's own prose
|
||||
* (it composes them from the fields), so they are literal strings here rather than i18n messages.
|
||||
*/
|
||||
export const displayPresets: {
|
||||
id: string;
|
||||
summary: string;
|
||||
fields: EffectivePolicy;
|
||||
}[] = [
|
||||
{
|
||||
id: "default",
|
||||
summary:
|
||||
"A virtual display per client, released 5 minutes after it disconnects.",
|
||||
fields: policyFields(),
|
||||
},
|
||||
{
|
||||
id: "shared-desktop",
|
||||
summary:
|
||||
"Every client sees the same desktop — no extra displays are created.",
|
||||
fields: policyFields({
|
||||
identity: "shared",
|
||||
topology: "primary",
|
||||
mode_conflict: "join",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "hotdesk",
|
||||
summary:
|
||||
"One display at a time; the physical heads go dark while you stream.",
|
||||
fields: policyFields({
|
||||
topology: "exclusive",
|
||||
max_displays: 1,
|
||||
keep_alive: { mode: "off" },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "workstation",
|
||||
summary: "Adds a display beside the monitors already on the desk.",
|
||||
fields: policyFields({ topology: "extend" }),
|
||||
},
|
||||
{
|
||||
id: "gaming-rig",
|
||||
summary:
|
||||
"Pins the display so it survives every disconnect — free it with Release.",
|
||||
fields: policyFields({
|
||||
topology: "exclusive",
|
||||
keep_alive: { mode: "forever" },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/** Two operator-saved bundles, so the custom-preset rail has something in it. */
|
||||
export const displayCustomPresets: CustomPreset[] = [
|
||||
{
|
||||
id: "cp-couch",
|
||||
name: "Couch (TV only)",
|
||||
fields: policyFields({
|
||||
topology: "exclusive",
|
||||
max_displays: 1,
|
||||
keep_alive: { mode: "forever" },
|
||||
}),
|
||||
game_session: "dedicated",
|
||||
},
|
||||
{
|
||||
id: "cp-office",
|
||||
name: "Office desk",
|
||||
fields: policyFields({ topology: "extend", identity: "shared" }),
|
||||
},
|
||||
];
|
||||
|
||||
/** What the host reports as in force — the `default` preset's expansion. */
|
||||
export const displayEffective: EffectivePolicy = policyFields();
|
||||
|
||||
/** The stored policy: a plain built-in pick, which is what most hosts sit on. */
|
||||
export const displayPolicy: DisplayPolicy = {
|
||||
preset: "default",
|
||||
game_session: "auto",
|
||||
version: 1,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user