✨ feat(default): redesign dashboard overview
Refresh the overview page with an actionable Get Started guide, live API request details, real usage sparklines, and OpenAI-inspired dashboard panels. Add collapsible setup state, role-aware quick actions, and localized copy so returning users can focus on platform health.
This commit is contained in:
@@ -1,29 +1,63 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface PanelWrapperProps {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
loading?: boolean
|
||||
empty?: boolean
|
||||
emptyMessage?: string
|
||||
height?: string
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
headerActions?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function PanelHeader(props: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const heading = (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
{props.description != null && (
|
||||
<div className='text-muted-foreground text-xs'>{props.description}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='border-b px-4 py-3 sm:px-5'>
|
||||
{props.actions != null ? (
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
{heading}
|
||||
{props.actions}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelWrapper(props: PanelWrapperProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedEmptyMessage = props.emptyMessage ?? t('No data available')
|
||||
const height = props.height ?? 'h-64'
|
||||
const frameClassName = cn(
|
||||
'overflow-hidden rounded-2xl border bg-card shadow-xs',
|
||||
props.className
|
||||
)
|
||||
|
||||
if (props.loading) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
</div>
|
||||
<div className='p-3 sm:p-5'>
|
||||
<div className={frameClassName}>
|
||||
<PanelHeader title={props.title} description={props.description} />
|
||||
<div className={cn('p-4 sm:p-5', props.contentClassName)}>
|
||||
<Skeleton className={`w-full ${height}`} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,12 +66,14 @@ export function PanelWrapper(props: PanelWrapperProps) {
|
||||
|
||||
if (props.empty) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
</div>
|
||||
<div className={frameClassName}>
|
||||
<PanelHeader title={props.title} description={props.description} />
|
||||
<div
|
||||
className={`text-muted-foreground flex items-center justify-center text-sm ${height}`}
|
||||
className={cn(
|
||||
'text-muted-foreground flex items-center justify-center px-4 text-sm',
|
||||
height,
|
||||
props.contentClassName
|
||||
)}
|
||||
>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
@@ -46,18 +82,15 @@ export function PanelWrapper(props: PanelWrapperProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
|
||||
{props.headerActions ? (
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
{props.headerActions}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
)}
|
||||
<div className={frameClassName}>
|
||||
<PanelHeader
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
actions={props.headerActions}
|
||||
/>
|
||||
<div className={cn('p-4 sm:p-5', props.contentClassName)}>
|
||||
{props.children}
|
||||
</div>
|
||||
<div className='p-3 sm:p-5'>{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+53
-12
@@ -1,53 +1,94 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
type StatCardTone = 'rose' | 'teal' | 'gray'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
sparkline?: number[]
|
||||
tone?: StatCardTone
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
action?: React.ReactNode
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
const TONE_CLASSES: Record<StatCardTone, string> = {
|
||||
rose: 'from-rose-500/80 via-rose-300/70 to-rose-200/20 dark:from-rose-400/70 dark:via-rose-500/30 dark:to-rose-500/5',
|
||||
teal: 'from-teal-500/80 via-teal-300/70 to-teal-200/20 dark:from-teal-400/70 dark:via-teal-500/30 dark:to-teal-500/5',
|
||||
gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20',
|
||||
}
|
||||
|
||||
function normalizeSparkline(values?: number[]): number[] {
|
||||
if (!values?.length) return []
|
||||
|
||||
const sanitized = values.map((value) => Math.max(0, Number(value) || 0))
|
||||
const max = Math.max(...sanitized)
|
||||
if (max <= 0) return sanitized.map(() => 0)
|
||||
|
||||
return sanitized.map((value) => Math.max(8, (value / max) * 100))
|
||||
}
|
||||
|
||||
export function StatCard(props: StatCardProps) {
|
||||
const Icon = props.icon
|
||||
const tone = props.tone ?? 'gray'
|
||||
const sparkline = normalizeSparkline(props.sparkline)
|
||||
|
||||
return (
|
||||
<div className='group flex flex-col gap-1'>
|
||||
<div className='group flex min-h-32 flex-col justify-between gap-3'>
|
||||
<div className='flex items-start justify-between gap-1'>
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wider uppercase sm:gap-2'>
|
||||
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium sm:gap-2'>
|
||||
<Icon
|
||||
className='text-muted-foreground/60 size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='line-clamp-2 leading-snug'>{props.title}</span>
|
||||
</div>
|
||||
{props.action && <div className='shrink-0'>{props.action}</div>}
|
||||
</div>
|
||||
|
||||
{props.loading ? (
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-7 w-24' />
|
||||
<Skeleton className='h-3.5 w-32' />
|
||||
</div>
|
||||
) : props.error ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
|
||||
--
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 hidden text-xs md:block'>
|
||||
<p className='text-muted-foreground/60 text-xs'>
|
||||
{props.description}
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='text-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-foreground font-mono text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
||||
{props.value}
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 hidden text-xs md:block'>
|
||||
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
|
||||
{props.description}
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex h-8 items-end gap-1' aria-hidden='true'>
|
||||
{sparkline.map((height, index) => (
|
||||
<span
|
||||
key={`${props.title}-spark-${index}`}
|
||||
className={cn(
|
||||
'flex-1 rounded-t-sm bg-linear-to-t',
|
||||
height <= 0 && 'opacity-20',
|
||||
TONE_CLASSES[tone]
|
||||
)}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user