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
+40 -4
View File
@@ -203,12 +203,11 @@ without losing the original.
### Recent generations ### Recent generations
The last 6 successful generations are saved in `localStorage` under The last 500 successful generations are saved on the server. Click any entry to restore both the **input form
`melodymuse-history`. Click any entry to restore both the **input form
values** AND the full **generated assets** — handy for comparing two values** AND the full **generated assets** — handy for comparing two
generations of the same idea. generations of the same idea.
Individual entries have an ✕ button to remove them. There's a **Clear all** Individual entries have an ✕ button to remove them. There's a **Clear history**
button at the bottom of the history panel. button at the bottom of the history panel.
### Download the ZIP ### Download the ZIP
@@ -393,6 +392,43 @@ provider, the key, or the prompts.
| `LLM_MODEL` | | `MiniMax-M3` | | | `LLM_MODEL` | | `MiniMax-M3` | |
| `PORT` | | `3000` | | | `PORT` | | `3000` | |
| `CORS_ORIGIN` | | `*` | Lock this down in production | | `CORS_ORIGIN` | | `*` | Lock this down in production |
| `DATA_DIR` | | `data` | Directory for persistent storage (e.g. `history.json`) |
### Docker and History Persistence
The application saves recent generations (history) to a file named `history.json` inside the directory specified by the `DATA_DIR` environment variable (defaults to `data` in the project root).
If you are running the application using Docker, any files written inside the container will be lost when the container is rebuilt or restarted. To ensure your history survives a Docker rebuild, you **must set up a Docker volume** mapped to the `DATA_DIR`.
**Example using `docker run`:**
```sh
docker run -d \
-p 3000:3000 \
-e LLM_ENDPOINT="https://api.minimax.chat/v1" \
-e LLM_API_KEY="sk-..." \
-e DATA_DIR="/app/data" \
-v melodymuse-data:/app/data \
melodymuse-image
```
**Example using `docker-compose.yml`:**
```yaml
services:
melodymuse:
image: melodymuse-image
ports:
- "3000:3000"
environment:
- LLM_ENDPOINT=https://api.minimax.chat/v1
- LLM_API_KEY=sk-...
- DATA_DIR=/app/data
volumes:
- melodymuse-data:/app/data
volumes:
melodymuse-data:
```
This ensures the `history.json` file is securely stored on your host machine and persists across rebuilds.
### Environment variables (Vite, dev only) ### Environment variables (Vite, dev only)
@@ -435,7 +471,7 @@ Response: `{ "style": "…", "mode": "…" }`.
- The API key is held by the Node process via `process.env`. It's never - The API key is held by the Node process via `process.env`. It's never
sent to the browser in any response, not even in the health check. sent to the browser in any response, not even in the health check.
- The `localStorage` keys the SPA writes: - The `localStorage` keys the SPA writes:
- `melodymuse-history` — last 6 generations - `melodymuse-draft` — current unsaved input draft
- `melodymuse-theme``'dark'` or `'light'` - `melodymuse-theme``'dark'` or `'light'`
- `melodymuse-config` — reserved, currently unused (kept for future - `melodymuse-config` — reserved, currently unused (kept for future
client-side server-URL override) client-side server-URL override)
+102 -4
View File
@@ -18,7 +18,7 @@
// (default: "*", fine for single-user personal use) // (default: "*", fine for single-user personal use)
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises'; import { readFile, writeFile, stat, mkdir } from 'node:fs/promises';
import { extname, join } from 'node:path'; import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -36,6 +36,9 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
const DIST = join(__dirname, 'dist'); const DIST = join(__dirname, 'dist');
const PORT = parseInt(process.env.PORT || '3000', 10); const PORT = parseInt(process.env.PORT || '3000', 10);
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'; const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data');
const HISTORY_FILE = join(DATA_DIR, 'history.json');
const MAX_HISTORY = 500;
const LLM = { const LLM = {
endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''), endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''),
@@ -52,10 +55,32 @@ if (!LLM.endpoint || !LLM.apiKey) {
process.exit(1); process.exit(1);
} }
// Ensure data directory exists
await mkdir(DATA_DIR, { recursive: true });
console.log(`[startup] LLM endpoint: ${LLM.endpoint}`); console.log(`[startup] LLM endpoint: ${LLM.endpoint}`);
console.log(`[startup] LLM model: ${LLM.model}`); console.log(`[startup] LLM model: ${LLM.model}`);
console.log(`[startup] Data dir: ${DATA_DIR}`);
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`); console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
// ──────────────────────────────────────────────────────────────────────────────
// History helpers
// ──────────────────────────────────────────────────────────────────────────────
async function readHistory() {
try {
const raw = await readFile(HISTORY_FILE, 'utf-8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async function writeHistory(entries) {
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
}
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
// JSON extraction + shape validation // JSON extraction + shape validation
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -330,13 +355,18 @@ async function handleStyleRandom(req, res) {
catch (err) { return json(res, { error: err.message }, 400); } catch (err) { return json(res, { error: err.message }, 400); }
const mode = body?.mode === 'crazy' ? 'crazy' : 'normal'; const mode = body?.mode === 'crazy' ? 'crazy' : 'normal';
const idea = typeof body?.idea === 'string' ? body.idea.trim() : '';
const signal = combinedSignal(req); const signal = combinedSignal(req);
const userMsg = idea
? `Generate one style description now. (User's idea: "${idea}")`
: 'Generate one style description now.';
try { try {
const content = await callProvider( const content = await callProvider(
[ [
{ role: 'system', content: buildStylePrompt(mode) }, { role: 'system', content: buildStylePrompt(mode, idea) },
{ role: 'user', content: 'Generate one style description now.' }, { role: 'user', content: userMsg },
], ],
{ max_tokens: 120 }, { max_tokens: 120 },
signal, signal,
@@ -346,13 +376,67 @@ async function handleStyleRandom(req, res) {
json(res, { style, mode }); json(res, { style, mode });
} catch (err) { } catch (err) {
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) { if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
return; // Client gave up; no point responding. return;
} }
console.error('[style/random]', err); console.error('[style/random]', err);
json(res, { error: err.message || 'Failed to generate style' }, 500); json(res, { error: err.message || 'Failed to generate style' }, 500);
} }
} }
async function handleGetHistory(req, res) {
try {
const entries = await readHistory();
json(res, entries);
} catch (err) {
console.error('[history/get]', err);
json(res, { error: 'Failed to read history' }, 500);
}
}
async function handleAddHistory(req, res) {
let body;
try { body = await readJsonBody(req); }
catch (err) { return json(res, { error: err.message }, 400); }
if (!body || typeof body.id !== 'string' || !body.input || !body.assets) {
return json(res, { error: 'Invalid history entry' }, 400);
}
try {
const entries = await readHistory();
// Deduplicate by id
const filtered = entries.filter(e => e.id !== body.id);
const next = [body, ...filtered].slice(0, MAX_HISTORY);
await writeHistory(next);
json(res, { ok: true });
} catch (err) {
console.error('[history/add]', err);
json(res, { error: 'Failed to save history' }, 500);
}
}
async function handleDeleteHistory(req, res, id) {
try {
const entries = await readHistory();
const next = entries.filter(e => e.id !== id);
await writeHistory(next);
json(res, { ok: true });
} catch (err) {
console.error('[history/delete]', err);
json(res, { error: 'Failed to delete entry' }, 500);
}
}
async function handleClearHistory(req, res) {
try {
await writeHistory([]);
json(res, { ok: true });
} catch (err) {
console.error('[history/clear]', err);
json(res, { error: 'Failed to clear history' }, 500);
}
}
async function handleGenerate(req, res) { async function handleGenerate(req, res) {
let body; let body;
try { body = await readJsonBody(req); } try { body = await readJsonBody(req); }
@@ -430,6 +514,20 @@ const server = createServer(async (req, res) => {
if (req.method === 'POST' && req.url.split('?')[0] === '/api/style/random') { if (req.method === 'POST' && req.url.split('?')[0] === '/api/style/random') {
return handleStyleRandom(req, res); return handleStyleRandom(req, res);
} }
// History endpoints
if (req.method === 'GET' && req.url.split('?')[0] === '/api/history') {
return handleGetHistory(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/history') {
return handleAddHistory(req, res);
}
if (req.method === 'DELETE' && req.url.split('?')[0] === '/api/history') {
return handleClearHistory(req, res);
}
const deleteMatch = req.method === 'DELETE' && req.url.match(/^\/api\/history\/([^/?]+)/);
if (deleteMatch) {
return handleDeleteHistory(req, res, decodeURIComponent(deleteMatch[1]));
}
// Anything under /api that didn't match is a 404 (don't fall through to the SPA). // Anything under /api that didn't match is a 404 (don't fall through to the SPA).
if (req.url.startsWith('/api/')) { if (req.url.startsWith('/api/')) {
+35 -2
View File
@@ -91,6 +91,7 @@ Output ONE short Suno style description.
Requirements: Requirements:
- A cohesive, production-ready combination of genres, instruments, BPM, mood, - A cohesive, production-ready combination of genres, instruments, BPM, mood,
and vocal style (or "instrumental, no vocals" if appropriate). and vocal style (or "instrumental, no vocals" if appropriate).
- If the user provides a music idea, tailor the style to complement it naturally.
- Comma-separated keywords. No sentences, no bullet points, no labels. - Comma-separated keywords. No sentences, no bullet points, no labels.
- Maximum 25 words. - Maximum 25 words.
- Output ONLY the style description line — no quotes, no commentary, no prefix, - Output ONLY the style description line — no quotes, no commentary, no prefix,
@@ -108,6 +109,8 @@ Output ONE short Suno style description.
Requirements: Requirements:
- DELIBERATELY combine genres, eras, or instruments that don't normally mix. - DELIBERATELY combine genres, eras, or instruments that don't normally mix.
- If the user provides a music idea, use it as a jumping-off point for an
unexpected, surprising twist on that idea.
- The combination should still be parseable by Suno: use real instrument and - The combination should still be parseable by Suno: use real instrument and
genre names, include a BPM, mention a vocal style. genre names, include a BPM, mention a vocal style.
- Comma-separated keywords. No sentences, no bullet points, no labels. - Comma-separated keywords. No sentences, no bullet points, no labels.
@@ -122,12 +125,40 @@ Examples of the expected output (do NOT copy these verbatim):
- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM - medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM
- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`; - tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`;
export const SYSTEM_PROMPT_LYRICS_PARTIAL = `You are a music production assistant specializing in Suno AI song creation.
The user has existing lyrics and wants to regenerate a SPECIFIC selected passage only.
Rules:
- Rewrite ONLY the selected passage. Keep everything outside it exactly the same.
- Match the existing rhyme scheme, meter, and section structure.
- Keep the same emotional tone, language, and Suno section tags.
- The lyrics must NOT reference the genre, instruments, or music production.
- Output ONLY the rewritten passage text — no JSON, no explanation, no quotes around it.
- Do NOT include section tags in your output unless the selected passage already contained them.`;
export function buildSystemPrompt(req) { export function buildSystemPrompt(req) {
if (req.section === 'lyrics_partial') return SYSTEM_PROMPT_LYRICS_PARTIAL;
if (req.section === 'all') return SYSTEM_PROMPT_ALL; if (req.section === 'all') return SYSTEM_PROMPT_ALL;
return SYSTEM_PROMPT_PARTIAL; return SYSTEM_PROMPT_PARTIAL;
} }
export function buildUserMessage(req) { export function buildUserMessage(req) {
if (req.section === 'lyrics_partial') {
return [
`Music idea: ${req.input}`,
`Language: ${req.language}`,
req.mood ? `Mood: ${req.mood}` : '',
req.vocals ? `Vocals: ${req.vocals}` : '',
'',
'Full current lyrics:',
req.context?.lyrics ?? '',
'',
'Selected passage to rewrite:',
req.selected_text ?? '',
].filter(l => l !== null).join('\n');
}
const lines = []; const lines = [];
lines.push(`Music idea: ${req.input}`); lines.push(`Music idea: ${req.input}`);
lines.push(`Language: ${req.language}`); lines.push(`Language: ${req.language}`);
@@ -145,6 +176,8 @@ export function buildUserMessage(req) {
return lines.join('\n'); return lines.join('\n');
} }
export function buildStylePrompt(mode) { export function buildStylePrompt(mode, idea) {
return mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL; const base = mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL;
if (!idea || !idea.trim()) return base;
return base + `\n\nUser's music idea (use as context for tailoring the style): "${idea.trim()}"`;
} }
+39 -19
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { import {
clearHistory, clearHistory,
formatRelative, formatRelative,
@@ -20,16 +20,26 @@ interface HistoryDrawerProps {
export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) { export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
const [entries, setEntries] = useState<HistoryEntry[]>([]); const [entries, setEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const { t } = useI18n(); const { t } = useI18n();
useEffect(() => { const fetchHistory = async () => {
setEntries(loadHistory()); setLoading(true);
}, []); try {
const data = await loadHistory();
setEntries(data);
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
const handler = () => setEntries(loadHistory()); if (open) fetchHistory();
window.addEventListener(HISTORY_UPDATED_EVENT, handler); }, [open]);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, handler);
useEffect(() => {
window.addEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
}, []); }, []);
// Close on Escape // 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"> <span className="flex items-center gap-2 text-sm font-bold text-fg">
<FontAwesomeIcon icon={faHistory} className="text-accent-primary" /> <FontAwesomeIcon icon={faHistory} className="text-accent-primary" />
{t.history} {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"> <span className="text-[10px] bg-accent-primary/15 text-accent-primary px-1.5 py-0.5 rounded-full font-bold">
{entries.length} {entries.length}
</span> </span>
)} )}
</span> </span>
<button <div className="flex items-center gap-2">
onClick={onClose} {loading && <FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-3 h-3" />}
className="w-7 h-7 flex items-center justify-center rounded-lg text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors" <button
aria-label="Close history" 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"
<FontAwesomeIcon icon={faTimes} /> aria-label="Close history"
</button> >
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
</div> </div>
{/* Entries */} {/* Entries */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-2 space-y-1"> <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> <p className="px-3 py-10 text-xs text-fg-muted text-center italic">{t.noHistory}</p>
) : ( ) : (
entries.map((e) => ( entries.map((e) => (
@@ -90,7 +103,9 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
key={e.id} key={e.id}
entry={e} entry={e}
onLoad={() => { onLoad(e); onClose(); }} 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"> <div className="p-2 border-t border-border shrink-0">
<button <button
type="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" 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} /> <FontAwesomeIcon icon={faTrashAlt} />
{t.history === 'History' ? 'Clear history' : 'Verlauf löschen'} {t.clearHistory}
</button> </button>
</div> </div>
)} )}
+39 -25
View File
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faCircleNotch, faCircleNotch,
faWandMagicSparkles, faWandMagicSparkles,
faShuffle,
faFire, faFire,
faMicrophone, faMicrophone,
faMusic, faMusic,
@@ -89,42 +88,65 @@ export function InputPanel({
} }
}; };
const fillExample = () => { // "Surprise me" fills style. If idea is empty, fills a random idea too.
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
const handleSurpriseMe = async () => { const handleSurpriseMe = async () => {
if (styleLoading) return; 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 ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
const ctrl = new AbortController(); const ctrl = new AbortController();
setStyleLoading("normal"); setStyleLoading("normal");
try { try {
const style = await randomStyle("normal", ctrl.signal); const style = await randomStyle("normal", ctrl.signal, ideaToUse);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals }); onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; if (err instanceof DOMException && err.name === "AbortError") return;
// Fallback to local style if server fails onChange({
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals }); ...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} finally { } finally {
setStyleLoading((curr) => (curr === "normal" ? null : curr)); 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 () => { const handleGoCrazy = async () => {
if (styleLoading) return; if (styleLoading) return;
const hasIdea = values.idea.trim().length > 0;
const ex = CRAZY_PAIRS[Math.floor(Math.random() * CRAZY_PAIRS.length)]; const ex = CRAZY_PAIRS[Math.floor(Math.random() * CRAZY_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
const ctrl = new AbortController(); const ctrl = new AbortController();
setStyleLoading("crazy"); setStyleLoading("crazy");
try { try {
const style = await randomStyle("crazy", ctrl.signal); const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals }); onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; 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 { } finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr)); setStyleLoading((curr) => (curr === "crazy" ? null : curr));
} }
@@ -151,15 +173,7 @@ export function InputPanel({
/> />
{/* Example / Surprise / Crazy buttons */} {/* Example / Surprise / Crazy buttons */}
<div className="flex flex-wrap gap-2 pt-0.5"> <div className="flex flex-wrap gap-2 pt-0.5">
<button {/* Removed Try an Example 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>
<button <button
type="button" type="button"
onClick={handleSurpriseMe} onClick={handleSurpriseMe}
+12 -9
View File
@@ -7,6 +7,7 @@ import { LyricsCard } from "./cards/LyricsCard";
import { StyleCard } from "./cards/StyleCard"; import { StyleCard } from "./cards/StyleCard";
import { VideoPromptsCard } from "./cards/VideoPromptsCard"; import { VideoPromptsCard } from "./cards/VideoPromptsCard";
import { YouTubeCard } from "./cards/YouTubeCard"; import { YouTubeCard } from "./cards/YouTubeCard";
import { useI18n } from "../lib/i18n";
export type SectionKey = export type SectionKey =
| "titles" | "titles"
@@ -27,7 +28,7 @@ interface ResultsPanelProps {
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void; onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
onChangeVideoPrompt: (index: number, value: string) => void; onChangeVideoPrompt: (index: number, value: string) => void;
onRegenerateAll: () => void; onRegenerateAll: () => void;
onRegenerateSection: (section: SectionKey) => void; onRegenerateSection: (section: SectionKey, selectedText?: string) => void;
onCancel?: () => void; onCancel?: () => void;
anyRegenerating: boolean; anyRegenerating: boolean;
regenerating: RegeneratingMap; regenerating: RegeneratingMap;
@@ -49,11 +50,12 @@ export function ResultsPanel({
regenerating, regenerating,
}: ResultsPanelProps) { }: ResultsPanelProps) {
const showSkeletons = loading && !assets; const showSkeletons = loading && !assets;
const { t } = useI18n();
return ( return (
<div> <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"> <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"> <div className="flex items-center gap-1">
{anyRegenerating && onCancel && ( {anyRegenerating && onCancel && (
<button <button
@@ -61,7 +63,7 @@ export function ResultsPanel({
onClick={onCancel} 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" 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> </button>
)} )}
{assets && ( {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" 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" /> <FontAwesomeIcon icon={faSync} spin={regenerating.all} className="w-3 h-3" />
Regenerate all {t.regenerateAll}
</button> </button>
)} )}
</div> </div>
@@ -100,7 +102,7 @@ export function ResultsPanel({
lyrics={assets.lyrics} lyrics={assets.lyrics}
originalLyrics={originalAssets.lyrics} originalLyrics={originalAssets.lyrics}
onChange={(v) => onChange("lyrics", v)} onChange={(v) => onChange("lyrics", v)}
onRegenerate={() => onRegenerateSection("lyrics")} onRegenerate={(selectedText) => onRegenerateSection("lyrics", selectedText)}
regenerating={Boolean(regenerating.lyrics)} regenerating={Boolean(regenerating.lyrics)}
/> />
<StyleCard <StyleCard
@@ -136,6 +138,7 @@ export function ResultsPanel({
} }
function EmptyState() { function EmptyState() {
const { t } = useI18n();
return ( 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="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"> <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" /> <FontAwesomeIcon icon={faMusic} className="w-7 h-7 text-accent-primary" />
</div> </div>
</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"> <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"> <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>{" "} </kbd>{" "}
to create titles, lyrics, style, video prompts, and a YouTube description. {t.emptyStateBody2}
</p> </p>
</div> </div>
); );
+35 -15
View File
@@ -3,12 +3,13 @@ import { CopyButton } from "../CopyButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileAlt, faUndo } from "@fortawesome/free-solid-svg-icons"; import { faFileAlt, faUndo } from "@fortawesome/free-solid-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight"; import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface LyricsCardProps { interface LyricsCardProps {
lyrics: string; lyrics: string;
originalLyrics: string; originalLyrics: string;
onChange: (next: string) => void; onChange: (next: string) => void;
onRegenerate: () => void; onRegenerate: (selectedText?: string) => void;
regenerating: boolean; regenerating: boolean;
} }
@@ -21,13 +22,25 @@ export function LyricsCard({
}: LyricsCardProps) { }: LyricsCardProps) {
const ref = useAutoHeight(lyrics, 200); const ref = useAutoHeight(lyrics, 200);
const dirty = lyrics !== originalLyrics; 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 ( return (
<ResultCard <ResultCard
index={1} index={1}
icon={<FontAwesomeIcon icon={faFileAlt} className="text-blue-400" />} icon={<FontAwesomeIcon icon={faFileAlt} className="text-blue-400" />}
title="Lyrics" title={t.lyrics}
onRegenerate={onRegenerate} onRegenerate={handleRegenerate}
regenerating={regenerating} regenerating={regenerating}
headerExtra={ headerExtra={
<> <>
@@ -37,25 +50,32 @@ export function LyricsCard({
onClick={() => onChange(originalLyrics)} onClick={() => onChange(originalLyrics)}
className="btn-ghost" className="btn-ghost"
aria-label="Revert lyrics to last generated version" 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" /> <FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span> <span>{t.revert}</span>
</button> </button>
)} )}
{lyrics && <CopyButton value={lyrics} label="Copy" />} {lyrics && <CopyButton value={lyrics} label={t.copy} />}
</> </>
} }
> >
<textarea <div className="flex flex-col gap-2">
ref={ref} <textarea
value={lyrics} ref={ref}
onChange={(e) => onChange(e.target.value)} value={lyrics}
placeholder="Lyrics will appear here…" onChange={(e) => onChange(e.target.value)}
spellCheck placeholder="Lyrics will appear here…"
className="textarea font-mono text-sm leading-relaxed scrollbar-thin" spellCheck
style={{ minHeight: 200 }} 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> </ResultCard>
); );
} }
+7 -6
View File
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUndo } from "@fortawesome/free-solid-svg-icons"; import { faUndo } from "@fortawesome/free-solid-svg-icons";
import { faYoutube } from "@fortawesome/free-brands-svg-icons"; import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight"; import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface YouTubeCardProps { interface YouTubeCardProps {
description: string; description: string;
@@ -22,12 +23,13 @@ export function YouTubeCard({
}: YouTubeCardProps) { }: YouTubeCardProps) {
const ref = useAutoHeight(description, 300); const ref = useAutoHeight(description, 300);
const dirty = description !== originalDescription; const dirty = description !== originalDescription;
const { t } = useI18n();
return ( return (
<ResultCard <ResultCard
index={4} index={4}
icon={<FontAwesomeIcon icon={faYoutube} className="text-red-500" />} icon={<FontAwesomeIcon icon={faYoutube} className="text-red-500" />}
title="YouTube Description" title={t.youtubeDesc}
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
regenerating={regenerating} regenerating={regenerating}
headerExtra={ headerExtra={
@@ -38,13 +40,13 @@ export function YouTubeCard({
onClick={() => onChange(originalDescription)} onClick={() => onChange(originalDescription)}
className="btn-ghost" className="btn-ghost"
aria-label="Revert description to last generated version" 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" /> <FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span> <span>{t.revert}</span>
</button> </button>
)} )}
{description && <CopyButton value={description} label="Copy" />} {description && <CopyButton value={description} label={t.copy} />}
</> </>
} }
> >
@@ -52,8 +54,7 @@ export function YouTubeCard({
ref={ref} ref={ref}
value={description} value={description}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
rows={10} className="textarea text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 300 }} style={{ minHeight: 300 }}
/> />
</ResultCard> </ResultCard>
+26 -8
View File
@@ -140,6 +140,15 @@
color: transparent; 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 { .gradient-bg {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 60%, var(--accent-secondary) 100%); 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; animation: blob-drift-3 26s ease-in-out infinite;
} }
/* Animated logo bars */ /* Animated logo bars (5 bars, matching favicon heights, slow) */
.music-bar { .music-bar {
display: inline-block; display: inline-block;
width: 3px; width: 3px;
border-radius: 2px; border-radius: 2px;
background: linear-gradient(180deg, var(--accent-warm) 0%, var(--accent-primary) 100%); background: linear-gradient(180deg, var(--accent-warm) 0%, var(--accent-primary) 100%);
transform-origin: bottom center; transform-origin: bottom center;
opacity: 0.9;
} }
.music-bar-1 { animation: bar-bounce 1.0s ease-in-out infinite; } .music-bar-1 { animation: bar-bounce-1 8.0s ease-in-out infinite; }
.music-bar-2 { animation: bar-bounce 1.0s ease-in-out 0.15s infinite; } .music-bar-2 { animation: bar-bounce-2 11.0s ease-in-out infinite; }
.music-bar-3 { animation: bar-bounce 1.0s ease-in-out 0.30s infinite; } .music-bar-3 { animation: bar-bounce-3 9.0s ease-in-out infinite; }
.music-bar-4 { animation: bar-bounce 1.0s ease-in-out 0.45s 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 */
@keyframes bar-bounce { @keyframes bar-bounce-1 { 0%, 100% { height: 6px; } 50% { height: 12px; } }
0%, 100% { height: 6px; opacity: 0.6; } @keyframes bar-bounce-2 { 0%, 100% { height: 11px; } 50% { height: 22px; } }
50% { height: 18px; opacity: 1; } @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 { @keyframes blob-drift-1 {
+42 -51
View File
@@ -1,15 +1,12 @@
// Recent-generations history. Persists the last N successful generations in // Shared history — stored on the server so all users see the same entries.
// localStorage so the user can revisit a song without re-running the model. // Falls back gracefully if the network is unavailable.
import type { InputValues } from "../components/InputPanel"; import type { InputValues } from "../components/InputPanel";
import type { SongAssets } from "./types"; import type { SongAssets } from "./types";
const HISTORY_KEY = "melodymuse-history"; const API_BASE =
const MAX_HISTORY = 6; (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 const HISTORY_UPDATED_EVENT = "melodymuse-history-updated";
export function notifyHistoryUpdated(): void { export function notifyHistoryUpdated(): void {
@@ -25,63 +22,57 @@ export interface HistoryEntry {
assets: SongAssets; assets: SongAssets;
} }
function readAll(): HistoryEntry[] { export async function loadHistory(): Promise<HistoryEntry[]> {
if (typeof window === "undefined") return [];
try { try {
const raw = window.localStorage.getItem(HISTORY_KEY); const resp = await fetch(`${API_BASE}/api/history`);
if (!raw) return []; if (!resp.ok) return [];
const parsed = JSON.parse(raw); const data = await resp.json();
if (!Array.isArray(parsed)) return []; return Array.isArray(data) ? data : [];
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",
);
} catch { } catch {
return []; return [];
} }
} }
function writeAll(entries: HistoryEntry[]): void { export async function addToHistory(
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(
input: InputValues, input: InputValues,
assets: SongAssets, assets: SongAssets,
): HistoryEntry[] { ): Promise<void> {
const entry: HistoryEntry = { const entry: HistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: Date.now(), timestamp: Date.now(),
input, input,
assets, assets,
}; };
const next = [entry, ...readAll()].slice(0, MAX_HISTORY); try {
writeAll(next); await fetch(`${API_BASE}/api/history`, {
return next; 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[] { export async function removeFromHistory(id: string): Promise<void> {
const next = readAll().filter((e) => e.id !== id); try {
writeAll(next); await fetch(`${API_BASE}/api/history/${encodeURIComponent(id)}`, {
return next; method: "DELETE",
});
notifyHistoryUpdated();
} catch {
// fail silently
}
} }
export function clearHistory(): void { export async function clearHistory(): Promise<void> {
writeAll([]); try {
await fetch(`${API_BASE}/api/history`, { method: "DELETE" });
notifyHistoryUpdated();
} catch {
// fail silently
}
} }
export function formatRelative( export function formatRelative(
@@ -90,12 +81,12 @@ export function formatRelative(
): string { ): string {
const diff = Math.max(0, now - timestamp); const diff = Math.max(0, now - timestamp);
const s = Math.floor(diff / 1000); const s = Math.floor(diff / 1000);
if (s < 60) return "just now"; if (s < 60) return "gerade eben";
const m = Math.floor(s / 60); 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); 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); const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`; if (d < 7) return `vor ${d} Tagen`;
return new Date(timestamp).toLocaleDateString(); return new Date(timestamp).toLocaleDateString("de-DE");
} }
+88 -23
View File
@@ -5,27 +5,27 @@ type Language = 'de' | 'en';
const translations = { const translations = {
de: { de: {
appTitle: 'MelodyMuse', appTitle: 'MelodyMuse',
appSubtitle: 'Generiere Suno Song-Assets mit KI', appSubtitle: 'KI-gestützte Suno Song-Asset-Erstellung',
musicIdea: 'Musik-Idee', musicIdea: 'Musik-Idee',
tryExample: 'Beispiel testen',
placeholderIdea: 'Beschreibe deine Musik-Idee…', placeholderIdea: 'Beschreibe deine Musik-Idee…',
shortcutHint: 'zum Generieren', surpriseMe: 'Überrasche mich',
goCrazy: 'Durchdrehen',
language: 'Sprache', language: 'Sprache',
musicStyle: 'Musikstil', musicStyle: 'Musikstil',
optional: '(optional)', optional: '(optional)',
placeholderStyle: 'z.B. dark synthwave, analog pads, 110 BPM', placeholderStyle: 'z.B. Dark Synthwave, analoge Pads, 110 BPM',
surpriseMe: 'Überrasch mich',
goCrazy: 'Verrückt',
mood: 'Stimmung', mood: 'Stimmung',
placeholderMood: 'z.B. melancholisch, euphorisch, spannend…', placeholderMood: 'z.B. melancholisch, euphorisch, spannend…',
vocals: 'Gesang', vocals: 'Gesang',
vocalsOption: 'Mit Gesang', vocalsOption: 'Mit Gesang',
instrumentalOption: 'Instrumental', instrumentalOption: 'Instrumental',
generateAssets: 'Song-Assets generieren', generateAssets: 'Song generieren',
generating: 'Generiere…', generating: 'Generiere…',
cancelGeneration: 'Generierung abbrechen', cancelGeneration: 'Abbrechen',
settings: 'Einstellungen', settings: 'Einstellungen',
titles: 'Titel', // Results
results: 'Ergebnisse',
titles: 'Songtitel',
lyrics: 'Songtext', lyrics: 'Songtext',
style: 'Stil', style: 'Stil',
videoPrompts: 'Video-Prompts', videoPrompts: 'Video-Prompts',
@@ -36,37 +36,70 @@ const translations = {
copy: 'Kopieren', copy: 'Kopieren',
regenerate: 'Neu generieren', regenerate: 'Neu generieren',
revert: 'Zurücksetzen', 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', 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', serverOffline: 'Server offline',
generationFailed: 'Generierung fehlgeschlagen — bitte erneut versuchen', generationFailed: 'Generierung fehlgeschlagen — bitte erneut versuchen',
generationCancelled: 'Generierung abgebrochen', generationCancelled: 'Generierung abgebrochen',
assetsGenerated: 'Song-Assets generiert', assetsGenerated: 'Song-Assets generiert!',
regeneratedAll: 'Alle Bereiche neu generiert', regeneratedAll: 'Alles neu generiert',
regeneratedSection: 'Bereich 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: { en: {
appTitle: 'MelodyMuse', appTitle: 'MelodyMuse',
appSubtitle: 'Generate Suno song assets with AI', appSubtitle: 'AI-powered Suno song asset generation',
musicIdea: 'Music idea', musicIdea: 'Music idea',
tryExample: 'Try an example',
placeholderIdea: 'Describe your music idea…', placeholderIdea: 'Describe your music idea…',
shortcutHint: 'to generate', surpriseMe: 'Surprise me',
goCrazy: 'Go crazy',
language: 'Language', language: 'Language',
musicStyle: 'Music style', musicStyle: 'Music style',
optional: '(optional)', optional: '(optional)',
placeholderStyle: 'e.g. dark synthwave, analog pads, 110 BPM', placeholderStyle: 'e.g. dark synthwave, analog pads, 110 BPM',
surpriseMe: 'Surprise me',
goCrazy: 'Go crazy',
mood: 'Mood', mood: 'Mood',
placeholderMood: 'e.g. melancholic, euphoric, tense…', placeholderMood: 'e.g. melancholic, euphoric, tense…',
vocals: 'Vocals', vocals: 'Vocals',
vocalsOption: 'Vocals', vocalsOption: 'Vocals',
instrumentalOption: 'Instrumental', instrumentalOption: 'Instrumental',
generateAssets: 'Generate song assets', generateAssets: 'Generate assets',
generating: 'Generating…', generating: 'Generating…',
cancelGeneration: 'Cancel generation', cancelGeneration: 'Cancel',
settings: 'Settings', settings: 'Settings',
// Results
results: 'Results',
titles: 'Titles', titles: 'Titles',
lyrics: 'Lyrics', lyrics: 'Lyrics',
style: 'Style', style: 'Style',
@@ -78,14 +111,47 @@ const translations = {
copy: 'Copy', copy: 'Copy',
regenerate: 'Regenerate', regenerate: 'Regenerate',
revert: 'Revert', 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', history: 'History',
noHistory: 'No history yet.', 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', serverOffline: 'Server offline',
generationFailed: 'Generation failed — please try again', generationFailed: 'Generation failed — please try again',
generationCancelled: 'Generation cancelled', generationCancelled: 'Generation cancelled',
assetsGenerated: 'Song assets generated', assetsGenerated: 'Song assets generated!',
regeneratedAll: 'Regenerated all sections', 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); const I18nContext = createContext<I18nContextType | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) { export function I18nProvider({ children }: { children: ReactNode }) {
// Try to load from localStorage, default to 'de'
const [lang, setLangState] = useState<Language>(() => { const [lang, setLangState] = useState<Language>(() => {
try { try {
const stored = localStorage.getItem('melodymuse-lang'); const stored = localStorage.getItem('melodymuse-lang');
+2 -1
View File
@@ -108,10 +108,11 @@ export interface StyleRandomResponse {
export async function randomStyle( export async function randomStyle(
mode: "normal" | "crazy", mode: "normal" | "crazy",
signal?: AbortSignal, signal?: AbortSignal,
idea?: string,
): Promise<string> { ): Promise<string> {
const data = await postJson<StyleRandomResponse>( const data = await postJson<StyleRandomResponse>(
"/api/style/random", "/api/style/random",
{ mode }, { mode, idea: idea ?? "" },
signal, signal,
); );
return data.style; 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 */ /** Animated equalizer bars for the logo */
function MusicBarsLogo() { function MusicBarsLogo() {
return ( return (
<div className="flex items-end gap-[3px] h-5" aria-hidden> <div className="flex items-end gap-[3px] h-[22px]" aria-hidden>
<div className="music-bar music-bar-1" style={{ height: 8 }} /> <div className="music-bar music-bar-1" style={{ height: 12 }} />
<div className="music-bar music-bar-2" style={{ height: 14 }} /> <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-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> </div>
); );
} }
@@ -90,6 +114,7 @@ export function HomePage() {
const [customTitle, setCustomTitle] = useState<string | null>(null); const [customTitle, setCustomTitle] = useState<string | null>(null);
const [serverOk, setServerOk] = useState<boolean | null>(null); const [serverOk, setServerOk] = useState<boolean | null>(null);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [chainRegenerate, setChainRegenerate] = useState<SectionKey | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating); const anyRegenerating = isAnyBusy(loading, regenerating);
@@ -143,7 +168,7 @@ export function HomePage() {
}, [anyRegenerating, cancel]); }, [anyRegenerating, cancel]);
const buildRequest = useCallback( const buildRequest = useCallback(
(section: GenerateCall["section"], extra: Partial<GenerateCall> = {}): GenerateCall => ({ (section: string, extra: Record<string, unknown> = {}): GenerateCall => ({
input: input.idea.trim(), input: input.idea.trim(),
language: resolveLanguage(input), language: resolveLanguage(input),
...(input.mood.trim() ? { mood: input.mood.trim() } : {}), ...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
@@ -151,7 +176,7 @@ export function HomePage() {
vocals: input.vocals, vocals: input.vocals,
section, section,
...extra, ...extra,
}), } as unknown as GenerateCall),
[input], [input],
); );
@@ -228,16 +253,37 @@ export function HomePage() {
}, [input, buildRequest, runGeneration, t]); }, [input, buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback( const handleRegenerateSection = useCallback(
async (section: SectionKey) => { async (section: SectionKey, selectedText?: string) => {
if (!input.idea.trim() || !assets) return; 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( await runGeneration(
() => buildRequest(section, { context: assets }), () => buildRequest(endpointSection, reqExtras),
(result) => { (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); setAssets(merged);
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged)); setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
// Reset custom title if titles were regenerated
if (section === "titles") setCustomTitle(null); if (section === "titles") setCustomTitle(null);
// Chain YouTube description regeneration if lyrics changed
if (section === "lyrics") {
setChainRegenerate("youtube_description");
}
}, },
(b) => setRegenerating((m) => ({ ...m, [section]: b })), (b) => setRegenerating((m) => ({ ...m, [section]: b })),
`${t.regeneratedSection} ${SECTION_LABELS[section]}`, `${t.regeneratedSection} ${SECTION_LABELS[section]}`,
@@ -246,6 +292,15 @@ export function HomePage() {
[input, assets, buildRequest, runGeneration, t], [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 () => { const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return; if (!assets || !selectedTitle) return;
try { try {
@@ -303,13 +358,13 @@ export function HomePage() {
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<MusicBarsLogo /> <MusicBarsLogo />
<span className="text-sm font-extrabold gradient-text tracking-tight"> <span className="text-sm font-extrabold gradient-text-animated tracking-tight">
MelodyMuse MelodyMuse
</span> </span>
</div> </div>
{/* Right controls */} {/* Right controls */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
{/* Social links — icon only */} {/* Social links — icon only */}
<a <a
href="https://www.youtube.com/@AIWentNonsense" href="https://www.youtube.com/@AIWentNonsense"
@@ -331,6 +386,27 @@ export function HomePage() {
{/* Divider */} {/* Divider */}
<div className="h-5 w-px bg-border" /> <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 */} {/* History toggle */}
<button <button
onClick={() => setHistoryOpen((o) => !o)} onClick={() => setHistoryOpen((o) => !o)}
@@ -349,31 +425,25 @@ export function HomePage() {
<FontAwesomeIcon icon={faCog} className="w-3.5 h-3.5" /> <FontAwesomeIcon icon={faCog} className="w-3.5 h-3.5" />
</Link> </Link>
{/* Language */} {/* Theme toggle switch */}
<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 */}
<button <button
onClick={toggleTheme} 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" 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 theme" 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> </button>
</div> </div>
</div> </div>
+27 -87
View File
@@ -12,35 +12,21 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../lib/toast"; import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm"; import { getServerStatus, type ServerStatus } from "../lib/llm";
import { clearHistory } from "../lib/history";
import { useI18n } from "../lib/i18n"; import { useI18n } from "../lib/i18n";
const HISTORY_KEY = "melodymuse-history";
const DRAFT_KEY = "melodymuse-draft"; const DRAFT_KEY = "melodymuse-draft";
const THEME_KEY = "melodymuse-theme";
interface LocalDataSummary { interface LocalDataSummary {
historyEntries: number;
hasDraft: boolean; hasDraft: boolean;
hasTheme: boolean;
} }
function readLocalDataSummary(): LocalDataSummary { function readLocalDataSummary(): LocalDataSummary {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return { historyEntries: 0, hasDraft: false, hasTheme: false }; return { hasDraft: 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 */
} }
const hasDraft = window.localStorage.getItem(DRAFT_KEY) !== null; const hasDraft = window.localStorage.getItem(DRAFT_KEY) !== null;
const hasTheme = window.localStorage.getItem(THEME_KEY) !== null; return { hasDraft };
return { historyEntries, hasDraft, hasTheme };
} }
export function SettingsPage() { export function SettingsPage() {
@@ -52,9 +38,7 @@ export function SettingsPage() {
const [statusError, setStatusError] = useState<string | null>(null); const [statusError, setStatusError] = useState<string | null>(null);
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [summary, setSummary] = useState<LocalDataSummary>({ const [summary, setSummary] = useState<LocalDataSummary>({
historyEntries: 0,
hasDraft: false, hasDraft: false,
hasTheme: false,
}); });
const check = async (signal?: AbortSignal) => { const check = async (signal?: AbortSignal) => {
@@ -64,7 +48,7 @@ export function SettingsPage() {
const s = await getServerStatus(signal); const s = await getServerStatus(signal);
setStatus(s); setStatus(s);
} catch (err) { } catch (err) {
setStatusError(err instanceof Error ? err.message : "Server unreachable"); setStatusError(err instanceof Error ? err.message : t.unreachable);
setStatus(null); setStatus(null);
} finally { } finally {
setChecking(false); setChecking(false);
@@ -78,40 +62,18 @@ export function SettingsPage() {
return () => ctrl.abort(); return () => ctrl.abort();
}, []); }, []);
const onClearHistory = () => { const onClearHistory = async () => {
if (summary.historyEntries === 0) return; if (!window.confirm(t.clearHistoryConfirm)) return;
if ( await clearHistory();
!window.confirm( toast.info(t.clearHistory);
`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 onClearDraft = () => { const onClearDraft = () => {
if (!summary.hasDraft) return; if (!summary.hasDraft) return;
if (!window.confirm(t.clearDraftConfirm)) return;
window.localStorage.removeItem(DRAFT_KEY); window.localStorage.removeItem(DRAFT_KEY);
setSummary((s) => ({ ...s, hasDraft: false })); setSummary((s) => ({ ...s, hasDraft: false }));
toast.info("In-progress draft cleared"); toast.info(t.clearDraft);
};
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");
}; };
return ( return (
@@ -135,7 +97,7 @@ export function SettingsPage() {
<section> <section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2"> <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" /> <FontAwesomeIcon icon={faServer} className="w-4 h-4 text-accent-primary" />
Server status {t.serverStatus}
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
@@ -145,23 +107,23 @@ export function SettingsPage() {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-fg"> <p className="text-sm font-medium text-fg">
{checking {checking
? "Checking…" ? t.checking
: status : status
? "Connected" ? t.connected
: statusError : statusError
? "Unreachable" ? t.unreachable
: "Unknown"} : "Unknown"}
</p> </p>
{status && ( {status && (
<p className="text-xs text-fg-muted truncate"> <p className="text-xs text-fg-muted truncate mt-1">
Model: <span className="font-mono">{status.model}</span> {t.model}: <span className="font-mono">{status.model}</span>
<br /> <br />
Endpoint:{" "} {t.endpoint}:{" "}
<span className="font-mono">{status.endpoint}</span> <span className="font-mono">{status.endpoint}</span>
</p> </p>
)} )}
{statusError && !checking && ( {statusError && !checking && (
<p className="text-xs text-rose-400 break-words"> <p className="text-xs text-rose-400 break-words mt-1">
{statusError} {statusError}
</p> </p>
)} )}
@@ -174,11 +136,11 @@ export function SettingsPage() {
className="btn-ghost" className="btn-ghost"
> >
{checking ? ( {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" /> <FontAwesomeIcon icon={faPlug} className="w-4 h-4" />
)} )}
<span>Re-check</span> <span>{t.recheck}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -187,36 +149,14 @@ export function SettingsPage() {
<hr className="border-border" /> <hr className="border-border" />
<section> <section>
<h2 className="text-sm font-semibold text-fg mb-3">Local data</h2> <h2 className="text-sm font-semibold text-fg mb-3">{t.localData}</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>
<ul className="text-xs text-fg-muted space-y-1 mb-4"> <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> <li>
<code className="font-mono text-accent-primary">melodymuse-draft</code> {" "} <code className="font-mono text-accent-primary">melodymuse-draft</code> {" "}
{summary.hasDraft ? "saved (in-progress input)" : "empty"} {summary.hasDraft ? t.draftSaved : t.draftEmpty}
</li>
<li>
<code className="font-mono text-accent-primary">melodymuse-theme</code> {" "}
{summary.hasTheme ? "set" : "using default"}
</li> </li>
</ul> </ul>
<div className="flex flex-col gap-2"> <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 <button
type="button" type="button"
onClick={onClearDraft} onClick={onClearDraft}
@@ -224,15 +164,15 @@ export function SettingsPage() {
className="btn-secondary" className="btn-secondary"
> >
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" /> <FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear in-progress draft {t.clearDraft}
</button> </button>
<button <button
type="button" type="button"
onClick={onClearAll} onClick={onClearHistory}
className="btn-secondary text-rose-400 hover:text-rose-300" className="btn-secondary text-rose-400 hover:text-rose-300 border-rose-400/20"
> >
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" /> <FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear all local data {t.clearHistory}
</button> </button>
</div> </div>
</section> </section>
@@ -242,7 +182,7 @@ export function SettingsPage() {
to="/" to="/"
className="text-xs font-semibold text-fg-muted hover:text-accent-primary transition-colors flex items-center justify-center gap-2" 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> </Link>
</div> </div>
</div> </div>