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
+39 -19
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faHistory, faTrashAlt, faTimes } from "@fortawesome/free-solid-svg-icons";
import { faClock, faHistory, faTrashAlt, faTimes, faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import {
clearHistory,
formatRelative,
@@ -20,16 +20,26 @@ interface HistoryDrawerProps {
export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
const [entries, setEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const { t } = useI18n();
useEffect(() => {
setEntries(loadHistory());
}, []);
const fetchHistory = async () => {
setLoading(true);
try {
const data = await loadHistory();
setEntries(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
const handler = () => setEntries(loadHistory());
window.addEventListener(HISTORY_UPDATED_EVENT, handler);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, handler);
if (open) fetchHistory();
}, [open]);
useEffect(() => {
window.addEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
}, []);
// Close on Escape
@@ -65,24 +75,27 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
<span className="flex items-center gap-2 text-sm font-bold text-fg">
<FontAwesomeIcon icon={faHistory} className="text-accent-primary" />
{t.history}
{entries.length > 0 && (
{!loading && entries.length > 0 && (
<span className="text-[10px] bg-accent-primary/15 text-accent-primary px-1.5 py-0.5 rounded-full font-bold">
{entries.length}
</span>
)}
</span>
<button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-lg text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
aria-label="Close history"
>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="flex items-center gap-2">
{loading && <FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-3 h-3" />}
<button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-lg text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
aria-label="Close history"
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
</div>
{/* Entries */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-2 space-y-1">
{entries.length === 0 ? (
{entries.length === 0 && !loading ? (
<p className="px-3 py-10 text-xs text-fg-muted text-center italic">{t.noHistory}</p>
) : (
entries.map((e) => (
@@ -90,7 +103,9 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
key={e.id}
entry={e}
onLoad={() => { onLoad(e); onClose(); }}
onRemove={() => setEntries(removeFromHistory(e.id))}
onRemove={async () => {
await removeFromHistory(e.id);
}}
/>
))
)}
@@ -101,11 +116,16 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={() => { clearHistory(); setEntries([]); }}
onClick={async () => {
if (window.confirm(t.clearHistoryConfirm)) {
await clearHistory();
setEntries([]);
}
}}
className="w-full inline-flex items-center justify-center gap-2 text-xs font-semibold text-fg-muted hover:text-rose-400 hover:bg-rose-400/10 transition-colors py-2 rounded-lg"
>
<FontAwesomeIcon icon={faTrashAlt} />
{t.history === 'History' ? 'Clear history' : 'Verlauf löschen'}
{t.clearHistory}
</button>
</div>
)}
+39 -25
View File
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleNotch,
faWandMagicSparkles,
faShuffle,
faFire,
faMicrophone,
faMusic,
@@ -89,42 +88,65 @@ export function InputPanel({
}
};
const fillExample = () => {
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
onChange({ ...values, idea: ex.idea, mood: values.mood || ex.mood, vocals: ex.vocals });
};
// "Surprise me" fills both idea AND style with a coherent pair
// "Surprise me" fills style. If idea is empty, fills a random idea too.
const handleSurpriseMe = async () => {
if (styleLoading) return;
// Try to get a random style from the server; also fill idea locally
const hasIdea = values.idea.trim().length > 0;
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
const ctrl = new AbortController();
setStyleLoading("normal");
try {
const style = await randomStyle("normal", ctrl.signal);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals });
const style = await randomStyle("normal", ctrl.signal, ideaToUse);
onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
// Fallback to local style if server fails
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals });
onChange({
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} finally {
setStyleLoading((curr) => (curr === "normal" ? null : curr));
}
};
// "Go crazy" fills both idea AND style with a wild pair
// "Go crazy" fills style wildly. If idea is empty, fills a wild idea too.
const handleGoCrazy = async () => {
if (styleLoading) return;
const hasIdea = values.idea.trim().length > 0;
const ex = CRAZY_PAIRS[Math.floor(Math.random() * CRAZY_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
const ctrl = new AbortController();
setStyleLoading("crazy");
try {
const style = await randomStyle("crazy", ctrl.signal);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals });
const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals });
onChange({
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr));
}
@@ -151,15 +173,7 @@ export function InputPanel({
/>
{/* Example / Surprise / Crazy buttons */}
<div className="flex flex-wrap gap-2 pt-0.5">
<button
type="button"
onClick={fillExample}
disabled={anyLoading}
className="btn-fun bg-bg-card border-border text-fg-muted hover:text-fg hover:border-accent-primary/40"
>
<FontAwesomeIcon icon={faShuffle} className="w-3 h-3" />
{t.tryExample}
</button>
{/* Removed Try an Example button */}
<button
type="button"
onClick={handleSurpriseMe}
+12 -9
View File
@@ -7,6 +7,7 @@ import { LyricsCard } from "./cards/LyricsCard";
import { StyleCard } from "./cards/StyleCard";
import { VideoPromptsCard } from "./cards/VideoPromptsCard";
import { YouTubeCard } from "./cards/YouTubeCard";
import { useI18n } from "../lib/i18n";
export type SectionKey =
| "titles"
@@ -27,7 +28,7 @@ interface ResultsPanelProps {
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
onChangeVideoPrompt: (index: number, value: string) => void;
onRegenerateAll: () => void;
onRegenerateSection: (section: SectionKey) => void;
onRegenerateSection: (section: SectionKey, selectedText?: string) => void;
onCancel?: () => void;
anyRegenerating: boolean;
regenerating: RegeneratingMap;
@@ -49,11 +50,12 @@ export function ResultsPanel({
regenerating,
}: ResultsPanelProps) {
const showSkeletons = loading && !assets;
const { t } = useI18n();
return (
<div>
<div className="sticky top-0 z-30 -mx-4 px-4 py-2.5 bg-bg/60 backdrop-blur-md border-b border-border mb-5 flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-fg">Results</h2>
<h2 className="text-sm font-bold text-fg">{t.results}</h2>
<div className="flex items-center gap-1">
{anyRegenerating && onCancel && (
<button
@@ -61,7 +63,7 @@ export function ResultsPanel({
onClick={onCancel}
className="px-2.5 py-1.5 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
>
Cancel
{t.cancel}
</button>
)}
{assets && (
@@ -72,7 +74,7 @@ export function ResultsPanel({
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FontAwesomeIcon icon={faSync} spin={regenerating.all} className="w-3 h-3" />
Regenerate all
{t.regenerateAll}
</button>
)}
</div>
@@ -100,7 +102,7 @@ export function ResultsPanel({
lyrics={assets.lyrics}
originalLyrics={originalAssets.lyrics}
onChange={(v) => onChange("lyrics", v)}
onRegenerate={() => onRegenerateSection("lyrics")}
onRegenerate={(selectedText) => onRegenerateSection("lyrics", selectedText)}
regenerating={Boolean(regenerating.lyrics)}
/>
<StyleCard
@@ -136,6 +138,7 @@ export function ResultsPanel({
}
function EmptyState() {
const { t } = useI18n();
return (
<div className="flex flex-col items-center justify-center text-center px-6 py-20 rounded-2xl border border-dashed border-border backdrop-blur-sm bg-bg-card/30">
<div className="relative w-16 h-16 mb-5">
@@ -144,13 +147,13 @@ function EmptyState() {
<FontAwesomeIcon icon={faMusic} className="w-7 h-7 text-accent-primary" />
</div>
</div>
<h3 className="text-base font-bold text-fg mb-1.5">Your song will appear here</h3>
<h3 className="text-base font-bold text-fg mb-1.5">{t.emptyStateTitle}</h3>
<p className="text-sm text-fg-muted max-w-sm leading-relaxed">
Describe a music idea on the left and press{" "}
{t.emptyStateBody}{" "}
<kbd className="px-1.5 py-0.5 rounded-lg bg-bg-hover border border-border text-[11px] font-mono text-fg">
Generate
{t.generateAssets}
</kbd>{" "}
to create titles, lyrics, style, video prompts, and a YouTube description.
{t.emptyStateBody2}
</p>
</div>
);
+35 -15
View File
@@ -3,12 +3,13 @@ import { CopyButton } from "../CopyButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileAlt, faUndo } from "@fortawesome/free-solid-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface LyricsCardProps {
lyrics: string;
originalLyrics: string;
onChange: (next: string) => void;
onRegenerate: () => void;
onRegenerate: (selectedText?: string) => void;
regenerating: boolean;
}
@@ -21,13 +22,25 @@ export function LyricsCard({
}: LyricsCardProps) {
const ref = useAutoHeight(lyrics, 200);
const dirty = lyrics !== originalLyrics;
const { t } = useI18n();
const handleRegenerate = () => {
let selected = "";
if (ref.current) {
selected = ref.current.value.substring(
ref.current.selectionStart,
ref.current.selectionEnd
).trim();
}
onRegenerate(selected || undefined);
};
return (
<ResultCard
index={1}
icon={<FontAwesomeIcon icon={faFileAlt} className="text-blue-400" />}
title="Lyrics"
onRegenerate={onRegenerate}
title={t.lyrics}
onRegenerate={handleRegenerate}
regenerating={regenerating}
headerExtra={
<>
@@ -37,25 +50,32 @@ export function LyricsCard({
onClick={() => onChange(originalLyrics)}
className="btn-ghost"
aria-label="Revert lyrics to last generated version"
title="Revert to last generated"
title={t.revert}
>
<FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span>
<span>{t.revert}</span>
</button>
)}
{lyrics && <CopyButton value={lyrics} label="Copy" />}
{lyrics && <CopyButton value={lyrics} label={t.copy} />}
</>
}
>
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 200 }}
/>
<div className="flex flex-col gap-2">
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
style={{ minHeight: 200 }}
/>
{lyrics && (
<p className="text-[10px] text-fg-muted font-medium px-1">
{t.partialRegenHint}
</p>
)}
</div>
</ResultCard>
);
}
+7 -6
View File
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUndo } from "@fortawesome/free-solid-svg-icons";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface YouTubeCardProps {
description: string;
@@ -22,12 +23,13 @@ export function YouTubeCard({
}: YouTubeCardProps) {
const ref = useAutoHeight(description, 300);
const dirty = description !== originalDescription;
const { t } = useI18n();
return (
<ResultCard
index={4}
icon={<FontAwesomeIcon icon={faYoutube} className="text-red-500" />}
title="YouTube Description"
title={t.youtubeDesc}
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={
@@ -38,13 +40,13 @@ export function YouTubeCard({
onClick={() => onChange(originalDescription)}
className="btn-ghost"
aria-label="Revert description to last generated version"
title="Revert to last generated"
title={t.revert}
>
<FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span>
<span>{t.revert}</span>
</button>
)}
{description && <CopyButton value={description} label="Copy" />}
{description && <CopyButton value={description} label={t.copy} />}
</>
}
>
@@ -52,8 +54,7 @@ export function YouTubeCard({
ref={ref}
value={description}
onChange={(e) => onChange(e.target.value)}
rows={10}
className="textarea text-sm leading-relaxed scrollbar-thin"
className="textarea text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
style={{ minHeight: 300 }}
/>
</ResultCard>
+26 -8
View File
@@ -140,6 +140,15 @@
color: transparent;
}
.gradient-text-animated {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 50%, var(--accent-secondary) 100%);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gradient-shift 6s linear infinite;
}
.gradient-bg {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 60%, var(--accent-secondary) 100%);
}
@@ -185,24 +194,33 @@
animation: blob-drift-3 26s ease-in-out infinite;
}
/* Animated logo bars */
/* Animated logo bars (5 bars, matching favicon heights, slow) */
.music-bar {
display: inline-block;
width: 3px;
border-radius: 2px;
background: linear-gradient(180deg, var(--accent-warm) 0%, var(--accent-primary) 100%);
transform-origin: bottom center;
opacity: 0.9;
}
.music-bar-1 { animation: bar-bounce 1.0s ease-in-out infinite; }
.music-bar-2 { animation: bar-bounce 1.0s ease-in-out 0.15s infinite; }
.music-bar-3 { animation: bar-bounce 1.0s ease-in-out 0.30s infinite; }
.music-bar-4 { animation: bar-bounce 1.0s ease-in-out 0.45s infinite; }
.music-bar-1 { animation: bar-bounce-1 8.0s ease-in-out infinite; }
.music-bar-2 { animation: bar-bounce-2 11.0s ease-in-out infinite; }
.music-bar-3 { animation: bar-bounce-3 9.0s ease-in-out infinite; }
.music-bar-4 { animation: bar-bounce-4 13.0s ease-in-out infinite; }
.music-bar-5 { animation: bar-bounce-5 10.0s ease-in-out infinite; }
}
/* Keyframes */
@keyframes bar-bounce {
0%, 100% { height: 6px; opacity: 0.6; }
50% { height: 18px; opacity: 1; }
@keyframes bar-bounce-1 { 0%, 100% { height: 6px; } 50% { height: 12px; } }
@keyframes bar-bounce-2 { 0%, 100% { height: 11px; } 50% { height: 22px; } }
@keyframes bar-bounce-3 { 0%, 100% { height: 5px; } 50% { height: 10px; } }
@keyframes bar-bounce-4 { 0%, 100% { height: 8px; } 50% { height: 16px; } }
@keyframes bar-bounce-5 { 0%, 100% { height: 4px; } 50% { height: 8px; } }
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes blob-drift-1 {
+42 -51
View File
@@ -1,15 +1,12 @@
// Recent-generations history. Persists the last N successful generations in
// localStorage so the user can revisit a song without re-running the model.
// Shared history — stored on the server so all users see the same entries.
// Falls back gracefully if the network is unavailable.
import type { InputValues } from "../components/InputPanel";
import type { SongAssets } from "./types";
const HISTORY_KEY = "melodymuse-history";
const MAX_HISTORY = 6;
const API_BASE =
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
// Fired on `window` whenever the history changes (add/remove/clear). The
// HistoryPanel listens for this so it can refresh its visible list without
// the user having to close and reopen it.
export const HISTORY_UPDATED_EVENT = "melodymuse-history-updated";
export function notifyHistoryUpdated(): void {
@@ -25,63 +22,57 @@ export interface HistoryEntry {
assets: SongAssets;
}
function readAll(): HistoryEntry[] {
if (typeof window === "undefined") return [];
export async function loadHistory(): Promise<HistoryEntry[]> {
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(e): e is HistoryEntry =>
e &&
typeof e === "object" &&
typeof e.id === "string" &&
typeof e.timestamp === "number" &&
typeof e.input === "object" &&
typeof e.assets === "object",
);
const resp = await fetch(`${API_BASE}/api/history`);
if (!resp.ok) return [];
const data = await resp.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function writeAll(entries: HistoryEntry[]): void {
try {
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
} catch {
// localStorage quota or disabled — fail silently; history is a nice-to-have.
}
notifyHistoryUpdated();
}
export function loadHistory(): HistoryEntry[] {
return readAll();
}
export function addToHistory(
export async function addToHistory(
input: InputValues,
assets: SongAssets,
): HistoryEntry[] {
): Promise<void> {
const entry: HistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: Date.now(),
input,
assets,
};
const next = [entry, ...readAll()].slice(0, MAX_HISTORY);
writeAll(next);
return next;
try {
await fetch(`${API_BASE}/api/history`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry),
});
notifyHistoryUpdated();
} catch {
// fail silently — history is a nice-to-have
}
}
export function removeFromHistory(id: string): HistoryEntry[] {
const next = readAll().filter((e) => e.id !== id);
writeAll(next);
return next;
export async function removeFromHistory(id: string): Promise<void> {
try {
await fetch(`${API_BASE}/api/history/${encodeURIComponent(id)}`, {
method: "DELETE",
});
notifyHistoryUpdated();
} catch {
// fail silently
}
}
export function clearHistory(): void {
writeAll([]);
export async function clearHistory(): Promise<void> {
try {
await fetch(`${API_BASE}/api/history`, { method: "DELETE" });
notifyHistoryUpdated();
} catch {
// fail silently
}
}
export function formatRelative(
@@ -90,12 +81,12 @@ export function formatRelative(
): string {
const diff = Math.max(0, now - timestamp);
const s = Math.floor(diff / 1000);
if (s < 60) return "just now";
if (s < 60) return "gerade eben";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
if (m < 60) return `vor ${m} Min.`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
if (h < 24) return `vor ${h} Std.`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(timestamp).toLocaleDateString();
if (d < 7) return `vor ${d} Tagen`;
return new Date(timestamp).toLocaleDateString("de-DE");
}
+88 -23
View File
@@ -5,27 +5,27 @@ type Language = 'de' | 'en';
const translations = {
de: {
appTitle: 'MelodyMuse',
appSubtitle: 'Generiere Suno Song-Assets mit KI',
appSubtitle: 'KI-gestützte Suno Song-Asset-Erstellung',
musicIdea: 'Musik-Idee',
tryExample: 'Beispiel testen',
placeholderIdea: 'Beschreibe deine Musik-Idee…',
shortcutHint: 'zum Generieren',
surpriseMe: 'Überrasche mich',
goCrazy: 'Durchdrehen',
language: 'Sprache',
musicStyle: 'Musikstil',
optional: '(optional)',
placeholderStyle: 'z.B. dark synthwave, analog pads, 110 BPM',
surpriseMe: 'Überrasch mich',
goCrazy: 'Verrückt',
placeholderStyle: 'z.B. Dark Synthwave, analoge Pads, 110 BPM',
mood: 'Stimmung',
placeholderMood: 'z.B. melancholisch, euphorisch, spannend…',
vocals: 'Gesang',
vocalsOption: 'Mit Gesang',
instrumentalOption: 'Instrumental',
generateAssets: 'Song-Assets generieren',
generateAssets: 'Song generieren',
generating: 'Generiere…',
cancelGeneration: 'Generierung abbrechen',
cancelGeneration: 'Abbrechen',
settings: 'Einstellungen',
titles: 'Titel',
// Results
results: 'Ergebnisse',
titles: 'Songtitel',
lyrics: 'Songtext',
style: 'Stil',
videoPrompts: 'Video-Prompts',
@@ -36,37 +36,70 @@ const translations = {
copy: 'Kopieren',
regenerate: 'Neu generieren',
revert: 'Zurücksetzen',
cancel: 'Abbrechen',
// Empty state
emptyStateTitle: 'Dein Song erscheint hier',
emptyStateBody: 'Beschreibe eine Musik-Idee auf der linken Seite und drücke',
emptyStateBody2: 'um Titel, Songtext, Stil, Video-Prompts und eine YouTube-Beschreibung zu erstellen.',
// History
history: 'Verlauf',
noHistory: 'Noch kein Verlauf vorhanden.',
noHistory: 'Noch keine Einträge vorhanden.',
clearHistory: 'Verlauf löschen',
loadEntry: 'Laden',
// Titles
editTitleHint: 'Dieser Titel wird als ZIP-Dateiname verwendet. Du kannst ihn frei bearbeiten.',
editTitlePlaceholder: 'Titel bearbeiten…',
// Lyrics partial
partialRegenHint: 'Text markieren → Neu generieren, um nur diesen Abschnitt zu ersetzen.',
// Server
serverOffline: 'Server offline',
generationFailed: 'Generierung fehlgeschlagen — bitte erneut versuchen',
generationCancelled: 'Generierung abgebrochen',
assetsGenerated: 'Song-Assets generiert',
regeneratedAll: 'Alle Bereiche neu generiert',
regeneratedSection: 'Bereich neu generiert',
assetsGenerated: 'Song-Assets generiert!',
regeneratedAll: 'Alles neu generiert',
regeneratedSection: 'Abschnitt neu generiert:',
// Settings
serverStatus: 'Server-Status',
connected: 'Verbunden',
unreachable: 'Nicht erreichbar',
checking: 'Prüfe…',
recheck: 'Erneut prüfen',
clearDraft: 'Entwurf löschen',
clearDraftConfirm: 'Aktuellen Entwurf aus diesem Browser löschen?',
clearHistoryConfirm: 'Den gesamten Verlauf löschen? Alle generierten Songs werden entfernt.',
backToGenerator: 'Zurück zum Generator',
model: 'Modell',
endpoint: 'Endpunkt',
localData: 'Lokale Daten',
draft: 'Entwurf',
draftSaved: 'gespeichert',
draftEmpty: 'leer',
// Download
selectTitleFirst: 'Zuerst Titel auswählen',
downloadZipBtn: 'ZIP herunterladen',
},
en: {
appTitle: 'MelodyMuse',
appSubtitle: 'Generate Suno song assets with AI',
appSubtitle: 'AI-powered Suno song asset generation',
musicIdea: 'Music idea',
tryExample: 'Try an example',
placeholderIdea: 'Describe your music idea…',
shortcutHint: 'to generate',
surpriseMe: 'Surprise me',
goCrazy: 'Go crazy',
language: 'Language',
musicStyle: 'Music style',
optional: '(optional)',
placeholderStyle: 'e.g. dark synthwave, analog pads, 110 BPM',
surpriseMe: 'Surprise me',
goCrazy: 'Go crazy',
mood: 'Mood',
placeholderMood: 'e.g. melancholic, euphoric, tense…',
vocals: 'Vocals',
vocalsOption: 'Vocals',
instrumentalOption: 'Instrumental',
generateAssets: 'Generate song assets',
generateAssets: 'Generate assets',
generating: 'Generating…',
cancelGeneration: 'Cancel generation',
cancelGeneration: 'Cancel',
settings: 'Settings',
// Results
results: 'Results',
titles: 'Titles',
lyrics: 'Lyrics',
style: 'Style',
@@ -78,14 +111,47 @@ const translations = {
copy: 'Copy',
regenerate: 'Regenerate',
revert: 'Revert',
cancel: 'Cancel',
// Empty state
emptyStateTitle: 'Your song will appear here',
emptyStateBody: 'Describe a music idea on the left and press',
emptyStateBody2: 'to create titles, lyrics, style, video prompts, and a YouTube description.',
// History
history: 'History',
noHistory: 'No history yet.',
clearHistory: 'Clear history',
loadEntry: 'Load',
// Titles
editTitleHint: 'This title is used for the ZIP filename. You can edit it freely.',
editTitlePlaceholder: 'Edit title…',
// Lyrics partial
partialRegenHint: 'Select text → Regenerate to replace only that passage.',
// Server
serverOffline: 'Server offline',
generationFailed: 'Generation failed — please try again',
generationCancelled: 'Generation cancelled',
assetsGenerated: 'Song assets generated',
assetsGenerated: 'Song assets generated!',
regeneratedAll: 'Regenerated all sections',
regeneratedSection: 'Regenerated section',
regeneratedSection: 'Regenerated section:',
// Settings
serverStatus: 'Server status',
connected: 'Connected',
unreachable: 'Unreachable',
checking: 'Checking…',
recheck: 'Re-check',
clearDraft: 'Clear draft',
clearDraftConfirm: 'Delete the current draft from this browser?',
clearHistoryConfirm: 'Clear all history? All generated songs will be removed for everyone.',
backToGenerator: 'Back to generator',
model: 'Model',
endpoint: 'Endpoint',
localData: 'Local data',
draft: 'Draft',
draftSaved: 'saved',
draftEmpty: 'empty',
// Download
selectTitleFirst: 'Select a title first',
downloadZipBtn: 'Download ZIP',
}
};
@@ -100,7 +166,6 @@ interface I18nContextType {
const I18nContext = createContext<I18nContextType | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
// Try to load from localStorage, default to 'de'
const [lang, setLangState] = useState<Language>(() => {
try {
const stored = localStorage.getItem('melodymuse-lang');
+2 -1
View File
@@ -108,10 +108,11 @@ export interface StyleRandomResponse {
export async function randomStyle(
mode: "normal" | "crazy",
signal?: AbortSignal,
idea?: string,
): Promise<string> {
const data = await postJson<StyleRandomResponse>(
"/api/style/random",
{ mode },
{ mode, idea: idea ?? "" },
signal,
);
return data.style;
+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>