471 lines
24 KiB
TypeScript
471 lines
24 KiB
TypeScript
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||
import { Song } from '../types';
|
||
import { Play, MoreHorizontal, Heart, ThumbsDown, ListPlus, Pause, Search, Filter, Check, Globe, Lock, Loader2, ThumbsUp, Share2, Video, Info, Clock } from 'lucide-react';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||
import { ShareModal } from './ShareModal';
|
||
import { AlbumCover } from './AlbumCover';
|
||
|
||
interface SongListProps {
|
||
songs: Song[];
|
||
currentSong: Song | null;
|
||
selectedSong: Song | null;
|
||
likedSongIds: Set<string>;
|
||
isPlaying: boolean;
|
||
onPlay: (song: Song) => void;
|
||
onSelect: (song: Song) => void;
|
||
onToggleLike: (songId: string) => void;
|
||
onAddToPlaylist: (song: Song) => void;
|
||
onOpenVideo?: (song: Song) => void;
|
||
onShowDetails?: (song: Song) => void;
|
||
onNavigateToProfile?: (username: string) => void;
|
||
onReusePrompt?: (song: Song) => void;
|
||
onDelete?: (song: Song) => void;
|
||
}
|
||
|
||
// ... existing code ...
|
||
|
||
|
||
|
||
// Define Filter Types
|
||
type FilterType = 'liked' | 'public' | 'private' | 'generating';
|
||
|
||
const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [
|
||
{ id: 'liked', label: 'Liked', icon: <ThumbsUp size={16} /> },
|
||
{ id: 'public', label: 'Public', icon: <Globe size={16} /> },
|
||
{ id: 'private', label: 'Private', icon: <Lock size={16} /> },
|
||
{ id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> },
|
||
];
|
||
|
||
export const SongList: React.FC<SongListProps> = ({
|
||
songs,
|
||
currentSong,
|
||
selectedSong,
|
||
likedSongIds,
|
||
isPlaying,
|
||
onPlay,
|
||
onSelect,
|
||
onToggleLike,
|
||
onAddToPlaylist,
|
||
onOpenVideo,
|
||
onShowDetails,
|
||
onNavigateToProfile,
|
||
onReusePrompt,
|
||
onDelete
|
||
}) => {
|
||
const { user } = useAuth();
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
|
||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||
const filterRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Close filter dropdown when clicking outside
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (filterRef.current && !filterRef.current.contains(event.target as Node)) {
|
||
setIsFilterOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}, []);
|
||
|
||
const toggleFilter = (filterId: FilterType) => {
|
||
setActiveFilters(prev => {
|
||
const newFilters = new Set(prev);
|
||
if (newFilters.has(filterId)) {
|
||
newFilters.delete(filterId);
|
||
} else {
|
||
newFilters.add(filterId);
|
||
}
|
||
return newFilters;
|
||
});
|
||
};
|
||
|
||
const filteredSongs = useMemo(() => {
|
||
return songs.filter(song => {
|
||
// 1. Search Logic
|
||
const matchesSearch =
|
||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
song.style.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
song.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||
|
||
if (!matchesSearch) return false;
|
||
|
||
// 2. Filter Logic
|
||
if (activeFilters.size === 0) return true;
|
||
|
||
if (activeFilters.has('liked') && !likedSongIds.has(song.id)) return false;
|
||
if (activeFilters.has('public') && !song.isPublic) return false;
|
||
if (activeFilters.has('private') && song.isPublic) return false;
|
||
if (activeFilters.has('generating') && !song.isGenerating) return false;
|
||
|
||
return true;
|
||
});
|
||
}, [songs, searchQuery, activeFilters, likedSongIds]);
|
||
|
||
return (
|
||
<div className="flex-1 bg-white dark:bg-black h-full overflow-y-auto custom-scrollbar p-6 pb-32 transition-colors duration-300">
|
||
<div className="max-w-5xl mx-auto w-full"> {/* Container constraint */}
|
||
|
||
{/* Header */}
|
||
<div className="flex flex-col gap-6 mb-8">
|
||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||
<span className="hover:text-black dark:hover:text-white cursor-pointer transition-colors">Workspaces</span>
|
||
<span className="text-zinc-400 dark:text-zinc-600">›</span>
|
||
<span className="text-zinc-900 dark:text-white font-medium">My Workspace</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="relative group flex-1">
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Search your songs..."
|
||
className="w-full bg-zinc-100 dark:bg-[#121214] border border-zinc-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:border-zinc-400 dark:focus:border-white/20 placeholder-zinc-500 dark:placeholder-zinc-600 transition-colors"
|
||
/>
|
||
<Search className="w-4 h-4 text-zinc-500 absolute left-3 top-3 group-focus-within:text-black dark:group-focus-within:text-white transition-colors" />
|
||
</div>
|
||
|
||
<div className="relative" ref={filterRef}>
|
||
<button
|
||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||
className={`
|
||
border text-xs font-bold px-4 py-2.5 rounded-lg flex items-center gap-2 transition-all select-none
|
||
${isFilterOpen || activeFilters.size > 0
|
||
? 'bg-zinc-900 dark:bg-white text-white dark:text-black border-transparent'
|
||
: 'bg-zinc-100 dark:bg-[#121214] hover:bg-zinc-200 dark:hover:bg-white/5 border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-white'
|
||
}
|
||
`}
|
||
>
|
||
<Filter size={14} fill={activeFilters.size > 0 ? "currentColor" : "none"} />
|
||
<span>Filters {activeFilters.size > 0 && `(${activeFilters.size})`}</span>
|
||
</button>
|
||
|
||
{/* Filter Dropdown */}
|
||
{isFilterOpen && (
|
||
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden py-1 z-50 animate-in fade-in zoom-in-95 duration-100 origin-top-right">
|
||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-500 uppercase tracking-wider">
|
||
Refine By
|
||
</div>
|
||
{FILTERS.map(filter => (
|
||
<button
|
||
key={filter.id}
|
||
onClick={() => toggleFilter(filter.id)}
|
||
className="w-full text-left px-4 py-2.5 flex items-center justify-between hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group"
|
||
>
|
||
<div className="flex items-center gap-3 text-sm font-medium text-zinc-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white">
|
||
<span className="text-zinc-400 dark:text-zinc-500 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition-colors">
|
||
{filter.icon}
|
||
</span>
|
||
{filter.label}
|
||
</div>
|
||
<div className={`
|
||
w-4 h-4 rounded border flex items-center justify-center transition-all
|
||
${activeFilters.has(filter.id)
|
||
? 'bg-pink-600 border-pink-600'
|
||
: 'border-zinc-300 dark:border-zinc-600 group-hover:border-zinc-400 dark:group-hover:border-zinc-500'
|
||
}
|
||
`}>
|
||
{activeFilters.has(filter.id) && <Check size={10} className="text-white" strokeWidth={4} />}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* List */}
|
||
<div className="space-y-2"> {/* Reduced vertical spacing */}
|
||
{filteredSongs.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-64 text-zinc-500 space-y-4 border border-dashed border-zinc-200 dark:border-white/5 rounded-2xl bg-zinc-50 dark:bg-white/[0.02]">
|
||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center">
|
||
<Filter size={32} />
|
||
</div>
|
||
<p className="font-medium">No songs match your filters.</p>
|
||
<button
|
||
onClick={() => { setActiveFilters(new Set()); setSearchQuery(''); }}
|
||
className="text-pink-600 dark:text-pink-500 text-sm font-bold hover:underline"
|
||
>
|
||
Clear filters
|
||
</button>
|
||
</div>
|
||
) : (
|
||
filteredSongs.map((song) => (
|
||
<SongItem
|
||
key={song.id}
|
||
song={song}
|
||
isCurrent={currentSong?.id === song.id}
|
||
isSelected={selectedSong?.id === song.id}
|
||
isLiked={likedSongIds.has(song.id)}
|
||
isPlaying={isPlaying}
|
||
isOwner={user?.id === song.userId}
|
||
onPlay={() => onPlay(song)}
|
||
onSelect={() => onSelect(song)}
|
||
onToggleLike={() => onToggleLike(song.id)}
|
||
onAddToPlaylist={() => onAddToPlaylist(song)}
|
||
onOpenVideo={() => onOpenVideo && onOpenVideo(song)}
|
||
onShowDetails={() => onShowDetails && onShowDetails(song)}
|
||
onNavigateToProfile={onNavigateToProfile}
|
||
onReusePrompt={() => onReusePrompt?.(song)}
|
||
onDelete={() => onDelete?.(song)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div> {/* End container */}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface SongItemProps {
|
||
song: Song;
|
||
isCurrent: boolean;
|
||
isSelected: boolean;
|
||
isLiked: boolean;
|
||
isPlaying: boolean;
|
||
isOwner: boolean;
|
||
onPlay: () => void;
|
||
onSelect: () => void;
|
||
onToggleLike: () => void;
|
||
onAddToPlaylist: () => void;
|
||
onOpenVideo?: () => void;
|
||
onShowDetails?: () => void;
|
||
onNavigateToProfile?: (username: string) => void;
|
||
onReusePrompt?: () => void;
|
||
onDelete?: () => void;
|
||
}
|
||
|
||
const SongItem: React.FC<SongItemProps> = ({
|
||
song,
|
||
isCurrent,
|
||
isSelected,
|
||
isLiked,
|
||
isPlaying,
|
||
isOwner,
|
||
onPlay,
|
||
onSelect,
|
||
onToggleLike,
|
||
onAddToPlaylist,
|
||
onOpenVideo,
|
||
onShowDetails,
|
||
onNavigateToProfile,
|
||
onReusePrompt,
|
||
onDelete
|
||
}) => {
|
||
const [showDropdown, setShowDropdown] = useState(false);
|
||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||
const [imageError, setImageError] = useState(false);
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
onClick={onSelect}
|
||
className={`group flex items-center gap-4 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-[#18181b] transition-all cursor-pointer border ${isSelected ? 'bg-zinc-100 dark:bg-[#18181b] border-zinc-200 dark:border-white/10' : 'border-transparent bg-transparent'}`}
|
||
>
|
||
|
||
{/* Cover Art - Reduced size */}
|
||
<div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm group/image">
|
||
{/* Use gradient fallback if no coverUrl or image fails to load */}
|
||
{(!song.coverUrl || imageError) ? (
|
||
<AlbumCover seed={song.id || song.title} size="full" className={`w-full h-full ${song.isGenerating ? 'opacity-20 blur-sm' : 'opacity-100'}`} />
|
||
) : (
|
||
<img
|
||
src={song.coverUrl}
|
||
alt={song.title}
|
||
className={`w-full h-full object-cover transition-opacity ${song.isGenerating ? 'opacity-20 blur-sm' : 'opacity-100'}`}
|
||
onError={() => setImageError(true)}
|
||
/>
|
||
)}
|
||
|
||
{song.isGenerating ? (
|
||
<div className="absolute inset-0 bg-black/40 flex flex-col items-center justify-center gap-1">
|
||
{song.queuePosition ? (
|
||
/* Queue indicator */
|
||
<>
|
||
<div className="w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center">
|
||
<Clock size={16} className="text-amber-400" />
|
||
</div>
|
||
<span className="text-[10px] font-medium text-amber-400">Queue #{song.queuePosition}</span>
|
||
</>
|
||
) : (
|
||
/* Generating - Music Waveform Animation */
|
||
<div className="flex items-end gap-1 h-6">
|
||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.0s' }}></div>
|
||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.2s' }}></div>
|
||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.4s' }}></div>
|
||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.1s' }}></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={`absolute inset-0 bg-black/40 flex items-center justify-center backdrop-blur-[1px] cursor-pointer transition-opacity duration-200 ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover/image:opacity-100'}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onPlay();
|
||
}}
|
||
>
|
||
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-lg transform transition-transform hover:scale-105">
|
||
{isCurrent && isPlaying ? (
|
||
<Pause fill="black" className="text-black w-5 h-5" />
|
||
) : (
|
||
<Play fill="black" className="text-black ml-1 w-5 h-5" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 min-w-0 flex flex-col justify-between py-1">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className={`font-bold text-lg truncate ${isCurrent ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-900 dark:text-white'}`}>
|
||
{song.title || (song.isGenerating ? (song.queuePosition ? "Queued..." : "Creating...") : "Untitled")}
|
||
</h3>
|
||
<span className="inline-flex items-center justify-center text-[9px] font-bold text-white bg-gradient-to-r from-pink-500 to-purple-500 px-1.5 py-0.5 rounded-sm shadow-sm">
|
||
v1.5
|
||
</span>
|
||
{song.isPublic === false && (
|
||
<Lock size={12} className="text-zinc-400 dark:text-zinc-500" />
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className="flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-opacity"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (song.creator && onNavigateToProfile) {
|
||
onNavigateToProfile(song.creator);
|
||
}
|
||
}}
|
||
>
|
||
<div className="w-4 h-4 rounded-full bg-purple-500 text-[8px] flex items-center justify-center font-bold text-white">
|
||
{(song.creator?.[0] || 'U').toUpperCase()}
|
||
</div>
|
||
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors hover:underline">
|
||
{song.creator || 'Unknown'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-zinc-500 dark:text-zinc-500 line-clamp-2 pt-1 font-medium max-w-2xl">
|
||
{song.style}
|
||
</p>
|
||
{song.isGenerating && (
|
||
<div className="pt-2">
|
||
<div className="h-1 rounded-full bg-zinc-200/70 dark:bg-white/10 overflow-hidden">
|
||
{song.progress !== undefined ? (
|
||
<div
|
||
className="h-full bg-gradient-to-r from-pink-500 to-purple-600 transition-all"
|
||
style={{ width: `${Math.min(100, Math.max(0, song.progress * 100))}%` }}
|
||
/>
|
||
) : (
|
||
<div className="h-full w-1/3 bg-gradient-to-r from-pink-500 to-purple-600 animate-pulse" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions Row - Hidden while generating */}
|
||
{!song.isGenerating && (
|
||
<div className="flex items-center gap-1 pt-2">
|
||
<button
|
||
className={`flex items-center gap-1 px-3 py-1.5 rounded-full hover:bg-white/5 transition-colors ${isLiked ? 'text-pink-600 dark:text-pink-500 bg-pink-100 dark:bg-pink-500/10' : 'text-zinc-400 hover:text-black dark:hover:text-white'}`}
|
||
onClick={(e) => { e.stopPropagation(); onToggleLike(); }}
|
||
>
|
||
<ThumbsUp size={16} fill={isLiked ? "currentColor" : "none"} />
|
||
{(song.likeCount || 0) > 0 && (
|
||
<span className="text-xs font-bold">{song.likeCount}</span>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||
onClick={(e) => { e.stopPropagation(); }}
|
||
>
|
||
<ThumbsDown size={16} />
|
||
</button>
|
||
|
||
<button
|
||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||
onClick={(e) => { e.stopPropagation(); setShareModalOpen(true); }}
|
||
title="Share"
|
||
>
|
||
<Share2 size={16} />
|
||
</button>
|
||
|
||
<button
|
||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||
onClick={(e) => { e.stopPropagation(); if (onOpenVideo) onOpenVideo(); }}
|
||
title="Create Video"
|
||
>
|
||
<Video size={16} />
|
||
</button>
|
||
|
||
<button
|
||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors ml-auto"
|
||
onClick={(e) => { e.stopPropagation(); onAddToPlaylist(); }}
|
||
title="Add to Playlist"
|
||
>
|
||
<ListPlus size={16} />
|
||
</button>
|
||
|
||
{/* Info Button - Visible only on small/medium screens where sidebar is hidden */}
|
||
<button
|
||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors xl:hidden"
|
||
onClick={(e) => { e.stopPropagation(); if (onShowDetails) onShowDetails(); }}
|
||
title="Song Details"
|
||
>
|
||
<Info size={16} />
|
||
</button>
|
||
|
||
<div className="relative">
|
||
<button
|
||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setShowDropdown(!showDropdown);
|
||
}}
|
||
>
|
||
<MoreHorizontal size={16} />
|
||
</button>
|
||
<SongDropdownMenu
|
||
song={song}
|
||
isOpen={showDropdown}
|
||
onClose={() => setShowDropdown(false)}
|
||
isOwner={isOwner}
|
||
onCreateVideo={() => onOpenVideo?.(song)}
|
||
onReusePrompt={() => onReusePrompt?.(song)}
|
||
onAddToPlaylist={() => onAddToPlaylist?.(song)}
|
||
onDelete={() => onDelete?.(song)}
|
||
onShare={() => setShareModalOpen(true)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Timestamp */}
|
||
<div className="text-xs font-mono text-zinc-500 dark:text-zinc-600 self-start pt-1">
|
||
{song.isGenerating ? (
|
||
<span className={song.queuePosition ? 'text-amber-500' : 'text-pink-500'}>
|
||
{song.queuePosition ? `#${song.queuePosition}` : 'Creating...'}
|
||
</span>
|
||
) : song.duration}
|
||
</div>
|
||
</div>
|
||
|
||
<ShareModal
|
||
isOpen={shareModalOpen}
|
||
onClose={() => setShareModalOpen(false)}
|
||
song={song}
|
||
/>
|
||
</>
|
||
);
|
||
};
|