feat(frontend): Implement navigation and layout (#44)
Implements the main navigation and layout structure: - Sidebar component with collapsible navigation and keyboard shortcut - AppHeader with project switcher and user menu - AppBreadcrumbs with auto-generation from pathname - ProjectSwitcher dropdown for quick project navigation - UserMenu with profile, settings, and logout - AppLayout component combining all layout elements Features: - Responsive design (mobile sidebar sheet, desktop sidebar) - Keyboard navigation (Cmd/Ctrl+B to toggle sidebar) - Dark mode support - WCAG AA accessible (ARIA labels, focus management) All 125 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
319
frontend/src/components/layout/Sidebar.tsx
Normal file
319
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Main Navigation Sidebar
|
||||
* Displays navigation links for the main application (projects, agents, etc.)
|
||||
* Collapsible on mobile with responsive behavior
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { usePathname } from '@/lib/i18n/routing';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
FolderKanban,
|
||||
Bot,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
IterationCw,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
/** If true, only matches exact path */
|
||||
exact?: boolean;
|
||||
/** If true, only visible to superusers */
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
name: 'Projects',
|
||||
href: '/projects',
|
||||
icon: FolderKanban,
|
||||
exact: true,
|
||||
},
|
||||
];
|
||||
|
||||
const projectNavItems: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
name: 'Issues',
|
||||
href: '/issues',
|
||||
icon: ListTodo,
|
||||
},
|
||||
{
|
||||
name: 'Sprints',
|
||||
href: '/sprints',
|
||||
icon: IterationCw,
|
||||
},
|
||||
{
|
||||
name: 'Agents',
|
||||
href: '/agents',
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{
|
||||
name: 'Agent Types',
|
||||
href: '/admin/agent-types',
|
||||
icon: Bot,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'Admin Panel',
|
||||
href: '/admin',
|
||||
icon: Shield,
|
||||
adminOnly: true,
|
||||
exact: true,
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
/** Current project slug, if viewing a project */
|
||||
projectSlug?: string;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface NavLinkProps {
|
||||
item: NavItem;
|
||||
collapsed: boolean;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
function NavLink({ item, collapsed, basePath = '' }: NavLinkProps) {
|
||||
const pathname = usePathname();
|
||||
const href = basePath ? `${basePath}${item.href}` : item.href;
|
||||
|
||||
const isActive = item.exact
|
||||
? pathname === href
|
||||
: pathname.startsWith(href);
|
||||
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
|
||||
collapsed && 'justify-center'
|
||||
)}
|
||||
title={collapsed ? item.name : undefined}
|
||||
data-testid={`nav-${item.name.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
||||
{!collapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
collapsed,
|
||||
projectSlug,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
projectSlug?: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.is_superuser ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex h-14 items-center justify-between border-b px-4">
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-semibold text-foreground">Navigation</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="h-8 w-8"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
data-testid="sidebar-toggle"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-2" aria-label="Main navigation">
|
||||
{/* Main navigation */}
|
||||
<div className="space-y-1">
|
||||
{!collapsed && (
|
||||
<span className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Main
|
||||
</span>
|
||||
)}
|
||||
{mainNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Project-specific navigation */}
|
||||
{projectSlug && (
|
||||
<div className="space-y-1 pt-4">
|
||||
{!collapsed && (
|
||||
<span className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Project
|
||||
</span>
|
||||
)}
|
||||
{projectNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
basePath={`/projects/${projectSlug}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin navigation */}
|
||||
{isAdmin && (
|
||||
<div className="space-y-1 pt-4">
|
||||
{!collapsed && (
|
||||
<span className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
{adminNavItems
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) => (
|
||||
<NavLink key={item.href} item={item} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User Info (only when expanded) */}
|
||||
{!collapsed && user && (
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-medium">
|
||||
{user.first_name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ projectSlug, className }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Handle keyboard shortcut for sidebar toggle (Cmd/Ctrl + B)
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'b') {
|
||||
event.preventDefault();
|
||||
setCollapsed((prev) => !prev);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile sidebar trigger */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden fixed left-4 top-4 z-40"
|
||||
aria-label="Open navigation menu"
|
||||
data-testid="mobile-menu-trigger"
|
||||
>
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SidebarContent
|
||||
collapsed={false}
|
||||
projectSlug={projectSlug}
|
||||
onToggle={() => setMobileOpen(false)}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'hidden lg:flex flex-col border-r bg-muted/40 transition-all duration-300',
|
||||
collapsed ? 'w-16' : 'w-64',
|
||||
className
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<SidebarContent
|
||||
collapsed={collapsed}
|
||||
projectSlug={projectSlug}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user