- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid - Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px) - Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation - Modified files across all components to ensure consistent 8px grid alignment - Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Select } from '@/components/ui/select';
|
|
import { logger } from '@/utils/logger';
|
|
import { parseApiError } from '@/utils/apiErrorHandler';
|
|
import {
|
|
Music,
|
|
Play,
|
|
Heart,
|
|
Download,
|
|
TrendingUp,
|
|
BarChart3,
|
|
ListMusic,
|
|
Share2,
|
|
} from 'lucide-react';
|
|
import {
|
|
getAnalyticsData,
|
|
type AnalyticsData,
|
|
} from '@/features/analytics/services/analyticsService';
|
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|
|
|
/**
|
|
* FE-PAGE-015: Analytics page for track and playlist statistics
|
|
*/
|
|
export function AnalyticsPage() {
|
|
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [period, setPeriod] = useState<number>(30);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
loadAnalytics();
|
|
}, [period]);
|
|
|
|
const loadAnalytics = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await getAnalyticsData(period);
|
|
setAnalytics(data);
|
|
} catch (err: unknown) {
|
|
const apiError = parseApiError(err);
|
|
logger.error('Failed to load analytics', {
|
|
error: apiError.message,
|
|
period,
|
|
});
|
|
setError(apiError.message);
|
|
} finally {
|
|
setLoading(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();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="flex items-center justify-center h-[400px]">
|
|
<LoadingSpinner />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Erreur</CardTitle>
|
|
<CardDescription>{error}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button onClick={loadAnalytics}>Réessayer</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!analytics) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="mb-6">
|
|
<h1 className="text-3xl font-bold mb-2">Analytics</h1>
|
|
<p className="text-muted-foreground">
|
|
Statistiques et métriques de performance pour vos pistes et playlists
|
|
</p>
|
|
</div>
|
|
|
|
{/* Period selector */}
|
|
<div className="mb-6 flex items-center gap-4">
|
|
<label htmlFor="period" className="text-sm font-medium">
|
|
Période:
|
|
</label>
|
|
<Select
|
|
options={[
|
|
{ value: '7', label: '7 derniers jours' },
|
|
{ value: '30', label: '30 derniers jours' },
|
|
{ value: '90', label: '90 derniers jours' },
|
|
{ value: '365', label: '1 an' },
|
|
]}
|
|
value={period.toString()}
|
|
onChange={(value) => setPeriod(parseInt(value as string, 10))}
|
|
placeholder="Sélectionner une période"
|
|
className="w-[180px]"
|
|
/>
|
|
</div>
|
|
|
|
{/* Track Analytics */}
|
|
<div className="mb-8">
|
|
<h2 className="text-2xl font-semibold mb-4 flex items-center gap-2">
|
|
<Music className="h-6 w-6" />
|
|
Statistiques des pistes
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des pistes
|
|
</CardTitle>
|
|
<Music className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.tracks.total_tracks)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des lectures
|
|
</CardTitle>
|
|
<Play className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.tracks.total_plays)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des likes
|
|
</CardTitle>
|
|
<Heart className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.tracks.total_likes)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des téléchargements
|
|
</CardTitle>
|
|
<Download className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.tracks.total_downloads)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Top Tracks */}
|
|
{analytics.tracks.top_tracks.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
Top 5 pistes
|
|
</CardTitle>
|
|
<CardDescription>Vos pistes les plus écoutées</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{analytics.tracks.top_tracks.map((track, index) => (
|
|
<div
|
|
key={track.id}
|
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent cursor-pointer"
|
|
onClick={() => navigate(`/tracks/${track.id}`)}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold">
|
|
{index + 1}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{track.title}</p>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Play className="h-3 w-3" />
|
|
{formatNumber(track.play_count)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Heart className="h-3 w-3" />
|
|
{formatNumber(track.like_count)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Playlist Analytics */}
|
|
<div className="mb-8">
|
|
<h2 className="text-2xl font-semibold mb-4 flex items-center gap-2">
|
|
<ListMusic className="h-6 w-6" />
|
|
Statistiques des playlists
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des playlists
|
|
</CardTitle>
|
|
<ListMusic className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.playlists.total_playlists)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des lectures
|
|
</CardTitle>
|
|
<Play className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.playlists.total_plays)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des likes
|
|
</CardTitle>
|
|
<Heart className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.playlists.total_likes)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total des partages
|
|
</CardTitle>
|
|
<Share2 className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analytics.playlists.total_shares)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Top Playlists */}
|
|
{analytics.playlists.top_playlists.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
Top 5 playlists
|
|
</CardTitle>
|
|
<CardDescription>Vos playlists les plus écoutées</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{analytics.playlists.top_playlists.map((playlist, index) => (
|
|
<div
|
|
key={playlist.id}
|
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent cursor-pointer"
|
|
onClick={() => navigate(`/playlists/${playlist.id}`)}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold">
|
|
{index + 1}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{playlist.name}</p>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Play className="h-3 w-3" />
|
|
{formatNumber(playlist.play_count)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Heart className="h-3 w-3" />
|
|
{formatNumber(playlist.like_count)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Summary Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<BarChart3 className="h-5 w-5" />
|
|
Résumé
|
|
</CardTitle>
|
|
<CardDescription>Vue d'ensemble de vos performances</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">
|
|
Lectures moyennes par piste
|
|
</p>
|
|
<p className="text-2xl font-bold">
|
|
{Math.round(analytics.tracks.average_play_count)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">
|
|
Lectures moyennes par playlist
|
|
</p>
|
|
<p className="text-2xl font-bold">
|
|
{Math.round(analytics.playlists.average_play_count)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|