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>
+27 -87
View File
@@ -12,35 +12,21 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm";
import { clearHistory } from "../lib/history";
import { useI18n } from "../lib/i18n";
const HISTORY_KEY = "melodymuse-history";
const DRAFT_KEY = "melodymuse-draft";
const THEME_KEY = "melodymuse-theme";
interface LocalDataSummary {
historyEntries: number;
hasDraft: boolean;
hasTheme: boolean;
}
function readLocalDataSummary(): LocalDataSummary {
if (typeof window === "undefined") {
return { historyEntries: 0, hasDraft: false, hasTheme: false };
}
let historyEntries = 0;
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) historyEntries = parsed.length;
}
} catch {
/* ignore */
return { hasDraft: false };
}
const hasDraft = window.localStorage.getItem(DRAFT_KEY) !== null;
const hasTheme = window.localStorage.getItem(THEME_KEY) !== null;
return { historyEntries, hasDraft, hasTheme };
return { hasDraft };
}
export function SettingsPage() {
@@ -52,9 +38,7 @@ export function SettingsPage() {
const [statusError, setStatusError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const [summary, setSummary] = useState<LocalDataSummary>({
historyEntries: 0,
hasDraft: false,
hasTheme: false,
});
const check = async (signal?: AbortSignal) => {
@@ -64,7 +48,7 @@ export function SettingsPage() {
const s = await getServerStatus(signal);
setStatus(s);
} catch (err) {
setStatusError(err instanceof Error ? err.message : "Server unreachable");
setStatusError(err instanceof Error ? err.message : t.unreachable);
setStatus(null);
} finally {
setChecking(false);
@@ -78,40 +62,18 @@ export function SettingsPage() {
return () => ctrl.abort();
}, []);
const onClearHistory = () => {
if (summary.historyEntries === 0) return;
if (
!window.confirm(
`Delete all ${summary.historyEntries} recent generation${
summary.historyEntries === 1 ? "" : "s"
} from this browser? This cannot be undone.`,
)
)
return;
window.localStorage.removeItem(HISTORY_KEY);
setSummary((s) => ({ ...s, historyEntries: 0 }));
toast.info("Recent generations cleared");
const onClearHistory = async () => {
if (!window.confirm(t.clearHistoryConfirm)) return;
await clearHistory();
toast.info(t.clearHistory);
};
const onClearDraft = () => {
if (!summary.hasDraft) return;
if (!window.confirm(t.clearDraftConfirm)) return;
window.localStorage.removeItem(DRAFT_KEY);
setSummary((s) => ({ ...s, hasDraft: false }));
toast.info("In-progress draft cleared");
};
const onClearAll = () => {
if (
!window.confirm(
"Clear all MelodyMuse data from this browser? This will remove the recent generations, the in-progress draft, and the theme preference.",
)
)
return;
window.localStorage.removeItem(HISTORY_KEY);
window.localStorage.removeItem(DRAFT_KEY);
window.localStorage.removeItem(THEME_KEY);
setSummary({ historyEntries: 0, hasDraft: false, hasTheme: false });
toast.info("Local data cleared");
toast.info(t.clearDraft);
};
return (
@@ -135,7 +97,7 @@ export function SettingsPage() {
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-accent-primary" />
Server status
{t.serverStatus}
</h2>
<div className="space-y-3">
@@ -145,23 +107,23 @@ export function SettingsPage() {
<div className="min-w-0">
<p className="text-sm font-medium text-fg">
{checking
? "Checking…"
? t.checking
: status
? "Connected"
? t.connected
: statusError
? "Unreachable"
? t.unreachable
: "Unknown"}
</p>
{status && (
<p className="text-xs text-fg-muted truncate">
Model: <span className="font-mono">{status.model}</span>
<p className="text-xs text-fg-muted truncate mt-1">
{t.model}: <span className="font-mono">{status.model}</span>
<br />
Endpoint:{" "}
{t.endpoint}:{" "}
<span className="font-mono">{status.endpoint}</span>
</p>
)}
{statusError && !checking && (
<p className="text-xs text-rose-400 break-words">
<p className="text-xs text-rose-400 break-words mt-1">
{statusError}
</p>
)}
@@ -174,11 +136,11 @@ export function SettingsPage() {
className="btn-ghost"
>
{checking ? (
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
) : (
<FontAwesomeIcon icon={faPlug} className="w-4 h-4" />
)}
<span>Re-check</span>
<span>{t.recheck}</span>
</button>
</div>
</div>
@@ -187,36 +149,14 @@ export function SettingsPage() {
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3">Local data</h2>
<p className="text-xs text-fg-muted mb-3">
MelodyMuse keeps some data in this browser. None of it leaves your
device except for the generation requests themselves.
</p>
<h2 className="text-sm font-semibold text-fg mb-3">{t.localData}</h2>
<ul className="text-xs text-fg-muted space-y-1 mb-4">
<li>
<code className="font-mono text-accent-primary">melodymuse-history</code> {" "}
{summary.historyEntries} recent generation
{summary.historyEntries === 1 ? "" : "s"}
</li>
<li>
<code className="font-mono text-accent-primary">melodymuse-draft</code> {" "}
{summary.hasDraft ? "saved (in-progress input)" : "empty"}
</li>
<li>
<code className="font-mono text-accent-primary">melodymuse-theme</code> {" "}
{summary.hasTheme ? "set" : "using default"}
{summary.hasDraft ? t.draftSaved : t.draftEmpty}
</li>
</ul>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onClearHistory}
disabled={summary.historyEntries === 0}
className="btn-secondary"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear recent generations
</button>
<button
type="button"
onClick={onClearDraft}
@@ -224,15 +164,15 @@ export function SettingsPage() {
className="btn-secondary"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear in-progress draft
{t.clearDraft}
</button>
<button
type="button"
onClick={onClearAll}
className="btn-secondary text-rose-400 hover:text-rose-300"
onClick={onClearHistory}
className="btn-secondary text-rose-400 hover:text-rose-300 border-rose-400/20"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear all local data
{t.clearHistory}
</button>
</div>
</section>
@@ -242,7 +182,7 @@ export function SettingsPage() {
to="/"
className="text-xs font-semibold text-fg-muted hover:text-accent-primary transition-colors flex items-center justify-center gap-2"
>
<FontAwesomeIcon icon={faArrowLeft} /> Back to generator
<FontAwesomeIcon icon={faArrowLeft} /> {t.backToGenerator}
</Link>
</div>
</div>