Replace dashboard BarChart with 21st.dev LineChart component

Swap the main site dashboard chart from a bar chart to a line chart
using 21st.dev's line-charts-6 component with dot grid background,
glow shadow, and animated active dots. Add Badge trend indicators
on metric cards using Phosphor icons. All existing features preserved
(annotations, comparison, export, live indicator, interval controls).

New UI primitives: line-charts-6, badge-2, card, button-1, avatar.
Added shadcn-compatible CSS variables and Tailwind color mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Usman Baig
2026-03-09 22:53:35 +01:00
parent 5721d25291
commit 033d735c3a
11 changed files with 3213 additions and 337 deletions

View File

@@ -2,34 +2,15 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { useTheme } from '@ciphera-net/ui'
import {
BarChart,
Bar,
XAxis,
CartesianGrid,
ReferenceLine,
} from 'recharts'
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts'
import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
import { Badge } from '@/components/ui/badge-2'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui'
const COLORS = {
brand: 'var(--chart-1)',
success: 'var(--color-success)',
danger: 'var(--color-error)',
}
const dashboardChartConfig = {
visitors: { label: 'Visitors', color: 'var(--chart-1)' },
pageviews: { label: 'Pageviews', color: 'var(--chart-1)' },
bounce_rate: { label: 'Bounce Rate', color: 'var(--chart-1)' },
avg_duration: { label: 'Visit Duration', color: 'var(--chart-1)' },
prevVisitors: { label: 'Previous', color: 'var(--chart-axis)' },
prevPageviews: { label: 'Previous', color: 'var(--chart-axis)' },
prevBounceRate: { label: 'Previous', color: 'var(--chart-axis)' },
prevAvgDuration: { label: 'Previous', color: 'var(--chart-axis)' },
} satisfies ChartConfig
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
const ANNOTATION_COLORS: Record<string, string> = {
deploy: '#3b82f6',
@@ -104,102 +85,54 @@ function formatEU(dateStr: string): string {
return `${d}/${m}/${y}`
}
// ─── Metric configurations ──────────────────────────────────────────
function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const duration = endDate.getTime() - startDate.getTime()
const METRIC_CONFIGS: {
key: MetricType
label: string
format: (v: number) => string
isNegative?: boolean
}[] = [
{ key: 'visitors', label: 'Unique Visitors', format: (v) => formatNumber(v) },
{ key: 'pageviews', label: 'Total Pageviews', format: (v) => formatNumber(v) },
{ key: 'bounce_rate', label: 'Bounce Rate', format: (v) => `${Math.round(v)}%`, isNegative: true },
{ key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) },
]
if (duration === 0) {
const prev = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
return prev.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
const chartConfig = {
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
pageviews: { label: 'Total Pageviews', color: '#3b82f6' },
bounce_rate: { label: 'Bounce Rate', color: '#a855f7' },
avg_duration: { label: 'Visit Duration', color: '#10b981' },
} satisfies ChartConfig
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
const prevStart = new Date(prevEnd.getTime() - duration)
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
return `${fmt(prevStart)} ${fmt(prevEnd)}`
}
// ─── Custom Tooltip ─────────────────────────────────────────────────
function getTrendContext(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const duration = endDate.getTime() - startDate.getTime()
if (duration === 0) return 'vs yesterday'
const days = Math.round(duration / (24 * 60 * 60 * 1000))
if (days === 1) return 'vs yesterday'
return `vs previous ${days} days`
}
// ─── Tooltip ─────────────────────────────────────────────────────────
function DashboardTooltipContent({
active,
payload,
label,
metric,
metricLabel,
formatNumberFn,
showComparison,
prevPeriodLabel,
}: {
interface TooltipProps {
active?: boolean
payload?: Array<{ payload: Record<string, number>; value: number; dataKey?: string }>
payload?: Array<{ dataKey: string; value: number; color: string }>
label?: string
metric: MetricType
metricLabel: string
formatNumberFn: (n: number) => string
showComparison: boolean
prevPeriodLabel?: string
}) {
if (!active || !payload?.length || !label) return null
}
const current = payload.find((p) => p.dataKey === metric) ?? payload[payload.length - 1]
const value = Number(current?.value ?? current?.payload?.[metric] ?? 0)
function CustomTooltip({ active, payload, metric }: TooltipProps) {
if (active && payload && payload.length) {
const entry = payload[0]
const config = METRIC_CONFIGS.find((m) => m.key === metric)
const prevKey = metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'
const prev = current?.payload?.[prevKey]
const hasPrev = showComparison && prev != null
const delta = hasPrev && prev > 0 ? Math.round(((value - prev) / prev) * 100) : null
const formatValue = (v: number) => {
if (metric === 'bounce_rate') return `${Math.round(v)}%`
if (metric === 'avg_duration') return formatDuration(v)
return formatNumberFn(v)
}
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3.5 py-2.5 shadow-xl">
<div className="text-[11px] font-medium mb-1 text-neutral-500 dark:text-neutral-400">
{label}
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-bold text-neutral-900 dark:text-neutral-50">
{formatValue(value)}
</span>
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-neutral-500 dark:text-neutral-400">
<span>vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : undefined,
}}
>
{delta > 0 ? '+' : ''}{delta}%
</span>
)}
if (config) {
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[120px]">
<div className="flex items-center gap-2 text-sm">
<div className="size-1.5 rounded-full" style={{ backgroundColor: entry.color }}></div>
<span className="text-neutral-500 dark:text-neutral-400">{config.label}:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{config.format(entry.value)}</span>
</div>
</div>
)}
</div>
)
)
}
}
return null
}
// ─── Chart Component ─────────────────────────────────────────────────
@@ -224,9 +157,9 @@ export default function Chart({
onDeleteAnnotation,
}: ChartProps) {
const [metric, setMetric] = useState<MetricType>('visitors')
const [showComparison, setShowComparison] = useState(false)
const chartContainerRef = useRef<HTMLDivElement>(null)
const { resolvedTheme } = useTheme()
const [showComparison, setShowComparison] = useState(false)
// ─── Annotation state ─────────────────────────────────────────────
const [annotationForm, setAnnotationForm] = useState<{
@@ -268,9 +201,7 @@ export default function Chart({
// ─── Data ──────────────────────────────────────────────────────────
const chartData = data.map((item, i) => {
const prevItem = prevData?.[i]
const chartData = data.map((item) => {
let formattedDate: string
if (interval === 'minute') {
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
@@ -291,10 +222,6 @@ export default function Chart({
visitors: item.visitors,
bounce_rate: item.bounce_rate,
avg_duration: item.avg_duration,
prevPageviews: prevItem?.pageviews,
prevVisitors: prevItem?.visitors,
prevBounceRate: prevItem?.bounce_rate,
prevAvgDuration: prevItem?.avg_duration,
}
})
@@ -369,228 +296,232 @@ export default function Chart({
}
}, [annotationForm.editingId, onDeleteAnnotation])
// ─── Metrics ───────────────────────────────────────────────────────
// ─── Metrics with trends ──────────────────────────────────────────
const calculateTrend = (current: number, previous?: number) => {
if (!previous) return null
if (previous === 0) return current > 0 ? 100 : 0
return Math.round(((current - previous) / previous) * 100)
}
const metricsWithTrends = METRIC_CONFIGS.map((m) => {
const value = stats[m.key]
const previousValue = prevStats?.[m.key]
const change = previousValue != null && previousValue > 0
? ((value - previousValue) / previousValue) * 100
: null
const isPositive = change !== null ? (m.isNegative ? change < 0 : change > 0) : null
const metrics = [
{ id: 'visitors' as const, label: 'Unique Visitors', value: formatNumber(stats.visitors), trend: calculateTrend(stats.visitors, prevStats?.visitors), invertTrend: false },
{ id: 'pageviews' as const, label: 'Total Pageviews', value: formatNumber(stats.pageviews), trend: calculateTrend(stats.pageviews, prevStats?.pageviews), invertTrend: false },
{ id: 'bounce_rate' as const, label: 'Bounce Rate', value: `${Math.round(stats.bounce_rate)}%`, trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate), invertTrend: true },
{ id: 'avg_duration' as const, label: 'Visit Duration', value: formatDuration(stats.avg_duration), trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration), invertTrend: false },
]
return {
...m,
value,
previousValue,
change,
isPositive,
}
})
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
const metricLabel = activeMetric.label
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
const trendContext = getTrendContext(dateRange)
const hasPrev = !!(prevData?.length && showComparison)
const hasData = data.length > 0
const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0)
// ─── Trend Badge ──────────────────────────────────────────────────
function TrendBadge({ trend, invert }: { trend: number | null; invert: boolean }) {
if (trend === null) return <span className="text-neutral-400 dark:text-neutral-500"></span>
const effective = invert ? -trend : trend
const isPositive = effective > 0
const isNegative = effective < 0
return (
<span className={`inline-flex items-center text-xs font-medium ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : isNegative ? 'text-red-500 dark:text-red-400' : 'text-neutral-400'}`}>
{isPositive ? <ArrowUpRightIcon className="w-3 h-3 mr-0.5" /> : isNegative ? <ArrowDownRightIcon className="w-3 h-3 mr-0.5" /> : null}
{Math.abs(trend)}%
</span>
)
}
const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0
)
// ─── Render ────────────────────────────────────────────────────────
return (
<div
ref={chartContainerRef}
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden relative"
role="region"
aria-label={`Analytics chart showing ${metricLabel} over time`}
>
{/* Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
{metrics.map((item) => {
const isActive = metric === item.id
return (
<button
key={item.id}
type="button"
onClick={() => setMetric(item.id)}
aria-pressed={isActive}
aria-label={`Show ${item.label} chart`}
className={`p-4 sm:px-6 sm:py-5 text-left transition-colors relative cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/50 ${isActive ? 'bg-neutral-50 dark:bg-neutral-800/40' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/20'}`}
>
<div className={`text-[11px] font-semibold uppercase tracking-wider mb-1.5 ${isActive ? 'text-neutral-900 dark:text-white' : 'text-neutral-400 dark:text-neutral-500'}`}>
{item.label}
</div>
<div className="flex items-baseline gap-2">
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
{item.value}
</span>
<TrendBadge trend={item.trend} invert={item.invertTrend} />
</div>
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 mt-0.5">{trendContext}</p>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-brand-orange" />
)}
</button>
)
})}
</div>
{/* Chart Area */}
<div className="px-4 sm:px-6 pt-4 pb-2">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-4">
{/* Left: metric label + avg badge */}
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{metricLabel}
</span>
{hasPrev && (
<div className="hidden sm:flex items-center gap-3 text-[11px] font-medium text-neutral-400 dark:text-neutral-500 ml-2">
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-brand-orange" />
Current
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-neutral-300 dark:bg-neutral-600" />
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
</span>
</div>
)}
</div>
{/* Right: controls */}
<div className="flex items-center gap-2">
{dateRange.start === dateRange.end ? (
<Select
value={todayInterval}
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
options={[
{ value: 'minute', label: '1 min' },
{ value: 'hour', label: '1 hour' },
]}
className="min-w-[90px]"
/>
) : (
<Select
value={multiDayInterval}
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
options={[
{ value: 'hour', label: '1 hour' },
{ value: 'day', label: '1 day' },
]}
className="min-w-[90px]"
/>
)}
{prevData?.length ? (
<Checkbox
checked={showComparison}
onCheckedChange={setShowComparison}
label="Compare"
/>
) : null}
<button
onClick={handleExportChart}
disabled={!hasData}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-30 cursor-pointer"
title="Export chart as PNG"
>
<DownloadIcon className="w-4 h-4" />
</button>
{canManageAnnotations && (
<div ref={chartContainerRef} className="relative">
<Card className="@container w-full overflow-hidden rounded-2xl">
<CardHeader className="p-0 mb-0">
{/* Metrics Grid - 21st.dev style */}
<div className="grid grid-cols-2 @2xl:grid-cols-2 @3xl:grid-cols-4 grow w-full">
{metricsWithTrends.map((m) => (
<button
onClick={() => setAnnotationForm({ visible: true, date: new Date().toISOString().slice(0, 10), time: '', text: '', category: 'other' })}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
title="Add annotation"
>
<PlusIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
{!hasData ? (
<div className="flex h-[250px] flex-col items-center justify-center gap-2">
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm text-neutral-400 dark:text-neutral-500">No data for this period</p>
</div>
) : !hasAnyNonZero ? (
<div className="flex h-[250px] flex-col items-center justify-center gap-2">
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm text-neutral-400 dark:text-neutral-500">No {metricLabel.toLowerCase()} recorded</p>
</div>
) : (
<div className="w-full" onContextMenu={handleChartContextMenu}>
<ChartContainer config={dashboardChartConfig} className="aspect-auto h-[250px] w-full">
<BarChart
accessibilityLayer
data={chartData}
margin={{ left: 12, right: 12 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip
content={
<DashboardTooltipContent
metric={metric}
metricLabel={metricLabel}
formatNumberFn={formatNumber}
showComparison={hasPrev}
prevPeriodLabel={prevPeriodLabel}
/>
}
/>
{hasPrev && (
<Bar
dataKey={metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'}
fill="var(--chart-axis)"
fillOpacity={0.15}
/>
key={m.key}
onClick={() => setMetric(m.key)}
className={cn(
'cursor-pointer flex-1 text-start p-4 last:border-b-0 border-b @2xl:border-b @2xl:even:border-r @3xl:border-b-0 @3xl:border-r @3xl:last:border-r-0 border-neutral-200 dark:border-neutral-800 transition-all',
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
)}
<Bar dataKey={metric} fill={`var(--color-${metric})`} />
{annotationMarkers.map((marker) => {
const primaryCategory = marker.annotations[0].category
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
return (
<ReferenceLine
key={`ann-${marker.x}`}
x={marker.x}
stroke={color}
strokeDasharray="4 4"
strokeWidth={1.5}
strokeOpacity={0.6}
/>
)
})}
</BarChart>
</ChartContainer>
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-neutral-500 dark:text-neutral-400">{m.label}</span>
{m.change !== null && (
<Badge variant={m.isPositive ? 'success' : 'destructive'} appearance="outline">
{m.isPositive ? <ArrowUp weight="bold" className="size-3" /> : <ArrowDown weight="bold" className="size-3" />}
{Math.abs(m.change).toFixed(1)}%
</Badge>
)}
</div>
<div className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</div>
{m.previousValue != null && (
<div className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">from {m.format(m.previousValue)}</div>
)}
</button>
))}
</div>
)}
</div>
</CardHeader>
<CardContent className="px-2.5 py-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-4 px-2">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{METRIC_CONFIGS.find((m) => m.key === metric)?.label}
</span>
</div>
<div className="flex items-center gap-2">
{dateRange.start === dateRange.end ? (
<Select
value={todayInterval}
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
options={[
{ value: 'minute', label: '1 min' },
{ value: 'hour', label: '1 hour' },
]}
className="min-w-[90px]"
/>
) : (
<Select
value={multiDayInterval}
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
options={[
{ value: 'hour', label: '1 hour' },
{ value: 'day', label: '1 day' },
]}
className="min-w-[90px]"
/>
)}
{prevData?.length ? (
<Checkbox
checked={showComparison}
onCheckedChange={setShowComparison}
label="Compare"
/>
) : null}
<button
onClick={handleExportChart}
disabled={!hasData}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-30 cursor-pointer"
title="Export chart as PNG"
>
<DownloadIcon className="w-4 h-4" />
</button>
{canManageAnnotations && (
<button
onClick={() => setAnnotationForm({ visible: true, date: new Date().toISOString().slice(0, 10), time: '', text: '', category: 'other' })}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
title="Add annotation"
>
<PlusIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
{!hasData || !hasAnyNonZero ? (
<div className="flex h-96 flex-col items-center justify-center gap-2">
<p className="text-sm text-neutral-400 dark:text-neutral-500">
{!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`}
</p>
</div>
) : (
<div className="w-full" onContextMenu={handleChartContextMenu}>
<ChartContainer
config={chartConfig}
className="h-96 w-full overflow-visible [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
>
<LineChart
data={chartData}
margin={{ top: 20, right: 20, left: 5, bottom: 20 }}
style={{ overflow: 'visible' }}
>
<defs>
<pattern id="dotGrid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="1" fill="var(--chart-grid)" fillOpacity="1" />
</pattern>
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
<feDropShadow
dx="4"
dy="6"
stdDeviation="25"
floodColor={`${chartConfig[metric]?.color}60`}
/>
</filter>
<filter id="dotShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="2" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.5)" />
</filter>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={10}
minTickGap={32}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={10}
tickCount={6}
tickFormatter={(value) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
return config ? config.format(value) : value.toString()
}}
/>
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
{/* Background dot grid pattern */}
<rect
x="60px"
y="-20px"
width="calc(100% - 75px)"
height="calc(100% - 10px)"
fill="url(#dotGrid)"
style={{ pointerEvents: 'none' }}
/>
{/* Annotation reference lines */}
{annotationMarkers.map((marker) => {
const primaryCategory = marker.annotations[0].category
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
return (
<ReferenceLine
key={`ann-${marker.x}`}
x={marker.x}
stroke={color}
strokeDasharray="4 4"
strokeWidth={1.5}
strokeOpacity={0.6}
/>
)
})}
<Line
type="monotone"
dataKey={metric}
stroke={chartConfig[metric]?.color}
strokeWidth={2}
filter="url(#lineShadow)"
dot={false}
activeDot={{
r: 6,
fill: chartConfig[metric]?.color,
stroke: 'white',
strokeWidth: 2,
filter: 'url(#dotShadow)',
}}
/>
</LineChart>
</ChartContainer>
</div>
)}
</CardContent>
</Card>
{/* Annotation tags */}
{annotationMarkers.length > 0 && (
<div className="px-4 sm:px-6 flex items-center gap-1 flex-wrap py-1.5 border-t border-neutral-100 dark:border-neutral-800">
<div className="px-4 sm:px-6 flex items-center gap-1 flex-wrap py-1.5 border-t border-neutral-100 dark:border-neutral-800 -mt-px rounded-b-2xl bg-white dark:bg-neutral-900 border-x border-b border-neutral-200 dark:border-neutral-800">
<span className="text-[10px] font-medium text-neutral-400 dark:text-neutral-500 mr-1">Annotations:</span>
{annotationMarkers.map((marker) => {
const primary = marker.annotations[0]
@@ -617,7 +548,6 @@ export default function Chart({
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="max-w-[120px] truncate">{primary.text}</span>
{count > 1 && <span className="text-neutral-400">+{count - 1}</span>}
{/* Hover tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block z-50 pointer-events-none">
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-2 min-w-[180px] max-w-[240px]">
{marker.annotations.map((a) => (
@@ -641,7 +571,7 @@ export default function Chart({
{/* Live indicator */}
{lastUpdatedAt != null && (
<div className="px-4 sm:px-6 pb-3 flex justify-end">
<div className="px-4 sm:px-6 pb-3 pt-2 flex justify-end">
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
@@ -692,7 +622,6 @@ export default function Chart({
{annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
</h3>
<div className="space-y-3">
{/* Date picker trigger */}
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Date</label>
<button
@@ -706,7 +635,6 @@ export default function Chart({
</svg>
</button>
</div>
{/* Time input */}
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Time <span className="text-neutral-400 dark:text-neutral-500">(optional)</span>
@@ -730,7 +658,6 @@ export default function Chart({
)}
</div>
</div>
{/* Note */}
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Note</label>
<input
@@ -744,7 +671,6 @@ export default function Chart({
/>
<span className="text-[10px] text-neutral-400 mt-0.5 block text-right">{annotationForm.text.length}/200</span>
</div>
{/* Category - custom Select */}
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Category</label>
<Select
@@ -792,7 +718,7 @@ export default function Chart({
</div>
)}
{/* ─── DatePicker overlay (single mode) ─────────────────────── */}
{/* ─── DatePicker overlay ─────────────────────── */}
<DatePicker
isOpen={calendarOpen}
onClose={() => setCalendarOpen(false)}

67
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,67 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, VariantProps } from 'class-variance-authority';
import { Avatar as AvatarPrimitive } from 'radix-ui';
const avatarStatusVariants = cva('flex items-center rounded-full size-2 border-2 border-background', {
variants: {
variant: {
online: 'bg-green-600',
offline: 'bg-zinc-600 dark:bg-zinc-300',
busy: 'bg-yellow-600',
away: 'bg-blue-600',
},
},
defaultVariants: {
variant: 'online',
},
});
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root data-slot="avatar" className={cn('relative flex shrink-0 size-10', className)} {...props} />
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<div className={cn('relative overflow-hidden rounded-full', className)}>
<AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square h-full w-full')} {...props} />
</div>
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex h-full w-full items-center justify-center rounded-full border border-border bg-accent text-accent-foreground text-xs',
className,
)}
{...props}
/>
);
}
function AvatarIndicator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="avatar-indicator"
className={cn('absolute flex size-6 items-center justify-center', className)}
{...props}
/>
);
}
function AvatarStatus({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof avatarStatusVariants>) {
return <div data-slot="avatar-status" className={cn(avatarStatusVariants({ variant }), className)} {...props} />;
}
export { Avatar, AvatarFallback, AvatarImage, AvatarIndicator, AvatarStatus, avatarStatusVariants };

230
components/ui/badge-2.tsx Normal file
View File

@@ -0,0 +1,230 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot as SlotPrimitive } from 'radix-ui';
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
asChild?: boolean;
dotClassName?: string;
disabled?: boolean;
}
export interface BadgeButtonProps
extends React.ButtonHTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeButtonVariants> {
asChild?: boolean;
}
export type BadgeDotProps = React.HTMLAttributes<HTMLSpanElement>;
const badgeVariants = cva(
'inline-flex items-center justify-center border border-transparent font-medium focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 [&_svg]:-ms-px [&_svg]:shrink-0',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
success:
'bg-[var(--color-success-accent,#22c55e)] text-[var(--color-success-foreground,#ffffff)]',
warning:
'bg-[var(--color-warning-accent,#eab308)] text-[var(--color-warning-foreground,#ffffff)]',
info: 'bg-[var(--color-info-accent,#8b5cf6)] text-[var(--color-info-foreground,#ffffff)]',
outline: 'bg-transparent border border-border text-secondary-foreground',
destructive: 'bg-destructive text-destructive-foreground',
},
appearance: {
default: '',
light: '',
outline: '',
ghost: 'border-transparent bg-transparent',
},
disabled: {
true: 'opacity-50 pointer-events-none',
},
size: {
lg: 'rounded-md px-[0.5rem] h-7 min-w-7 gap-1.5 text-xs [&_svg]:size-3.5',
md: 'rounded-md px-[0.45rem] h-6 min-w-6 gap-1.5 text-xs [&_svg]:size-3.5 ',
sm: 'rounded-sm px-[0.325rem] h-5 min-w-5 gap-1 text-[0.6875rem] leading-[0.75rem] [&_svg]:size-3',
xs: 'rounded-sm px-[0.25rem] h-4 min-w-4 gap-1 text-[0.625rem] leading-[0.5rem] [&_svg]:size-3',
},
shape: {
default: '',
circle: 'rounded-full',
},
},
compoundVariants: [
/* Light */
{
variant: 'primary',
appearance: 'light',
className:
'text-blue-700 bg-blue-50 dark:bg-blue-950 dark:text-blue-600',
},
{
variant: 'secondary',
appearance: 'light',
className: 'bg-secondary dark:bg-secondary/50 text-secondary-foreground',
},
{
variant: 'success',
appearance: 'light',
className:
'text-green-800 bg-green-100 dark:bg-green-950 dark:text-green-600',
},
{
variant: 'warning',
appearance: 'light',
className:
'text-yellow-700 bg-yellow-100 dark:bg-yellow-950 dark:text-yellow-600',
},
{
variant: 'info',
appearance: 'light',
className:
'text-violet-700 bg-violet-100 dark:bg-violet-950 dark:text-violet-400',
},
{
variant: 'destructive',
appearance: 'light',
className:
'text-red-700 bg-red-50 dark:bg-red-950 dark:text-red-600',
},
/* Outline */
{
variant: 'primary',
appearance: 'outline',
className:
'text-blue-700 border-blue-100 bg-blue-50 dark:bg-blue-950 dark:border-blue-900 dark:text-blue-600',
},
{
variant: 'success',
appearance: 'outline',
className:
'text-green-700 border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-900 dark:text-green-600',
},
{
variant: 'warning',
appearance: 'outline',
className:
'text-yellow-700 border-yellow-200 bg-yellow-50 dark:bg-yellow-950 dark:border-yellow-900 dark:text-yellow-600',
},
{
variant: 'info',
appearance: 'outline',
className:
'text-violet-700 border-violet-100 bg-violet-50 dark:bg-violet-950 dark:border-violet-900 dark:text-violet-400',
},
{
variant: 'destructive',
appearance: 'outline',
className:
'text-red-700 border-red-100 bg-red-50 dark:bg-red-950 dark:border-red-900 dark:text-red-600',
},
/* Ghost */
{
variant: 'primary',
appearance: 'ghost',
className: 'text-primary',
},
{
variant: 'secondary',
appearance: 'ghost',
className: 'text-secondary-foreground',
},
{
variant: 'success',
appearance: 'ghost',
className: 'text-green-500',
},
{
variant: 'warning',
appearance: 'ghost',
className: 'text-yellow-500',
},
{
variant: 'info',
appearance: 'ghost',
className: 'text-violet-500',
},
{
variant: 'destructive',
appearance: 'ghost',
className: 'text-destructive',
},
{ size: 'lg', appearance: 'ghost', className: 'px-0' },
{ size: 'md', appearance: 'ghost', className: 'px-0' },
{ size: 'sm', appearance: 'ghost', className: 'px-0' },
{ size: 'xs', appearance: 'ghost', className: 'px-0' },
],
defaultVariants: {
variant: 'primary',
appearance: 'default',
size: 'md',
},
},
);
const badgeButtonVariants = cva(
'cursor-pointer transition-all inline-flex items-center justify-center leading-none size-3.5 [&>svg]:opacity-100! [&>svg]:size-3.5 p-0 rounded-md -me-0.5 opacity-60 hover:opacity-100',
{
variants: {
variant: {
default: '',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
size,
appearance,
shape,
asChild = false,
disabled,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant, size, appearance, shape, disabled }), className)}
{...props}
/>
);
}
function BadgeButton({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'button'> & VariantProps<typeof badgeButtonVariants> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Slot : 'span';
return (
<Comp
data-slot="badge-button"
className={cn(badgeButtonVariants({ variant, className }))}
role="button"
{...props}
/>
);
}
function BadgeDot({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="badge-dot"
className={cn('size-1.5 rounded-full bg-[currentColor] opacity-75', className)}
{...props}
/>
);
}
export { Badge, BadgeButton, BadgeDot, badgeVariants };

412
components/ui/button-1.tsx Normal file
View File

@@ -0,0 +1,412 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { CaretDown } from '@phosphor-icons/react';
import { Slot as SlotPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'cursor-pointer group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center has-data-[arrow=true]:justify-between whitespace-nowrap text-sm font-medium ring-offset-background transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90 data-[state=open]:bg-primary/90',
mono: 'bg-zinc-950 text-white dark:bg-zinc-300 dark:text-black hover:bg-zinc-950/90 dark:hover:bg-zinc-300/90 data-[state=open]:bg-zinc-950/90 dark:data-[state=open]:bg-zinc-300/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 data-[state=open]:bg-destructive/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/90 data-[state=open]:bg-secondary/90',
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent data-[state=open]:bg-accent',
dashed:
'text-accent-foreground border border-input border-dashed bg-background hover:bg-accent hover:text-accent-foreground data-[state=open]:text-accent-foreground',
ghost:
'text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
dim: 'text-muted-foreground hover:text-foreground data-[state=open]:text-foreground',
foreground: '',
inverse: '',
},
appearance: {
default: '',
ghost: '',
},
underline: {
solid: '',
dashed: '',
},
underlined: {
solid: '',
dashed: '',
},
size: {
lg: 'h-10 rounded-md px-4 text-sm gap-1.5 [&_svg:not([class*=size-])]:size-4',
md: 'h-8.5 rounded-md px-3 gap-1.5 text-[0.8125rem] leading-[--text-sm--line-height] [&_svg:not([class*=size-])]:size-4',
sm: 'h-7 rounded-md px-2.5 gap-1.25 text-xs [&_svg:not([class*=size-])]:size-3.5',
icon: 'size-8.5 rounded-md [&_svg:not([class*=size-])]:size-4 shrink-0',
},
autoHeight: {
true: '',
false: '',
},
shape: {
default: '',
circle: 'rounded-full',
},
mode: {
default: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
icon: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
link: 'text-primary h-auto p-0 bg-transparent rounded-none hover:bg-transparent data-[state=open]:bg-transparent',
input: `
justify-start font-normal hover:bg-background [&_svg]:transition-colors [&_svg]:hover:text-foreground data-[state=open]:bg-background
focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/30
[[data-state=open]>&]:border-ring [[data-state=open]>&]:outline-hidden [[data-state=open]>&]:ring-[3px]
[[data-state=open]>&]:ring-ring/30
aria-invalid:border-destructive/60 aria-invalid:ring-destructive/10 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/20
in-data-[invalid=true]:border-destructive/60 in-data-[invalid=true]:ring-destructive/10 dark:in-data-[invalid=true]:border-destructive dark:in-data-[invalid=true]:ring-destructive/20
`,
},
placeholder: {
true: 'text-muted-foreground',
false: '',
},
},
compoundVariants: [
// Icons opacity for default mode
{
variant: 'ghost',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'outline',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'dashed',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'secondary',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
// Icons opacity for default mode
{
variant: 'outline',
mode: 'input',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'outline',
mode: 'icon',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
// Auto height
{
size: 'md',
autoHeight: true,
className: 'h-auto min-h-8.5',
},
{
size: 'sm',
autoHeight: true,
className: 'h-auto min-h-7',
},
{
size: 'lg',
autoHeight: true,
className: 'h-auto min-h-10',
},
// Shadow support
{
variant: 'primary',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'mono',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'secondary',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'outline',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'dashed',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'destructive',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
// Shadow support
{
variant: 'primary',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'mono',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'secondary',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'outline',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'dashed',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'destructive',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
// Link
{
variant: 'primary',
mode: 'link',
underline: 'solid',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'primary',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'primary',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'primary',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-primary hover:text-primary/90 [&_svg]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
{
variant: 'inverse',
mode: 'link',
underline: 'solid',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'inverse',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'inverse',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'inverse',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
{
variant: 'foreground',
mode: 'link',
underline: 'solid',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'foreground',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'foreground',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'foreground',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
// Ghost
{
variant: 'primary',
appearance: 'ghost',
className: 'bg-transparent text-primary/90 hover:bg-primary/5 data-[state=open]:bg-primary/5',
},
{
variant: 'destructive',
appearance: 'ghost',
className: 'bg-transparent text-destructive/90 hover:bg-destructive/5 data-[state=open]:bg-destructive/5',
},
{
variant: 'ghost',
mode: 'icon',
className: 'text-muted-foreground',
},
// Size
{
size: 'sm',
mode: 'icon',
className: 'w-7 h-7 p-0 [&_svg:not([class*=size-])]:size-3.5',
},
{
size: 'md',
mode: 'icon',
className: 'w-8.5 h-8.5 p-0 [&_svg:not([class*=size-])]:size-4',
},
{
size: 'icon',
className: 'w-8.5 h-8.5 p-0 [&_svg:not([class*=size-])]:size-4',
},
{
size: 'lg',
mode: 'icon',
className: 'w-10 h-10 p-0 [&_svg:not([class*=size-])]:size-4',
},
// Input mode
{
mode: 'input',
placeholder: true,
variant: 'outline',
className: 'font-normal text-muted-foreground',
},
{
mode: 'input',
variant: 'outline',
size: 'sm',
className: 'gap-1.25',
},
{
mode: 'input',
variant: 'outline',
size: 'md',
className: 'gap-1.5',
},
{
mode: 'input',
variant: 'outline',
size: 'lg',
className: 'gap-1.5',
},
],
defaultVariants: {
variant: 'primary',
mode: 'default',
size: 'md',
shape: 'default',
appearance: 'default',
},
},
);
function Button({
className,
selected,
variant,
shape,
appearance,
mode,
size,
autoHeight,
underlined,
underline,
asChild = false,
placeholder = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
selected?: boolean;
asChild?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(
buttonVariants({
variant,
size,
shape,
appearance,
mode,
autoHeight,
placeholder,
underlined,
underline,
className,
}),
asChild && props.disabled && 'pointer-events-none opacity-50',
)}
{...(selected && { 'data-state': 'open' })}
{...props}
/>
);
}
interface ButtonArrowProps extends React.SVGProps<SVGSVGElement> {
icon?: React.ComponentType<{ className?: string }>;
}
function ButtonArrow({ icon: Icon = CaretDown, className, ...props }: ButtonArrowProps) {
return <Icon data-slot="button-arrow" className={cn('ms-auto -me-1', className)} {...(props as Record<string, unknown>)} />;
}
export { Button, ButtonArrow, buttonVariants };

147
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,147 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
// Define CardContext
type CardContextType = {
variant: 'default' | 'accent';
};
const CardContext = React.createContext<CardContextType>({
variant: 'default', // Default value
});
// Hook to use CardContext
const useCardContext = () => {
const context = React.useContext(CardContext);
if (!context) {
throw new Error('useCardContext must be used within a Card component');
}
return context;
};
// Variants
const cardVariants = cva('flex flex-col items-stretch text-card-foreground rounded-xl', {
variants: {
variant: {
default: 'bg-card border border-border shadow-xs black/5',
accent: 'bg-muted shadow-xs p-1',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardHeaderVariants = cva('flex items-center justify-between flex-wrap px-5 min-h-14 gap-2.5', {
variants: {
variant: {
default: 'border-b border-border',
accent: '',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardContentVariants = cva('grow p-5', {
variants: {
variant: {
default: '',
accent: 'bg-card rounded-t-xl [&:last-child]:rounded-b-xl',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardTableVariants = cva('grid grow', {
variants: {
variant: {
default: '',
accent: 'bg-card rounded-xl',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardFooterVariants = cva('flex items-center px-5 min-h-14', {
variants: {
variant: {
default: 'border-t border-border',
accent: 'bg-card rounded-b-xl mt-[2px]',
},
},
defaultVariants: {
variant: 'default',
},
});
// Card Component
function Card({
className,
variant = 'default',
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof cardVariants>) {
return (
<CardContext.Provider value={{ variant: variant || 'default' }}>
<div data-slot="card" className={cn(cardVariants({ variant }), className)} {...props} />
</CardContext.Provider>
);
}
// CardHeader Component
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-header" className={cn(cardHeaderVariants({ variant }), className)} {...props} />;
}
// CardContent Component
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-content" className={cn(cardContentVariants({ variant }), className)} {...props} />;
}
// CardTable Component
function CardTable({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-table" className={cn(cardTableVariants({ variant }), className)} {...props} />;
}
// CardFooter Component
function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-footer" className={cn(cardFooterVariants({ variant }), className)} {...props} />;
}
// Other Components
function CardHeading({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-heading" className={cn('space-y-1', className)} {...props} />;
}
function CardToolbar({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-toolbar" className={cn('flex items-center gap-2.5', className)} {...props} />;
}
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
data-slot="card-title"
className={cn('text-base font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />;
}
// Exports
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardHeading, CardTable, CardTitle, CardToolbar };

View File

@@ -0,0 +1,290 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import * as RechartsPrimitive from 'recharts';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string' ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
if (labelFormatter) {
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn('shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]', {
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
})}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn('[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3')}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

1
lib/utils.ts Normal file
View File

@@ -0,0 +1 @@
export { cn } from '@ciphera-net/ui'

1756
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"cobe": "^0.6.5",
"country-flag-icons": "^1.6.4",
"d3-scale": "^4.0.2",
@@ -28,6 +29,7 @@
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7",
"next": "^16.1.1",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",

View File

@@ -17,6 +17,19 @@
--chart-5: #f59e0b;
--chart-grid: #f5f5f5;
--chart-axis: #a3a3a3;
/* * shadcn-compatible semantic tokens (for 21st.dev components) */
--background: 255 255 255;
--foreground: 23 23 23;
--card: 255 255 255;
--card-foreground: 23 23 23;
--popover: 255 255 255;
--popover-foreground: 23 23 23;
--primary: 253 94 15;
--primary-foreground: 255 255 255;
--secondary: 245 245 245;
--secondary-foreground: 23 23 23;
--destructive-foreground: 255 255 255;
}
.dark {
@@ -27,6 +40,19 @@
--chart-5: #fbbf24;
--chart-grid: #262626;
--chart-axis: #737373;
/* * shadcn-compatible dark mode overrides */
--background: 10 10 10;
--foreground: 250 250 250;
--card: 23 23 23;
--card-foreground: 250 250 250;
--popover: 23 23 23;
--popover-foreground: 250 250 250;
--primary: 253 94 15;
--primary-foreground: 255 255 255;
--secondary: 38 38 38;
--secondary-foreground: 250 250 250;
--destructive-foreground: 255 255 255;
}
body {

View File

@@ -16,6 +16,27 @@ const config: Config = {
fontFamily: {
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
},
colors: {
background: 'rgb(var(--background) / <alpha-value>)',
foreground: 'rgb(var(--foreground) / <alpha-value>)',
card: {
DEFAULT: 'rgb(var(--card) / <alpha-value>)',
foreground: 'rgb(var(--card-foreground) / <alpha-value>)',
},
popover: {
DEFAULT: 'rgb(var(--popover) / <alpha-value>)',
foreground: 'rgb(var(--popover-foreground) / <alpha-value>)',
},
primary: {
DEFAULT: 'rgb(var(--primary) / <alpha-value>)',
foreground: 'rgb(var(--primary-foreground) / <alpha-value>)',
},
secondary: {
DEFAULT: 'rgb(var(--secondary) / <alpha-value>)',
foreground: 'rgb(var(--secondary-foreground) / <alpha-value>)',
},
border: 'rgb(var(--border) / <alpha-value>)',
},
},
},
plugins: [