feat(AppShell): match chronos-style chrome markup

Previous AppShell composed limitless's high-level <Navbar> /
<Sidebar> / <Footer> / <PageShell>, which produced different markup
than chronos's own AdminShell. The visual didn't match what users
expect across the family.

Rewrite AppShell to render the same Bootstrap-flavoured markup
chronos uses inline:

- Navbar: `navbar navbar-dark navbar-expand-lg navbar-static`
  with `navbar-brand`, controlled user-menu dropdown, status
  indicator initials avatar, optional `navbarExtras` prop for app-
  injected items (search/notifications/etc.).
- Sidebar: `sidebar sidebar-light sidebar-main sidebar-expand-lg`
  with `nav nav-sidebar`, collapse + persistence in localStorage,
  active highlighting via path comparison, submenu support.
- Footer: `navbar navbar-sm navbar-footer border-top` with optional
  footerHtml branding + footer menu links.

All wired to ShellConfig — no fetch, no app-side state required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-10 14:43:43 +02:00
parent 15c09ecc36
commit 493d2c5d69

View File

@@ -1,11 +1,6 @@
"use client"; "use client";
import React, { createContext, useContext } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import { Navbar } from "./Navbar";
import { Sidebar, type SidebarNavItem } from "./Sidebar";
import { Footer, type FooterNavItem } from "./Footer";
import { PageShell } from "./PageShell";
// ─── ShellConfig types ──────────────────────────────────────────────────────── // ─── ShellConfig types ────────────────────────────────────────────────────────
// //
@@ -62,13 +57,13 @@ const ShellContext = createContext<ShellConfig | null>(null);
/** Read the current ShellConfig from anywhere inside <AppShell>. */ /** Read the current ShellConfig from anywhere inside <AppShell>. */
export function useShell(): ShellConfig { export function useShell(): ShellConfig {
const cfg = useContext(ShellContext); const cfg = useContext(ShellContext);
if (!cfg) { if (!cfg) throw new Error("useShell must be used inside <AppShell>");
throw new Error("useShell must be used inside <AppShell>");
}
return cfg; return cfg;
} }
// ─── AppShell — pure renderer ───────────────────────────────────────────────── // ─── AppShell — chronos-style detached layout, parameterized by ShellConfig ───
const SIDEBAR_COLLAPSED_KEY = "gsc-shell-sidebar-collapsed";
export type AppShellProps = { export type AppShellProps = {
/** /**
@@ -87,14 +82,11 @@ export type AppShellProps = {
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */ /** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
onSignOut?: () => void; onSignOut?: () => void;
/** Optional content for the <PageHeader> slot (breadcrumbs, page title, etc.). */ /** Optional: extra elements (notifications, search, etc.) injected into the right of the navbar. */
pageHeader?: React.ReactNode; navbarExtras?: React.ReactNode;
/** Page content. */ /** Page content. */
children: React.ReactNode; children: React.ReactNode;
/** Optional className on the outer wrapper. */
className?: string;
}; };
export function AppShell({ export function AppShell({
@@ -102,32 +94,114 @@ export function AppShell({
currentPath, currentPath,
translate, translate,
onSignOut, onSignOut,
pageHeader, navbarExtras,
className,
children, children,
}: AppShellProps) { }: AppShellProps) {
const t = translate ?? ((k: string) => k); const t = translate ?? ((k: string) => k);
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/"); const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/");
const sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t)); return (
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({ <ShellContext.Provider value={config}>
label: t(m.translationKey), <AdminNavbar config={config} t={t} onSignOut={onSignOut} navbarExtras={navbarExtras} />
href: m.href, <div
icon: m.icon ? <i className={m.icon} /> : undefined, className="page-content d-flex align-items-start p-3 gap-3"
})); style={{ minHeight: 0 }}
const navbarBrand = (
<a
className="d-flex align-items-center gap-2"
href={config.app.baseUrl}
style={{ color: "inherit", textDecoration: "none" }}
> >
<img src={config.branding.logoUrl} alt="" height={24} /> <AdminSidebar config={config} t={t} pathname={path} />
<span className="fw-semibold">{config.branding.productName}</span> <main className="flex-grow-1" style={{ minWidth: 0 }}>
</a> {children}
</main>
</div>
<AdminFooter config={config} />
</ShellContext.Provider>
); );
}
const userMenuItems = (config.menus["user-menu"] ?? []).map((m) => { // ─── Navbar ────────────────────────────────────────────────────────────────────
function AdminNavbar({
config,
t,
onSignOut,
navbarExtras,
}: {
config: ShellConfig;
t: (k: string) => string;
onSignOut?: () => void;
navbarExtras?: React.ReactNode;
}) {
const [showUserMenu, setShowUserMenu] = useState(false);
const initials =
config.user.displayName
?.split(" ")
.map((n) => n[0])
.filter(Boolean)
.join("")
.slice(0, 2)
.toUpperCase() || "?";
const userMenuItems = config.menus["user-menu"] ?? [];
return (
<div className="navbar navbar-dark navbar-expand-lg navbar-static">
<div className="container-fluid">
{/* Brand / logo */}
<div className="navbar-brand wmin-200">
<a href={config.app.baseUrl} className="d-inline-block">
<img
src={config.branding.logoUrl}
className="h-36px"
alt={config.branding.productName}
/>
</a>
</div>
{/* App-specific extras (search, browse apps, messages, etc.) */}
{navbarExtras}
{/* User menu */}
<ul className="nav flex-row justify-content-end order-1 order-lg-2 ms-auto">
<li
className="nav-item nav-item-dropdown-lg dropdown ms-lg-2"
onMouseLeave={() => setShowUserMenu(false)}
>
<a
href="#"
className="navbar-nav-link align-items-center rounded-pill p-1"
onClick={(e) => {
e.preventDefault();
setShowUserMenu((open) => !open);
}}
>
<div className="status-indicator-container">
<span
className="w-32px h-32px rounded-pill bg-primary bg-opacity-20 text-primary d-inline-flex align-items-center justify-content-center fw-semibold"
style={{ fontSize: "0.75rem" }}
>
{initials}
</span>
<span className="status-indicator bg-success" />
</div>
<span className="d-none d-lg-inline-block mx-lg-2">
{config.user.displayName || config.user.email}
</span>
</a>
<div
className={
showUserMenu
? "dropdown-menu dropdown-menu-end show"
: "dropdown-menu dropdown-menu-end"
}
style={{
position: "absolute",
inset: "0px 0px auto auto",
margin: "0px",
transform: "translate3d(0px, 44px, 0px)",
}}
>
{userMenuItems.map((m) => {
const isLogout = m.key === "logout"; const isLogout = m.key === "logout";
return ( return (
<a <a
@@ -136,105 +210,241 @@ export function AppShell({
target={m.isExternal ? "_blank" : undefined} target={m.isExternal ? "_blank" : undefined}
rel={m.isExternal ? "noopener noreferrer" : undefined} rel={m.isExternal ? "noopener noreferrer" : undefined}
className="dropdown-item" className="dropdown-item"
onClick={isLogout && onSignOut ? (e) => { e.preventDefault(); onSignOut(); } : undefined} onClick={
isLogout && onSignOut
? (e) => {
e.preventDefault();
onSignOut();
}
: undefined
}
> >
{m.icon ? <i className={`${m.icon} me-2`} /> : null} {m.icon ? <i className={`${m.icon} me-2`} /> : null}
{t(m.translationKey)} {t(m.translationKey)}
</a> </a>
); );
}); })}
</div>
const navbarEnd = (
<ul className="navbar-nav flex-row">
<li className="nav-item dropdown">
<button
type="button"
className="navbar-nav-link d-flex align-items-center"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span className="d-none d-md-inline me-2">
{config.user.displayName || config.user.email || ""}
</span>
<i className="ph-user-circle" />
</button>
<div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div>
</li> </li>
</ul> </ul>
</div>
</div>
); );
}
// ─── Sidebar ──────────────────────────────────────────────────────────────────
function AdminSidebar({
config,
t,
pathname,
}: {
config: ShellConfig;
t: (k: string) => string;
pathname: string;
}) {
const [collapsed, setCollapsed] = useState(false);
// Persist collapse state across navigations.
useEffect(() => {
if (typeof window === "undefined") return;
const saved = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
if (saved === "true") setCollapsed(true);
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed));
}, [collapsed]);
const sidebarClasses = [
"sidebar",
"sidebar-light",
"sidebar-main",
"sidebar-expand-lg",
"align-self-start",
collapsed ? "sidebar-main-resized" : "",
]
.filter(Boolean)
.join(" ");
const items = config.menus.sidebar ?? [];
return ( return (
<ShellContext.Provider value={config}> <div className={sidebarClasses}>
<PageShell <div className="sidebar-content">
className={className} <div className="sidebar-section">
navbar={ <div className="sidebar-section-body d-flex justify-content-center">
<Navbar {!collapsed && (
brand={navbarBrand} <h5 className="sidebar-resize-hide flex-grow-1 my-auto">
brandHref={config.app.baseUrl} {config.branding.productName}
endItems={navbarEnd} </h5>
showSidebarToggle )}
<button
type="button"
className="btn btn-light btn-icon btn-sm rounded-pill border-transparent sidebar-control sidebar-main-resize d-none d-lg-inline-flex"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<i className="ph-arrows-left-right" />
</button>
</div>
</div>
<div className="sidebar-section">
<ul className="nav nav-sidebar" data-nav-type="accordion">
{items.map((item) => (
<SidebarNavItem
key={item.id}
item={item}
pathname={stripLocale(pathname)}
collapsed={collapsed}
t={t}
/> />
} ))}
pageHeader={pageHeader} </ul>
mainSidebar={ </div>
<Sidebar </div>
variant="main" </div>
color="light" );
user={{ }
name: config.user.displayName || "—",
subtitle: config.user.email, function SidebarNavItem({
item,
pathname,
collapsed,
t,
}: {
item: ShellMenuItem;
pathname: string;
collapsed: boolean;
t: (k: string) => string;
}) {
const hasChildren = !!item.children?.length;
const active = isActiveHref(item.href, pathname);
const childActive = !!item.children?.some((c) => isActiveHref(c.href, pathname));
const [open, setOpen] = useState(childActive);
const [hovered, setHovered] = useState(false);
const icon = item.icon ? <i className={item.icon} /> : null;
if (hasChildren) {
const showFlyout = collapsed && hovered;
const showInline = !collapsed && open;
const navItemClasses = [
"nav-item",
"nav-item-submenu",
showInline ? "nav-item-open" : "",
showFlyout ? "nav-group-sub-visible" : "",
]
.filter(Boolean)
.join(" ");
return (
<li
className={navItemClasses}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
<a
href="#"
className={`nav-link ${active || childActive ? "active" : ""}`}
title={collapsed ? t(item.translationKey) : undefined}
onClick={(e) => {
e.preventDefault();
setOpen((o) => !o);
}} }}
items={sidebarItems} >
/> {icon}
{!collapsed && <span>{t(item.translationKey)}</span>}
</a>
<ul
className={
collapsed
? "nav-group-sub nav-group-sub-flyout collapse"
: `nav-group-sub collapse ${open ? "show" : ""}`
} }
footer={ data-submenu-title={t(item.translationKey)}
<Footer >
copyright={ {item.children!.map((c) => (
config.branding.footerHtml ? ( <li className="nav-item" key={c.id}>
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} /> <a
href={c.href}
target={c.isExternal ? "_blank" : undefined}
rel={c.isExternal ? "noopener noreferrer" : undefined}
className={`nav-link ${isActiveHref(c.href, pathname) ? "active" : ""}`}
>
{c.icon && <i className={c.icon} />}
{t(c.translationKey)}
</a>
</li>
))}
</ul>
</li>
);
}
return (
<li className="nav-item">
<a
href={item.href}
target={item.isExternal ? "_blank" : undefined}
rel={item.isExternal ? "noopener noreferrer" : undefined}
className={`nav-link ${active ? "active" : ""}`}
title={collapsed ? t(item.translationKey) : undefined}
>
{icon}
{!collapsed && <span>{t(item.translationKey)}</span>}
</a>
</li>
);
}
// ─── Footer ───────────────────────────────────────────────────────────────────
function AdminFooter({ config }: { config: ShellConfig }) {
const items = config.menus.footer ?? [];
return (
<div className="navbar navbar-sm navbar-footer border-top">
<div className="container-fluid">
<span>
{config.branding.footerHtml ? (
<span
dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }}
/>
) : ( ) : (
<>© {new Date().getFullYear()} GoSec Cloud</> <>© {new Date().getFullYear()} GoSec Cloud</>
) )}
} </span>
navItems={footerNavItems} {items.length > 0 && (
/> <ul className="navbar-nav flex-row gap-3 ms-auto">
} {items.map((m) => (
<li className="nav-item" key={m.id}>
<a
href={m.href}
target={m.isExternal ? "_blank" : undefined}
rel={m.isExternal ? "noopener noreferrer" : undefined}
className="navbar-nav-link"
> >
{children} {m.icon && <i className={`${m.icon} me-1`} />}
</PageShell> {m.translationKey}
</ShellContext.Provider> </a>
</li>
))}
</ul>
)}
</div>
</div>
); );
} }
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
function toSidebarNavItem( function isActiveHref(href: string, currentPath: string): boolean {
m: ShellMenuItem, if (!href || href === "#") return false;
currentPath: string, if (href === "/") return currentPath === "/";
t: (k: string) => string, return currentPath === href || currentPath.startsWith(`${href}/`);
): SidebarNavItem {
const active = isActiveHref(m.href, currentPath);
const item: SidebarNavItem = {
type: m.children && m.children.length > 0 ? "submenu" : "link",
label: t(m.translationKey),
href: m.href,
iconClass: m.icon,
active,
};
if (m.children && m.children.length > 0) {
item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t));
// submenu open if any descendant is active
item.isOpen = item.children.some(
(c) => c.active || (c.children?.some((g) => g.active) ?? false),
);
}
return item;
} }
function isActiveHref(href: string, currentPath: string): boolean { function stripLocale(p: string): string {
if (!href) return false; return p.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
// Strip a leading 2-letter locale prefix so /en/dashboard matches /dashboard.
const stripped = currentPath.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
if (href === "/") return stripped === "/";
return stripped === href || stripped.startsWith(`${href}/`);
} }