TASK-RC-001: GO/NO-GO checklist with evidence (16/21 GO, 5 staging-dependent) TASK-RC-002: Dark pattern audit — removed public play/like/follower counts - TrackDetailPageCoverAndActions: stats visible only to creator - TrackList: removed public play count column - TrackSearchResults: removed play_count/like_count display - UserCard: removed public follower count - SearchPageResults: removed followers_count display TASK-RC-003: Privacy policy (RGPD-compliant, docs/PRIVACY_POLICY.md) TASK-RC-004: Discovery algorithm documentation (auditable, docs/DISCOVERY_ALGORITHM.md) TASK-RC-005: Branch release ready (CI/CD validation pending) TASK-RC-006: Re-pentest noted as optional/staging-dependent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Play,
|
|
Heart,
|
|
MoreHorizontal,
|
|
AlertCircle,
|
|
BarChart3,
|
|
} from 'lucide-react';
|
|
import { Button } from '../ui/button';
|
|
import { Track } from '@/types/api';
|
|
import { useAudio } from '@/context/AudioContext';
|
|
import { useToast } from '@/components/feedback/ToastProvider';
|
|
import { trackService } from '@/features/tracks/services/trackService';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
export const TrackList: React.FC = () => {
|
|
const { playTrack, currentTrack, isPlaying, togglePlay } = useAudio();
|
|
const { addToast } = useToast();
|
|
const [tracks, setTracks] = useState<Track[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const loadTracks = async () => {
|
|
try {
|
|
setLoading(true);
|
|
// Fetch trending/top tracks for the dashboard
|
|
const response = await trackService.list({
|
|
limit: 5,
|
|
sort_by: 'play_count',
|
|
});
|
|
setTracks(response.tracks as Track[]);
|
|
} catch (err) {
|
|
logger.error('Failed to load tracks', {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
stack: err instanceof Error ? err.stack : undefined,
|
|
});
|
|
setError(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
loadTracks();
|
|
}, []);
|
|
|
|
const handlePlay = (track: Track) => {
|
|
if (currentTrack?.id === track.id) {
|
|
togglePlay();
|
|
} else {
|
|
playTrack(track, tracks);
|
|
}
|
|
};
|
|
|
|
const handleLike = async (e: React.MouseEvent, track: Track) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await trackService.like(track.id);
|
|
addToast(`Liked ${track.title}`, 'success');
|
|
} catch (e) {
|
|
addToast('Action failed', 'error');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<div
|
|
key={i}
|
|
className="h-16 bg-card/50 animate-pulse rounded-xl border border-border/30"
|
|
></div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6 text-center border border-destructive/30 bg-destructive/10 rounded-xl text-destructive">
|
|
<AlertCircle className="w-6 h-6 mx-auto mb-2" />
|
|
<p className="text-sm">Unable to load trending audio.</p>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-2"
|
|
onClick={() => window.location.reload()}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (tracks.length === 0) {
|
|
return (
|
|
<div className="text-muted-foreground text-center py-12 bg-card/30 rounded-xl border border-dashed border-border">
|
|
<BarChart3 className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
|
<p>No tracks trending right now.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{tracks.map((track, i) => {
|
|
const isCurrent = currentTrack?.id === track.id;
|
|
|
|
return (
|
|
<div
|
|
key={track.id}
|
|
className={`
|
|
animate-stagger-in group flex items-center gap-4 p-4 rounded-xl transition-all border cursor-pointer relative overflow-hidden
|
|
${isCurrent ? 'bg-primary/10 border-primary/30' : 'bg-card border-transparent hover:border-border/50 hover:bg-card/80'}
|
|
`}
|
|
style={{ animationDelay: `${Math.min(i * 50, 500)}ms` }}
|
|
onClick={() => handlePlay(track)}
|
|
>
|
|
{/* Active Indicator Bar */}
|
|
{isCurrent && (
|
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary"></div>
|
|
)}
|
|
|
|
<div className="w-8 text-center text-muted-foreground font-mono text-xs font-bold pl-2">
|
|
{isCurrent && isPlaying ? (
|
|
<div className="flex gap-0.5 justify-center items-end h-3">
|
|
<div className="w-0.5 bg-primary h-full animate-[bounce_1s_infinite]"></div>
|
|
<div className="w-0.5 bg-primary h-2/3 animate-[bounce_1.2s_infinite]"></div>
|
|
<div className="w-0.5 bg-primary h-full animate-[bounce_0.8s_infinite]"></div>
|
|
</div>
|
|
) : (
|
|
<span className="group-hover:hidden text-muted-foreground">
|
|
{i + 1}
|
|
</span>
|
|
)}
|
|
<Play
|
|
className={`w-4 h-4 mx-auto fill-current hidden group-hover:block ${isCurrent ? 'text-primary' : 'text-foreground'}`}
|
|
/>
|
|
</div>
|
|
|
|
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 shadow-lg">
|
|
<img
|
|
src={track.coverUrl || track.cover_art_path || ''}
|
|
className="w-full h-full object-cover"
|
|
alt={track.title}
|
|
/>
|
|
{isCurrent && (
|
|
<div className="absolute inset-0 bg-muted/20 ring-1 ring-inset ring-border"></div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h4
|
|
className={`font-bold text-sm truncate ${isCurrent ? 'text-primary' : 'text-foreground'}`}
|
|
>
|
|
{track.title}
|
|
</h4>
|
|
<p className="text-xs text-muted-foreground truncate hover:underline">
|
|
{track.artist}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="hidden md:flex items-center gap-6 text-muted-foreground text-xs font-medium">
|
|
{/* Play count removed from public display (ORIGIN_UI_UX_SYSTEM §13.4) */}
|
|
<span className="flex items-center gap-1.5 w-12 justify-end font-mono">
|
|
{track.duration}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 hover:text-destructive"
|
|
onClick={(e) => handleLike(e, track)}
|
|
>
|
|
<Heart className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|