Files
Claude-Code-Workflow/ccw/frontend/src/components/shared/StatCard.tsx
catlog22 166211dcd4 feat: add Unsplash search hook and API proxy routes
- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce.
- Created Unsplash API client functions for searching photos and triggering downloads.
- Added proxy routes for Unsplash API to handle search requests and background image uploads.
- Introduced accessibility utilities for WCAG compliance checks and motion preference management.
- Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
2026-02-08 20:01:28 +08:00

178 lines
5.0 KiB
TypeScript

// ========================================
// StatCard Component
// ========================================
// Reusable stat card for dashboard metrics
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
import { Sparkline } from '@/components/charts/Sparkline';
const statCardVariants = cva(
'transition-all duration-200 hover:shadow-md hover-glow',
{
variants: {
variant: {
default: 'border-border',
primary: 'border-primary/30 bg-primary/5',
success: 'border-success/30 bg-success/5',
warning: 'border-warning/30 bg-warning/5',
danger: 'border-destructive/30 bg-destructive/5',
info: 'border-info/30 bg-info/5',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const iconContainerVariants = cva(
'flex h-10 w-10 items-center justify-center rounded-lg',
{
variants: {
variant: {
default: 'bg-muted text-muted-foreground',
primary: 'bg-primary/10 text-primary',
success: 'bg-success/10 text-success',
warning: 'bg-warning/10 text-warning',
danger: 'bg-destructive/10 text-destructive',
info: 'bg-info/10 text-info',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface StatCardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof statCardVariants> {
/** Card title */
title: string;
/** Stat value to display */
value: number | string;
/** Optional icon component */
icon?: LucideIcon;
/** Optional trend direction */
trend?: 'up' | 'down' | 'neutral';
/** Optional trend value (e.g., "+12%") */
trendValue?: string;
/** Loading state */
isLoading?: boolean;
/** Optional description */
description?: string;
/** Optional sparkline data (e.g., last 7 days) */
sparklineData?: number[];
/** Whether to show sparkline */
showSparkline?: boolean;
}
/**
* StatCard component for displaying dashboard metrics
*
* @example
* ```tsx
* <StatCard
* title="Total Sessions"
* value={42}
* icon={FolderIcon}
* variant="primary"
* trend="up"
* trendValue="+5"
* />
* ```
*/
export function StatCard({
className,
variant,
title,
value,
icon: Icon,
trend,
trendValue,
isLoading = false,
description,
sparklineData,
showSparkline = false,
...props
}: StatCardProps) {
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
const trendColor =
trend === 'up'
? 'text-success'
: trend === 'down'
? 'text-destructive'
: 'text-muted-foreground';
return (
<Card className={cn(statCardVariants({ variant }), className)} {...props}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground truncate">
{title}
</p>
<div className="mt-2 flex items-baseline gap-2">
{isLoading ? (
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
) : (
<p className="text-2xl font-semibold text-card-foreground">
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
)}
{trend && trendValue && !isLoading && (
<span className={cn('flex items-center text-xs font-medium', trendColor)}>
<TrendIcon className="mr-0.5 h-3 w-3" />
{trendValue}
</span>
)}
</div>
{description && (
<p className="mt-1 text-xs text-muted-foreground truncate">
{description}
</p>
)}
{showSparkline && sparklineData && sparklineData.length > 0 && (
<div className="mt-3 -mx-2">
<Sparkline
data={sparklineData}
height={40}
strokeWidth={2}
/>
</div>
)}
</div>
{Icon && (
<div className={cn(iconContainerVariants({ variant }))}>
<Icon className="h-5 w-5" />
</div>
)}
</div>
</CardContent>
</Card>
);
}
/**
* Skeleton loader for StatCard
*/
export function StatCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn('animate-pulse', className)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="h-4 w-20 rounded bg-muted" />
<div className="mt-3 h-8 w-16 rounded bg-muted" />
</div>
<div className="h-10 w-10 rounded-lg bg-muted" />
</div>
</CardContent>
</Card>
);
}