veza/apps/web/src/pages/DashboardPage.tsx
senke 3fb12b2ce2 aesthetic-improvements: automated replacement of decorative cyan with steel (80/20 rule, Action 11.3.1.3)
- Created automated script (scripts/replace-decorative-cyan.py) to systematically replace decorative/informational kodo-cyan instances with kodo-steel variants
- Script intelligently preserves active/functional states, design system variants, semantic indicators, and interactive states
- Modified 85 files, replaced 145 decorative instances, preserved 47 functional instances
- No linter errors, type safety maintained
- Action 11.3.1.3 significantly advanced (total: ~302 instances replaced across ~229 files including previous batches)
2026-01-16 11:40:13 +01:00

457 lines
18 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useUser } from '@/features/auth/hooks/useUser';
// Action 2.1.1.4: Removed unused library imports - library data now comes from dashboard endpoint
import { useDashboard } from '@/features/dashboard/hooks/useDashboard';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Music,
MessageSquare,
Users,
Heart,
Library,
Upload,
Plus,
TrendingUp,
Activity,
Clock,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { KodoEmptyState } from '@/components/ui/KodoEmptyState';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { FAB } from '@/components/ui/FAB';
import { cn } from '@/lib/utils';
/**
* Dashboard Premium - Version MVP avec UI moderne et professionnelle
* Intégration complète avec backend Go uniquement
*/
export function DashboardPage() {
// Action 2.1.1.4: Removed fetchItems call - library preview now comes from dashboard endpoint
const {
stats,
recentActivity,
isLoading: isLoadingDashboard,
} = useDashboard();
const navigate = useNavigate();
const { data: user } = useUser();
// Action 10.1.1.1: State for showing all metrics
const [showAllMetrics, setShowAllMetrics] = useState(false);
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
};
const dashboardStats = [
{
title: 'Pistes écoutées',
value: stats ? formatNumber(stats.tracks_played) : '0',
change: stats?.tracks_played_change || '+0%',
icon: Music,
color: 'text-kodo-steel',
bgGradient: 'bg-kodo-steel/10',
},
{
title: 'Messages envoyés',
value: stats ? formatNumber(stats.messages_sent) : '0',
change: stats?.messages_sent_change || '+0%',
icon: MessageSquare,
color: 'text-kodo-lime',
bgGradient: 'bg-kodo-lime/10',
},
{
title: 'Favoris',
value: stats ? formatNumber(stats.favorites) : '0',
change: stats?.favorites_change || '+0%',
icon: Heart,
color: 'text-kodo-magenta',
bgGradient: 'bg-kodo-magenta/10',
},
{
title: 'Amis actifs',
value: stats ? formatNumber(stats.active_friends) : '0',
change: stats?.active_friends_change || '+0%',
icon: Users,
color: 'text-kodo-gold',
bgGradient: 'bg-kodo-gold/10',
},
];
// Action 10.1.1.1: Show only 2-3 key metrics initially
const visibleStats = showAllMetrics ? dashboardStats : dashboardStats.slice(0, 2);
const formattedTimestamps = useMemo(() => {
const cache: Record<string, string> = {};
return recentActivity.reduce(
(acc, activity) => {
if (!cache[activity.timestamp]) {
try {
cache[activity.timestamp] = formatDistanceToNow(
new Date(activity.timestamp),
{
addSuffix: true,
locale: fr,
},
);
} catch {
cache[activity.timestamp] = 'Récemment';
}
}
acc[activity.timestamp] = cache[activity.timestamp];
return acc;
},
{} as Record<string, string>,
);
}, [recentActivity]);
const formatTimestamp = (timestamp: string) => {
return formattedTimestamps[timestamp] || 'Récemment';
};
return (
<div className="space-y-12 pb-12 animate-fade-in">
{/* Header Section */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-white tracking-tight">
Bienvenue,{' '}
<span className="text-kodo-steel">
{user?.username || 'Utilisateur'}
</span>
</h1>
<p className="text-white opacity-80 text-sm">
Voici un aperçu de votre activité sur Veza
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="lg"
onClick={() => navigate('/library')}
className="hidden sm:flex"
>
<Library className="w-4 h-4 mr-2" />
Bibliothèque
</Button>
</div>
</div>
{/* Stats Grid */}
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{visibleStats.map((stat, i) => {
const isPrimary = i === 0; // First stat (tracks played) is primary
return (
<Card
key={i}
className={cn(
'group hover:border-kodo-steel/50 transition-all duration-300',
isPrimary && 'md:col-span-2 lg:col-span-2', // Primary stat spans 2 columns
)}
>
<CardContent className={cn('p-6', isPrimary && 'p-8')}>
<div className="flex items-center justify-between mb-4">
<div
className={cn(
'rounded-xl',
stat.bgGradient,
'border border-white/10 transition-colors duration-300',
isPrimary ? 'p-4' : 'p-3',
)}
>
<stat.icon
className={cn(
stat.color,
isPrimary ? 'w-8 h-8' : 'w-5 h-5',
)}
/>
</div>
<div
className={cn(
'font-semibold px-2 py-1 rounded-lg',
isPrimary ? 'text-sm' : 'text-xs',
stat.change.startsWith('+')
? 'bg-kodo-lime/10 text-kodo-lime border border-kodo-lime/20'
: 'bg-kodo-red/10 text-kodo-red border border-kodo-red/20',
)}
>
{stat.change}
</div>
</div>
<div className="space-y-1">
<p
className={cn(
'font-medium text-kodo-secondary uppercase tracking-wider',
isPrimary ? 'text-sm' : 'text-xs',
)}
>
{stat.title}
</p>
<p
className={cn(
'font-bold text-white',
isPrimary ? 'text-6xl' : 'text-3xl',
)}
>
{stat.value}
</p>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Action 10.1.1.1: "View All" button to show remaining metrics */}
{!showAllMetrics && dashboardStats.length > 2 && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => setShowAllMetrics(true)}
className="flex items-center gap-2"
>
Voir tout
<ChevronDown className="w-4 h-4" />
</Button>
</div>
)}
{showAllMetrics && dashboardStats.length > 2 && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => setShowAllMetrics(false)}
className="flex items-center gap-2"
>
Voir moins
<ChevronUp className="w-4 h-4" />
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Activity Feed */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
{/* Action 10.1.1.5: Use accordion for collapsible section */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="activity" className="border-none">
<AccordionTrigger className="p-0 hover:no-underline">
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-kodo-steel" />
Activité récente
</CardTitle>
</AccordionTrigger>
<AccordionContent className="pt-6">
{/* Action 10.1.1.3: Use tabs to organize secondary info */}
<Tabs defaultValue="chart" className="mt-0">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chart">Graphique</TabsTrigger>
<TabsTrigger value="activity">Activité</TabsTrigger>
</TabsList>
<TabsContent value="chart" className="mt-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-kodo-steel" />
Activité récente
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="text-xs">
7J
</Button>
<Button variant="outline" size="sm" className="text-xs text-kodo-steel bg-kodo-steel/10 border-kodo-steel/20">
30J
</Button>
<Button variant="ghost" size="sm" className="text-xs">
MAX
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="h-64 flex items-end gap-2">
{[40, 65, 35, 90, 55, 75, 45, 85, 60, 70, 50, 95].map(
(h, i) => (
<div
key={i}
className="flex-1 bg-gradient-to-t from-kodo-steel/40 to-kodo-steel/20 rounded-t-lg transition-all duration-300 hover:from-kodo-steel/60 hover:to-kodo-steel/40 cursor-pointer group relative"
style={{ height: `${h}%` }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-kodo-ink border border-kodo-steel/30 text-kodo-steel text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap shadow-lg">
{h}%
</div>
</div>
),
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activity" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-kodo-steel" />
Dernières activités
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{isLoadingDashboard ? (
Array(3)
.fill(0)
.map((_, i) => (
<div
key={i}
className="h-16 bg-white/5 rounded-xl animate-pulse"
/>
))
) : recentActivity.length > 0 ? (
recentActivity.slice(0, 5).map((act, i) => (
<div
key={i}
className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 hover:border-kodo-steel/50 transition-all duration-200 group cursor-pointer"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-kodo-steel/20 border border-kodo-steel/20 flex items-center justify-center transition-colors">
<Clock className="w-5 h-5 text-kodo-steel" />
</div>
<div>
<p className="text-sm font-semibold text-white group-hover:text-white transition-colors">
{act.title}
</p>
<p className="text-xs text-kodo-secondary mt-0.5">
{act.description || 'Activité système'}
</p>
</div>
</div>
<div className="text-xs text-kodo-secondary font-mono">
{formatTimestamp(act.timestamp)}
</div>
</div>
))
) : (
<KodoEmptyState
icon={Activity}
title="Aucune activité récente"
description="Vos activités apparaîtront ici"
/>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardHeader>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Actions rapides</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{[
{
label: 'Upload Track',
icon: Upload,
action: () => navigate('/library?action=upload'),
variant: 'default' as const,
},
{
label: 'Bibliothèque',
icon: Library,
action: () => navigate('/library'),
variant: 'outline' as const,
},
{
label: 'Messages',
icon: MessageSquare,
action: () => navigate('/chat'),
variant: 'outline' as const,
},
].map((action, i) => (
<Button
key={i}
variant={action.variant}
className="w-full justify-start"
onClick={action.action}
>
<action.icon className="w-4 h-4 mr-2" />
{action.label}
</Button>
))}
</CardContent>
</Card>
{/* System Status */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Statut système</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-kodo-secondary">Backend API</span>
<span className="text-kodo-lime font-semibold flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-kodo-lime animate-pulse" />
En ligne
</span>
</div>
<div className="h-1.5 w-full bg-white/5 rounded-full overflow-hidden">
<div className="h-full bg-kodo-lime w-full" />
</div>
</div>
<div className="pt-4 border-t border-white/5">
<Button
variant="ghost"
className="w-full"
onClick={() => navigate('/settings')}
>
Paramètres système
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Floating Action Button for Upload */}
<FAB
position="bottom-right"
size="lg"
showLabel
label="Upload Track"
onClick={() => navigate('/library?action=upload')}
>
<Plus className="w-6 h-6" />
</FAB>
</div>
);
}