UI enhancements, i18n, history drawer, and requested features

This commit is contained in:
2026-06-04 00:01:32 +02:00
parent c3e167bde9
commit 3a2c738bd0
14 changed files with 598 additions and 288 deletions
+104 -34
View File
@@ -62,14 +62,38 @@ function loadDraft(): InputValues | 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-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="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: 6 }} />
<div className="music-bar music-bar-4" style={{ height: 16 }} />
<div className="music-bar music-bar-5" style={{ height: 8 }} />
</div>
);
}
@@ -90,6 +114,7 @@ export function HomePage() {
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);
@@ -143,7 +168,7 @@ export function HomePage() {
}, [anyRegenerating, cancel]);
const buildRequest = useCallback(
(section: GenerateCall["section"], extra: Partial<GenerateCall> = {}): GenerateCall => ({
(section: string, extra: Record<string, unknown> = {}): GenerateCall => ({
input: input.idea.trim(),
language: resolveLanguage(input),
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
@@ -151,7 +176,7 @@ export function HomePage() {
vocals: input.vocals,
section,
...extra,
}),
} as unknown as GenerateCall),
[input],
);
@@ -228,16 +253,37 @@ export function HomePage() {
}, [input, buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback(
async (section: SectionKey) => {
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(section, { context: assets }),
() => buildRequest(endpointSection, reqExtras),
(result) => {
const merged: SongAssets = { ...assets, ...result };
let merged: SongAssets;
if (isPartialLyrics) {
// Replace only the selected text in the existing lyrics
const newPartial = (result as any).lyrics || (result as any).text || String(result);
const replaced = assets.lyrics.replace(selectedText, newPartial);
merged = { ...assets, lyrics: replaced };
} else {
merged = { ...assets, ...result };
}
setAssets(merged);
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
// Reset custom title if titles were regenerated
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]}`,
@@ -246,6 +292,15 @@ export function HomePage() {
[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 {
@@ -303,13 +358,13 @@ export function HomePage() {
{/* Logo */}
<div className="flex items-center gap-2.5">
<MusicBarsLogo />
<span className="text-sm font-extrabold gradient-text tracking-tight">
<span className="text-sm font-extrabold gradient-text-animated tracking-tight">
MelodyMuse
</span>
</div>
{/* Right controls */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-4">
{/* Social links — icon only */}
<a
href="https://www.youtube.com/@AIWentNonsense"
@@ -331,6 +386,27 @@ export function HomePage() {
{/* 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)}
@@ -349,31 +425,25 @@ export function HomePage() {
<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-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-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>
</div>
{/* Theme toggle */}
{/* Theme toggle switch */}
<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"
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"
>
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} className="w-3.5 h-3.5" />
<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={faSun} 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>