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",
|
"app_tagline": "Verwaltungskonsole",
|
||||||
"nav_dashboard": "Übersicht",
|
"nav_dashboard": "Übersicht",
|
||||||
"nav_host": "Host",
|
"nav_host": "Host",
|
||||||
|
"nav_displays": "Virtuelle Anzeigen",
|
||||||
"nav_clients": "Gekoppelte Geräte",
|
"nav_clients": "Gekoppelte Geräte",
|
||||||
"nav_pairing": "Kopplung",
|
"nav_pairing": "Kopplung",
|
||||||
"nav_library": "Bibliothek",
|
"nav_library": "Bibliothek",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||||
"host_displays": "Virtuelle Displays",
|
"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.",
|
"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": "Voreinstellung",
|
||||||
"display_preset_custom": "Benutzerdefiniert",
|
"display_preset_custom": "Benutzerdefiniert",
|
||||||
"display_preset_default": "Standard",
|
"display_preset_default": "Standard",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"app_tagline": "management console",
|
"app_tagline": "management console",
|
||||||
"nav_dashboard": "Dashboard",
|
"nav_dashboard": "Dashboard",
|
||||||
"nav_host": "Host",
|
"nav_host": "Host",
|
||||||
|
"nav_displays": "Virtual displays",
|
||||||
"nav_clients": "Paired clients",
|
"nav_clients": "Paired clients",
|
||||||
"nav_pairing": "Pairing",
|
"nav_pairing": "Pairing",
|
||||||
"nav_library": "Library",
|
"nav_library": "Library",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||||
"host_displays": "Virtual displays",
|
"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.",
|
"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": "Preset",
|
||||||
"display_preset_custom": "Custom",
|
"display_preset_custom": "Custom",
|
||||||
"display_preset_default": "Default",
|
"display_preset_default": "Default",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
GaugeCircle,
|
GaugeCircle,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LibraryBig,
|
LibraryBig,
|
||||||
|
MonitorPlay,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -21,6 +22,7 @@ const MLink = motion(Link);
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: "/", icon: Activity, label: () => m.nav_dashboard() },
|
{ to: "/", icon: Activity, label: () => m.nav_dashboard() },
|
||||||
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
{ 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: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||||
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
|
{ 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,9 +60,10 @@ export const DisplaySection: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-card">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{m.host_displays()}</CardTitle>
|
<CardTitle>{m.display_config_title()}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
|
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
|
||||||
@@ -78,9 +79,17 @@ export const DisplaySection: FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.display_live()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<LiveDisplays />
|
<LiveDisplays />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,10 +151,10 @@ const DisplayForm: FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* One-click presets */}
|
{/* One-click presets — a 2-up grid so each has room to breathe */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Label>{m.display_preset()}</Label>
|
<Label className="text-base font-semibold">{m.display_preset()}</Label>
|
||||||
<div className="grid gap-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;
|
||||||
@@ -153,8 +162,10 @@ const DisplayForm: FC<{
|
|||||||
const selected = preset === id;
|
const selected = preset === id;
|
||||||
const soon = DISABLED_PRESETS.has(id);
|
const soon = DISABLED_PRESETS.has(id);
|
||||||
const cls = [
|
const cls = [
|
||||||
"w-full rounded-md border p-3 text-left transition-colors",
|
"flex h-full flex-col rounded-lg border p-4 text-left transition-colors",
|
||||||
selected ? "border-primary ring-1 ring-primary" : "hover:bg-muted/50",
|
selected
|
||||||
|
? "border-primary ring-1 ring-primary"
|
||||||
|
: "hover:border-primary/40 hover:bg-muted/50",
|
||||||
soon ? "opacity-60" : "",
|
soon ? "opacity-60" : "",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
return (
|
return (
|
||||||
@@ -166,7 +177,7 @@ const DisplayForm: FC<{
|
|||||||
className={cls}
|
className={cls}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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))()}
|
{(PRESET_LABEL[id] ?? (() => id))()}
|
||||||
{soon && (
|
{soon && (
|
||||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
<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>
|
<Badge variant="success">{m.display_preset_current()}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{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">{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>
|
||||||
@@ -361,10 +374,9 @@ const LiveDisplays: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h4 className="text-sm font-medium">{m.display_live()}</h4>
|
|
||||||
{kept.length > 0 && (
|
{kept.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -373,8 +385,8 @@ const LiveDisplays: FC = () => {
|
|||||||
>
|
>
|
||||||
{m.display_release_all()}
|
{m.display_release_all()}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</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>
|
||||||
) : (
|
) : (
|
||||||
@@ -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 type { FC } from "react";
|
||||||
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
||||||
import { useLocale } from "@/lib/i18n";
|
import { useLocale } from "@/lib/i18n";
|
||||||
import { DisplaySection } from "./DisplayCard";
|
|
||||||
import { GpuSection } from "./GpuCard";
|
import { GpuSection } from "./GpuCard";
|
||||||
import { HostView } from "./view";
|
import { HostView } from "./view";
|
||||||
|
|
||||||
@@ -10,12 +9,5 @@ export const SectionHost: FC = () => {
|
|||||||
const host = useGetHostInfo();
|
const host = useGetHostInfo();
|
||||||
const compositors = useListCompositors();
|
const compositors = useListCompositors();
|
||||||
|
|
||||||
return (
|
return <HostView host={host} compositors={compositors} gpu={<GpuSection />} />;
|
||||||
<HostView
|
|
||||||
host={host}
|
|
||||||
compositors={compositors}
|
|
||||||
gpu={<GpuSection />}
|
|
||||||
displays={<DisplaySection />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ export const HostView: FC<{
|
|||||||
compositors: Loadable<AvailableCompositor[]>;
|
compositors: Loadable<AvailableCompositor[]>;
|
||||||
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
|
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
|
||||||
gpu?: ReactNode;
|
gpu?: ReactNode;
|
||||||
/** The virtual-display management card (self-contained container — see `DisplayCard.tsx`). */
|
}> = ({ host, compositors, gpu }) => {
|
||||||
displays?: ReactNode;
|
|
||||||
}> = ({ host, compositors, gpu, displays }) => {
|
|
||||||
const h = host.data;
|
const h = host.data;
|
||||||
return (
|
return (
|
||||||
<Section maxWidth={false}>
|
<Section maxWidth={false}>
|
||||||
@@ -83,8 +81,6 @@ export const HostView: FC<{
|
|||||||
|
|
||||||
{gpu}
|
{gpu}
|
||||||
|
|
||||||
{displays}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{m.host_compositors()}</CardTitle>
|
<CardTitle>{m.host_compositors()}</CardTitle>
|
||||||
|
|||||||
Reference in New Issue
Block a user