Files
jobtrackingapp/job-tracker-ui/src/layout/AppShell.tsx
T
cesnimda acf60c2a07
CI and Deploy / test (pull_request) Failing after 50s
CI and Deploy / deploy (pull_request) Has been skipped
build(frontend): migrate CRA to Next.js (CSR lift-and-shift)
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while
keeping the app's actual routing/rendering model unchanged -- the app
is almost entirely behind auth with no proven SSR/SEO need, so a real
App Router rewrite would touch ~90 files for zero user-visible benefit.

- next.config.js: output:'export' (static HTML+JS, same "single
  index.html served by nginx with try_files fallback" deploy as CRA).
- app/layout.tsx + app/page.tsx: root shell ports public/index.html's
  <head>, mounts the whole existing App tree client-only (ssr:false)
  since it reads window/localStorage during initial render and Next's
  static prerender would otherwise execute that on the server.
- Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects
  any `pages/` dir under the app root and tried to build our React
  Router page components as its own routes).
- REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development,
  Dockerfile, docker-compose.yml build args.
- Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported
  under Turbopack) with a small inline JobbjaktMark component.
- TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax
  4.9's parser rejects; CRA never hit this because babel doesn't
  type-check).
- Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts,
  public/index.html); kept react-scripts as the Jest test runner only
  (next/jest migration not needed -- the existing config already works).

Verified: `next build` static export succeeds, `next dev` serves the
landing page and client-side routes (login etc.) correctly, all 57
frontend tests + 172 backend tests still green.

Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s
in `next dev` since there's no server route for it -- the app only
ever mounts at "/". Production is unaffected: nginx's existing
try_files fallback still serves index.html for any path.
2026-07-12 00:50:45 +02:00

478 lines
17 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import {
AppBar,
Avatar,
Badge,
Box,
Breadcrumbs,
Divider,
Drawer,
IconButton,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Toolbar,
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
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 { useI18n } from "../i18n/I18nProvider";
export type NavItem = {
to: string;
label: string;
icon: React.ReactNode;
hidden?: boolean;
section?: string;
badgeCount?: number;
};
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";
export default function AppShell({
pageTitle,
breadcrumbs,
pathname,
nav,
navBottom,
onNavigate,
onToggleDrawer,
drawerOpen,
user,
notificationsCount,
onOpenNotifications,
onOpenSettings,
onOpenProfile,
onSignOut,
rightActions,
children,
}: {
pageTitle: string;
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 };
notificationsCount?: number;
onOpenNotifications?: () => void;
onOpenSettings?: () => void;
onOpenProfile?: () => void;
onSignOut?: () => void;
rightActions?: React.ReactNode;
children: React.ReactNode;
}) {
const { t } = useI18n();
const isMobile = useMediaQuery("(max-width:767.95px)");
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
try {
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
} catch {
return false;
}
});
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
useEffect(() => {
try {
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
} catch {
// ignore storage failures
}
}, [desktopNavCollapsed]);
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]);
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
{groups.map(([section, rows]) => (
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
{section && !desktopNavCollapsed ? (
<Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}>
{section}
</Typography>
) : null}
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
{rows.map((item) => {
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
return (
<ListItemButton
key={item.to}
selected={selected}
onClick={() => onNavigate(item.to)}
title={desktopNavCollapsed ? item.label : undefined}
sx={(muiTheme: any) => ({
borderRadius: 2,
mb: 0.5,
minHeight: 44,
px: desktopNavCollapsed ? 1 : 1.5,
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
border: "1px solid transparent",
"&.Mui-selected": {
backgroundColor: muiTheme.vars.palette.action.hover,
borderColor: muiTheme.vars.palette.divider,
},
})}
>
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center" }}>
{item.badgeCount && item.badgeCount > 0 ? (
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
{item.icon}
</Badge>
) : item.icon}
</ListItemIcon>
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
</ListItemButton>
);
})}
</List>
</Box>
))}
</Box>
);
const drawerContent = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<JobbjaktMark style={{ width: 22, height: 22 }} />
{!desktopNavCollapsed ? (
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Jobbjakt
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("appTagline")}
</Typography>
</Box>
) : null}
</Box>
</Box>
<Divider />
{renderNavList(grouped.top)}
<Box sx={{ flex: 1 }} />
<Divider />
{renderNavList(grouped.bottom)}
</Box>
);
const nameForAvatar = user?.userName || user?.displayName || user?.email;
const initials = initialsFrom(nameForAvatar);
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const userMenuOpen = Boolean(userMenuAnchor);
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar
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: 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"
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>
{user ? (
<IconButton
size="small"
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 }}>{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")}
onClick={onOpenNotifications}
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")}
onClick={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 ? "Expand sidebar" : "Collapse sidebar"}
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" },
}}
>
<IconButton
color="secondary"
size="small"
title={t("notifications")}
onClick={onOpenNotifications}
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")}
onClick={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"
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 }}>{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" }}
>
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
onOpenProfile?.();
}}
>
{t("profileMenu")}
</MenuItem>
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
onOpenSettings?.();
}}
>
{t("settingsMenu")}
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
onSignOut?.();
}}
>
{t("signOut")}
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={(muiTheme: any) => ({
display: { xs: "none", md: "block" },
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
boxSizing: "border-box",
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
backgroundImage: "none",
boxShadow: "none",
},
})}
open
>
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
{drawerContent}
</Drawer>
<Drawer
variant="temporary"
open={drawerOpen}
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
sx={(muiTheme: any) => ({
display: { xs: "block", md: "none" },
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
backgroundImage: "none",
},
})}
>
{drawerContent}
</Drawer>
<Box
component="main"
sx={{
flex: 1,
p: { xs: 2, sm: 3 },
bgcolor: "background.default",
minHeight: "100vh",
}}
>
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: 2, 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>
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
{pageTitle}
</Typography>
</Box>
</Box>
{children}
</Box>
</Box>
</Box>
);
}