302 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|