veza/apps/web/src/components/dashboard/TrackList.tsx
senke 7c69474cf9 aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 11:50:46 +01:00

191 lines
6.4 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 '@/context/ToastContext';
import { trackService } from '@/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);
} 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-kodo-ink/50 animate-pulse rounded-xl border border-kodo-steel/30"
></div>
))}
</div>
);
}
if (error) {
return (
<div className="p-6 text-center border border-kodo-red/30 bg-kodo-red/10 rounded-xl text-kodo-red">
<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-kodo-content-dim text-center py-12 bg-kodo-ink/30 rounded-xl border border-dashed border-kodo-steel">
<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={`
group flex items-center gap-4 p-4 rounded-xl transition-all border cursor-pointer relative overflow-hidden
${isCurrent ? 'bg-kodo-cyan/10 border-kodo-cyan/30' : 'bg-kodo-ink border-transparent hover:border-kodo-steel/50 hover:bg-kodo-ink/80'}
`}
onClick={() => handlePlay(track)}
>
{/* Active Indicator Bar */}
{isCurrent && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-kodo-cyan"></div>
)}
<div className="w-8 text-center text-kodo-content-dim 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-kodo-cyan h-full animate-[bounce_1s_infinite]"></div>
<div className="w-0.5 bg-kodo-cyan h-2/3 animate-[bounce_1.2s_infinite]"></div>
<div className="w-0.5 bg-kodo-cyan h-full animate-[bounce_0.8s_infinite]"></div>
</div>
) : (
<span className="group-hover:hidden text-kodo-content-dim">
{i + 1}
</span>
)}
<Play
className={`w-4 h-4 mx-auto fill-current hidden group-hover:block ${isCurrent ? 'text-kodo-cyan' : 'text-white'}`}
/>
</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-kodo-steel/20 ring-1 ring-inset ring-kodo-steel"></div>
)}
</div>
<div className="flex-1 min-w-0">
<h4
className={`font-bold text-sm truncate ${isCurrent ? 'text-kodo-cyan' : 'text-white'}`}
>
{track.title}
</h4>
<p className="text-xs text-kodo-content-dim truncate hover:underline">
{track.artist}
</p>
</div>
<div className="hidden md:flex items-center gap-6 text-kodo-content-dim text-xs font-medium">
<span className="flex items-center gap-1.5 w-16 justify-end">
<Play className="w-3 h-3" />{' '}
{(track.plays || track.play_count) > 1000
? `${((track.plays || track.play_count) / 1000).toFixed(1)}k`
: track.plays || track.play_count}
</span>
<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-kodo-magenta"
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>
);
};