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:
Claude
2026-05-10 09:42:57 +02:00
commit cf068ce4ec
115 changed files with 36542 additions and 0 deletions

319
src/layout/Sidebar.tsx Normal file
View 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;