203 lines
7.5 KiB
TypeScript
203 lines
7.5 KiB
TypeScript
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';
|
|
import { Confirm } from './ui';
|
|
|
|
/* 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 [confirmingOffline, setConfirmingOffline] = useState(false);
|
|
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'}
|
|
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
|
|
>
|
|
<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>
|
|
{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}
|
|
</>
|
|
);
|
|
}
|