266 lines
7.2 KiB
TypeScript
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
|
|
}
|