Files
ITWorx-Pulse-Public/apps/web/src/AppShell.tsx
T
2026-09-30 01:14:48 +00:00

163 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { copy } from './copy';
import { OperatorCommandBar } from './OperatorCommandBar';
import { routeFromLocation, type RoutePath } from './routes';
import {
WORKSPACE_EVENT,
isFavorite,
readWorkspaceState,
recordRecent,
removeSavedView,
saveCurrentView,
toggleFavorite,
} from './operatorWorkspace';
type SectionId = 'command' | 'infrastructure' | 'workloads' | 'services' | 'response' | 'manage';
type NavigationDefinition = {
path: RoutePath;
label: string;
icon: string;
group: string;
section: SectionId;
primary: boolean;
};
export const navigation = [
{ path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command, section: 'command', primary: true },
{ path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command, section: 'command', primary: true },
{ path: '/explore', label: copy.navigation.explore, icon: '⌁', group: copy.navigation.groups.command, section: 'command', primary: true },
{ path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true },
{ path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
{ path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true },
{ path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
{ path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
{ path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
{ path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
{ path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
{ path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
{ path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads, section: 'workloads', primary: true },
{ path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads, section: 'workloads', primary: false },
{ path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services, section: 'services', primary: true },
{ path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services, section: 'services', primary: false },
{ path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response, section: 'response', primary: true },
{ path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response, section: 'response', primary: false },
{ path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response, section: 'response', primary: false },
{ path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage, section: 'manage', primary: true },
{ path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage, section: 'manage', primary: true },
{ path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage, section: 'manage', primary: false },
{ path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage, section: 'manage', primary: false },
{ path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage, section: 'manage', primary: false },
] as const satisfies ReadonlyArray<NavigationDefinition>;
export type NavigationItem = (typeof navigation)[number];
const sectionLabels: Record<SectionId, string> = {
command: 'Operator',
infrastructure: 'Infrastructuur',
workloads: 'Workloads',
services: 'Services',
response: 'Response',
manage: 'Beheer',
};
const primaryNavigation = navigation.filter((item) => item.primary);
const mobilePrimaryOrder = new Map<RoutePath, number>([['/', 0], ['/incidents', 1], ['/containers', 2], ['/storage', 3], ['/services', 4]]);
const mobilePrimaryNavigation = navigation.filter((item) => mobilePrimaryOrder.has(item.path)).sort((left, right) => mobilePrimaryOrder.get(left.path)! - mobilePrimaryOrder.get(right.path)!);
const mobileMoreNavigation = navigation.filter((item) => !mobilePrimaryOrder.has(item.path));
const navigationGroups = Array.from(new Set(primaryNavigation.map((item) => item.group)));
function matchesRoute(route: RoutePath, path: RoutePath): boolean {
return route === path || (path !== '/' && route.startsWith(path + '/'));
}
function routeDefinition(route: RoutePath): NavigationItem {
return [...navigation]
.sort((left, right) => right.path.length - left.path.length)
.find((item) => matchesRoute(route, item.path)) ?? navigation[0];
}
function detailSegment(route: RoutePath, item: NavigationItem): string | null {
if (route === item.path) return null;
const raw = route.slice(item.path.length + 1).split('/')[0];
if (!raw) return null;
try { return decodeURIComponent(raw).slice(0, 80); } catch { return raw.slice(0, 80); }
}
export function navigate(path: string): void {
const from = window.location.pathname + window.location.search;
if (path === from) return;
window.history.pushState({ pulseFrom: from }, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function NavigationLink({ item, label = item.label, domain = false }: { item: NavigationItem; label?: string; domain?: boolean }) {
const active = matchesRoute(routeFromLocation(window.location.pathname), item.path);
return <li><a className={(active ? 'nav-link nav-link--active' : 'nav-link') + (domain ? ' nav-link--domain' : '')} aria-current={active ? 'page' : undefined} href={item.path} title={label} onClick={(event) => { event.preventDefault(); navigate(item.path); }}><span className="nav-icon" aria-hidden="true">{item.icon}</span><span>{label}</span></a></li>;
}
function DesktopNavigation({ route }: { route: RoutePath }) {
const activeItem = routeDefinition(route);
const activeGroup = activeItem.group;
const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set([navigationGroups[0], activeGroup]));
useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
return <nav className="desktop-navigation" aria-label={copy.navigation.label}>{navigationGroups.map((group) => {
const items = primaryNavigation.filter((item) => item.group === group);
return <details className="nav-group" key={group} open={openGroups.has(group)} onToggle={(event) => { const open = event.currentTarget.open; setOpenGroups((current) => { if (current.has(group) === open) return current; const next = new Set(current); if (open) next.add(group); else next.delete(group); return next; }); }}><summary><span>{group}</span><span aria-hidden="true">⌄</span></summary><ul className="nav-list">{items.map((item) => <NavigationLink key={item.path} item={item} domain />)}</ul></details>;
})}</nav>;
}
function ShellStatusBadge({ label, tone }: { label: string; tone: 'unknown' | 'ready' }) {
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : '?'}</span>{label}</span>;
}
function WorkspaceContext({ route, currentNavigation }: { route: RoutePath; currentNavigation: NavigationItem }) {
const [, setVersion] = useState(0);
useEffect(() => {
const refresh = () => setVersion((value) => value + 1);
window.addEventListener(WORKSPACE_EVENT, refresh);
return () => window.removeEventListener(WORKSPACE_EVENT, refresh);
}, []);
const item = routeDefinition(route);
const sectionItems = navigation.filter((entry) => entry.section === item.section && entry.path !== '/wallboard');
const detail = detailSegment(route, item);
const currentPath = window.location.pathname + window.location.search;
const favorite = isFavorite(currentPath);
const saved = readWorkspaceState().views.some((entry) => entry.path === currentPath);
const from = typeof window.history.state?.pulseFrom === 'string' ? window.history.state.pulseFrom as string : '';
const fallback = sectionItems.find((entry) => entry.primary)?.path ?? '/';
const backTarget = from && from !== currentPath ? from : (route !== fallback ? fallback : '');
const pageLabel = detail ? currentNavigation.label + ' · ' + detail : currentNavigation.label;
useEffect(() => { recordRecent(pageLabel, currentPath); }, [pageLabel, currentPath]);
return <div className="workspace-context" aria-label="Werkcontext">
<div className="workspace-context-main">
{backTarget && <button className="workspace-back" type="button" onClick={() => navigate(backTarget)} aria-label="Terug naar vorige context"><span aria-hidden="true">←</span><span>Terug</span></button>}
<nav className="workspace-breadcrumbs" aria-label="Breadcrumb">
<a href="/" onClick={(event) => { event.preventDefault(); navigate('/'); }}>Pulse</a><span aria-hidden="true">/</span>
<button type="button" onClick={() => navigate(sectionItems.find((entry) => entry.primary)?.path ?? '/')}>{sectionLabels[item.section]}</button><span aria-hidden="true">/</span>
<strong>{pageLabel}</strong>
</nav>
</div>
<div className="workspace-actions">
<button className={'workspace-action' + (favorite ? ' workspace-action--active' : '')} type="button" aria-pressed={favorite} onClick={() => toggleFavorite(pageLabel, currentPath)} title={favorite ? 'Verwijder uit favorieten' : 'Voeg toe aan favorieten'}><span aria-hidden="true">☆</span><span>{favorite ? 'Favoriet' : 'Favoriet'}</span></button>
<button className={'workspace-action' + (saved ? ' workspace-action--active' : '')} type="button" aria-pressed={saved} onClick={() => saved ? removeSavedView(currentPath) : saveCurrentView(pageLabel, currentPath)} title={saved ? 'Verwijder opgeslagen view' : 'Bewaar huidige filters en view'}><span aria-hidden="true">▣</span><span>{saved ? 'View bewaard' : 'Bewaar view'}</span></button>
</div>
<nav className="workspace-subnav" aria-label={sectionLabels[item.section] + ' navigatie'}>{sectionItems.map((entry) => {
const active = matchesRoute(route, entry.path);
return <a key={entry.path} href={entry.path} aria-current={active ? 'page' : undefined} onClick={(event) => { event.preventDefault(); navigate(entry.path); }}>{entry.label}</a>;
})}</nav>
</div>;
}
export function OperatorAppShell({ route, shellStatus, currentNavigation, children }: { route: RoutePath; shellStatus: { label: string; tone: 'unknown' | 'ready'; detail: string }; currentNavigation: NavigationItem; children: ReactNode }) {
const resolved = useMemo(() => routeDefinition(route), [route]);
return <div className="app-shell"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><aside className="sidebar"><a className="brand" href="/" title={copy.brand.name} onClick={(event) => { event.preventDefault(); navigate('/'); }}><span className="brand-mark" aria-hidden="true"><span>P</span></span><span className="brand-copy"><strong>{copy.brand.name}</strong><small>{copy.brand.context}</small></span></a><DesktopNavigation route={route} /><nav className="mobile-navigation" aria-label={copy.navigation.label}><ul className="nav-list mobile-primary-list">{mobilePrimaryNavigation.map((item) => <NavigationLink key={item.path} item={item} label={item.path === '/storage' ? copy.navigation.mobileStorage : item.label} />)}</ul><details className="mobile-more" open={mobileMoreNavigation.some((item) => matchesRoute(route, item.path))}><summary>{copy.navigation.more}</summary><ul className="nav-list">{mobileMoreNavigation.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details></nav><div className="sidebar-status" title={shellStatus.detail}><ShellStatusBadge label={shellStatus.label} tone={shellStatus.tone} /><span>{shellStatus.detail}</span></div></aside><div className="app-workspace"><OperatorCommandBar routeLabel={route === '/404' ? copy.notFound.context : currentNavigation.label} navigation={navigation} onNavigate={navigate} /><WorkspaceContext route={route} currentNavigation={resolved} /><main id="main-content" className="content">{children}</main></div></div>;
}