Initial commit: ACE-Step UI - Open source music generation interface

This commit is contained in:
fspecii
2026-02-04 03:09:38 +02:00
commit 44f7563014
187 changed files with 99985 additions and 0 deletions
+456
View File
@@ -0,0 +1,456 @@
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>
</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}
/>
</>
);
};