chore: initialize @limitless/ui git repo + add AppShell
This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.
Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.
Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
sidebar, footer) backed by gsc-shell-api. Public surface:
AppShell, ShellProvider, useShell, ShellConfig types
Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
getToken; AppShell composes the existing PageShell / Navbar /
Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
`tsc` command was a no-op because tsconfig.json sets noEmit:true
for typecheck speed. Existing dist/ only existed because of an
earlier emit; clean rebuilds were silently broken.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
319
src/layout/Sidebar.tsx
Normal file
319
src/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export type SidebarNavItem = {
|
||||
type?: 'link' | 'header' | 'divider' | 'submenu';
|
||||
label?: React.ReactNode;
|
||||
href?: string;
|
||||
icon?: React.ReactNode;
|
||||
iconClass?: string;
|
||||
badge?: React.ReactNode;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: SidebarNavItem[];
|
||||
onClick?: () => void;
|
||||
/** Whether submenu is open (controlled externally) */
|
||||
isOpen?: boolean;
|
||||
/** Callback to toggle submenu open state */
|
||||
onToggle?: () => void;
|
||||
/** Custom link component (e.g., NavLink from Remix) */
|
||||
LinkComponent?: React.ComponentType<any>;
|
||||
};
|
||||
|
||||
export type SidebarUserInfo = {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
avatar?: string;
|
||||
menuItems?: SidebarNavItem[];
|
||||
};
|
||||
|
||||
export type SidebarProps = {
|
||||
/** Sidebar variant: 'main' | 'secondary' | 'right' */
|
||||
variant?: 'main' | 'secondary' | 'right';
|
||||
/** Light or dark color scheme */
|
||||
color?: 'light' | 'dark';
|
||||
/** User info block (Layout 3 material style) */
|
||||
user?: SidebarUserInfo;
|
||||
/** Optional header content at top of sidebar */
|
||||
header?: React.ReactNode;
|
||||
/** Navigation items */
|
||||
items: SidebarNavItem[];
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
/** Mobile toggler title */
|
||||
mobileTitle?: string;
|
||||
/** Sidebar expand breakpoint */
|
||||
expandBreakpoint?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Whether sidebar is collapsed */
|
||||
collapsed?: boolean;
|
||||
/** Callback when mobile sidebar close is clicked */
|
||||
onMobileClose?: () => void;
|
||||
/** Custom link component for all nav items (e.g., NavLink from Remix) */
|
||||
LinkComponent?: React.ComponentType<any>;
|
||||
/** Current pathname for active state detection */
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Layout 3 detached sidebar with nav-sidebar navigation.
|
||||
* Supports user menu, headers, dividers, and multi-level submenus.
|
||||
*/
|
||||
export function Sidebar({
|
||||
variant = 'main',
|
||||
color = 'light',
|
||||
user,
|
||||
header,
|
||||
items,
|
||||
className = '',
|
||||
mobileTitle = 'Main sidebar',
|
||||
expandBreakpoint = 'lg',
|
||||
collapsed = false,
|
||||
onMobileClose,
|
||||
LinkComponent,
|
||||
pathname = '',
|
||||
}: SidebarProps) {
|
||||
const variantClass = variant === 'main' ? 'sidebar-main' : variant === 'secondary' ? 'sidebar-secondary' : 'sidebar-right';
|
||||
const colorClass = color === 'dark' ? 'sidebar-dark' : 'sidebar-light';
|
||||
const expandClass = `sidebar-expand-${expandBreakpoint}`;
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${colorClass} ${variantClass} ${expandClass} align-self-start ${className}`.trim()}>
|
||||
{/* Sidebar mobile toggler */}
|
||||
<div className="sidebar-mobile-toggler text-center">
|
||||
<a href="#" className="sidebar-mobile-main-toggle" onClick={(e) => { e.preventDefault(); onMobileClose?.(); }}>
|
||||
<i className="ph-arrow-left"></i>
|
||||
</a>
|
||||
<span className="fw-semibold">{mobileTitle}</span>
|
||||
<a href="#" className="sidebar-mobile-expand">
|
||||
<i className="ph-arrows-out-simple"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Sidebar content */}
|
||||
<div className="sidebar-content">
|
||||
{header && <div className="sidebar-section">{header}</div>}
|
||||
{/* User menu (material style) */}
|
||||
{user && <SidebarUserMenu user={user} />}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="card card-sidebar-mobile">
|
||||
<div className="card-body p-0">
|
||||
<ul className="nav nav-sidebar" data-nav-type="accordion">
|
||||
{items.map((item, idx) => (
|
||||
<SidebarNavNode key={item.href || item.label?.toString() || idx} item={item} collapsed={collapsed} LinkComponent={LinkComponent} pathname={pathname} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUserMenu({ user }: { user: SidebarUserInfo }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="sidebar-user-material">
|
||||
<div className="sidebar-user-material-body card-img-top">
|
||||
<div className="card-body text-center">
|
||||
<a href="#">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} className="img-fluid rounded-circle shadow-2 mb-3" width="80" height="80" alt="" />
|
||||
) : (
|
||||
<div className="rounded-circle bg-secondary d-inline-flex align-items-center justify-content-center mb-3" style={{ width: 80, height: 80 }}>
|
||||
<span className="text-white h4 mb-0">{user.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
<h6 className="mb-0 text-white text-shadow-dark">{user.name}</h6>
|
||||
{user.subtitle && <span className="font-size-sm text-white text-shadow-dark">{user.subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-user-material-footer">
|
||||
<a
|
||||
href="#user-nav"
|
||||
className="d-flex justify-content-between align-items-center text-shadow-dark dropdown-toggle"
|
||||
onClick={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
|
||||
>
|
||||
<span>My account</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`collapse ${isOpen ? 'show' : ''}`} id="user-nav">
|
||||
{user.menuItems && (
|
||||
<ul className="nav nav-sidebar">
|
||||
{user.menuItems.map((item, idx) => (
|
||||
<SidebarNavNode key={idx} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNavNode({
|
||||
item,
|
||||
level = 0,
|
||||
collapsed = false,
|
||||
LinkComponent,
|
||||
pathname = '',
|
||||
}: {
|
||||
item: SidebarNavItem;
|
||||
level?: number;
|
||||
collapsed?: boolean;
|
||||
LinkComponent?: React.ComponentType<any>;
|
||||
pathname?: string;
|
||||
}) {
|
||||
// Check if any child is active (current path matches)
|
||||
const hasActiveChild = item.children?.some(child => child.href && pathname.startsWith(child.href)) || false;
|
||||
// Use external isOpen if provided, otherwise fall back to local state
|
||||
const [localIsOpen, setLocalIsOpen] = useState(hasActiveChild);
|
||||
const isOpen = item.isOpen !== undefined ? item.isOpen : localIsOpen;
|
||||
|
||||
if (item.type === 'header') {
|
||||
if (collapsed) return null;
|
||||
return (
|
||||
<li className="nav-item-header">
|
||||
<div className="text-uppercase font-size-xs line-height-xs">{item.label}</div>
|
||||
{item.icon && <i className="ph-list" title={String(item.label)}></i>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'divider') {
|
||||
return <li className="nav-item-divider"></li>;
|
||||
}
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isSubmenu = item.type === 'submenu' || hasChildren;
|
||||
|
||||
// Determine if item is active based on pathname
|
||||
const isItemActive = item.active ?? (item.href
|
||||
? pathname.startsWith(item.href)
|
||||
: item.children?.some(child => child.href && pathname.startsWith(child.href)) || false);
|
||||
|
||||
const linkClasses = [
|
||||
'nav-link',
|
||||
'd-flex',
|
||||
'align-items-center',
|
||||
isItemActive ? 'active' : '',
|
||||
item.disabled ? 'disabled' : '',
|
||||
collapsed ? 'justify-content-center' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isSubmenu) {
|
||||
e.preventDefault();
|
||||
if (item.onToggle) {
|
||||
item.onToggle();
|
||||
} else {
|
||||
setLocalIsOpen(!isOpen);
|
||||
}
|
||||
}
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Render icon
|
||||
const renderIcon = () => {
|
||||
if (item.icon) {
|
||||
return <span className={collapsed ? '' : 'me-2'}>{item.icon}</span>;
|
||||
}
|
||||
if (item.iconClass) {
|
||||
return <i className={`${item.iconClass} ${collapsed ? '' : 'me-2'}`}></i>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isSubmenu) {
|
||||
// Use button for submenu toggle to avoid anchor navigation issues
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (collapsed) {
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.onToggle) {
|
||||
item.onToggle();
|
||||
} else {
|
||||
setLocalIsOpen(prev => !prev);
|
||||
}
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className={`nav-item nav-item-submenu ${isOpen ? 'nav-item-open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${linkClasses} w-100 text-start border-0 bg-transparent`}
|
||||
title={collapsed ? String(item.label) : undefined}
|
||||
aria-expanded={isOpen}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{renderIcon()}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-grow-1">{item.label}</span>
|
||||
<i className={`ph-caret-${isOpen ? 'up' : 'down'} ms-auto`}></i>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<ul
|
||||
className="nav nav-group-sub"
|
||||
data-submenu-title={String(item.label)}
|
||||
>
|
||||
{item.children!.map((child, idx) => (
|
||||
<SidebarNavNode
|
||||
key={child.href || child.label?.toString() || idx}
|
||||
item={child}
|
||||
level={level + 1}
|
||||
collapsed={collapsed}
|
||||
LinkComponent={LinkComponent}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Use custom LinkComponent if provided, otherwise use <a>
|
||||
const Link = LinkComponent || 'a';
|
||||
const linkProps = LinkComponent
|
||||
? {
|
||||
to: item.href || '#',
|
||||
className: linkClasses,
|
||||
title: collapsed ? String(item.label) : undefined,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
// Stop propagation to prevent parent handlers from interfering
|
||||
e.stopPropagation();
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
// Don't prevent default - let navigation proceed
|
||||
},
|
||||
}
|
||||
: { href: item.href || '#', className: linkClasses, onClick: handleClick, title: collapsed ? String(item.label) : undefined };
|
||||
|
||||
return (
|
||||
<li className="nav-item">
|
||||
<Link {...linkProps}>
|
||||
{renderIcon()}
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
{!collapsed && item.badge && <span className="badge ms-auto align-self-center">{item.badge}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type SidebarItem = SidebarNavItem;
|
||||
Reference in New Issue
Block a user