Files
MelodyMuse/src/pages/HomePage.tsx
T

561 lines
20 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { faLightbulb, faMoon, faHistory, faCog, faRobot } from "@fortawesome/free-solid-svg-icons";
import { Link } from "react-router-dom";
import { InputPanel } from "../components/InputPanel";
import type { InputValues } from "../components/InputPanel";
import {
ResultsPanel,
type RegeneratingMap,
type SectionKey,
} from "../components/ResultsPanel";
import { StickyZipBar } from "../components/StickyZipBar";
import { HistoryDrawer } from "../components/HistoryPanel";
import { Footer } from "../components/Footer";
import { useToast } from "../lib/toast";
import { generateSong, getServerStatus } from "../lib/llm";
import { addToHistory, type HistoryEntry } from "../lib/history";
import { buildSongZip, downloadBlob, sanitizeFilename } from "../lib/zip";
import {
SECTION_LABELS,
type SongAssets,
type VideoPrompt,
} from "../lib/types";
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: "",
language: "English",
customLanguage: "",
style_hint: "",
mood: "",
vocals: "vocals",
};
function resolveLanguage(v: InputValues): string {
return v.language === "Other"
? v.customLanguage.trim() || "English"
: v.language;
}
function isAnyBusy(loading: boolean, regenerating: RegeneratingMap): boolean {
if (loading) return true;
for (const v of Object.values(regenerating)) if (v) return true;
return false;
}
function loadDraft(): InputValues | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<InputValues> | null;
if (!parsed || typeof parsed !== "object") return null;
return { ...DEFAULT_INPUT, ...parsed };
} catch {
return null;
}
}
function FlagDE() {
return (
<svg viewBox="0 0 5 3" className="w-5 h-3.5 rounded-[2px] overflow-hidden shadow-sm">
<rect width="5" height="3" y="0" fill="#000" />
<rect width="5" height="2" y="1" fill="#D00" />
<rect width="5" height="1" y="2" fill="#FFCE00" />
</svg>
);
}
function FlagEN() {
return (
<svg viewBox="0 0 60 30" className="w-5 h-3.5 rounded-[2px] overflow-hidden shadow-sm">
<clipPath id="t"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/></clipPath>
<rect width="60" height="30" fill="#012169"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#t)" stroke="#C8102E" strokeWidth="4"/>
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
</svg>
);
}
/** Animated equalizer bars for the logo */
function MusicBarsLogo() {
return (
<div className="flex items-end gap-[3px] h-[22px]" aria-hidden>
<div className="music-bar music-bar-1" style={{ height: 12 }} />
<div className="music-bar music-bar-2" style={{ height: 22 }} />
<div className="music-bar music-bar-3" style={{ height: 10 }} />
<div className="music-bar music-bar-4" style={{ height: 16 }} />
<div className="music-bar music-bar-5" style={{ height: 8 }} />
</div>
);
}
export function HomePage() {
const toast = useToast();
const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme();
const [input, setInput] = useState<InputValues>(
() => loadDraft() ?? DEFAULT_INPUT,
);
const [assets, setAssets] = useState<SongAssets | null>(null);
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false);
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
const [customTitle, setCustomTitle] = useState<string | null>(null);
const [serverOk, setServerOk] = useState<boolean | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [chainRegenerate, setChainRegenerate] = useState<SectionKey | null>(null);
const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating);
// Save draft
useEffect(() => {
const id = window.setTimeout(() => {
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); }
catch { /* ignore */ }
}, DRAFT_DEBOUNCE_MS);
return () => window.clearTimeout(id);
}, [input]);
// Server health check
useEffect(() => {
let cancelled = false;
const check = async () => {
try {
const s = await getServerStatus();
if (!cancelled) setServerOk(Boolean(s.ok));
} catch {
if (!cancelled) setServerOk(false);
}
};
check();
const id = window.setInterval(check, 30_000);
return () => { cancelled = true; window.clearInterval(id); };
}, []);
useEffect(() => () => abortRef.current?.abort(), []);
// Resolved title for ZIP: custom override > chip selection
const selectedTitle = useMemo(() => {
if (customTitle !== null) return customTitle;
if (!assets || assets.titles.length === 0) return undefined;
return assets.titles[Math.min(selectedTitleIndex, assets.titles.length - 1)];
}, [assets, selectedTitleIndex, customTitle]);
const cancel = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
useEffect(() => {
if (!anyRegenerating) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { e.preventDefault(); cancel(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [anyRegenerating, cancel]);
const buildRequest = useCallback(
(section: string, extra: Record<string, unknown> = {}): 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],
);
const runGeneration = useCallback(
async (
build: () => GenerateCall,
onSuccess: (result: Partial<SongAssets>) => void,
busySetter: (b: boolean) => void,
successMsg: string,
) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
busySetter(true);
try {
const req = build();
const result = await generateSong(req, { signal: ctrl.signal });
onSuccess(result);
toast.success(successMsg);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
toast.info(t.generationCancelled);
} else {
toast.error(err instanceof Error ? err.message : t.generationFailed);
}
} finally {
busySetter(false);
if (abortRef.current === ctrl) abortRef.current = null;
}
},
[toast, t],
);
const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return;
setAssets(null);
setOriginalAssets(null);
setCustomTitle(null);
await runGeneration(
() => buildRequest("all"),
(result) => {
const next = result as SongAssets;
setAssets(next);
setOriginalAssets(next);
setSelectedTitleIndex(0);
addToHistory(input, next);
},
setLoading,
t.assetsGenerated,
);
}, [input, buildRequest, runGeneration, t]);
const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return;
setAssets(null);
setOriginalAssets(null);
setCustomTitle(null);
await runGeneration(
() => buildRequest("all"),
(result) => {
const next = result as SongAssets;
setAssets(next);
setOriginalAssets(next);
setSelectedTitleIndex(0);
addToHistory(input, next);
},
(b) => {
setLoading(b);
setRegenerating((m) => ({ ...m, all: b }));
},
t.regeneratedAll,
);
}, [input, buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback(
async (section: SectionKey, selectedText?: string) => {
if (!input.idea.trim() || !assets) return;
const isPartialLyrics = section === "lyrics" && !!selectedText;
const endpointSection = isPartialLyrics ? "lyrics_partial" : section;
const reqExtras = { context: assets };
if (isPartialLyrics) {
(reqExtras as any).selected_text = selectedText;
}
await runGeneration(
() => buildRequest(endpointSection, reqExtras),
(result) => {
let merged: SongAssets;
if (isPartialLyrics) {
// Replace only the selected text in the existing lyrics
let newPartial = (result as any).lyrics || (result as any).text || String(result);
// Cleanup quotes or markdown if the LLM leaked them
newPartial = newPartial.replace(/^```[a-z]*\n/gi, '').replace(/\n```$/g, '').trim();
if (newPartial.startsWith('"') && newPartial.endsWith('"')) {
newPartial = newPartial.slice(1, -1).trim();
}
// Normalize newlines to prevent replace() from failing due to \r\n vs \n
const normalizedAssetsLyrics = assets.lyrics.replace(/\r\n/g, '\n');
const normalizedSelected = selectedText.replace(/\r\n/g, '\n');
const replaced = normalizedAssetsLyrics.replace(normalizedSelected, newPartial);
merged = { ...assets, lyrics: replaced };
} else {
merged = { ...assets, ...result };
}
setAssets(merged);
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
if (section === "titles") setCustomTitle(null);
// Chain YouTube description regeneration if lyrics changed
if (section === "lyrics") {
setChainRegenerate("youtube_description");
}
},
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
);
},
[input, assets, buildRequest, runGeneration, t],
);
// Execute chained regeneration
useEffect(() => {
if (chainRegenerate && assets && !anyRegenerating) {
const section = chainRegenerate;
setChainRegenerate(null);
handleRegenerateSection(section);
}
}, [chainRegenerate, assets, anyRegenerating, handleRegenerateSection]);
const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return;
try {
const blob = await buildSongZip(assets, selectedTitle);
const safe = sanitizeFilename(selectedTitle);
downloadBlob(blob, `${safe || "song"}.zip`);
} catch {
toast.error("Could not generate ZIP — please try again");
}
}, [assets, selectedTitle, toast]);
const handleAssetChange = useCallback(
<K extends keyof SongAssets>(key: K, value: SongAssets[K]) => {
setAssets((curr) => (curr ? { ...curr, [key]: value } : curr));
},
[],
);
const handleVideoPromptChange = useCallback(
(index: number, value: string) => {
setAssets((curr) => {
if (!curr) return curr;
const next: VideoPrompt[] = curr.video_prompts.map((p, i) =>
i === index ? { ...p, prompt: value } : p,
);
return { ...curr, video_prompts: next };
});
},
[],
);
const handleLoadHistory = useCallback(
(entry: HistoryEntry) => {
if (anyRegenerating) cancel();
setInput(entry.input);
setAssets(entry.assets);
setOriginalAssets(entry.assets);
setSelectedTitleIndex(0);
setCustomTitle(null);
toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`);
},
[anyRegenerating, cancel, toast],
);
return (
<div className="flex flex-col min-h-[100dvh] md:h-screen overflow-y-auto md:overflow-hidden">
{/* Liquid background blobs */}
<div className="liquid-blob-1" />
<div className="liquid-blob-2" />
<div className="liquid-blob-3" />
{/* Header */}
<header className="shrink-0 z-30 sticky top-0 bg-bg-card/70 backdrop-blur-md border-b border-border shadow-sm">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 h-12 flex items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-2.5">
<MusicBarsLogo />
<span className="text-sm font-extrabold gradient-text-animated tracking-tight">
MelodyMuse
</span>
</div>
{/* Right controls */}
<div className="flex items-center gap-4">
{/* External Links */}
<div className="flex items-center gap-1">
<a
href="https://suno.com/create"
target="_blank"
rel="noopener noreferrer"
className="px-2 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-orange-500 hover:bg-orange-500/10 transition-all duration-150 font-bold text-[11px] tracking-wide uppercase"
title="Create on Suno"
>
Suno
</a>
<a
href="https://chat.orfel.de/c/new"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-blue-400 hover:bg-blue-400/10 transition-all duration-150"
title="Open Chat AI"
>
<FontAwesomeIcon icon={faRobot} className="w-4 h-4" />
</a>
<a
href="https://www.youtube.com/@AIWentNonsense"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-rose-500 hover:bg-rose-500/10 transition-all duration-150"
title="AI Went Nonsense on YouTube"
>
<FontAwesomeIcon icon={faYoutube} className="w-4 h-4" />
</a>
</div>
{/* Server offline indicator */}
{serverOk === false && (
<span className="text-[10px] font-bold text-rose-400 bg-rose-400/10 px-2 py-0.5 rounded-full border border-rose-400/20">
{t.serverOffline}
</span>
)}
{/* Divider */}
<div className="h-5 w-px bg-border" />
{/* Language */}
<div className="flex items-center gap-2">
<button
onClick={() => setLang("de")}
className={`flex items-center justify-center transition-all duration-150 ${lang === "de" ? "scale-110 ring-2 ring-accent-primary rounded-[4px]" : "opacity-40 hover:opacity-80"}`}
title="Deutsch"
>
<FlagDE />
</button>
<button
onClick={() => setLang("en")}
className={`flex items-center justify-center transition-all duration-150 ${lang === "en" ? "scale-110 ring-2 ring-accent-primary rounded-[4px]" : "opacity-40 hover:opacity-80"}`}
title="English"
>
<FlagEN />
</button>
</div>
{/* Divider */}
<div className="h-5 w-px bg-border" />
{/* History toggle */}
<button
onClick={() => setHistoryOpen((o) => !o)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-all duration-150 relative"
title={t.history}
>
<FontAwesomeIcon icon={faHistory} className="w-3.5 h-3.5" />
</button>
{/* Settings link */}
<Link
to="/settings"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-all duration-150"
title={t.settings}
>
<FontAwesomeIcon icon={faCog} className="w-3.5 h-3.5" />
</Link>
{/* Theme toggle switch */}
<button
onClick={toggleTheme}
className="relative flex items-center justify-between w-12 h-6 bg-border/50 border border-border/50 hover:bg-border/80 rounded-full p-0.5 cursor-pointer transition-colors"
title="Toggle dark/light mode"
>
<div
className={`absolute left-0.5 top-0.5 w-5 h-5 rounded-full shadow-sm transition-transform duration-300 ease-in-out z-0 ${
theme === "dark" ? "translate-x-6 bg-bg-card ring-1 ring-border" : "translate-x-0 bg-white ring-1 ring-gray-200"
}`}
>
<div className={`w-full h-full rounded-full ${theme === "dark" ? "gradient-bg opacity-10" : "opacity-0"} transition-opacity duration-300`} />
</div>
<div className="w-5 h-5 flex items-center justify-center z-10">
<FontAwesomeIcon icon={faLightbulb} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-fg-muted/60" : "text-yellow-500"}`} />
</div>
<div className="w-5 h-5 flex items-center justify-center z-10">
<FontAwesomeIcon icon={faMoon} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-accent-primary" : "text-fg-muted/60"}`} />
</div>
</button>
</div>
</div>
</header>
{/* Main content: 1/3 left input + 2/3 right results */}
<div className="flex-1 min-h-0 flex flex-col md:flex-row md:overflow-hidden max-w-screen-2xl mx-auto w-full px-4 sm:px-6 py-4 gap-5">
{/* LEFT PANEL — 1/3 — Input */}
<div className="w-full md:w-[340px] xl:w-[380px] shrink-0 flex flex-col">
<div className="card p-4 flex flex-col flex-none md:flex-1 min-h-0">
<InputPanel
values={input}
onChange={setInput}
onSubmit={handleGenerate}
loading={loading}
disabled={anyRegenerating}
cancelButton={
<button
type="button"
onClick={cancel}
className="btn-secondary w-full text-xs"
>
{t.cancelGeneration}
</button>
}
/>
</div>
</div>
{/* RIGHT PANEL — 2/3 — Results */}
<div className="flex-1 min-w-0 flex flex-col md:overflow-hidden gap-4 md:gap-0 pb-4 md:pb-0">
<div className="flex-1 md:overflow-y-auto md:scrollbar-thin px-1">
<ResultsPanel
assets={assets}
originalAssets={originalAssets}
loading={loading}
selectedTitleIndex={selectedTitleIndex}
onSelectTitle={setSelectedTitleIndex}
onTitleEdit={setCustomTitle}
onChange={handleAssetChange}
onChangeVideoPrompt={handleVideoPromptChange}
onRegenerateAll={handleRegenerateAll}
onRegenerateSection={handleRegenerateSection}
onCancel={cancel}
anyRegenerating={anyRegenerating}
regenerating={regenerating}
/>
</div>
{/* Download bar inside results column, always visible */}
{assets && (
<div className="shrink-0 pt-2 sticky bottom-4 md:static z-20">
<StickyZipBar
title={selectedTitle}
busy={loading}
onDownload={handleDownload}
inline
/>
</div>
)}
</div>
</div>
{/* Footer — always visible */}
<Footer />
{/* History Drawer (right-side slide-in) */}
<HistoryDrawer
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onLoad={handleLoadHistory}
/>
</div>
);
}
function truncate(s: string, n: number): string {
const t = s.trim();
if (t.length <= n) return t || "previous generation";
return t.slice(0, n - 1) + "…";
}
type GenerateCall = Parameters<typeof generateSong>[0];