689 lines
29 KiB
TypeScript
689 lines
29 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import {
|
|
AppBar,
|
|
Avatar,
|
|
Badge,
|
|
Box,
|
|
Breadcrumbs,
|
|
Button,
|
|
ButtonGroup,
|
|
Chip,
|
|
Divider,
|
|
Drawer,
|
|
IconButton,
|
|
List,
|
|
ListItem,
|
|
ListItemButton,
|
|
ListItemIcon,
|
|
ListItemText,
|
|
ListSubheader,
|
|
Menu,
|
|
MenuItem,
|
|
Toolbar,
|
|
Tooltip,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
import type { Theme } from "@mui/material/styles";
|
|
|
|
import MenuIcon from "@mui/icons-material/Menu";
|
|
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
|
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
|
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
|
|
|
import JobbjaktMark from "../assets/JobbjaktMark";
|
|
import { PageHeader, type PageWidth } from "./PageLayout";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
|
|
export type NavItem = {
|
|
to: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
hidden?: boolean;
|
|
section?: string;
|
|
badgeCount?: number;
|
|
children?: NavItem[];
|
|
activeFor?: string[];
|
|
};
|
|
|
|
function initialsFrom(s?: string) {
|
|
const v = (s ?? "").trim();
|
|
if (!v) return "?";
|
|
const parts = v.split(/[\s@._-]+/).filter(Boolean);
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
}
|
|
|
|
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
|
|
|
|
// Which single nav item owns the current path. A child route must not light up its parent:
|
|
// /career/builder/5 belongs to "CV Builder" (/career/builder), not "Career Workspace" (/career),
|
|
// even though the old `pathname.startsWith(to + "/")` test matched both. Explicit ownership =
|
|
// the LONGEST `to` that the path is at or under wins; everything else is inactive. Exported so the
|
|
// rule is unit-testable without rendering the shell.
|
|
export function activeNavTo(pathname: string, tos: string[]): string | null {
|
|
let best: string | null = null;
|
|
for (const to of tos) {
|
|
const owns = pathname === to || pathname.startsWith(to + "/");
|
|
if (owns && (best === null || to.length > best.length)) best = to;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export function activeNavItemTo(pathname: string, items: NavItem[]): string | null {
|
|
const matches: Array<{ to: string; matchLength: number }> = [];
|
|
const visit = (item: NavItem) => {
|
|
for (const candidate of [item.to, ...(item.activeFor ?? [])]) {
|
|
const owns = pathname === candidate || pathname.startsWith(candidate + "/");
|
|
if (owns) matches.push({ to: item.to, matchLength: candidate.length });
|
|
}
|
|
item.children?.filter((child) => !child.hidden).forEach(visit);
|
|
};
|
|
items.filter((item) => !item.hidden).forEach(visit);
|
|
return matches.sort((a, b) => b.matchLength - a.matchLength)[0]?.to ?? null;
|
|
}
|
|
|
|
// Preserve the existing inverse navigation surface in both themes.
|
|
const SIDEBAR_BG = "var(--palette-navigation-background)";
|
|
const SIDEBAR_BORDER = "var(--palette-navigation-border)";
|
|
const SIDEBAR_TEXT_MUTED = "var(--palette-navigation-muted)";
|
|
const SIDEBAR_TEXT = "var(--palette-navigation-text)";
|
|
const SIDEBAR_SELECTED_BG = "var(--palette-navigation-selected)";
|
|
const SIDEBAR_SELECTED_TEXT = "var(--palette-navigation-selectedText)";
|
|
const SIDEBAR_SELECTED_ICON = "var(--palette-navigation-selectedIcon)";
|
|
|
|
export default function AppShell({
|
|
pageTitle,
|
|
pageSubtitle,
|
|
pageLayout,
|
|
breadcrumbs,
|
|
pathname,
|
|
nav,
|
|
navBottom,
|
|
onNavigate,
|
|
onToggleDrawer,
|
|
drawerOpen,
|
|
user,
|
|
buildMetadata,
|
|
notificationsCount,
|
|
onOpenNotifications,
|
|
onOpenSettings,
|
|
onOpenProfile,
|
|
onSignOut,
|
|
rightActions,
|
|
children,
|
|
}: {
|
|
pageTitle: string;
|
|
pageSubtitle?: string;
|
|
pageLayout?: PageWidth;
|
|
breadcrumbs: string[];
|
|
pathname: string;
|
|
nav: NavItem[];
|
|
navBottom: NavItem[];
|
|
onNavigate: (to: string) => void;
|
|
onToggleDrawer: (open: boolean) => void;
|
|
drawerOpen: boolean;
|
|
user?: { email?: string; userName?: string; displayName?: string; avatarImageDataUrl?: string; roleLabel?: string };
|
|
buildMetadata?: { version: string; commitSha?: string };
|
|
notificationsCount?: number;
|
|
onOpenNotifications?: (anchorEl: HTMLElement) => void;
|
|
onOpenSettings?: () => void;
|
|
onOpenProfile?: () => void;
|
|
onSignOut?: () => void;
|
|
rightActions?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const { language, setLanguage, t } = useI18n();
|
|
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
|
|
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
|
try {
|
|
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
|
|
const navCollapsed = !isMobile && desktopNavCollapsed;
|
|
const [header, setHeader] = useState<HTMLElement | null>(null);
|
|
const [headerHeight, setHeaderHeight] = useState<number>();
|
|
const [secondaryMenu, setSecondaryMenu] = useState<{ anchor: HTMLElement; item: NavItem } | null>(null);
|
|
const pageTitleRef = useRef<HTMLHeadingElement>(null);
|
|
const pendingFocusPath = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile && drawerOpen) onToggleDrawer(false);
|
|
}, [isMobile, drawerOpen, onToggleDrawer]);
|
|
|
|
useEffect(() => {
|
|
if (!header || typeof ResizeObserver === "undefined") return;
|
|
const measure = () => setHeaderHeight(Math.ceil(header.getBoundingClientRect().height));
|
|
measure();
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(header);
|
|
return () => observer.disconnect();
|
|
}, [header]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
|
|
} catch {
|
|
// ignore storage failures
|
|
}
|
|
}, [desktopNavCollapsed]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingFocusPath.current) return;
|
|
const shouldFocus = pendingFocusPath.current === pathname;
|
|
pendingFocusPath.current = null;
|
|
if (shouldFocus) pageTitleRef.current?.focus({ preventScroll: true });
|
|
}, [pathname]);
|
|
|
|
const navigateFromShell = (to: string) => {
|
|
if (pathname === to) {
|
|
pageTitleRef.current?.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
pendingFocusPath.current = to;
|
|
setSecondaryMenu(null);
|
|
onNavigate(to);
|
|
};
|
|
|
|
const runShellRouteAction = (to: string, action?: () => void) => {
|
|
if (pathname === to) pageTitleRef.current?.focus({ preventScroll: true });
|
|
else pendingFocusPath.current = to;
|
|
action?.();
|
|
};
|
|
|
|
const grouped = useMemo(() => {
|
|
const groupItems = (items: NavItem[]) => {
|
|
const map = new Map<string, NavItem[]>();
|
|
for (const it of items.filter((x) => !x.hidden)) {
|
|
const key = it.section || "";
|
|
map.set(key, [...(map.get(key) ?? []), it]);
|
|
}
|
|
return Array.from(map.entries());
|
|
};
|
|
|
|
return {
|
|
top: groupItems(nav),
|
|
bottom: groupItems(navBottom),
|
|
};
|
|
}, [nav, navBottom]);
|
|
|
|
// Compute the one active destination across BOTH nav lists, so the most specific route wins.
|
|
const activeTo = useMemo(
|
|
() => activeNavItemTo(pathname, [...nav, ...navBottom]),
|
|
[pathname, nav, navBottom],
|
|
);
|
|
const desktopNavigationRef = useRef<HTMLDivElement>(null);
|
|
const mobileNavigationRef = useRef<HTMLDivElement>(null);
|
|
// New destinations can extend the existing scrollable list below its initial viewport.
|
|
// Reveal the actual current item on entry, without resetting a user's later manual scroll.
|
|
useEffect(() => {
|
|
const navigation = isMobile ? mobileNavigationRef.current : desktopNavigationRef.current;
|
|
navigation?.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
}, [activeTo, navCollapsed, isMobile, drawerOpen, headerHeight]);
|
|
|
|
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
|
<Box sx={{ px: navCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
|
{groups.map(([section, rows]) => (
|
|
<Box key={section || "_"} sx={{ mb: navCollapsed ? 1 : 1.25 }}>
|
|
{section && !navCollapsed ? (
|
|
<Typography variant="caption" sx={{ px: 1.25, color: SIDEBAR_TEXT_MUTED, fontWeight: 700, letterSpacing: "0.04em" }}>
|
|
{section}
|
|
</Typography>
|
|
) : null}
|
|
<List aria-label={section || t("navigation")} sx={{ px: navCollapsed ? 0.25 : 0.75, pt: navCollapsed ? 0.25 : 0.75 }}>
|
|
{rows.map((item) => {
|
|
const selected = item.to === activeTo || Boolean(item.children?.some((child) => child.to === activeTo));
|
|
const parentButton = (
|
|
<ListItemButton
|
|
selected={selected}
|
|
onClick={(event) => navCollapsed && item.children?.length
|
|
? setSecondaryMenu({ anchor: event.currentTarget, item })
|
|
: navigateFromShell(item.to)}
|
|
aria-label={item.label}
|
|
aria-current={item.to === activeTo ? "page" : selected ? "location" : undefined}
|
|
aria-haspopup={navCollapsed && item.children?.length ? "menu" : undefined}
|
|
aria-expanded={navCollapsed && secondaryMenu?.item.to === item.to ? true : undefined}
|
|
sx={{
|
|
borderRadius: 1.5,
|
|
minHeight: 44,
|
|
px: navCollapsed ? 1 : 1.25,
|
|
justifyContent: navCollapsed ? "center" : "flex-start",
|
|
border: "1px solid transparent",
|
|
color: SIDEBAR_TEXT_MUTED,
|
|
position: "relative",
|
|
"&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT },
|
|
"&.Mui-selected": { backgroundColor: SIDEBAR_SELECTED_BG, color: SIDEBAR_SELECTED_TEXT },
|
|
"&.Mui-selected::before": { content: '""', position: "absolute", insetInlineStart: 0, top: 9, bottom: 9, width: 3, borderRadius: 2, bgcolor: SIDEBAR_SELECTED_ICON },
|
|
"&.Mui-selected:hover": { backgroundColor: SIDEBAR_SELECTED_BG },
|
|
}}
|
|
>
|
|
<ListItemIcon sx={{ minWidth: navCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
|
|
{item.badgeCount && item.badgeCount > 0 ? (
|
|
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>{item.icon}</Badge>
|
|
) : item.icon}
|
|
</ListItemIcon>
|
|
{!navCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: selected ? 700 : 600 }} /> : null}
|
|
</ListItemButton>
|
|
);
|
|
return (
|
|
<ListItem key={item.to} disablePadding sx={{ mb: 0.375, display: "block" }}>
|
|
{navCollapsed ? <Tooltip title={item.label} placement="right">{parentButton}</Tooltip> : parentButton}
|
|
{!navCollapsed && item.children?.length ? (
|
|
<List disablePadding aria-label={item.label} sx={{ mb: 0.5 }}>
|
|
{item.children.filter((child) => !child.hidden).map((child) => {
|
|
const childSelected = child.to === activeTo;
|
|
return <ListItem key={child.to} disablePadding>
|
|
<ListItemButton
|
|
selected={childSelected}
|
|
aria-current={childSelected ? "page" : undefined}
|
|
onClick={() => navigateFromShell(child.to)}
|
|
sx={{ minHeight: 36, borderRadius: 1.5, pl: 5.75, pr: 1.25, py: 0.5, color: SIDEBAR_TEXT_MUTED, "&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT }, "&.Mui-selected": { backgroundColor: SIDEBAR_SELECTED_BG, color: SIDEBAR_SELECTED_TEXT }, "&.Mui-selected:hover": { backgroundColor: SIDEBAR_SELECTED_BG } }}
|
|
>
|
|
<ListItemText primary={child.label} primaryTypographyProps={{ variant: "body2", fontWeight: childSelected ? 700 : 500 }} />
|
|
{child.badgeCount && child.badgeCount > 0 ? <Badge color="error" badgeContent={child.badgeCount > 99 ? "99+" : child.badgeCount} /> : null}
|
|
</ListItemButton>
|
|
</ListItem>;
|
|
})}
|
|
</List>
|
|
) : null}
|
|
</ListItem>
|
|
);
|
|
})}
|
|
</List>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
);
|
|
|
|
const drawerContent = (
|
|
<Box component="nav" aria-label={t("navigation")} sx={{ "--app-focus-color": "var(--palette-focus-inverse)", flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
|
|
<Box sx={{ px: navCollapsed ? 1.5 : 2.25, py: navCollapsed ? 2 : 2.5, display: "flex", justifyContent: navCollapsed ? "center" : "flex-start" }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: navCollapsed ? "center" : "flex-start" }}>
|
|
<JobbjaktMark style={{ width: 30, height: 30, flexShrink: 0 }} />
|
|
{!navCollapsed ? (
|
|
<Box>
|
|
<Typography variant="h6" sx={{ fontWeight: 700, color: SIDEBAR_SELECTED_TEXT }}>
|
|
Jobbjakt
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: SIDEBAR_TEXT_MUTED }}>
|
|
{t("appTagline")}
|
|
</Typography>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
|
|
|
|
<Box sx={{ flex: 1, minHeight: 0, overflowY: "auto" }}>{renderNavList(grouped.top)}</Box>
|
|
|
|
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
|
|
|
|
{renderNavList(grouped.bottom)}
|
|
|
|
<Menu anchorEl={secondaryMenu?.anchor} open={Boolean(secondaryMenu)} onClose={() => setSecondaryMenu(null)} anchorOrigin={{ vertical: "top", horizontal: "right" }}>
|
|
{secondaryMenu ? [secondaryMenu.item, ...(secondaryMenu.item.children ?? [])].filter((item) => !item.hidden).map((item) => (
|
|
<MenuItem key={item.to} selected={item.to === activeTo} onClick={() => navigateFromShell(item.to)}>{item.label}</MenuItem>
|
|
)) : null}
|
|
</Menu>
|
|
</Box>
|
|
);
|
|
|
|
const nameForAvatar = user?.userName || user?.displayName || user?.email;
|
|
const initials = initialsFrom(nameForAvatar);
|
|
const buildVersion = buildMetadata?.version.trim();
|
|
const buildCommit = buildMetadata?.commitSha?.trim();
|
|
const buildBadge = buildVersion ? (
|
|
<Tooltip title={buildCommit ? `Version ${buildVersion} • commit ${buildCommit}` : `Version ${buildVersion}`}>
|
|
<Chip
|
|
data-testid="admin-version-badge"
|
|
aria-label={`Application version ${buildVersion}${buildCommit ? `, commit ${buildCommit}` : ""}`}
|
|
label={`v${buildVersion}`}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{
|
|
maxWidth: 150,
|
|
fontWeight: 700,
|
|
fontVariantNumeric: "tabular-nums",
|
|
bgcolor: "background.paper",
|
|
"& .MuiChip-label": { overflow: "hidden", textOverflow: "ellipsis" },
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
) : null;
|
|
|
|
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
|
|
const userMenuOpen = Boolean(userMenuAnchor);
|
|
|
|
return (
|
|
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
|
<Box component="a" href="#app-main-content" sx={{ position: "fixed", top: 8, left: 8, zIndex: (theme) => theme.zIndex.tooltip + 1, px: 2, py: 1, borderRadius: 1, bgcolor: "background.paper", color: "text.primary", transform: "translateY(-160%)", "&:focus": { transform: "translateY(0)" } }}>
|
|
{t("skipToMainContent")}
|
|
</Box>
|
|
<AppBar
|
|
ref={setHeader}
|
|
position="fixed"
|
|
color="inherit"
|
|
elevation={0}
|
|
sx={(muiTheme: any) => ({
|
|
borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
|
backgroundColor: muiTheme.vars.palette.background.default,
|
|
backgroundImage: "none",
|
|
zIndex: isMobile ? muiTheme.zIndex.appBar : muiTheme.zIndex.drawer + 1,
|
|
})}
|
|
>
|
|
<Toolbar
|
|
sx={{
|
|
gap: 1.25,
|
|
px: { xs: 2, md: 3 },
|
|
py: { xs: 1.25, md: 0 },
|
|
minHeight: { xs: 68, md: 76 },
|
|
alignItems: { xs: "stretch", md: "center" },
|
|
flexWrap: { xs: "wrap", md: "nowrap" },
|
|
}}
|
|
>
|
|
{isMobile ? (
|
|
<>
|
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, width: "100%", minWidth: 0 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
|
<IconButton
|
|
edge="start"
|
|
size="small"
|
|
color="secondary"
|
|
aria-label={t("openNavigation")}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={drawerOpen}
|
|
onClick={() => onToggleDrawer(true)}
|
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
|
>
|
|
<MenuIcon fontSize="small" />
|
|
</IconButton>
|
|
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.9, minWidth: 0, pl: 0.25 }}>
|
|
<JobbjaktMark style={{ width: 18, height: 18, flex: "0 0 auto" }} />
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, letterSpacing: -0.2 }} noWrap>
|
|
Jobbjakt
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<StackLanguageToggle language={language} setLanguage={setLanguage} />
|
|
{user ? (
|
|
<IconButton
|
|
size="small"
|
|
aria-label={t("user")}
|
|
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
|
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
|
|
>
|
|
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 28, height: 28, fontWeight: 900, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
|
|
</IconButton>
|
|
) : <Box sx={{ width: 42, height: 42 }} />}
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, width: "100%" }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flex: "0 0 auto" }}>
|
|
<IconButton
|
|
color="secondary"
|
|
size="small"
|
|
title={t("notifications")}
|
|
aria-label={t("notifications")}
|
|
onClick={(event) => onOpenNotifications?.(event.currentTarget)}
|
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
|
>
|
|
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
|
<NotificationsNoneIcon fontSize="small" />
|
|
</Badge>
|
|
</IconButton>
|
|
<IconButton
|
|
color="secondary"
|
|
size="small"
|
|
title={t("settings")}
|
|
aria-label={t("settings")}
|
|
onClick={() => runShellRouteAction("/settings", onOpenSettings)}
|
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
|
>
|
|
<SettingsOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
'& > *': { minWidth: 0 },
|
|
}}
|
|
>
|
|
{rightActions}
|
|
</Box>
|
|
</Box>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0, flex: 1 }}>
|
|
<IconButton
|
|
size="small"
|
|
color="secondary"
|
|
onClick={() => setDesktopNavCollapsed((value) => !value)}
|
|
title={desktopNavCollapsed ? t("expandSidebar") : t("collapseSidebar")}
|
|
aria-label={desktopNavCollapsed ? t("expandSidebar") : t("collapseSidebar")}
|
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }}
|
|
>
|
|
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "flex-end",
|
|
gap: 1,
|
|
flexWrap: "wrap",
|
|
width: { xs: "100%", md: "auto" },
|
|
flex: { xs: "1 1 100%", md: "0 1 auto" },
|
|
}}
|
|
>
|
|
{buildBadge}
|
|
<StackLanguageToggle language={language} setLanguage={setLanguage} />
|
|
<IconButton
|
|
color="secondary"
|
|
size="small"
|
|
title={t("notifications")}
|
|
aria-label={t("notifications")}
|
|
onClick={(event) => onOpenNotifications?.(event.currentTarget)}
|
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
|
>
|
|
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
|
<NotificationsNoneIcon fontSize="small" />
|
|
</Badge>
|
|
</IconButton>
|
|
<IconButton
|
|
color="secondary"
|
|
size="small"
|
|
title={t("settings")}
|
|
aria-label={t("settings")}
|
|
onClick={() => runShellRouteAction("/settings", onOpenSettings)}
|
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
|
>
|
|
<SettingsOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", flex: { xs: "1 1 auto", md: "0 1 auto" }, justifyContent: "flex-end" }}>
|
|
{rightActions}
|
|
</Box>
|
|
|
|
{user ? (
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}>
|
|
<IconButton
|
|
size="small"
|
|
aria-label={t("user")}
|
|
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
|
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
|
>
|
|
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
|
|
</IconButton>
|
|
<Box sx={{ display: { xs: "none", sm: "block" }, minWidth: 0 }}>
|
|
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }} noWrap>
|
|
{user.userName || user.displayName || user.email || t("user")}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{user.roleLabel || ""}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
</>
|
|
)}
|
|
<Menu
|
|
anchorEl={userMenuAnchor}
|
|
open={userMenuOpen}
|
|
onClose={() => setUserMenuAnchor(null)}
|
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
|
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
|
>
|
|
{isMobile && buildVersion ? (
|
|
<ListSubheader disableSticky role="presentation" sx={{ maxWidth: 280, py: 1, lineHeight: 1.5, overflowWrap: "anywhere" }}>
|
|
<Typography variant="body2">{t("applicationVersion", { version: buildVersion })}</Typography>
|
|
{buildCommit ? <Typography variant="caption">commit {buildCommit}</Typography> : null}
|
|
</ListSubheader>
|
|
) : null}
|
|
<MenuItem
|
|
onClick={() => {
|
|
setUserMenuAnchor(null);
|
|
runShellRouteAction("/profile", onOpenProfile);
|
|
}}
|
|
>
|
|
{t("profileMenu")}
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={() => {
|
|
setUserMenuAnchor(null);
|
|
runShellRouteAction("/settings", onOpenSettings);
|
|
}}
|
|
>
|
|
{t("settingsMenu")}
|
|
</MenuItem>
|
|
<Divider />
|
|
<MenuItem
|
|
onClick={() => {
|
|
setUserMenuAnchor(null);
|
|
onSignOut?.();
|
|
}}
|
|
>
|
|
{t("signOut")}
|
|
</MenuItem>
|
|
</Menu>
|
|
</Toolbar>
|
|
</AppBar>
|
|
|
|
<Drawer
|
|
ref={desktopNavigationRef}
|
|
variant="permanent"
|
|
sx={{
|
|
display: isMobile ? "none" : "block",
|
|
width: drawerWidth,
|
|
flexShrink: 0,
|
|
[`& .MuiDrawer-paper`]: {
|
|
width: drawerWidth,
|
|
boxSizing: "border-box",
|
|
borderRight: `1px solid ${SIDEBAR_BORDER}`,
|
|
backgroundColor: SIDEBAR_BG,
|
|
backgroundImage: "none",
|
|
boxShadow: "none",
|
|
},
|
|
}}
|
|
open
|
|
>
|
|
<Box aria-hidden sx={{ height: headerHeight ?? { xs: 68, md: 76 }, flexShrink: 0 }} />
|
|
{drawerContent}
|
|
</Drawer>
|
|
|
|
<Drawer
|
|
ref={mobileNavigationRef}
|
|
variant="temporary"
|
|
open={isMobile && drawerOpen}
|
|
onClose={() => onToggleDrawer(false)}
|
|
ModalProps={{ keepMounted: true }}
|
|
slotProps={{
|
|
paper: { "aria-label": t("navigation") },
|
|
transition: { onEntered: (node: HTMLElement) => {
|
|
if (node === document.activeElement || !node.contains(document.activeElement)) node.querySelector<HTMLElement>('[role="button"]')?.focus();
|
|
} },
|
|
}}
|
|
sx={{
|
|
display: isMobile ? "block" : "none",
|
|
[`& .MuiDrawer-paper`]: {
|
|
width: drawerWidth,
|
|
borderRight: `1px solid ${SIDEBAR_BORDER}`,
|
|
backgroundColor: SIDEBAR_BG,
|
|
backgroundImage: "none",
|
|
},
|
|
}}
|
|
>
|
|
{drawerContent}
|
|
</Drawer>
|
|
|
|
<Box
|
|
component="main"
|
|
id="app-main-content"
|
|
tabIndex={-1}
|
|
sx={{
|
|
flex: 1,
|
|
"--app-shell-header-height": `${headerHeight ?? (isMobile ? 124 : 76)}px`,
|
|
p: "var(--app-page-gutter)",
|
|
// Keep the usual feedback lane stable before a delayed toast arrives.
|
|
pb: "calc(var(--app-page-gutter) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))",
|
|
bgcolor: "background.default",
|
|
minHeight: "100vh",
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Box sx={{ mx: "auto", maxWidth: pageLayout ? `var(--app-page-width-${pageLayout})` : 1320, width: "100%", minWidth: 0 }}>
|
|
<Box aria-hidden sx={{ height: headerHeight ?? { xs: isMobile ? 124 : 68, md: 76 } }} />
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
|
|
{breadcrumbs.map((c) => (
|
|
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600, overflowWrap: "anywhere" }}>
|
|
{c}
|
|
</Typography>
|
|
))}
|
|
</Breadcrumbs>
|
|
<PageHeader title={pageTitle} description={pageSubtitle} titleRef={pageTitleRef} />
|
|
</Box>
|
|
</Box>
|
|
|
|
{children}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function StackLanguageToggle({ language, setLanguage, onDark = false }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void; onDark?: boolean }) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<ButtonGroup
|
|
size="small"
|
|
variant="outlined"
|
|
aria-label={t("applicationLanguage")}
|
|
sx={{
|
|
flex: "0 0 auto",
|
|
"& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 },
|
|
...(onDark ? { "& .MuiButton-outlined": { color: "#fff", borderColor: "rgba(255,255,255,0.55)" } } : {}),
|
|
}}
|
|
>
|
|
<Button aria-label="English" aria-pressed={language === "en"} variant={language === "en" ? "contained" : "outlined"} onClick={() => setLanguage("en")}>EN</Button>
|
|
<Button aria-label="Norsk" aria-pressed={language === "nb"} variant={language === "nb" ? "contained" : "outlined"} onClick={() => setLanguage("nb")}>NO</Button>
|
|
</ButtonGroup>
|
|
);
|
|
}
|