From 6e645835dca2ec88fca3018edc2c3d6b8788324f Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Tue, 30 Dec 2025 01:35:39 +0100 Subject: [PATCH] feat(frontend): Implement navigation and layout (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/layout/AppBreadcrumbs.tsx | 149 +++++++ frontend/src/components/layout/AppHeader.tsx | 100 +++++ frontend/src/components/layout/AppLayout.tsx | 170 ++++++++ .../src/components/layout/ProjectSwitcher.tsx | 191 +++++++++ frontend/src/components/layout/Sidebar.tsx | 319 +++++++++++++++ frontend/src/components/layout/UserMenu.tsx | 172 ++++++++ frontend/src/components/layout/index.ts | 10 + .../components/layout/AppBreadcrumbs.test.tsx | 179 ++++++++ .../components/layout/AppHeader.test.tsx | 177 ++++++++ .../components/layout/AppLayout.test.tsx | 386 ++++++++++++++++++ .../layout/ProjectSwitcher.test.tsx | 273 +++++++++++++ .../tests/components/layout/Sidebar.test.tsx | 322 +++++++++++++++ .../tests/components/layout/UserMenu.test.tsx | 323 +++++++++++++++ 13 files changed, 2771 insertions(+) create mode 100644 frontend/src/components/layout/AppBreadcrumbs.tsx create mode 100644 frontend/src/components/layout/AppHeader.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/layout/ProjectSwitcher.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/layout/UserMenu.tsx create mode 100644 frontend/tests/components/layout/AppBreadcrumbs.test.tsx create mode 100644 frontend/tests/components/layout/AppHeader.test.tsx create mode 100644 frontend/tests/components/layout/AppLayout.test.tsx create mode 100644 frontend/tests/components/layout/ProjectSwitcher.test.tsx create mode 100644 frontend/tests/components/layout/Sidebar.test.tsx create mode 100644 frontend/tests/components/layout/UserMenu.test.tsx diff --git a/frontend/src/components/layout/AppBreadcrumbs.tsx b/frontend/src/components/layout/AppBreadcrumbs.tsx new file mode 100644 index 0000000..a242af0 --- /dev/null +++ b/frontend/src/components/layout/AppBreadcrumbs.tsx @@ -0,0 +1,149 @@ +/** + * Application Breadcrumbs + * Displays navigation breadcrumb trail for application pages + * Supports dynamic breadcrumb items and project context + */ + +'use client'; + +import { Link } from '@/lib/i18n/routing'; +import { usePathname } from '@/lib/i18n/routing'; +import { ChevronRight, Home } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface BreadcrumbItem { + /** Display label */ + label: string; + /** Navigation href */ + href: string; + /** If true, this is the current page */ + current?: boolean; +} + +interface AppBreadcrumbsProps { + /** Custom breadcrumb items (overrides auto-generation) */ + items?: BreadcrumbItem[]; + /** Show home icon as first item */ + showHome?: boolean; + /** Additional className */ + className?: string; +} + +/** Path segment to label mappings */ +const pathLabels: Record = { + projects: 'Projects', + issues: 'Issues', + sprints: 'Sprints', + agents: 'Agents', + settings: 'Settings', + dashboard: 'Dashboard', + admin: 'Admin', + 'agent-types': 'Agent Types', + users: 'Users', + organizations: 'Organizations', +}; + +/** + * Generate breadcrumb items from the current pathname + */ +function generateBreadcrumbs(pathname: string): BreadcrumbItem[] { + const segments = pathname.split('/').filter(Boolean); + const breadcrumbs: BreadcrumbItem[] = []; + + let currentPath = ''; + + segments.forEach((segment, index) => { + currentPath += `/${segment}`; + + // Skip locale segments (2-letter codes) + if (segment.length === 2 && /^[a-z]{2}$/i.test(segment)) { + return; + } + + // Check if segment is a dynamic ID/slug (not in pathLabels) + const label = pathLabels[segment] || segment; + const isLast = index === segments.length - 1; + + breadcrumbs.push({ + label, + href: currentPath, + current: isLast, + }); + }); + + return breadcrumbs; +} + +export function AppBreadcrumbs({ + items, + showHome = true, + className, +}: AppBreadcrumbsProps) { + const pathname = usePathname(); + + // Use provided items or generate from pathname + const breadcrumbs = items ?? generateBreadcrumbs(pathname); + + // Don't render if no breadcrumbs or only home + if (breadcrumbs.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..cfe4632 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.tsx @@ -0,0 +1,100 @@ +/** + * Application Header Component + * Top header bar for the main application layout + * Includes logo, project switcher, and user menu + */ + +'use client'; + +import Image from 'next/image'; +import { Link } from '@/lib/i18n/routing'; +import { cn } from '@/lib/utils'; +import { ThemeToggle } from '@/components/theme'; +import { LocaleSwitcher } from '@/components/i18n'; +import { ProjectSwitcher } from './ProjectSwitcher'; +import { UserMenu } from './UserMenu'; + +interface Project { + id: string; + slug: string; + name: string; +} + +interface AppHeaderProps { + /** 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 AppHeader({ + currentProject, + projects = [], + onProjectChange, + className, +}: AppHeaderProps) { + return ( +
+
+ {/* Left side - Logo and Project Switcher */} +
+ {/* Logo - visible on mobile, hidden on desktop when sidebar is visible */} + + + Syndarix + + + {/* Project Switcher */} + {projects.length > 0 && ( +
+ +
+ )} +
+ + {/* Right side - Actions */} +
+ + + +
+
+ + {/* Mobile Project Switcher */} + {projects.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..cc4840c --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,170 @@ +/** + * Application Layout Component + * Main layout wrapper that combines sidebar, header, breadcrumbs, and content area + * Provides the standard application shell for authenticated pages + */ + +'use client'; + +import { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { Sidebar } from './Sidebar'; +import { AppHeader } from './AppHeader'; +import { AppBreadcrumbs, type BreadcrumbItem } from './AppBreadcrumbs'; + +interface Project { + id: string; + slug: string; + name: string; +} + +interface AppLayoutProps { + /** Page content */ + children: ReactNode; + /** Current project (for project-specific pages) */ + currentProject?: Project; + /** List of available projects */ + projects?: Project[]; + /** Callback when project is changed */ + onProjectChange?: (projectSlug: string) => void; + /** Custom breadcrumb items (overrides auto-generation) */ + breadcrumbs?: BreadcrumbItem[]; + /** Hide breadcrumbs */ + hideBreadcrumbs?: boolean; + /** Hide sidebar */ + hideSidebar?: boolean; + /** Additional className for main content area */ + className?: string; + /** Additional className for the outer wrapper */ + wrapperClassName?: string; +} + +export function AppLayout({ + children, + currentProject, + projects = [], + onProjectChange, + breadcrumbs, + hideBreadcrumbs = false, + hideSidebar = false, + className, + wrapperClassName, +}: AppLayoutProps) { + return ( +
+ {/* Header */} + + + {/* Main content area with sidebar */} +
+ {/* Sidebar */} + {!hideSidebar && } + + {/* Content area */} +
+ {/* Breadcrumbs */} + {!hideBreadcrumbs && } + + {/* Main content */} +
+ {children} +
+
+
+
+ ); +} + +/** + * Page Container Component + * Standard container for page content with consistent padding and max-width + */ +interface PageContainerProps { + /** Page content */ + children: ReactNode; + /** Maximum width constraint */ + maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '6xl' | 'full'; + /** Additional className */ + className?: string; +} + +const maxWidthClasses: Record = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + '2xl': 'max-w-2xl', + '4xl': 'max-w-4xl', + '6xl': 'max-w-6xl', + full: 'max-w-full', +}; + +export function PageContainer({ + children, + maxWidth = '6xl', + className, +}: PageContainerProps) { + return ( +
+ {children} +
+ ); +} + +/** + * Page Header Component + * Consistent header for page titles and actions + */ +interface PageHeaderProps { + /** Page title */ + title: string; + /** Optional description */ + description?: string; + /** Action buttons or other content */ + actions?: ReactNode; + /** Additional className */ + className?: string; +} + +export function PageHeader({ + title, + description, + actions, + className, +}: PageHeaderProps) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/src/components/layout/ProjectSwitcher.tsx b/frontend/src/components/layout/ProjectSwitcher.tsx new file mode 100644 index 0000000..0c624e5 --- /dev/null +++ b/frontend/src/components/layout/ProjectSwitcher.tsx @@ -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 ( + + ); + } + + return ( + + + + + + Projects + + {projects.map((project) => ( + handleProjectChange(project.slug)} + className="cursor-pointer" + data-testid={`project-option-${project.slug}`} + > + + ))} + + + + + + ); +} + +/** + * 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 ( + + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..2df71b9 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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 ( + +