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,191 @@
/**
* Project Switcher Component
* Dropdown selector for switching between projects
* Displays current project and allows quick project navigation
*/
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from '@/lib/i18n/routing';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { FolderKanban, Plus, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Project {
id: string;
slug: string;
name: string;
/** Optional description */
description?: string;
}
interface ProjectSwitcherProps {
/** Currently selected project */
currentProject?: Project;
/** List of available projects */
projects: Project[];
/** Callback when project is changed */
onProjectChange?: (projectSlug: string) => void;
/** Additional className */
className?: string;
}
export function ProjectSwitcher({
currentProject,
projects,
onProjectChange,
className,
}: ProjectSwitcherProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const handleProjectChange = useCallback(
(projectSlug: string) => {
if (onProjectChange) {
onProjectChange(projectSlug);
} else {
// Default behavior: navigate to project dashboard
router.push(`/projects/${projectSlug}`);
}
setOpen(false);
},
[onProjectChange, router]
);
const handleCreateProject = useCallback(() => {
router.push('/projects/new');
setOpen(false);
}, [router]);
// If no projects, show create button
if (projects.length === 0) {
return (
<Button
variant="outline"
size="sm"
onClick={handleCreateProject}
className={cn('gap-2', className)}
data-testid="create-project-button"
>
<Plus className="h-4 w-4" aria-hidden="true" />
<span>Create Project</span>
</Button>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('gap-2 min-w-[160px] justify-between', className)}
data-testid="project-switcher-trigger"
aria-label={
currentProject
? `Switch project, current: ${currentProject.name}`
: 'Select project'
}
>
<div className="flex items-center gap-2">
<FolderKanban className="h-4 w-4" aria-hidden="true" />
<span className="truncate max-w-[120px]">
{currentProject?.name ?? 'Select Project'}
</span>
</div>
<ChevronDown className="h-4 w-4 opacity-50" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[200px]"
data-testid="project-switcher-menu"
>
<DropdownMenuLabel>Projects</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onSelect={() => handleProjectChange(project.slug)}
className="cursor-pointer"
data-testid={`project-option-${project.slug}`}
>
<FolderKanban className="mr-2 h-4 w-4" aria-hidden="true" />
<span className="truncate">{project.name}</span>
{currentProject?.id === project.id && (
<span className="ml-auto text-xs text-muted-foreground">Current</span>
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={handleCreateProject}
className="cursor-pointer"
data-testid="create-project-option"
>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
<span>Create New Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* Alternative version using Select component for simpler use cases
*/
interface ProjectSelectProps {
/** Currently selected project slug */
value?: string;
/** List of available projects */
projects: Project[];
/** Callback when project is selected */
onValueChange: (projectSlug: string) => void;
/** Placeholder text */
placeholder?: string;
/** Additional className */
className?: string;
}
export function ProjectSelect({
value,
projects,
onValueChange,
placeholder = 'Select a project',
className,
}: ProjectSelectProps) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className={cn('w-[200px]', className)} data-testid="project-select">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem
key={project.id}
value={project.slug}
data-testid={`project-select-option-${project.slug}`}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}