Merge branch 'feature/44-navigation-layout' into dev

This commit is contained in:
2025-12-30 02:10:09 +01:00
13 changed files with 2771 additions and 0 deletions

View File

@@ -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<string, string> = {
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 (
<nav
aria-label="Breadcrumb"
className={cn('border-b bg-background px-4 py-3 lg:px-6', className)}
data-testid="breadcrumbs"
>
<ol className="flex flex-wrap items-center gap-1 text-sm">
{/* Home link */}
{showHome && (
<li className="flex items-center">
<Link
href="/"
className="flex items-center text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
aria-label="Home"
data-testid="breadcrumb-home"
>
<Home className="h-4 w-4" aria-hidden="true" />
</Link>
</li>
)}
{/* Breadcrumb items */}
{breadcrumbs.map((breadcrumb, index) => {
const showSeparator = showHome || index > 0;
const testId = `breadcrumb-${breadcrumb.label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<li key={breadcrumb.href} className="flex items-center">
{showSeparator && (
<ChevronRight
className="mx-1 h-4 w-4 text-muted-foreground flex-shrink-0"
aria-hidden="true"
/>
)}
{breadcrumb.current ? (
<span
className="font-medium text-foreground"
aria-current="page"
data-testid={testId}
>
{breadcrumb.label}
</span>
) : (
<Link
href={breadcrumb.href}
className="text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
data-testid={testId}
>
{breadcrumb.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -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 (
<header
className={cn(
'sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60',
className
)}
data-testid="app-header"
>
<div className="flex h-14 items-center px-4 lg:px-6">
{/* Left side - Logo and Project Switcher */}
<div className="flex items-center gap-4">
{/* Logo - visible on mobile, hidden on desktop when sidebar is visible */}
<Link
href="/"
className="flex items-center gap-2 lg:hidden"
aria-label="Syndarix home"
>
<Image
src="/logo-icon.svg"
alt=""
width={28}
height={28}
className="h-7 w-7"
aria-hidden="true"
/>
<span className="font-semibold text-foreground">Syndarix</span>
</Link>
{/* Project Switcher */}
{projects.length > 0 && (
<div className="hidden sm:block">
<ProjectSwitcher
currentProject={currentProject}
projects={projects}
onProjectChange={onProjectChange}
/>
</div>
)}
</div>
{/* Right side - Actions */}
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
<LocaleSwitcher />
<UserMenu />
</div>
</div>
{/* Mobile Project Switcher */}
{projects.length > 0 && (
<div className="border-t px-4 py-2 sm:hidden">
<ProjectSwitcher
currentProject={currentProject}
projects={projects}
onProjectChange={onProjectChange}
/>
</div>
)}
</header>
);
}

View File

@@ -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 (
<div
className={cn('flex min-h-screen flex-col bg-background', wrapperClassName)}
data-testid="app-layout"
>
{/* Header */}
<AppHeader
currentProject={currentProject}
projects={projects}
onProjectChange={onProjectChange}
/>
{/* Main content area with sidebar */}
<div className="flex flex-1">
{/* Sidebar */}
{!hideSidebar && <Sidebar projectSlug={currentProject?.slug} />}
{/* Content area */}
<div className="flex flex-1 flex-col">
{/* Breadcrumbs */}
{!hideBreadcrumbs && <AppBreadcrumbs items={breadcrumbs} />}
{/* Main content */}
<main
className={cn('flex-1', className)}
id="main-content"
tabIndex={-1}
>
{children}
</main>
</div>
</div>
</div>
);
}
/**
* 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<string, string> = {
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 (
<div
className={cn(
'container mx-auto px-4 py-6 lg:px-6 lg:py-8',
maxWidthClasses[maxWidth],
className
)}
data-testid="page-container"
>
{children}
</div>
);
}
/**
* 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 (
<div
className={cn(
'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between',
className
)}
data-testid="page-header"
>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

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

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

View File

@@ -0,0 +1,172 @@
/**
* User Menu Component
* Dropdown menu with user profile, settings, and logout options
*/
'use client';
import { Link } from '@/lib/i18n/routing';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/auth/AuthContext';
import { useLogout } from '@/lib/api/hooks/useAuth';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuGroup,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
User,
LogOut,
Shield,
Lock,
Monitor,
UserCog,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface UserMenuProps {
/** Additional className */
className?: string;
}
/**
* Get user initials for avatar display
*/
function getUserInitials(firstName?: string | null, lastName?: string | null): string {
if (!firstName) return 'U';
const first = firstName.charAt(0).toUpperCase();
const last = lastName?.charAt(0).toUpperCase() || '';
return `${first}${last}`;
}
export function UserMenu({ className }: UserMenuProps) {
const t = useTranslations('navigation');
const { user } = useAuth();
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const handleLogout = () => {
logout();
};
if (!user) {
return null;
}
const initials = getUserInitials(user.first_name, user.last_name);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn('relative h-9 w-9 rounded-full', className)}
data-testid="user-menu-trigger"
aria-label={`User menu for ${user.first_name || user.email}`}
>
<Avatar className="h-9 w-9">
<AvatarFallback className="text-sm">{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
align="end"
data-testid="user-menu-content"
>
{/* User info header */}
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.first_name} {user.last_name}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Settings group */}
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link
href="/settings/profile"
className="cursor-pointer"
data-testid="user-menu-profile"
>
<User className="mr-2 h-4 w-4" aria-hidden="true" />
{t('profile')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href="/settings/password"
className="cursor-pointer"
data-testid="user-menu-password"
>
<Lock className="mr-2 h-4 w-4" aria-hidden="true" />
{t('settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href="/settings/sessions"
className="cursor-pointer"
data-testid="user-menu-sessions"
>
<Monitor className="mr-2 h-4 w-4" aria-hidden="true" />
Sessions
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href="/settings/preferences"
className="cursor-pointer"
data-testid="user-menu-preferences"
>
<UserCog className="mr-2 h-4 w-4" aria-hidden="true" />
Preferences
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
{/* Admin link (superusers only) */}
{user.is_superuser && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="/admin"
className="cursor-pointer"
data-testid="user-menu-admin"
>
<Shield className="mr-2 h-4 w-4" aria-hidden="true" />
{t('adminPanel')}
</Link>
</DropdownMenuItem>
</>
)}
{/* Logout */}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive focus:bg-destructive/10"
onClick={handleLogout}
disabled={isLoggingOut}
data-testid="user-menu-logout"
>
<LogOut className="mr-2 h-4 w-4" aria-hidden="true" />
{isLoggingOut ? t('loggingOut') : t('logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -3,7 +3,17 @@
* Common layout elements for authenticated pages
*/
// Existing components
export { Header } from './Header';
export { Footer } from './Footer';
export { HeaderSkeleton } from './HeaderSkeleton';
export { AuthLoadingSkeleton } from './AuthLoadingSkeleton';
// Application layout components (Issue #44)
export { AppLayout, PageContainer, PageHeader } from './AppLayout';
export { AppHeader } from './AppHeader';
export { Sidebar } from './Sidebar';
export { AppBreadcrumbs } from './AppBreadcrumbs';
export type { BreadcrumbItem } from './AppBreadcrumbs';
export { ProjectSwitcher, ProjectSelect } from './ProjectSwitcher';
export { UserMenu } from './UserMenu';