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:
+134
-88
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user