Files
ace-step-ui/components/SongList.tsx
T
2026-02-04 23:15:00 -05:00

481 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 > 1 ? song.progress / 100 : song.progress) * 100)
)}%`,
}}
/>
) : (
<div className="h-full w-1/3 bg-gradient-to-r from-pink-500 to-purple-600 animate-pulse" />
)}
</div>
{song.progress !== undefined && (
<div className="mt-1 text-[10px] text-zinc-500 dark:text-zinc-400">
{Math.round((song.progress > 1 ? song.progress / 100 : song.progress) * 100)}%
</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}
/>
</>
);
};