fix(web): unsaved custom display settings are visible and recoverable

Everything else on the Displays page auto-applies — a preset click, the
game-session choice, the experimental toggles — but the Custom block does
not, and its Save button sat at the bottom of a block taller than most
viewports. People edited, never scrolled far enough to find it, navigated
away, and silently lost the lot.

The draft is now compared against the last-seeded server value, and that
one boolean drives the whole affordance: a badge in the card header
(visible without scrolling), an amber ring plus a title on the block, and
a sticky action bar pinned to the viewport for as long as any part of the
block is on screen. The bar states which of the two states you are in
rather than leaving it implied, Save disables when there is nothing to
save, and Discard changes puts the stored policy back.

Two ways edits could still vanish are closed: a preset click now confirms
before overwriting pending edits, and a reload/close warns. Re-picking
Custom while already on Custom is a no-op instead of re-seeding, which
would otherwise fill in defaults for unset fields and report "unsaved
changes" for a click that changed nothing.

Adds a `warning` badge variant + --warning token for the pending state —
attention, not failure, and distinct from the destructive red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c4318609c0da5ae1313081c5e488f685504f70d1)
This commit is contained in:
2026-07-28 17:01:59 +02:00
parent 5ea087ca47
commit 3b11288c97
6 changed files with 310 additions and 68 deletions
+6
View File
@@ -136,6 +136,12 @@
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren", "display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
"display_preset_delete": "Löschen", "display_preset_delete": "Löschen",
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?", "display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
"display_custom_title": "Eigene Einstellungen",
"display_unsaved": "Nicht gespeicherte Änderungen",
"display_unsaved_hint": "Diese Optionen greifen erst, wenn du speicherst.",
"display_all_saved": "Alle Änderungen gespeichert",
"display_revert": "Änderungen verwerfen",
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
"clients_title": "Gekoppelte Geräte", "clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.", "clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name", "clients_name": "Name",
+6
View File
@@ -136,6 +136,12 @@
"display_preset_update": "Update to current settings", "display_preset_update": "Update to current settings",
"display_preset_delete": "Delete", "display_preset_delete": "Delete",
"display_preset_delete_confirm": "Delete this custom preset?", "display_preset_delete_confirm": "Delete this custom preset?",
"display_custom_title": "Custom settings",
"display_unsaved": "Unsaved changes",
"display_unsaved_hint": "These options only take effect once you save.",
"display_all_saved": "All changes saved",
"display_revert": "Discard changes",
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
"clients_title": "Paired clients", "clients_title": "Paired clients",
"clients_empty": "No paired clients yet.", "clients_empty": "No paired clients yet.",
"clients_name": "Name", "clients_name": "Name",
+3
View File
@@ -12,6 +12,9 @@ const badgeVariants = cva(
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-destructive text-destructive-foreground",
success: "border-transparent bg-[var(--success)] text-white", success: "border-transparent bg-[var(--success)] text-white",
// Attention, not failure — pending/unsaved state. Dark text: amber is a light
// colour in both themes, and white on it fails contrast.
warning: "border-transparent bg-[var(--warning)] text-black",
outline: "text-foreground", outline: "text-foreground",
}, },
}, },
+289 -68
View File
@@ -2,10 +2,18 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button"; import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { type FC, type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { import {
getGetDisplayStateQueryKey, type FC,
type MouseEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDisplaySettingsQueryKey, getGetDisplaySettingsQueryKey,
getGetDisplayStateQueryKey,
useCreateCustomPreset, useCreateCustomPreset,
useDeleteCustomPreset, useDeleteCustomPreset,
useGetDisplaySettings, useGetDisplaySettings,
@@ -28,7 +36,6 @@ import type {
Preset, Preset,
Topology, Topology,
} from "@/api/gen/model"; } from "@/api/gen/model";
import { ApiError } from "@/api/fetcher";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -89,15 +96,47 @@ export const DisplaySection: FC = () => {
}, },
); );
// Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental
// toggle), so the draft can silently diverge from what the host is actually running. Reading the
// ref during render is safe here because every write to it is paired with a `setDraft`, so a
// changed seed always comes with the re-render that reads it.
const dirty =
draft !== null &&
seeded.current !== null &&
!deepEqual(draft, seeded.current);
const revert = () => {
if (seeded.current) setDraft(seeded.current);
};
// Last line of defence: a reload/close with pending edits loses them silently otherwise. The
// browser shows its own generic wording — the text is ignored, only returning a value counts.
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
return ( return (
<div className="flex flex-col gap-card"> <div className="flex flex-col gap-card">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle> <div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>{m.display_config_title()}</CardTitle>
{/* Visible without scrolling to the save button — the card is taller than the
viewport, which is exactly how the pending edits went unnoticed. */}
{dirty && <Badge variant="warning">{m.display_unsaved()}</Badge>}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">{m.host_displays_help()}</p> <p className="max-w-prose text-sm text-muted-foreground">
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}> {m.host_displays_help()}
</p>
<QueryState
isLoading={q.isLoading}
error={q.error}
refetch={q.refetch}
>
{q.data && draft && ( {q.data && draft && (
<DisplayForm <DisplayForm
draft={draft} draft={draft}
@@ -106,6 +145,8 @@ export const DisplaySection: FC = () => {
customPresets={q.data.custom_presets} customPresets={q.data.custom_presets}
apply={apply} apply={apply}
busy={save.isPending} busy={save.isPending}
dirty={dirty}
revert={revert}
error={apiErrorMessage(save.error)} error={apiErrorMessage(save.error)}
/> />
)} )}
@@ -141,8 +182,22 @@ const DisplayForm: FC<{
customPresets: CustomPreset[]; customPresets: CustomPreset[];
apply: (p: DisplayPolicy) => void; apply: (p: DisplayPolicy) => void;
busy: boolean; busy: boolean;
/** The draft differs from what the host has stored — drives the save bar + the discard guard. */
dirty: boolean;
/** Throw the draft away and go back to the stored policy. */
revert: () => void;
error?: string; error?: string;
}> = ({ draft, setDraft, presets, customPresets, apply, busy, error }) => { }> = ({
draft,
setDraft,
presets,
customPresets,
apply,
busy,
dirty,
revert,
error,
}) => {
const qc = useQueryClient(); const qc = useQueryClient();
const createPreset = useCreateCustomPreset(); const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset(); const updatePreset = useUpdateCustomPreset();
@@ -169,11 +224,19 @@ const DisplayForm: FC<{
max_displays: draft.max_displays ?? 4, max_displays: draft.max_displays ?? 4,
}; };
const effective: EffectivePolicy = const effective: EffectivePolicy =
(isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ?? customFields; (isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ??
customFields;
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current // The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
// effective behavior (nothing changes until you Save). // effective behavior (nothing changes until you Save).
const pickPreset = (id: string) => { const pickPreset = (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;
// 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;
if (id === "custom") { if (id === "custom") {
setDraft({ setDraft({
version: 1, version: 1,
@@ -201,7 +264,8 @@ const DisplayForm: FC<{
// Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the // 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-…). // one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…).
const applyCustomPreset = (p: CustomPreset) => const applyCustomPreset = (p: CustomPreset) => {
if (dirty && !confirm(m.display_discard_confirm())) return;
apply({ apply({
version: 1, version: 1,
preset: "custom", preset: "custom",
@@ -216,6 +280,7 @@ const DisplayForm: FC<{
// (found on-glass, .136). Every orthogonal axis has to be listed. // (found on-glass, .136). Every orthogonal axis has to be listed.
capture_monitor: draft.capture_monitor ?? null, capture_monitor: draft.capture_monitor ?? null,
}); });
};
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session // A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
// value-match this preset (there is no id on DisplayPolicy — match by value). // value-match this preset (there is no id on DisplayPolicy — match by value).
@@ -231,7 +296,11 @@ const DisplayForm: FC<{
if (!name) return; // cancelled or empty if (!name) return; // cancelled or empty
createPreset.mutate( createPreset.mutate(
{ {
data: { name, fields: effective, game_session: draft.game_session ?? "auto" }, data: {
name,
fields: effective,
game_session: draft.game_session ?? "auto",
},
}, },
{ onSuccess: invalidateSettings }, { onSuccess: invalidateSettings },
); );
@@ -240,7 +309,14 @@ const DisplayForm: FC<{
const name = prompt(m.display_preset_name(), p.name)?.trim(); const name = prompt(m.display_preset_name(), p.name)?.trim();
if (!name) return; if (!name) return;
updatePreset.mutate( updatePreset.mutate(
{ id: p.id, data: { name, fields: p.fields, game_session: p.game_session ?? "auto" } }, {
id: p.id,
data: {
name,
fields: p.fields,
game_session: p.game_session ?? "auto",
},
},
{ onSuccess: invalidateSettings }, { onSuccess: invalidateSettings },
); );
}; };
@@ -248,7 +324,11 @@ const DisplayForm: FC<{
updatePreset.mutate( updatePreset.mutate(
{ {
id: p.id, id: p.id,
data: { name: p.name, fields: effective, game_session: draft.game_session ?? "auto" }, data: {
name: p.name,
fields: effective,
game_session: draft.game_session ?? "auto",
},
}, },
{ onSuccess: invalidateSettings }, { onSuccess: invalidateSettings },
); );
@@ -259,21 +339,27 @@ const DisplayForm: FC<{
const ka = customFields.keep_alive; const ka = customFields.keep_alive;
// The duration value, remembered across the Off/Keep toggle so switching back restores it. // The duration value, remembered across the Off/Keep toggle so switching back restores it.
const [keepSecs, setKeepSecs] = useState(ka.mode === "duration" ? ka.seconds : 300); const [keepSecs, setKeepSecs] = useState(
ka.mode === "duration" ? ka.seconds : 300,
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* One-click presets — a 2-up grid so each has room to breathe */} {/* One-click presets — a 2-up grid so each has room to breathe */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="mb-1 block text-base font-semibold">{m.display_preset()}</Label> <Label className="mb-1 block text-base font-semibold">
{m.display_preset()}
</Label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => { {PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id); const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields; const fields = id === "custom" ? undefined : p?.fields;
const summary = id === "custom" ? m.display_custom_desc() : p?.summary; const summary =
id === "custom" ? m.display_custom_desc() : p?.summary;
// The built-in "Custom" card is the hand-edit mode; when the active Custom policy // The built-in "Custom" card is the hand-edit mode; when the active Custom policy
// value-matches a saved preset, that preset's card owns the "current" ring instead. // value-matches a saved preset, that preset's card owns the "current" ring instead.
const selected = preset === id && !(id === "custom" && anyCustomSelected); const selected =
preset === id && !(id === "custom" && anyCustomSelected);
const soon = DISABLED_PRESETS.has(id); const soon = DISABLED_PRESETS.has(id);
const disabled = busy || soon; const disabled = busy || soon;
const pick = () => { const pick = () => {
@@ -310,18 +396,30 @@ const DisplayForm: FC<{
)} )}
</span> </span>
{selected && ( {selected && (
<Badge variant="success">{m.display_preset_current()}</Badge> <Badge variant="success">
{m.display_preset_current()}
</Badge>
)} )}
</div> </div>
{summary && ( {summary && (
<p className="mt-1 text-sm text-muted-foreground">{summary}</p> <p className="mt-1 text-sm text-muted-foreground">
{summary}
</p>
)} )}
{fields && ( {fields && (
<div className="mt-auto flex flex-wrap gap-1.5 pt-3"> <div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge> <Badge variant="secondary">
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge> {fmtKeepAlive(fields.keep_alive)}
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge> </Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge> <Badge variant="secondary">
{tr(TOPOLOGY_LABEL, fields.topology)}
</Badge>
<Badge variant="outline">
{tr(CONFLICT_LABEL, fields.mode_conflict)}
</Badge>
<Badge variant="outline">
{tr(IDENTITY_LABEL, fields.identity)}
</Badge>
</div> </div>
)} )}
</Card> </Card>
@@ -364,20 +462,40 @@ const DisplayForm: FC<{
</div> </div>
)} )}
{presetError && ( {presetError && (
<p className="text-sm text-amber-600 dark:text-amber-500">{presetError}</p> <p className="text-sm text-amber-600 dark:text-amber-500">
{presetError}
</p>
)} )}
</div> </div>
{/* Custom: every option by hand */} {/* Custom: every option by hand. Unlike everything else on this page these fields do NOT
auto-apply, so the block is titled, ringed while dirty, and ends in a sticky save bar. */}
{isCustom && ( {isCustom && (
<div className="space-y-6 rounded-lg border p-5"> <div
<Field label={m.display_keep_alive()} help={m.display_keep_alive_help()}> className={cn(
"space-y-6 rounded-lg border p-5",
dirty && "border-[var(--warning)]",
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-base font-semibold">
{m.display_custom_title()}
</h3>
{dirty && <Badge variant="warning">{m.display_unsaved()}</Badge>}
</div>
<Field
label={m.display_keep_alive()}
help={m.display_keep_alive_help()}
>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
size="sm" size="sm"
variant={ka.mode === "off" ? "default" : "outline"} variant={ka.mode === "off" ? "default" : "outline"}
disabled={busy} disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })} onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "off" } })
}
> >
{m.display_keep_alive_off()} {m.display_keep_alive_off()}
</Button> </Button>
@@ -386,7 +504,10 @@ const DisplayForm: FC<{
variant={ka.mode === "duration" ? "default" : "outline"} variant={ka.mode === "duration" ? "default" : "outline"}
disabled={busy} disabled={busy}
onClick={() => onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: keepSecs } }) setDraft({
...draft,
keep_alive: { mode: "duration", seconds: keepSecs },
})
} }
> >
{m.display_keep_alive_keep()} {m.display_keep_alive_keep()}
@@ -395,7 +516,9 @@ const DisplayForm: FC<{
size="sm" size="sm"
variant={ka.mode === "forever" ? "default" : "outline"} variant={ka.mode === "forever" ? "default" : "outline"}
disabled={busy} disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "forever" } })} onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "forever" } })
}
> >
{m.display_keep_alive_forever()} {m.display_keep_alive_forever()}
</Button> </Button>
@@ -410,7 +533,10 @@ const DisplayForm: FC<{
onChange={(e) => { onChange={(e) => {
const n = Math.max(0, Number(e.target.value) || 0); const n = Math.max(0, Number(e.target.value) || 0);
setKeepSecs(n); setKeepSecs(n);
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: n } }); setDraft({
...draft,
keep_alive: { mode: "duration", seconds: n },
});
}} }}
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@@ -437,7 +563,9 @@ const DisplayForm: FC<{
options={["separate", "steal", "join", "reject"]} options={["separate", "steal", "join", "reject"]}
labels={CONFLICT_LABEL} labels={CONFLICT_LABEL}
disabled={busy} disabled={busy}
onPick={(v) => setDraft({ ...draft, mode_conflict: v as ModeConflict })} onPick={(v) =>
setDraft({ ...draft, mode_conflict: v as ModeConflict })
}
/> />
<Choice <Choice
label={m.display_identity()} label={m.display_identity()}
@@ -458,7 +586,10 @@ const DisplayForm: FC<{
onPick={(v) => onPick={(v) =>
setDraft({ setDraft({
...draft, ...draft,
layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} }, layout: {
mode: v as LayoutMode,
positions: draft.layout?.positions ?? {},
},
}) })
} }
/> />
@@ -474,14 +605,39 @@ const DisplayForm: FC<{
onChange={(e) => onChange={(e) =>
setDraft({ setDraft({
...draft, ...draft,
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)), max_displays: Math.min(
16,
Math.max(1, Number(e.target.value) || 1),
),
}) })
} }
/> />
</Field> </Field>
<div className="border-t pt-4"> {/* Sticky: the Custom block is taller than most viewports, and a save button parked
<Button onClick={() => apply(draft)} disabled={busy}> at its bottom edge is invisible until you scroll all the way down — people
edited, navigated away, and lost the lot. `bottom-0` pins it to the viewport
while any part of the block is on screen, and it settles into place at the end. */}
<div
className={cn(
"sticky bottom-0 z-10 -mx-5 -mb-5 flex flex-wrap items-center justify-end gap-3 rounded-b-lg border-t px-5 py-3 backdrop-blur",
dirty ? "bg-[var(--warning)]/10" : "bg-neutral/80",
)}
>
<span
className={cn(
"mr-auto text-sm",
dirty ? "font-medium" : "text-muted-foreground",
)}
>
{dirty ? m.display_unsaved_hint() : m.display_all_saved()}
</span>
{dirty && (
<Button variant="ghost" onClick={revert} disabled={busy}>
{m.display_revert()}
</Button>
)}
<Button onClick={() => apply(draft)} disabled={busy || !dirty}>
{m.display_save()} {m.display_save()}
</Button> </Button>
</div> </div>
@@ -537,15 +693,27 @@ const DisplayForm: FC<{
{/* What's in force right now */} {/* What's in force right now */}
<div className="flex flex-wrap items-center gap-2 border-t pt-3"> <div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span> <span className="text-sm text-muted-foreground">
{m.display_effective()}:
</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge> <Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, effective.topology)}</Badge> <Badge variant="secondary">
<Badge variant="outline">{tr(CONFLICT_LABEL, effective.mode_conflict)}</Badge> {tr(TOPOLOGY_LABEL, effective.topology)}
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge> </Badge>
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge> <Badge variant="outline">
{tr(CONFLICT_LABEL, effective.mode_conflict)}
</Badge>
<Badge variant="outline">
{tr(IDENTITY_LABEL, effective.identity)}
</Badge>
<Badge variant="outline">
{tr(LAYOUT_LABEL, effective.layout.mode)}
</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge> <Badge variant="outline">{`${effective.max_displays}×`}</Badge>
{(draft.game_session ?? "auto") === "dedicated" && ( {(draft.game_session ?? "auto") === "dedicated" && (
<Badge variant="secondary">{m.display_game_session_dedicated()}</Badge> <Badge variant="secondary">
{m.display_game_session_dedicated()}
</Badge>
)} )}
{(draft.ddc_power_off ?? false) && ( {(draft.ddc_power_off ?? false) && (
<Badge variant="outline">{m.display_ddc_badge()}</Badge> <Badge variant="outline">{m.display_ddc_badge()}</Badge>
@@ -555,8 +723,12 @@ const DisplayForm: FC<{
)} )}
</div> </div>
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p> <p className="max-w-prose text-xs text-muted-foreground">
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>} {m.display_pending_note()}
</p>
{error && (
<p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>
)}
</div> </div>
); );
}; };
@@ -571,7 +743,9 @@ const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
<div className="space-y-3"> <div className="space-y-3">
<Label className="block">{label}</Label> <Label className="block">{label}</Label>
{children} {children}
{help && <p className="max-w-prose text-xs text-muted-foreground">{help}</p>} {help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div> </div>
); );
@@ -682,9 +856,13 @@ const CustomPresetCard: FC<{
)} )}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span className="min-w-0 truncate text-base font-semibold">{preset.name}</span> <span className="min-w-0 truncate text-base font-semibold">
{preset.name}
</span>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{selected && <Badge variant="success">{m.display_preset_current()}</Badge>} {selected && (
<Badge variant="success">{m.display_preset_current()}</Badge>
)}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -720,10 +898,14 @@ const CustomPresetCard: FC<{
<div className="mt-auto flex flex-wrap gap-1.5 pt-3"> <div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge> <Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge> <Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge> <Badge variant="outline">
{tr(CONFLICT_LABEL, fields.mode_conflict)}
</Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge> <Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
{(preset.game_session ?? "auto") !== "auto" && ( {(preset.game_session ?? "auto") !== "auto" && (
<Badge variant="secondary">{tr(GAME_SESSION_LABEL, preset.game_session)}</Badge> <Badge variant="secondary">
{tr(GAME_SESSION_LABEL, preset.game_session)}
</Badge>
)} )}
</div> </div>
</Card> </Card>
@@ -744,14 +926,21 @@ const LiveDisplays: FC = () => {
const doRelease = (slot?: number) => const doRelease = (slot?: number) =>
release.mutate( release.mutate(
{ data: { slot: slot ?? null } }, { data: { slot: slot ?? null } },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) }, {
onSuccess: () =>
qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }),
},
); );
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Wrap in QueryState (like the settings card) so a failed/in-flight `/display/state` {/* Wrap in QueryState (like the settings card) so a failed/in-flight `/display/state`
fetch surfaces as loading/error instead of masquerading as "no live displays". */} fetch surfaces as loading/error instead of masquerading as "no live displays". */}
<QueryState isLoading={state.isLoading} error={state.error} refetch={state.refetch}> <QueryState
isLoading={state.isLoading}
error={state.error}
refetch={state.refetch}
>
{kept.length > 0 && ( {kept.length > 0 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@@ -765,7 +954,9 @@ const LiveDisplays: FC = () => {
</div> </div>
)} )}
{displays.length === 0 ? ( {displays.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p> <p className="text-sm text-muted-foreground">
{m.display_none_live()}
</p>
) : ( ) : (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{displays.map((d) => ( {displays.map((d) => (
@@ -790,18 +981,24 @@ const LiveDisplays: FC = () => {
* `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect. * `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect.
* Shown only for a ≥2-display group — arranging a single display is moot. * Shown only for a ≥2-display group — arranging a single display is moot.
*/ */
const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => { const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
displays,
}) => {
const qc = useQueryClient(); const qc = useQueryClient();
const saveLayout = useSetDisplayLayout(); const saveLayout = useSetDisplayLayout();
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key). // Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
const arrangeable = displays.filter((d) => d.identity_slot != null); const arrangeable = displays.filter((d) => d.identity_slot != null);
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions. // Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
const [pos, setPos] = useState<Record<string, { x: number; y: number }> | null>(null); const [pos, setPos] = useState<Record<
string,
{ x: number; y: number }
> | null>(null);
useEffect(() => { useEffect(() => {
if (pos === null && arrangeable.length > 0) { if (pos === null && arrangeable.length > 0) {
const seed: Record<string, { x: number; y: number }> = {}; const seed: Record<string, { x: number; y: number }> = {};
for (const d of arrangeable) seed[String(d.identity_slot)] = { x: d.x, y: d.y }; for (const d of arrangeable)
seed[String(d.identity_slot)] = { x: d.x, y: d.y };
setPos(seed); setPos(seed);
} }
}, [arrangeable, pos]); }, [arrangeable, pos]);
@@ -831,15 +1028,21 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
return ( return (
<div className="space-y-2 border-t pt-4"> <div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium">{m.display_arrange()}</h4> <h4 className="text-sm font-medium">{m.display_arrange()}</h4>
<p className="text-xs text-muted-foreground">{m.display_arrange_help()}</p> <p className="text-xs text-muted-foreground">
{m.display_arrange_help()}
</p>
<div className="space-y-2"> <div className="space-y-2">
{arrangeable.map((d) => { {arrangeable.map((d) => {
const slot = d.identity_slot as number; const slot = d.identity_slot as number;
const p = cur[String(slot)] ?? { x: d.x, y: d.y }; const p = cur[String(slot)] ?? { x: d.x, y: d.y };
return ( return (
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm"> <div
key={d.slot}
className="flex flex-wrap items-center gap-2 text-sm"
>
<span className="w-44 truncate"> <span className="w-44 truncate">
{d.mode} <code className="text-xs text-muted-foreground">#{slot}</code> {d.mode}{" "}
<code className="text-xs text-muted-foreground">#{slot}</code>
</span> </span>
<Label className="text-xs" htmlFor={`disp-x-${slot}`}> <Label className="text-xs" htmlFor={`disp-x-${slot}`}>
X X
@@ -850,7 +1053,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
className="w-24" className="w-24"
value={p.x} value={p.x}
disabled={saveLayout.isPending} disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))} onChange={(e) =>
setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))
}
/> />
<Label className="text-xs" htmlFor={`disp-y-${slot}`}> <Label className="text-xs" htmlFor={`disp-y-${slot}`}>
Y Y
@@ -861,7 +1066,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
className="w-24" className="w-24"
value={p.y} value={p.y}
disabled={saveLayout.isPending} disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))} onChange={(e) =>
setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))
}
/> />
</div> </div>
); );
@@ -879,11 +1086,11 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
); );
}; };
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({ const DisplayRow: FC<{
d, d: ApiDisplayInfo;
busy, busy: boolean;
onRelease, onRelease: () => void;
}) => { }> = ({ d, busy, onRelease }) => {
const active = d.state === "active"; const active = d.state === "active";
const stateLabel = const stateLabel =
d.state === "active" d.state === "active"
@@ -898,7 +1105,9 @@ const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }
<span className="font-medium">{d.mode}</span> <span className="font-medium">{d.mode}</span>
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge> <Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
{active && d.sessions > 0 && ( {active && d.sessions > 0 && (
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge> <Badge variant="outline">
{m.display_sessions({ count: d.sessions })}
</Badge>
)} )}
</div> </div>
<code className="text-xs text-muted-foreground"> <code className="text-xs text-muted-foreground">
@@ -974,17 +1183,29 @@ const GAME_SESSION_LABEL: Record<string, () => string> = {
* (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */ * (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */
const deepEqual = (a: unknown, b: unknown): boolean => { const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) return true; if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; if (
typeof a !== "object" ||
typeof b !== "object" ||
a === null ||
b === null
)
return false;
const ak = Object.keys(a as object); const ak = Object.keys(a as object);
const bk = Object.keys(b as object); const bk = Object.keys(b as object);
if (ak.length !== bk.length) return false; if (ak.length !== bk.length) return false;
return ak.every((k) => return ak.every((k) =>
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]), deepEqual(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k],
),
); );
}; };
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */ /** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => { const tr = (
map: Record<string, () => string>,
key: string | null | undefined,
): string => {
const fn = key == null ? undefined : map[key]; const fn = key == null ? undefined : map[key];
return fn ? fn() : String(key ?? ""); return fn ? fn() : String(key ?? "");
}; };
+1
View File
@@ -5,6 +5,7 @@ const VARIANTS = [
"default", "default",
"secondary", "secondary",
"success", "success",
"warning",
"destructive", "destructive",
"outline", "outline",
] as const; ] as const;
+5
View File
@@ -56,6 +56,9 @@
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--success: oklch(0.6 0.14 160); --success: oklch(0.6 0.14 160);
/* Amber — "needs your attention but nothing is broken" (unsaved edits), the badge
counterpart of the inline amber warning text. */
--warning: oklch(0.72 0.15 75);
--destructive: oklch(0.55 0.22 18); --destructive: oklch(0.55 0.22 18);
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
@@ -101,6 +104,7 @@
--primary-foreground: #141019; --primary-foreground: #141019;
--success: oklch(0.7 0.15 160); --success: oklch(0.7 0.15 160);
--warning: oklch(0.78 0.16 75);
--destructive: oklch(0.62 0.21 18); --destructive: oklch(0.62 0.21 18);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
} }
@@ -142,6 +146,7 @@
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success); --color-success: var(--success);
--color-warning: var(--warning);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);