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(null); const [headerHeight, setHeaderHeight] = useState(); const [secondaryMenu, setSecondaryMenu] = useState<{ anchor: HTMLElement; item: NavItem } | null>(null); const pageTitleRef = useRef(null); const pendingFocusPath = useRef(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(); 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(null); const mobileNavigationRef = useRef(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('[aria-current="page"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); }, [activeTo, navCollapsed, isMobile, drawerOpen, headerHeight]); const renderNavList = (groups: Array<[string, NavItem[]]>) => ( {groups.map(([section, rows]) => ( {section && !navCollapsed ? ( {section} ) : null} {rows.map((item) => { const selected = item.to === activeTo || Boolean(item.children?.some((child) => child.to === activeTo)); const parentButton = ( 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 }, }} > {item.badgeCount && item.badgeCount > 0 ? ( 99 ? "99+" : item.badgeCount}>{item.icon} ) : item.icon} {!navCollapsed ? : null} ); return ( {navCollapsed ? {parentButton} : parentButton} {!navCollapsed && item.children?.length ? ( {item.children.filter((child) => !child.hidden).map((child) => { const childSelected = child.to === activeTo; return 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 } }} > {child.badgeCount && child.badgeCount > 0 ? 99 ? "99+" : child.badgeCount} /> : null} ; })} ) : null} ); })} ))} ); const drawerContent = ( {!navCollapsed ? ( Jobbjakt {t("appTagline")} ) : null} {renderNavList(grouped.top)} {renderNavList(grouped.bottom)} setSecondaryMenu(null)} anchorOrigin={{ vertical: "top", horizontal: "right" }}> {secondaryMenu ? [secondaryMenu.item, ...(secondaryMenu.item.children ?? [])].filter((item) => !item.hidden).map((item) => ( navigateFromShell(item.to)}>{item.label} )) : null} ); 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 ? ( ) : null; const [userMenuAnchor, setUserMenuAnchor] = useState(null); const userMenuOpen = Boolean(userMenuAnchor); return ( 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")} ({ 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, })} > {isMobile ? ( <> onToggleDrawer(true)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }} > Jobbjakt {user ? ( setUserMenuAnchor(e.currentTarget)} sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }} > {initials} ) : } onOpenNotifications?.(event.currentTarget)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }} > runShellRouteAction("/settings", onOpenSettings)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }} > *': { minWidth: 0 }, }} > {rightActions} ) : ( <> 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 }} > {buildBadge} onOpenNotifications?.(event.currentTarget)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }} > runShellRouteAction("/settings", onOpenSettings)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }} > {rightActions} {user ? ( setUserMenuAnchor(e.currentTarget)} sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }} > {initials} {user.userName || user.displayName || user.email || t("user")} {user.roleLabel || ""} ) : null} )} setUserMenuAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "right" }} transformOrigin={{ vertical: "top", horizontal: "right" }} > {isMobile && buildVersion ? ( {t("applicationVersion", { version: buildVersion })} {buildCommit ? commit {buildCommit} : null} ) : null} { setUserMenuAnchor(null); runShellRouteAction("/profile", onOpenProfile); }} > {t("profileMenu")} { setUserMenuAnchor(null); runShellRouteAction("/settings", onOpenSettings); }} > {t("settingsMenu")} { setUserMenuAnchor(null); onSignOut?.(); }} > {t("signOut")} {drawerContent} 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('[role="button"]')?.focus(); } }, }} sx={{ display: isMobile ? "block" : "none", [`& .MuiDrawer-paper`]: { width: drawerWidth, borderRight: `1px solid ${SIDEBAR_BORDER}`, backgroundColor: SIDEBAR_BG, backgroundImage: "none", }, }} > {drawerContent} {breadcrumbs.map((c) => ( {c} ))} {children} ); } export function StackLanguageToggle({ language, setLanguage, onDark = false }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void; onDark?: boolean }) { const { t } = useI18n(); return ( ); }