Files
memby/admin-ui/src/components/Layout.tsx
T

203 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-08-14 09:40:03 +12:00
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';
2026-08-14 11:47:32 +12:00
import { Confirm } from './ui';
2026-08-14 09:40:03 +12:00
/* 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);
2026-08-14 11:47:32 +12:00
const [confirmingOffline, setConfirmingOffline] = useState(false);
2026-08-14 09:40:03 +12:00
const location = useLocation();
const offline = Boolean(status?.maintenance?.enabled);
const toggleAvailability = async () => {
if (changingAvailability || !status) 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'}
2026-08-14 11:47:32 +12:00
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
2026-08-14 09:40:03 +12:00
>
<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>
2026-08-14 11:47:32 +12:00
{confirmingOffline ? (
<Confirm
title="Take Memby offline?"
body="Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available."
confirmLabel="Go offline"
destructive
busy={changingAvailability}
onConfirm={() => {
setConfirmingOffline(false);
void toggleAvailability();
}}
onCancel={() => setConfirmingOffline(false)}
/>
) : null}
2026-08-14 09:40:03 +12:00
</>
);
}