561 lines
20 KiB
TypeScript
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];
|