- 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)
457 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|