0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+88
View File
@@ -0,0 +1,88 @@
import type { Tone } from '../lib/format';
/* Icons live here and nowhere else. Each is the `d` of one stroked path on a 24×24 grid,
so a page never carries SVG markup of its own and two screens showing the same idea
cannot draw it two ways. An unknown name draws nothing rather than a broken box — a
mark is decoration, and a typo in one must never be what an operator notices about a
page. */
export const icons = {
overview: 'M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z',
library: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4',
people:
'M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2',
person: 'M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
tv: 'M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5',
sliders: 'M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6',
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8',
pulse: 'M3 12h3.5L9 19l5-14 2.5 7H21',
chart: 'M4 19V9m5 10V5m5 14v-7m5 7V3',
chip: 'M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22',
database:
'M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6',
download: 'M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15',
upload: 'M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6',
sync: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
search: 'M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20',
star: 'm12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z',
sparkle:
'm10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z',
bell: 'M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0',
shield: 'm12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6',
wrench: 'm14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z',
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
list: 'M4 7h16M4 12h16M4 17h10',
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
check: 'm5 12.5 4.5 4.5L19 7.5',
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
captions: 'M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5',
journey: 'M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3',
plug: 'M9 3v6M15 3v6M6.5 9h11v3.5a5.5 5.5 0 0 1-11 0zM12 18v3',
calendar: 'M4 6h16v15H4zM8 3v5M16 3v5M4 11h16',
trash: 'M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13M10 11v5M14 11v5',
plus: 'M12 5v14M5 12h14',
close: 'M6 6l12 12M18 6 6 18',
caret: 'm6 9 6 6 6-6',
external: 'M14 4h6v6M20 4l-9 9M18 14v5.5H4.5V6H10',
refresh: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
filter: 'M3.5 5.5h17l-6.5 7.5V20l-4-2v-5L3.5 5.5Z',
menu: 'M4 7h16M4 12h16M4 17h16',
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.5 9h17M3.5 15h17M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18Z',
logout: 'M15 17l5-5-5-5M20 12H9M12 4H5v16h7',
} as const;
export type IconName = keyof typeof icons;
export function Icon({ name, className }: { name: IconName; className?: string }) {
const path = icons[name];
if (!path) return null;
return (
<svg
className={className ?? 'ico'}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
<path d={path} />
</svg>
);
}
/** Glyph is an icon in a tinted plate — the tile and card-heading mark.
*
* The tone is what says which area a thing belongs to, so it is passed rather than
* derived: the same idea wears the same colour on every page it appears on, which is
* most of what makes two dozen screens read as one console. */
export function Glyph({ name, tone }: { name: IconName; tone?: Tone }) {
if (!icons[name]) return null;
return (
<span className="glyph" data-tone={tone}>
<Icon name={name} />
</span>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
import { OmniSearch } from './OmniSearch';
import { NotificationBell } from './NotificationBell';
import { nav } from '../nav';
import { useNotifications } from '../lib/notifications';
import { useGateway } from '../lib/gateway';
import { time } from '../lib/format';
/* The shell: a top bar spanning the width, a rail down the left, and the page.
*
* Everything in the bar is true of the console rather than of any one screen — which
* gateway this is, whether it is answering, the page search, the activity bell and the way
* out. Everything in the rail is a destination. Nothing else lives in either. */
function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const { unread } = useNotifications();
const location = useLocation();
// A group holding the current page is open regardless of what was collapsed last time:
// a rail that hides the page you are on is a rail that has lost its place.
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
try {
return JSON.parse(localStorage.getItem('memby-admin-nav') ?? '{}') as Record<string, boolean>;
} catch {
return {};
}
});
const toggle = (id: string) => {
setCollapsed((current) => {
const next = { ...current, [id]: !current[id] };
try {
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
} catch {
// Private-mode storage refusals cost the memory of which groups were open, and
// nothing else.
}
return next;
});
};
return (
<nav className="rail" id="rail" data-open={open || undefined} aria-label="Console sections">
{nav.map((group) => {
const items = group.items.filter((item) => !item.hidden);
if (items.length === 0) return null;
const holdsCurrent = items.some((item) => location.pathname === item.path);
const expanded = holdsCurrent || !collapsed[group.id];
return (
<div className="rail-group" key={group.id}>
{group.label ? (
<button
type="button"
className="rail-head"
aria-expanded={expanded}
onClick={() => toggle(group.id)}
>
{group.label}
<Icon name="caret" className="ico caret" />
</button>
) : null}
{expanded
? items.map((item) => (
<NavLink
key={item.id}
to={item.path}
end={item.path === '/admin'}
onClick={onNavigate}
className={({ isActive }) => (isActive ? 'on' : '')}
aria-current={undefined}
>
{({ isActive }) => (
<span
style={{ display: 'contents' }}
// NavLink's own aria-current lands on the anchor; this mirrors it
// onto the element the stylesheet selects.
ref={(node) => {
const anchor = node?.parentElement;
if (anchor) {
if (isActive) anchor.setAttribute('aria-current', 'page');
else anchor.removeAttribute('aria-current');
}
}}
>
{item.icon ? <Icon name={item.icon} /> : null}
{item.label}
{item.badge === 'notifications' && unread > 0 ? (
<span className="rail-badge">{unread > 99 ? '99+' : unread}</span>
) : null}
</span>
)}
</NavLink>
))
: null}
</div>
);
})}
</nav>
);
}
export function Layout() {
const { version, online, checkedAt, status, setMaintenance } = useGateway();
const [railOpen, setRailOpen] = useState(false);
const [changingAvailability, setChangingAvailability] = useState(false);
const location = useLocation();
const offline = Boolean(status?.maintenance?.enabled);
const toggleAvailability = async () => {
if (changingAvailability || !status) return;
if (!offline && !window.confirm('Take Memby offline for every television?')) return;
setChangingAvailability(true);
try {
await setMaintenance(!offline);
} finally {
setChangingAvailability(false);
}
};
// The rail is an overlay on a narrow screen, so a navigation has to close it — otherwise
// it sits over the page that was just opened.
useEffect(() => setRailOpen(false), [location.pathname]);
return (
<>
<a className="skip" href="#main">
Skip to content
</a>
<header className="topbar">
<a className="topbar-brand" href="/admin">
<span className="brand-mark" aria-hidden="true">
M
</span>
<span className="brand-word">Memby Gateway</span>
</a>
<button
type="button"
className="rail-toggle"
aria-label="Sections"
aria-expanded={railOpen}
aria-controls="rail"
onClick={() => setRailOpen((current) => !current)}
>
<Icon name="menu" />
</button>
<div className="topbar-spacer" />
<div className="topbar-tools">
<OmniSearch />
<span className="topbar-version">gateway {version || 'unknown'}</span>
{/* This is both a live gateway verdict and its operator-controlled availability.
The switch is intentionally in the shared header: taking the household
offline should not require leaving the page where the operator noticed it. */}
<button
type="button"
className="topbar-status"
data-tone={online && !offline ? 'ok' : 'bad'}
aria-pressed={offline}
disabled={!status || changingAvailability}
title={offline ? 'Bring Memby back online' : 'Take Memby offline'}
onClick={() => void toggleAvailability()}
>
<span className="dot" />
<b>{offline ? 'offline' : online ? 'online' : 'not responding'}</b>
<span>{offline ? 'click to bring back online' : checkedAt ? `updated ${time(checkedAt)}` : ''}</span>
</button>
<NotificationBell />
<form method="post" action="/admin/logout">
<button type="submit" data-variant="quiet" data-size="sm" title="Sign out">
<Icon name="logout" />
</button>
</form>
</div>
</header>
<Rail open={railOpen} onNavigate={() => setRailOpen(false)} />
<main className="page" id="main">
<Outlet />
</main>
</>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { Glyph, Icon } from './Icon';
import { ago } from '../lib/format';
import { eventIcon, eventTone, useNotifications } from '../lib/notifications';
/* The bell in the top bar: rolling operational events, live.
*
* Opening it marks what it shows as read, rather than a "mark read" control per row.
* The rule is the one the television's own alerts follow in reverse — there, an alert is
* only marked seen once it has actually been drawn, because one composed behind an overlay
* was never seen. Here the panel *is* the drawing, so opening it is the receipt. */
const BADGE_MAX = 99;
export function NotificationBell() {
const { events, unread, connected, markRead, markAllRead } = useNotifications();
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKey);
};
}, []);
useEffect(() => {
if (!open) return;
const visible = events.filter((event) => !event.readAt).map((event) => event.id);
if (visible.length > 0) void markRead(visible);
// Deliberately keyed on `open` alone. Re-running it as events arrive would mark an
// event read the instant it landed while the panel happened to be open, which is the
// one case where the operator genuinely has not read it yet.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<div className="bell" ref={root} data-open={open || undefined}>
<button
type="button"
className="bell-button"
aria-label={unread > 0 ? `Activity, ${unread} unread` : 'Activity'}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<Icon name="bell" />
{unread > 0 ? (
<span className="bell-badge">{unread > BADGE_MAX ? `${BADGE_MAX}+` : unread}</span>
) : null}
</button>
{open ? (
<div className="bell-panel">
<div className="bell-head">
<b>Activity</b>
<div className="row tight">
{/* Whether the stream is attached is stated, because "nothing has happened"
and "we stopped being told" look identical otherwise. */}
{connected ? null : <span className="tag" data-tone="warn">reconnecting</span>}
{unread > 0 ? (
<button type="button" data-variant="quiet" data-size="sm" onClick={() => void markAllRead()}>
Mark all read
</button>
) : null}
</div>
</div>
<div className="bell-list">
{events.length === 0 ? (
<p className="empty">Nothing has happened yet.</p>
) : (
events.slice(0, 20).map((event) => {
const body = (
<>
<Glyph name={eventIcon(event.type)} tone={eventTone(event)} />
<span className="bell-body">
<b>{event.title || event.type}</b>
{event.summary ? <p>{event.summary}</p> : null}
<time dateTime={event.occurredAt}>{ago(event.occurredAt)}</time>
</span>
</>
);
return event.link ? (
<Link
key={event.id}
className="bell-item"
data-unread={!event.readAt || undefined}
to={event.link}
onClick={() => setOpen(false)}
>
{body}
</Link>
) : (
<div key={event.id} className="bell-item" data-unread={!event.readAt || undefined}>
{body}
</div>
);
})
)}
</div>
<div className="bell-foot">
<Link to="/admin/activity" onClick={() => setOpen(false)}>
All activity
</Link>
</div>
</div>
) : null}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Icon, type IconName } from './Icon';
import { searchableNavItems } from '../nav';
import { useGateway } from '../lib/gateway';
/* The rail is a menu; this is the same two dozen pages addressed by name.
*
* It exists because a rail seven groups deep is more than anybody reads down, and because
* it is the only thing on screen saying the console can be driven from the keyboard —
* which is why the shortcut is printed on the control rather than merely working. */
interface SearchItem {
id: string;
path: string;
label: string;
title: string;
intro: string;
group: string;
icon?: IconName;
}
interface Ranked {
item: SearchItem;
rank: number;
}
/* A page's own name outranks the sentence under it, or typing "se" answers with every page
whose description happens to contain those letters and buries Searches. Ties keep the
rail's order, so the list never reshuffles between two equally good matches. */
function score(item: SearchItem, query: string): number {
const label = item.label.toLowerCase();
const group = item.group.toLowerCase();
if (label.startsWith(query)) return 4;
if (label.includes(query)) return 3;
if (group.includes(query)) return 2;
return `${item.title} ${item.intro}`.toLowerCase().includes(query) ? 1 : 0;
}
export function OmniSearch() {
const navigate = useNavigate();
const { status } = useGateway();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [cursor, setCursor] = useState(0);
const root = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const results = useMemo<Ranked[]>(() => {
const needle = query.trim().toLowerCase();
const destinations: SearchItem[] = [
...searchableNavItems,
...(status?.requestUsers ?? []).map((user) => ({
id: `user-${user.id}`,
path: `/admin/accounts/${encodeURIComponent(user.id)}`,
label: user.username || 'Unnamed user',
title: `User: ${user.username || 'Unnamed user'}`,
intro: 'Open this users devices and settings.',
group: 'Users',
icon: 'people' as const,
})),
...(status?.clients ?? []).map((client) => ({
id: `device-${client.deviceId}`,
path: `/admin/devices/${encodeURIComponent(client.deviceId)}`,
label: client.deviceName || 'Unnamed device',
title: `Device: ${client.deviceName || 'Unnamed device'}`,
intro: `${client.username || 'Unknown user'} · ${client.version || 'unknown version'}`,
group: 'Devices',
icon: 'tv' as const,
})),
];
return destinations
.map((item, index) => ({ item, rank: needle ? score(item, needle) : 1, index }))
.filter((entry) => entry.rank > 0)
.sort((a, b) => b.rank - a.rank || a.index - b.index)
.map(({ item, rank }) => ({ item, rank }));
}, [query]);
// Something is always selected, so Enter has an answer without an arrow press first.
useEffect(() => setCursor(0), [query]);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, []);
useEffect(() => {
// Shift+S from anywhere, except from inside something being typed into — the console
// is full of fields, and a shortcut that ate a capital letter would be worse than none.
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'S' || !event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
const active = document.activeElement as HTMLElement | null;
if (active && (active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName))) return;
event.preventDefault();
input.current?.focus();
input.current?.select();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const go = (path: string) => {
setOpen(false);
setQuery('');
input.current?.blur();
navigate(path);
};
return (
<div className="omni" ref={root} data-open={open || undefined}>
<div className="omni-input">
<Icon name="search" />
<input
ref={input}
type="search"
value={query}
placeholder="Search pages, users and devices…"
aria-label="Search pages, users and devices"
aria-expanded={open}
onFocus={() => setOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setQuery('');
setOpen(false);
input.current?.blur();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
setCursor((current) => Math.min(current + 1, results.length - 1));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setCursor((current) => Math.max(current - 1, 0));
} else if (event.key === 'Enter') {
const target = results[cursor];
if (!target) return;
event.preventDefault();
go(target.item.path);
}
}}
/>
<span className="omni-key">S</span>
</div>
{open ? (
<div className="omni-panel" role="listbox">
{results.length === 0 ? (
<p className="empty">No pages, users or devices match that search.</p>
) : (
results.map((entry, index) => (
<a
key={entry.item.id}
className={index === cursor ? 'omni-item on' : 'omni-item'}
href={entry.item.path}
role="option"
aria-selected={index === cursor}
onPointerEnter={() => setCursor(index)}
onClick={(event) => {
event.preventDefault();
go(entry.item.path);
}}
>
{entry.item.icon ? <Icon name={entry.item.icon} /> : null}
<span>
<b>{entry.item.label}</b>
<small>{entry.item.intro}</small>
</span>
<span className="omni-group">{entry.item.group}</span>
</a>
))
)}
</div>
) : null}
</div>
);
}
+484
View File
@@ -0,0 +1,484 @@
import { type ReactNode, useEffect, useId, useRef, useState } from 'react';
import { Glyph, Icon, type IconName } from './Icon';
import type { Tone } from '../lib/format';
/* The component vocabulary. Every page is built from what is below and adds nothing of its
own: a screen that needs a look it cannot get from here is a missing component, not a
licence for a style attribute. */
/* ---------- structure ---------- */
export function PageHead({
title,
intro,
actions,
crumbs,
}: {
title: string;
intro?: string;
actions?: ReactNode;
crumbs?: ReactNode;
}) {
return (
<header className="page-head">
{crumbs ? <nav className="crumbs">{crumbs}</nav> : null}
<div className="page-head-row">
<div>
<h1>{title}</h1>
{intro ? <p>{intro}</p> : null}
</div>
{actions ? <div className="page-head-actions">{actions}</div> : null}
</div>
</header>
);
}
export function Card({
title,
intro,
icon,
tone,
actions,
footer,
children,
}: {
title?: string;
intro?: string;
icon?: IconName;
tone?: Tone;
actions?: ReactNode;
footer?: ReactNode;
children: ReactNode;
}) {
return (
<section className="card">
{title ? (
<div className="card-head">
{icon ? <Glyph name={icon} tone={tone} /> : null}
<div className="card-head-text">
<h2>{title}</h2>
{intro ? <p>{intro}</p> : null}
</div>
{actions ? <div className="card-head-actions">{actions}</div> : null}
</div>
) : null}
{children}
{footer ? <div className="card-foot">{footer}</div> : null}
</section>
);
}
export function Grid({ cols, children }: { cols?: '2' | 'wide'; children: ReactNode }) {
return (
<div className="grid" data-cols={cols}>
{children}
</div>
);
}
/* ---------- tiles ---------- */
export interface TileSpec {
label: string;
value: ReactNode;
/** small is for a value that is a sentence rather than a number. */
small?: boolean;
icon?: IconName;
tone?: Tone;
to?: string;
}
export function Tiles({ tiles }: { tiles: TileSpec[] }) {
return (
<div className="tiles">
{tiles.map((tile) => (
<div className="tile" key={tile.label}>
{tile.icon ? <Glyph name={tile.icon} tone={tile.tone} /> : null}
<b className={tile.small ? 'small' : undefined}>{tile.value}</b>
<span>{tile.label}</span>
</div>
))}
</div>
);
}
/* ---------- verdicts ---------- */
export function Tag({ children, tone }: { children: ReactNode; tone?: Tone }) {
return (
<span className="tag" data-tone={tone}>
{children}
</span>
);
}
export function Chip({ children, tone }: { children: ReactNode; tone?: Tone }) {
return (
<span className="chip" data-tone={tone}>
{children}
</span>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <p className="empty">{children}</p>;
}
export function EmptyRow({ columns, children }: { columns: number; children: ReactNode }) {
return (
<tr>
<td colSpan={columns} className="muted">
<p className="empty">{children}</p>
</td>
</tr>
);
}
export function Note({ children, tone }: { children: ReactNode; tone?: Tone }) {
return (
<p className="note" data-tone={tone}>
{children}
</p>
);
}
/* ---------- controls ---------- */
export function Button({
children,
onClick,
variant,
size,
disabled,
busy,
icon,
type = 'button',
title,
}: {
children?: ReactNode;
onClick?: () => void;
variant?: 'primary' | 'danger' | 'quiet';
size?: 'sm';
disabled?: boolean;
busy?: boolean;
icon?: IconName;
type?: 'button' | 'submit';
title?: string;
}) {
return (
<button
type={type}
className=""
data-variant={variant}
data-size={size}
disabled={disabled || busy}
onClick={onClick}
title={title}
>
{busy ? <span className="spinner" /> : icon ? <Icon name={icon} /> : null}
{children}
</button>
);
}
export function Field({
label,
hint,
children,
grow,
}: {
label: string;
hint?: string;
children: ReactNode;
grow?: boolean;
}) {
return (
<label className={grow ? 'field grow' : 'field'}>
<span>{label}</span>
{children}
{hint ? <small>{hint}</small> : null}
</label>
);
}
/** Toggle is the console's on/off control. A switch rather than a checkbox: the console is
* full of policy that is on or off, and a control saying which way it points from across
* the room is worth the extra markup. */
export function Toggle({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
}) {
return (
<label className="check">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span className="switch" />
<span className="check-body">
<b>{label}</b>
{hint ? <p>{hint}</p> : null}
</span>
</label>
);
}
/** Segments is a row of exclusive choices — a date window, a severity — where a select
* would hide the options behind a click. */
export function Segments<T extends string | number>({
value,
options,
onChange,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (next: T) => void;
}) {
return (
<div className="segments" role="group">
{options.map((option) => (
<button
key={String(option.value)}
type="button"
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
/* ---------- tables ---------- */
export function TableWrap({ children }: { children: ReactNode }) {
return <div className="table-wrap">{children}</div>;
}
/** SortHeader is a column header that sorts. It is a button so it is reachable from a
* keyboard, and it reports aria-sort so a screen reader can say which way. */
export function SortHeader<K extends string>({
label,
column,
sort,
onSort,
numeric,
}: {
label: string;
column: K;
sort: { key: K | null; direction: 'asc' | 'desc' };
onSort: (key: K) => void;
numeric?: boolean;
}) {
const active = sort.key === column;
return (
<th
className={numeric ? 'num' : undefined}
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
>
<button type="button" onClick={() => onSort(column)}>
{label}
{active ? <span aria-hidden="true">{sort.direction === 'asc' ? '↑' : '↓'}</span> : null}
</button>
</th>
);
}
/** useSort is the state behind SortHeader: pressing the active column reverses it,
* pressing another switches to it descending — the useful default, since every numeric
* column here is read biggest-first. */
export function useSort<K extends string>(initial: K | null = null) {
const [sort, setSort] = useState<{ key: K | null; direction: 'asc' | 'desc' }>({
key: initial,
direction: 'desc',
});
const onSort = (key: K) =>
setSort((current) =>
current.key === key
? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'desc' },
);
return { sort, onSort };
}
/* ---------- charts ---------- */
/** Bars is the console's one chart: a day-by-day count, drawn with divs.
*
* A charting library would be the single largest dependency here for the sake of four
* bar charts, and this console's whole stance is that it fetches nothing from anywhere. */
export function Bars({
data,
labelOf,
valueOf,
toneOf,
title,
}: {
data: readonly unknown[];
labelOf: (row: never, index: number) => string;
valueOf: (row: never) => number;
toneOf?: (row: never) => Tone | undefined;
title?: (row: never) => string;
}) {
if (data.length === 0) return <Empty>Nothing in this window.</Empty>;
const values = data.map((row) => valueOf(row as never));
const peak = Math.max(1, ...values);
return (
<>
<div className="bars">
{data.map((row, index) => {
const value = valueOf(row as never);
return (
<div
key={index}
className="bar"
data-tone={toneOf?.(row as never)}
data-empty={value === 0 || undefined}
style={{ height: `${Math.max(2, (value / peak) * 100)}%` }}
title={title ? title(row as never) : `${labelOf(row as never, index)}: ${value}`}
/>
);
})}
</div>
<div className="bars-axis">
<span>{labelOf(data[0] as never, 0)}</span>
<span>{labelOf(data[data.length - 1] as never, data.length - 1)}</span>
</div>
</>
);
}
export function Meter({ value, total, tone }: { value: number; total: number; tone?: Tone }) {
const share = total > 0 ? Math.min(1, value / total) : 0;
return (
<div className="meter" data-tone={tone}>
<div style={{ width: `${share * 100}%` }} />
</div>
);
}
/* ---------- feedback ---------- */
export function Banner({ message, onDismiss }: { message: string; onDismiss?: () => void }) {
if (!message) return null;
return (
<div className="banner" role="alert">
<Icon name="alert" />
<span>{message}</span>
{onDismiss ? (
<button type="button" onClick={onDismiss} aria-label="Dismiss">
<Icon name="close" />
</button>
) : null}
</div>
);
}
/** Loading is the page's own first-load state: a shape rather than a spinner, so the
* layout does not jump when the data lands. */
export function Loading({ rows = 3 }: { rows?: number }) {
return (
<div className="loading-page" aria-busy="true" aria-label="Loading">
<div className="loading-heading"><span className="skeleton" /><span className="skeleton" /></div>
<div className="loading-tiles">{Array.from({ length: 4 }, (_, index) => <span key={index} className="skeleton" />)}</div>
{Array.from({ length: rows }, (_, index) => <section key={index} className="loading-card"><span className="skeleton" /><span className="skeleton" /><span className="skeleton" /></section>)}
</div>
);
}
/* ---------- dialog ---------- */
/** Confirm is the one destructive-action guard.
*
* Every irreversible button in the console goes through it rather than through the
* browser's own confirm(), which cannot say *what* is about to happen in more than one
* line and cannot be styled to look like it belongs to the page it interrupts. */
export function Confirm({
title,
body,
confirmLabel = 'Confirm',
destructive,
busy,
onConfirm,
onCancel,
}: {
title: string;
body: ReactNode;
confirmLabel?: string;
destructive?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const headingId = useId();
const first = useRef<HTMLDivElement>(null);
useEffect(() => {
first.current?.focus();
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby={headingId} ref={first} tabIndex={-1}>
<h2 id={headingId}>{title}</h2>
<p>{body}</p>
<div className="dialog-actions">
<Button onClick={onCancel} variant="quiet">
Cancel
</Button>
<Button onClick={onConfirm} variant={destructive ? 'danger' : 'primary'} busy={busy}>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}
/* ---------- key/value ---------- */
/** KeyValue is a label and a verdict: the console's densest way of stating a handful of
* facts about one subject, and what the overview is built from. */
export function KeyValue({ rows }: { rows: { label: string; value: ReactNode }[] }) {
return (
<div className="kv">
{rows.map((row) => (
<div className="kv-row" key={row.label}>
<span>{row.label}</span>
<span>{row.value}</span>
</div>
))}
</div>
);
}
/** PlainTiles is a tile strip with no card around it, for a group of numbers inside one. */
export function PlainTiles({ tiles }: { tiles: TileSpec[] }) {
return (
<div className="tiles plain">
{tiles.map((tile) => (
<div className="tile" key={tile.label}>
<b className={tile.small ? 'small' : undefined}>{tile.value}</b>
<span>{tile.label}</span>
</div>
))}
</div>
);
}