forked from cardosofelipe/fast-next-template
Merge branch 'feature/44-navigation-layout' into dev
This commit is contained in:
149
frontend/src/components/layout/AppBreadcrumbs.tsx
Normal file
149
frontend/src/components/layout/AppBreadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/layout/AppHeader.tsx
Normal file
100
frontend/src/components/layout/AppHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/layout/AppLayout.tsx
Normal file
170
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/layout/ProjectSwitcher.tsx
Normal file
191
frontend/src/components/layout/ProjectSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/layout/UserMenu.tsx
Normal file
172
frontend/src/components/layout/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user