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 type { ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Activity, ClipboardList, LayoutDashboard, LogOut, Settings, Shield, Users, Wifi, WifiOff } from "lucide-react";
|
import { Activity, ClipboardList, LayoutDashboard, Settings, Users } from "lucide-react";
|
||||||
import { useAuth, useLive } from "../state";
|
import { useLive } from "../state";
|
||||||
import { Mark } from "./Mark";
|
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.
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
const groups = [
|
||||||
{ href: "/peers", label: "Peers", icon: Activity },
|
{
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
label: "Overview",
|
||||||
{ href: "/users", label: "Users", icon: Users },
|
items: [
|
||||||
{ href: "/audit", label: "Audit log", icon: ClipboardList },
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/account", label: "Account", icon: Shield },
|
{ 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 }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { me, signOut } = useAuth();
|
|
||||||
const { connected } = useLive();
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="sidebar">
|
{/* Phones and narrow windows: brand and account controls up top. */}
|
||||||
<div className="brand">
|
<header className="topbar">
|
||||||
<div className="brand-mark">
|
<Link href="/" className="brand" aria-label="WGX dashboard">
|
||||||
<Mark size={34} />
|
<Mark size={30} />
|
||||||
</div>
|
<span className="brand-name">WGX</span>
|
||||||
<div>
|
</Link>
|
||||||
<div className="brand-name">WGX</div>
|
<div className="topbar-right">
|
||||||
<div className="brand-sub">WireGuard server</div>
|
<LivePill />
|
||||||
</div>
|
<ThemeToggle />
|
||||||
|
<UserMenu placement="down" />
|
||||||
</div>
|
</div>
|
||||||
<nav className="nav">
|
</header>
|
||||||
{items.map((it) => {
|
|
||||||
const active = it.href === "/" ? location === "/" : location.startsWith(it.href);
|
{/* Desktop: everything lives in the sidebar. */}
|
||||||
const Icon = it.icon;
|
<aside className="sidebar">
|
||||||
return (
|
<Link href="/" className="brand" aria-label="WGX dashboard">
|
||||||
<Link key={it.href} href={it.href} className={active ? "active" : ""}>
|
<Mark size={34} />
|
||||||
<Icon />
|
<span>
|
||||||
<span>{it.label}</span>
|
<span className="brand-name">WGX</span>
|
||||||
</Link>
|
<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>
|
</nav>
|
||||||
<div className="sidebar-foot">
|
<div className="sidebar-foot">
|
||||||
<ThemeSwitch compact />
|
<div className="sidebar-tools">
|
||||||
<div title={connected ? "Live updates connected" : "Live updates reconnecting"} className="nowrap">
|
<LivePill />
|
||||||
{connected ? <Wifi size={13} style={{ verticalAlign: -2, color: "var(--ok)" }} /> : <WifiOff size={13} style={{ verticalAlign: -2, color: "var(--warn)" }} />} {connected ? "live" : "reconnecting"}
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<div className="nowrap">
|
<UserMenu placement="up" showName />
|
||||||
{me?.username} <span className="faint">({me?.role})</span>
|
<a className="source" href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
|
||||||
</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
|
AGPL-3.0 source
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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>
|
</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
|
// 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
|
// 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 }) {
|
export function Mark({ size = 32 }: { size?: number }) {
|
||||||
|
const id = useId();
|
||||||
|
const shield = `${id}-shield`;
|
||||||
|
const lock = `${id}-lock`;
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 128 128" role="img" aria-label="WGX">
|
<svg width={size} height={size} viewBox="0 0 128 128" role="img" aria-label="WGX">
|
||||||
<defs>
|
<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="0" stopColor="#26332e" />
|
||||||
<stop offset="1" stopColor="#121a17" />
|
<stop offset="1" stopColor="#121a17" />
|
||||||
</linearGradient>
|
</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="0" stopColor="#3fae90" />
|
||||||
<stop offset="1" stopColor="#2b8f75" />
|
<stop offset="1" stopColor="#2b8f75" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</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="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" />
|
<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" />
|
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Monitor, Moon, Sun } from "lucide-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 }[] = [
|
const options: { value: ThemeChoice; label: string; icon: typeof Moon }[] = [
|
||||||
{ value: "dark", label: "Dark", icon: 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 },
|
{ value: "system", label: "System", icon: Monitor },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dark / Light / System. `compact` shows icons only, for the sidebar.
|
function useThemeChoice(): [ThemeChoice, (v: ThemeChoice) => void] {
|
||||||
export function ThemeSwitch({ compact = false }: { compact?: boolean }) {
|
|
||||||
const [choice, setChoice] = useState<ThemeChoice>(getThemeChoice);
|
const [choice, setChoice] = useState<ThemeChoice>(getThemeChoice);
|
||||||
const pick = (v: ThemeChoice) => {
|
useEffect(() => subscribeTheme(setChoice), []);
|
||||||
setThemeChoice(v);
|
return [choice, setThemeChoice];
|
||||||
setChoice(v);
|
}
|
||||||
};
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="segmented" role="radiogroup" aria-label="Theme">
|
<div className="segmented" role="radiogroup" aria-label="Theme">
|
||||||
{options.map((o) => {
|
{options.map((o) => {
|
||||||
const Icon = o.icon;
|
const Icon = o.icon;
|
||||||
return (
|
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)}>
|
<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)}>
|
||||||
<Icon size={14} style={{ verticalAlign: -2 }} />
|
{icons && <Icon size={14} style={{ verticalAlign: -2 }} />}
|
||||||
{!compact && <span style={{ marginLeft: 6 }}>{o.label}</span>}
|
{!compact && <span style={{ marginLeft: icons ? 6 : 0 }}>{o.label}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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" />
|
<Stat label="Sent" value={bytes(totals?.tx ?? 0)} sub="to peers, all time" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-2 mt" style={{ gridTemplateColumns: "2fr 1fr" }}>
|
<div className="grid grid-main mt">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>Traffic</h2>
|
<h2>Traffic</h2>
|
||||||
@@ -175,8 +175,8 @@ export function Dashboard() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Peer</th>
|
<th>Peer</th>
|
||||||
<th>Address</th>
|
<th>Address</th>
|
||||||
<th>Endpoint</th>
|
<th className="hide-md">Endpoint</th>
|
||||||
<th>Session</th>
|
<th className="hide-sm">Session</th>
|
||||||
<th>Handshake</th>
|
<th>Handshake</th>
|
||||||
<th className="right">Rate</th>
|
<th className="right">Rate</th>
|
||||||
<th className="right">Transfer</th>
|
<th className="right">Transfer</th>
|
||||||
@@ -192,8 +192,8 @@ export function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">{p.ipv4}</td>
|
<td className="mono">{p.ipv4}</td>
|
||||||
<td className="mono">{l.endpoint ?? "—"}</td>
|
<td className="mono hide-md">{l.endpoint ?? "—"}</td>
|
||||||
<td>{duration(l.connectedSince, now)}</td>
|
<td className="hide-sm">{duration(l.connectedSince, now)}</td>
|
||||||
<td>{ago(l.lastHandshake, now)}</td>
|
<td>{ago(l.lastHandshake, now)}</td>
|
||||||
<td className="num right">
|
<td className="num right">
|
||||||
↓ {rate(l.rxRate)} · ↑ {rate(l.txRate)}
|
↓ {rate(l.rxRate)} · ↑ {rate(l.txRate)}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function Peers() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar">
|
<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)" }} />
|
<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)} />
|
<input className="input search" style={{ paddingLeft: 28 }} placeholder="Search name, address, key…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -128,10 +128,10 @@ export function Peers() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Peer</th>
|
<th>Peer</th>
|
||||||
<th>Address</th>
|
<th>Address</th>
|
||||||
<th>Endpoint</th>
|
<th className="hide-md">Endpoint</th>
|
||||||
<th>Handshake</th>
|
<th>Handshake</th>
|
||||||
<th className="right">Rate</th>
|
<th className="right hide-sm">Rate</th>
|
||||||
<th></th>
|
<th className="hide-md"></th>
|
||||||
<th className="right">Transfer</th>
|
<th className="right">Transfer</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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>}
|
{!p.serverKeys && <span className="badge" style={{ marginLeft: 8 }} title="The client holds its own private key">client key</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono nowrap">{p.ipv4}</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="nowrap">{ago(l.lastHandshake, now)}</td>
|
||||||
<td className="num right">{l.connected ? `↓ ${rate(l.rxRate)} ↑ ${rate(l.txRate)}` : <span className="faint">—</span>}</td>
|
<td className="num right hide-sm">{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="hide-md">{l.connected && <Sparkline values={history.current.get(p.id) ?? []} />}</td>
|
||||||
<td className="num right">
|
<td className="num right">
|
||||||
{bytes(l.rx)} <span className="faint">/</span> {bytes(l.tx)}
|
{bytes(l.rx)} <span className="faint">/</span> {bytes(l.tx)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
--rx: #5f9de0;
|
--rx: #5f9de0;
|
||||||
--tx: #3fae90;
|
--tx: #3fae90;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
|
--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;
|
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -76,44 +76,182 @@ p { margin: 0 0 8px; }
|
|||||||
button, input, select, textarea { font: inherit; color: inherit; }
|
button, input, select, textarea { font: inherit; color: inherit; }
|
||||||
::selection { background: var(--accent-soft); }
|
::selection { background: var(--accent-soft); }
|
||||||
|
|
||||||
/* Layout */
|
/* Layout: a sidebar from 900px up; below that a top bar and a bottom tab
|
||||||
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100%; }
|
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 {
|
.sidebar {
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
padding: 16px 12px;
|
padding: 14px 12px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 16px; }
|
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px; color: var(--fg); line-height: 1.15; }
|
||||||
.brand-mark { display: grid; place-items: center; flex: none; line-height: 0; }
|
.brand:hover { text-decoration: none; }
|
||||||
.brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
|
.brand svg { flex: none; display: block; }
|
||||||
.brand-sub { font-size: 11px; color: var(--fg-muted); }
|
.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 {
|
.nav a {
|
||||||
|
position: relative;
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 10px; border-radius: 8px; color: var(--fg-muted); font-weight: 500;
|
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:hover { background: var(--bg-sunken); text-decoration: none; color: var(--fg); }
|
||||||
.nav a.active { background: var(--accent-soft); color: var(--accent); }
|
.nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
.nav a svg { width: 17px; height: 17px; }
|
.nav a.active::before { content: ""; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
|
||||||
.sidebar-foot { margin-top: auto; padding: 8px; font-size: 12px; color: var(--fg-muted); display: flex; flex-direction: column; gap: 6px; }
|
.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; }
|
.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 { 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; }
|
.page-head p { color: var(--fg-muted); margin: 2px 0 0; }
|
||||||
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
/* Top bar and tab bar exist only on narrow screens. */
|
||||||
.shell { grid-template-columns: 1fr; }
|
.topbar { display: none; }
|
||||||
.sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); }
|
.tabbar { display: none; }
|
||||||
.brand { padding: 4px 8px; }
|
|
||||||
.nav { display: flex; flex-wrap: wrap; gap: 2px; }
|
/* Shared chrome pieces */
|
||||||
.nav a span { display: none; }
|
.icon-btn {
|
||||||
.sidebar-foot { margin-top: 0; margin-left: auto; }
|
display: inline-grid; place-items: center; width: 36px; height: 36px; flex: none;
|
||||||
.main { padding: 16px; }
|
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 */
|
/* 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: 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; } }
|
@media (max-width: 640px) { .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; } }
|
||||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
.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 { padding: 14px 16px; }
|
||||||
.stat-label { font-size: 12px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
.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 { width: 100%; max-width: 640px; max-height: calc(100vh - 40px); overflow: auto; }
|
||||||
.modal.wide { max-width: 860px; }
|
.modal.wide { max-width: 860px; }
|
||||||
.modal .card-head h2 { font-size: 16px; }
|
.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 */
|
||||||
.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 { 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; }
|
.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 */
|
/* Peer detail */
|
||||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; }
|
.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; }
|
.nowrap { white-space: nowrap; }
|
||||||
.mt { margin-top: 12px; }
|
.mt { margin-top: 12px; }
|
||||||
.recovery { columns: 2; font-family: var(--mono); font-size: 14px; }
|
.recovery { columns: 2; font-family: var(--mono); font-size: 14px; }
|
||||||
|
@media (max-width: 480px) { .recovery { columns: 1; } }
|
||||||
.recovery li { margin: 4px 0; }
|
.recovery li { margin: 4px 0; }
|
||||||
.search { max-width: 280px; }
|
.search { max-width: 280px; }
|
||||||
.segmented { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 8px; overflow: hidden; }
|
.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 KEY = "wgx.theme";
|
||||||
const media = window.matchMedia("(prefers-color-scheme: light)");
|
const media = window.matchMedia("(prefers-color-scheme: light)");
|
||||||
|
const listeners = new Set<(c: ThemeChoice) => void>();
|
||||||
|
|
||||||
export function getThemeChoice(): ThemeChoice {
|
export function getThemeChoice(): ThemeChoice {
|
||||||
try {
|
try {
|
||||||
@@ -17,13 +18,17 @@ export function getThemeChoice(): ThemeChoice {
|
|||||||
return "dark";
|
return "dark";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(choice: ThemeChoice): "dark" | "light" {
|
export function resolveTheme(choice: ThemeChoice): "dark" | "light" {
|
||||||
if (choice === "system") return media.matches ? "light" : "dark";
|
if (choice === "system") return media.matches ? "light" : "dark";
|
||||||
return choice;
|
return choice;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyTheme(choice: ThemeChoice) {
|
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) {
|
export function setThemeChoice(choice: ThemeChoice) {
|
||||||
@@ -33,6 +38,15 @@ export function setThemeChoice(choice: ThemeChoice) {
|
|||||||
/* storage unavailable */
|
/* storage unavailable */
|
||||||
}
|
}
|
||||||
applyTheme(choice);
|
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
|
// Called once at startup: paints the right theme before React renders and
|
||||||
|
|||||||