a4f81dec48
Save named bundles of the display-management policy (the six behavior axes
plus the game-session axis) as custom presets, alongside the built-ins. A
custom preset is data — stored in <config>/display-presets.json — not a Preset
enum variant, so DisplayPolicy::effective() stays pure and the built-in set is
untouched; applying one writes a Custom policy via the existing PUT
/display/settings.
- policy.rs: CustomPreset/CustomPresetInput + load/add/update/delete store
- mgmt.rs: GET/POST /display/presets + PUT/DELETE /display/presets/{id},
surfaced on GET /display/settings
- web console: custom-preset cards with save-as / edit / delete + i18n
- regenerated api/openapi.json; docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
870 lines
28 KiB
TypeScript
870 lines
28 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
||
import { Button } from "@unom/ui/button";
|
||
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||
import { type FC, type MouseEvent, type ReactNode, useEffect, useState } from "react";
|
||
import {
|
||
getGetDisplayStateQueryKey,
|
||
getGetDisplaySettingsQueryKey,
|
||
useCreateCustomPreset,
|
||
useDeleteCustomPreset,
|
||
useGetDisplaySettings,
|
||
useGetDisplayState,
|
||
useReleaseDisplay,
|
||
useSetDisplayLayout,
|
||
useSetDisplaySettings,
|
||
useUpdateCustomPreset,
|
||
} from "@/api/gen/display/display";
|
||
import type {
|
||
ApiDisplayInfo,
|
||
CustomPreset,
|
||
DisplayPolicy,
|
||
EffectivePolicy,
|
||
GameSession,
|
||
Identity,
|
||
KeepAlive,
|
||
LayoutMode,
|
||
ModeConflict,
|
||
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";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { cn } from "@/lib/utils";
|
||
import { m } from "@/paraglide/messages";
|
||
|
||
/**
|
||
* Container: the host's virtual-display management policy (design/display-management.md). Lets the
|
||
* operator pick a one-click preset OR set every option by hand — all WITHOUT any client connected
|
||
* (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement
|
||
* table below act on whatever is currently streaming.
|
||
*/
|
||
export const DisplaySection: FC = () => {
|
||
const qc = useQueryClient();
|
||
const q = useGetDisplaySettings();
|
||
const save = useSetDisplaySettings();
|
||
|
||
// Local edit buffer, seeded once from the server and re-seeded after every successful apply.
|
||
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
|
||
useEffect(() => {
|
||
if (q.data && draft === null) setDraft(q.data.settings);
|
||
}, [q.data, draft]);
|
||
|
||
// Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on
|
||
// the next connect; a live session keeps the display it opened on.
|
||
const apply = (policy: DisplayPolicy) =>
|
||
save.mutate(
|
||
{ data: policy },
|
||
{
|
||
onSuccess: (res) => {
|
||
setDraft(res.settings);
|
||
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
||
},
|
||
},
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-card">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{m.display_config_title()}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<p className="max-w-prose text-sm text-muted-foreground">{m.host_displays_help()}</p>
|
||
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
|
||
{q.data && draft && (
|
||
<DisplayForm
|
||
draft={draft}
|
||
setDraft={setDraft}
|
||
presets={q.data.presets}
|
||
customPresets={q.data.custom_presets}
|
||
apply={apply}
|
||
busy={save.isPending}
|
||
error={apiErrorMessage(save.error)}
|
||
/>
|
||
)}
|
||
</QueryState>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{m.display_live()}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<LiveDisplays />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */
|
||
const PRESET_ORDER = [
|
||
"default",
|
||
"shared-desktop",
|
||
"hotdesk",
|
||
"workstation",
|
||
"gaming-rig",
|
||
"custom",
|
||
] as const;
|
||
|
||
const DisplayForm: FC<{
|
||
draft: DisplayPolicy;
|
||
setDraft: (p: DisplayPolicy) => void;
|
||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||
customPresets: CustomPreset[];
|
||
apply: (p: DisplayPolicy) => void;
|
||
busy: boolean;
|
||
error?: string;
|
||
}> = ({ draft, setDraft, presets, customPresets, apply, busy, error }) => {
|
||
const qc = useQueryClient();
|
||
const createPreset = useCreateCustomPreset();
|
||
const updatePreset = useUpdateCustomPreset();
|
||
const deletePreset = useDeleteCustomPreset();
|
||
const invalidateSettings = () =>
|
||
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
||
const presetBusy =
|
||
createPreset.isPending || updatePreset.isPending || deletePreset.isPending;
|
||
const presetError = apiErrorMessage(
|
||
createPreset.error ?? updatePreset.error ?? deletePreset.error,
|
||
);
|
||
|
||
const preset: Preset = draft.preset ?? "custom";
|
||
const isCustom = preset === "custom";
|
||
|
||
// The Custom fields (defaults filled): the edit buffer when preset === "custom", and what a
|
||
// preset→Custom switch is seeded from, so you customize starting from the current behavior.
|
||
const customFields: EffectivePolicy = {
|
||
keep_alive: draft.keep_alive ?? { mode: "duration", seconds: 10 },
|
||
topology: draft.topology ?? "auto",
|
||
mode_conflict: draft.mode_conflict ?? "separate",
|
||
identity: draft.identity ?? "per-client",
|
||
layout: draft.layout ?? { mode: "auto-row", positions: {} },
|
||
max_displays: draft.max_displays ?? 4,
|
||
};
|
||
const effective: EffectivePolicy =
|
||
(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) => {
|
||
if (id === "custom") {
|
||
setDraft({
|
||
version: 1,
|
||
preset: "custom",
|
||
keep_alive: effective.keep_alive,
|
||
topology: effective.topology,
|
||
mode_conflict: effective.mode_conflict,
|
||
identity: effective.identity,
|
||
layout: effective.layout,
|
||
max_displays: effective.max_displays,
|
||
// Game-session is orthogonal to the preset — carry it through the Custom switch.
|
||
game_session: draft.game_session ?? "auto",
|
||
});
|
||
} else {
|
||
apply({ ...draft, preset: id as Preset });
|
||
}
|
||
};
|
||
|
||
// 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) =>
|
||
apply({
|
||
version: 1,
|
||
preset: "custom",
|
||
...p.fields,
|
||
game_session: p.game_session ?? "auto",
|
||
});
|
||
|
||
// 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).
|
||
const customSelected = (p: CustomPreset): boolean =>
|
||
isCustom &&
|
||
(draft.game_session ?? "auto") === (p.game_session ?? "auto") &&
|
||
deepEqual(effective, p.fields);
|
||
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();
|
||
if (!name) return; // cancelled or empty
|
||
createPreset.mutate(
|
||
{
|
||
data: { name, fields: effective, game_session: draft.game_session ?? "auto" },
|
||
},
|
||
{ onSuccess: invalidateSettings },
|
||
);
|
||
};
|
||
const renamePreset = (p: CustomPreset) => {
|
||
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" } },
|
||
{ onSuccess: invalidateSettings },
|
||
);
|
||
};
|
||
const updatePresetToCurrent = (p: CustomPreset) =>
|
||
updatePreset.mutate(
|
||
{
|
||
id: p.id,
|
||
data: { name: p.name, fields: effective, game_session: draft.game_session ?? "auto" },
|
||
},
|
||
{ onSuccess: invalidateSettings },
|
||
);
|
||
const removePreset = (p: CustomPreset) => {
|
||
if (!confirm(m.display_preset_delete_confirm())) return;
|
||
deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings });
|
||
};
|
||
|
||
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);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* One-click presets — a 2-up grid so each has room to breathe */}
|
||
<div className="space-y-4">
|
||
<Label className="mb-1 block text-base font-semibold">{m.display_preset()}</Label>
|
||
<div 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;
|
||
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 soon = DISABLED_PRESETS.has(id);
|
||
const disabled = busy || soon;
|
||
const pick = () => {
|
||
if (!disabled) pickPreset(id);
|
||
};
|
||
return (
|
||
<Card
|
||
key={id}
|
||
interactive
|
||
role="button"
|
||
tabIndex={disabled ? -1 : 0}
|
||
aria-pressed={selected}
|
||
aria-disabled={disabled || undefined}
|
||
onClick={pick}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
pick();
|
||
}
|
||
}}
|
||
className={cn(
|
||
"flex h-full flex-col p-4",
|
||
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
|
||
selected && "ring-2 ring-primary",
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-base font-semibold">
|
||
{(PRESET_LABEL[id] ?? (() => id))()}
|
||
{soon && (
|
||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||
{m.display_preset_soon()}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{selected && (
|
||
<Badge variant="success">{m.display_preset_current()}</Badge>
|
||
)}
|
||
</div>
|
||
{summary && (
|
||
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
|
||
)}
|
||
{fields && (
|
||
<div className="mt-auto flex flex-wrap gap-1.5 pt-3">
|
||
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</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>
|
||
)}
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but
|
||
editable/deletable, plus a "Save as preset" that captures the current effective behavior. */}
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<Label className="text-base font-semibold">
|
||
{m.display_preset_custom_label()}
|
||
</Label>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={busy || presetBusy}
|
||
onClick={saveAsPreset}
|
||
>
|
||
<Plus className="mr-1 size-4" />
|
||
{m.display_preset_save_as()}
|
||
</Button>
|
||
</div>
|
||
{customPresets.length > 0 && (
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{customPresets.map((p) => (
|
||
<CustomPresetCard
|
||
key={p.id}
|
||
preset={p}
|
||
selected={customSelected(p)}
|
||
busy={busy || presetBusy}
|
||
onApply={() => applyCustomPreset(p)}
|
||
onRename={() => renamePreset(p)}
|
||
onUpdate={() => updatePresetToCurrent(p)}
|
||
onDelete={() => removePreset(p)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{presetError && (
|
||
<p className="text-sm text-amber-600 dark:text-amber-500">{presetError}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Custom: every option by hand */}
|
||
{isCustom && (
|
||
<div className="space-y-6 rounded-lg border p-5">
|
||
<Field label={m.display_keep_alive()} help={m.display_keep_alive_help()}>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant={ka.mode === "off" ? "default" : "outline"}
|
||
disabled={busy}
|
||
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
|
||
>
|
||
{m.display_keep_alive_off()}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant={ka.mode === "duration" ? "default" : "outline"}
|
||
disabled={busy}
|
||
onClick={() =>
|
||
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: keepSecs } })
|
||
}
|
||
>
|
||
{m.display_keep_alive_keep()}
|
||
</Button>
|
||
{ka.mode === "duration" && (
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
className="w-24"
|
||
value={ka.seconds}
|
||
disabled={busy}
|
||
onChange={(e) => {
|
||
const n = Math.max(0, Number(e.target.value) || 0);
|
||
setKeepSecs(n);
|
||
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: n } });
|
||
}}
|
||
/>
|
||
<span className="text-sm text-muted-foreground">
|
||
{m.display_keep_alive_seconds()}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Field>
|
||
|
||
<Choice
|
||
label={m.display_topology()}
|
||
help={m.display_topology_help()}
|
||
value={customFields.topology}
|
||
options={["auto", "extend", "primary", "exclusive"]}
|
||
labels={TOPOLOGY_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) => setDraft({ ...draft, topology: v as Topology })}
|
||
/>
|
||
<Choice
|
||
label={m.display_conflict()}
|
||
help={m.display_conflict_help()}
|
||
value={customFields.mode_conflict}
|
||
options={["separate", "steal", "join", "reject"]}
|
||
labels={CONFLICT_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) => setDraft({ ...draft, mode_conflict: v as ModeConflict })}
|
||
/>
|
||
<Choice
|
||
label={m.display_identity()}
|
||
help={m.display_identity_help()}
|
||
value={customFields.identity}
|
||
options={["shared", "per-client", "per-client-mode"]}
|
||
labels={IDENTITY_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) => setDraft({ ...draft, identity: v as Identity })}
|
||
/>
|
||
<Choice
|
||
label={m.display_layout_mode()}
|
||
help={m.display_layout_help()}
|
||
value={customFields.layout.mode ?? "auto-row"}
|
||
options={["auto-row", "manual"]}
|
||
labels={LAYOUT_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) =>
|
||
setDraft({
|
||
...draft,
|
||
layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} },
|
||
})
|
||
}
|
||
/>
|
||
|
||
<Field label={m.display_max()}>
|
||
<Input
|
||
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)),
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
<div className="border-t pt-4">
|
||
<Button onClick={() => apply(draft)} disabled={busy}>
|
||
{m.display_save()}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Game-session routing — orthogonal to the preset/lifecycle axes, so it lives outside the
|
||
Custom block and applies immediately on change (like a preset click). */}
|
||
<div className="border-t pt-4">
|
||
<Choice
|
||
label={m.display_game_session()}
|
||
help={m.display_game_session_help()}
|
||
value={draft.game_session ?? "auto"}
|
||
options={["auto", "dedicated"]}
|
||
labels={GAME_SESSION_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) => {
|
||
const next = { ...draft, game_session: v as GameSession };
|
||
setDraft(next);
|
||
apply(next);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* What's in force right now */}
|
||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
||
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
|
||
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, effective.topology)}</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>
|
||
{(draft.game_session ?? "auto") === "dedicated" && (
|
||
<Badge variant="secondary">{m.display_game_session_dedicated()}</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** A labeled config field — label, then the control, then optional help. The single source of the
|
||
* label→control→help spacing so every field (keep-alive, the button groups, max-displays) lines up. */
|
||
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
|
||
label,
|
||
help,
|
||
children,
|
||
}) => (
|
||
<div className="space-y-3">
|
||
<Label className="block">{label}</Label>
|
||
{children}
|
||
{help && <p className="max-w-prose text-xs text-muted-foreground">{help}</p>}
|
||
</div>
|
||
);
|
||
|
||
/** A [`Field`] whose control is a row of mutually-exclusive option buttons (topology / conflict / …). */
|
||
const Choice: FC<{
|
||
label: string;
|
||
help?: string;
|
||
value: string;
|
||
options: readonly string[];
|
||
labels: Record<string, () => string>;
|
||
disabled: boolean;
|
||
onPick: (v: string) => void;
|
||
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
|
||
<Field label={label} help={help}>
|
||
<div className="flex flex-wrap gap-2">
|
||
{options.map((o) => (
|
||
<Button
|
||
key={o}
|
||
size="sm"
|
||
variant={value === o ? "default" : "outline"}
|
||
disabled={disabled}
|
||
onClick={() => onPick(o)}
|
||
>
|
||
{(labels[o] ?? (() => o))()}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
);
|
||
|
||
/**
|
||
* One saved custom preset — the same interactive card as the built-ins (click to apply → writes a
|
||
* `Custom` policy carrying `preset.fields`), plus rename / update-to-current / delete affordances
|
||
* (each stops propagation so it doesn't also fire the card's apply). Field badges mirror the
|
||
* built-ins; the game-session badge shows only when it isn't the default `auto`.
|
||
*/
|
||
const CustomPresetCard: FC<{
|
||
preset: CustomPreset;
|
||
selected: boolean;
|
||
busy: boolean;
|
||
onApply: () => void;
|
||
onRename: () => void;
|
||
onUpdate: () => void;
|
||
onDelete: () => void;
|
||
}> = ({ preset, selected, busy, onApply, onRename, onUpdate, onDelete }) => {
|
||
const fields = preset.fields;
|
||
const stop = (fn: () => void) => (e: MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (!busy) fn();
|
||
};
|
||
return (
|
||
<Card
|
||
interactive
|
||
role="button"
|
||
tabIndex={busy ? -1 : 0}
|
||
aria-pressed={selected}
|
||
aria-disabled={busy || undefined}
|
||
onClick={() => !busy && onApply()}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
if (!busy) onApply();
|
||
}
|
||
}}
|
||
className={cn(
|
||
"flex h-full flex-col p-4",
|
||
busy ? "cursor-not-allowed opacity-60" : "cursor-pointer",
|
||
selected && "ring-2 ring-primary",
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<span className="min-w-0 truncate text-base font-semibold">{preset.name}</span>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{selected && <Badge variant="success">{m.display_preset_current()}</Badge>}
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
disabled={busy}
|
||
title={m.display_preset_edit()}
|
||
aria-label={m.display_preset_edit()}
|
||
onClick={stop(onRename)}
|
||
>
|
||
<Pencil className="size-4" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
disabled={busy}
|
||
title={m.display_preset_update()}
|
||
aria-label={m.display_preset_update()}
|
||
onClick={stop(onUpdate)}
|
||
>
|
||
<RefreshCw className="size-4" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
disabled={busy}
|
||
title={m.display_preset_delete()}
|
||
aria-label={m.display_preset_delete()}
|
||
onClick={stop(onDelete)}
|
||
>
|
||
<Trash2 className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-auto flex flex-wrap gap-1.5 pt-3">
|
||
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</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>
|
||
{(preset.game_session ?? "auto") !== "auto" && (
|
||
<Badge variant="secondary">{tr(GAME_SESSION_LABEL, preset.game_session)}</Badge>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
|
||
* for lingering/pinned ones (active displays can't be released — that's session control).
|
||
*/
|
||
const LiveDisplays: FC = () => {
|
||
const qc = useQueryClient();
|
||
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
|
||
const release = useReleaseDisplay();
|
||
const displays = state.data?.displays ?? [];
|
||
const kept = displays.filter((d) => d.state !== "active");
|
||
|
||
const doRelease = (slot?: number) =>
|
||
release.mutate(
|
||
{ data: { slot: slot ?? null } },
|
||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{kept.length > 0 && (
|
||
<div className="flex justify-end">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={release.isPending}
|
||
onClick={() => doRelease()}
|
||
>
|
||
{m.display_release_all()}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{displays.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
|
||
) : (
|
||
<ul className="divide-y rounded-md border">
|
||
{displays.map((d) => (
|
||
<DisplayRow
|
||
key={d.slot}
|
||
d={d}
|
||
busy={release.isPending}
|
||
onRelease={() => doRelease(d.slot)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<DisplayArrangement displays={displays} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* The multi-monitor **arrangement** editor (design/display-management.md §6.2): an x/y table over the
|
||
* live displays that carry a stable identity slot (the manual-layout key). Saving writes
|
||
* `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 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<Record<string, { x: number; y: number }> | null>(null);
|
||
useEffect(() => {
|
||
if (pos === null && arrangeable.length > 0) {
|
||
const seed: Record<string, { x: number; y: number }> = {};
|
||
for (const d of arrangeable) seed[String(d.identity_slot)] = { x: d.x, y: d.y };
|
||
setPos(seed);
|
||
}
|
||
}, [arrangeable, pos]);
|
||
|
||
if (arrangeable.length < 2) return null;
|
||
const cur = pos ?? {};
|
||
|
||
const setXY = (slot: number, key: "x" | "y", val: number) => {
|
||
const k = String(slot);
|
||
setPos({ ...cur, [k]: { ...(cur[k] ?? { x: 0, y: 0 }), [key]: val } });
|
||
};
|
||
|
||
const onSave = () =>
|
||
saveLayout.mutate(
|
||
{ data: { positions: cur } },
|
||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-2 border-t pt-4">
|
||
<h4 className="text-sm font-medium">{m.display_arrange()}</h4>
|
||
<p className="text-xs text-muted-foreground">{m.display_arrange_help()}</p>
|
||
<div className="space-y-2">
|
||
{arrangeable.map((d) => {
|
||
const slot = d.identity_slot as number;
|
||
const p = cur[String(slot)] ?? { x: d.x, y: d.y };
|
||
return (
|
||
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm">
|
||
<span className="w-44 truncate">
|
||
{d.mode} <code className="text-xs text-muted-foreground">#{slot}</code>
|
||
</span>
|
||
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
|
||
X
|
||
</Label>
|
||
<Input
|
||
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))}
|
||
/>
|
||
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
|
||
Y
|
||
</Label>
|
||
<Input
|
||
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))}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{saveLayout.error && (
|
||
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||
{apiErrorMessage(saveLayout.error)}
|
||
</p>
|
||
)}
|
||
<Button size="sm" onClick={onSave} disabled={saveLayout.isPending}>
|
||
{m.display_arrange_save()}
|
||
</Button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({
|
||
d,
|
||
busy,
|
||
onRelease,
|
||
}) => {
|
||
const active = d.state === "active";
|
||
const stateLabel =
|
||
d.state === "active"
|
||
? m.display_state_active()
|
||
: d.state === "pinned"
|
||
? m.display_state_pinned()
|
||
: m.display_state_lingering();
|
||
return (
|
||
<li className="flex items-center justify-between gap-4 px-4 py-3">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-medium">{d.mode}</span>
|
||
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
|
||
{active && d.sessions > 0 && (
|
||
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge>
|
||
)}
|
||
</div>
|
||
<code className="text-xs text-muted-foreground">
|
||
{d.backend}
|
||
{d.expires_in_ms != null
|
||
? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}`
|
||
: ""}
|
||
</code>
|
||
</div>
|
||
{!active && (
|
||
<Button size="sm" variant="outline" disabled={busy} onClick={onRelease}>
|
||
{m.display_release_btn()}
|
||
</Button>
|
||
)}
|
||
</li>
|
||
);
|
||
};
|
||
|
||
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
|
||
const apiErrorMessage = (err: unknown): string | undefined => {
|
||
if (err instanceof ApiError) {
|
||
const data = err.data as { error?: string } | undefined;
|
||
return data?.error ?? err.message;
|
||
}
|
||
return err ? String(err) : undefined;
|
||
};
|
||
|
||
/** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty
|
||
* now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and
|
||
* freed via Release. */
|
||
const DISABLED_PRESETS: ReadonlySet<string> = new Set<string>();
|
||
|
||
const PRESET_LABEL: Record<string, () => string> = {
|
||
custom: m.display_preset_custom,
|
||
default: m.display_preset_default,
|
||
"gaming-rig": m.display_preset_gaming_rig,
|
||
"shared-desktop": m.display_preset_shared_desktop,
|
||
hotdesk: m.display_preset_hotdesk,
|
||
workstation: m.display_preset_workstation,
|
||
};
|
||
|
||
const TOPOLOGY_LABEL: Record<string, () => string> = {
|
||
auto: m.display_topology_auto,
|
||
extend: m.display_topology_extend,
|
||
primary: m.display_topology_primary,
|
||
exclusive: m.display_topology_exclusive,
|
||
};
|
||
|
||
const CONFLICT_LABEL: Record<string, () => string> = {
|
||
separate: m.display_conflict_separate,
|
||
steal: m.display_conflict_steal,
|
||
join: m.display_conflict_join,
|
||
reject: m.display_conflict_reject,
|
||
};
|
||
|
||
const IDENTITY_LABEL: Record<string, () => string> = {
|
||
shared: m.display_identity_shared,
|
||
"per-client": m.display_identity_per_client,
|
||
"per-client-mode": m.display_identity_per_client_mode,
|
||
};
|
||
|
||
const LAYOUT_LABEL: Record<string, () => string> = {
|
||
"auto-row": m.display_layout_auto_row,
|
||
manual: m.display_layout_manual,
|
||
};
|
||
|
||
const GAME_SESSION_LABEL: Record<string, () => string> = {
|
||
auto: m.display_game_session_auto,
|
||
dedicated: m.display_game_session_dedicated,
|
||
};
|
||
|
||
/** Structural equality for the value-match of a custom preset's fields against the effective policy
|
||
* (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */
|
||
const deepEqual = (a: unknown, b: unknown): boolean => {
|
||
if (a === b) return true;
|
||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
||
const ak = Object.keys(a as object);
|
||
const bk = Object.keys(b as object);
|
||
if (ak.length !== bk.length) return false;
|
||
return ak.every((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). */
|
||
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
|
||
const fn = key == null ? undefined : map[key];
|
||
return fn ? fn() : String(key ?? "");
|
||
};
|
||
|
||
const fmtKeepAlive = (k: KeepAlive): string => {
|
||
switch (k.mode) {
|
||
case "off":
|
||
return m.display_keep_alive_off();
|
||
case "duration":
|
||
return `${k.seconds} ${m.display_keep_alive_seconds()}`;
|
||
case "forever":
|
||
return "∞";
|
||
}
|
||
};
|