veza/apps/web/src/features/tracks/pages/TrackDetailPage.tsx

302 lines
9.7 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getTrack, TrackUploadError } from '../services/trackService';
import { usePlayerStore } from '@/features/player/store/playerStore';
import type { Track as PlayerTrack } from '@/features/player/types';
import { toast } from 'react-hot-toast';
import { Button, Card } from '@veza/design-system';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Play, Pause, ArrowLeft, Share2, Plus, MessageCircle, History, BarChart3 } from 'lucide-react';
import { Music } from 'lucide-react';
import type { Track } from '../types/track';
import { CommentSection } from '../components/CommentSection';
import { ShareDialog } from '../components/ShareDialog';
import { TrackHistory } from '../components/TrackHistory';
import { TrackStatsDisplay } from '../components/TrackStatsDisplay';
// FE-PAGE-007: Complete Track Detail page implementation
// MIGRATED: Now using Kōdō Design System
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
export function TrackDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [track, setTrack] = useState<Track | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const { play, pause, currentTrack, isPlaying, addToQueue } = usePlayerStore();
useEffect(() => {
if (!id) {
setError('Track ID is required');
setIsLoading(false);
return;
}
const loadTrack = async () => {
try {
setIsLoading(true);
setError(null);
if (!id) {
throw new Error('Invalid track ID');
}
const loadedTrack = await getTrack(id);
setTrack(loadedTrack);
} catch (err) {
const errorMessage =
err instanceof TrackUploadError
? err.message
: err instanceof Error
? err.message
: 'Failed to load track';
setError(errorMessage);
if (err instanceof TrackUploadError) {
toast.error(errorMessage);
}
} finally {
setIsLoading(false);
}
};
loadTrack();
}, [id]);
const mapToPlayerTrack = (t: Track): PlayerTrack => ({
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
duration: t.duration,
url: t.stream_manifest_url || t.file_path,
cover: t.cover_art_path,
genre: t.genre,
});
const handlePlay = () => {
if (!track) return;
const playerTrack = mapToPlayerTrack(track);
play(playerTrack);
};
const handlePause = () => {
pause();
};
const handleAddToQueue = () => {
if (!track) return;
const playerTrack = mapToPlayerTrack(track);
addToQueue([playerTrack]);
toast.success('Track ajouté à la file d\'attente');
};
const handleShare = () => {
setIsShareDialogOpen(true);
};
const isCurrentTrack = currentTrack?.id === track?.id;
const isCurrentlyPlaying = isCurrentTrack && isPlaying;
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner />
<span className="ml-2 text-kodo-secondary">Chargement du track...</span>
</div>
</div>
);
}
if (error || !track) {
return (
<div className="container mx-auto px-4 py-8">
<Card variant="default">
<div className="text-center p-6">
<h2 className="text-2xl font-bold text-kodo-red mb-2">Error</h2>
<p className="text-kodo-secondary">
{error || 'Track introuvable'}
</p>
<Button
onClick={() => navigate(-1)}
className="mt-4"
variant="ghost"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</Button>
</div>
</Card>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<Button
onClick={() => navigate(-1)}
variant="ghost"
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</Button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Cover Art */}
<div className="md:col-span-1">
<Card variant="gaming">
{track.cover_art_path ? (
<img
src={track.cover_art_path}
alt={track.title}
className="w-full aspect-square object-cover rounded-lg"
/>
) : (
<div className="w-full aspect-square bg-kodo-slate rounded-lg flex items-center justify-center">
<Music className="h-24 w-24 text-kodo-cyan" />
</div>
)}
</Card>
</div>
{/* Track Details */}
<div className="md:col-span-2 space-y-6">
<Card variant="default">
<h1 className="text-3xl font-display font-bold text-white mb-2">{track.title}</h1>
<p className="text-xl text-kodo-secondary mb-4">{track.artist}</p>
{track.album && (
<p className="text-lg text-kodo-secondary mb-4">Album: {track.album}</p>
)}
<div className="flex gap-2 mb-6">
{isCurrentlyPlaying ? (
<Button onClick={handlePause} variant="primary" size="lg">
<Pause className="h-5 w-5 mr-2" />
Pause
</Button>
) : (
<Button onClick={handlePlay} variant="gaming" size="lg">
<Play className="h-5 w-5 mr-2" />
Play
</Button>
)}
<Button
onClick={handleAddToQueue}
variant="secondary"
size="lg"
title="Ajouter à la file d'attente"
>
<Plus className="h-5 w-5 mr-2" />
Queue
</Button>
<Button
onClick={handleShare}
variant="ghost"
size="lg"
title="Partager"
>
<Share2 className="h-5 w-5 mr-2" />
Partager
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-kodo-secondary">Durée</p>
<p className="font-semibold text-white">{formatDuration(track.duration)}</p>
</div>
{track.genre && (
<div>
<p className="text-kodo-secondary">Genre</p>
<p className="font-semibold text-white">{track.genre}</p>
</div>
)}
{track.year && (
<div>
<p className="text-kodo-secondary">Année</p>
<p className="font-semibold text-white">{track.year}</p>
</div>
)}
<div>
<p className="text-kodo-secondary">Lectures</p>
<p className="font-semibold text-kodo-cyan">{track.play_count}</p>
</div>
<div>
<p className="text-kodo-secondary">Likes</p>
<p className="font-semibold text-kodo-magenta">{track.like_count}</p>
</div>
</div>
</Card>
{/* FE-PAGE-007: Analytics Section */}
<Card variant="gaming">
<div className="flex items-center gap-2 mb-4">
<BarChart3 className="h-5 w-5 text-kodo-gold" />
<h2 className="text-xl font-heading font-bold text-white">Analytics</h2>
</div>
<TrackStatsDisplay
trackId={parseInt(track.id, 10) || 0}
variant="horizontal"
showLabels={true}
/>
</Card>
{/* FE-PAGE-007: Tabs for Comments, History, etc. */}
<Tabs defaultValue="comments" className="w-full">
<TabsList>
<TabsTrigger value="comments">
<MessageCircle className="mr-2 h-4 w-4" />
Comments
</TabsTrigger>
<TabsTrigger value="history">
<History className="mr-2 h-4 w-4" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="comments" className="mt-4">
<CommentSection trackId={track.id} />
</TabsContent>
<TabsContent value="history" className="mt-4">
<Card variant="default">
<h3 className="text-lg font-heading font-bold text-white mb-4">Version History</h3>
<TrackHistory trackId={track.id} limit={20} />
</Card>
</TabsContent>
</Tabs>
{/* Waveform */}
{track.waveform_path && (
<Card variant="glass">
<h2 className="text-xl font-heading font-bold text-white mb-4">Waveform</h2>
<img
src={track.waveform_path}
alt="Waveform"
className="w-full h-32 object-contain"
/>
</Card>
)}
</div>
</div>
{/* FE-PAGE-007: Share Dialog */}
{track && (
<ShareDialog
open={isShareDialogOpen}
onClose={() => setIsShareDialogOpen(false)}
trackId={track.id}
trackTitle={track.title}
/>
)}
</div>
);
}