refactor(tracks): split TrackDetailPage into module with Hero, CoverAndActions, Info, Tabs, Skeleton, NotFound
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
793ad47e27
commit
06c963be46
13 changed files with 544 additions and 255 deletions
|
|
@ -1,27 +1,53 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { TrackDetailPage } from './TrackDetailPage';
|
||||
import {
|
||||
TrackDetailPage,
|
||||
TrackDetailPageSkeleton,
|
||||
TrackDetailPageNotFound,
|
||||
} from './TrackDetailPage';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const meta: Meta<typeof TrackDetailPage> = {
|
||||
title: 'App/Pages/Tracks/TrackDetailPage',
|
||||
component: TrackDetailPage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<MemoryRouter initialEntries={['/tracks/demo-track']}>
|
||||
<Routes>
|
||||
<Route path="/tracks/:id" element={<div className="bg-kodo-background min-h-screen"><Story /></div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
title: 'App/Pages/Tracks/TrackDetailPage',
|
||||
component: TrackDetailPage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<MemoryRouter initialEntries={['/tracks/demo-track']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tracks/:id"
|
||||
element={
|
||||
<div className="bg-kodo-background min-h-layout-page">
|
||||
<Story />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { name: 'Par défaut' };
|
||||
export const Loading: Story = { name: 'Chargement' };
|
||||
export const NotFound: Story = { name: 'Non trouvé' };
|
||||
|
||||
export const Loading: Story = {
|
||||
name: 'Chargement',
|
||||
render: () => <TrackDetailPageSkeleton />,
|
||||
decorators: [],
|
||||
};
|
||||
|
||||
export const NotFound: Story = {
|
||||
name: 'Non trouvé',
|
||||
render: () => (
|
||||
<TrackDetailPageNotFound
|
||||
error={new Error('Track not found')}
|
||||
onRetry={() => {}}
|
||||
/>
|
||||
),
|
||||
decorators: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,238 +1,9 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* TrackDetailPage — re-export from feature module.
|
||||
*/
|
||||
export {
|
||||
TrackDetailPage,
|
||||
TrackDetailPageSkeleton,
|
||||
TrackDetailPageNotFound,
|
||||
} from './track-detail-page';
|
||||
export type { TrackDetailPageProps } from './track-detail-page';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TrackDetailPageHero } from './TrackDetailPageHero';
|
||||
import { TrackDetailPageCoverAndActions } from './TrackDetailPageCoverAndActions';
|
||||
import { TrackDetailPageInfo } from './TrackDetailPageInfo';
|
||||
import { TrackDetailPageTabs } from './TrackDetailPageTabs';
|
||||
import { TrackDetailPageNotFound } from './TrackDetailPageNotFound';
|
||||
import { TrackDetailPageSkeleton } from './TrackDetailPageSkeleton';
|
||||
import { useTrackDetailPage } from './useTrackDetailPage';
|
||||
import type { TrackDetailPageProps } from './types';
|
||||
|
||||
export function TrackDetailPage(props?: TrackDetailPageProps) {
|
||||
const trackIdOverride = props?.trackId;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
track,
|
||||
isLoading,
|
||||
error,
|
||||
loadTrack,
|
||||
isShareDialogOpen,
|
||||
setIsShareDialogOpen,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleAddToQueue,
|
||||
handleShare,
|
||||
isCurrentlyPlaying,
|
||||
} = useTrackDetailPage(trackIdOverride);
|
||||
|
||||
if (isLoading) {
|
||||
return <TrackDetailPageSkeleton />;
|
||||
}
|
||||
|
||||
if (error || !track) {
|
||||
return (
|
||||
<TrackDetailPageNotFound
|
||||
error={error}
|
||||
onRetry={loadTrack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-layout-page pb-24 relative overflow-hidden bg-background">
|
||||
<TrackDetailPageHero track={track} />
|
||||
|
||||
<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">
|
||||
<TrackDetailPageCoverAndActions
|
||||
track={track}
|
||||
isCurrentlyPlaying={isCurrentlyPlaying}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onAddToQueue={handleAddToQueue}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
<TrackDetailPageInfo track={track} />
|
||||
<TrackDetailPageTabs
|
||||
track={track}
|
||||
isShareDialogOpen={isShareDialogOpen}
|
||||
onShareDialogClose={() => setIsShareDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { Music, Play, Pause, Plus, Share2, Heart } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import type { Track } from '../../types/track';
|
||||
|
||||
interface TrackDetailPageCoverAndActionsProps {
|
||||
track: Track;
|
||||
isCurrentlyPlaying: boolean;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onAddToQueue: () => void;
|
||||
onShare: () => void;
|
||||
}
|
||||
|
||||
export function TrackDetailPageCoverAndActions({
|
||||
track,
|
||||
isCurrentlyPlaying,
|
||||
onPlay,
|
||||
onPause,
|
||||
onAddToQueue,
|
||||
onShare,
|
||||
}: TrackDetailPageCoverAndActionsProps) {
|
||||
const coverUrl = (track as { cover_art_path?: string }).cover_art_path;
|
||||
const likeCount = (track as { like_count?: number }).like_count ?? 0;
|
||||
const playCount = (track as { play_count?: number }).play_count ?? 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{coverUrl ? (
|
||||
<img
|
||||
src={coverUrl}
|
||||
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>
|
||||
)}
|
||||
<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={onPause}
|
||||
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={onPlay}
|
||||
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={onAddToQueue} variant="secondary" className="h-12 w-12 p-0 rounded-xl" title="Add to Queue">
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button onClick={onShare} variant="secondary" className="h-12 w-12 p-0 rounded-xl" title="Share">
|
||||
<Share2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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">{likeCount}</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">{playCount}</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">Plays</span>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { Track } from '../../types/track';
|
||||
|
||||
interface TrackDetailPageHeroProps {
|
||||
track: Track;
|
||||
}
|
||||
|
||||
export function TrackDetailPageHero({ track }: TrackDetailPageHeroProps) {
|
||||
const coverUrl = (track as { cover_art_path?: string }).cover_art_path;
|
||||
return (
|
||||
<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: coverUrl ? `url(${coverUrl}) center/cover` : 'var(--primary)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background/40 via-background/80 to-background" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { Clock } from 'lucide-react';
|
||||
import { formatDuration } from './utils';
|
||||
import type { Track } from '../../types/track';
|
||||
|
||||
interface TrackDetailPageInfoProps {
|
||||
track: Track;
|
||||
}
|
||||
|
||||
export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
|
||||
const waveformPath = (track as { waveform_path?: string }).waveform_path;
|
||||
const album = (track as { album?: string }).album;
|
||||
const year = (track as { year?: number }).year;
|
||||
const genre = (track as { genre?: string }).genre;
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
<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>
|
||||
{album && <span>• {album}</span>}
|
||||
{year != null && <span>• {year}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-6">
|
||||
{genre && (
|
||||
<span className="px-3 py-1 rounded-full bg-white/5 border border-white/10 text-sm">
|
||||
{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>
|
||||
|
||||
{waveformPath && (
|
||||
<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={waveformPath}
|
||||
alt="Waveform"
|
||||
className="w-full h-full object-cover opacity-60 group-hover:opacity-80 transition-opacity mix-blend-screen"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||
|
||||
interface TrackDetailPageNotFoundProps {
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function TrackDetailPageNotFound({ error, onRetry }: TrackDetailPageNotFoundProps) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 flex flex-col items-center justify-center min-h-layout-page">
|
||||
<ErrorDisplay
|
||||
error={error || new Error('Track not found')}
|
||||
variant="card"
|
||||
severity="error"
|
||||
onRetry={onRetry}
|
||||
actions={[
|
||||
{
|
||||
label: 'Go Back',
|
||||
onClick: () => navigate(-1),
|
||||
variant: 'outline',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Card } from '@/components/ui/card';
|
||||
|
||||
export function TrackDetailPageSkeleton() {
|
||||
return (
|
||||
<div className="min-h-layout-page pb-24 relative overflow-hidden bg-background">
|
||||
<div className="absolute inset-0 h-[60vh] bg-muted/30 pointer-events-none" />
|
||||
<div className="container mx-auto px-4 relative z-10 pt-8">
|
||||
<div className="h-10 w-24 bg-muted animate-pulse rounded-full mb-8" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<div className="aspect-square rounded-3xl bg-muted animate-pulse" />
|
||||
<Card variant="glass" className="p-4 border-white/5 bg-black/30 backdrop-blur-xl">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 h-12 bg-muted animate-pulse rounded-lg" />
|
||||
<div className="h-12 w-12 bg-muted animate-pulse rounded-xl" />
|
||||
<div className="h-12 w-12 bg-muted animate-pulse rounded-xl" />
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card variant="glass" className="p-4 bg-black/20">
|
||||
<div className="h-5 w-8 bg-muted animate-pulse rounded mx-auto mb-2" />
|
||||
<div className="h-4 w-12 bg-muted animate-pulse rounded mx-auto" />
|
||||
</Card>
|
||||
<Card variant="glass" className="p-4 bg-black/20">
|
||||
<div className="h-5 w-8 bg-muted animate-pulse rounded mx-auto mb-2" />
|
||||
<div className="h-4 w-12 bg-muted animate-pulse rounded mx-auto" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
<div>
|
||||
<div className="h-12 w-3/4 max-w-xl bg-muted animate-pulse rounded mb-4" />
|
||||
<div className="h-6 w-48 bg-muted animate-pulse rounded mb-6" />
|
||||
<div className="flex gap-3">
|
||||
<div className="h-8 w-20 bg-muted animate-pulse rounded-full" />
|
||||
<div className="h-8 w-24 bg-muted animate-pulse rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-24 w-full bg-muted animate-pulse rounded-xl" />
|
||||
<div className="space-y-4">
|
||||
<div className="h-10 w-full max-w-md bg-muted animate-pulse rounded" />
|
||||
<div className="h-64 w-full bg-muted animate-pulse rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { BarChart3 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { CommentSection } from '../../components/CommentSection';
|
||||
import { ShareDialog } from '../../components/ShareDialog';
|
||||
import { TrackHistory } from '../../components/TrackHistory';
|
||||
import { TrackStatsDisplay } from '../../components/TrackStatsDisplay';
|
||||
import type { Track } from '../../types/track';
|
||||
|
||||
interface TrackDetailPageTabsProps {
|
||||
track: Track;
|
||||
isShareDialogOpen: boolean;
|
||||
onShareDialogClose: () => void;
|
||||
}
|
||||
|
||||
export function TrackDetailPageTabs({
|
||||
track,
|
||||
isShareDialogOpen,
|
||||
onShareDialogClose,
|
||||
}: TrackDetailPageTabsProps) {
|
||||
const trackIdNum = parseInt(track.id, 10) || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={trackIdNum} 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>
|
||||
|
||||
<ShareDialog
|
||||
open={isShareDialogOpen}
|
||||
onClose={onShareDialogClose}
|
||||
trackId={track.id}
|
||||
trackTitle={track.title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { TrackDetailPage } from './TrackDetailPage';
|
||||
export { TrackDetailPageSkeleton } from './TrackDetailPageSkeleton';
|
||||
export { TrackDetailPageNotFound } from './TrackDetailPageNotFound';
|
||||
export { useTrackDetailPage } from './useTrackDetailPage';
|
||||
export { formatDuration } from './utils';
|
||||
export type { TrackDetailPageProps } from './types';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export interface TrackDetailPageProps {
|
||||
/** When set, overrides useParams id (e.g. for stories). */
|
||||
trackId?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } 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 type { Track } from '../../types/track';
|
||||
|
||||
export function useTrackDetailPage(trackIdOverride?: string) {
|
||||
const { id: paramId } = useParams<{ id: string }>();
|
||||
const id = trackIdOverride ?? paramId ?? '';
|
||||
|
||||
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 = useCallback(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);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTrack();
|
||||
}, [loadTrack]);
|
||||
|
||||
const mapToPlayerTrack = (t: Track): PlayerTrack => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
duration: t.duration,
|
||||
url: (t as { stream_manifest_url?: string; file_path?: string }).stream_manifest_url || t.file_path,
|
||||
cover: (t as { cover_art_path?: string }).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;
|
||||
|
||||
return {
|
||||
id,
|
||||
track,
|
||||
isLoading,
|
||||
error,
|
||||
loadTrack,
|
||||
isShareDialogOpen,
|
||||
setIsShareDialogOpen,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleAddToQueue,
|
||||
handleShare,
|
||||
isCurrentTrack,
|
||||
isCurrentlyPlaying,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
Loading…
Reference in a new issue