feat: complete UI overhaul (liquid glass theme, i18n, FontAwesome)

This commit is contained in:
2026-06-03 22:00:37 +02:00
parent e5a9936066
commit d34d34565f
23 changed files with 732 additions and 580 deletions
+87 -76
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Youtube } from "lucide-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 { InputPanel } from "../components/InputPanel";
import type { InputValues } from "../components/InputPanel";
import {
@@ -10,7 +12,6 @@ import {
import { StickyZipBar } from "../components/StickyZipBar";
import { HistoryPanel } from "../components/HistoryPanel";
import { Footer } from "../components/Footer";
import { EqualizerIcon } from "../components/EqualizerIcon";
import { useToast } from "../lib/toast";
import { generateSong, getServerStatus } from "../lib/llm";
import { addToHistory, type HistoryEntry } from "../lib/history";
@@ -20,9 +21,9 @@ import {
type SongAssets,
type VideoPrompt,
} from "../lib/types";
import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme";
// localStorage key for the in-progress input form. Lets the user survive
// an accidental refresh without losing what they were typing.
const DRAFT_KEY = "melodymuse-draft";
const DRAFT_DEBOUNCE_MS = 400;
@@ -62,42 +63,33 @@ function loadDraft(): InputValues | null {
export function HomePage() {
const toast = useToast();
const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme();
// Hydrate the input from any previously-saved draft so the user can recover
// an accidental refresh.
const [input, setInput] = useState<InputValues>(
() => loadDraft() ?? DEFAULT_INPUT,
);
const [assets, setAssets] = useState<SongAssets | null>(null);
// The "last generated" snapshot. The cards compare their current editable
// values against these to decide whether to show the Revert button.
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false);
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
const [serverOk, setServerOk] = useState<boolean | null>(null);
// AbortController for the *current* network request. Refs are used so the
// cancel handler always sees the latest value without re-binding.
const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating);
// Debounced auto-save: persist the input form ~400ms after the last edit so
// a refresh doesn't wipe what the user was typing.
useEffect(() => {
const id = window.setTimeout(() => {
try {
window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input));
} catch {
// Quota or disabled — fine, history is the more durable store.
// ignore
}
}, DRAFT_DEBOUNCE_MS);
return () => window.clearTimeout(id);
}, [input]);
// Check the server on mount and re-check periodically so the header
// "Server offline" hint stays current without a manual refresh.
useEffect(() => {
let cancelled = false;
const check = async () => {
@@ -116,7 +108,6 @@ export function HomePage() {
};
}, []);
// Clean up any in-flight request on unmount.
useEffect(() => () => abortRef.current?.abort(), []);
const selectedTitle = useMemo(() => {
@@ -131,7 +122,6 @@ export function HomePage() {
abortRef.current = null;
}, []);
// Esc cancels any in-flight generation.
useEffect(() => {
if (!anyRegenerating) return;
const onKey = (e: KeyboardEvent) => {
@@ -144,9 +134,6 @@ export function HomePage() {
return () => window.removeEventListener("keydown", onKey);
}, [anyRegenerating, cancel]);
// Build the request payload from the current form values. Kept as a
// function so all three call sites (full / regenerate all / per-section)
// stay in sync.
const buildRequest = useCallback(
(
section: GenerateCall["section"],
@@ -172,7 +159,6 @@ export function HomePage() {
busySetter: (b: boolean) => void,
successMsg: string,
) => {
// Cancel any in-flight request before starting a new one.
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
@@ -185,12 +171,12 @@ export function HomePage() {
toast.success(successMsg);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
toast.info("Generation cancelled");
toast.info(t.generationCancelled);
} else {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
: t.generationFailed,
);
}
} finally {
@@ -198,13 +184,11 @@ export function HomePage() {
if (abortRef.current === ctrl) abortRef.current = null;
}
},
[toast],
[toast, t],
);
const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return;
// Clear the old assets so the skeleton state shows during generation
// instead of the previous song's cards.
setAssets(null);
setOriginalAssets(null);
await runGeneration(
@@ -217,9 +201,9 @@ export function HomePage() {
addToHistory(input, next);
},
setLoading,
"Song assets generated",
t.assetsGenerated,
);
}, [input, buildRequest, runGeneration]);
}, [input, buildRequest, runGeneration, t]);
const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return;
@@ -238,9 +222,9 @@ export function HomePage() {
setLoading(b);
setRegenerating((m) => ({ ...m, all: b }));
},
"Regenerated all sections",
t.regeneratedAll,
);
}, [input, buildRequest, runGeneration]);
}, [input, buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback(
async (section: SectionKey) => {
@@ -248,18 +232,15 @@ export function HomePage() {
await runGeneration(
() => buildRequest(section, { context: assets }),
(result) => {
// Merge partial into current assets, and update the original
// snapshot for the keys that came back. Any keys the user has
// hand-edited that the model *didn't* return stay as-is.
const merged: SongAssets = { ...assets, ...result };
setAssets(merged);
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
},
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
`Regenerated ${SECTION_LABELS[section]}`,
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
);
},
[input, assets, buildRequest, runGeneration],
[input, assets, buildRequest, runGeneration, t],
);
const handleDownload = useCallback(async () => {
@@ -273,7 +254,6 @@ export function HomePage() {
}
}, [assets, selectedTitle, toast]);
// Asset editing handlers
const handleAssetChange = useCallback(
<K extends keyof SongAssets>(key: K, value: SongAssets[K]) => {
setAssets((curr) => (curr ? { ...curr, [key]: value } : curr));
@@ -294,7 +274,6 @@ export function HomePage() {
[],
);
// Restore a previous generation from history.
const handleLoadHistory = useCallback(
(entry: HistoryEntry) => {
if (anyRegenerating) cancel();
@@ -308,61 +287,94 @@ export function HomePage() {
);
return (
<div className="min-h-screen pb-32">
<header className="sticky top-0 z-20 bg-bg/80 backdrop-blur border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<EqualizerIcon size={18} className="shrink-0" />
<span className="text-sm font-medium text-fg-muted">
MelodyMuse
<div className="flex flex-col h-screen overflow-hidden">
<div className="liquid-blob-1" />
<div className="liquid-blob-2" />
{/* 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}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-4">
<a
href="https://www.youtube.com/@AIWentNonsense"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-fg-muted hover:text-fg transition-colors"
className="inline-flex items-center gap-1.5 text-xs font-semibold text-fg-muted hover:text-rose-500 transition-colors"
title="AI Went Nonsense on YouTube"
>
<Youtube className="w-4 h-4" />
<FontAwesomeIcon icon={faYoutube} className="w-4 h-4" />
<span className="hidden sm:inline">YouTube</span>
</a>
{serverOk === false && (
<span
className="text-xs text-rose-300 hidden sm:inline"
title="The MelodyMuse server is unreachable. Generation will fail."
>
Server offline
<span className="text-xs font-bold text-rose-400 bg-rose-400/10 px-2 py-0.5 rounded" title="Server unreachable">
{t.serverOffline}
</span>
)}
<div className="flex items-center gap-1 border-l border-border pl-4">
<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"}`}
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"}`}
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>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-10">
<div className="grid grid-cols-1 lg:grid-cols-[35%_minmax(0,1fr)] gap-8">
<div className="lg:sticky lg:top-20 lg:self-start space-y-4">
<InputPanel
values={input}
onChange={setInput}
onSubmit={handleGenerate}
loading={loading}
disabled={anyRegenerating}
cancelButton={
<button
type="button"
onClick={cancel}
className="btn-secondary w-full"
>
Cancel generation
</button>
}
/>
{/* 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>
}
/>
</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>
<div>
{/* Results Main Area */}
<div className="flex-1 overflow-y-auto scrollbar-thin pb-20">
<ResultsPanel
assets={assets}
originalAssets={originalAssets}
@@ -377,9 +389,10 @@ export function HomePage() {
anyRegenerating={anyRegenerating}
regenerating={regenerating}
/>
<Footer />
</div>
</div>
</main>
</div>
{assets && (
<StickyZipBar
@@ -388,8 +401,6 @@ export function HomePage() {
onDownload={handleDownload}
/>
)}
<Footer />
</div>
);
}