feat(frontend): add Projects list page and components for #54

Implement the projects CRUD page with:
- ProjectCard: Card component with status badge, progress, metrics, actions
- ProjectFilters: Search, status filter, complexity, sort controls
- ProjectsGrid: Grid/list view toggle with loading and empty states
- useProjects hook: Mock data with filtering, sorting, pagination

Features include:
- Debounced search (300ms)
- Quick filters (status) and extended filters (complexity, sort)
- Grid and list view toggle
- Click navigation to project detail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 17:20:17 +01:00
parent 6f5dd58b54
commit 50b865b23b
5 changed files with 1006 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
/**
* ProjectCard Component
*
* Displays a project card for the projects grid view.
* Shows project status, progress, metrics, and quick info.
*
* @see Issue #54
*/
'use client';
import { Bot, CircleDot, Clock, MoreVertical, Archive, Play, Pause } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { ProjectStatusBadge } from './StatusBadge';
import { ProgressBar } from './ProgressBar';
import type { ProjectListItem } from '@/lib/api/hooks/useProjects';
export interface ProjectCardProps {
/** Project data */
project: ProjectListItem;
/** Called when card is clicked */
onClick?: () => void;
/** Called when action menu item is selected */
onAction?: (action: 'archive' | 'pause' | 'resume' | 'delete') => void;
/** Additional CSS classes */
className?: string;
}
/**
* Complexity indicator dots
*/
function ComplexityIndicator({ complexity }: { complexity: 'low' | 'medium' | 'high' }) {
const levels = { low: 1, medium: 2, high: 3 };
const level = levels[complexity];
return (
<div className="flex items-center gap-0.5" title={`${complexity} complexity`}>
{[1, 2, 3].map((i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full',
i <= level ? 'bg-primary' : 'bg-muted'
)}
/>
))}
</div>
);
}
/**
* Loading skeleton for project cards
*/
export function ProjectCardSkeleton() {
return (
<Card className="h-[220px]">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="mt-2 h-6 w-3/4" />
<Skeleton className="mt-2 h-4 w-full" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-2 w-full" />
<div className="flex flex-wrap gap-1">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="flex justify-between">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
</CardContent>
</Card>
);
}
export function ProjectCard({ project, onClick, onAction, className }: ProjectCardProps) {
return (
<Card
className={cn(
'group h-full cursor-pointer transition-all hover:border-primary hover:shadow-md',
className
)}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
/* istanbul ignore next -- keyboard handler */
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<ProjectStatusBadge status={project.status} />
<div className="flex items-center gap-2">
<ComplexityIndicator complexity={project.complexity} />
{onAction && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Project actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{project.status === 'paused' ? (
<DropdownMenuItem onClick={() => onAction('resume')}>
<Play className="mr-2 h-4 w-4" />
Resume Project
</DropdownMenuItem>
) : project.status === 'active' ? (
<DropdownMenuItem onClick={() => onAction('pause')}>
<Pause className="mr-2 h-4 w-4" />
Pause Project
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => onAction('archive')}>
<Archive className="mr-2 h-4 w-4" />
Archive Project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onAction('delete')}
>
Delete Project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<CardTitle className="mt-2 line-clamp-1 text-lg">{project.name}</CardTitle>
{project.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">{project.description}</p>
)}
</CardHeader>
<CardContent className="space-y-3">
<ProgressBar value={project.progress} size="sm" showLabel />
{project.tags && project.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{project.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{project.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{project.tags.length - 3}
</Badge>
)}
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Bot className="h-3 w-3" />
{project.activeAgents}
</span>
<span className="flex items-center gap-1">
<CircleDot className="h-3 w-3" />
{project.openIssues}
</span>
</div>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{project.lastActivity}
</span>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,272 @@
/**
* ProjectFilters Component
*
* Filter controls for the projects list including:
* - Search input with debounce
* - Status filter
* - Extended filters (complexity, etc.)
* - Sort controls
* - View mode toggle (grid/list)
*
* @see Issue #54
*/
'use client';
import { useState } from 'react';
import {
Search,
Filter,
LayoutGrid,
List,
ChevronDown,
X,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { ProjectStatus } from './types';
export type ViewMode = 'grid' | 'list';
export type SortBy = 'recent' | 'name' | 'progress' | 'issues';
export type SortOrder = 'asc' | 'desc';
export type Complexity = 'low' | 'medium' | 'high' | 'all';
export interface ProjectFiltersProps {
/** Current search query */
searchQuery: string;
/** Called when search changes */
onSearchChange: (query: string) => void;
/** Current status filter */
statusFilter: ProjectStatus | 'all';
/** Called when status filter changes */
onStatusFilterChange: (status: ProjectStatus | 'all') => void;
/** Current complexity filter */
complexityFilter: Complexity;
/** Called when complexity filter changes */
onComplexityFilterChange: (complexity: Complexity) => void;
/** Current sort field */
sortBy: SortBy;
/** Called when sort field changes */
onSortByChange: (sortBy: SortBy) => void;
/** Current sort order */
sortOrder: SortOrder;
/** Called when sort order changes */
onSortOrderChange: (order: SortOrder) => void;
/** Current view mode */
viewMode: ViewMode;
/** Called when view mode changes */
onViewModeChange: (mode: ViewMode) => void;
/** Additional CSS classes */
className?: string;
}
const statusOptions: { value: ProjectStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'paused', label: 'Paused' },
{ value: 'completed', label: 'Completed' },
{ value: 'archived', label: 'Archived' },
];
const complexityOptions: { value: Complexity; label: string }[] = [
{ value: 'all', label: 'All Complexity' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
];
const sortOptions: { value: SortBy; label: string }[] = [
{ value: 'recent', label: 'Most Recent' },
{ value: 'name', label: 'Name' },
{ value: 'progress', label: 'Progress' },
{ value: 'issues', label: 'Issues' },
];
export function ProjectFilters({
searchQuery,
onSearchChange,
statusFilter,
onStatusFilterChange,
complexityFilter,
onComplexityFilterChange,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
viewMode,
onViewModeChange,
className,
}: ProjectFiltersProps) {
const [showExtended, setShowExtended] = useState(false);
// Check if any filters are active
const hasActiveFilters =
statusFilter !== 'all' || complexityFilter !== 'all' || searchQuery !== '';
// Count active filters for badge
const activeFilterCount =
(statusFilter !== 'all' ? 1 : 0) +
(complexityFilter !== 'all' ? 1 : 0) +
(searchQuery !== '' ? 1 : 0);
const clearAllFilters = () => {
onSearchChange('');
onStatusFilterChange('all');
onComplexityFilterChange('all');
};
return (
<div className={cn('space-y-4', className)}>
{/* Quick Filters Row */}
<div className="flex flex-col gap-4 sm:flex-row">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
aria-label="Search projects"
/>
</div>
{/* Status Filter */}
<Select
value={statusFilter}
onValueChange={(value) => onStatusFilterChange(value as ProjectStatus | 'all')}
>
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Extended Filters Toggle */}
<Button
variant="outline"
onClick={() => setShowExtended(!showExtended)}
className={cn(hasActiveFilters && 'border-primary')}
>
<Filter className="mr-2 h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-2">
{activeFilterCount}
</Badge>
)}
<ChevronDown
className={cn('ml-2 h-4 w-4 transition-transform', showExtended && 'rotate-180')}
/>
</Button>
{/* View Mode Toggle */}
<div className="flex rounded-lg border">
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => onViewModeChange('grid')}
aria-label="Grid view"
className="rounded-r-none"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => onViewModeChange('list')}
aria-label="List view"
className="rounded-l-none"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
{/* Extended Filters */}
{showExtended && (
<Card className="p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
{/* Complexity Filter */}
<div className="flex-1">
<label className="mb-2 block text-sm font-medium">Complexity</label>
<Select
value={complexityFilter}
onValueChange={(value) => onComplexityFilterChange(value as Complexity)}
>
<SelectTrigger aria-label="Filter by complexity">
<SelectValue placeholder="Complexity" />
</SelectTrigger>
<SelectContent>
{complexityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Sort By */}
<div className="flex-1">
<label className="mb-2 block text-sm font-medium">Sort By</label>
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as SortBy)}>
<SelectTrigger aria-label="Sort by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Sort Order */}
<div className="flex-1">
<label className="mb-2 block text-sm font-medium">Order</label>
<Select
value={sortOrder}
onValueChange={(value) => onSortOrderChange(value as SortOrder)}
>
<SelectTrigger aria-label="Sort order">
<SelectValue placeholder="Order" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Descending</SelectItem>
<SelectItem value="asc">Ascending</SelectItem>
</SelectContent>
</Select>
</div>
{/* Clear Filters */}
{hasActiveFilters && (
<Button variant="ghost" onClick={clearAllFilters} className="gap-2">
<X className="h-4 w-4" />
Clear Filters
</Button>
)}
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
/**
* ProjectsGrid Component
*
* Displays projects in either grid or list view with
* loading and empty states.
*
* @see Issue #54
*/
'use client';
import { Folder, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Link } from '@/lib/i18n/routing';
import { cn } from '@/lib/utils';
import { ProjectCard, ProjectCardSkeleton } from './ProjectCard';
import type { ProjectListItem } from '@/lib/api/hooks/useProjects';
import type { ViewMode } from './ProjectFilters';
export interface ProjectsGridProps {
/** Projects to display */
projects: ProjectListItem[];
/** Whether data is loading */
isLoading?: boolean;
/** Current view mode */
viewMode?: ViewMode;
/** Called when a project card is clicked */
onProjectClick?: (project: ProjectListItem) => void;
/** Called when a project action is selected */
onProjectAction?: (project: ProjectListItem, action: 'archive' | 'pause' | 'resume' | 'delete') => void;
/** Whether filters are currently applied (affects empty state message) */
hasFilters?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Empty state component
*/
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
return (
<div className="py-16 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Folder className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">No projects found</h3>
<p className="mt-1 text-muted-foreground">
{hasFilters
? 'Try adjusting your filters or search query'
: 'Get started by creating your first project'}
</p>
{!hasFilters && (
<Button asChild className="mt-4">
<Link href="/projects/new">
<Plus className="mr-2 h-4 w-4" />
Create Project
</Link>
</Button>
)}
</div>
);
}
/**
* Loading skeleton grid
*/
function LoadingSkeleton({ viewMode }: { viewMode: ViewMode }) {
return (
<div
className={cn(
viewMode === 'grid'
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-4'
)}
>
{[1, 2, 3, 4, 5, 6].map((i) => (
<ProjectCardSkeleton key={i} />
))}
</div>
);
}
export function ProjectsGrid({
projects,
isLoading = false,
viewMode = 'grid',
onProjectClick,
onProjectAction,
hasFilters = false,
className,
}: ProjectsGridProps) {
if (isLoading) {
return <LoadingSkeleton viewMode={viewMode} />;
}
if (projects.length === 0) {
return <EmptyState hasFilters={hasFilters} />;
}
return (
<div
className={cn(
viewMode === 'grid'
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-4',
className
)}
>
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
onClick={() => onProjectClick?.(project)}
onAction={onProjectAction ? (action) => onProjectAction(project, action) : undefined}
/>
))}
</div>
);
}