- Created state normalization utility (stateNormalization.ts) with functions: * normalize/denormalize for converting arrays to normalized state * addToNormalized, updateInNormalized, removeFromNormalized * Helper functions for working with normalized state - Applied normalization to LibraryStore (items and favorites) - Updated storeSelectors to convert normalized state to arrays - Updated DashboardPage components to use new selectors - Updated tests to work with normalized state structure - Improved performance with O(1) lookups instead of O(n) array searches
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { useLibraryItems, useLibraryActions, useLibraryStatus } from '@/utils/storeSelectors';
|
|
import { useDashboard } from '@/features/dashboard/hooks/useDashboard';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Music, MessageSquare, Users, Heart, Library, Upload } from 'lucide-react';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { fr } from 'date-fns/locale';
|
|
|
|
/**
|
|
* Page principale du dashboard avec statistiques et aperçu de l'activité.
|
|
* FE-PAGE-001: Complete Dashboard page implementation
|
|
*/
|
|
export function DashboardPage() {
|
|
const { user } = useAuthStore();
|
|
// FE-STATE-009: Use selector that returns denormalized array
|
|
const items = useLibraryItems();
|
|
const { fetchItems } = useLibraryActions();
|
|
const { isLoading: isLoadingLibrary } = useLibraryStatus();
|
|
const { stats, recentActivity, isLoading: isLoadingDashboard } = useDashboard();
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
fetchItems({ limit: 5 });
|
|
}, [fetchItems]);
|
|
|
|
// FE-PAGE-001: Format stats with real data
|
|
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-blue-600',
|
|
},
|
|
{
|
|
title: 'Messages envoyés',
|
|
value: stats ? formatNumber(stats.messages_sent) : '0',
|
|
change: stats?.messages_sent_change || '+0%',
|
|
icon: MessageSquare,
|
|
color: 'text-green-600',
|
|
},
|
|
{
|
|
title: 'Favoris',
|
|
value: stats ? formatNumber(stats.favorites) : '0',
|
|
change: stats?.favorites_change || '+0%',
|
|
icon: Heart,
|
|
color: 'text-red-600',
|
|
},
|
|
{
|
|
title: 'Amis actifs',
|
|
value: stats ? formatNumber(stats.active_friends) : '0',
|
|
change: stats?.active_friends_change || '+0%',
|
|
icon: Users,
|
|
color: 'text-purple-600',
|
|
},
|
|
];
|
|
|
|
// FE-PAGE-001: Get activity icon color based on type
|
|
const getActivityColor = (type: string) => {
|
|
switch (type) {
|
|
case 'track_upload':
|
|
return 'bg-blue-600';
|
|
case 'message_received':
|
|
return 'bg-green-600';
|
|
case 'favorite_added':
|
|
return 'bg-red-600';
|
|
case 'playlist_created':
|
|
return 'bg-purple-600';
|
|
case 'comment_added':
|
|
return 'bg-yellow-600';
|
|
default:
|
|
return 'bg-gray-600';
|
|
}
|
|
};
|
|
|
|
// FE-PAGE-001: Format timestamp to relative time
|
|
const formatTimestamp = (timestamp: string) => {
|
|
try {
|
|
return formatDistanceToNow(new Date(timestamp), {
|
|
addSuffix: true,
|
|
locale: fr,
|
|
});
|
|
} catch {
|
|
return 'Récemment';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6">
|
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">Dashboard</h1>
|
|
|
|
{/* En-tête avec salutation */}
|
|
<div className="mb-6">
|
|
<p className="text-muted-foreground">
|
|
{user?.first_name || user?.username
|
|
? `Bonjour, ${user.first_name || user.username} !`
|
|
: 'Bienvenue sur Veza'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* FE-PAGE-001: Statistiques avec données réelles */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
|
|
{isLoadingDashboard ? (
|
|
// Loading skeleton
|
|
[...Array(4)].map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<div className="h-4 bg-muted rounded w-24"></div>
|
|
<div className="h-4 w-4 bg-muted rounded"></div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-8 bg-muted rounded w-16 mb-2"></div>
|
|
<div className="h-3 bg-muted rounded w-20"></div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
) : (
|
|
dashboardStats.map((stat) => (
|
|
<Card key={stat.title}>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
{stat.title}
|
|
</CardTitle>
|
|
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stat.value}</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{stat.change && (
|
|
<span className="text-green-600">{stat.change}</span>
|
|
)}
|
|
{stat.change && ' par rapport au mois dernier'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Aperçu récent */}
|
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
{/* Activité récente */}
|
|
<Card className="md:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle>Activité récente</CardTitle>
|
|
<CardDescription>
|
|
Vos dernières interactions sur la plateforme
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoadingDashboard ? (
|
|
// Loading skeleton
|
|
<div className="space-y-4">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="flex items-center space-x-4">
|
|
<div className="w-2 h-2 bg-muted rounded-full"></div>
|
|
<div className="flex-1 space-y-1">
|
|
<div className="h-4 bg-muted rounded w-3/4"></div>
|
|
<div className="h-3 bg-muted rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : recentActivity.length > 0 ? (
|
|
// FE-PAGE-001: Real activity data
|
|
<div className="space-y-4">
|
|
{recentActivity.slice(0, 5).map((activity) => (
|
|
<div key={activity.id} className="flex items-center space-x-4">
|
|
<div
|
|
className={`w-2 h-2 ${getActivityColor(activity.type)} rounded-full`}
|
|
></div>
|
|
<div className="flex-1 space-y-1">
|
|
<p className="text-sm font-medium">{activity.title}</p>
|
|
{activity.description && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{activity.description}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatTimestamp(activity.timestamp)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
Aucune activité récente
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Pistes récentes */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Pistes récentes</CardTitle>
|
|
<CardDescription>
|
|
Dernières pistes de votre bibliothèque
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoadingLibrary ? (
|
|
<div className="space-y-2">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="flex items-center space-x-3">
|
|
<div className="w-10 h-10 bg-muted rounded"></div>
|
|
<div className="flex-1 space-y-1">
|
|
<div className="h-4 bg-muted rounded w-3/4"></div>
|
|
<div className="h-3 bg-muted rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{items.slice(0, 3).map((item) => (
|
|
<div key={item.id} className="flex items-center space-x-3">
|
|
<div className="w-10 h-10 bg-muted rounded flex items-center justify-center">
|
|
<Music className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{item.title}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{item.description || 'Aucune description'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{items.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
Aucune piste dans votre bibliothèque
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* FE-PAGE-001: Actions rapides avec navigation fonctionnelle */}
|
|
<Card className="mt-6">
|
|
<CardHeader>
|
|
<CardTitle>Actions rapides</CardTitle>
|
|
<CardDescription>
|
|
Accédez rapidement aux fonctionnalités principales
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<Button
|
|
variant="outline"
|
|
className="h-20 flex-col space-y-2"
|
|
onClick={() => navigate('/library?action=upload')}
|
|
>
|
|
<Upload className="h-6 w-6" />
|
|
<span>Nouvelle piste</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-20 flex-col space-y-2"
|
|
onClick={() => navigate('/chat')}
|
|
>
|
|
<MessageSquare className="h-6 w-6" />
|
|
<span>Nouveau chat</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-20 flex-col space-y-2"
|
|
onClick={() => navigate('/library')}
|
|
>
|
|
<Library className="h-6 w-6" />
|
|
<span>Bibliothèque</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-20 flex-col space-y-2"
|
|
onClick={() => navigate('/profile?tab=friends')}
|
|
>
|
|
<Users className="h-6 w-6" />
|
|
<span>Inviter des amis</span>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|