Refresh the console shell for desktop and phones

The sidebar keeps the navigation on wide screens, now in two groups with a
clearer active state, and its footer holds the live indicator, a one-tap
theme toggle and a user menu with the account page, the full theme switch
and sign out. Below 900px the shell becomes a top bar (brand, live, theme,
user menu) and a bottom tab bar, so the primary pages are within thumb
reach and nothing stacks above the content.

Phones also get: no sideways scroll (the dashboard's fixed 2fr/1fr grid
collapses), stat cards two-up, low-value table columns hidden, modals as
bottom sheets, bigger touch targets, 16px inputs so iOS does not zoom, and
toasts that clear the tab bar. The meta theme-color follows the theme.

The mark's SVG gradients now take per-instance ids. With two copies on the
page the shared id resolved to the hidden top-bar copy and painted nothing,
which is why the logo looked washed out in light mode.

The tagline reads "WireGuard eXtended", which is where the name comes from.
README screenshots retaken.
This commit is contained in:
2026-09-12 23:01:05 -07:00
parent 5996488953
commit 17a62fdc59
13 changed files with 411 additions and 94 deletions
+94 -43
View File
@@ -1,64 +1,115 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Activity, ClipboardList, LayoutDashboard, LogOut, Settings, Shield, Users, Wifi, WifiOff } from "lucide-react";
import { useAuth, useLive } from "../state";
import { Activity, ClipboardList, LayoutDashboard, Settings, Users } from "lucide-react";
import { useLive } from "../state";
import { Mark } from "./Mark";
import { ThemeSwitch } from "./ThemeSwitch";
import { ThemeToggle } from "./ThemeSwitch";
import { UserMenu } from "./UserMenu";
const items = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/peers", label: "Peers", icon: Activity },
{ href: "/settings", label: "Settings", icon: Settings },
{ href: "/users", label: "Users", icon: Users },
{ href: "/audit", label: "Audit log", icon: ClipboardList },
{ href: "/account", label: "Account", icon: Shield },
// Account is reached through the user menu, so it is not a nav item.
const groups = [
{
label: "Overview",
items: [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/peers", label: "Peers", icon: Activity },
],
},
{
label: "Administration",
items: [
{ href: "/settings", label: "Settings", icon: Settings },
{ href: "/users", label: "Users", icon: Users },
{ href: "/audit", label: "Audit log", icon: ClipboardList },
],
},
];
const all = groups.flatMap((g) => g.items);
function isActive(href: string, location: string) {
return href === "/" ? location === "/" : location.startsWith(href);
}
function LivePill() {
const { connected } = useLive();
return (
<span className={`live-pill${connected ? " on" : ""}`} title={connected ? "Live updates connected" : "Live updates reconnecting"}>
<i /> {connected ? "Live" : "Reconnecting"}
</span>
);
}
export function Layout({ children }: { children: ReactNode }) {
const [location] = useLocation();
const { me, signOut } = useAuth();
const { connected } = useLive();
return (
<div className="shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">
<Mark size={34} />
</div>
<div>
<div className="brand-name">WGX</div>
<div className="brand-sub">WireGuard server</div>
</div>
{/* Phones and narrow windows: brand and account controls up top. */}
<header className="topbar">
<Link href="/" className="brand" aria-label="WGX dashboard">
<Mark size={30} />
<span className="brand-name">WGX</span>
</Link>
<div className="topbar-right">
<LivePill />
<ThemeToggle />
<UserMenu placement="down" />
</div>
<nav className="nav">
{items.map((it) => {
const active = it.href === "/" ? location === "/" : location.startsWith(it.href);
const Icon = it.icon;
return (
<Link key={it.href} href={it.href} className={active ? "active" : ""}>
<Icon />
<span>{it.label}</span>
</Link>
);
})}
</header>
{/* Desktop: everything lives in the sidebar. */}
<aside className="sidebar">
<Link href="/" className="brand" aria-label="WGX dashboard">
<Mark size={34} />
<span>
<span className="brand-name">WGX</span>
<span className="brand-sub">WireGuard eXtended</span>
</span>
</Link>
<nav className="nav" aria-label="Main">
{groups.map((g) => (
<div className="nav-group" key={g.label}>
<div className="nav-label">{g.label}</div>
{g.items.map((it) => {
const Icon = it.icon;
return (
<Link key={it.href} href={it.href} className={isActive(it.href, location) ? "active" : ""} aria-current={isActive(it.href, location) ? "page" : undefined}>
<Icon />
<span>{it.label}</span>
</Link>
);
})}
</div>
))}
</nav>
<div className="sidebar-foot">
<ThemeSwitch compact />
<div title={connected ? "Live updates connected" : "Live updates reconnecting"} className="nowrap">
{connected ? <Wifi size={13} style={{ verticalAlign: -2, color: "var(--ok)" }} /> : <WifiOff size={13} style={{ verticalAlign: -2, color: "var(--warn)" }} />} {connected ? "live" : "reconnecting"}
<div className="sidebar-tools">
<LivePill />
<ThemeToggle />
</div>
<div className="nowrap">
{me?.username} <span className="faint">({me?.role})</span>
</div>
<button className="btn sm ghost" onClick={() => void signOut()} style={{ alignSelf: "flex-start", marginLeft: -6 }}>
<LogOut /> Sign out
</button>
<a className="nowrap" href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
<UserMenu placement="up" showName />
<a className="source" href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
AGPL-3.0 source
</a>
</div>
</aside>
<main className="main">{children}</main>
<main className="main" id="main">
{children}
</main>
{/* Phones: primary navigation as a tab bar within thumb reach. */}
<nav className="tabbar" aria-label="Main">
{all.map((it) => {
const Icon = it.icon;
const active = isActive(it.href, location);
return (
<Link key={it.href} href={it.href} className={active ? "active" : ""} aria-current={active ? "page" : undefined}>
<Icon />
<span>{it.label}</span>
</Link>
);
})}
</nav>
</div>
);
}
+13 -5
View File
@@ -1,23 +1,31 @@
import { useId } from "react";
// The WGX mark: a padlock on a shield, with the W as its keyhole. The same
// drawing as docs/brand/wgx-mark.svg, inlined so it needs no request and
// takes its size from wherever it is placed.
// takes its size from wherever it is placed. Gradient ids are per instance:
// the mark appears more than once on a page (sidebar, top bar), and a shared
// id would resolve to whichever copy comes first, which may be hidden and
// then paints nothing.
export function Mark({ size = 32 }: { size?: number }) {
const id = useId();
const shield = `${id}-shield`;
const lock = `${id}-lock`;
return (
<svg width={size} height={size} viewBox="0 0 128 128" role="img" aria-label="WGX">
<defs>
<linearGradient id="wgx-shield" x1="0" y1="0" x2="0" y2="1">
<linearGradient id={shield} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#26332e" />
<stop offset="1" stopColor="#121a17" />
</linearGradient>
<linearGradient id="wgx-lock" x1="0" y1="0" x2="0" y2="1">
<linearGradient id={lock} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3fae90" />
<stop offset="1" stopColor="#2b8f75" />
</linearGradient>
</defs>
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="url(#wgx-shield)" />
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill={`url(#${shield})`} />
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#3fae90" strokeWidth="3" strokeLinejoin="round" />
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" strokeWidth="8.5" strokeLinecap="round" />
<rect x="34" y="58" width="60" height="42" rx="10" fill="url(#wgx-lock)" />
<rect x="34" y="58" width="60" height="42" rx="10" fill={`url(#${lock})`} />
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
+30 -11
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Monitor, Moon, Sun } from "lucide-react";
import { getThemeChoice, setThemeChoice, type ThemeChoice } from "../theme";
import { getThemeChoice, setThemeChoice, subscribeTheme, type ThemeChoice } from "../theme";
const options: { value: ThemeChoice; label: string; icon: typeof Moon }[] = [
{ value: "dark", label: "Dark", icon: Moon },
@@ -8,24 +8,43 @@ const options: { value: ThemeChoice; label: string; icon: typeof Moon }[] = [
{ value: "system", label: "System", icon: Monitor },
];
// Dark / Light / System. `compact` shows icons only, for the sidebar.
export function ThemeSwitch({ compact = false }: { compact?: boolean }) {
function useThemeChoice(): [ThemeChoice, (v: ThemeChoice) => void] {
const [choice, setChoice] = useState<ThemeChoice>(getThemeChoice);
const pick = (v: ThemeChoice) => {
setThemeChoice(v);
setChoice(v);
};
useEffect(() => subscribeTheme(setChoice), []);
return [choice, setThemeChoice];
}
// Dark / Light / System as a segmented control. `compact` shows icons only;
// `icons={false}` shows labels only, for narrow places like the user menu.
export function ThemeSwitch({ compact = false, icons = true }: { compact?: boolean; icons?: boolean }) {
const [choice, pick] = useThemeChoice();
return (
<div className="segmented" role="radiogroup" aria-label="Theme">
{options.map((o) => {
const Icon = o.icon;
return (
<button key={o.value} role="radio" aria-checked={choice === o.value} aria-label={o.label} title={o.label} className={choice === o.value ? "active" : ""} onClick={() => pick(o.value)}>
<Icon size={14} style={{ verticalAlign: -2 }} />
{!compact && <span style={{ marginLeft: 6 }}>{o.label}</span>}
<button key={o.value} type="button" role="radio" aria-checked={choice === o.value} aria-label={o.label} title={o.label} className={choice === o.value ? "active" : ""} onClick={() => pick(o.value)}>
{icons && <Icon size={14} style={{ verticalAlign: -2 }} />}
{!compact && <span style={{ marginLeft: icons ? 6 : 0 }}>{o.label}</span>}
</button>
);
})}
</div>
);
}
// One button that steps dark → light → system. Used in the shell where a
// three-way control would crowd the bar; the full switch lives in the user
// menu and on the Account page.
export function ThemeToggle() {
const [choice, pick] = useThemeChoice();
const idx = options.findIndex((o) => o.value === choice);
const current = options[idx] ?? options[0];
const next = options[(idx + 1) % options.length];
const Icon = current.icon;
return (
<button type="button" className="icon-btn" title={`Theme: ${current.label}. Switch to ${next.label.toLowerCase()}`} aria-label={`Theme: ${current.label}. Switch to ${next.label.toLowerCase()}`} onClick={() => pick(next.value)}>
<Icon size={17} />
</button>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "wouter";
import { ChevronDown, LogOut, Shield } from "lucide-react";
import { useAuth } from "../state";
import { ThemeSwitch } from "./ThemeSwitch";
// The signed-in user: avatar button that opens a small menu with the
// account page, the theme switch and sign out. `placement` says which way
// the menu opens; the sidebar footer opens upward, the top bar downward.
export function UserMenu({ placement, showName = false }: { placement: "up" | "down"; showName?: boolean }) {
const { me, signOut } = useAuth();
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (root.current && !root.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (!me) return null;
const initial = me.username.slice(0, 1).toUpperCase();
return (
<div className={`user-menu ${placement}`} ref={root}>
<button type="button" className={`user-btn${showName ? " wide" : ""}`} aria-haspopup="menu" aria-expanded={open} aria-label={showName ? undefined : `Account menu for ${me.username}`} onClick={() => setOpen((v) => !v)}>
<span className="avatar" aria-hidden="true">
{initial}
</span>
{showName && (
<>
<span className="user-text">
<span className="user-name">{me.username}</span>
<span className="user-role">{me.role}</span>
</span>
<ChevronDown size={15} className="user-chev" />
</>
)}
</button>
{open && (
<div className="menu" role="menu">
<div className="menu-head">
<span className="avatar lg" aria-hidden="true">
{initial}
</span>
<div className="user-text">
<span className="user-name">{me.username}</span>
<span className="user-role">{me.role}</span>
</div>
</div>
<Link href="/account" role="menuitem" className="menu-item" onClick={() => setOpen(false)}>
<Shield size={16} /> Account &amp; security
</Link>
<div className="menu-row">
<span className="menu-label">Theme</span>
<ThemeSwitch icons={false} />
</div>
<button type="button" role="menuitem" className="menu-item" onClick={() => void signOut()}>
<LogOut size={16} /> Sign out
</button>
</div>
)}
</div>
);
}