Additional drag and drop refinements

This commit is contained in:
riversedge
2026-02-05 12:57:33 -05:00
parent 00c783eea9
commit 9c3a3d7515
4 changed files with 139 additions and 63 deletions
+22
View File
@@ -1081,6 +1081,26 @@ export default function App() {
setMobileShowList(false); setMobileShowList(false);
}; };
const handleUseUploadAsReference = (track: { audio_url: string; filename: string }) => {
setPendingAudioSelection({
target: 'reference',
url: track.audio_url,
title: track.filename.replace(/\.[^/.]+$/, ''),
});
setCurrentView('create');
setMobileShowList(false);
};
const handleCoverUpload = (track: { audio_url: string; filename: string }) => {
setPendingAudioSelection({
target: 'source',
url: track.audio_url,
title: track.filename.replace(/\.[^/.]+$/, ''),
});
setCurrentView('create');
setMobileShowList(false);
};
const handleBackFromPlaylist = () => { const handleBackFromPlaylist = () => {
setViewingPlaylistId(null); setViewingPlaylistId(null);
setCurrentView('library'); setCurrentView('library');
@@ -1232,6 +1252,8 @@ export default function App() {
onDeleteMany={handleDeleteSongs} onDeleteMany={handleDeleteSongs}
onUseAsReference={handleUseAsReference} onUseAsReference={handleUseAsReference}
onCoverSong={handleCoverSong} onCoverSong={handleCoverSong}
onUseUploadAsReference={handleUseUploadAsReference}
onCoverUpload={handleCoverUpload}
/> />
</div> </div>
+6 -4
View File
@@ -705,7 +705,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
}; };
const handleWorkspaceDrop = (e: React.DragEvent<HTMLDivElement>) => { const handleWorkspaceDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.files?.length) { if (e.dataTransfer.files?.length || e.dataTransfer.types.includes('application/x-ace-audio')) {
handleDrop(e, audioTab); handleDrop(e, audioTab);
} }
}; };
@@ -815,9 +815,11 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<div className="absolute inset-0 bg-white/70 dark:bg-black/50 backdrop-blur-sm" /> <div className="absolute inset-0 bg-white/70 dark:bg-black/50 backdrop-blur-sm" />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-2 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white/90 dark:bg-zinc-900/90 px-6 py-5 shadow-xl"> <div className="flex flex-col items-center gap-2 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white/90 dark:bg-zinc-900/90 px-6 py-5 shadow-xl">
<div className={`w-12 h-12 rounded-full ${dragKind === 'audio' ? 'bg-green-500/90' : 'bg-gradient-to-br from-pink-500 to-purple-600'} text-white flex items-center justify-center shadow-lg`}> {dragKind !== 'audio' && (
{dragKind === 'audio' ? <Plus size={22} /> : <Upload size={22} />} <div className="w-12 h-12 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 text-white flex items-center justify-center shadow-lg">
</div> <Upload size={22} />
</div>
)}
<div className="text-sm font-semibold text-zinc-900 dark:text-white"> <div className="text-sm font-semibold text-zinc-900 dark:text-white">
{dragKind === 'audio' ? 'Drop to use audio' : 'Drop to upload'} {dragKind === 'audio' ? 'Drop to use audio' : 'Drop to upload'}
</div> </div>
+23 -17
View File
@@ -189,23 +189,29 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
label="Extract Stems" label="Extract Stems"
onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems} onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems}
/> />
<MenuItem {onReusePrompt && (
icon={<Repeat size={14} />} <MenuItem
label="Reuse Prompt" icon={<Repeat size={14} />}
onClick={() => handleAction(onReusePrompt)} label="Reuse Prompt"
/> onClick={() => handleAction(onReusePrompt)}
<MenuItem />
icon={<Layers size={14} />} )}
label="Use as Reference" {onUseAsReference && (
onClick={() => handleAction(onUseAsReference)} <MenuItem
disabled={!song.audioUrl} icon={<Layers size={14} />}
/> label="Use as Reference"
<MenuItem onClick={() => handleAction(onUseAsReference)}
icon={<Layers size={14} />} disabled={!song.audioUrl}
label="Cover Song" />
onClick={() => handleAction(onCoverSong)} )}
disabled={!song.audioUrl} {onCoverSong && (
/> <MenuItem
icon={<Layers size={14} />}
label="Cover Song"
onClick={() => handleAction(onCoverSong)}
disabled={!song.audioUrl}
/>
)}
<MenuDivider /> <MenuDivider />
+88 -42
View File
@@ -25,6 +25,8 @@ interface SongListProps {
onDeleteMany?: (songs: Song[]) => void; onDeleteMany?: (songs: Song[]) => void;
onUseAsReference?: (song: Song) => void; onUseAsReference?: (song: Song) => void;
onCoverSong?: (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 ... // ... existing code ...
@@ -41,6 +43,38 @@ const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [
{ id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> }, { 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> = ({ export const SongList: React.FC<SongListProps> = ({
songs, songs,
currentSong, currentSong,
@@ -59,7 +93,9 @@ export const SongList: React.FC<SongListProps> = ({
onDelete, onDelete,
onDeleteMany, onDeleteMany,
onUseAsReference, onUseAsReference,
onCoverSong onCoverSong,
onUseUploadAsReference,
onCoverUpload
}) => { }) => {
const { user } = useAuth(); const { user } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -350,6 +386,8 @@ export const SongList: React.FC<SongListProps> = ({
isPublic: false, isPublic: false,
} as Song); } as Song);
}} }}
onUseAsReference={() => onUseUploadAsReference?.(item.track)}
onCoverSong={() => onCoverUpload?.(item.track)}
/> />
) )
)) ))
@@ -422,6 +460,18 @@ const SongItem: React.FC<SongItemProps> = ({
title: song.title || 'Untitled', title: song.title || 'Untitled',
source: 'song', 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' : ''}`} 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' : ''}`}
> >
@@ -617,7 +667,7 @@ const SongItem: React.FC<SongItemProps> = ({
onClose={() => setShowDropdown(false)} onClose={() => setShowDropdown(false)}
isOwner={isOwner} isOwner={isOwner}
onCreateVideo={() => onOpenVideo?.(song)} onCreateVideo={() => onOpenVideo?.(song)}
onReusePrompt={() => onReusePrompt?.(song)} onReusePrompt={onReusePrompt ? () => onReusePrompt?.(song) : undefined}
onAddToPlaylist={() => onAddToPlaylist?.(song)} onAddToPlaylist={() => onAddToPlaylist?.(song)}
onDelete={() => onDelete?.(song)} onDelete={() => onDelete?.(song)}
onShare={() => setShareModalOpen(true)} onShare={() => setShareModalOpen(true)}
@@ -651,50 +701,46 @@ const SongItem: React.FC<SongItemProps> = ({
const UploadItem: React.FC<{ const UploadItem: React.FC<{
track: { id: string; filename: string; audio_url: string; duration?: number | null }; track: { id: string; filename: string; audio_url: string; duration?: number | null };
onPlay: (audioUrl: string, title: string) => void; onPlay: (audioUrl: string, title: string) => void;
}> = ({ track, onPlay }) => { onUseAsReference?: () => void;
onCoverSong?: () => void;
}> = ({ track, onPlay, onUseAsReference, onCoverSong }) => {
const title = track.filename.replace(/\.[^/.]+$/, ''); const title = track.filename.replace(/\.[^/.]+$/, '');
const duration = track.duration const duration = track.duration
? `${Math.floor(track.duration / 60)}:${String(Math.floor(track.duration % 60)).padStart(2, '0')}` ? `${Math.floor(track.duration / 60)}:${String(Math.floor(track.duration % 60)).padStart(2, '0')}`
: '--:--'; : '--:--';
return ( return (
<div <SongItem
className="group flex items-center gap-4 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-[#18181b] transition-all cursor-grab active:cursor-grabbing border border-transparent" song={{
draggable id: `upload_${track.id}`,
onDragStart={(e) => { title,
e.dataTransfer.effectAllowed = 'copy'; lyrics: '',
e.dataTransfer.setData('application/x-ace-audio', JSON.stringify({ style: 'Upload',
url: track.audio_url, coverUrl: '',
title, duration,
source: 'upload', createdAt: new Date(),
})); tags: [],
}} audioUrl: track.audio_url,
> isPublic: false,
<div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm"> } as Song}
<AlbumCover seed={track.id || title} size="full" className="w-full h-full opacity-40" /> isCurrent={false}
<div isSelected={false}
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer transition-opacity" isSelectionMode={false}
onClick={(e) => { isChecked={false}
e.stopPropagation(); isLiked={false}
onPlay(track.audio_url, title); isPlaying={false}
}} isOwner={false}
> onPlay={() => onPlay(track.audio_url, title)}
<div className="w-9 h-9 rounded-full bg-white flex items-center justify-center shadow-lg"> onSelect={() => onPlay(track.audio_url, title)}
<Play fill="black" className="text-black ml-0.5 w-4 h-4" /> onToggleSelect={() => undefined}
</div> onToggleLike={() => undefined}
</div> onAddToPlaylist={() => undefined}
</div> onOpenVideo={() => undefined}
onShowDetails={() => undefined}
<div className="flex-1 min-w-0"> onNavigateToProfile={() => undefined}
<div className="text-sm font-bold text-zinc-900 dark:text-white truncate"> onReusePrompt={undefined}
{title} onDelete={() => undefined}
</div> onUseAsReference={onUseAsReference}
<div className="text-[11px] text-zinc-400 mt-1">Upload</div> onCoverSong={onCoverSong}
</div> />
<div className="text-xs font-mono text-zinc-500 dark:text-zinc-600 self-start pt-1">
{duration}
</div>
</div>
); );
}; };