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:
@@ -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}/`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user