veza/apps/web/desy/components/ui/stat-card.tsx
2026-01-22 17:23:11 +01:00

266 lines
7.2 KiB
TypeScript

'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
// Trend icons
const TrendUpIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
)
const TrendDownIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6" />
<polyline points="17 18 23 18 23 12" />
</svg>
)
const statCardVariants = cva(
'relative flex flex-col gap-2 p-5 rounded-xl border transition-all',
{
variants: {
variant: {
default: 'bg-card border-border',
muted: 'bg-muted/50 border-transparent',
glass: 'bg-card/80 backdrop-blur-xl border-border/50',
gradient: 'bg-gradient-to-br from-card to-muted border-border',
glow: 'bg-card border-border hover:shadow-[0_0_30px_oklch(0.75_0.18_195_/_0.1)] hover:border-primary/30',
},
},
defaultVariants: {
variant: 'default',
},
},
)
interface StatCardProps
extends React.ComponentProps<'div'>,
VariantProps<typeof statCardVariants> {
label: string
value: string | number
change?: number
changeLabel?: string
icon?: React.ReactNode
trend?: 'up' | 'down' | 'neutral'
sparkline?: number[]
}
function StatCard({
label,
value,
change,
changeLabel,
icon,
trend,
sparkline,
variant,
className,
...props
}: StatCardProps) {
const trendColor = trend === 'up'
? 'text-success'
: trend === 'down'
? 'text-destructive'
: 'text-muted-foreground'
return (
<div className={cn(statCardVariants({ variant }), className)} {...props}>
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="text-2xl font-bold tracking-tight mt-1">{value}</p>
</div>
{icon && (
<div className="size-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
{icon}
</div>
)}
</div>
{(change !== undefined || sparkline) && (
<div className="flex items-center justify-between mt-2">
{change !== undefined && (
<div className={cn('flex items-center gap-1 text-sm', trendColor)}>
{trend === 'up' && <TrendUpIcon />}
{trend === 'down' && <TrendDownIcon />}
<span className="font-medium">
{change > 0 && '+'}
{change}%
</span>
{changeLabel && (
<span className="text-muted-foreground text-xs">
{changeLabel}
</span>
)}
</div>
)}
{sparkline && (
<Sparkline data={sparkline} trend={trend} />
)}
</div>
)}
</div>
)
}
// Mini sparkline chart
interface SparklineProps {
data: number[]
trend?: 'up' | 'down' | 'neutral'
className?: string
}
function Sparkline({ data, trend, className }: SparklineProps) {
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const points = data.map((value, i) => {
const x = (i / (data.length - 1)) * 100
const y = 100 - ((value - min) / range) * 100
return `${x},${y}`
}).join(' ')
const strokeColor = trend === 'up'
? 'stroke-success'
: trend === 'down'
? 'stroke-destructive'
: 'stroke-primary'
return (
<svg
viewBox="0 0 100 40"
className={cn('w-16 h-8', className)}
preserveAspectRatio="none"
>
<polyline
points={points}
fill="none"
className={cn('stroke-2', strokeColor)}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
// Stats Grid
interface StatsGridProps extends React.ComponentProps<'div'> {
columns?: 2 | 3 | 4
}
function StatsGrid({ columns = 4, className, children, ...props }: StatsGridProps) {
return (
<div
className={cn(
'grid gap-4',
columns === 2 && 'grid-cols-1 sm:grid-cols-2',
columns === 3 && 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
columns === 4 && 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
className
)}
{...props}
>
{children}
</div>
)
}
// XP/Gaming style progress
interface XPBarProps {
level: number
currentXP: number
maxXP: number
className?: string
}
function XPBar({ level, currentXP, maxXP, className }: XPBarProps) {
const progress = (currentXP / maxXP) * 100
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between text-xs">
<span className="font-mono text-[oklch(0.88_0.16_85)] font-bold">
LVL {level}
</span>
<span className="font-mono text-muted-foreground">
{currentXP.toLocaleString()} / {maxXP.toLocaleString()} XP
</span>
</div>
<div className="relative h-3 bg-muted rounded-full overflow-hidden border border-[oklch(0.88_0.16_85_/_0.3)]">
<div
className="h-full bg-gradient-to-r from-[oklch(0.75_0.16_85)] to-[oklch(0.88_0.16_85)] transition-all duration-500 relative"
style={{ width: `${progress}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-[shimmer_2s_ease-in-out_infinite]" />
</div>
</div>
</div>
)
}
// Leaderboard entry
interface LeaderboardEntryProps {
rank: number
name: string
avatar?: string
initials: string
score: number | string
highlight?: boolean
className?: string
}
function LeaderboardEntry({
rank,
name,
avatar,
initials,
score,
highlight = false,
className,
}: LeaderboardEntryProps) {
const rankColor = rank === 1
? 'text-[oklch(0.88_0.16_85)]'
: rank === 2
? 'text-muted-foreground'
: rank === 3
? 'text-[oklch(0.65_0.12_45)]'
: 'text-muted-foreground'
return (
<div className={cn(
'flex items-center gap-3 p-3 rounded-lg transition-colors',
highlight ? 'bg-primary/10 border border-primary/20' : 'hover:bg-muted/50',
className
)}>
<span className={cn('w-6 text-center font-mono font-bold text-sm', rankColor)}>
{rank}
</span>
<div className="size-8 rounded-full bg-gradient-to-br from-primary to-secondary overflow-hidden flex items-center justify-center text-xs font-semibold text-primary-foreground">
{avatar ? (
<img src={avatar || "/placeholder.svg"} alt={name} className="size-full object-cover" />
) : (
initials
)}
</div>
<span className="flex-1 font-medium text-sm truncate">{name}</span>
<span className="font-mono text-sm text-muted-foreground">{score}</span>
</div>
)
}
export {
StatCard,
Sparkline,
StatsGrid,
XPBar,
LeaderboardEntry,
statCardVariants
}