diff --git a/App.tsx b/App.tsx index 0f8bdfd..2ef185c 100644 --- a/App.tsx +++ b/App.tsx @@ -15,6 +15,7 @@ import { Song, GenerationParams, View, Playlist } from './types'; import { generateApi, songsApi, playlistsApi, getAudioUrl } from './services/api'; import { useAuth } from './context/AuthContext'; import { useResponsive } from './context/ResponsiveContext'; +import { lmService } from './services/lmService'; import { List } from 'lucide-react'; import { PlaylistDetail } from './components/PlaylistDetail'; import { Toast, ToastType } from './components/Toast'; @@ -28,9 +29,10 @@ export default function App() { // Auth const { user, token, isAuthenticated, isLoading: authLoading, setupUser, logout } = useAuth(); const [showUsernameModal, setShowUsernameModal] = useState(false); - // Track multiple concurrent generation jobs - const activeJobsRef = useRef }>>(new Map()); + const activeJobsRef = useRef>(new Map()); const [activeJobCount, setActiveJobCount] = useState(0); + const radioStartedRef = useRef(false); + const generationLockRef = useRef(false); // Theme State const [theme, setTheme] = useState<'dark' | 'light'>(() => { @@ -62,6 +64,8 @@ export default function App() { const [volume, setVolume] = useState(0.8); const [isShuffle, setIsShuffle] = useState(false); const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('all'); + const [isRadioMode, setIsRadioMode] = useState(false); + const [radioParams, setRadioParams] = useState(null); // UI State const [isGenerating, setIsGenerating] = useState(false); @@ -97,7 +101,7 @@ export default function App() { const audioRef = useRef(null); const pendingSeekRef = useRef(null); - const playNextRef = useRef<() => void>(() => {}); + const playNextRef = useRef<() => void>(() => { }); // Mobile Details Modal State const [showMobileDetails, setShowMobileDetails] = useState(false); @@ -328,6 +332,35 @@ export default function App() { loadSongs(); }, [isAuthenticated, token]); + // Re-sort songs when Radio Mode changes to ensure correct display order + useEffect(() => { + setSongs(prev => { + const sorted = [...prev].sort((a, b) => { + const timeA = new Date(a.createdAt).getTime(); + const timeB = new Date(b.createdAt).getTime(); + return isRadioMode ? timeA - timeB : timeB - timeA; + }); + return sorted; + }); + }, [isRadioMode]); + + // Sync currentSong with updated song references from songs list + // This is crucial for Radio Mode to pick up audioUrl when generation finishes + useEffect(() => { + if (currentSong) { + const updated = songs.find(s => s.id === currentSong.id); + if (updated && updated !== currentSong) { + setCurrentSong(updated); + } + } + if (selectedSong) { + const updated = songs.find(s => s.id === selectedSong.id); + if (updated && updated !== selectedSong) { + setSelectedSong(updated); + } + } + }, [songs, currentSong, selectedSong]); + const loadReferenceTracks = useCallback(async () => { if (!isAuthenticated || !token) return; try { @@ -362,15 +395,58 @@ export default function App() { const playNext = useCallback(() => { if (!currentSong) return; - const queue = getActiveQueue(currentSong); + + // In Radio Mode, always use the dynamic 'songs' list as the source of truth. + // This ensures newly generated songs are immediately picked up. + // Otherwise, use the active queue. + const queue = isRadioMode ? songs : getActiveQueue(currentSong); + if (queue.length === 0) return; - const currentIndex = queueIndex >= 0 && queue[queueIndex]?.id === currentSong.id - ? queueIndex - : queue.findIndex(s => s.id === currentSong.id); + // Find current index in the chosen queue + const currentIndex = queue.findIndex(s => s.id === currentSong.id); if (currentIndex === -1) return; - if (repeatMode === 'one') { + // Determine Next Index + let nextIndex; + if (isShuffle && !isRadioMode) { // Shuffle makes no sense in Radio Mode (chronological) + // Simple shuffle logic + nextIndex = Math.floor(Math.random() * queue.length); + // Avoid repeating same song if possible + if (queue.length > 1 && nextIndex === currentIndex) { + nextIndex = (nextIndex + 1) % queue.length; + } + } else { + // Sequential + nextIndex = (currentIndex + 1) % queue.length; + } + + // Radio Mode Logic: Stop at end of list (don't wrap to old songs) + if (isRadioMode) { + // If we wrapped around to 0, it means we reached the end. + // In "Append" mode (Oldest First), the newest song is at end. + // If we are at end, we wait for next song (which should be generating). + if (nextIndex === 0 && currentIndex === queue.length - 1) { + // End of list. Stop playing. The 'useEffect' trigger should pick up when new song arrives? + // Or if new song IS ready (already appended), nextIndex wouldn't be 0 yet? + // Wait, (currentIndex + 1) % length. + // If length is 5. Current is 4. Next is 0. + // We want to stop. + setIsPlaying(false); + return; + } + } + + const nextSong = queue[nextIndex]; + + // Safety: Don't play if generating or invalid + if (!nextSong || (isRadioMode && (nextSong.isGenerating || !nextSong.audioUrl))) { + setIsPlaying(false); + return; + } + + // Normal Transition + if (repeatMode === 'one' && !isRadioMode) { if (audioRef.current) { audioRef.current.currentTime = 0; audioRef.current.play(); @@ -378,20 +454,10 @@ export default function App() { return; } - let nextIndex; - if (isShuffle) { - do { - nextIndex = Math.floor(Math.random() * queue.length); - } while (queue.length > 1 && nextIndex === currentIndex); - } else { - nextIndex = (currentIndex + 1) % queue.length; - } - - const nextSong = queue[nextIndex]; setQueueIndex(nextIndex); setCurrentSong(nextSong); setIsPlaying(true); - }, [currentSong, queueIndex, isShuffle, repeatMode, playQueue, songs]); + }, [currentSong, queueIndex, isShuffle, repeatMode, playQueue, songs, isRadioMode]); const playPrevious = useCallback(() => { if (!currentSong) return; @@ -423,6 +489,78 @@ export default function App() { playNextRef.current = playNext; }, [playNext]); + // Radio Mode Logic: Trigger next generation + const generateNextRadioSong = useCallback(async (baseParams: GenerationParams) => { + // Robust Locking + if (generationLockRef.current || isGenerating) return; + generationLockRef.current = true; + + try { + // 1. Force batch size to 1 for Radio mode + const newParams = { ...baseParams, batchSize: 1 }; + + // 2. Extract topic + const topic = newParams.songDescription || newParams.style || "Free style"; + + // 3. Generate new Style/Lyrics/Title using lmService + setIsGenerating(true); + + const style = await lmService.generateStyle(topic); + const lyrics = await lmService.generateLyrics(topic, style); + const title = await lmService.generateTitle(topic); + + // 4. Update params with new unique content + newParams.style = style; + newParams.lyrics = lyrics; + newParams.title = title; + newParams.prompt = lyrics; + + // 5. Trigger generation + // setIsGenerating(false); // REMOVED: Caused race condition re-triggering useEffect + + await handleGenerate(newParams); + + } catch (error) { + console.error("Failed to generate next radio song:", error); + setIsGenerating(false); + await handleGenerate({ ...baseParams, batchSize: 1 }); + } finally { + // Release lock after initiation + generationLockRef.current = false; + } + }, [isGenerating, isRadioMode]); + + useEffect(() => { + if (!isRadioMode || !radioParams) return; + + // Case 1: Initial Start via Ref (prevents double generation) + if (radioStartedRef.current) { + radioStartedRef.current = false; + generateNextRadioSong(radioParams); + return; + } + + // Case 2: Standard Loop + // Only buffer if playing, not currently generating, and we have songs in the list. + if (isPlaying && currentSong && !isGenerating && !generationLockRef.current && songs.length > 0) { + // If we are playing the LATEST song, generate the next one. + /* Loop Logic */ + if (songs.length > 0) { + const newestSong = songs[songs.length - 1]; // Oldest First -> Last is newest + + // Strict Double-Generation Guard: + // If the newest song is ALREADY generating (temp), do NOT trigger another one. + if (newestSong.isGenerating) return; + + if (newestSong && newestSong.id === currentSong.id) { + // Current song is the last one. Generate next. + // Pass params but force batch 1 + generateNextRadioSong(radioParams); + } + } + } + }, [isRadioMode, isPlaying, currentSong, songs, radioParams, isGenerating, generateNextRadioSong]); + // Audio Setup useEffect(() => { audioRef.current = new Audio(); @@ -509,7 +647,12 @@ export default function App() { } }; - if (audio.src !== currentSong.audioUrl) { + // Normalize URLs for comparison to avoid unnecessary reloads + const getAbsoluteUrl = (url: string) => new URL(url, window.location.href).href; + const currentSrc = getAbsoluteUrl(audio.src); + const targetSrc = getAbsoluteUrl(currentSong.audioUrl); + + if (currentSrc !== targetSrc) { audio.src = currentSong.audioUrl; audio.load(); if (isPlaying) playAudio(); @@ -551,7 +694,7 @@ export default function App() { if (!token) return; try { const response = await songsApi.getMySongs(token); - const loadedSongs: Song[] = response.songs.map(s => ({ + let fetchedSongs: Song[] = response.songs.map(s => ({ id: s.id, title: s.title, lyrics: s.lyrics, @@ -568,35 +711,52 @@ export default function App() { creator: s.creator, generationParams: (() => { try { - if (!s.generation_params) return undefined; - return typeof s.generation_params === 'string' ? JSON.parse(s.generation_params) : s.generation_params; + if (s.generation_params) { + return typeof s.generation_params === 'string' ? JSON.parse(s.generation_params) : s.generation_params; + } + return undefined; } catch { return undefined; } })(), })); - // Preserve any generating songs that aren't in the loaded list setSongs(prev => { - const generatingSongs = prev.filter(s => s.isGenerating); - const mergedSongs = [...generatingSongs]; - for (const song of loadedSongs) { - if (!mergedSongs.some(s => s.id === song.id)) { - mergedSongs.push(song); - } - } - // Sort by creation date, newest first - return mergedSongs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + // 1. Keep any local songs that are currently generating (temp songs) + // AND are not already in the fetched list + const generatingSongs = prev.filter(p => + p.isGenerating && !fetchedSongs.some(f => f.id === p.id) + ); + + // 2. Merge: Fetched Songs + Generating Songs + let mergedSongs = [...fetchedSongs, ...generatingSongs]; + + // 3. Sort based on mode + // ALWAYS sort by CreatedAt. + // Normal Mode: Newest First (Desc) + // Radio Mode: Oldest First (Asc) -> So "End" is "Newest" + mergedSongs.sort((a, b) => { + const timeA = new Date(a.createdAt).getTime(); + const timeB = new Date(b.createdAt).getTime(); + return isRadioMode ? timeA - timeB : timeB - timeA; + }); + + return mergedSongs; }); - // If the current selection was a temp/generating song, replace it with newest real song - if (selectedSong?.isGenerating || (selectedSong && !loadedSongs.some(s => s.id === selectedSong.id))) { - setSelectedSong(loadedSongs[0] ?? null); - } + // Update selected song if needed (e.g. if it was just loaded) + // Note: We avoid doing this in the setSongs callback to keep it pure. + // But we can't easily access the NEW state here. + // Just relying on ID persistence is usually enough. + } catch (error) { - console.error('Failed to refresh songs:', error); + console.error('Failed to load songs', error); + if (error instanceof Error && error.message === 'Unauthorized') { + logout(); + } } - }, [token]); + }, [token, logout, isRadioMode]); + const beginPollingJob = useCallback((jobId: string, tempId: string) => { if (!token) return; @@ -672,8 +832,13 @@ export default function App() { } setIsGenerating(true); - setCurrentView('create'); - setMobileShowList(false); + + // In Radio Mode, avoid layout thrashing or changing view, + // to prevent audio element unmounting or UI jumping. + if (!isRadioMode) { + setCurrentView('create'); + setMobileShowList(false); + } // Create unique temp ID for this job const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -690,9 +855,22 @@ export default function App() { isPublic: true }; - setSongs(prev => [tempSong, ...prev]); - setSelectedSong(tempSong); - setShowRightSidebar(true); + setSongs(prev => { + const newSongs = [...prev, tempSong]; + // Enforce consistent sorting immediately + return newSongs.sort((a, b) => { + const timeA = new Date(a.createdAt).getTime(); + const timeB = new Date(b.createdAt).getTime(); + return isRadioMode ? timeA - timeB : timeB - timeA; + }); + }); + + // Only select and show sidebar in normal mode. + // In Radio mode, we want to keep listening to the CURRENT song, not jump to the new temp one. + if (!isRadioMode) { + setSelectedSong(tempSong); + setShowRightSidebar(true); + } try { const job = await generateApi.startGeneration({ @@ -799,10 +977,16 @@ export default function App() { } })(); - next.unshift(buildTempSongFromParams(params, tempId, job.created_at)); + next.push(buildTempSongFromParams(params, tempId, job.created_at)); existingIds.add(tempId); } - return next; + + // Sort resumed jobs correctly + return next.sort((a, b) => { + const timeA = new Date(a.createdAt).getTime(); + const timeB = new Date(b.createdAt).getTime(); + return isRadioMode ? timeA - timeB : timeB - timeA; + }); }); for (const job of jobsToResume) { @@ -828,8 +1012,8 @@ export default function App() { const nextQueue = list && list.length > 0 ? list : (playQueue.length > 0 && playQueue.some(s => s.id === song.id)) - ? playQueue - : (songs.some(s => s.id === song.id) ? songs : [song]); + ? playQueue + : (songs.some(s => s.id === song.id) ? songs : [song]); const nextIndex = nextQueue.findIndex(s => s.id === song.id); setPlayQueue(nextQueue); setQueueIndex(nextIndex); @@ -1219,6 +1403,19 @@ export default function App() { `}> { + setIsRadioMode(true); + setRadioParams(params); + radioStartedRef.current = true; + // Let useEffect handle the first generation to prevent double triggers + showToast('Radio Mode Started! Continuous music coming up.', 'success'); + }} + onStopRadio={() => { + setIsRadioMode(false); + setRadioParams(null); + showToast('Radio Mode Stopped.', 'success'); + }} + isRadioMode={isRadioMode} isGenerating={isGenerating} initialData={reuseData} createdSongs={songs} diff --git a/components/CreatePanel.tsx b/components/CreatePanel.tsx index a041000..2cfebab 100644 --- a/components/CreatePanel.tsx +++ b/components/CreatePanel.tsx @@ -3,6 +3,7 @@ import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, import { GenerationParams, Song } from '../types'; import { useAuth } from '../context/AuthContext'; import { generateApi } from '../services/api'; +import { lmService } from '../services/lmService'; interface ReferenceTrack { id: string; @@ -22,6 +23,9 @@ interface CreatePanelProps { createdSongs?: Song[]; pendingAudioSelection?: { target: 'reference' | 'source'; url: string; title?: string } | null; onAudioSelectionApplied?: () => void; + onStartRadio?: (params: GenerationParams) => void; + onStopRadio?: () => void; + isRadioMode?: boolean; } const KEY_SIGNATURES = [ @@ -108,6 +112,9 @@ export const CreatePanel: React.FC = ({ createdSongs = [], pendingAudioSelection, onAudioSelectionApplied, + onStartRadio, + onStopRadio, + isRadioMode = false, }) => { const { isAuthenticated, token, user } = useAuth(); @@ -202,6 +209,9 @@ export const CreatePanel: React.FC = ({ const [uploadError, setUploadError] = useState(null); const [isFormattingStyle, setIsFormattingStyle] = useState(false); const [isFormattingLyrics, setIsFormattingLyrics] = useState(false); + const [isGeneratingStyle, setIsGeneratingStyle] = useState(false); + const [isGeneratingLyrics, setIsGeneratingLyrics] = useState(false); + const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); const [isDraggingFile, setIsDraggingFile] = useState(false); const [dragKind, setDragKind] = useState<'file' | 'audio' | null>(null); const referenceInputRef = useRef(null); @@ -451,6 +461,69 @@ export const CreatePanel: React.FC = ({ e.target.value = ''; }; + const handleGenerateLyrics = async () => { + if (!songDescription && !style) { + alert("Please provide a song description or style first."); + return; + } + setIsGeneratingLyrics(true); + try { + const topic = songDescription || style; + const result = await lmService.generateLyrics(topic, style); + if (result) { + setLyrics(result); + setCustomMode(true); + } + } catch (err) { + console.error(err); + alert("Failed to generate lyrics: " + (err instanceof Error ? err.message : String(err))); + } finally { + setIsGeneratingLyrics(false); + } + }; + + const handleGenerateStyle = async () => { + if (!songDescription && !lyrics) { + alert("Please provide a song description or lyrics first."); + return; + } + setIsGeneratingStyle(true); + try { + const topic = songDescription || lyrics; + const result = await lmService.generateStyle(topic); + if (result) { + setStyle(result); + setCustomMode(true); + } + } catch (err) { + console.error(err); + alert("Failed to generate style: " + (err instanceof Error ? err.message : String(err))); + } finally { + setIsGeneratingStyle(false); + } + }; + + const handleGenerateTitle = async () => { + if (!songDescription && !lyrics && !style) { + alert("Please provide some information about the song first."); + return; + } + setIsGeneratingTitle(true); + try { + const topic = songDescription || lyrics || style; + const result = await lmService.generateTitle(topic); + if (result) { + setTitle(result); + setCustomMode(true); + } + } catch (err) { + console.error(err); + alert("Failed to generate title: " + (err instanceof Error ? err.message : String(err))); + } finally { + setIsGeneratingTitle(false); + } + }; + // Format handler - uses LLM to enhance style/lyrics and auto-fill parameters const handleFormat = async (target: 'style' | 'lyrics') => { if (!token || !style.trim()) return; @@ -460,19 +533,36 @@ export const CreatePanel: React.FC = ({ setIsFormattingLyrics(true); } try { - const result = await generateApi.formatInput({ - caption: style, - lyrics: lyrics, - bpm: bpm > 0 ? bpm : undefined, - duration: duration > 0 ? duration : undefined, - keyScale: keyScale || undefined, - timeSignature: timeSignature || undefined, - temperature: lmTemperature, - topK: lmTopK > 0 ? lmTopK : undefined, - topP: lmTopP, - lmModel: lmModel || 'acestep-5Hz-lm-0.6B', - lmBackend: lmBackend || 'pt', - }, token); + let result; + // Try Client-side Gemini first + const settings = lmService.getSettings(); + if (settings.backend === 'gemini' && settings.geminiApiKey) { + result = await lmService.formatInput({ + caption: style, + lyrics: lyrics, + bpm: bpm > 0 ? bpm : undefined, + duration: duration > 0 ? duration : undefined, + keyScale: keyScale || undefined, + timeSignature: timeSignature || undefined + }); + } + + // If not Gemini or Gemini failed (signaled by FALLBACK_TO_SERVER or null), use Server API + if (!result || (result.error === 'FALLBACK_TO_SERVER')) { + result = await generateApi.formatInput({ + caption: style, + lyrics: lyrics, + bpm: bpm > 0 ? bpm : undefined, + duration: duration > 0 ? duration : undefined, + keyScale: keyScale || undefined, + timeSignature: timeSignature || undefined, + temperature: lmTemperature, + topK: lmTopK > 0 ? lmTopK : undefined, + topP: lmTopP, + lmModel: lmModel || 'acestep-5Hz-lm-0.6B', + lmBackend: lmBackend || 'pt', + }, token); + } if (result.success) { // Update fields with LLM-generated values @@ -728,7 +818,7 @@ export const CreatePanel: React.FC = ({ } }; - const handleGenerate = () => { + const executeGenerate = (mode: 'normal' | 'radio') => { const styleWithGender = (() => { if (!vocalGender) return style; const genderHint = vocalGender === 'male' ? 'Male vocals' : 'Female vocals'; @@ -736,24 +826,25 @@ export const CreatePanel: React.FC = ({ return trimmed ? `${trimmed}\n${genderHint}` : genderHint; })(); - // Bulk generation: loop bulkCount times - for (let i = 0; i < bulkCount; i++) { + const count = mode === 'radio' ? 1 : bulkCount; + + // Bulk generation: loop bulkCount times (or once for radio) + for (let i = 0; i < count; i++) { // Seed handling: first job uses user's seed, rest get random seeds let jobSeed = -1; if (!randomSeed && i === 0) { jobSeed = seed; } else if (!randomSeed && i > 0) { - // Subsequent jobs get random seeds for variety jobSeed = Math.floor(Math.random() * 4294967295); } - onGenerate({ + const params = { customMode, songDescription: customMode ? undefined : songDescription, prompt: lyrics, lyrics, style: styleWithGender, - title: bulkCount > 1 ? `${title} (${i + 1})` : title, + title: count > 1 ? `${title} (${i + 1})` : title, instrumental, vocalLanguage, bpm, @@ -762,7 +853,7 @@ export const CreatePanel: React.FC = ({ duration, inferenceSteps, guidanceScale, - batchSize, + batchSize: mode === 'radio' ? 1 : batchSize, randomSeed: randomSeed || i > 0, // Force random for subsequent bulk jobs seed: jobSeed, thinking, @@ -809,15 +900,24 @@ export const CreatePanel: React.FC = ({ return parsed.length ? parsed : undefined; })(), isFormatCaption, - }); + }; + + if (mode === 'radio' && onStartRadio) { + onStartRadio(params); + } else { + onGenerate(params); + } } // Reset bulk count after generation - if (bulkCount > 1) { + if (bulkCount > 1 && mode === 'normal') { setBulkCount(1); } }; + const handleGenerate = () => executeGenerate('normal'); + const handleRadio = () => executeGenerate('radio'); + return (
= ({ placeholder="A happy pop song about summer adventures with friends..." className="w-full h-32 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none" /> +
+ + +
{/* Vocal Language (Simple) */} @@ -1066,22 +1184,20 @@ export const CreatePanel: React.FC = ({ @@ -1100,9 +1216,9 @@ export const CreatePanel: React.FC = ({ className="relative flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 text-white flex items-center justify-center shadow-lg shadow-pink-500/20 hover:scale-105 transition-transform" > {referencePlaying ? ( - + ) : ( - + )} {formatTime(referenceDuration)} @@ -1139,7 +1255,7 @@ export const CreatePanel: React.FC = ({ onClick={() => { setReferenceAudioUrl(''); setReferenceAudioTitle(''); setReferencePlaying(false); setReferenceTime(0); setReferenceDuration(0); }} className="p-1.5 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-600 dark:hover:text-white transition-colors" > - + )} @@ -1153,9 +1269,9 @@ export const CreatePanel: React.FC = ({ className="relative flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 text-white flex items-center justify-center shadow-lg shadow-emerald-500/20 hover:scale-105 transition-transform" > {sourcePlaying ? ( - + ) : ( - + )} {formatTime(sourceDuration)} @@ -1192,7 +1308,7 @@ export const CreatePanel: React.FC = ({ onClick={() => { setSourceAudioUrl(''); setSourceAudioTitle(''); setSourcePlaying(false); setSourceTime(0); setSourceDuration(0); }} className="p-1.5 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-600 dark:hover:text-white transition-colors" > - + )} @@ -1205,7 +1321,7 @@ export const CreatePanel: React.FC = ({ className="flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors" > - + Library @@ -1221,7 +1337,7 @@ export const CreatePanel: React.FC = ({ className="flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors" > - + Upload @@ -1243,14 +1359,21 @@ export const CreatePanel: React.FC = ({
+
- +
+ + +