feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
31
frontend/src/components/layout/AppLayout.tsx
Normal file
31
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { ShortcutsDialog } from './ShortcutsDialog'
|
||||
import { CommandPalette } from '@/components/command-palette/CommandPalette'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-md focus:text-sm focus:font-medium"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<Header />
|
||||
<main id="main-content" tabIndex={-1} className="flex-1 overflow-auto p-2 sm:p-4">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
<CommandPalette />
|
||||
<ShortcutsDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
frontend/src/components/layout/Header.tsx
Normal file
225
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, Link } from '@tanstack/react-router'
|
||||
import { LogOut, ChevronDown, User, Search, Sun, Moon, RefreshCw, Menu, Settings } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { tenantsApi } from '@/lib/api'
|
||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||
import type { ConnectionState } from '@/hooks/useEventStream'
|
||||
|
||||
// ─── Connection State Indicator ──────────────────────────────────────────────
|
||||
|
||||
const CONNECTION_CONFIG: Record<
|
||||
ConnectionState,
|
||||
{ colorClass: string; label: string; pulse: boolean }
|
||||
> = {
|
||||
connected: { colorClass: 'bg-success', label: 'Connected', pulse: false },
|
||||
connecting: { colorClass: 'bg-warning', label: 'Connecting...', pulse: true },
|
||||
reconnecting: { colorClass: 'bg-warning', label: 'Reconnecting...', pulse: true },
|
||||
disconnected: { colorClass: 'bg-error', label: 'Disconnected', pulse: false },
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function ConnectionIndicator({
|
||||
state,
|
||||
lastConnectedAt,
|
||||
onReconnect,
|
||||
}: {
|
||||
state: ConnectionState
|
||||
lastConnectedAt: Date | null
|
||||
onReconnect: () => void
|
||||
}) {
|
||||
const { colorClass, label, pulse } = CONNECTION_CONFIG[state]
|
||||
|
||||
return (
|
||||
<div className="relative group flex items-center">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${colorClass} ${pulse ? 'animate-pulse' : ''}`}
|
||||
role="status"
|
||||
aria-label={`Real-time connection: ${label}`}
|
||||
/>
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute right-0 top-full mt-2 hidden group-hover:block z-50">
|
||||
<div className="bg-surface border border-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
|
||||
<p className="font-medium text-text-primary">{label}</p>
|
||||
{lastConnectedAt && (
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Last connected: {formatTime(lastConnectedAt)}
|
||||
</p>
|
||||
)}
|
||||
{state === 'disconnected' && (
|
||||
<button
|
||||
onClick={onReconnect}
|
||||
className="flex items-center gap-1 mt-1.5 text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Header ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuth()
|
||||
const { selectedTenantId, setSelectedTenantId, theme, setTheme } = useUIStore()
|
||||
const { connectionState, lastConnectedAt, reconnect } = useEventStreamContext()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: tenantsApi.list,
|
||||
enabled: isSuperAdmin(user),
|
||||
select: (data) => data.filter((t) => t.id !== SYSTEM_TENANT_ID),
|
||||
})
|
||||
|
||||
const selectedTenant = tenants?.find((t) => t.id === selectedTenantId)
|
||||
|
||||
// Auto-select when there's exactly one tenant and nothing selected
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin(user) && tenants && tenants.length === 1 && !selectedTenantId) {
|
||||
setSelectedTenantId(tenants[0].id)
|
||||
}
|
||||
}, [tenants, selectedTenantId, user, setSelectedTenantId])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
void navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
super_admin: 'Super Admin',
|
||||
tenant_admin: 'Admin',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between h-12 px-4 border-b border-border bg-sidebar flex-shrink-0" data-testid="header">
|
||||
{/* Left: hamburger + tenant context */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => useUIStore.getState().setMobileSidebarOpen(true)}
|
||||
className="lg:hidden p-1.5 rounded-md text-text-muted hover:text-text-primary hover:bg-elevated transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
{isSuperAdmin(user) && tenants && tenants.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors" data-testid="header-org-selector">
|
||||
<span>{selectedTenant ? selectedTenant.name : 'All Organizations'}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuLabel>Organization Context</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setSelectedTenantId(null)}>
|
||||
All Organizations
|
||||
</DropdownMenuItem>
|
||||
{tenants.map((tenant) => (
|
||||
<DropdownMenuItem
|
||||
key={tenant.id}
|
||||
onClick={() => setSelectedTenantId(tenant.id)}
|
||||
>
|
||||
{tenant.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: actions + user menu */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
onClick={() => useCommandPalette.getState().setOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border text-text-muted text-sm hover:text-text-secondary hover:border-border-bright transition-colors"
|
||||
aria-label="Search (Cmd+K)"
|
||||
data-testid="header-search"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<span className="hidden lg:inline">Search...</span>
|
||||
<kbd className="hidden lg:inline-flex items-center gap-0.5 rounded border border-border px-1.5 py-0.5 text-[10px] font-mono text-text-muted">
|
||||
Cmd+K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{/* Dark/light mode toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-elevated transition-colors"
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
data-testid="header-theme-toggle"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
{/* SSE connection state indicator */}
|
||||
<ConnectionIndicator
|
||||
state={connectionState}
|
||||
lastConnectedAt={lastConnectedAt}
|
||||
onReconnect={reconnect}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors" aria-label="User menu" data-testid="header-user-menu">
|
||||
<div className="w-6 h-6 rounded-full bg-elevated flex items-center justify-center">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="hidden lg:block">{user?.name ?? user?.email}</span>
|
||||
{/* Only show role if it differs from the display name */}
|
||||
{(user?.name ?? user?.email) !== (roleLabel[user?.role ?? ''] ?? user?.role) && (
|
||||
<span className="hidden lg:block text-xs text-text-muted">
|
||||
{roleLabel[user?.role ?? ''] ?? user?.role}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="font-normal text-text-secondary text-xs">{user?.email}</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void handleLogout()} className="text-error" data-testid="button-sign-out">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/layout/PageTransition.tsx
Normal file
35
frontend/src/components/layout/PageTransition.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
const variants = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -8 },
|
||||
}
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode
|
||||
/** Unique key for AnimatePresence to detect page changes */
|
||||
pageKey: string
|
||||
}
|
||||
|
||||
export function PageTransition({ children, pageKey }: PageTransitionProps) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return <div key={pageKey}>{children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={pageKey}
|
||||
variants={variants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/layout/ShortcutsDialog.tsx
Normal file
70
frontend/src/components/layout/ShortcutsDialog.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { shortcuts, categoryLabels, type ShortcutDef } from '@/lib/shortcuts'
|
||||
import { ShortcutHint } from '@/components/ui/shortcut-hint'
|
||||
import { useShortcut } from '@/hooks/useShortcut'
|
||||
|
||||
export function ShortcutsDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// ? key opens the shortcuts dialog
|
||||
useShortcut('?', () => setOpen(true))
|
||||
|
||||
// Group shortcuts by category
|
||||
const grouped = shortcuts.reduce<
|
||||
Record<ShortcutDef['category'], ShortcutDef[]>
|
||||
>(
|
||||
(acc, shortcut) => {
|
||||
acc[shortcut.category].push(shortcut)
|
||||
return acc
|
||||
},
|
||||
{ global: [], navigation: [], 'device-list': [] },
|
||||
)
|
||||
|
||||
const categories: ShortcutDef['category'][] = [
|
||||
'global',
|
||||
'navigation',
|
||||
'device-list',
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 mt-2">
|
||||
{categories.map((category) => {
|
||||
const items = grouped[category]
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div key={category}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-2">
|
||||
{categoryLabels[category]}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{items.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-elevated/50"
|
||||
>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<ShortcutHint keys={shortcut.key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
344
frontend/src/components/layout/Sidebar.tsx
Normal file
344
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
Monitor,
|
||||
Building2,
|
||||
Users,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Bell,
|
||||
BellRing,
|
||||
Download,
|
||||
Terminal,
|
||||
FileCode,
|
||||
FileText,
|
||||
MapPin,
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
Wrench,
|
||||
ClipboardList,
|
||||
Calendar,
|
||||
Key,
|
||||
Layers,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
|
||||
Eye,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { AlertBadge } from '@/components/alerts/AlertBadge'
|
||||
import { RugLogo } from '@/components/brand/RugLogo'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.FC<{ className?: string }>
|
||||
exact?: boolean
|
||||
badge?: React.ReactNode
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { user } = useAuth()
|
||||
const { sidebarCollapsed, toggleSidebar, mobileSidebarOpen, setMobileSidebarOpen } = useUIStore()
|
||||
const routerState = useRouterState()
|
||||
const currentPath = routerState.location.pathname
|
||||
|
||||
// Keyboard toggle: [ key collapses/expands sidebar
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === '[' &&
|
||||
!['INPUT', 'TEXTAREA', 'SELECT'].includes(
|
||||
(e.target as HTMLElement).tagName,
|
||||
)
|
||||
) {
|
||||
e.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
label: 'Fleet',
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/',
|
||||
icon: LayoutDashboard,
|
||||
exact: true,
|
||||
},
|
||||
// Only show Devices for non-super_admin (super_admin uses Organizations in Admin)
|
||||
...(!isSuperAdmin(user) && user?.tenant_id
|
||||
? [
|
||||
{
|
||||
label: 'Devices',
|
||||
href: `/tenants/${user.tenant_id}/devices`,
|
||||
icon: Monitor,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Map',
|
||||
href: '/map',
|
||||
icon: MapPin,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Manage',
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Config Editor',
|
||||
href: '/config-editor',
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
label: 'Batch Config',
|
||||
href: '/batch-config',
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
label: 'Bulk Commands',
|
||||
href: '/bulk-commands',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
label: 'Templates',
|
||||
href: '/templates',
|
||||
icon: FileCode,
|
||||
},
|
||||
{
|
||||
label: 'Firmware',
|
||||
href: '/firmware',
|
||||
icon: Download,
|
||||
},
|
||||
{
|
||||
label: 'Maintenance',
|
||||
href: '/maintenance',
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
label: 'VPN',
|
||||
href: '/vpn',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
label: 'Certificates',
|
||||
href: '/certificates',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Monitor',
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Topology',
|
||||
href: '/topology',
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
label: 'Alerts',
|
||||
href: '/alerts',
|
||||
icon: Bell,
|
||||
badge: <AlertBadge />,
|
||||
},
|
||||
{
|
||||
label: 'Alert Rules',
|
||||
href: '/alert-rules',
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
label: 'Audit Trail',
|
||||
href: '/audit',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
...(isTenantAdmin(user)
|
||||
? [
|
||||
{
|
||||
label: 'Transparency',
|
||||
href: '/transparency',
|
||||
icon: Eye,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Reports',
|
||||
href: '/reports',
|
||||
icon: FileText,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
visible: isSuperAdmin(user) || isTenantAdmin(user),
|
||||
items: [
|
||||
...(isTenantAdmin(user) && user?.tenant_id
|
||||
? [
|
||||
{
|
||||
label: 'Users',
|
||||
href: `/tenants/${user.tenant_id}/users`,
|
||||
icon: Users,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSuperAdmin(user) || isTenantAdmin(user)
|
||||
? [
|
||||
{
|
||||
label: 'Organizations',
|
||||
href: '/tenants',
|
||||
icon: Building2,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'API Keys',
|
||||
href: '/settings/api-keys',
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
label: 'About',
|
||||
href: '/about',
|
||||
icon: Info,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleSections = sections.filter((s) => s.visible)
|
||||
|
||||
const isActive = (item: NavItem) => {
|
||||
if (item.exact) return currentPath === item.href
|
||||
// Settings should only match exact to avoid catching everything
|
||||
if (item.href === '/settings') return currentPath === '/settings' || currentPath.startsWith('/settings/')
|
||||
return currentPath.startsWith(item.href) && item.href.length > 1
|
||||
}
|
||||
|
||||
const sidebarContent = (showCollapsed: boolean) => (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-12 px-3 border-b border-border',
|
||||
showCollapsed ? 'justify-center' : 'gap-2',
|
||||
)}
|
||||
>
|
||||
<RugLogo size={showCollapsed ? 24 : 28} className="flex-shrink-0" />
|
||||
{!showCollapsed && (
|
||||
<span className="text-sm font-semibold text-text-primary truncate">
|
||||
TOD
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-2 overflow-y-auto">
|
||||
{visibleSections.map((section, sectionIdx) => (
|
||||
<div key={section.label}>
|
||||
{showCollapsed && sectionIdx > 0 && (
|
||||
<div className="mx-2 my-1 border-t border-border" />
|
||||
)}
|
||||
{!showCollapsed && (
|
||||
<div className="px-3 pt-4 pb-1 text-[10px] font-semibold uppercase tracking-wider text-text-muted">
|
||||
{section.label}
|
||||
</div>
|
||||
)}
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={`${section.label}-${item.label}`}
|
||||
to={item.href}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 mx-1 rounded-md text-sm transition-colors min-h-[44px]',
|
||||
active
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated/50',
|
||||
showCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
title={showCollapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{!showCollapsed && (
|
||||
<>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.badge && <span className="ml-auto">{item.badge}</span>}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Collapse toggle (hidden on mobile) */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="hidden lg:flex items-center justify-center h-10 border-t border-border text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={showCollapsed ? 'Expand sidebar ([)' : 'Collapse sidebar ([)'}
|
||||
aria-label={showCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
data-testid="sidebar-toggle"
|
||||
>
|
||||
{showCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside
|
||||
data-testid="sidebar"
|
||||
className={cn(
|
||||
'hidden lg:flex flex-col border-r border-border bg-sidebar transition-all duration-200',
|
||||
sidebarCollapsed ? 'w-12' : 'w-60',
|
||||
)}
|
||||
>
|
||||
{sidebarContent(sidebarCollapsed)}
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileSidebarOpen && (
|
||||
<>
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-black/50"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<aside className="lg:hidden fixed inset-y-0 left-0 z-50 w-60 flex flex-col bg-sidebar border-r border-border shadow-xl">
|
||||
{sidebarContent(false)}
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user