UI enhancements, i18n, history drawer, and requested features
This commit is contained in:
@@ -203,12 +203,11 @@ without losing the original.
|
||||
|
||||
### Recent generations
|
||||
|
||||
The last 6 successful generations are saved in `localStorage` under
|
||||
`melodymuse-history`. Click any entry to restore both the **input form
|
||||
The last 500 successful generations are saved on the server. Click any entry to restore both the **input form
|
||||
values** AND the full **generated assets** — handy for comparing two
|
||||
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.
|
||||
|
||||
### Download the ZIP
|
||||
@@ -393,6 +392,43 @@ provider, the key, or the prompts.
|
||||
| `LLM_MODEL` | | `MiniMax-M3` | |
|
||||
| `PORT` | | `3000` | |
|
||||
| `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)
|
||||
|
||||
@@ -435,7 +471,7 @@ Response: `{ "style": "…", "mode": "…" }`.
|
||||
- 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.
|
||||
- The `localStorage` keys the SPA writes:
|
||||
- `melodymuse-history` — last 6 generations
|
||||
- `melodymuse-draft` — current unsaved input draft
|
||||
- `melodymuse-theme` — `'dark'` or `'light'`
|
||||
- `melodymuse-config` — reserved, currently unused (kept for future
|
||||
client-side server-URL override)
|
||||
|
||||
+102
-4
@@ -18,7 +18,7 @@
|
||||
// (default: "*", fine for single-user personal use)
|
||||
|
||||
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 { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -36,6 +36,9 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const DIST = join(__dirname, 'dist');
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
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 = {
|
||||
endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''),
|
||||
@@ -52,10 +55,32 @@ if (!LLM.endpoint || !LLM.apiKey) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
await mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
console.log(`[startup] LLM endpoint: ${LLM.endpoint}`);
|
||||
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}`);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -330,13 +355,18 @@ async function handleStyleRandom(req, res) {
|
||||
catch (err) { return json(res, { error: err.message }, 400); }
|
||||
|
||||
const mode = body?.mode === 'crazy' ? 'crazy' : 'normal';
|
||||
const idea = typeof body?.idea === 'string' ? body.idea.trim() : '';
|
||||
const signal = combinedSignal(req);
|
||||
|
||||
const userMsg = idea
|
||||
? `Generate one style description now. (User's idea: "${idea}")`
|
||||
: 'Generate one style description now.';
|
||||
|
||||
try {
|
||||
const content = await callProvider(
|
||||
[
|
||||
{ role: 'system', content: buildStylePrompt(mode) },
|
||||
{ role: 'user', content: 'Generate one style description now.' },
|
||||
{ role: 'system', content: buildStylePrompt(mode, idea) },
|
||||
{ role: 'user', content: userMsg },
|
||||
],
|
||||
{ max_tokens: 120 },
|
||||
signal,
|
||||
@@ -346,13 +376,67 @@ async function handleStyleRandom(req, res) {
|
||||
json(res, { style, mode });
|
||||
} catch (err) {
|
||||
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
|
||||
return; // Client gave up; no point responding.
|
||||
return;
|
||||
}
|
||||
console.error('[style/random]', err);
|
||||
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) {
|
||||
let body;
|
||||
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') {
|
||||
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).
|
||||
if (req.url.startsWith('/api/')) {
|
||||
|
||||
+35
-2
@@ -91,6 +91,7 @@ Output ONE short Suno style description.
|
||||
Requirements:
|
||||
- A cohesive, production-ready combination of genres, instruments, BPM, mood,
|
||||
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.
|
||||
- Maximum 25 words.
|
||||
- Output ONLY the style description line — no quotes, no commentary, no prefix,
|
||||
@@ -108,6 +109,8 @@ Output ONE short Suno style description.
|
||||
|
||||
Requirements:
|
||||
- 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
|
||||
genre names, include a BPM, mention a vocal style.
|
||||
- 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
|
||||
- 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) {
|
||||
if (req.section === 'lyrics_partial') return SYSTEM_PROMPT_LYRICS_PARTIAL;
|
||||
if (req.section === 'all') return SYSTEM_PROMPT_ALL;
|
||||
return SYSTEM_PROMPT_PARTIAL;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
lines.push(`Music idea: ${req.input}`);
|
||||
lines.push(`Language: ${req.language}`);
|
||||
@@ -145,6 +176,8 @@ export function buildUserMessage(req) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildStylePrompt(mode) {
|
||||
return mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL;
|
||||
export function buildStylePrompt(mode, idea) {
|
||||
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()}"`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -62,14 +62,38 @@ function loadDraft(): InputValues | null {
|
||||
}
|
||||
}
|
||||
|
||||
function FlagDE() {
|
||||
return (
|
||||
<svg viewBox="0 0 5 3" className="w-5 h-3.5 rounded-[2px] overflow-hidden shadow-sm">
|
||||
<rect width="5" height="3" y="0" fill="#000" />
|
||||
<rect width="5" height="2" y="1" fill="#D00" />
|
||||
<rect width="5" height="1" y="2" fill="#FFCE00" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FlagEN() {
|
||||
return (
|
||||
<svg viewBox="0 0 60 30" className="w-5 h-3.5 rounded-[2px] overflow-hidden shadow-sm">
|
||||
<clipPath id="t"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/></clipPath>
|
||||
<rect width="60" height="30" fill="#012169"/>
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#t)" stroke="#C8102E" strokeWidth="4"/>
|
||||
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
|
||||
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Animated equalizer bars for the logo */
|
||||
function MusicBarsLogo() {
|
||||
return (
|
||||
<div className="flex items-end gap-[3px] h-5" aria-hidden>
|
||||
<div className="music-bar music-bar-1" style={{ height: 8 }} />
|
||||
<div className="music-bar music-bar-2" style={{ height: 14 }} />
|
||||
<div className="flex items-end gap-[3px] h-[22px]" aria-hidden>
|
||||
<div className="music-bar music-bar-1" style={{ height: 12 }} />
|
||||
<div className="music-bar music-bar-2" style={{ height: 22 }} />
|
||||
<div className="music-bar music-bar-3" style={{ height: 10 }} />
|
||||
<div className="music-bar music-bar-4" style={{ height: 6 }} />
|
||||
<div className="music-bar music-bar-4" style={{ height: 16 }} />
|
||||
<div className="music-bar music-bar-5" style={{ height: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +114,7 @@ export function HomePage() {
|
||||
const [customTitle, setCustomTitle] = useState<string | null>(null);
|
||||
const [serverOk, setServerOk] = useState<boolean | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [chainRegenerate, setChainRegenerate] = useState<SectionKey | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const anyRegenerating = isAnyBusy(loading, regenerating);
|
||||
@@ -143,7 +168,7 @@ export function HomePage() {
|
||||
}, [anyRegenerating, cancel]);
|
||||
|
||||
const buildRequest = useCallback(
|
||||
(section: GenerateCall["section"], extra: Partial<GenerateCall> = {}): GenerateCall => ({
|
||||
(section: string, extra: Record<string, unknown> = {}): GenerateCall => ({
|
||||
input: input.idea.trim(),
|
||||
language: resolveLanguage(input),
|
||||
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
|
||||
@@ -151,7 +176,7 @@ export function HomePage() {
|
||||
vocals: input.vocals,
|
||||
section,
|
||||
...extra,
|
||||
}),
|
||||
} as unknown as GenerateCall),
|
||||
[input],
|
||||
);
|
||||
|
||||
@@ -228,16 +253,37 @@ export function HomePage() {
|
||||
}, [input, buildRequest, runGeneration, t]);
|
||||
|
||||
const handleRegenerateSection = useCallback(
|
||||
async (section: SectionKey) => {
|
||||
async (section: SectionKey, selectedText?: string) => {
|
||||
if (!input.idea.trim() || !assets) return;
|
||||
|
||||
const isPartialLyrics = section === "lyrics" && !!selectedText;
|
||||
const endpointSection = isPartialLyrics ? "lyrics_partial" : section;
|
||||
const reqExtras = { context: assets };
|
||||
if (isPartialLyrics) {
|
||||
(reqExtras as any).selected_text = selectedText;
|
||||
}
|
||||
|
||||
await runGeneration(
|
||||
() => buildRequest(section, { context: assets }),
|
||||
() => buildRequest(endpointSection, reqExtras),
|
||||
(result) => {
|
||||
const merged: SongAssets = { ...assets, ...result };
|
||||
let merged: SongAssets;
|
||||
if (isPartialLyrics) {
|
||||
// Replace only the selected text in the existing lyrics
|
||||
const newPartial = (result as any).lyrics || (result as any).text || String(result);
|
||||
const replaced = assets.lyrics.replace(selectedText, newPartial);
|
||||
merged = { ...assets, lyrics: replaced };
|
||||
} else {
|
||||
merged = { ...assets, ...result };
|
||||
}
|
||||
|
||||
setAssets(merged);
|
||||
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
|
||||
// Reset custom title if titles were regenerated
|
||||
if (section === "titles") setCustomTitle(null);
|
||||
|
||||
// Chain YouTube description regeneration if lyrics changed
|
||||
if (section === "lyrics") {
|
||||
setChainRegenerate("youtube_description");
|
||||
}
|
||||
},
|
||||
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
|
||||
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
|
||||
@@ -246,6 +292,15 @@ export function HomePage() {
|
||||
[input, assets, buildRequest, runGeneration, t],
|
||||
);
|
||||
|
||||
// Execute chained regeneration
|
||||
useEffect(() => {
|
||||
if (chainRegenerate && assets && !anyRegenerating) {
|
||||
const section = chainRegenerate;
|
||||
setChainRegenerate(null);
|
||||
handleRegenerateSection(section);
|
||||
}
|
||||
}, [chainRegenerate, assets, anyRegenerating, handleRegenerateSection]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!assets || !selectedTitle) return;
|
||||
try {
|
||||
@@ -303,13 +358,13 @@ export function HomePage() {
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MusicBarsLogo />
|
||||
<span className="text-sm font-extrabold gradient-text tracking-tight">
|
||||
<span className="text-sm font-extrabold gradient-text-animated tracking-tight">
|
||||
MelodyMuse
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Social links — icon only */}
|
||||
<a
|
||||
href="https://www.youtube.com/@AIWentNonsense"
|
||||
@@ -331,6 +386,27 @@ export function HomePage() {
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setLang("de")}
|
||||
className={`flex items-center justify-center transition-all duration-150 ${lang === "de" ? "scale-110 ring-2 ring-accent-primary rounded-[4px]" : "opacity-40 hover:opacity-80"}`}
|
||||
title="Deutsch"
|
||||
>
|
||||
<FlagDE />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang("en")}
|
||||
className={`flex items-center justify-center transition-all duration-150 ${lang === "en" ? "scale-110 ring-2 ring-accent-primary rounded-[4px]" : "opacity-40 hover:opacity-80"}`}
|
||||
title="English"
|
||||
>
|
||||
<FlagEN />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
{/* History toggle */}
|
||||
<button
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
@@ -349,31 +425,25 @@ export function HomePage() {
|
||||
<FontAwesomeIcon icon={faCog} className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setLang("de")}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "de" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
|
||||
title="Deutsch"
|
||||
>
|
||||
🇩🇪
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang("en")}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "en" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
|
||||
title="English"
|
||||
>
|
||||
🇬🇧
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
{/* Theme toggle switch */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-all duration-150"
|
||||
title="Toggle theme"
|
||||
className="relative flex items-center justify-between w-12 h-6 bg-border/50 border border-border/50 hover:bg-border/80 rounded-full p-0.5 cursor-pointer transition-colors"
|
||||
title="Toggle dark/light mode"
|
||||
>
|
||||
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} className="w-3.5 h-3.5" />
|
||||
<div
|
||||
className={`absolute left-0.5 top-0.5 w-5 h-5 rounded-full shadow-sm transition-transform duration-300 ease-in-out z-0 ${
|
||||
theme === "dark" ? "translate-x-6 bg-bg-card ring-1 ring-border" : "translate-x-0 bg-white ring-1 ring-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-full h-full rounded-full ${theme === "dark" ? "gradient-bg opacity-10" : "opacity-0"} transition-opacity duration-300`} />
|
||||
</div>
|
||||
<div className="w-5 h-5 flex items-center justify-center z-10">
|
||||
<FontAwesomeIcon icon={faSun} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-fg-muted/60" : "text-yellow-500"}`} />
|
||||
</div>
|
||||
<div className="w-5 h-5 flex items-center justify-center z-10">
|
||||
<FontAwesomeIcon icon={faMoon} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-accent-primary" : "text-fg-muted/60"}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-87
@@ -12,35 +12,21 @@ import {
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { getServerStatus, type ServerStatus } from "../lib/llm";
|
||||
import { clearHistory } from "../lib/history";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
const HISTORY_KEY = "melodymuse-history";
|
||||
const DRAFT_KEY = "melodymuse-draft";
|
||||
const THEME_KEY = "melodymuse-theme";
|
||||
|
||||
interface LocalDataSummary {
|
||||
historyEntries: number;
|
||||
hasDraft: boolean;
|
||||
hasTheme: boolean;
|
||||
}
|
||||
|
||||
function readLocalDataSummary(): LocalDataSummary {
|
||||
if (typeof window === "undefined") {
|
||||
return { historyEntries: 0, hasDraft: false, hasTheme: false };
|
||||
}
|
||||
let historyEntries = 0;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HISTORY_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) historyEntries = parsed.length;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
return { hasDraft: false };
|
||||
}
|
||||
const hasDraft = window.localStorage.getItem(DRAFT_KEY) !== null;
|
||||
const hasTheme = window.localStorage.getItem(THEME_KEY) !== null;
|
||||
return { historyEntries, hasDraft, hasTheme };
|
||||
return { hasDraft };
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -52,9 +38,7 @@ export function SettingsPage() {
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [summary, setSummary] = useState<LocalDataSummary>({
|
||||
historyEntries: 0,
|
||||
hasDraft: false,
|
||||
hasTheme: false,
|
||||
});
|
||||
|
||||
const check = async (signal?: AbortSignal) => {
|
||||
@@ -64,7 +48,7 @@ export function SettingsPage() {
|
||||
const s = await getServerStatus(signal);
|
||||
setStatus(s);
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : "Server unreachable");
|
||||
setStatusError(err instanceof Error ? err.message : t.unreachable);
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
@@ -78,40 +62,18 @@ export function SettingsPage() {
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
|
||||
const onClearHistory = () => {
|
||||
if (summary.historyEntries === 0) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete all ${summary.historyEntries} recent generation${
|
||||
summary.historyEntries === 1 ? "" : "s"
|
||||
} from this browser? This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
window.localStorage.removeItem(HISTORY_KEY);
|
||||
setSummary((s) => ({ ...s, historyEntries: 0 }));
|
||||
toast.info("Recent generations cleared");
|
||||
const onClearHistory = async () => {
|
||||
if (!window.confirm(t.clearHistoryConfirm)) return;
|
||||
await clearHistory();
|
||||
toast.info(t.clearHistory);
|
||||
};
|
||||
|
||||
const onClearDraft = () => {
|
||||
if (!summary.hasDraft) return;
|
||||
if (!window.confirm(t.clearDraftConfirm)) return;
|
||||
window.localStorage.removeItem(DRAFT_KEY);
|
||||
setSummary((s) => ({ ...s, hasDraft: false }));
|
||||
toast.info("In-progress draft cleared");
|
||||
};
|
||||
|
||||
const onClearAll = () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Clear all MelodyMuse data from this browser? This will remove the recent generations, the in-progress draft, and the theme preference.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
window.localStorage.removeItem(HISTORY_KEY);
|
||||
window.localStorage.removeItem(DRAFT_KEY);
|
||||
window.localStorage.removeItem(THEME_KEY);
|
||||
setSummary({ historyEntries: 0, hasDraft: false, hasTheme: false });
|
||||
toast.info("Local data cleared");
|
||||
toast.info(t.clearDraft);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -135,7 +97,7 @@ export function SettingsPage() {
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-accent-primary" />
|
||||
Server status
|
||||
{t.serverStatus}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -145,23 +107,23 @@ export function SettingsPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg">
|
||||
{checking
|
||||
? "Checking…"
|
||||
? t.checking
|
||||
: status
|
||||
? "Connected"
|
||||
? t.connected
|
||||
: statusError
|
||||
? "Unreachable"
|
||||
? t.unreachable
|
||||
: "Unknown"}
|
||||
</p>
|
||||
{status && (
|
||||
<p className="text-xs text-fg-muted truncate">
|
||||
Model: <span className="font-mono">{status.model}</span>
|
||||
<p className="text-xs text-fg-muted truncate mt-1">
|
||||
{t.model}: <span className="font-mono">{status.model}</span>
|
||||
<br />
|
||||
Endpoint:{" "}
|
||||
{t.endpoint}:{" "}
|
||||
<span className="font-mono">{status.endpoint}</span>
|
||||
</p>
|
||||
)}
|
||||
{statusError && !checking && (
|
||||
<p className="text-xs text-rose-400 break-words">
|
||||
<p className="text-xs text-rose-400 break-words mt-1">
|
||||
{statusError}
|
||||
</p>
|
||||
)}
|
||||
@@ -174,11 +136,11 @@ export function SettingsPage() {
|
||||
className="btn-ghost"
|
||||
>
|
||||
{checking ? (
|
||||
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
|
||||
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faPlug} className="w-4 h-4" />
|
||||
)}
|
||||
<span>Re-check</span>
|
||||
<span>{t.recheck}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,36 +149,14 @@ export function SettingsPage() {
|
||||
<hr className="border-border" />
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-fg mb-3">Local data</h2>
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
MelodyMuse keeps some data in this browser. None of it leaves your
|
||||
device except for the generation requests themselves.
|
||||
</p>
|
||||
<h2 className="text-sm font-semibold text-fg mb-3">{t.localData}</h2>
|
||||
<ul className="text-xs text-fg-muted space-y-1 mb-4">
|
||||
<li>
|
||||
<code className="font-mono text-accent-primary">melodymuse-history</code> —{" "}
|
||||
{summary.historyEntries} recent generation
|
||||
{summary.historyEntries === 1 ? "" : "s"}
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono text-accent-primary">melodymuse-draft</code> —{" "}
|
||||
{summary.hasDraft ? "saved (in-progress input)" : "empty"}
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono text-accent-primary">melodymuse-theme</code> —{" "}
|
||||
{summary.hasTheme ? "set" : "using default"}
|
||||
{summary.hasDraft ? t.draftSaved : t.draftEmpty}
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearHistory}
|
||||
disabled={summary.historyEntries === 0}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
|
||||
Clear recent generations
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearDraft}
|
||||
@@ -224,15 +164,15 @@ export function SettingsPage() {
|
||||
className="btn-secondary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
|
||||
Clear in-progress draft
|
||||
{t.clearDraft}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="btn-secondary text-rose-400 hover:text-rose-300"
|
||||
onClick={onClearHistory}
|
||||
className="btn-secondary text-rose-400 hover:text-rose-300 border-rose-400/20"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
|
||||
Clear all local data
|
||||
{t.clearHistory}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -242,7 +182,7 @@ export function SettingsPage() {
|
||||
to="/"
|
||||
className="text-xs font-semibold text-fg-muted hover:text-accent-primary transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Back to generator
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> {t.backToGenerator}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user