Multi select and cover/reference is playable

This commit is contained in:
riversedge
2026-02-04 23:38:39 -05:00
parent f3e8f092f5
commit 6527c42f41
3 changed files with 210 additions and 13 deletions
+55
View File
@@ -950,6 +950,60 @@ export default function App() {
} }
}; };
const handleDeleteSongs = async (songsToDelete: Song[]) => {
if (!token || songsToDelete.length === 0) return;
const confirmed = window.confirm(
`Delete ${songsToDelete.length} songs? This action cannot be undone.`
);
if (!confirmed) return;
const idsToDelete = new Set(songsToDelete.map(song => song.id));
const succeeded: string[] = [];
const failed: string[] = [];
for (const song of songsToDelete) {
try {
await songsApi.deleteSong(song.id, token);
succeeded.push(song.id);
} catch (error) {
console.error('Failed to delete song:', error);
failed.push(song.id);
}
}
if (succeeded.length > 0) {
setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id)));
setLikedSongIds(prev => {
const next = new Set(prev);
succeeded.forEach(id => next.delete(id));
return next;
});
if (selectedSong?.id && succeeded.includes(selectedSong.id)) {
setSelectedSong(null);
}
if (currentSong?.id && succeeded.includes(currentSong.id)) {
setCurrentSong(null);
setIsPlaying(false);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
}
setPlayQueue(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id)));
}
if (failed.length > 0) {
showToast(`Deleted ${succeeded.length}/${songsToDelete.length} songs`, 'error');
} else {
showToast('Songs deleted successfully');
}
};
const handleDeleteReferenceTrack = async (trackId: string) => { const handleDeleteReferenceTrack = async (trackId: string) => {
if (!token) return; if (!token) return;
const confirmed = window.confirm('Delete this upload? This action cannot be undone.'); const confirmed = window.confirm('Delete this upload? This action cannot be undone.');
@@ -1156,6 +1210,7 @@ export default function App() {
onNavigateToProfile={handleNavigateToProfile} onNavigateToProfile={handleNavigateToProfile}
onReusePrompt={handleReuse} onReusePrompt={handleReuse}
onDelete={handleDeleteSong} onDelete={handleDeleteSong}
onDeleteMany={handleDeleteSongs}
/> />
</div> </div>
+46 -12
View File
@@ -276,12 +276,29 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
</div> </div>
</div> </div>
</div> </div>
<button <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" 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={() => openSource(song.generationParams?.referenceAudioUrl)} onClick={() => {
> if (!song.generationParams?.referenceAudioUrl || !onPlay) return;
Open const previewSong = {
</button> 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> </div>
)} )}
{song.generationParams?.sourceAudioUrl && ( {song.generationParams?.sourceAudioUrl && (
@@ -295,12 +312,29 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
</div> </div>
</div> </div>
</div> </div>
<button <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" 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={() => openSource(song.generationParams?.sourceAudioUrl)} onClick={() => {
> if (!song.generationParams?.sourceAudioUrl || !onPlay) return;
Open const previewSong = {
</button> 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>
+109 -1
View File
@@ -21,6 +21,7 @@ interface SongListProps {
onNavigateToProfile?: (username: string) => void; onNavigateToProfile?: (username: string) => void;
onReusePrompt?: (song: Song) => void; onReusePrompt?: (song: Song) => void;
onDelete?: (song: Song) => void; onDelete?: (song: Song) => void;
onDeleteMany?: (songs: Song[]) => void;
} }
// ... existing code ... // ... existing code ...
@@ -51,12 +52,15 @@ export const SongList: React.FC<SongListProps> = ({
onShowDetails, onShowDetails,
onNavigateToProfile, onNavigateToProfile,
onReusePrompt, onReusePrompt,
onDelete onDelete,
onDeleteMany
}) => { }) => {
const { user } = useAuth(); const { user } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set()); const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
const [isFilterOpen, setIsFilterOpen] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const filterRef = useRef<HTMLDivElement>(null); const filterRef = useRef<HTMLDivElement>(null);
// Close filter dropdown when clicking outside // Close filter dropdown when clicking outside
@@ -70,6 +74,18 @@ export const SongList: React.FC<SongListProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); 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) => { const toggleFilter = (filterId: FilterType) => {
setActiveFilters(prev => { setActiveFilters(prev => {
const newFilters = new Set(prev); const newFilters = new Set(prev);
@@ -104,6 +120,14 @@ export const SongList: React.FC<SongListProps> = ({
}); });
}, [songs, searchQuery, activeFilters, likedSongIds]); }, [songs, searchQuery, activeFilters, likedSongIds]);
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 ( 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="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 */} <div className="max-w-5xl mx-auto w-full"> {/* Container constraint */}
@@ -175,7 +199,57 @@ export const SongList: React.FC<SongListProps> = ({
</div> </div>
)} )}
</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> </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> </div>
{/* List */} {/* List */}
@@ -200,11 +274,22 @@ export const SongList: React.FC<SongListProps> = ({
song={song} song={song}
isCurrent={currentSong?.id === song.id} isCurrent={currentSong?.id === song.id}
isSelected={selectedSong?.id === song.id} isSelected={selectedSong?.id === song.id}
isSelectionMode={isSelecting}
isChecked={selectedIds.has(song.id)}
isLiked={likedSongIds.has(song.id)} isLiked={likedSongIds.has(song.id)}
isPlaying={isPlaying} isPlaying={isPlaying}
isOwner={user?.id === song.userId} isOwner={user?.id === song.userId}
onPlay={() => onPlay(song)} onPlay={() => onPlay(song)}
onSelect={() => onSelect(song)} onSelect={() => onSelect(song)}
onToggleSelect={() => {
if (song.isGenerating) return;
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(song.id)) next.delete(song.id);
else next.add(song.id);
return next;
});
}}
onToggleLike={() => onToggleLike(song.id)} onToggleLike={() => onToggleLike(song.id)}
onAddToPlaylist={() => onAddToPlaylist(song)} onAddToPlaylist={() => onAddToPlaylist(song)}
onOpenVideo={() => onOpenVideo && onOpenVideo(song)} onOpenVideo={() => onOpenVideo && onOpenVideo(song)}
@@ -225,11 +310,14 @@ interface SongItemProps {
song: Song; song: Song;
isCurrent: boolean; isCurrent: boolean;
isSelected: boolean; isSelected: boolean;
isSelectionMode: boolean;
isChecked: boolean;
isLiked: boolean; isLiked: boolean;
isPlaying: boolean; isPlaying: boolean;
isOwner: boolean; isOwner: boolean;
onPlay: () => void; onPlay: () => void;
onSelect: () => void; onSelect: () => void;
onToggleSelect: () => void;
onToggleLike: () => void; onToggleLike: () => void;
onAddToPlaylist: () => void; onAddToPlaylist: () => void;
onOpenVideo?: () => void; onOpenVideo?: () => void;
@@ -243,11 +331,14 @@ const SongItem: React.FC<SongItemProps> = ({
song, song,
isCurrent, isCurrent,
isSelected, isSelected,
isSelectionMode,
isChecked,
isLiked, isLiked,
isPlaying, isPlaying,
isOwner, isOwner,
onPlay, onPlay,
onSelect, onSelect,
onToggleSelect,
onToggleLike, onToggleLike,
onAddToPlaylist, onAddToPlaylist,
onOpenVideo, onOpenVideo,
@@ -266,6 +357,23 @@ const SongItem: React.FC<SongItemProps> = ({
onClick={onSelect} 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'}`} 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'}`}
> >
{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 */} {/* 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"> <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">