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:
2025-12-30 01:35:39 +01:00
parent d6db6af964
commit 6e645835dc
13 changed files with 2771 additions and 0 deletions

View 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>
</>
);
}