diff --git a/App.tsx b/App.tsx index 522142e..169f798 100644 --- a/App.tsx +++ b/App.tsx @@ -46,6 +46,7 @@ export default function App() { const [songs, setSongs] = useState([]); const [playlists, setPlaylists] = useState([]); const [likedSongIds, setLikedSongIds] = useState>(new Set()); + const [referenceTracks, setReferenceTracks] = useState([]); const [playQueue, setPlayQueue] = useState([]); const [queueIndex, setQueueIndex] = useState(-1); @@ -107,6 +108,17 @@ export default function App() { isVisible: false, }); + interface ReferenceTrack { + id: string; + filename: string; + storage_key: string; + duration: number | null; + file_size_bytes: number | null; + tags: string[] | null; + created_at: string; + audio_url: string; + } + const showToast = (message: string, type: ToastType = 'success') => { setToast({ message, type, isVisible: true }); }; @@ -307,6 +319,31 @@ export default function App() { loadSongs(); }, [isAuthenticated, token]); + const loadReferenceTracks = useCallback(async () => { + if (!isAuthenticated || !token) return; + try { + const response = await fetch('/api/reference-tracks', { + headers: { Authorization: `Bearer ${token}` } + }); + if (!response.ok) return; + const data = await response.json(); + setReferenceTracks(data.tracks || []); + } catch (error) { + console.error('Failed to load reference tracks:', error); + } + }, [isAuthenticated, token]); + + // Load reference tracks for Library + useEffect(() => { + loadReferenceTracks(); + }, [loadReferenceTracks]); + + useEffect(() => { + if (currentView === 'library') { + loadReferenceTracks(); + } + }, [currentView, loadReferenceTracks]); + // Player Logic const getActiveQueue = (song?: Song) => { if (playQueue.length > 0) return playQueue; @@ -816,6 +853,26 @@ export default function App() { } }; + const handleDeleteReferenceTrack = async (trackId: string) => { + if (!token) return; + const confirmed = window.confirm('Delete this upload? This action cannot be undone.'); + if (!confirmed) return; + try { + const response = await fetch(`/api/reference-tracks/${trackId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + if (!response.ok) { + throw new Error('Failed to delete upload'); + } + setReferenceTracks(prev => prev.filter(track => track.id !== trackId)); + showToast('Upload deleted successfully'); + } catch (error) { + console.error('Failed to delete upload:', error); + showToast('Failed to delete upload', 'error'); + } + }; + const createPlaylist = async (name: string, description: string) => { if (!token) return; try { @@ -882,19 +939,28 @@ export default function App() { // Render Layout Logic const renderContent = () => { switch (currentView) { - case 'library': + case 'library': { + const allSongs = user ? songs.filter(s => s.userId === user.id) : []; return ( likedSongIds.has(s.id))} playlists={playlists} + referenceTracks={referenceTracks} onPlaySong={playSong} onCreatePlaylist={() => { setSongToAddToPlaylist(null); setIsCreatePlaylistModalOpen(true); }} onSelectPlaylist={(p) => handleNavigateToPlaylist(p.id)} + onAddToPlaylist={openAddToPlaylistModal} + onOpenVideo={openVideoGenerator} + onReusePrompt={handleReuse} + onDeleteSong={handleDeleteSong} + onDeleteReferenceTrack={handleDeleteReferenceTrack} /> ); + } case 'profile': if (!viewingUsername) return null; diff --git a/components/CreatePanel.tsx b/components/CreatePanel.tsx index 75cdc33..d4f9825 100644 --- a/components/CreatePanel.tsx +++ b/components/CreatePanel.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause } from 'lucide-react'; +import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react'; import { GenerationParams, Song } from '../types'; import { useAuth } from '../context/AuthContext'; import { generateApi } from '../services/api'; @@ -174,8 +174,10 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati const [isUploadingSource, setIsUploadingSource] = useState(false); const [uploadError, setUploadError] = useState(null); const [isFormatting, setIsFormatting] = useState(false); + const [isDraggingFile, setIsDraggingFile] = useState(false); const referenceInputRef = useRef(null); const sourceInputRef = useRef(null); + const dragDepthRef = useRef(0); const [showAudioModal, setShowAudioModal] = useState(false); const [audioModalTarget, setAudioModalTarget] = useState<'reference' | 'source'>('reference'); const [tempAudioUrl, setTempAudioUrl] = useState(''); @@ -273,6 +275,50 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }; }, [isResizing]); + useEffect(() => { + const isFileDrag = (e: DragEvent) => + !!(e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')); + + const handleDragEnter = (e: DragEvent) => { + if (!isFileDrag(e)) return; + dragDepthRef.current += 1; + setIsDraggingFile(true); + e.preventDefault(); + }; + + const handleDragOver = (e: DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + }; + + const handleDragLeave = (e: DragEvent) => { + if (!isFileDrag(e)) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDraggingFile(false); + } + }; + + const handleDrop = (e: DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + dragDepthRef.current = 0; + setIsDraggingFile(false); + }; + + window.addEventListener('dragenter', handleDragEnter); + window.addEventListener('dragover', handleDragOver); + window.addEventListener('dragleave', handleDragLeave); + window.addEventListener('drop', handleDrop); + + return () => { + window.removeEventListener('dragenter', handleDragEnter); + window.removeEventListener('dragover', handleDragOver); + window.removeEventListener('dragleave', handleDragLeave); + window.removeEventListener('drop', handleDrop); + }; + }, []); + const startResizing = (e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); @@ -303,13 +349,13 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati const handleFileSelect = (e: React.ChangeEvent, target: 'reference' | 'source') => { const file = e.target.files?.[0]; if (file) { - void uploadAudio(file, target); + void uploadReferenceTrack(file, target); } e.target.value = ''; }; - // Format handler - uses LLM to enhance style and auto-fill parameters - const handleFormat = async () => { + // Format handler - uses LLM to enhance style/lyrics and auto-fill parameters + const handleFormat = async (target: 'style' | 'lyrics') => { if (!token || !style.trim()) return; setIsFormatting(true); try { @@ -327,14 +373,14 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati if (result.success) { // Update fields with LLM-generated values - if (result.caption) setStyle(result.caption); - if (result.lyrics) setLyrics(result.lyrics); + if (target === 'style' && result.caption) setStyle(result.caption); + if (target === 'lyrics' && result.lyrics) setLyrics(result.lyrics); if (result.bpm && result.bpm > 0) setBpm(result.bpm); if (result.duration && result.duration > 0) setDuration(result.duration); if (result.key_scale) setKeyScale(result.key_scale); if (result.time_signature) setTimeSignature(result.time_signature); if (result.language) setVocalLanguage(result.language); - setIsFormatCaption(true); + if (target === 'style') setIsFormatCaption(true); } else { console.error('Format failed:', result.error || result.status_message); alert(result.error || result.status_message || 'Format failed. Make sure the LLM is initialized.'); @@ -372,7 +418,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati } }, [token]); - const uploadReferenceTrack = async (file: File) => { + const uploadReferenceTrack = async (file: File, target?: 'reference' | 'source') => { if (!token) { setUploadError('Please sign in to upload audio.'); return; @@ -398,7 +444,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati setReferenceTracks(prev => [data.track, ...prev]); // Also set as current reference/source - if (audioModalTarget === 'reference') { + const selectedTarget = target ?? audioModalTarget; + if (selectedTarget === 'reference') { setReferenceAudioUrl(data.track.audio_url); } else { setSourceAudioUrl(data.track.audio_url); @@ -494,7 +541,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file) { - void uploadAudio(file, target); + void uploadReferenceTrack(file, target); } }; @@ -502,6 +549,18 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati e.preventDefault(); }; + const handleWorkspaceDrop = (e: React.DragEvent) => { + if (e.dataTransfer.files?.length) { + handleDrop(e, audioTab); + } + }; + + const handleWorkspaceDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + } + }; + const handleGenerate = () => { // Bulk generation: loop bulkCount times for (let i = 0; i < bulkCount; i++) { @@ -582,7 +641,27 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }; return ( -
+
+ {isDraggingFile && ( +
+
+
+
+
+ +
+
Drop to upload
+
+ Uploading as {audioTab === 'reference' ? 'Reference' : 'Cover'} +
+
+
+
+ )}
= ({ onGenerate, isGenerati {instrumental ? 'Instrumental' : 'Vocal'}