Skip to content

Commit 215186c

Browse files
Copilottikazyq
andcommitted
feat(ui-components): Add ProjectCard and Stats components
Phase 4 & 6 implementation: - Add ProjectCard with avatar, description, tags, stats - Add StatsCard with icon, value, trend indicator - Add StatsOverview grid of stats cards - Add ProgressBar with variants (success, warning, danger, info) - Add Storybook stories for all new components - Update spec 185 with progress notes Bundle size: ~32KB gzipped Note: ProjectSwitcher and SpecDependencyGraph remain in packages/ui as they require framework-specific routing integration. Co-authored-by: tikazyq <[email protected]>
1 parent ab95d9f commit 215186c

File tree

13 files changed

+944
-4
lines changed

13 files changed

+944
-4
lines changed

packages/ui-components/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ function MyComponent() {
5252
### Project Components
5353

5454
- `ProjectAvatar` - Avatar with initials and color from project name
55+
- `ProjectCard` - Project card with avatar, description, stats, and tags
56+
57+
### Stats Components
58+
59+
- `StatsCard` - Single stat card with icon and trend indicator
60+
- `StatsOverview` - Grid of stats cards for project overview
61+
- `ProgressBar` - Horizontal progress bar with variants
5562

5663
### Search & Filter Components
5764

packages/ui-components/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './layout';
44
export * from './project';
55
export * from './navigation';
66
export * from './search';
7+
export * from './stats';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { ProjectAvatar, type ProjectAvatarProps } from './project-avatar';
2+
export { ProjectCard, type ProjectCardProps, type ProjectCardData } from './project-card';
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* ProjectCard component
3+
* Displays a project card with avatar, name, description, and stats
4+
*/
5+
6+
import { Star, FileText, MoreHorizontal } from 'lucide-react';
7+
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
8+
import { Button } from '@/components/ui/button';
9+
import { Badge } from '@/components/ui/badge';
10+
import { ProjectAvatar } from './project-avatar';
11+
import { cn } from '@/lib/utils';
12+
import { formatRelativeTime } from '@/lib/date-utils';
13+
14+
export interface ProjectCardData {
15+
id: string;
16+
name: string;
17+
description?: string | null;
18+
color?: string | null;
19+
icon?: string | null;
20+
favorite?: boolean;
21+
specsCount?: number;
22+
updatedAt?: string | Date | null;
23+
tags?: string[];
24+
}
25+
26+
export interface ProjectCardProps {
27+
/** Project data to display */
28+
project: ProjectCardData;
29+
/** Click handler for the card */
30+
onClick?: () => void;
31+
/** Handler for favorite toggle */
32+
onFavoriteToggle?: (favorite: boolean) => void;
33+
/** Handler for more options */
34+
onMoreOptions?: () => void;
35+
/** Whether the card is currently selected */
36+
selected?: boolean;
37+
/** Additional CSS classes */
38+
className?: string;
39+
/** Locale for date formatting */
40+
locale?: string;
41+
/** Labels for localization */
42+
labels?: {
43+
specs?: string;
44+
spec?: string;
45+
updated?: string;
46+
noDescription?: string;
47+
toggleFavorite?: string;
48+
moreOptions?: string;
49+
};
50+
}
51+
52+
const defaultLabels = {
53+
specs: 'specs',
54+
spec: 'spec',
55+
updated: 'Updated',
56+
noDescription: 'No description',
57+
toggleFavorite: 'Toggle favorite',
58+
moreOptions: 'More options',
59+
};
60+
61+
export function ProjectCard({
62+
project,
63+
onClick,
64+
onFavoriteToggle,
65+
onMoreOptions,
66+
selected = false,
67+
className,
68+
locale,
69+
labels = {},
70+
}: ProjectCardProps) {
71+
const l = { ...defaultLabels, ...labels };
72+
const specsLabel = project.specsCount === 1 ? l.spec : l.specs;
73+
74+
return (
75+
<Card
76+
className={cn(
77+
'cursor-pointer transition-all hover:shadow-md hover:border-primary/50',
78+
selected && 'border-primary ring-2 ring-primary/20',
79+
className
80+
)}
81+
onClick={onClick}
82+
role="button"
83+
tabIndex={0}
84+
onKeyDown={(e) => {
85+
if (e.key === 'Enter' || e.key === ' ') {
86+
e.preventDefault();
87+
onClick?.();
88+
}
89+
}}
90+
>
91+
<CardHeader className="pb-3">
92+
<div className="flex items-start justify-between gap-2">
93+
<div className="flex items-center gap-3 min-w-0">
94+
<ProjectAvatar
95+
name={project.name}
96+
color={project.color || undefined}
97+
icon={project.icon || undefined}
98+
size="lg"
99+
/>
100+
<div className="min-w-0 flex-1">
101+
<div className="flex items-center gap-2">
102+
<h3 className="font-semibold text-base leading-tight truncate">
103+
{project.name}
104+
</h3>
105+
{project.favorite && (
106+
<Star className="h-4 w-4 shrink-0 fill-yellow-500 text-yellow-500" />
107+
)}
108+
</div>
109+
<p className="text-sm text-muted-foreground truncate mt-0.5">
110+
{project.description || l.noDescription}
111+
</p>
112+
</div>
113+
</div>
114+
<div className="flex items-center gap-1 shrink-0">
115+
{onFavoriteToggle && (
116+
<Button
117+
variant="ghost"
118+
size="icon"
119+
className="h-8 w-8"
120+
onClick={(e) => {
121+
e.stopPropagation();
122+
onFavoriteToggle(!project.favorite);
123+
}}
124+
aria-label={l.toggleFavorite}
125+
>
126+
<Star
127+
className={cn(
128+
'h-4 w-4',
129+
project.favorite ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'
130+
)}
131+
/>
132+
</Button>
133+
)}
134+
{onMoreOptions && (
135+
<Button
136+
variant="ghost"
137+
size="icon"
138+
className="h-8 w-8"
139+
onClick={(e) => {
140+
e.stopPropagation();
141+
onMoreOptions();
142+
}}
143+
aria-label={l.moreOptions}
144+
>
145+
<MoreHorizontal className="h-4 w-4" />
146+
</Button>
147+
)}
148+
</div>
149+
</div>
150+
</CardHeader>
151+
152+
<CardContent className="pt-0 pb-3">
153+
{/* Tags */}
154+
{project.tags && project.tags.length > 0 && (
155+
<div className="flex flex-wrap gap-1 mb-3">
156+
{project.tags.slice(0, 3).map((tag, index) => (
157+
<Badge
158+
key={`${tag}-${index}`}
159+
variant="secondary"
160+
className="text-xs px-1.5 py-0 h-5"
161+
>
162+
{tag}
163+
</Badge>
164+
))}
165+
{project.tags.length > 3 && (
166+
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5">
167+
+{project.tags.length - 3}
168+
</Badge>
169+
)}
170+
</div>
171+
)}
172+
</CardContent>
173+
174+
<CardFooter className="pt-0">
175+
<div className="flex items-center justify-between w-full text-xs text-muted-foreground">
176+
<div className="flex items-center gap-1">
177+
<FileText className="h-3.5 w-3.5" />
178+
<span>
179+
{project.specsCount ?? 0} {specsLabel}
180+
</span>
181+
</div>
182+
{project.updatedAt && (
183+
<span>
184+
{l.updated} {formatRelativeTime(project.updatedAt, locale)}
185+
</span>
186+
)}
187+
</div>
188+
</CardFooter>
189+
</Card>
190+
);
191+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { StatsCard, type StatsCardProps } from './stats-card';
2+
export { StatsOverview, type StatsOverviewProps, type StatsData } from './stats-overview';
3+
export { ProgressBar, type ProgressBarProps } from './progress-bar';
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* ProgressBar component
3+
* Displays a horizontal progress bar with optional label
4+
*/
5+
6+
import { cn } from '@/lib/utils';
7+
8+
export interface ProgressBarProps {
9+
/** Progress value (0-100) */
10+
value: number;
11+
/** Label to display */
12+
label?: string;
13+
/** Show percentage */
14+
showPercentage?: boolean;
15+
/** Color variant */
16+
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info';
17+
/** Size variant */
18+
size?: 'sm' | 'md' | 'lg';
19+
/** Additional CSS classes */
20+
className?: string;
21+
}
22+
23+
const variantClasses = {
24+
default: 'bg-primary',
25+
success: 'bg-green-500',
26+
warning: 'bg-orange-500',
27+
danger: 'bg-red-500',
28+
info: 'bg-blue-500',
29+
};
30+
31+
const sizeClasses = {
32+
sm: 'h-1.5',
33+
md: 'h-2',
34+
lg: 'h-3',
35+
};
36+
37+
export function ProgressBar({
38+
value,
39+
label,
40+
showPercentage = false,
41+
variant = 'default',
42+
size = 'md',
43+
className,
44+
}: ProgressBarProps) {
45+
const clampedValue = Math.min(100, Math.max(0, value));
46+
47+
return (
48+
<div className={cn('w-full', className)}>
49+
{(label || showPercentage) && (
50+
<div className="flex items-center justify-between mb-1">
51+
{label && <span className="text-sm font-medium">{label}</span>}
52+
{showPercentage && (
53+
<span className="text-sm text-muted-foreground">{Math.round(clampedValue)}%</span>
54+
)}
55+
</div>
56+
)}
57+
<div className={cn('w-full bg-muted rounded-full overflow-hidden', sizeClasses[size])}>
58+
<div
59+
className={cn('h-full rounded-full transition-all duration-300', variantClasses[variant])}
60+
style={{ width: `${clampedValue}%` }}
61+
/>
62+
</div>
63+
</div>
64+
);
65+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* StatsCard component
3+
* Displays a single stat with icon and optional trend indicator
4+
*/
5+
6+
import { type LucideIcon, TrendingUp, TrendingDown, Minus } from 'lucide-react';
7+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8+
import { cn } from '@/lib/utils';
9+
10+
export interface StatsCardProps {
11+
/** Title of the stat */
12+
title: string;
13+
/** Main value to display */
14+
value: number | string;
15+
/** Optional subtitle or description */
16+
subtitle?: string;
17+
/** Icon to display */
18+
icon?: LucideIcon;
19+
/** Icon color class */
20+
iconColorClass?: string;
21+
/** Background gradient color class */
22+
gradientClass?: string;
23+
/** Trend direction */
24+
trend?: 'up' | 'down' | 'neutral';
25+
/** Trend percentage or label */
26+
trendValue?: string;
27+
/** Additional CSS classes */
28+
className?: string;
29+
}
30+
31+
export function StatsCard({
32+
title,
33+
value,
34+
subtitle,
35+
icon: Icon,
36+
iconColorClass = 'text-blue-600',
37+
gradientClass = 'from-blue-500/10',
38+
trend,
39+
trendValue,
40+
className,
41+
}: StatsCardProps) {
42+
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
43+
44+
return (
45+
<Card className={cn('relative overflow-hidden', className)}>
46+
<div className={cn('absolute inset-0 bg-gradient-to-br to-transparent', gradientClass)} />
47+
<CardHeader className="relative pb-3">
48+
<div className="flex items-center justify-between">
49+
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
50+
{Icon && <Icon className={cn('h-5 w-5', iconColorClass)} />}
51+
</div>
52+
</CardHeader>
53+
<CardContent className="relative">
54+
<div className="flex items-end gap-2">
55+
<div className="text-3xl font-bold">{value}</div>
56+
{trend && trendValue && (
57+
<div
58+
className={cn(
59+
'flex items-center gap-0.5 text-sm font-medium',
60+
trend === 'up' && 'text-green-600',
61+
trend === 'down' && 'text-red-600',
62+
trend === 'neutral' && 'text-muted-foreground'
63+
)}
64+
>
65+
<TrendIcon className="h-3.5 w-3.5" />
66+
<span>{trendValue}</span>
67+
</div>
68+
)}
69+
</div>
70+
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
71+
</CardContent>
72+
</Card>
73+
);
74+
}

0 commit comments

Comments
 (0)