diff --git a/web/messages/de.json b/web/messages/de.json
index caa7a743..591f4168 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -136,6 +136,12 @@
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
"display_preset_delete": "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_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
diff --git a/web/messages/en.json b/web/messages/en.json
index 2c4e300c..11450235 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -136,6 +136,12 @@
"display_preset_update": "Update to current settings",
"display_preset_delete": "Delete",
"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_empty": "No paired clients yet.",
"clients_name": "Name",
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
index 4bf9f77f..6ac845e0 100644
--- a/web/src/components/ui/badge.tsx
+++ b/web/src/components/ui/badge.tsx
@@ -12,6 +12,9 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground",
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",
},
},
diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx
index 2fae425a..3f1f83c8 100644
--- a/web/src/sections/Displays/DisplayCard.tsx
+++ b/web/src/sections/Displays/DisplayCard.tsx
@@ -2,10 +2,18 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
-import { type FC, type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react";
import {
- getGetDisplayStateQueryKey,
+ type FC,
+ type MouseEvent,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { ApiError } from "@/api/fetcher";
+import {
getGetDisplaySettingsQueryKey,
+ getGetDisplayStateQueryKey,
useCreateCustomPreset,
useDeleteCustomPreset,
useGetDisplaySettings,
@@ -28,7 +36,6 @@ import type {
Preset,
Topology,
} from "@/api/gen/model";
-import { ApiError } from "@/api/fetcher";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
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 (
- {m.display_config_title()}
+
+ {m.display_config_title()}
+ {/* Visible without scrolling to the save button — the card is taller than the
+ viewport, which is exactly how the pending edits went unnoticed. */}
+ {dirty && {m.display_unsaved()}}
+
-
{m.host_displays_help()}
-
+
+ {m.host_displays_help()}
+
+
{q.data && draft && (
{
customPresets={q.data.custom_presets}
apply={apply}
busy={save.isPending}
+ dirty={dirty}
+ revert={revert}
error={apiErrorMessage(save.error)}
/>
)}
@@ -141,8 +182,22 @@ const DisplayForm: FC<{
customPresets: CustomPreset[];
apply: (p: DisplayPolicy) => void;
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;
-}> = ({ draft, setDraft, presets, customPresets, apply, busy, error }) => {
+}> = ({
+ draft,
+ setDraft,
+ presets,
+ customPresets,
+ apply,
+ busy,
+ dirty,
+ revert,
+ error,
+}) => {
const qc = useQueryClient();
const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset();
@@ -169,11 +224,19 @@ const DisplayForm: FC<{
max_displays: draft.max_displays ?? 4,
};
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
// effective behavior (nothing changes until you Save).
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") {
setDraft({
version: 1,
@@ -201,7 +264,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) =>
+ const applyCustomPreset = (p: CustomPreset) => {
+ if (dirty && !confirm(m.display_discard_confirm())) return;
apply({
version: 1,
preset: "custom",
@@ -216,6 +280,7 @@ const DisplayForm: FC<{
// (found on-glass, .136). Every orthogonal axis has to be listed.
capture_monitor: draft.capture_monitor ?? null,
});
+ };
// 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).
@@ -231,7 +296,11 @@ const DisplayForm: FC<{
if (!name) return; // cancelled or empty
createPreset.mutate(
{
- data: { name, fields: effective, game_session: draft.game_session ?? "auto" },
+ data: {
+ name,
+ fields: effective,
+ game_session: draft.game_session ?? "auto",
+ },
},
{ onSuccess: invalidateSettings },
);
@@ -240,7 +309,14 @@ const DisplayForm: FC<{
const name = prompt(m.display_preset_name(), p.name)?.trim();
if (!name) return;
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 },
);
};
@@ -248,7 +324,11 @@ const DisplayForm: FC<{
updatePreset.mutate(
{
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 },
);
@@ -259,21 +339,27 @@ const DisplayForm: FC<{
const ka = customFields.keep_alive;
// 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 (
{/* One-click presets — a 2-up grid so each has room to breathe */}
-
+
{PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id);
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
// 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 disabled = busy || soon;
const pick = () => {
@@ -310,18 +396,30 @@ const DisplayForm: FC<{
)}
{selected && (
- {m.display_preset_current()}
+
+ {m.display_preset_current()}
+
)}
- {/* 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 && (
-
{/* 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". */}
-
+
{kept.length > 0 && (
{
)}
{displays.length === 0 ? (
-
{m.display_none_live()}
+
+ {m.display_none_live()}
+
) : (
{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.
* 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 saveLayout = useSetDisplayLayout();
// 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);
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
- const [pos, setPos] = useState | null>(null);
+ const [pos, setPos] = useState | null>(null);
useEffect(() => {
if (pos === null && arrangeable.length > 0) {
const seed: Record = {};
- 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);
}
}, [arrangeable, pos]);
@@ -831,15 +1028,21 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
return (