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);
};
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 = () => {
setViewingPlaylistId(null);
setCurrentView('library');
@@ -1232,6 +1252,8 @@ export default function App() {
onDeleteMany={handleDeleteSongs}
onUseAsReference={handleUseAsReference}
onCoverSong={handleCoverSong}
onUseUploadAsReference={handleUseUploadAsReference}
onCoverUpload={handleCoverUpload}
/>
</div>
+6 -4
View File
@@ -705,7 +705,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
};
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);
}
};
@@ -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 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={`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' ? <Plus size={22} /> : <Upload size={22} />}
</div>
{dragKind !== 'audio' && (
<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">
<Upload size={22} />
</div>
)}
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
{dragKind === 'audio' ? 'Drop to use audio' : 'Drop to upload'}
</div>
+23 -17
View File
@@ -189,23 +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)}
/>
<MenuItem
icon={<Layers size={14} />}
label="Use as Reference"
onClick={() => handleAction(onUseAsReference)}
disabled={!song.audioUrl}
/>
<MenuItem
icon={<Layers size={14} />}
label="Cover Song"
onClick={() => handleAction(onCoverSong)}
disabled={!song.audioUrl}
/>
{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 />
+88 -42
View File
@@ -25,6 +25,8 @@ interface SongListProps {
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 ...
@@ -41,6 +43,38 @@ 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,
@@ -59,7 +93,9 @@ export const SongList: React.FC<SongListProps> = ({
onDelete,
onDeleteMany,
onUseAsReference,
onCoverSong
onCoverSong,
onUseUploadAsReference,
onCoverUpload
}) => {
const { user } = useAuth();
const [searchQuery, setSearchQuery] = useState('');
@@ -350,6 +386,8 @@ export const SongList: React.FC<SongListProps> = ({
isPublic: false,
} as Song);
}}
onUseAsReference={() => onUseUploadAsReference?.(item.track)}
onCoverSong={() => onCoverUpload?.(item.track)}
/>
)
))
@@ -422,6 +460,18 @@ const SongItem: React.FC<SongItemProps> = ({
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' : ''}`}
>
@@ -617,7 +667,7 @@ 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)}
@@ -651,50 +701,46 @@ 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;
}> = ({ track, onPlay }) => {
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 (
<div
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"
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/x-ace-audio', JSON.stringify({
url: track.audio_url,
title,
source: 'upload',
}));
}}
>
<div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm">
<AlbumCover seed={track.id || title} size="full" className="w-full h-full opacity-40" />
<div
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer transition-opacity"
onClick={(e) => {
e.stopPropagation();
onPlay(track.audio_url, title);
}}
>
<div className="w-9 h-9 rounded-full bg-white flex items-center justify-center shadow-lg">
<Play fill="black" className="text-black ml-0.5 w-4 h-4" />
</div>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-zinc-900 dark:text-white truncate">
{title}
</div>
<div className="text-[11px] text-zinc-400 mt-1">Upload</div>
</div>
<div className="text-xs font-mono text-zinc-500 dark:text-zinc-600 self-start pt-1">
{duration}
</div>
</div>
<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}
/>
);
};