Merge PR #24: Various UI improvements from riversedge

Includes progress bar, drag and drop, gender buttons, upload
improvements, and dynamic duration limits.
This commit is contained in:
fspecii
2026-02-05 22:34:01 +02:00
19 changed files with 1892 additions and 285 deletions
+573 -166
View File
File diff suppressed because it is too large Load Diff
+175 -5
View File
@@ -1,26 +1,70 @@
import React, { useState } from 'react';
import { Song, Playlist } from '../types';
import { Heart, Plus, Music, Play } from 'lucide-react';
import { Heart, Plus, Music, Play, MoreHorizontal, Trash2 } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { SongDropdownMenu } from './SongDropdownMenu';
import { ShareModal } from './ShareModal';
import { AlbumCover } from './AlbumCover';
interface LibraryViewProps {
allSongs: Song[];
likedSongs: Song[];
playlists: Playlist[];
referenceTracks: ReferenceTrack[];
onPlaySong: (song: Song, list?: Song[]) => void;
onCreatePlaylist: () => void;
onSelectPlaylist: (playlist: Playlist) => void;
onAddToPlaylist: (song: Song) => void;
onOpenVideo?: (song: Song) => void;
onReusePrompt?: (song: Song) => void;
onDeleteSong?: (song: Song) => void;
onDeleteReferenceTrack?: (trackId: string) => void;
}
interface ReferenceTrack {
id: string;
filename: string;
storage_key: string;
duration: number | null;
file_size_bytes: number | null;
tags: string[] | null;
created_at: string;
audio_url: string;
}
export const LibraryView: React.FC<LibraryViewProps> = ({
allSongs,
likedSongs,
playlists,
referenceTracks,
onPlaySong,
onCreatePlaylist,
onSelectPlaylist
onSelectPlaylist,
onAddToPlaylist,
onOpenVideo,
onReusePrompt,
onDeleteSong,
onDeleteReferenceTrack,
}) => {
const [activeTab, setActiveTab] = useState<'playlists' | 'liked'>('liked');
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<'all' | 'playlists' | 'liked' | 'uploads'>('all');
const [shareModalOpen, setShareModalOpen] = useState(false);
const [shareSong, setShareSong] = useState<Song | null>(null);
const formatBytes = (bytes?: number | null) => {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
};
return (
<>
<div className="flex-1 bg-white dark:bg-black overflow-y-auto custom-scrollbar p-6 lg:p-10 pb-32 transition-colors duration-300">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Your Library</h1>
@@ -35,6 +79,13 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
{/* Tabs */}
<div className="flex items-center gap-4 mb-8 border-b border-zinc-200 dark:border-white/10 pb-1">
<button
onClick={() => setActiveTab('all')}
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'all' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
>
All Songs
{activeTab === 'all' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button>
<button
onClick={() => setActiveTab('liked')}
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'liked' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
@@ -49,10 +100,68 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
Playlists
{activeTab === 'playlists' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button>
<button
onClick={() => setActiveTab('uploads')}
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'uploads' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
>
Uploads
{activeTab === 'uploads' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button>
</div>
{/* Content */}
{activeTab === 'liked' ? (
{activeTab === 'all' && (
<div className="space-y-1">
{allSongs.length === 0 ? (
<div className="text-sm text-zinc-500 dark:text-zinc-400">No songs yet.</div>
) : (
allSongs.map((song, idx) => (
<div key={song.id} className="group flex items-center gap-4 p-2 rounded hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors" onClick={() => onPlaySong(song, allSongs)}>
<span className="text-zinc-400 dark:text-zinc-500 w-6 text-center group-hover:hidden">{idx + 1}</span>
<span className="text-zinc-900 dark:text-white w-6 text-center hidden group-hover:block"><Play size={14} fill="currentColor" /></span>
{song.coverUrl ? (
<img src={song.coverUrl} className="w-10 h-10 rounded object-cover shadow-sm" alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
) : (
<AlbumCover seed={song.id || song.title} size="sm" className="w-10 h-10" />
)}
<div className="flex-1 min-w-0">
<div className="text-zinc-900 dark:text-white font-medium truncate">{song.title}</div>
<div className="text-zinc-500 dark:text-zinc-400 text-xs">{song.style}</div>
</div>
<div className="text-zinc-500 dark:text-zinc-400 text-sm font-mono">{song.duration}</div>
<div className="relative ml-2">
<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();
setShareSong(prev => prev?.id === song.id ? null : song);
}}
>
<MoreHorizontal size={16} />
</button>
<SongDropdownMenu
song={song}
isOpen={shareSong?.id === song.id}
onClose={() => setShareSong(null)}
isOwner={user ? song.userId === user.id : false}
onCreateVideo={() => onOpenVideo?.(song)}
onReusePrompt={() => onReusePrompt?.(song)}
onAddToPlaylist={() => onAddToPlaylist(song)}
onDelete={() => onDeleteSong?.(song)}
onShare={() => {
setShareModalOpen(true);
}}
/>
</div>
</div>
))
)}
</div>
)}
{activeTab === 'liked' && (
<div>
<div className="bg-gradient-to-b from-indigo-500/10 to-zinc-50 dark:from-indigo-800/50 dark:to-zinc-900/50 p-6 rounded-xl flex items-end gap-6 mb-8 cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group border border-zinc-200 dark:border-white/5" onClick={() => likedSongs.length > 0 && onPlaySong(likedSongs[0], likedSongs)}>
<div className="w-40 h-40 bg-gradient-to-br from-indigo-500 to-purple-400 rounded shadow-2xl flex items-center justify-center">
@@ -91,11 +200,36 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
<div className="text-zinc-500 dark:text-zinc-400 text-sm font-mono">{song.duration}</div>
<div className="text-green-500"><Heart fill="#22c55e" size={16} /></div>
<div className="relative ml-2">
<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();
setShareSong(prev => prev?.id === song.id ? null : song);
}}
>
<MoreHorizontal size={16} />
</button>
<SongDropdownMenu
song={song}
isOpen={shareSong?.id === song.id}
onClose={() => setShareSong(null)}
isOwner={user ? song.userId === user.id : false}
onCreateVideo={() => onOpenVideo?.(song)}
onReusePrompt={() => onReusePrompt?.(song)}
onAddToPlaylist={() => onAddToPlaylist(song)}
onDelete={() => onDeleteSong?.(song)}
onShare={() => {
setShareModalOpen(true);
}}
/>
</div>
</div>
))}
</div>
</div>
) : (
)}
{activeTab === 'playlists' && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{playlists.map((playlist) => (
<div key={playlist.id} className="bg-white dark:bg-zinc-900/40 p-4 rounded-lg border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10 hover:shadow-lg dark:hover:bg-zinc-900 transition-all group cursor-pointer" onClick={() => onSelectPlaylist(playlist)}>
@@ -112,6 +246,42 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
))}
</div>
)}
{activeTab === 'uploads' && (
<div className="space-y-2">
{referenceTracks.length === 0 ? (
<div className="text-sm text-zinc-500 dark:text-zinc-400">No uploads yet.</div>
) : (
referenceTracks.map((track) => (
<div key={track.id} className="flex items-center gap-4 p-3 rounded-lg border border-zinc-200 dark:border-white/5 bg-white dark:bg-zinc-900/40">
<div className="w-10 h-10 rounded bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
<Music size={18} className="text-zinc-500 dark:text-zinc-400" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-900 dark:text-white truncate">{track.filename}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{formatBytes(track.file_size_bytes)} {new Date(track.created_at).toLocaleDateString()}
</div>
</div>
<button
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-500 hover:text-red-600 transition-colors"
onClick={() => onDeleteReferenceTrack?.(track.id)}
title="Delete upload"
>
<Trash2 size={16} />
</button>
</div>
))
)}
</div>
)}
</div>
{shareSong && (
<ShareModal
isOpen={shareModalOpen}
onClose={() => { setShareModalOpen(false); setShareSong(null); }}
song={shareSong}
/>
)}
</>
);
};
+102
View File
@@ -95,6 +95,25 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
}
};
const getSourceLabel = (url?: string) => {
if (!url) return 'None';
try {
const parsed = new URL(url, window.location.origin);
const name = decodeURIComponent(parsed.pathname.split('/').pop() || url);
return name.replace(/\.[^/.]+$/, '') || name;
} catch {
const parts = url.split('/');
const name = decodeURIComponent(parts[parts.length - 1] || url);
return name.replace(/\.[^/.]+$/, '') || name;
}
};
const openSource = (url?: string) => {
if (!url) return;
const resolved = url.startsWith('http') ? url : `${window.location.origin}${url}`;
window.open(resolved, '_blank');
};
if (!song) return (
<div className="w-full h-full bg-zinc-50 dark:bg-suno-panel border-l border-zinc-200 dark:border-white/5 flex items-center justify-center text-zinc-400 dark:text-zinc-500 text-sm transition-colors duration-300">
<div className="flex flex-col items-center gap-2">
@@ -347,6 +366,89 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
</div>
</div>
{(song.generationParams?.referenceAudioUrl || song.generationParams?.sourceAudioUrl) && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
<LinkIcon size={14} />
Sources
</div>
<div className="space-y-2">
{song.generationParams?.referenceAudioUrl && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900/40 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Music size={14} className="text-zinc-400" />
<div className="min-w-0">
<div className="text-xs text-zinc-500">Reference</div>
<div className="text-sm font-medium text-zinc-900 dark:text-white truncate">
{song.generationParams?.referenceAudioTitle || getSourceLabel(song.generationParams?.referenceAudioUrl)}
</div>
</div>
</div>
<button
className="text-xs px-2 py-1 rounded-full border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
onClick={() => {
if (!song.generationParams?.referenceAudioUrl || !onPlay) return;
const previewSong = {
id: `ref_${song.id}`,
title: song.generationParams?.referenceAudioTitle || getSourceLabel(song.generationParams?.referenceAudioUrl),
lyrics: '',
style: 'Reference',
coverUrl: song.coverUrl,
duration: '0:00',
createdAt: new Date(),
tags: [],
audioUrl: song.generationParams?.referenceAudioUrl,
isPublic: false,
userId: song.userId,
creator: song.creator,
};
onPlay(previewSong);
}}
>
Play
</button>
</div>
)}
{song.generationParams?.sourceAudioUrl && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900/40 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Layers size={14} className="text-zinc-400" />
<div className="min-w-0">
<div className="text-xs text-zinc-500">Cover</div>
<div className="text-sm font-medium text-zinc-900 dark:text-white truncate">
{song.generationParams?.sourceAudioTitle || getSourceLabel(song.generationParams?.sourceAudioUrl)}
</div>
</div>
</div>
<button
className="text-xs px-2 py-1 rounded-full border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
onClick={() => {
if (!song.generationParams?.sourceAudioUrl || !onPlay) return;
const previewSong = {
id: `cover_${song.id}`,
title: song.generationParams?.sourceAudioTitle || getSourceLabel(song.generationParams?.sourceAudioUrl),
lyrics: '',
style: 'Cover',
coverUrl: song.coverUrl,
duration: '0:00',
createdAt: new Date(),
tags: [],
audioUrl: song.generationParams?.sourceAudioUrl,
isPublic: false,
userId: song.userId,
creator: song.creator,
};
onPlay(previewSong);
}}
>
Play
</button>
</div>
)}
</div>
</div>
)}
<div className="h-px bg-zinc-200 dark:bg-white/5 w-full"></div>
{/* Tags / Style */}
+28 -6
View File
@@ -26,6 +26,8 @@ interface SongDropdownMenuProps {
onDownload?: () => void;
onShare?: () => void;
onDelete?: () => void;
onUseAsReference?: () => void;
onCoverSong?: () => void;
}
interface MenuItemProps {
@@ -70,7 +72,9 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
onAddToPlaylist,
onDownload,
onShare,
onDelete
onDelete,
onUseAsReference,
onCoverSong
}) => {
const menuRef = useRef<HTMLDivElement>(null);
@@ -185,11 +189,29 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
label="Extract Stems"
onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems}
/>
<MenuItem
icon={<Repeat size={14} />}
label="Reuse Prompt"
onClick={() => handleAction(onReusePrompt)}
/>
{onReusePrompt && (
<MenuItem
icon={<Repeat size={14} />}
label="Reuse Prompt"
onClick={() => handleAction(onReusePrompt)}
/>
)}
{onUseAsReference && (
<MenuItem
icon={<Layers size={14} />}
label="Use as Reference"
onClick={() => handleAction(onUseAsReference)}
disabled={!song.audioUrl}
/>
)}
{onCoverSong && (
<MenuItem
icon={<Layers size={14} />}
label="Cover Song"
onClick={() => handleAction(onCoverSong)}
disabled={!song.audioUrl}
/>
)}
<MenuDivider />
+315 -25
View File
@@ -12,6 +12,7 @@ interface SongListProps {
selectedSong: Song | null;
likedSongIds: Set<string>;
isPlaying: boolean;
referenceTracks?: { id: string; filename: string; audio_url: string; duration?: number | null; created_at?: string }[];
onPlay: (song: Song) => void;
onSelect: (song: Song) => void;
onToggleLike: (songId: string) => void;
@@ -21,6 +22,11 @@ interface SongListProps {
onNavigateToProfile?: (username: string) => void;
onReusePrompt?: (song: Song) => void;
onDelete?: (song: Song) => void;
onDeleteMany?: (songs: Song[]) => void;
onUseAsReference?: (song: Song) => void;
onCoverSong?: (song: Song) => void;
onUseUploadAsReference?: (track: { audio_url: string; filename: string }) => void;
onCoverUpload?: (track: { audio_url: string; filename: string }) => void;
}
// ... existing code ...
@@ -37,12 +43,45 @@ const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [
{ id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> },
];
const createDragPreview = (element: HTMLElement) => {
const clone = element.cloneNode(true) as HTMLElement;
clone.style.width = `${element.offsetWidth}px`;
clone.style.position = 'fixed';
clone.style.top = '-1000px';
clone.style.left = '-1000px';
clone.style.pointerEvents = 'none';
clone.style.opacity = '0.95';
const badge = document.createElement('div');
badge.textContent = '+';
badge.style.position = 'absolute';
badge.style.left = '8px';
badge.style.bottom = '8px';
badge.style.width = '24px';
badge.style.height = '24px';
badge.style.display = 'flex';
badge.style.alignItems = 'center';
badge.style.justifyContent = 'center';
badge.style.borderRadius = '9999px';
badge.style.background = '#22c55e';
badge.style.color = 'white';
badge.style.boxShadow = '0 6px 16px rgba(0,0,0,0.25)';
badge.style.fontSize = '16px';
badge.style.lineHeight = '1';
clone.style.position = 'relative';
clone.appendChild(badge);
document.body.appendChild(clone);
return clone;
};
export const SongList: React.FC<SongListProps> = ({
songs,
currentSong,
selectedSong,
likedSongIds,
isPlaying,
referenceTracks = [],
onPlay,
onSelect,
onToggleLike,
@@ -51,12 +90,19 @@ export const SongList: React.FC<SongListProps> = ({
onShowDetails,
onNavigateToProfile,
onReusePrompt,
onDelete
onDelete,
onDeleteMany,
onUseAsReference,
onCoverSong,
onUseUploadAsReference,
onCoverUpload
}) => {
const { user } = useAuth();
const [searchQuery, setSearchQuery] = useState('');
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const filterRef = useRef<HTMLDivElement>(null);
// Close filter dropdown when clicking outside
@@ -70,6 +116,18 @@ export const SongList: React.FC<SongListProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
setSelectedIds(prev => {
if (prev.size === 0) return prev;
const validIds = new Set(songs.map(song => song.id));
const next = new Set<string>();
prev.forEach(id => {
if (validIds.has(id)) next.add(id);
});
return next;
});
}, [songs]);
const toggleFilter = (filterId: FilterType) => {
setActiveFilters(prev => {
const newFilters = new Set(prev);
@@ -104,6 +162,39 @@ export const SongList: React.FC<SongListProps> = ({
});
}, [songs, searchQuery, activeFilters, likedSongIds]);
const filteredUploads = useMemo(() => {
if (activeFilters.size > 0) return [];
if (!referenceTracks.length) return [];
return referenceTracks.filter(track => {
const title = track.filename.replace(/\.[^/.]+$/, '');
return title.toLowerCase().includes(searchQuery.toLowerCase());
});
}, [referenceTracks, searchQuery, activeFilters]);
const listItems = useMemo(() => {
const songItems = filteredSongs.map(song => ({
type: 'song' as const,
id: song.id,
createdAt: song.createdAt,
song
}));
const uploadItems = filteredUploads.map(track => ({
type: 'upload' as const,
id: track.id,
createdAt: new Date(track.created_at || Date.now()),
track
}));
return [...songItems, ...uploadItems].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}, [filteredSongs, filteredUploads]);
const selectableSongs = useMemo(
() => filteredSongs.filter(song => !song.isGenerating),
[filteredSongs]
);
const allSelected = selectableSongs.length > 0 && selectableSongs.every(song => selectedIds.has(song.id));
const selectedSongs = selectableSongs.filter(song => selectedIds.has(song.id));
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 */}
@@ -175,12 +266,62 @@ export const SongList: React.FC<SongListProps> = ({
</div>
)}
</div>
<button
onClick={() => {
setIsSelecting(prev => !prev);
setSelectedIds(new Set());
}}
className={`border text-xs font-bold px-4 py-2.5 rounded-lg flex items-center gap-2 transition-all select-none ${isSelecting
? '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'
}`}
>
Select
</button>
</div>
{isSelecting && (
<div className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 px-4 py-3">
<div className="text-sm text-zinc-600 dark:text-zinc-300">
{selectedSongs.length} selected
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const next = new Set<string>();
if (!allSelected) {
selectableSongs.forEach(song => next.add(song.id));
}
setSelectedIds(next);
}}
className="px-3 py-1.5 rounded-lg text-xs font-semibold border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20"
>
{allSelected ? 'Clear all' : 'Select all'}
</button>
<button
onClick={() => {
if (!selectedSongs.length) return;
onDeleteMany?.(selectedSongs);
setSelectedIds(new Set());
setIsSelecting(false);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border ${selectedSongs.length
? 'border-red-500 text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10'
: 'border-zinc-200 dark:border-white/10 text-zinc-400 cursor-not-allowed'
}`}
disabled={!selectedSongs.length}
>
Delete
</button>
</div>
</div>
)}
</div>
{/* List */}
<div className="space-y-2"> {/* Reduced vertical spacing */}
{filteredSongs.length === 0 ? (
{listItems.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} />
@@ -194,25 +335,61 @@ export const SongList: React.FC<SongListProps> = ({
</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)}
/>
listItems.map((item) => (
item.type === 'song' ? (
<SongItem
key={item.id}
song={item.song}
isCurrent={currentSong?.id === item.song.id}
isSelected={selectedSong?.id === item.song.id}
isSelectionMode={isSelecting}
isChecked={selectedIds.has(item.song.id)}
isLiked={likedSongIds.has(item.song.id)}
isPlaying={isPlaying}
isOwner={user?.id === item.song.userId}
onPlay={() => onPlay(item.song)}
onSelect={() => onSelect(item.song)}
onToggleSelect={() => {
if (item.song.isGenerating) return;
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(item.song.id)) next.delete(item.song.id);
else next.add(item.song.id);
return next;
});
}}
onToggleLike={() => onToggleLike(item.song.id)}
onAddToPlaylist={() => onAddToPlaylist(item.song)}
onOpenVideo={() => onOpenVideo && onOpenVideo(item.song)}
onShowDetails={() => onShowDetails && onShowDetails(item.song)}
onNavigateToProfile={onNavigateToProfile}
onReusePrompt={() => onReusePrompt?.(item.song)}
onDelete={() => onDelete?.(item.song)}
onUseAsReference={() => onUseAsReference?.(item.song)}
onCoverSong={() => onCoverSong?.(item.song)}
/>
) : (
<UploadItem
key={`upload_${item.id}`}
track={item.track}
onPlay={(audioUrl, title) => {
onPlay({
id: `upload_${item.id}`,
title,
lyrics: '',
style: 'Upload',
coverUrl: '',
duration: '0:00',
createdAt: item.createdAt,
tags: [],
audioUrl,
isPublic: false,
} as Song);
}}
onUseAsReference={() => onUseUploadAsReference?.(item.track)}
onCoverSong={() => onCoverUpload?.(item.track)}
/>
)
))
)}
</div>
@@ -225,11 +402,14 @@ interface SongItemProps {
song: Song;
isCurrent: boolean;
isSelected: boolean;
isSelectionMode: boolean;
isChecked: boolean;
isLiked: boolean;
isPlaying: boolean;
isOwner: boolean;
onPlay: () => void;
onSelect: () => void;
onToggleSelect: () => void;
onToggleLike: () => void;
onAddToPlaylist: () => void;
onOpenVideo?: () => void;
@@ -237,24 +417,31 @@ interface SongItemProps {
onNavigateToProfile?: (username: string) => void;
onReusePrompt?: () => void;
onDelete?: () => void;
onUseAsReference?: () => void;
onCoverSong?: () => void;
}
const SongItem: React.FC<SongItemProps> = ({
song,
isCurrent,
isSelected,
isSelectionMode,
isChecked,
isLiked,
isPlaying,
isOwner,
onPlay,
onSelect,
onToggleSelect,
onToggleLike,
onAddToPlaylist,
onOpenVideo,
onShowDetails,
onNavigateToProfile,
onReusePrompt,
onDelete
onDelete,
onUseAsReference,
onCoverSong
}) => {
const [showDropdown, setShowDropdown] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -264,8 +451,47 @@ const SongItem: React.FC<SongItemProps> = ({
<>
<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'}`}
draggable={Boolean(song.audioUrl) && !song.isGenerating}
onDragStart={(e) => {
if (!song.audioUrl || song.isGenerating) return;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/x-ace-audio', JSON.stringify({
url: song.audioUrl,
title: song.title || 'Untitled',
source: 'song',
}));
const preview = createDragPreview(e.currentTarget);
const rect = e.currentTarget.getBoundingClientRect();
const offsetX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const offsetY = Math.max(0, Math.min(rect.height, e.clientY - rect.top));
e.dataTransfer.setDragImage(preview, offsetX, offsetY);
setTimeout(() => {
try {
preview.remove();
} catch {
// ignore
}
}, 0);
}}
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'} ${song.audioUrl && !song.isGenerating ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
{isSelectionMode && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleSelect();
}}
className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${isChecked
? 'bg-pink-600 border-pink-600 text-white'
: 'border-zinc-300 dark:border-zinc-600 text-transparent hover:border-zinc-400 dark:hover:border-zinc-500'
} ${song.isGenerating ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={song.isGenerating}
aria-pressed={isChecked}
>
<Check size={12} strokeWidth={3} className={isChecked ? 'text-white' : 'text-transparent'} />
</button>
)}
{/* 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">
@@ -355,6 +581,21 @@ const SongItem: React.FC<SongItemProps> = ({
<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">
<div
className={`h-full bg-gradient-to-r from-pink-500 to-purple-600 transition-all ${song.progress === undefined ? 'opacity-40' : ''}`}
style={{
width: `${Math.min(
100,
Math.max(0, ((song.progress ?? 0) > 1 ? (song.progress ?? 0) / 100 : (song.progress ?? 0)) * 100)
)}%`,
}}
/>
</div>
</div>
)}
</div>
{/* Actions Row - Hidden while generating */}
@@ -426,10 +667,12 @@ const SongItem: React.FC<SongItemProps> = ({
onClose={() => setShowDropdown(false)}
isOwner={isOwner}
onCreateVideo={() => onOpenVideo?.(song)}
onReusePrompt={() => onReusePrompt?.(song)}
onReusePrompt={onReusePrompt ? () => onReusePrompt?.(song) : undefined}
onAddToPlaylist={() => onAddToPlaylist?.(song)}
onDelete={() => onDelete?.(song)}
onShare={() => setShareModalOpen(true)}
onUseAsReference={() => onUseAsReference?.()}
onCoverSong={() => onCoverSong?.()}
/>
</div>
</div>
@@ -453,4 +696,51 @@ const SongItem: React.FC<SongItemProps> = ({
/>
</>
);
};
};
const UploadItem: React.FC<{
track: { id: string; filename: string; audio_url: string; duration?: number | null };
onPlay: (audioUrl: string, title: string) => void;
onUseAsReference?: () => void;
onCoverSong?: () => void;
}> = ({ track, onPlay, onUseAsReference, onCoverSong }) => {
const title = track.filename.replace(/\.[^/.]+$/, '');
const duration = track.duration
? `${Math.floor(track.duration / 60)}:${String(Math.floor(track.duration % 60)).padStart(2, '0')}`
: '--:--';
return (
<SongItem
song={{
id: `upload_${track.id}`,
title,
lyrics: '',
style: 'Upload',
coverUrl: '',
duration,
createdAt: new Date(),
tags: [],
audioUrl: track.audio_url,
isPublic: false,
} as Song}
isCurrent={false}
isSelected={false}
isSelectionMode={false}
isChecked={false}
isLiked={false}
isPlaying={false}
isOwner={false}
onPlay={() => onPlay(track.audio_url, title)}
onSelect={() => onPlay(track.audio_url, title)}
onToggleSelect={() => undefined}
onToggleLike={() => undefined}
onAddToPlaylist={() => undefined}
onOpenVideo={() => undefined}
onShowDetails={() => undefined}
onNavigateToProfile={() => undefined}
onReusePrompt={undefined}
onDelete={() => undefined}
onUseAsReference={onUseAsReference}
onCoverSong={onCoverSong}
/>
);
};