325046eaf2
Phase 4: i18n all 15 components from PR #19. Sidebar gains collapse/expand toggle. Player gets playback speed selector (0.25x-2.0x) fixing bug B1 (hardcoded Chinese '正常'). SongList adds inline title editing and model version badge. Delete actions now use ConfirmDialog instead of window.confirm. Volume persisted to localStorage. Training nav item deferred to Phase 7.
295 lines
15 KiB
TypeScript
295 lines
15 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Song, Playlist, playlistsApi, songsApi, getAudioUrl } from '../services/api';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useI18n } from '../context/I18nContext';
|
|
import { ArrowLeft, Play, MoreHorizontal, Clock, Calendar, Shuffle, Trash2, Mic2, Music } from 'lucide-react';
|
|
|
|
interface PlaylistDetailProps {
|
|
playlistId: string;
|
|
onBack: () => void;
|
|
onPlaySong: (song: Song, list?: Song[]) => void;
|
|
onSelect: (song: Song) => void;
|
|
onNavigateToProfile: (username: string) => void;
|
|
}
|
|
|
|
export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBack, onPlaySong, onSelect, onNavigateToProfile }) => {
|
|
const { user: currentUser, token } = useAuth();
|
|
const { t } = useI18n();
|
|
const [playlist, setPlaylist] = useState<Playlist & { creator_avatar?: string } | null>(null);
|
|
const [songs, setSongs] = useState<Song[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
loadPlaylist();
|
|
}, [playlistId]);
|
|
|
|
const loadPlaylist = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await playlistsApi.getPlaylist(playlistId, token);
|
|
// res.playlist comes from DB row, which now includes creator_avatar
|
|
setPlaylist(res.playlist as any);
|
|
|
|
const mappedSongs: Song[] = res.songs.map((s: any) => ({
|
|
id: s.id,
|
|
title: s.title,
|
|
lyrics: s.lyrics,
|
|
style: s.style,
|
|
coverUrl: s.cover_url || s.coverUrl || `https://picsum.photos/seed/${s.id}/400/400`,
|
|
audioUrl: getAudioUrl(s.audio_url || s.audioUrl, s.id),
|
|
duration: s.duration,
|
|
bpm: s.bpm,
|
|
tags: s.tags || [],
|
|
isPublic: s.is_public || false,
|
|
likeCount: s.like_count || 0,
|
|
viewCount: s.view_count || 0,
|
|
creator: s.creator,
|
|
created_at: s.created_at,
|
|
addedAt: s.added_at
|
|
}));
|
|
|
|
setSongs(mappedSongs);
|
|
} catch (error) {
|
|
console.error('Failed to load playlist:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// ... (retaining methods handleRemove, handleDelete) ...
|
|
const handleRemoveSong = async (songId: string) => {
|
|
if (!token || !playlist) return;
|
|
try {
|
|
await playlistsApi.removeSong(playlist.id, songId, token);
|
|
setSongs(prev => prev.filter(s => s.id !== songId));
|
|
} catch (error) {
|
|
console.error('Failed to remove song:', error);
|
|
}
|
|
};
|
|
|
|
const handleDeletePlaylist = async () => {
|
|
if (!token || !playlist) return;
|
|
if (!confirm(t('deletePlaylistConfirm'))) return;
|
|
try {
|
|
await playlistsApi.delete(playlist.id, token);
|
|
onBack();
|
|
} catch (error) {
|
|
console.error('Failed to delete playlist:', error);
|
|
}
|
|
};
|
|
|
|
if (loading) return (
|
|
<div className="flex items-center justify-center h-full bg-black">
|
|
<div className="text-zinc-400 gap-2 flex items-center">
|
|
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
|
|
{t('loadingPlaylist')}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (!playlist) return (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 bg-black">
|
|
<div className="text-zinc-400">{t('playlistNotFound')}</div>
|
|
<button onClick={onBack} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-white">
|
|
{t('goBack')}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
const isOwner = currentUser?.id === playlist.user_id;
|
|
|
|
// Gradient based on ID/Name
|
|
const gradients = [
|
|
'from-purple-900 to-black',
|
|
'from-blue-900 to-black',
|
|
'from-indigo-900 to-black',
|
|
'from-rose-900 to-black',
|
|
];
|
|
const bgGradient = gradients[playlist.name.length % gradients.length];
|
|
|
|
return (
|
|
<div className={`w-full h-full flex flex-col bg-gradient-to-b ${bgGradient} overflow-hidden`}>
|
|
{/* Header */}
|
|
<div className="flex-shrink-0 p-4 md:p-8 pt-12 md:pt-8 flex flex-col md:flex-row gap-4 md:gap-8 items-center md:items-end bg-black/20 backdrop-blur-lg border-b border-white/10">
|
|
{/* Cover */}
|
|
<div className="w-32 h-32 md:w-52 md:h-52 shadow-2xl rounded-lg bg-zinc-800 flex items-center justify-center overflow-hidden flex-shrink-0 group relative">
|
|
{playlist.cover_url ? (
|
|
<img src={playlist.cover_url} alt={playlist.name} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center">
|
|
<Music size={40} className="text-white/20 md:hidden" />
|
|
<Music size={64} className="text-white/20 hidden md:block" />
|
|
<span className="text-4xl md:text-6xl font-bold text-white/10">{playlist.name[0].toUpperCase()}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 space-y-2 md:space-y-4 text-center md:text-left">
|
|
<span className="text-xs font-bold tracking-wider uppercase text-white/80">{t('playlist')}</span>
|
|
<h1 className="text-2xl md:text-5xl lg:text-7xl font-bold text-white tracking-tight leading-none drop-shadow-lg">
|
|
{playlist.name}
|
|
</h1>
|
|
{playlist.description && (
|
|
<p className="text-zinc-300 text-sm max-w-2xl hidden md:block">{playlist.description}</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-center md:justify-start gap-2 text-sm text-white font-medium flex-wrap">
|
|
{playlist.creator && (
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer hover:underline"
|
|
onClick={() => onNavigateToProfile(playlist.creator!)}
|
|
>
|
|
{playlist.creator_avatar ? (
|
|
<img src={playlist.creator_avatar} alt={playlist.creator} className="w-5 h-5 md:w-6 md:h-6 rounded-full object-cover" />
|
|
) : (
|
|
<div className="w-5 h-5 md:w-6 md:h-6 rounded-full bg-gradient-to-r from-green-400 to-blue-500"></div>
|
|
)}
|
|
<span>{playlist.creator}</span>
|
|
</div>
|
|
)}
|
|
<span className="w-1 h-1 rounded-full bg-white/50"></span>
|
|
<span>{songs.length} {t('songs')}</span>
|
|
<span className="w-1 h-1 rounded-full bg-white/50 hidden md:block"></span>
|
|
<span className="text-zinc-400 hidden md:block">
|
|
{songs.reduce((acc, s) => acc + (s.duration ? (typeof s.duration === 'string' ? 0 : s.duration) : 0), 0) > 0
|
|
? Math.floor(songs.reduce((acc, s) => acc + (s.duration as number || 0), 0) / 60) + " " + t('min')
|
|
: ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions Bar */}
|
|
<div className="px-4 md:px-8 py-3 md:py-4 bg-black/20 flex items-center gap-3 md:gap-4">
|
|
<button
|
|
onClick={() => songs.length > 0 && onPlaySong(songs[0], songs)}
|
|
className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-green-500 hover:scale-105 transition-transform flex items-center justify-center text-black shadow-lg"
|
|
>
|
|
<Play size={24} fill="currentColor" className="ml-1" />
|
|
</button>
|
|
|
|
{isOwner && (
|
|
<button
|
|
onClick={handleDeletePlaylist}
|
|
className="text-zinc-400 hover:text-red-500 transition-colors p-2"
|
|
title={t('deletePlaylist')}
|
|
>
|
|
<Trash2 size={20} />
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex-1"></div>
|
|
|
|
<div className="text-zinc-400 text-xs md:text-sm">
|
|
{playlist.is_public ? t('public') : t('private')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Song List */}
|
|
<div className="flex-1 overflow-y-auto bg-black/40">
|
|
<div className="px-2 md:px-8 py-2 md:py-4 pb-24 lg:pb-32">
|
|
{/* Desktop Header */}
|
|
<div className="hidden md:grid grid-cols-[16px_4fr_3fr_2fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-white/10 text-sm font-medium text-zinc-400 mb-2 sticky top-0 bg-[#121212] z-10">
|
|
<span>#</span>
|
|
<span>{t('title')}</span>
|
|
<span>{t('artist')}</span>
|
|
<span>{t('dateAdded')}</span>
|
|
<span className="text-right"><Clock size={16} className="inline" /></span>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
{songs.map((song, index) => (
|
|
<div
|
|
key={song.id}
|
|
className="group flex md:grid md:grid-cols-[16px_4fr_3fr_2fr_minmax(120px,1fr)] gap-3 md:gap-4 px-2 md:px-4 py-3 rounded-md hover:bg-white/10 items-center transition-colors text-sm text-zinc-400 hover:text-white cursor-pointer"
|
|
onClick={() => {
|
|
onSelect(song);
|
|
onPlaySong(song, songs);
|
|
}}
|
|
>
|
|
{/* Index - hidden on mobile */}
|
|
<span className="hidden md:block group-hover:text-white">{index + 1}</span>
|
|
|
|
{/* Cover + Title */}
|
|
<div className="flex items-center gap-3 overflow-hidden flex-1 md:flex-none">
|
|
<div className="w-12 h-12 md:w-10 md:h-10 rounded bg-zinc-800 flex-shrink-0 overflow-hidden relative group/img">
|
|
<img src={song.coverUrl} alt="" className="w-full h-full object-cover" />
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onPlaySong(song, songs);
|
|
}}
|
|
className="absolute inset-0 bg-black/50 flex md:hidden group-hover/img:flex items-center justify-center text-white"
|
|
>
|
|
<Play size={16} fill="white" />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col truncate min-w-0">
|
|
<span className="font-medium text-white truncate">{song.title}</span>
|
|
<span className="text-xs text-zinc-500 group-hover:text-zinc-400 truncate">
|
|
{song.creator || t('unknown')} <span className="md:hidden">• {song.duration ? `${Math.floor(song.duration / 60)}:${String(Math.floor(song.duration % 60)).padStart(2, '0')}` : '0:00'}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Artist - hidden on mobile */}
|
|
<span className="hidden md:block hover:underline cursor-pointer truncate" onClick={(e) => {
|
|
e.stopPropagation();
|
|
song.creator && onNavigateToProfile(song.creator);
|
|
}}>
|
|
{song.creator || t('unknown')}
|
|
</span>
|
|
|
|
{/* Date Added - hidden on mobile */}
|
|
<span className="hidden md:block">
|
|
{song.addedAt ? new Date(song.addedAt).toLocaleDateString() : t('justNow')}
|
|
</span>
|
|
|
|
{/* Duration + Actions */}
|
|
<div className="hidden md:flex items-center justify-end gap-4">
|
|
<span className="font-mono text-xs">
|
|
{song.duration ? `${Math.floor(song.duration / 60)}:${String(Math.floor(song.duration % 60)).padStart(2, '0')}` : '0:00'}
|
|
</span>
|
|
{isOwner && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveSong(song.id);
|
|
}}
|
|
className="opacity-0 group-hover:opacity-100 text-zinc-500 hover:text-white transition-opacity"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile delete button */}
|
|
{isOwner && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveSong(song.id);
|
|
}}
|
|
className="md:hidden text-zinc-500 hover:text-white p-2"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Back button absolute */}
|
|
<button
|
|
onClick={onBack}
|
|
className="absolute top-6 left-6 z-50 w-8 h-8 rounded-full bg-black/50 flex items-center justify-center text-white hover:bg-black/70 transition-colors"
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|