UI enhancements, i18n, history drawer, and requested features

This commit is contained in:
2026-06-04 00:01:32 +02:00
parent c3e167bde9
commit 3a2c738bd0
14 changed files with 598 additions and 288 deletions
+40 -4
View File
@@ -203,12 +203,11 @@ without losing the original.
### Recent generations
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
View File
@@ -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
View File
@@ -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()}"`;
}
+39 -19
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faHistory, faTrashAlt, faTimes } from "@fortawesome/free-solid-svg-icons";
import { faClock, faHistory, faTrashAlt, faTimes, faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import {
clearHistory,
formatRelative,
@@ -20,16 +20,26 @@ interface HistoryDrawerProps {
export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
const [entries, setEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const { t } = useI18n();
useEffect(() => {
setEntries(loadHistory());
}, []);
const fetchHistory = async () => {
setLoading(true);
try {
const data = await loadHistory();
setEntries(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
const handler = () => setEntries(loadHistory());
window.addEventListener(HISTORY_UPDATED_EVENT, handler);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, handler);
if (open) fetchHistory();
}, [open]);
useEffect(() => {
window.addEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, fetchHistory);
}, []);
// Close on Escape
@@ -65,24 +75,27 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
<span className="flex items-center gap-2 text-sm font-bold text-fg">
<FontAwesomeIcon icon={faHistory} className="text-accent-primary" />
{t.history}
{entries.length > 0 && (
{!loading && entries.length > 0 && (
<span className="text-[10px] bg-accent-primary/15 text-accent-primary px-1.5 py-0.5 rounded-full font-bold">
{entries.length}
</span>
)}
</span>
<button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-lg text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
aria-label="Close history"
>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="flex items-center gap-2">
{loading && <FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-3 h-3" />}
<button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-lg text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
aria-label="Close history"
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
</div>
{/* Entries */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-2 space-y-1">
{entries.length === 0 ? (
{entries.length === 0 && !loading ? (
<p className="px-3 py-10 text-xs text-fg-muted text-center italic">{t.noHistory}</p>
) : (
entries.map((e) => (
@@ -90,7 +103,9 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
key={e.id}
entry={e}
onLoad={() => { onLoad(e); onClose(); }}
onRemove={() => setEntries(removeFromHistory(e.id))}
onRemove={async () => {
await removeFromHistory(e.id);
}}
/>
))
)}
@@ -101,11 +116,16 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={() => { clearHistory(); setEntries([]); }}
onClick={async () => {
if (window.confirm(t.clearHistoryConfirm)) {
await clearHistory();
setEntries([]);
}
}}
className="w-full inline-flex items-center justify-center gap-2 text-xs font-semibold text-fg-muted hover:text-rose-400 hover:bg-rose-400/10 transition-colors py-2 rounded-lg"
>
<FontAwesomeIcon icon={faTrashAlt} />
{t.history === 'History' ? 'Clear history' : 'Verlauf löschen'}
{t.clearHistory}
</button>
</div>
)}
+39 -25
View File
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleNotch,
faWandMagicSparkles,
faShuffle,
faFire,
faMicrophone,
faMusic,
@@ -89,42 +88,65 @@ export function InputPanel({
}
};
const fillExample = () => {
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
onChange({ ...values, idea: ex.idea, mood: values.mood || ex.mood, vocals: ex.vocals });
};
// "Surprise me" fills both idea AND style with a coherent pair
// "Surprise me" fills style. If idea is empty, fills a random idea too.
const handleSurpriseMe = async () => {
if (styleLoading) return;
// Try to get a random style from the server; also fill idea locally
const hasIdea = values.idea.trim().length > 0;
const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
const ctrl = new AbortController();
setStyleLoading("normal");
try {
const style = await randomStyle("normal", ctrl.signal);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals });
const style = await randomStyle("normal", ctrl.signal, ideaToUse);
onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
// Fallback to local style if server fails
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals });
onChange({
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} finally {
setStyleLoading((curr) => (curr === "normal" ? null : curr));
}
};
// "Go crazy" fills both idea AND style with a wild pair
// "Go crazy" fills style wildly. If idea is empty, fills a wild idea too.
const handleGoCrazy = async () => {
if (styleLoading) return;
const hasIdea = values.idea.trim().length > 0;
const ex = CRAZY_PAIRS[Math.floor(Math.random() * CRAZY_PAIRS.length)];
const ideaToUse = hasIdea ? values.idea : ex.idea;
const ctrl = new AbortController();
setStyleLoading("crazy");
try {
const style = await randomStyle("crazy", ctrl.signal);
onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals });
const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
onChange({
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals });
onChange({
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
} finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr));
}
@@ -151,15 +173,7 @@ export function InputPanel({
/>
{/* Example / Surprise / Crazy buttons */}
<div className="flex flex-wrap gap-2 pt-0.5">
<button
type="button"
onClick={fillExample}
disabled={anyLoading}
className="btn-fun bg-bg-card border-border text-fg-muted hover:text-fg hover:border-accent-primary/40"
>
<FontAwesomeIcon icon={faShuffle} className="w-3 h-3" />
{t.tryExample}
</button>
{/* Removed Try an Example button */}
<button
type="button"
onClick={handleSurpriseMe}
+12 -9
View File
@@ -7,6 +7,7 @@ import { LyricsCard } from "./cards/LyricsCard";
import { StyleCard } from "./cards/StyleCard";
import { VideoPromptsCard } from "./cards/VideoPromptsCard";
import { YouTubeCard } from "./cards/YouTubeCard";
import { useI18n } from "../lib/i18n";
export type SectionKey =
| "titles"
@@ -27,7 +28,7 @@ interface ResultsPanelProps {
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
onChangeVideoPrompt: (index: number, value: string) => void;
onRegenerateAll: () => void;
onRegenerateSection: (section: SectionKey) => void;
onRegenerateSection: (section: SectionKey, selectedText?: string) => void;
onCancel?: () => void;
anyRegenerating: boolean;
regenerating: RegeneratingMap;
@@ -49,11 +50,12 @@ export function ResultsPanel({
regenerating,
}: ResultsPanelProps) {
const showSkeletons = loading && !assets;
const { t } = useI18n();
return (
<div>
<div className="sticky top-0 z-30 -mx-4 px-4 py-2.5 bg-bg/60 backdrop-blur-md border-b border-border mb-5 flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-fg">Results</h2>
<h2 className="text-sm font-bold text-fg">{t.results}</h2>
<div className="flex items-center gap-1">
{anyRegenerating && onCancel && (
<button
@@ -61,7 +63,7 @@ export function ResultsPanel({
onClick={onCancel}
className="px-2.5 py-1.5 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
>
Cancel
{t.cancel}
</button>
)}
{assets && (
@@ -72,7 +74,7 @@ export function ResultsPanel({
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FontAwesomeIcon icon={faSync} spin={regenerating.all} className="w-3 h-3" />
Regenerate all
{t.regenerateAll}
</button>
)}
</div>
@@ -100,7 +102,7 @@ export function ResultsPanel({
lyrics={assets.lyrics}
originalLyrics={originalAssets.lyrics}
onChange={(v) => onChange("lyrics", v)}
onRegenerate={() => onRegenerateSection("lyrics")}
onRegenerate={(selectedText) => onRegenerateSection("lyrics", selectedText)}
regenerating={Boolean(regenerating.lyrics)}
/>
<StyleCard
@@ -136,6 +138,7 @@ export function ResultsPanel({
}
function EmptyState() {
const { t } = useI18n();
return (
<div className="flex flex-col items-center justify-center text-center px-6 py-20 rounded-2xl border border-dashed border-border backdrop-blur-sm bg-bg-card/30">
<div className="relative w-16 h-16 mb-5">
@@ -144,13 +147,13 @@ function EmptyState() {
<FontAwesomeIcon icon={faMusic} className="w-7 h-7 text-accent-primary" />
</div>
</div>
<h3 className="text-base font-bold text-fg mb-1.5">Your song will appear here</h3>
<h3 className="text-base font-bold text-fg mb-1.5">{t.emptyStateTitle}</h3>
<p className="text-sm text-fg-muted max-w-sm leading-relaxed">
Describe a music idea on the left and press{" "}
{t.emptyStateBody}{" "}
<kbd className="px-1.5 py-0.5 rounded-lg bg-bg-hover border border-border text-[11px] font-mono text-fg">
Generate
{t.generateAssets}
</kbd>{" "}
to create titles, lyrics, style, video prompts, and a YouTube description.
{t.emptyStateBody2}
</p>
</div>
);
+35 -15
View File
@@ -3,12 +3,13 @@ import { CopyButton } from "../CopyButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileAlt, faUndo } from "@fortawesome/free-solid-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface LyricsCardProps {
lyrics: string;
originalLyrics: string;
onChange: (next: string) => void;
onRegenerate: () => void;
onRegenerate: (selectedText?: string) => void;
regenerating: boolean;
}
@@ -21,13 +22,25 @@ export function LyricsCard({
}: LyricsCardProps) {
const ref = useAutoHeight(lyrics, 200);
const dirty = lyrics !== originalLyrics;
const { t } = useI18n();
const handleRegenerate = () => {
let selected = "";
if (ref.current) {
selected = ref.current.value.substring(
ref.current.selectionStart,
ref.current.selectionEnd
).trim();
}
onRegenerate(selected || undefined);
};
return (
<ResultCard
index={1}
icon={<FontAwesomeIcon icon={faFileAlt} className="text-blue-400" />}
title="Lyrics"
onRegenerate={onRegenerate}
title={t.lyrics}
onRegenerate={handleRegenerate}
regenerating={regenerating}
headerExtra={
<>
@@ -37,25 +50,32 @@ export function LyricsCard({
onClick={() => onChange(originalLyrics)}
className="btn-ghost"
aria-label="Revert lyrics to last generated version"
title="Revert to last generated"
title={t.revert}
>
<FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span>
<span>{t.revert}</span>
</button>
)}
{lyrics && <CopyButton value={lyrics} label="Copy" />}
{lyrics && <CopyButton value={lyrics} label={t.copy} />}
</>
}
>
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 200 }}
/>
<div className="flex flex-col gap-2">
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
style={{ minHeight: 200 }}
/>
{lyrics && (
<p className="text-[10px] text-fg-muted font-medium px-1">
{t.partialRegenHint}
</p>
)}
</div>
</ResultCard>
);
}
+7 -6
View File
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUndo } from "@fortawesome/free-solid-svg-icons";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight";
import { useI18n } from "../../lib/i18n";
interface YouTubeCardProps {
description: string;
@@ -22,12 +23,13 @@ export function YouTubeCard({
}: YouTubeCardProps) {
const ref = useAutoHeight(description, 300);
const dirty = description !== originalDescription;
const { t } = useI18n();
return (
<ResultCard
index={4}
icon={<FontAwesomeIcon icon={faYoutube} className="text-red-500" />}
title="YouTube Description"
title={t.youtubeDesc}
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={
@@ -38,13 +40,13 @@ export function YouTubeCard({
onClick={() => onChange(originalDescription)}
className="btn-ghost"
aria-label="Revert description to last generated version"
title="Revert to last generated"
title={t.revert}
>
<FontAwesomeIcon icon={faUndo} className="w-3.5 h-3.5" />
<span>Revert</span>
<span>{t.revert}</span>
</button>
)}
{description && <CopyButton value={description} label="Copy" />}
{description && <CopyButton value={description} label={t.copy} />}
</>
}
>
@@ -52,8 +54,7 @@ export function YouTubeCard({
ref={ref}
value={description}
onChange={(e) => onChange(e.target.value)}
rows={10}
className="textarea text-sm leading-relaxed scrollbar-thin"
className="textarea text-sm leading-relaxed scrollbar-thin max-h-[18rem] overflow-y-auto"
style={{ minHeight: 300 }}
/>
</ResultCard>
+26 -8
View File
@@ -140,6 +140,15 @@
color: transparent;
}
.gradient-text-animated {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 50%, var(--accent-secondary) 100%);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gradient-shift 6s linear infinite;
}
.gradient-bg {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-warm) 60%, var(--accent-secondary) 100%);
}
@@ -185,24 +194,33 @@
animation: blob-drift-3 26s ease-in-out infinite;
}
/* Animated logo bars */
/* Animated logo bars (5 bars, matching favicon heights, slow) */
.music-bar {
display: inline-block;
width: 3px;
border-radius: 2px;
background: linear-gradient(180deg, var(--accent-warm) 0%, var(--accent-primary) 100%);
transform-origin: bottom center;
opacity: 0.9;
}
.music-bar-1 { animation: bar-bounce 1.0s ease-in-out infinite; }
.music-bar-2 { animation: bar-bounce 1.0s ease-in-out 0.15s infinite; }
.music-bar-3 { animation: bar-bounce 1.0s ease-in-out 0.30s infinite; }
.music-bar-4 { animation: bar-bounce 1.0s ease-in-out 0.45s infinite; }
.music-bar-1 { animation: bar-bounce-1 8.0s ease-in-out infinite; }
.music-bar-2 { animation: bar-bounce-2 11.0s ease-in-out infinite; }
.music-bar-3 { animation: bar-bounce-3 9.0s ease-in-out infinite; }
.music-bar-4 { animation: bar-bounce-4 13.0s ease-in-out infinite; }
.music-bar-5 { animation: bar-bounce-5 10.0s ease-in-out infinite; }
}
/* Keyframes */
@keyframes bar-bounce {
0%, 100% { height: 6px; opacity: 0.6; }
50% { height: 18px; opacity: 1; }
@keyframes bar-bounce-1 { 0%, 100% { height: 6px; } 50% { height: 12px; } }
@keyframes bar-bounce-2 { 0%, 100% { height: 11px; } 50% { height: 22px; } }
@keyframes bar-bounce-3 { 0%, 100% { height: 5px; } 50% { height: 10px; } }
@keyframes bar-bounce-4 { 0%, 100% { height: 8px; } 50% { height: 16px; } }
@keyframes bar-bounce-5 { 0%, 100% { height: 4px; } 50% { height: 8px; } }
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes blob-drift-1 {
+42 -51
View File
@@ -1,15 +1,12 @@
// Recent-generations history. Persists the last N successful generations in
// localStorage so the user can revisit a song without re-running the model.
// Shared history — stored on the server so all users see the same entries.
// Falls back gracefully if the network is unavailable.
import type { InputValues } from "../components/InputPanel";
import type { SongAssets } from "./types";
const HISTORY_KEY = "melodymuse-history";
const MAX_HISTORY = 6;
const API_BASE =
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
// Fired on `window` whenever the history changes (add/remove/clear). The
// HistoryPanel listens for this so it can refresh its visible list without
// the user having to close and reopen it.
export const HISTORY_UPDATED_EVENT = "melodymuse-history-updated";
export function notifyHistoryUpdated(): void {
@@ -25,63 +22,57 @@ export interface HistoryEntry {
assets: SongAssets;
}
function readAll(): HistoryEntry[] {
if (typeof window === "undefined") return [];
export async function loadHistory(): Promise<HistoryEntry[]> {
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(e): e is HistoryEntry =>
e &&
typeof e === "object" &&
typeof e.id === "string" &&
typeof e.timestamp === "number" &&
typeof e.input === "object" &&
typeof e.assets === "object",
);
const resp = await fetch(`${API_BASE}/api/history`);
if (!resp.ok) return [];
const data = await resp.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function writeAll(entries: HistoryEntry[]): void {
try {
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
} catch {
// localStorage quota or disabled — fail silently; history is a nice-to-have.
}
notifyHistoryUpdated();
}
export function loadHistory(): HistoryEntry[] {
return readAll();
}
export function addToHistory(
export async function addToHistory(
input: InputValues,
assets: SongAssets,
): HistoryEntry[] {
): Promise<void> {
const entry: HistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: Date.now(),
input,
assets,
};
const next = [entry, ...readAll()].slice(0, MAX_HISTORY);
writeAll(next);
return next;
try {
await fetch(`${API_BASE}/api/history`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry),
});
notifyHistoryUpdated();
} catch {
// fail silently — history is a nice-to-have
}
}
export function removeFromHistory(id: string): HistoryEntry[] {
const next = readAll().filter((e) => e.id !== id);
writeAll(next);
return next;
export async function removeFromHistory(id: string): Promise<void> {
try {
await fetch(`${API_BASE}/api/history/${encodeURIComponent(id)}`, {
method: "DELETE",
});
notifyHistoryUpdated();
} catch {
// fail silently
}
}
export function clearHistory(): void {
writeAll([]);
export async function clearHistory(): Promise<void> {
try {
await fetch(`${API_BASE}/api/history`, { method: "DELETE" });
notifyHistoryUpdated();
} catch {
// fail silently
}
}
export function formatRelative(
@@ -90,12 +81,12 @@ export function formatRelative(
): string {
const diff = Math.max(0, now - timestamp);
const s = Math.floor(diff / 1000);
if (s < 60) return "just now";
if (s < 60) return "gerade eben";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
if (m < 60) return `vor ${m} Min.`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
if (h < 24) return `vor ${h} Std.`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(timestamp).toLocaleDateString();
if (d < 7) return `vor ${d} Tagen`;
return new Date(timestamp).toLocaleDateString("de-DE");
}
+88 -23
View File
@@ -5,27 +5,27 @@ type Language = 'de' | 'en';
const translations = {
de: {
appTitle: 'MelodyMuse',
appSubtitle: 'Generiere Suno Song-Assets mit KI',
appSubtitle: 'KI-gestützte Suno Song-Asset-Erstellung',
musicIdea: 'Musik-Idee',
tryExample: 'Beispiel testen',
placeholderIdea: 'Beschreibe deine Musik-Idee…',
shortcutHint: 'zum Generieren',
surpriseMe: 'Überrasche mich',
goCrazy: 'Durchdrehen',
language: 'Sprache',
musicStyle: 'Musikstil',
optional: '(optional)',
placeholderStyle: 'z.B. dark synthwave, analog pads, 110 BPM',
surpriseMe: 'Überrasch mich',
goCrazy: 'Verrückt',
placeholderStyle: 'z.B. Dark Synthwave, analoge Pads, 110 BPM',
mood: 'Stimmung',
placeholderMood: 'z.B. melancholisch, euphorisch, spannend…',
vocals: 'Gesang',
vocalsOption: 'Mit Gesang',
instrumentalOption: 'Instrumental',
generateAssets: 'Song-Assets generieren',
generateAssets: 'Song generieren',
generating: 'Generiere…',
cancelGeneration: 'Generierung abbrechen',
cancelGeneration: 'Abbrechen',
settings: 'Einstellungen',
titles: 'Titel',
// Results
results: 'Ergebnisse',
titles: 'Songtitel',
lyrics: 'Songtext',
style: 'Stil',
videoPrompts: 'Video-Prompts',
@@ -36,37 +36,70 @@ const translations = {
copy: 'Kopieren',
regenerate: 'Neu generieren',
revert: 'Zurücksetzen',
cancel: 'Abbrechen',
// Empty state
emptyStateTitle: 'Dein Song erscheint hier',
emptyStateBody: 'Beschreibe eine Musik-Idee auf der linken Seite und drücke',
emptyStateBody2: 'um Titel, Songtext, Stil, Video-Prompts und eine YouTube-Beschreibung zu erstellen.',
// History
history: 'Verlauf',
noHistory: 'Noch kein Verlauf vorhanden.',
noHistory: 'Noch keine Einträge vorhanden.',
clearHistory: 'Verlauf löschen',
loadEntry: 'Laden',
// Titles
editTitleHint: 'Dieser Titel wird als ZIP-Dateiname verwendet. Du kannst ihn frei bearbeiten.',
editTitlePlaceholder: 'Titel bearbeiten…',
// Lyrics partial
partialRegenHint: 'Text markieren → Neu generieren, um nur diesen Abschnitt zu ersetzen.',
// Server
serverOffline: 'Server offline',
generationFailed: 'Generierung fehlgeschlagen — bitte erneut versuchen',
generationCancelled: 'Generierung abgebrochen',
assetsGenerated: 'Song-Assets generiert',
regeneratedAll: 'Alle Bereiche neu generiert',
regeneratedSection: 'Bereich neu generiert',
assetsGenerated: 'Song-Assets generiert!',
regeneratedAll: 'Alles neu generiert',
regeneratedSection: 'Abschnitt neu generiert:',
// Settings
serverStatus: 'Server-Status',
connected: 'Verbunden',
unreachable: 'Nicht erreichbar',
checking: 'Prüfe…',
recheck: 'Erneut prüfen',
clearDraft: 'Entwurf löschen',
clearDraftConfirm: 'Aktuellen Entwurf aus diesem Browser löschen?',
clearHistoryConfirm: 'Den gesamten Verlauf löschen? Alle generierten Songs werden entfernt.',
backToGenerator: 'Zurück zum Generator',
model: 'Modell',
endpoint: 'Endpunkt',
localData: 'Lokale Daten',
draft: 'Entwurf',
draftSaved: 'gespeichert',
draftEmpty: 'leer',
// Download
selectTitleFirst: 'Zuerst Titel auswählen',
downloadZipBtn: 'ZIP herunterladen',
},
en: {
appTitle: 'MelodyMuse',
appSubtitle: 'Generate Suno song assets with AI',
appSubtitle: 'AI-powered Suno song asset generation',
musicIdea: 'Music idea',
tryExample: 'Try an example',
placeholderIdea: 'Describe your music idea…',
shortcutHint: 'to generate',
surpriseMe: 'Surprise me',
goCrazy: 'Go crazy',
language: 'Language',
musicStyle: 'Music style',
optional: '(optional)',
placeholderStyle: 'e.g. dark synthwave, analog pads, 110 BPM',
surpriseMe: 'Surprise me',
goCrazy: 'Go crazy',
mood: 'Mood',
placeholderMood: 'e.g. melancholic, euphoric, tense…',
vocals: 'Vocals',
vocalsOption: 'Vocals',
instrumentalOption: 'Instrumental',
generateAssets: 'Generate song assets',
generateAssets: 'Generate assets',
generating: 'Generating…',
cancelGeneration: 'Cancel generation',
cancelGeneration: 'Cancel',
settings: 'Settings',
// Results
results: 'Results',
titles: 'Titles',
lyrics: 'Lyrics',
style: 'Style',
@@ -78,14 +111,47 @@ const translations = {
copy: 'Copy',
regenerate: 'Regenerate',
revert: 'Revert',
cancel: 'Cancel',
// Empty state
emptyStateTitle: 'Your song will appear here',
emptyStateBody: 'Describe a music idea on the left and press',
emptyStateBody2: 'to create titles, lyrics, style, video prompts, and a YouTube description.',
// History
history: 'History',
noHistory: 'No history yet.',
clearHistory: 'Clear history',
loadEntry: 'Load',
// Titles
editTitleHint: 'This title is used for the ZIP filename. You can edit it freely.',
editTitlePlaceholder: 'Edit title…',
// Lyrics partial
partialRegenHint: 'Select text → Regenerate to replace only that passage.',
// Server
serverOffline: 'Server offline',
generationFailed: 'Generation failed — please try again',
generationCancelled: 'Generation cancelled',
assetsGenerated: 'Song assets generated',
assetsGenerated: 'Song assets generated!',
regeneratedAll: 'Regenerated all sections',
regeneratedSection: 'Regenerated section',
regeneratedSection: 'Regenerated section:',
// Settings
serverStatus: 'Server status',
connected: 'Connected',
unreachable: 'Unreachable',
checking: 'Checking…',
recheck: 'Re-check',
clearDraft: 'Clear draft',
clearDraftConfirm: 'Delete the current draft from this browser?',
clearHistoryConfirm: 'Clear all history? All generated songs will be removed for everyone.',
backToGenerator: 'Back to generator',
model: 'Model',
endpoint: 'Endpoint',
localData: 'Local data',
draft: 'Draft',
draftSaved: 'saved',
draftEmpty: 'empty',
// Download
selectTitleFirst: 'Select a title first',
downloadZipBtn: 'Download ZIP',
}
};
@@ -100,7 +166,6 @@ interface I18nContextType {
const I18nContext = createContext<I18nContextType | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
// Try to load from localStorage, default to 'de'
const [lang, setLangState] = useState<Language>(() => {
try {
const stored = localStorage.getItem('melodymuse-lang');
+2 -1
View File
@@ -108,10 +108,11 @@ export interface StyleRandomResponse {
export async function randomStyle(
mode: "normal" | "crazy",
signal?: AbortSignal,
idea?: string,
): Promise<string> {
const data = await postJson<StyleRandomResponse>(
"/api/style/random",
{ mode },
{ mode, idea: idea ?? "" },
signal,
);
return data.style;
+104 -34
View File
@@ -62,14 +62,38 @@ function loadDraft(): InputValues | null {
}
}
function FlagDE() {
return (
<svg viewBox="0 0 5 3" className="w-5 h-3.5 rounded-[2px] overflow-hidden shadow-sm">
<rect width="5" height="3" y="0" fill="#000" />
<rect width="5" height="2" y="1" fill="#D00" />
<rect width="5" height="1" y="2" fill="#FFCE00" />
</svg>
);
}
function FlagEN() {
return (
<svg viewBox="0 0 60 30" className="w-5 h-3.5 rounded-[2px] overflow-hidden shadow-sm">
<clipPath id="t"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/></clipPath>
<rect width="60" height="30" fill="#012169"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#t)" stroke="#C8102E" strokeWidth="4"/>
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
</svg>
);
}
/** Animated equalizer bars for the logo */
function MusicBarsLogo() {
return (
<div className="flex items-end gap-[3px] h-5" aria-hidden>
<div className="music-bar music-bar-1" style={{ height: 8 }} />
<div className="music-bar music-bar-2" style={{ height: 14 }} />
<div className="flex items-end gap-[3px] h-[22px]" aria-hidden>
<div className="music-bar music-bar-1" style={{ height: 12 }} />
<div className="music-bar music-bar-2" style={{ height: 22 }} />
<div className="music-bar music-bar-3" style={{ height: 10 }} />
<div className="music-bar music-bar-4" style={{ height: 6 }} />
<div className="music-bar music-bar-4" style={{ height: 16 }} />
<div className="music-bar music-bar-5" style={{ height: 8 }} />
</div>
);
}
@@ -90,6 +114,7 @@ export function HomePage() {
const [customTitle, setCustomTitle] = useState<string | null>(null);
const [serverOk, setServerOk] = useState<boolean | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [chainRegenerate, setChainRegenerate] = useState<SectionKey | null>(null);
const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating);
@@ -143,7 +168,7 @@ export function HomePage() {
}, [anyRegenerating, cancel]);
const buildRequest = useCallback(
(section: GenerateCall["section"], extra: Partial<GenerateCall> = {}): GenerateCall => ({
(section: string, extra: Record<string, unknown> = {}): GenerateCall => ({
input: input.idea.trim(),
language: resolveLanguage(input),
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
@@ -151,7 +176,7 @@ export function HomePage() {
vocals: input.vocals,
section,
...extra,
}),
} as unknown as GenerateCall),
[input],
);
@@ -228,16 +253,37 @@ export function HomePage() {
}, [input, buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback(
async (section: SectionKey) => {
async (section: SectionKey, selectedText?: string) => {
if (!input.idea.trim() || !assets) return;
const isPartialLyrics = section === "lyrics" && !!selectedText;
const endpointSection = isPartialLyrics ? "lyrics_partial" : section;
const reqExtras = { context: assets };
if (isPartialLyrics) {
(reqExtras as any).selected_text = selectedText;
}
await runGeneration(
() => buildRequest(section, { context: assets }),
() => buildRequest(endpointSection, reqExtras),
(result) => {
const merged: SongAssets = { ...assets, ...result };
let merged: SongAssets;
if (isPartialLyrics) {
// Replace only the selected text in the existing lyrics
const newPartial = (result as any).lyrics || (result as any).text || String(result);
const replaced = assets.lyrics.replace(selectedText, newPartial);
merged = { ...assets, lyrics: replaced };
} else {
merged = { ...assets, ...result };
}
setAssets(merged);
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
// Reset custom title if titles were regenerated
if (section === "titles") setCustomTitle(null);
// Chain YouTube description regeneration if lyrics changed
if (section === "lyrics") {
setChainRegenerate("youtube_description");
}
},
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
@@ -246,6 +292,15 @@ export function HomePage() {
[input, assets, buildRequest, runGeneration, t],
);
// Execute chained regeneration
useEffect(() => {
if (chainRegenerate && assets && !anyRegenerating) {
const section = chainRegenerate;
setChainRegenerate(null);
handleRegenerateSection(section);
}
}, [chainRegenerate, assets, anyRegenerating, handleRegenerateSection]);
const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return;
try {
@@ -303,13 +358,13 @@ export function HomePage() {
{/* Logo */}
<div className="flex items-center gap-2.5">
<MusicBarsLogo />
<span className="text-sm font-extrabold gradient-text tracking-tight">
<span className="text-sm font-extrabold gradient-text-animated tracking-tight">
MelodyMuse
</span>
</div>
{/* Right controls */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-4">
{/* Social links — icon only */}
<a
href="https://www.youtube.com/@AIWentNonsense"
@@ -331,6 +386,27 @@ export function HomePage() {
{/* Divider */}
<div className="h-5 w-px bg-border" />
{/* Language */}
<div className="flex items-center gap-2">
<button
onClick={() => setLang("de")}
className={`flex items-center justify-center transition-all duration-150 ${lang === "de" ? "scale-110 ring-2 ring-accent-primary rounded-[4px]" : "opacity-40 hover:opacity-80"}`}
title="Deutsch"
>
<FlagDE />
</button>
<button
onClick={() => setLang("en")}
className={`flex items-center justify-center transition-all duration-150 ${lang === "en" ? "scale-110 ring-2 ring-accent-primary rounded-[4px]" : "opacity-40 hover:opacity-80"}`}
title="English"
>
<FlagEN />
</button>
</div>
{/* Divider */}
<div className="h-5 w-px bg-border" />
{/* History toggle */}
<button
onClick={() => setHistoryOpen((o) => !o)}
@@ -349,31 +425,25 @@ export function HomePage() {
<FontAwesomeIcon icon={faCog} className="w-3.5 h-3.5" />
</Link>
{/* Language */}
<div className="flex items-center gap-0.5">
<button
onClick={() => setLang("de")}
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "de" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
title="Deutsch"
>
🇩🇪
</button>
<button
onClick={() => setLang("en")}
className={`w-7 h-7 rounded-lg flex items-center justify-center text-base transition-all duration-150 ${lang === "en" ? "bg-bg-hover shadow-sm scale-110" : "opacity-40 hover:opacity-80"}`}
title="English"
>
🇬🇧
</button>
</div>
{/* Theme toggle */}
{/* Theme toggle switch */}
<button
onClick={toggleTheme}
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-all duration-150"
title="Toggle theme"
className="relative flex items-center justify-between w-12 h-6 bg-border/50 border border-border/50 hover:bg-border/80 rounded-full p-0.5 cursor-pointer transition-colors"
title="Toggle dark/light mode"
>
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} className="w-3.5 h-3.5" />
<div
className={`absolute left-0.5 top-0.5 w-5 h-5 rounded-full shadow-sm transition-transform duration-300 ease-in-out z-0 ${
theme === "dark" ? "translate-x-6 bg-bg-card ring-1 ring-border" : "translate-x-0 bg-white ring-1 ring-gray-200"
}`}
>
<div className={`w-full h-full rounded-full ${theme === "dark" ? "gradient-bg opacity-10" : "opacity-0"} transition-opacity duration-300`} />
</div>
<div className="w-5 h-5 flex items-center justify-center z-10">
<FontAwesomeIcon icon={faSun} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-fg-muted/60" : "text-yellow-500"}`} />
</div>
<div className="w-5 h-5 flex items-center justify-center z-10">
<FontAwesomeIcon icon={faMoon} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-accent-primary" : "text-fg-muted/60"}`} />
</div>
</button>
</div>
</div>
+27 -87
View File
@@ -12,35 +12,21 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm";
import { clearHistory } from "../lib/history";
import { useI18n } from "../lib/i18n";
const HISTORY_KEY = "melodymuse-history";
const DRAFT_KEY = "melodymuse-draft";
const THEME_KEY = "melodymuse-theme";
interface LocalDataSummary {
historyEntries: number;
hasDraft: boolean;
hasTheme: boolean;
}
function readLocalDataSummary(): LocalDataSummary {
if (typeof window === "undefined") {
return { historyEntries: 0, hasDraft: false, hasTheme: false };
}
let historyEntries = 0;
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) historyEntries = parsed.length;
}
} catch {
/* ignore */
return { hasDraft: false };
}
const hasDraft = window.localStorage.getItem(DRAFT_KEY) !== null;
const hasTheme = window.localStorage.getItem(THEME_KEY) !== null;
return { historyEntries, hasDraft, hasTheme };
return { hasDraft };
}
export function SettingsPage() {
@@ -52,9 +38,7 @@ export function SettingsPage() {
const [statusError, setStatusError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const [summary, setSummary] = useState<LocalDataSummary>({
historyEntries: 0,
hasDraft: false,
hasTheme: false,
});
const check = async (signal?: AbortSignal) => {
@@ -64,7 +48,7 @@ export function SettingsPage() {
const s = await getServerStatus(signal);
setStatus(s);
} catch (err) {
setStatusError(err instanceof Error ? err.message : "Server unreachable");
setStatusError(err instanceof Error ? err.message : t.unreachable);
setStatus(null);
} finally {
setChecking(false);
@@ -78,40 +62,18 @@ export function SettingsPage() {
return () => ctrl.abort();
}, []);
const onClearHistory = () => {
if (summary.historyEntries === 0) return;
if (
!window.confirm(
`Delete all ${summary.historyEntries} recent generation${
summary.historyEntries === 1 ? "" : "s"
} from this browser? This cannot be undone.`,
)
)
return;
window.localStorage.removeItem(HISTORY_KEY);
setSummary((s) => ({ ...s, historyEntries: 0 }));
toast.info("Recent generations cleared");
const onClearHistory = async () => {
if (!window.confirm(t.clearHistoryConfirm)) return;
await clearHistory();
toast.info(t.clearHistory);
};
const onClearDraft = () => {
if (!summary.hasDraft) return;
if (!window.confirm(t.clearDraftConfirm)) return;
window.localStorage.removeItem(DRAFT_KEY);
setSummary((s) => ({ ...s, hasDraft: false }));
toast.info("In-progress draft cleared");
};
const onClearAll = () => {
if (
!window.confirm(
"Clear all MelodyMuse data from this browser? This will remove the recent generations, the in-progress draft, and the theme preference.",
)
)
return;
window.localStorage.removeItem(HISTORY_KEY);
window.localStorage.removeItem(DRAFT_KEY);
window.localStorage.removeItem(THEME_KEY);
setSummary({ historyEntries: 0, hasDraft: false, hasTheme: false });
toast.info("Local data cleared");
toast.info(t.clearDraft);
};
return (
@@ -135,7 +97,7 @@ export function SettingsPage() {
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-accent-primary" />
Server status
{t.serverStatus}
</h2>
<div className="space-y-3">
@@ -145,23 +107,23 @@ export function SettingsPage() {
<div className="min-w-0">
<p className="text-sm font-medium text-fg">
{checking
? "Checking…"
? t.checking
: status
? "Connected"
? t.connected
: statusError
? "Unreachable"
? t.unreachable
: "Unknown"}
</p>
{status && (
<p className="text-xs text-fg-muted truncate">
Model: <span className="font-mono">{status.model}</span>
<p className="text-xs text-fg-muted truncate mt-1">
{t.model}: <span className="font-mono">{status.model}</span>
<br />
Endpoint:{" "}
{t.endpoint}:{" "}
<span className="font-mono">{status.endpoint}</span>
</p>
)}
{statusError && !checking && (
<p className="text-xs text-rose-400 break-words">
<p className="text-xs text-rose-400 break-words mt-1">
{statusError}
</p>
)}
@@ -174,11 +136,11 @@ export function SettingsPage() {
className="btn-ghost"
>
{checking ? (
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
) : (
<FontAwesomeIcon icon={faPlug} className="w-4 h-4" />
)}
<span>Re-check</span>
<span>{t.recheck}</span>
</button>
</div>
</div>
@@ -187,36 +149,14 @@ export function SettingsPage() {
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3">Local data</h2>
<p className="text-xs text-fg-muted mb-3">
MelodyMuse keeps some data in this browser. None of it leaves your
device except for the generation requests themselves.
</p>
<h2 className="text-sm font-semibold text-fg mb-3">{t.localData}</h2>
<ul className="text-xs text-fg-muted space-y-1 mb-4">
<li>
<code className="font-mono text-accent-primary">melodymuse-history</code> {" "}
{summary.historyEntries} recent generation
{summary.historyEntries === 1 ? "" : "s"}
</li>
<li>
<code className="font-mono text-accent-primary">melodymuse-draft</code> {" "}
{summary.hasDraft ? "saved (in-progress input)" : "empty"}
</li>
<li>
<code className="font-mono text-accent-primary">melodymuse-theme</code> {" "}
{summary.hasTheme ? "set" : "using default"}
{summary.hasDraft ? t.draftSaved : t.draftEmpty}
</li>
</ul>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onClearHistory}
disabled={summary.historyEntries === 0}
className="btn-secondary"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear recent generations
</button>
<button
type="button"
onClick={onClearDraft}
@@ -224,15 +164,15 @@ export function SettingsPage() {
className="btn-secondary"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear in-progress draft
{t.clearDraft}
</button>
<button
type="button"
onClick={onClearAll}
className="btn-secondary text-rose-400 hover:text-rose-300"
onClick={onClearHistory}
className="btn-secondary text-rose-400 hover:text-rose-300 border-rose-400/20"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
Clear all local data
{t.clearHistory}
</button>
</div>
</section>
@@ -242,7 +182,7 @@ export function SettingsPage() {
to="/"
className="text-xs font-semibold text-fg-muted hover:text-accent-primary transition-colors flex items-center justify-center gap-2"
>
<FontAwesomeIcon icon={faArrowLeft} /> Back to generator
<FontAwesomeIcon icon={faArrowLeft} /> {t.backToGenerator}
</Link>
</div>
</div>