0.1.38 gateway
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 user’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user