feat: major UI overhaul - 1/3+2/3 layout, animated logo, history drawer, editable titles, Czech, dark default, playful design

This commit is contained in:
2026-06-03 23:06:56 +02:00
parent 8d09b17467
commit c3e167bde9
11 changed files with 678 additions and 437 deletions
+134 -88
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { faSun, faMoon, faMusic } from "@fortawesome/free-solid-svg-icons";
import { faSun, faMoon, faHistory, faCog } from "@fortawesome/free-solid-svg-icons";
import { Link } from "react-router-dom";
import { InputPanel } from "../components/InputPanel";
import type { InputValues } from "../components/InputPanel";
import {
@@ -10,7 +11,7 @@ import {
type SectionKey,
} from "../components/ResultsPanel";
import { StickyZipBar } from "../components/StickyZipBar";
import { HistoryPanel } from "../components/HistoryPanel";
import { HistoryDrawer } from "../components/HistoryPanel";
import { Footer } from "../components/Footer";
import { useToast } from "../lib/toast";
import { generateSong, getServerStatus } from "../lib/llm";
@@ -61,6 +62,18 @@ function loadDraft(): InputValues | null {
}
}
/** Animated equalizer bars for the logo */
function MusicBarsLogo() {
return (
<div className="flex items-end gap-[3px] h-5" aria-hidden>
<div className="music-bar music-bar-1" style={{ height: 8 }} />
<div className="music-bar music-bar-2" style={{ height: 14 }} />
<div className="music-bar music-bar-3" style={{ height: 10 }} />
<div className="music-bar music-bar-4" style={{ height: 6 }} />
</div>
);
}
export function HomePage() {
const toast = useToast();
const { t, lang, setLang } = useI18n();
@@ -74,22 +87,23 @@ export function HomePage() {
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 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
}
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 () => {
@@ -102,20 +116,17 @@ export function HomePage() {
};
check();
const id = window.setInterval(check, 30_000);
return () => {
cancelled = true;
window.clearInterval(id);
};
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]);
return assets.titles[Math.min(selectedTitleIndex, assets.titles.length - 1)];
}, [assets, selectedTitleIndex, customTitle]);
const cancel = useCallback(() => {
abortRef.current?.abort();
@@ -125,26 +136,18 @@ export function HomePage() {
useEffect(() => {
if (!anyRegenerating) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
if (e.key === "Escape") { e.preventDefault(); cancel(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [anyRegenerating, cancel]);
const buildRequest = useCallback(
(
section: GenerateCall["section"],
extra: Partial<GenerateCall> = {},
): GenerateCall => ({
(section: GenerateCall["section"], extra: Partial<GenerateCall> = {}): 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() }
: {}),
...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}),
vocals: input.vocals,
section,
...extra,
@@ -173,11 +176,7 @@ export function HomePage() {
if (err instanceof DOMException && err.name === "AbortError") {
toast.info(t.generationCancelled);
} else {
toast.error(
err instanceof Error
? err.message
: t.generationFailed,
);
toast.error(err instanceof Error ? err.message : t.generationFailed);
}
} finally {
busySetter(false);
@@ -191,6 +190,7 @@ export function HomePage() {
if (!input.idea.trim()) return;
setAssets(null);
setOriginalAssets(null);
setCustomTitle(null);
await runGeneration(
() => buildRequest("all"),
(result) => {
@@ -209,6 +209,7 @@ export function HomePage() {
if (!input.idea.trim()) return;
setAssets(null);
setOriginalAssets(null);
setCustomTitle(null);
await runGeneration(
() => buildRequest("all"),
(result) => {
@@ -235,6 +236,8 @@ export function HomePage() {
const merged: SongAssets = { ...assets, ...result };
setAssets(merged);
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
// Reset custom title if titles were regenerated
if (section === "titles") setCustomTitle(null);
},
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
@@ -281,6 +284,7 @@ export function HomePage() {
setAssets(entry.assets);
setOriginalAssets(entry.assets);
setSelectedTitleIndex(0);
setCustomTitle(null);
toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`);
},
[anyRegenerating, cancel, toast],
@@ -288,99 +292,128 @@ export function HomePage() {
return (
<div className="flex flex-col h-screen 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-20 bg-bg-card/40 backdrop-blur-md border-b border-border shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-12 flex items-center justify-between">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faMusic} className="text-accent-primary" />
<span className="text-sm font-bold text-fg tracking-wide">
{t.appTitle}
<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 tracking-tight">
MelodyMuse
</span>
</div>
<div className="flex items-center gap-4">
{/* Right controls */}
<div className="flex items-center gap-3">
{/* Social links — icon only */}
<a
href="https://www.youtube.com/@AIWentNonsense"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-semibold text-fg-muted hover:text-rose-500 transition-colors"
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" />
<span className="hidden sm:inline">YouTube</span>
</a>
{/* Server offline indicator */}
{serverOk === false && (
<span className="text-xs font-bold text-rose-400 bg-rose-400/10 px-2 py-0.5 rounded" title="Server unreachable">
<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>
)}
<div className="flex items-center gap-1 border-l border-border pl-4">
{/* 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>
{/* Language */}
<div className="flex items-center gap-0.5">
<button
onClick={() => setLang("de")}
className={`w-7 h-7 rounded flex items-center justify-center text-[15px] transition-colors ${lang === "de" ? "bg-bg-hover shadow-sm" : "opacity-50 hover:opacity-100"}`}
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "de" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
title="Deutsch"
>
🇩🇪
</button>
<button
onClick={() => setLang("en")}
className={`w-7 h-7 rounded flex items-center justify-center text-[15px] transition-colors ${lang === "en" ? "bg-bg-hover shadow-sm" : "opacity-50 hover:opacity-100"}`}
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "en" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
title="English"
>
🇬🇧
</button>
<button
onClick={toggleTheme}
className="w-7 h-7 rounded flex items-center justify-center text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-colors ml-1"
title="Toggle Theme"
>
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
</button>
</div>
{/* Theme toggle */}
<button
onClick={toggleTheme}
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="Toggle theme"
>
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} className="w-3.5 h-3.5" />
</button>
</div>
</div>
</header>
{/* Main Layout: Fixed Input Top, Scrollable Bottom */}
<div className="flex-1 min-h-0 flex flex-col max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-4 gap-6">
{/* Input Control Bar (Always visible at top) */}
<div className="shrink-0 z-10 animate-fade-up">
<InputPanel
values={input}
onChange={setInput}
onSubmit={handleGenerate}
loading={loading}
disabled={anyRegenerating}
cancelButton={
<button
type="button"
onClick={cancel}
className="btn-secondary w-full"
>
{t.cancelGeneration}
</button>
}
/>
{/* Main content: 1/3 left input + 2/3 right results */}
<div className="flex-1 min-h-0 flex 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-[340px] xl:w-[380px] shrink-0 flex flex-col">
<div className="card p-4 flex flex-col 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>
{/* Bottom Area (History Sidebar + Results Area) */}
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden animate-fade-up stagger-1">
{/* History Sidebar */}
<div className="hidden lg:flex flex-col w-1/4 shrink-0 overflow-hidden">
<HistoryPanel onLoad={handleLoadHistory} />
</div>
{/* Results Main Area */}
<div className="flex-1 overflow-y-auto scrollbar-thin pb-20">
{/* RIGHT PANEL — 2/3 — Results */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-thin px-1">
<ResultsPanel
assets={assets}
originalAssets={originalAssets}
loading={loading}
selectedTitleIndex={selectedTitleIndex}
onSelectTitle={setSelectedTitleIndex}
onTitleEdit={setCustomTitle}
onChange={handleAssetChange}
onChangeVideoPrompt={handleVideoPromptChange}
onRegenerateAll={handleRegenerateAll}
@@ -389,18 +422,31 @@ export function HomePage() {
anyRegenerating={anyRegenerating}
regenerating={regenerating}
/>
<Footer />
</div>
{/* Download bar inside results column, always visible */}
{assets && (
<div className="shrink-0 pt-2">
<StickyZipBar
title={selectedTitle}
busy={loading}
onDownload={handleDownload}
inline
/>
</div>
)}
</div>
</div>
{assets && (
<StickyZipBar
title={selectedTitle}
busy={loading}
onDownload={handleDownload}
/>
)}
{/* Footer — always visible */}
<Footer />
{/* History Drawer (right-side slide-in) */}
<HistoryDrawer
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onLoad={handleLoadHistory}
/>
</div>
);
}