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
</>
);
}