feat(web): move Virtual displays to its own nav section; roomier preset grid
The Host page was crowded (identity, codecs, ports, GPU, displays, compositors) and the virtual-display config surface is large enough to warrant its own home. - New **Virtual displays** nav section: `/displays` route + `sections/Displays` (moved DisplayCard out of `sections/Host`), a `MonitorPlay` sidebar entry after Host, and `nav_displays` i18n. Removed the displays card from the Host page/view. - On its own page the card splits into two: **Configuration** (presets + custom axes) and **Live displays** (the live list + arrangement table) — room to breathe. - Presets now render in a max-2-column grid (`sm:grid-cols-2`) with larger padding, a bigger section heading + preset titles (text-base semibold), roomier spacing, and bottom-aligned "what it sets" badges so the cards line up. web tsc + vite build + biome-lint green; deployed + verified on the Mutter box (.21). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"app_tagline": "Verwaltungskonsole",
|
||||
"nav_dashboard": "Übersicht",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtuelle Anzeigen",
|
||||
"nav_clients": "Gekoppelte Geräte",
|
||||
"nav_pairing": "Kopplung",
|
||||
"nav_library": "Bibliothek",
|
||||
@@ -49,6 +50,7 @@
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"host_displays": "Virtuelle Displays",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_config_title": "Konfiguration",
|
||||
"display_preset": "Voreinstellung",
|
||||
"display_preset_custom": "Benutzerdefiniert",
|
||||
"display_preset_default": "Standard",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"app_tagline": "management console",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtual displays",
|
||||
"nav_clients": "Paired clients",
|
||||
"nav_pairing": "Pairing",
|
||||
"nav_library": "Library",
|
||||
@@ -49,6 +50,7 @@
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"host_displays": "Virtual displays",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_config_title": "Configuration",
|
||||
"display_preset": "Preset",
|
||||
"display_preset_custom": "Custom",
|
||||
"display_preset_default": "Default",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GaugeCircle,
|
||||
KeyRound,
|
||||
LibraryBig,
|
||||
MonitorPlay,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -21,6 +22,7 @@ const MLink = motion(Link);
|
||||
const NAV = [
|
||||
{ to: "/", icon: Activity, label: () => m.nav_dashboard() },
|
||||
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
||||
{ to: "/displays", icon: MonitorPlay, label: () => m.nav_displays() },
|
||||
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SectionDisplays } from "@/sections/Displays";
|
||||
|
||||
export const Route = createFileRoute("/displays")({ component: SectionDisplays });
|
||||
@@ -60,27 +60,36 @@ export const DisplaySection: FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.host_displays()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="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}
|
||||
apply={apply}
|
||||
busy={save.isPending}
|
||||
error={apiErrorMessage(save.error)}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
<LiveDisplays />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-card">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.display_config_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="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}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -142,10 +151,10 @@ const DisplayForm: FC<{
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* One-click presets */}
|
||||
<div className="space-y-2">
|
||||
<Label>{m.display_preset()}</Label>
|
||||
<div className="grid gap-2">
|
||||
{/* One-click presets — a 2-up grid so each has room to breathe */}
|
||||
<div className="space-y-3">
|
||||
<Label className="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;
|
||||
@@ -153,8 +162,10 @@ const DisplayForm: FC<{
|
||||
const selected = preset === id;
|
||||
const soon = DISABLED_PRESETS.has(id);
|
||||
const cls = [
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
selected ? "border-primary ring-1 ring-primary" : "hover:bg-muted/50",
|
||||
"flex h-full flex-col rounded-lg border p-4 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary ring-1 ring-primary"
|
||||
: "hover:border-primary/40 hover:bg-muted/50",
|
||||
soon ? "opacity-60" : "",
|
||||
].join(" ");
|
||||
return (
|
||||
@@ -166,7 +177,7 @@ const DisplayForm: FC<{
|
||||
className={cls}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
<span className="text-base font-semibold">
|
||||
{(PRESET_LABEL[id] ?? (() => id))()}
|
||||
{soon && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
@@ -178,9 +189,11 @@ const DisplayForm: FC<{
|
||||
<Badge variant="success">{m.display_preset_current()}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{summary && <p className="mt-0.5 text-xs text-muted-foreground">{summary}</p>}
|
||||
{summary && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
{fields && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<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>
|
||||
@@ -361,10 +374,9 @@ const LiveDisplays: FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h4 className="text-sm font-medium">{m.display_live()}</h4>
|
||||
{kept.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{kept.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -373,8 +385,8 @@ const LiveDisplays: FC = () => {
|
||||
>
|
||||
{m.display_release_all()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displays.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
|
||||
) : (
|
||||
@@ -0,0 +1,22 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import type { FC } from "react";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { DisplaySection } from "./DisplayCard";
|
||||
|
||||
/**
|
||||
* The **Virtual displays** page (design/display-management.md): the host's virtual-display policy
|
||||
* (presets + every axis) plus the live-display list + multi-monitor arrangement. Its own nav
|
||||
* section — the config surface is large enough to warrant the room, and it kept the Host page busy.
|
||||
*/
|
||||
export const SectionDisplays: FC = () => {
|
||||
useLocale();
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
|
||||
<DisplaySection />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { DisplaySection } from "./DisplayCard";
|
||||
import { GpuSection } from "./GpuCard";
|
||||
import { HostView } from "./view";
|
||||
|
||||
@@ -10,12 +9,5 @@ export const SectionHost: FC = () => {
|
||||
const host = useGetHostInfo();
|
||||
const compositors = useListCompositors();
|
||||
|
||||
return (
|
||||
<HostView
|
||||
host={host}
|
||||
compositors={compositors}
|
||||
gpu={<GpuSection />}
|
||||
displays={<DisplaySection />}
|
||||
/>
|
||||
);
|
||||
return <HostView host={host} compositors={compositors} gpu={<GpuSection />} />;
|
||||
};
|
||||
|
||||
@@ -13,9 +13,7 @@ export const HostView: FC<{
|
||||
compositors: Loadable<AvailableCompositor[]>;
|
||||
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
|
||||
gpu?: ReactNode;
|
||||
/** The virtual-display management card (self-contained container — see `DisplayCard.tsx`). */
|
||||
displays?: ReactNode;
|
||||
}> = ({ host, compositors, gpu, displays }) => {
|
||||
}> = ({ host, compositors, gpu }) => {
|
||||
const h = host.data;
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
@@ -83,8 +81,6 @@ export const HostView: FC<{
|
||||
|
||||
{gpu}
|
||||
|
||||
{displays}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.host_compositors()}</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user