veza/apps/web/src/components/dashboard/TrackList.tsx
senke d168bfd9e4 feat(v1.0.0-rc1): release candidate — GO/NO-GO audit, dark pattern fix, docs
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>
2026-03-13 16:23:18 +01:00

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