Release 0.14.0-alpha #42
@@ -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
67
components/ui/avatar.tsx
Normal 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
230
components/ui/badge-2.tsx
Normal 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
412
components/ui/button-1.tsx
Normal 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
147
components/ui/card.tsx
Normal 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 };
|
||||
290
components/ui/line-charts-6.tsx
Normal file
290
components/ui/line-charts-6.tsx
Normal 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
1
lib/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { cn } from '@ciphera-net/ui'
|
||||
1756
package-lock.json
generated
1756
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user