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.
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 72 KiB |
@@ -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 = [
|
||||
// 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 },
|
||||
{ href: "/account", label: "Account", icon: Shield },
|
||||
],
|
||||
},
|
||||
];
|
||||
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">
|
||||
{/* 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>
|
||||
</header>
|
||||
|
||||
{/* Desktop: everything lives in the sidebar. */}
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">
|
||||
<Link href="/" className="brand" aria-label="WGX dashboard">
|
||||
<Mark size={34} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="brand-name">WGX</div>
|
||||
<div className="brand-sub">WireGuard server</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{items.map((it) => {
|
||||
const active = it.href === "/" ? location === "/" : location.startsWith(it.href);
|
||||
<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={active ? "active" : ""}>
|
||||
<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">
|
||||
<div className="sidebar-tools">
|
||||
<LivePill />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<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" 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 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>
|
||||
<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">
|
||||
AGPL-3.0 source
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function Dashboard() {
|
||||
<Stat label="Sent" value={bytes(totals?.tx ?? 0)} sub="to peers, all time" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2 mt" style={{ gridTemplateColumns: "2fr 1fr" }}>
|
||||
<div className="grid grid-main mt">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Traffic</h2>
|
||||
@@ -175,8 +175,8 @@ export function Dashboard() {
|
||||
<tr>
|
||||
<th>Peer</th>
|
||||
<th>Address</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Session</th>
|
||||
<th className="hide-md">Endpoint</th>
|
||||
<th className="hide-sm">Session</th>
|
||||
<th>Handshake</th>
|
||||
<th className="right">Rate</th>
|
||||
<th className="right">Transfer</th>
|
||||
@@ -192,8 +192,8 @@ export function Dashboard() {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="mono">{p.ipv4}</td>
|
||||
<td className="mono">{l.endpoint ?? "—"}</td>
|
||||
<td>{duration(l.connectedSince, now)}</td>
|
||||
<td className="mono hide-md">{l.endpoint ?? "—"}</td>
|
||||
<td className="hide-sm">{duration(l.connectedSince, now)}</td>
|
||||
<td>{ago(l.lastHandshake, now)}</td>
|
||||
<td className="num right">
|
||||
↓ {rate(l.rxRate)} · ↑ {rate(l.txRate)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function Peers() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<div style={{ position: "relative" }}>
|
||||
<div className="search-wrap" style={{ position: "relative" }}>
|
||||
<Search size={14} style={{ position: "absolute", left: 9, top: 10, color: "var(--fg-faint)" }} />
|
||||
<input className="input search" style={{ paddingLeft: 28 }} placeholder="Search name, address, key…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
@@ -128,10 +128,10 @@ export function Peers() {
|
||||
<tr>
|
||||
<th>Peer</th>
|
||||
<th>Address</th>
|
||||
<th>Endpoint</th>
|
||||
<th className="hide-md">Endpoint</th>
|
||||
<th>Handshake</th>
|
||||
<th className="right">Rate</th>
|
||||
<th></th>
|
||||
<th className="right hide-sm">Rate</th>
|
||||
<th className="hide-md"></th>
|
||||
<th className="right">Transfer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -148,10 +148,10 @@ export function Peers() {
|
||||
{!p.serverKeys && <span className="badge" style={{ marginLeft: 8 }} title="The client holds its own private key">client key</span>}
|
||||
</td>
|
||||
<td className="mono nowrap">{p.ipv4}</td>
|
||||
<td className="mono nowrap">{l.endpoint || <span className="faint">—</span>}</td>
|
||||
<td className="mono nowrap hide-md">{l.endpoint || <span className="faint">—</span>}</td>
|
||||
<td className="nowrap">{ago(l.lastHandshake, now)}</td>
|
||||
<td className="num right">{l.connected ? `↓ ${rate(l.rxRate)} ↑ ${rate(l.txRate)}` : <span className="faint">—</span>}</td>
|
||||
<td>{l.connected && <Sparkline values={history.current.get(p.id) ?? []} />}</td>
|
||||
<td className="num right hide-sm">{l.connected ? `↓ ${rate(l.rxRate)} ↑ ${rate(l.txRate)}` : <span className="faint">—</span>}</td>
|
||||
<td className="hide-md">{l.connected && <Sparkline values={history.current.get(p.id) ?? []} />}</td>
|
||||
<td className="num right">
|
||||
{bytes(l.rx)} <span className="faint">/</span> {bytes(l.tx)}
|
||||
</td>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
--rx: #5f9de0;
|
||||
--tx: #3fae90;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
--radius: 10px;
|
||||
--radius: 12px;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color-scheme: dark;
|
||||
@@ -76,44 +76,182 @@ p { margin: 0 0 8px; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
/* Layout */
|
||||
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100%; }
|
||||
/* Layout: a sidebar from 900px up; below that a top bar and a bottom tab
|
||||
bar, with the account controls in the top bar. */
|
||||
:root {
|
||||
--sidebar-w: 236px;
|
||||
--topbar-h: 56px;
|
||||
--tabbar-h: 60px;
|
||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
.shell { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 100%; }
|
||||
.sidebar {
|
||||
background: var(--bg-elev);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 16px 12px;
|
||||
padding: 14px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 16px; }
|
||||
.brand-mark { display: grid; place-items: center; flex: none; line-height: 0; }
|
||||
.brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
|
||||
.brand-sub { font-size: 11px; color: var(--fg-muted); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px; color: var(--fg); line-height: 1.15; }
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand svg { flex: none; display: block; }
|
||||
.brand-name { display: block; font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
|
||||
.brand-sub { display: block; font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
|
||||
.nav { display: flex; flex-direction: column; gap: 14px; margin-top: 10px; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 2px; }
|
||||
.nav-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-faint); padding: 0 10px 6px; }
|
||||
.nav a {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; color: var(--fg-muted); font-weight: 500;
|
||||
transition: background 0.15s var(--ease), color 0.15s var(--ease);
|
||||
}
|
||||
.nav a:hover { background: var(--bg-sunken); text-decoration: none; color: var(--fg); }
|
||||
.nav a.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.nav a svg { width: 17px; height: 17px; }
|
||||
.sidebar-foot { margin-top: auto; padding: 8px; font-size: 12px; color: var(--fg-muted); display: flex; flex-direction: column; gap: 6px; }
|
||||
.nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.nav a.active::before { content: ""; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
|
||||
.nav a svg { width: 17px; height: 17px; flex: none; }
|
||||
.sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.sidebar-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 2px; }
|
||||
.source { font-size: 11.5px; color: var(--fg-faint); padding: 0 8px; }
|
||||
.source:hover { color: var(--accent); }
|
||||
.main { padding: 24px 28px 48px; min-width: 0; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.page-head p { color: var(--fg-muted); margin: 2px 0 0; }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.brand { padding: 4px 8px; }
|
||||
.nav { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
.nav a span { display: none; }
|
||||
.sidebar-foot { margin-top: 0; margin-left: auto; }
|
||||
.main { padding: 16px; }
|
||||
/* Top bar and tab bar exist only on narrow screens. */
|
||||
.topbar { display: none; }
|
||||
.tabbar { display: none; }
|
||||
|
||||
/* Shared chrome pieces */
|
||||
.icon-btn {
|
||||
display: inline-grid; place-items: center; width: 36px; height: 36px; flex: none;
|
||||
border-radius: 9px; border: 1px solid transparent; background: transparent; color: var(--fg-muted); cursor: pointer;
|
||||
transition: background 0.15s var(--ease), color 0.15s var(--ease);
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-sunken); color: var(--fg); }
|
||||
.live-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px; border-radius: 999px;
|
||||
font-size: 11.5px; font-weight: 600; color: var(--warn); background: var(--warn-soft); white-space: nowrap;
|
||||
}
|
||||
.live-pill i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.live-pill.on { color: var(--ok); background: var(--ok-soft); }
|
||||
.live-pill.on i { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent); }
|
||||
.avatar {
|
||||
display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; flex: none;
|
||||
background: var(--accent-soft); color: var(--accent); font-weight: 700; font-size: 13px; letter-spacing: 0;
|
||||
}
|
||||
.avatar.lg { width: 38px; height: 38px; font-size: 16px; }
|
||||
.user-menu { position: relative; }
|
||||
.user-btn {
|
||||
display: inline-flex; align-items: center; gap: 10px; padding: 3px; border-radius: 999px; border: 1px solid transparent;
|
||||
background: transparent; cursor: pointer; text-align: left; color: var(--fg);
|
||||
transition: background 0.15s var(--ease), border-color 0.15s var(--ease);
|
||||
}
|
||||
.user-btn:hover, .user-btn[aria-expanded="true"] { background: var(--bg-sunken); }
|
||||
.user-btn.wide { width: 100%; padding: 6px 8px; border-radius: 10px; border-color: var(--line); background: var(--bg); }
|
||||
.user-btn.wide:hover, .user-btn.wide[aria-expanded="true"] { border-color: var(--line-strong); background: var(--bg-sunken); }
|
||||
.user-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
|
||||
.user-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-role { font-size: 11px; color: var(--fg-muted); text-transform: capitalize; }
|
||||
.user-chev { margin-left: auto; color: var(--fg-faint); transition: transform 0.2s var(--ease); }
|
||||
.user-menu.up .user-chev { transform: rotate(180deg); }
|
||||
.user-menu.up .user-btn[aria-expanded="true"] .user-chev { transform: rotate(0); }
|
||||
.user-menu.down .user-btn[aria-expanded="true"] .user-chev { transform: rotate(180deg); }
|
||||
.menu {
|
||||
position: absolute; z-index: 40; min-width: 250px; padding: 6px;
|
||||
background: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 12px; box-shadow: var(--shadow);
|
||||
animation: menu-in 0.16s var(--ease);
|
||||
}
|
||||
.user-menu.up .menu { left: 0; right: 0; bottom: calc(100% + 8px); }
|
||||
.user-menu.down .menu { right: 0; top: calc(100% + 8px); }
|
||||
@keyframes menu-in { from { opacity: 0; transform: translateY(var(--menu-shift, 4px)); } to { opacity: 1; transform: none; } }
|
||||
.user-menu.up .menu { --menu-shift: -4px; }
|
||||
.menu-head { display: flex; align-items: center; gap: 10px; padding: 8px 10px 10px; border-bottom: 1px solid var(--line); margin-bottom: 6px; }
|
||||
.menu-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border-radius: 8px; border: 0;
|
||||
background: transparent; color: var(--fg); font-weight: 500; cursor: pointer; text-align: left;
|
||||
}
|
||||
.menu-item:hover { background: var(--bg-sunken); text-decoration: none; }
|
||||
.menu-item svg { color: var(--fg-muted); flex: none; }
|
||||
.menu-row { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px 10px; }
|
||||
.menu-row .segmented { display: flex; }
|
||||
.menu-row .segmented button { flex: 1; justify-content: center; text-align: center; }
|
||||
.menu-label { font-size: 12.5px; color: var(--fg-muted); font-weight: 500; }
|
||||
|
||||
/* Narrow: top bar + tab bar */
|
||||
@media (max-width: 899px) {
|
||||
/* Rows must not stretch, or a short page pushes its content down. */
|
||||
.shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
|
||||
.sidebar { display: none; }
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
height: var(--topbar-h); padding: 0 10px 0 8px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
background: color-mix(in srgb, var(--bg-elev) 88%, transparent);
|
||||
backdrop-filter: saturate(1.4) blur(10px);
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(10px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .brand { padding: 4px 6px; gap: 8px; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 4px; }
|
||||
.topbar-right .live-pill { margin-right: 4px; }
|
||||
.main { padding: 16px 16px calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 24px); }
|
||||
.tabbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||
height: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
|
||||
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
|
||||
background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
|
||||
backdrop-filter: saturate(1.4) blur(10px);
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(10px);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.tabbar a {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
|
||||
border-radius: 10px; color: var(--fg-muted); font-size: 10.5px; font-weight: 500; letter-spacing: 0.01em;
|
||||
transition: color 0.15s var(--ease), background 0.15s var(--ease);
|
||||
}
|
||||
.tabbar a:hover { text-decoration: none; }
|
||||
.tabbar a svg { width: 21px; height: 21px; }
|
||||
.tabbar a.active { color: var(--accent); }
|
||||
.tabbar a.active svg { filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 45%, transparent)); }
|
||||
.tabbar a:active { background: var(--bg-sunken); }
|
||||
.page-head { margin-bottom: 14px; }
|
||||
.page-head h1 { font-size: 19px; }
|
||||
.card-head { flex-wrap: wrap; }
|
||||
.toolbar { width: 100%; }
|
||||
.toolbar .search { max-width: none; flex: 1 1 100%; }
|
||||
.search-wrap { flex: 1 1 100%; }
|
||||
.segmented { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
|
||||
.segmented::-webkit-scrollbar { display: none; }
|
||||
.segmented button { white-space: nowrap; }
|
||||
}
|
||||
|
||||
/* Touch: fingers need more room than pointers. */
|
||||
@media (pointer: coarse) {
|
||||
.btn { min-height: 38px; }
|
||||
.btn.sm { min-height: 32px; }
|
||||
.btn.icon { min-width: 38px; }
|
||||
.icon-btn { width: 40px; height: 40px; }
|
||||
.input, textarea.input, select.input { min-height: 42px; font-size: 16px; }
|
||||
.segmented button { padding: 8px 12px; }
|
||||
.tabs button { padding: 10px 12px; }
|
||||
.nav a, .menu-item { padding-top: 10px; padding-bottom: 10px; }
|
||||
tbody tr.clickable td { padding-top: 12px; padding-bottom: 12px; }
|
||||
}
|
||||
a, button { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* Cards and grids */
|
||||
@@ -132,6 +270,11 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
||||
@media (max-width: 1100px) { .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; } }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.grid-main { grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); }
|
||||
@media (max-width: 1100px) { .grid-main { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 640px) { .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: 12px 14px; } .stat-value { font-size: 21px; } }
|
||||
@media (max-width: 899px) { .hide-md { display: none !important; } }
|
||||
@media (max-width: 640px) { .hide-sm { display: none !important; } }
|
||||
|
||||
.stat { padding: 14px 16px; }
|
||||
.stat-label { font-size: 12px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
@@ -215,11 +358,18 @@ textarea.input { min-height: 72px; resize: vertical; }
|
||||
.modal { width: 100%; max-width: 640px; max-height: calc(100vh - 40px); overflow: auto; }
|
||||
.modal.wide { max-width: 860px; }
|
||||
.modal .card-head h2 { font-size: 16px; }
|
||||
@media (max-width: 640px) {
|
||||
.modal-back { place-items: end center; padding: 0; }
|
||||
.modal, .modal.wide { max-width: none; max-height: 92dvh; border-radius: 16px 16px 0 0; border-bottom: 0; animation: sheet-in 0.22s var(--ease); }
|
||||
.modal .card-head { position: sticky; top: 0; background: var(--bg-elev); z-index: 1; border-radius: 16px 16px 0 0; }
|
||||
@keyframes sheet-in { from { transform: translateY(24px); opacity: 0.6; } to { transform: none; opacity: 1; } }
|
||||
}
|
||||
|
||||
/* Toasts */
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 60; }
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; left: 16px; align-items: flex-end; display: flex; flex-direction: column; gap: 8px; z-index: 60; }
|
||||
.toast { background: var(--fg); color: var(--bg); padding: 10px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 13px; max-width: 360px; }
|
||||
.toast.bad { background: var(--bad); color: #fff; }
|
||||
@media (max-width: 899px) { .toasts { bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 12px); } .toast { max-width: 100%; } }
|
||||
|
||||
/* Peer detail */
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; }
|
||||
@@ -247,6 +397,7 @@ pre.config { background: var(--bg-sunken); border: 1px solid var(--line); border
|
||||
.nowrap { white-space: nowrap; }
|
||||
.mt { margin-top: 12px; }
|
||||
.recovery { columns: 2; font-family: var(--mono); font-size: 14px; }
|
||||
@media (max-width: 480px) { .recovery { columns: 1; } }
|
||||
.recovery li { margin: 4px 0; }
|
||||
.search { max-width: 280px; }
|
||||
.segmented { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 8px; overflow: hidden; }
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ThemeChoice = "dark" | "light" | "system";
|
||||
|
||||
const KEY = "wgx.theme";
|
||||
const media = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const listeners = new Set<(c: ThemeChoice) => void>();
|
||||
|
||||
export function getThemeChoice(): ThemeChoice {
|
||||
try {
|
||||
@@ -17,13 +18,17 @@ export function getThemeChoice(): ThemeChoice {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function resolve(choice: ThemeChoice): "dark" | "light" {
|
||||
export function resolveTheme(choice: ThemeChoice): "dark" | "light" {
|
||||
if (choice === "system") return media.matches ? "light" : "dark";
|
||||
return choice;
|
||||
}
|
||||
|
||||
export function applyTheme(choice: ThemeChoice) {
|
||||
document.documentElement.dataset.theme = resolve(choice);
|
||||
const resolved = resolveTheme(choice);
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
// Keeps the browser chrome (address bar on phones) in step with the page.
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute("content", resolved === "light" ? "#f4f6f5" : "#121a17");
|
||||
}
|
||||
|
||||
export function setThemeChoice(choice: ThemeChoice) {
|
||||
@@ -33,6 +38,15 @@ export function setThemeChoice(choice: ThemeChoice) {
|
||||
/* storage unavailable */
|
||||
}
|
||||
applyTheme(choice);
|
||||
listeners.forEach((l) => l(choice));
|
||||
}
|
||||
|
||||
// Every switch on the page shares one choice; this keeps them in step.
|
||||
export function subscribeTheme(l: (c: ThemeChoice) => void): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
// Called once at startup: paints the right theme before React renders and
|
||||
|
||||