diff --git a/server.mjs b/server.mjs index 233da23..0e0f34e 100644 --- a/server.mjs +++ b/server.mjs @@ -82,17 +82,22 @@ console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`); // History helpers // ────────────────────────────────────────────────────────────────────────────── +let historyCache = null; + async function readHistory() { + if (historyCache !== null) return historyCache; try { const raw = await readFile(HISTORY_FILE, 'utf-8'); const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; + historyCache = Array.isArray(parsed) ? parsed : []; } catch { - return []; + historyCache = []; } + return historyCache; } async function writeHistory(entries) { + historyCache = entries; await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8'); } diff --git a/src/App.tsx b/src/App.tsx index 2b015b0..73aad96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,24 @@ +import { Suspense, lazy } from 'react'; import { Route, Routes } from 'react-router-dom'; -import { HomePage } from './pages/HomePage'; -import { SettingsPage } from './pages/SettingsPage'; import { ToastProvider } from './lib/toast'; import { I18nProvider } from './lib/i18n'; import { ThemeProvider } from './lib/theme'; +const HomePage = lazy(() => import('./pages/HomePage').then(module => ({ default: module.HomePage }))); +const SettingsPage = lazy(() => import('./pages/SettingsPage').then(module => ({ default: module.SettingsPage }))); + export default function App() { return ( - - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + + diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx index 1f5511b..d0eca1c 100644 --- a/src/components/InputPanel.tsx +++ b/src/components/InputPanel.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent, type KeyboardEvent } from "react"; +import { useState, useEffect, type FormEvent, type KeyboardEvent } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCircleNotch, @@ -51,7 +51,8 @@ const CRAZY_PAIRS: { idea: string; style: string; mood: string; vocals: Vocals } ]; interface InputPanelProps { - values: InputValues; + initialValues: InputValues; + forcedUpdate?: { count: number; values: InputValues }; onChange: (next: InputValues) => void; onSubmit: () => void; loading: boolean; @@ -60,19 +61,32 @@ interface InputPanelProps { } export function InputPanel({ - values, + initialValues, + forcedUpdate, onChange, onSubmit, loading, disabled, cancelButton, }: InputPanelProps) { + const [values, setValues] = useState(initialValues); const [styleLoading, setStyleLoading] = useState(null); const elapsed = useElapsed(loading); const { t } = useI18n(); - const set = (key: K, value: InputValues[K]) => - onChange({ ...values, [key]: value }); + // Handle external updates (e.g. from loading history) + useEffect(() => { + if (forcedUpdate && forcedUpdate.count > 0) { + setValues(forcedUpdate.values); + onChange(forcedUpdate.values); + } + }, [forcedUpdate, onChange]); + + const set = (key: K, value: InputValues[K]) => { + const next = { ...values, [key]: value }; + setValues(next); + onChange(next); + }; const canSubmit = !loading && !disabled && values.idea.trim().length > 0; @@ -99,22 +113,26 @@ export function InputPanel({ setStyleLoading("normal"); try { const style = await randomStyle("normal", ctrl.signal, ideaToUse); - onChange({ + const nextValues = { ...values, idea: ideaToUse, style_hint: style, mood: hasIdea ? values.mood : ex.mood, vocals: hasIdea ? values.vocals : ex.vocals - }); + }; + setValues(nextValues); + onChange(nextValues); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - onChange({ + const nextValues = { ...values, idea: ideaToUse, style_hint: hasIdea ? values.style_hint : ex.style, mood: hasIdea ? values.mood : ex.mood, vocals: hasIdea ? values.vocals : ex.vocals - }); + }; + setValues(nextValues); + onChange(nextValues); } finally { setStyleLoading((curr) => (curr === "normal" ? null : curr)); } @@ -131,22 +149,26 @@ export function InputPanel({ setStyleLoading("crazy"); try { const style = await randomStyle("crazy", ctrl.signal, ideaToUse); - onChange({ + const nextValues = { ...values, idea: ideaToUse, style_hint: style, mood: hasIdea ? values.mood : ex.mood, vocals: hasIdea ? values.vocals : ex.vocals - }); + }; + setValues(nextValues); + onChange(nextValues); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - onChange({ + const nextValues = { ...values, idea: ideaToUse, style_hint: hasIdea ? values.style_hint : ex.style, mood: hasIdea ? values.mood : ex.mood, vocals: hasIdea ? values.vocals : ex.vocals - }); + }; + setValues(nextValues); + onChange(nextValues); } finally { setStyleLoading((curr) => (curr === "crazy" ? null : curr)); } diff --git a/src/index.css b/src/index.css index dfcee96..d00e95b 100644 --- a/src/index.css +++ b/src/index.css @@ -192,18 +192,21 @@ position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw; background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%); opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none; + will-change: transform; animation: blob-drift-1 18s ease-in-out infinite; } .liquid-blob-2 { position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw; background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%); opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none; + will-change: transform; animation: blob-drift-2 22s ease-in-out infinite; } .liquid-blob-3 { position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw; background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%); opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none; + will-change: transform; animation: blob-drift-3 26s ease-in-out infinite; } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 656027a..062d380 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -26,7 +26,6 @@ import { useI18n } from "../lib/i18n"; import { useTheme } from "../lib/theme"; const DRAFT_KEY = "melodymuse-draft"; -const DRAFT_DEBOUNCE_MS = 400; const DEFAULT_INPUT: InputValues = { idea: "", @@ -103,9 +102,8 @@ export function HomePage() { const { t, lang, setLang } = useI18n(); const { theme, toggleTheme } = useTheme(); - const [input, setInput] = useState( - () => loadDraft() ?? DEFAULT_INPUT, - ); + const inputRef = useRef(loadDraft() ?? DEFAULT_INPUT); + const [forcedUpdate, setForcedUpdate] = useState<{ count: number; values: InputValues }>(); const [assets, setAssets] = useState(null); const [originalAssets, setOriginalAssets] = useState(null); const [loading, setLoading] = useState(false); @@ -119,14 +117,14 @@ export function HomePage() { const abortRef = useRef(null); const anyRegenerating = isAnyBusy(loading, regenerating); - // Save draft + // Save draft periodically useEffect(() => { - const id = window.setTimeout(() => { - try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); } + const id = window.setInterval(() => { + try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(inputRef.current)); } catch { /* ignore */ } - }, DRAFT_DEBOUNCE_MS); - return () => window.clearTimeout(id); - }, [input]); + }, 1000); + return () => window.clearInterval(id); + }, []); // Server health check useEffect(() => { @@ -168,16 +166,19 @@ export function HomePage() { }, [anyRegenerating, cancel]); const buildRequest = useCallback( - (section: string, extra: Record = {}): GenerateCall => ({ - input: input.idea.trim(), - language: resolveLanguage(input), - ...(input.mood.trim() ? { mood: input.mood.trim() } : {}), - ...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}), - vocals: input.vocals, - section, - ...extra, - } as unknown as GenerateCall), - [input], + (section: string, extra: Record = {}): GenerateCall => { + const input = inputRef.current; + return { + input: input.idea.trim(), + language: resolveLanguage(input), + ...(input.mood.trim() ? { mood: input.mood.trim() } : {}), + ...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}), + vocals: input.vocals, + section, + ...extra, + } as unknown as GenerateCall; + }, + [], ); const runGeneration = useCallback( @@ -212,6 +213,7 @@ export function HomePage() { ); const handleGenerate = useCallback(async () => { + const input = inputRef.current; if (!input.idea.trim()) return; setAssets(null); setOriginalAssets(null); @@ -228,9 +230,10 @@ export function HomePage() { setLoading, t.assetsGenerated, ); - }, [input, buildRequest, runGeneration, t]); + }, [buildRequest, runGeneration, t]); const handleRegenerateAll = useCallback(async () => { + const input = inputRef.current; if (!input.idea.trim()) return; setAssets(null); setOriginalAssets(null); @@ -250,10 +253,11 @@ export function HomePage() { }, t.regeneratedAll, ); - }, [input, buildRequest, runGeneration, t]); + }, [buildRequest, runGeneration, t]); const handleRegenerateSection = useCallback( async (section: SectionKey, selectedText?: string) => { + const input = inputRef.current; if (!input.idea.trim() || !assets) return; const isPartialLyrics = section === "lyrics" && !!selectedText; @@ -299,7 +303,7 @@ export function HomePage() { `${t.regeneratedSection} ${SECTION_LABELS[section]}`, ); }, - [input, assets, buildRequest, runGeneration, t], + [assets, buildRequest, runGeneration, t], ); // Execute chained regeneration @@ -345,7 +349,8 @@ export function HomePage() { const handleLoadHistory = useCallback( (entry: HistoryEntry) => { if (anyRegenerating) cancel(); - setInput(entry.input); + inputRef.current = entry.input; + setForcedUpdate((prev) => ({ count: (prev?.count ?? 0) + 1, values: entry.input })); setAssets(entry.assets); setOriginalAssets(entry.assets); setSelectedTitleIndex(0); @@ -486,8 +491,9 @@ export function HomePage() { { inputRef.current = next; }} onSubmit={handleGenerate} loading={loading} disabled={anyRegenerating}