UI enhancements, i18n, history drawer, and requested features
This commit is contained in:
+104
-34
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user