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

238 lines
12 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';
import toast from '@/utils/toast';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
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, BarChart3, Music, Heart, Clock
} 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';
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) play(mapToPlayerTrack(track)); };
const handlePause = () => { pause(); };
const handleAddToQueue = () => { if (track) { addToQueue([mapToPlayerTrack(track)]); toast.success("Added to queue"); } };
const handleShare = () => { setIsShareDialogOpen(true); };
const isCurrentTrack = currentTrack?.id === track?.id;
const isCurrentlyPlaying = isCurrentTrack && isPlaying;
if (isLoading) {
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
}
if (error || !track) {
return (
<div className="container mx-auto px-4 py-8 flex items-center justify-center min-h-screen">
<ErrorDisplay
error={error || new Error('Track not found')}
variant="card"
severity="error"
onRetry={loadTrack}
actions={[{ label: 'Go Back', onClick: () => navigate(-1), variant: 'outline' }]}
/>
</div>
);
}
return (
<div className="min-h-screen pb-24 relative overflow-hidden bg-background">
{/* 1. Immersive Blur Background */}
<div className="absolute inset-0 h-[60vh] overflow-hidden pointer-events-none">
<div className="absolute inset-x-0 -top-40 h-[150%] opacity-20 blur-[120px] scale-110"
style={{ background: track.cover_art_path ? `url(${track.cover_art_path}) center/cover` : 'var(--primary)' }}
/>
<div className="absolute inset-0 bg-gradient-to-b from-background/40 via-background/80 to-background" />
</div>
<div className="container mx-auto px-4 relative z-10 pt-8">
<Button onClick={() => navigate(-1)} variant="ghost" className="mb-6 rounded-full hover:bg-white/10">
<ArrowLeft className="h-4 w-4 mr-2" /> Back
</Button>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
{/* Left Column: Cover & Main Actions */}
<div className="lg:col-span-4 sticky top-24 space-y-8">
<div className="relative aspect-square rounded-3xl overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 group">
{track.cover_art_path ? (
<img src={track.cover_art_path} alt={track.title} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
) : (
<div className="w-full h-full bg-gradient-to-br from-gray-800 to-black flex items-center justify-center">
<Music className="h-24 w-24 text-white/20" />
</div>
)}
{/* Vinyl Shine Effect */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
</div>
<Card variant="glass" className="p-4 border-white/5 bg-black/30 backdrop-blur-xl">
<div className="flex gap-3">
{isCurrentlyPlaying ? (
<Button onClick={handlePause} className="flex-1 h-12 bg-primary text-primary-foreground font-bold shadow-glow-cyan hover:scale-[1.02] transition-transform">
<Pause className="h-5 w-5 mr-2 fill-current" /> Pause
</Button>
) : (
<Button onClick={handlePlay} className="flex-1 h-12 bg-primary text-primary-foreground font-bold shadow-glow-cyan hover:scale-[1.02] transition-transform">
<Play className="h-5 w-5 mr-2 fill-current" /> Play
</Button>
)}
<Button onClick={handleAddToQueue} variant="secondary" className="h-12 w-12 p-0 rounded-xl" title="Add to Queue">
<Plus className="h-5 w-5" />
</Button>
<Button onClick={handleShare} variant="secondary" className="h-12 w-12 p-0 rounded-xl" title="Share">
<Share2 className="h-5 w-5" />
</Button>
</div>
</Card>
{/* Quick Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<Card variant="glass" className="p-4 flex flex-col items-center justify-center bg-black/20 text-center hover:bg-white/5 transition-colors">
<Heart className="w-5 h-5 text-magenta-500 mb-1" />
<span className="text-xl font-bold text-white">{track.like_count}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider">Likes</span>
</Card>
<Card variant="glass" className="p-4 flex flex-col items-center justify-center bg-black/20 text-center hover:bg-white/5 transition-colors">
<Play className="w-5 h-5 text-cyan-500 mb-1" />
<span className="text-xl font-bold text-white">{track.play_count}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider">Plays</span>
</Card>
</div>
</div>
{/* Right Column: Details & Content */}
<div className="lg:col-span-8 space-y-8">
{/* Header Info */}
<div>
<h1 className="text-4xl md:text-6xl font-display font-bold text-white mb-2 leading-tight">{track.title}</h1>
<div className="flex flex-wrap items-center gap-4 text-lg md:text-xl text-muted-foreground">
<span className="text-primary font-medium">{track.artist}</span>
{track.album && <span> {track.album}</span>}
{track.year && <span> {track.year}</span>}
</div>
<div className="flex flex-wrap gap-3 mt-6">
{track.genre && (
<span className="px-3 py-1 rounded-full bg-white/5 border border-white/10 text-sm">{track.genre}</span>
)}
<span className="px-3 py-1 rounded-full bg-white/5 border border-white/10 text-sm flex items-center gap-1">
<Clock className="w-3 h-3" /> {formatDuration(track.duration)}
</span>
</div>
</div>
{/* Waveform */}
{track.waveform_path && (
<div className="relative h-24 w-full bg-black/20 rounded-xl border border-white/5 overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/10 to-magenta-500/10 opacity-50" />
<img src={track.waveform_path} alt="Waveform" className="w-full h-full object-cover opacity-60 group-hover:opacity-80 transition-opacity mix-blend-screen" />
</div>
)}
{/* Tabs & Content */}
<Tabs defaultValue="comments" className="w-full">
<TabsList className="bg-transparent border-b border-white/10 w-full justify-start h-auto p-0 gap-8 mb-6 rounded-none">
<TabsTrigger value="comments" className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-3 px-0 text-lg font-display bg-transparent">
Discussion <span className="ml-2 text-xs bg-white/10 px-2 py-0.5 rounded-full text-muted-foreground align-middle">24</span>
</TabsTrigger>
<TabsTrigger value="analytics" className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-3 px-0 text-lg font-display bg-transparent">
Analytics
</TabsTrigger>
<TabsTrigger value="history" className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-3 px-0 text-lg font-display bg-transparent">
History
</TabsTrigger>
</TabsList>
<TabsContent value="comments" className="animate-fade-in">
<CommentSection trackId={track.id} />
</TabsContent>
<TabsContent value="analytics" className="animate-fade-in">
<Card variant="glass" className="p-6">
<div className="flex items-center gap-3 mb-6">
<BarChart3 className="w-5 h-5 text-primary" />
<h3 className="text-xl font-bold">Performance Data</h3>
</div>
<TrackStatsDisplay trackId={parseInt(track.id, 10) || 0} variant="horizontal" showLabels={true} />
</Card>
</TabsContent>
<TabsContent value="history" className="animate-fade-in">
<Card variant="glass" className="p-6">
<h3 className="text-xl font-bold mb-6">Version History</h3>
<TrackHistory trackId={track.id} limit={20} />
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
{track && <ShareDialog open={isShareDialogOpen} onClose={() => setIsShareDialogOpen(false)} trackId={track.id} trackTitle={track.title} />}
</div>
);
}