Release 0.14.0-alpha #42
@@ -2,34 +2,15 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||||
import { useTheme } from '@ciphera-net/ui'
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
import {
|
import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
|
||||||
BarChart,
|
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
|
||||||
Bar,
|
import { Badge } from '@/components/ui/badge-2'
|
||||||
XAxis,
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
CartesianGrid,
|
|
||||||
ReferenceLine,
|
|
||||||
} from 'recharts'
|
|
||||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts'
|
|
||||||
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
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'
|
import { Checkbox } from '@ciphera-net/ui'
|
||||||
|
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
|
||||||
const COLORS = {
|
import { cn } from '@/lib/utils'
|
||||||
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
|
|
||||||
|
|
||||||
const ANNOTATION_COLORS: Record<string, string> = {
|
const ANNOTATION_COLORS: Record<string, string> = {
|
||||||
deploy: '#3b82f6',
|
deploy: '#3b82f6',
|
||||||
@@ -104,102 +85,54 @@ function formatEU(dateStr: string): string {
|
|||||||
return `${d}/${m}/${y}`
|
return `${d}/${m}/${y}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Metric configurations ──────────────────────────────────────────
|
||||||
|
|
||||||
function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
|
const METRIC_CONFIGS: {
|
||||||
const startDate = new Date(dateRange.start)
|
key: MetricType
|
||||||
const endDate = new Date(dateRange.end)
|
label: string
|
||||||
const duration = endDate.getTime() - startDate.getTime()
|
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 chartConfig = {
|
||||||
const prev = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
|
||||||
return prev.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
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)
|
// ─── Custom Tooltip ─────────────────────────────────────────────────
|
||||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
|
||||||
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
||||||
return `${fmt(prevStart)} – ${fmt(prevEnd)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTrendContext(dateRange: { start: string; end: string }): string {
|
interface TooltipProps {
|
||||||
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,
|
|
||||||
}: {
|
|
||||||
active?: boolean
|
active?: boolean
|
||||||
payload?: Array<{ payload: Record<string, number>; value: number; dataKey?: string }>
|
payload?: Array<{ dataKey: string; value: number; color: string }>
|
||||||
label?: string
|
label?: string
|
||||||
metric: MetricType
|
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]
|
function CustomTooltip({ active, payload, metric }: TooltipProps) {
|
||||||
const value = Number(current?.value ?? current?.payload?.[metric] ?? 0)
|
if (active && payload && payload.length) {
|
||||||
|
const entry = payload[0]
|
||||||
const prevKey = metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'
|
const config = METRIC_CONFIGS.find((m) => m.key === metric)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (config) {
|
||||||
return (
|
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="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="text-[11px] font-medium mb-1 text-neutral-500 dark:text-neutral-400">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{label}
|
<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 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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chart Component ─────────────────────────────────────────────────
|
// ─── Chart Component ─────────────────────────────────────────────────
|
||||||
@@ -224,9 +157,9 @@ export default function Chart({
|
|||||||
onDeleteAnnotation,
|
onDeleteAnnotation,
|
||||||
}: ChartProps) {
|
}: ChartProps) {
|
||||||
const [metric, setMetric] = useState<MetricType>('visitors')
|
const [metric, setMetric] = useState<MetricType>('visitors')
|
||||||
const [showComparison, setShowComparison] = useState(false)
|
|
||||||
const chartContainerRef = useRef<HTMLDivElement>(null)
|
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
const [showComparison, setShowComparison] = useState(false)
|
||||||
|
|
||||||
// ─── Annotation state ─────────────────────────────────────────────
|
// ─── Annotation state ─────────────────────────────────────────────
|
||||||
const [annotationForm, setAnnotationForm] = useState<{
|
const [annotationForm, setAnnotationForm] = useState<{
|
||||||
@@ -268,9 +201,7 @@ export default function Chart({
|
|||||||
|
|
||||||
// ─── Data ──────────────────────────────────────────────────────────
|
// ─── Data ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const chartData = data.map((item, i) => {
|
const chartData = data.map((item) => {
|
||||||
const prevItem = prevData?.[i]
|
|
||||||
|
|
||||||
let formattedDate: string
|
let formattedDate: string
|
||||||
if (interval === 'minute') {
|
if (interval === 'minute') {
|
||||||
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||||
@@ -291,10 +222,6 @@ export default function Chart({
|
|||||||
visitors: item.visitors,
|
visitors: item.visitors,
|
||||||
bounce_rate: item.bounce_rate,
|
bounce_rate: item.bounce_rate,
|
||||||
avg_duration: item.avg_duration,
|
avg_duration: item.avg_duration,
|
||||||
prevPageviews: prevItem?.pageviews,
|
|
||||||
prevVisitors: prevItem?.visitors,
|
|
||||||
prevBounceRate: prevItem?.bounce_rate,
|
|
||||||
prevAvgDuration: prevItem?.avg_duration,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -369,109 +296,72 @@ export default function Chart({
|
|||||||
}
|
}
|
||||||
}, [annotationForm.editingId, onDeleteAnnotation])
|
}, [annotationForm.editingId, onDeleteAnnotation])
|
||||||
|
|
||||||
// ─── Metrics ───────────────────────────────────────────────────────
|
// ─── Metrics with trends ──────────────────────────────────────────
|
||||||
|
|
||||||
const calculateTrend = (current: number, previous?: number) => {
|
const metricsWithTrends = METRIC_CONFIGS.map((m) => {
|
||||||
if (!previous) return null
|
const value = stats[m.key]
|
||||||
if (previous === 0) return current > 0 ? 100 : 0
|
const previousValue = prevStats?.[m.key]
|
||||||
return Math.round(((current - previous) / previous) * 100)
|
const change = previousValue != null && previousValue > 0
|
||||||
|
? ((value - previousValue) / previousValue) * 100
|
||||||
|
: null
|
||||||
|
const isPositive = change !== null ? (m.isNegative ? change < 0 : change > 0) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
value,
|
||||||
|
previousValue,
|
||||||
|
change,
|
||||||
|
isPositive,
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
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 },
|
|
||||||
]
|
|
||||||
|
|
||||||
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 hasData = data.length > 0
|
||||||
const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 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>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Render ────────────────────────────────────────────────────────
|
// ─── Render ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={chartContainerRef} className="relative">
|
||||||
ref={chartContainerRef}
|
<Card className="@container w-full overflow-hidden rounded-2xl">
|
||||||
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden relative"
|
<CardHeader className="p-0 mb-0">
|
||||||
role="region"
|
{/* Metrics Grid - 21st.dev style */}
|
||||||
aria-label={`Analytics chart showing ${metricLabel} over time`}
|
<div className="grid grid-cols-2 @2xl:grid-cols-2 @3xl:grid-cols-4 grow w-full">
|
||||||
>
|
{metricsWithTrends.map((m) => (
|
||||||
{/* 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
|
<button
|
||||||
key={item.id}
|
key={m.key}
|
||||||
type="button"
|
onClick={() => setMetric(m.key)}
|
||||||
onClick={() => setMetric(item.id)}
|
className={cn(
|
||||||
aria-pressed={isActive}
|
'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',
|
||||||
aria-label={`Show ${item.label} chart`}
|
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
|
||||||
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'}`}>
|
<div className="flex items-center justify-between mb-2">
|
||||||
{item.label}
|
<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>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</div>
|
||||||
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
|
{m.previousValue != null && (
|
||||||
{item.value}
|
<div className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">from {m.format(m.previousValue)}</div>
|
||||||
</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>
|
</button>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
{/* Chart Area */}
|
<CardContent className="px-2.5 py-4">
|
||||||
<div className="px-4 sm:px-6 pt-4 pb-2">
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
<div className="flex items-center justify-between gap-3 mb-4 px-2">
|
||||||
{/* Left: metric label + avg badge */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||||
{metricLabel}
|
{METRIC_CONFIGS.find((m) => m.key === metric)?.label}
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: controls */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{dateRange.start === dateRange.end ? (
|
{dateRange.start === dateRange.end ? (
|
||||||
<Select
|
<Select
|
||||||
@@ -524,51 +414,74 @@ export default function Chart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasData ? (
|
{!hasData || !hasAnyNonZero ? (
|
||||||
<div className="flex h-[250px] flex-col items-center justify-center gap-2">
|
<div className="flex h-96 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">
|
||||||
<p className="text-sm text-neutral-400 dark:text-neutral-500">No data for this period</p>
|
{!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`}
|
||||||
</div>
|
</p>
|
||||||
) : !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>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full" onContextMenu={handleChartContextMenu}>
|
<div className="w-full" onContextMenu={handleChartContextMenu}>
|
||||||
<ChartContainer config={dashboardChartConfig} className="aspect-auto h-[250px] w-full">
|
<ChartContainer
|
||||||
<BarChart
|
config={chartConfig}
|
||||||
accessibilityLayer
|
className="h-96 w-full overflow-visible [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
|
||||||
data={chartData}
|
|
||||||
margin={{ left: 12, right: 12 }}
|
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<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
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
|
||||||
|
tickMargin={10}
|
||||||
minTickGap={32}
|
minTickGap={32}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
|
||||||
content={
|
<YAxis
|
||||||
<DashboardTooltipContent
|
axisLine={false}
|
||||||
metric={metric}
|
tickLine={false}
|
||||||
metricLabel={metricLabel}
|
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
|
||||||
formatNumberFn={formatNumber}
|
tickMargin={10}
|
||||||
showComparison={hasPrev}
|
tickCount={6}
|
||||||
prevPeriodLabel={prevPeriodLabel}
|
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' }}
|
||||||
/>
|
/>
|
||||||
{hasPrev && (
|
|
||||||
<Bar
|
{/* Annotation reference lines */}
|
||||||
dataKey={metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'}
|
|
||||||
fill="var(--chart-axis)"
|
|
||||||
fillOpacity={0.15}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Bar dataKey={metric} fill={`var(--color-${metric})`} />
|
|
||||||
{annotationMarkers.map((marker) => {
|
{annotationMarkers.map((marker) => {
|
||||||
const primaryCategory = marker.annotations[0].category
|
const primaryCategory = marker.annotations[0].category
|
||||||
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
|
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
|
||||||
@@ -583,14 +496,32 @@ export default function Chart({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</BarChart>
|
|
||||||
|
<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>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Annotation tags */}
|
||||||
{annotationMarkers.length > 0 && (
|
{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>
|
<span className="text-[10px] font-medium text-neutral-400 dark:text-neutral-500 mr-1">Annotations:</span>
|
||||||
{annotationMarkers.map((marker) => {
|
{annotationMarkers.map((marker) => {
|
||||||
const primary = marker.annotations[0]
|
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="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||||
<span className="max-w-[120px] truncate">{primary.text}</span>
|
<span className="max-w-[120px] truncate">{primary.text}</span>
|
||||||
{count > 1 && <span className="text-neutral-400">+{count - 1}</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="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]">
|
<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) => (
|
{marker.annotations.map((a) => (
|
||||||
@@ -641,7 +571,7 @@ export default function Chart({
|
|||||||
|
|
||||||
{/* Live indicator */}
|
{/* Live indicator */}
|
||||||
{lastUpdatedAt != null && (
|
{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">
|
<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="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" />
|
<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'}
|
{annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Date picker trigger */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Date</label>
|
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Date</label>
|
||||||
<button
|
<button
|
||||||
@@ -706,7 +635,6 @@ export default function Chart({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Time input */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
<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>
|
Time <span className="text-neutral-400 dark:text-neutral-500">(optional)</span>
|
||||||
@@ -730,7 +658,6 @@ export default function Chart({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Note */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Note</label>
|
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Note</label>
|
||||||
<input
|
<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>
|
<span className="text-[10px] text-neutral-400 mt-0.5 block text-right">{annotationForm.text.length}/200</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Category - custom Select */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Category</label>
|
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Category</label>
|
||||||
<Select
|
<Select
|
||||||
@@ -792,7 +718,7 @@ export default function Chart({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── DatePicker overlay (single mode) ─────────────────────── */}
|
{/* ─── DatePicker overlay ─────────────────────── */}
|
||||||
<DatePicker
|
<DatePicker
|
||||||
isOpen={calendarOpen}
|
isOpen={calendarOpen}
|
||||||
onClose={() => setCalendarOpen(false)}
|
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/react-stripe-js": "^5.6.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"jspdf": "^4.0.0",
|
"jspdf": "^4.0.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -17,6 +17,19 @@
|
|||||||
--chart-5: #f59e0b;
|
--chart-5: #f59e0b;
|
||||||
--chart-grid: #f5f5f5;
|
--chart-grid: #f5f5f5;
|
||||||
--chart-axis: #a3a3a3;
|
--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 {
|
.dark {
|
||||||
@@ -27,6 +40,19 @@
|
|||||||
--chart-5: #fbbf24;
|
--chart-5: #fbbf24;
|
||||||
--chart-grid: #262626;
|
--chart-grid: #262626;
|
||||||
--chart-axis: #737373;
|
--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 {
|
body {
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ const config: Config = {
|
|||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
|
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: [
|
plugins: [
|
||||||
|
|||||||
Reference in New Issue
Block a user