Multi select and cover/reference is playable
This commit is contained in:
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user