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";
import React, { createContext, useContext } from "react";
import { Navbar } from "./Navbar";
import { Sidebar, type SidebarNavItem } from "./Sidebar";
import { Footer, type FooterNavItem } from "./Footer";
import { PageShell } from "./PageShell";
import React, { createContext, useContext, useEffect, useState } from "react";
// ─── ShellConfig types ────────────────────────────────────────────────────────
//
@@ -62,13 +57,13 @@ const ShellContext = createContext<ShellConfig | null>(null);
/** Read the current ShellConfig from anywhere inside <AppShell>. */
export function useShell(): ShellConfig {
const cfg = useContext(ShellContext);
if (!cfg) {
throw new Error("useShell must be used inside <AppShell>");
}
if (!cfg) throw new Error("useShell must be used inside <AppShell>");
return cfg;
}
// ─── AppShell — pure renderer ─────────────────────────────────────────────────
// ─── AppShell — chronos-style detached layout, parameterized by ShellConfig ───
const SIDEBAR_COLLAPSED_KEY = "gsc-shell-sidebar-collapsed";
export type AppShellProps = {
/**
@@ -87,14 +82,11 @@ export type AppShellProps = {
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
onSignOut?: () => void;
/** Optional content for the <PageHeader> slot (breadcrumbs, page title, etc.). */
pageHeader?: React.ReactNode;
/** Optional: extra elements (notifications, search, etc.) injected into the right of the navbar. */
navbarExtras?: React.ReactNode;
/** Page content. */
children: React.ReactNode;
/** Optional className on the outer wrapper. */
className?: string;
};
export function AppShell({
@@ -102,32 +94,114 @@ export function AppShell({
currentPath,
translate,
onSignOut,
pageHeader,
className,
navbarExtras,
children,
}: AppShellProps) {
const t = translate ?? ((k: string) => k);
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/");
const sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t));
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({
label: t(m.translationKey),
href: m.href,
icon: m.icon ? <i className={m.icon} /> : undefined,
}));
const navbarBrand = (
<a
className="d-flex align-items-center gap-2"
href={config.app.baseUrl}
style={{ color: "inherit", textDecoration: "none" }}
return (
<ShellContext.Provider value={config}>
<AdminNavbar config={config} t={t} onSignOut={onSignOut} navbarExtras={navbarExtras} />
<div
className="page-content d-flex align-items-start p-3 gap-3"
style={{ minHeight: 0 }}
>
<img src={config.branding.logoUrl} alt="" height={24} />
<span className="fw-semibold">{config.branding.productName}</span>
</a>
<AdminSidebar config={config} t={t} pathname={path} />
<main className="flex-grow-1" style={{ minWidth: 0 }}>
{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";
return (
<a
@@ -136,105 +210,241 @@ export function AppShell({
target={m.isExternal ? "_blank" : undefined}
rel={m.isExternal ? "noopener noreferrer" : undefined}
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}
{t(m.translationKey)}
</a>
);
});
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>
})}
</div>
</li>
</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 (
<ShellContext.Provider value={config}>
<PageShell
className={className}
navbar={
<Navbar
brand={navbarBrand}
brandHref={config.app.baseUrl}
endItems={navbarEnd}
showSidebarToggle
<div className={sidebarClasses}>
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-body d-flex justify-content-center">
{!collapsed && (
<h5 className="sidebar-resize-hide flex-grow-1 my-auto">
{config.branding.productName}
</h5>
)}
<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}
mainSidebar={
<Sidebar
variant="main"
color="light"
user={{
name: config.user.displayName || "—",
subtitle: config.user.email,
))}
</ul>
</div>
</div>
</div>
);
}
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={
<Footer
copyright={
config.branding.footerHtml ? (
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} />
data-submenu-title={t(item.translationKey)}
>
{item.children!.map((c) => (
<li className="nav-item" key={c.id}>
<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</>
)
}
navItems={footerNavItems}
/>
}
)}
</span>
{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}
</PageShell>
</ShellContext.Provider>
{m.icon && <i className={`${m.icon} me-1`} />}
{m.translationKey}
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function toSidebarNavItem(
m: ShellMenuItem,
currentPath: string,
t: (k: string) => string,
): 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 {
if (!href || href === "#") return false;
if (href === "/") return currentPath === "/";
return currentPath === href || currentPath.startsWith(`${href}/`);
}
function isActiveHref(href: string, currentPath: string): boolean {
if (!href) return false;
// 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}/`);
function stripLocale(p: string): string {
return p.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
}