feat: implement dashboard components including header, loading states, statistics grid, and top sounds section
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped

This commit is contained in:
JSC
2025-08-15 12:12:30 +02:00
parent c6912f5a92
commit 907a5df5c7
6 changed files with 427 additions and 406 deletions

View File

@@ -0,0 +1,30 @@
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
interface DashboardHeaderProps {
onRefresh: () => void
isRefreshing: boolean
}
export function DashboardHeader({ onRefresh, isRefreshing }: DashboardHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Overview of your soundboard and track statistics
</p>
</div>
<Button
onClick={onRefresh}
variant="outline"
size="sm"
disabled={isRefreshing}
>
<RefreshCw
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</Button>
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { AppLayout } from '@/components/AppLayout'
import { DashboardHeader } from '@/components/dashboard/DashboardHeader'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface LoadingSkeletonProps {
title: string
description: string
}
export function LoadingSkeleton({ title, description }: LoadingSkeletonProps) {
return (
<AppLayout
breadcrumb={{
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<DashboardHeader onRefresh={() => {}} isRefreshing={false} />
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Soundboard Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Loading...
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold animate-pulse">---</div>
</CardContent>
</Card>
))}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Track Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i + 4}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Loading...
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold animate-pulse">---</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</div>
</AppLayout>
)
}
export function ErrorState({ error }: { error: string }) {
return (
<AppLayout
breadcrumb={{
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<DashboardHeader onRefresh={() => {}} isRefreshing={false} />
<div className="border-2 border-dashed border-destructive/25 rounded-lg p-4">
<p className="text-destructive">Error loading statistics: {error}</p>
</div>
</div>
</AppLayout>
)
}

View File

@@ -0,0 +1,25 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { LucideIcon } from 'lucide-react'
import { ReactNode } from 'react'
interface StatisticCardProps {
title: string
icon: LucideIcon
value: ReactNode
description: string
}
export function StatisticCard({ title, icon: Icon, value, description }: StatisticCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,114 @@
import { StatisticCard } from '@/components/dashboard/StatisticCard'
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
import { NumberFlowSize } from '@/components/ui/number-flow-size'
import NumberFlow from '@number-flow/react'
import { Clock, HardDrive, Music, Play, Volume2 } from 'lucide-react'
interface SoundboardStatistics {
sound_count: number
total_play_count: number
total_duration: number
total_size: number
}
interface TrackStatistics {
track_count: number
total_play_count: number
total_duration: number
total_size: number
}
interface StatisticsGridProps {
soundboardStatistics: SoundboardStatistics
trackStatistics: TrackStatistics
}
export function StatisticsGrid({ soundboardStatistics, trackStatistics }: StatisticsGridProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Soundboard Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatisticCard
title="Total Sounds"
icon={Volume2}
value={<NumberFlow value={soundboardStatistics.sound_count} />}
description="Soundboard audio files"
/>
<StatisticCard
title="Total Plays"
icon={Play}
value={<NumberFlow value={soundboardStatistics.total_play_count} />}
description="All-time play count"
/>
<StatisticCard
title="Total Duration"
icon={Clock}
value={
<NumberFlowDuration
duration={soundboardStatistics.total_duration}
variant="wordy"
/>
}
description="Combined audio duration"
/>
<StatisticCard
title="Total Size"
icon={HardDrive}
value={
<NumberFlowSize
size={soundboardStatistics.total_size}
binary={true}
/>
}
description="Original + normalized files"
/>
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Track Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatisticCard
title="Total Tracks"
icon={Music}
value={<NumberFlow value={trackStatistics.track_count} />}
description="Extracted audio tracks"
/>
<StatisticCard
title="Total Plays"
icon={Play}
value={<NumberFlow value={trackStatistics.total_play_count} />}
description="All-time play count"
/>
<StatisticCard
title="Total Duration"
icon={Clock}
value={
<NumberFlowDuration
duration={trackStatistics.total_duration}
variant="wordy"
/>
}
description="Combined track duration"
/>
<StatisticCard
title="Total Size"
icon={HardDrive}
value={
<NumberFlowSize
size={trackStatistics.total_size}
binary={true}
/>
}
description="Original + normalized files"
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import NumberFlow from '@number-flow/react'
import { Clock, Loader2, Music, Trophy } from 'lucide-react'
interface TopSound {
id: number
name: string
type: string
play_count: number
duration: number | null
created_at: string | null
}
interface TopSoundsSectionProps {
topSounds: TopSound[]
loading: boolean
soundType: string
period: string
limit: number
onSoundTypeChange: (value: string) => void
onPeriodChange: (value: string) => void
onLimitChange: (value: number) => void
}
export function TopSoundsSection({
topSounds,
loading,
soundType,
period,
limit,
onSoundTypeChange,
onPeriodChange,
onLimitChange,
}: TopSoundsSectionProps) {
return (
<div className="mt-8">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
<CardTitle>Top Sounds</CardTitle>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Type:</span>
<Select value={soundType} onValueChange={onSoundTypeChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="SDB">Soundboard</SelectItem>
<SelectItem value="EXT">Tracks</SelectItem>
<SelectItem value="TTS">TTS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Period:</span>
<Select value={period} onValueChange={onPeriodChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="1_day">1 Day</SelectItem>
<SelectItem value="1_week">1 Week</SelectItem>
<SelectItem value="1_month">1 Month</SelectItem>
<SelectItem value="1_year">1 Year</SelectItem>
<SelectItem value="all_time">All Time</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Count:</span>
<Select
value={limit.toString()}
onValueChange={value => onLimitChange(parseInt(value))}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading top sounds...
</div>
) : topSounds.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No sounds found for the selected criteria</p>
</div>
) : (
<div className="space-y-3">
{topSounds.map((sound, index) => (
<div
key={sound.id}
className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg"
>
<div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full font-bold text-sm">
{index + 1}
</div>
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{sound.name}</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
{sound.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<NumberFlowDuration
duration={sound.duration}
variant="wordy"
/>
</span>
)}
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
{sound.type}
</span>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-primary">
<NumberFlow value={sound.play_count} />
</div>
<div className="text-xs text-muted-foreground">plays</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,26 +1,8 @@
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button' import { DashboardHeader } from '@/components/dashboard/DashboardHeader'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { ErrorState, LoadingSkeleton } from '@/components/dashboard/DashboardLoadingStates'
import { NumberFlowDuration } from '@/components/ui/number-flow-duration' import { StatisticsGrid } from '@/components/dashboard/StatisticsGrid'
import { NumberFlowSize } from '@/components/ui/number-flow-size' import { TopSoundsSection } from '@/components/dashboard/TopSoundsSection'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import NumberFlow from '@number-flow/react'
import {
Clock,
HardDrive,
Loader2,
Music,
Play,
RefreshCw,
Trophy,
Volume2,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
interface SoundboardStatistics { interface SoundboardStatistics {
@@ -176,94 +158,11 @@ export function DashboardPage() {
}, [fetchTopSounds]) }, [fetchTopSounds])
if (loading) { if (loading) {
return ( return <LoadingSkeleton title="Dashboard" description="Loading dashboard statistics..." />
<AppLayout
breadcrumb={{
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Overview of your soundboard and track statistics
</p>
</div>
</div>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Soundboard Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Loading...
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold animate-pulse">
---
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Track Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i + 4}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Loading...
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold animate-pulse">
---
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</div>
</AppLayout>
)
} }
if (error || !soundboardStatistics || !trackStatistics) { if (error || !soundboardStatistics || !trackStatistics) {
return ( return <ErrorState error={error || 'Failed to load statistics'} />
<AppLayout
breadcrumb={{
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Overview of your soundboard and track statistics
</p>
</div>
</div>
<div className="border-2 border-dashed border-destructive/25 rounded-lg p-4">
<p className="text-destructive">
Error loading statistics: {error}
</p>
</div>
</div>
</AppLayout>
)
} }
return ( return (
@@ -273,306 +172,24 @@ export function DashboardPage() {
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6"> <DashboardHeader onRefresh={refreshAll} isRefreshing={refreshing} />
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Overview of your soundboard and track statistics
</p>
</div>
<Button
onClick={refreshAll}
variant="outline"
size="sm"
disabled={refreshing}
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
/>
</Button>
</div>
<div className="space-y-6"> <div className="space-y-6">
{/* Soundboard Statistics */} <StatisticsGrid
<div> soundboardStatistics={soundboardStatistics}
<h2 className="text-lg font-semibold mb-3 text-muted-foreground"> trackStatistics={trackStatistics}
Soundboard Statistics />
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <TopSoundsSection
<Card> topSounds={topSounds}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> loading={topSoundsLoading}
<CardTitle className="text-sm font-medium"> soundType={soundType}
Total Sounds period={period}
</CardTitle> limit={limit}
<Volume2 className="h-4 w-4 text-muted-foreground" /> onSoundTypeChange={setSoundType}
</CardHeader> onPeriodChange={setPeriod}
<CardContent> onLimitChange={setLimit}
<div className="text-2xl font-bold"> />
<NumberFlow value={soundboardStatistics.sound_count} />
</div>
<p className="text-xs text-muted-foreground">
Soundboard audio files
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Plays
</CardTitle>
<Play className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlow value={soundboardStatistics.total_play_count} />
</div>
<p className="text-xs text-muted-foreground">
All-time play count
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Duration
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowDuration
duration={soundboardStatistics.total_duration}
variant="wordy"
/>
</div>
<p className="text-xs text-muted-foreground">
Combined audio duration
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Size
</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowSize
size={soundboardStatistics.total_size}
binary={true}
/>
</div>
<p className="text-xs text-muted-foreground">
Original + normalized files
</p>
</CardContent>
</Card>
</div>
</div>
{/* Track Statistics */}
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Track Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Tracks
</CardTitle>
<Music className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlow value={trackStatistics.track_count} />
</div>
<p className="text-xs text-muted-foreground">
Extracted audio tracks
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Plays
</CardTitle>
<Play className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlow value={trackStatistics.total_play_count} />
</div>
<p className="text-xs text-muted-foreground">
All-time play count
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Duration
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowDuration
duration={trackStatistics.total_duration}
variant="wordy"
/>
</div>
<p className="text-xs text-muted-foreground">
Combined track duration
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Size
</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowSize
size={trackStatistics.total_size}
binary={true}
/>
</div>
<p className="text-xs text-muted-foreground">
Original + normalized files
</p>
</CardContent>
</Card>
</div>
</div>
{/* Top Sounds Section */}
<div className="mt-8">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
<CardTitle>Top Sounds</CardTitle>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Type:</span>
<Select value={soundType} onValueChange={setSoundType}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="SDB">Soundboard</SelectItem>
<SelectItem value="EXT">Tracks</SelectItem>
<SelectItem value="TTS">TTS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Period:</span>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="1_day">1 Day</SelectItem>
<SelectItem value="1_week">1 Week</SelectItem>
<SelectItem value="1_month">1 Month</SelectItem>
<SelectItem value="1_year">1 Year</SelectItem>
<SelectItem value="all_time">All Time</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Count:</span>
<Select
value={limit.toString()}
onValueChange={value => setLimit(parseInt(value))}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardHeader>
<CardContent>
{topSoundsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading top sounds...
</div>
) : topSounds.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No sounds found for the selected criteria</p>
</div>
) : (
<div className="space-y-3">
{topSounds.map((sound, index) => (
<div
key={sound.id}
className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg"
>
<div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full font-bold text-sm">
{index + 1}
</div>
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{sound.name}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
{sound.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<NumberFlowDuration
duration={sound.duration}
variant="wordy"
/>
</span>
)}
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
{sound.type}
</span>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-primary">
<NumberFlow value={sound.play_count} />
</div>
<div className="text-xs text-muted-foreground">
plays
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
</AppLayout> </AppLayout>