veza/apps/web/src/features/tracks/pages/TrackDetailPage.tsx
senke 3250cc7f70 aesthetic-improvements: reduce decorative cyan in track detail and library (80/20 rule, batch 7)
- TrackDetailPage: decorative empty state icon (text-kodo-cyan → text-kodo-steel) and informational play count text (text-kodo-cyan → text-kodo-steel)
- LibraryPage: decorative genre badges (bg-kodo-cyan/10 text-kodo-cyan → bg-kodo-steel/10 text-kodo-steel, 2 instances: grid view and list view)
- Total: ~2 files, ~4 instances replaced
- Preserved: Active/selected states (LibraryPage view mode selection, selected tracks, TrackList selected tracks, TrackListRow selected state, QualitySelector selected quality, PlaybackSpeedControl selected speed, PlaylistBatchActions batch mode banner, ChatSidebar selected conversation, TrackFilters active filters badge, PlaylistList selected view mode, TrackGridDensitySelector selected density, ViewToggle selected view mode), semantic status indicators (TrackHistory updated action), functional loading indicators (PlayerLoading spinner), primary actions, design system variants
- Action 11.3.1.3 in progress (seventh batch: track detail and library genre badges)
2026-01-16 11:17:46 +01:00

324 lines
10 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getTrack } from '../services/trackService';
import { TrackServiceError as TrackUploadError } from '../errors/trackErrors';
import { usePlayerStore } from '@/features/player/store/playerStore';
import type { Track as PlayerTrack } from '@/features/player/types';
// CRITICAL FIX: Utiliser le wrapper lazy pour éviter les collisions de noms de variables
import toast from '@/utils/toast';
import { Button, Card } from '@veza/design-system';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
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<Error | null>(null);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const { play, pause, currentTrack, isPlaying, addToQueue } = usePlayerStore();
const loadTrack = async () => {
if (!id) {
setError(new Error('Track ID is required'));
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError(null);
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(new Error(errorMessage));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
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">
<ErrorDisplay
error={error || new Error('Track introuvable')}
variant="card"
severity="error"
context={{
action: 'fetching track',
resource: 'track',
resourceId: id,
}}
onRetry={loadTrack}
actions={[
{
label: 'Retour',
onClick: () => navigate(-1),
variant: 'outline',
},
]}
/>
</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-steel" />
</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-steel">
{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>
);
}