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:
senke 2026-02-07 06:57:32 +01:00
parent 793ad47e27
commit 06c963be46
13 changed files with 544 additions and 255 deletions

View file

@ -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: [],
};

View file

@ -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';

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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';

View file

@ -0,0 +1,4 @@
export interface TrackDetailPageProps {
/** When set, overrides useParams id (e.g. for stories). */
trackId?: string;
}

View file

@ -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,
};
}

View file

@ -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')}`;
}