UI enhancements, i18n, history drawer, and requested features
This commit is contained in:
@@ -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
@@ -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
@@ -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()}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +75,14 @@ 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loading && <FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-3 h-3" />}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
|
||||||
@@ -79,10 +91,11 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
|
|||||||
<FontAwesomeIcon icon={faTimes} />
|
<FontAwesomeIcon icon={faTimes} />
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
value={lyrics}
|
value={lyrics}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder="Lyrics will appear here…"
|
placeholder="Lyrics will appear here…"
|
||||||
spellCheck
|
spellCheck
|
||||||
className="textarea font-mono text-sm leading-relaxed scrollbar-thin"
|
className="textarea font-mono text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
|
||||||
style={{ minHeight: 200 }}
|
style={{ minHeight: 200 }}
|
||||||
/>
|
/>
|
||||||
|
{lyrics && (
|
||||||
|
<p className="text-[10px] text-fg-muted font-medium px-1">
|
||||||
|
{t.partialRegenHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</ResultCard>
|
</ResultCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
+26
-86
@@ -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>
|
||||||
)}
|
)}
|
||||||
@@ -178,7 +140,7 @@ export function SettingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user