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";
|
||||
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
pageHeader={pageHeader}
|
||||
mainSidebar={
|
||||
<Sidebar
|
||||
variant="main"
|
||||
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={
|
||||
<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}(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user