Additional drag and drop refinements
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user