Add cancel, history, revert, and quality-of-life features

UX

- Add elapsed-time counter and Cancel button to in-flight generations,
  wired through AbortController so partial responses are discarded
  cleanly. The cancel action is available from both the input panel
  and the results header.
- Add a Recent generations panel below the input. The last 6 successful
  generations are saved to localStorage; one click reloads both the
  input fields and the generated assets.
- Add a Revert button to every editable card. It appears the moment
  the current value diverges from the last generated value and restores
  the field with a single click.
- Add an Empty state to the right panel with a friendly hint pointing
  at the Generate button and the Settings page.
- Add a 'Try an example' button that fills the input with a random
  starter idea (idea + mood + vocals).
- Add Cmd/Ctrl+Enter as a keyboard shortcut to generate.
- Add a Footer with project info and a privacy reminder.
- Add a 'Clear saved key' button to the Settings page so the user can
  remove the API key without overwriting it.

Bug fix

- Settings > Test Connection used to save the in-progress form values
  to localStorage before testing. It now uses the in-memory candidate
  config, so failed tests don't pollute the saved config.

Code quality

- Extract the duplicated useAutoHeight hook to src/lib/useAutoHeight.ts.
- Extract a useElapsed hook for the loading timer.
- Move InputValues into src/lib/types.ts (was duplicated in
  InputPanel.tsx) and add SECTION_LABELS, replacing the humanizeSection
  switch in HomePage.
- Centralize the filename sanitization: HomePage now calls
  sanitizeFilename from zip.ts instead of duplicating the regex.
- Add previewConfig / testConnectionWithConfig helpers to llm.ts to
  support in-memory connection tests.
This commit is contained in:
2026-06-03 01:47:11 +02:00
parent d42410560f
commit b945417773
16 changed files with 991 additions and 282 deletions
+38 -8
View File
@@ -11,6 +11,18 @@ OpenAI-compatible `/chat/completions` endpoint, using credentials that you
paste into the Settings page. Those credentials are kept in this browser's paste into the Settings page. Those credentials are kept in this browser's
`localStorage`. `localStorage`.
## Features
- 🎵 **All 5 Suno assets in one click** — titles, lyrics, style, video prompts, YouTube description
- ✏️ **Inline editing** — every generated field is editable; revert any edit with one click
- 🔄 **Per-section regeneration** — regenerate just one section; the rest of the song is passed as context for consistency
-**Elapsed-time counter** + ❌ **Cancel** button for long generations
- 🕘 **Recent generations** — last 6 are kept in localStorage; one click to reload
- ⌨️ **Keyboard shortcut**`Cmd/Ctrl + Enter` to generate
- 🎲 **Try an example** — fill the input with a random sample idea
- 🌗 **Dark / light mode** — toggle in the header, persisted
- 💾 **Standalone** — no backend, no signup, no telemetry
## Tech stack ## Tech stack
- React 18 + TypeScript + Vite - React 18 + TypeScript + Vite
@@ -27,15 +39,29 @@ MelodyMuse/
│ ├── main.tsx │ ├── main.tsx
│ ├── index.css # Tailwind layers + design-system components │ ├── index.css # Tailwind layers + design-system components
│ ├── components/ # UI building blocks │ ├── components/ # UI building blocks
│ │ ── cards/ # The five result cards │ │ ── cards/ # The five result cards
│ │ ├── ConfigBanner.tsx
│ │ ├── CopyButton.tsx
│ │ ├── EqualizerIcon.tsx
│ │ ├── Footer.tsx
│ │ ├── HistoryPanel.tsx
│ │ ├── InputPanel.tsx
│ │ ├── ResultCard.tsx
│ │ ├── ResultsPanel.tsx
│ │ ├── SkeletonCard.tsx
│ │ ├── StickyZipBar.tsx
│ │ └── ThemeToggle.tsx
│ ├── pages/ # HomePage, SettingsPage │ ├── pages/ # HomePage, SettingsPage
│ ├── lib/ │ ├── lib/
│ │ ├── history.ts # recent-generations persistence
│ │ ├── llm.ts # direct fetch → provider, JSON extraction, config persistence │ │ ├── llm.ts # direct fetch → provider, JSON extraction, config persistence
│ │ ├── prompts.ts # system + user prompt construction │ │ ├── prompts.ts # system + user prompt construction
│ │ ├── types.ts # shared TypeScript types
│ │ ├── zip.ts # JSZip layout
│ │ ├── theme.ts # dark/light mode │ │ ├── theme.ts # dark/light mode
│ │ ── toast.tsx # toast context │ │ ── toast.tsx # toast context
│ │ ├── types.ts # shared TypeScript types + SECTION_LABELS
│ │ ├── useAutoHeight.ts # shared textarea auto-grow hook
│ │ ├── useElapsed.ts # shared "X seconds elapsed" hook
│ │ └── zip.ts # JSZip layout
│ └── vite-env.d.ts │ └── vite-env.d.ts
├── public/favicon.svg ├── public/favicon.svg
├── index.html ├── index.html
@@ -68,10 +94,11 @@ MelodyMuse/
- **Model Name** — e.g. `MiniMax-M3` - **Model Name** — e.g. `MiniMax-M3`
Click **Save Configuration**, then **Test Connection** to confirm Click **Save Configuration**, then **Test Connection** to confirm
everything is wired up. everything is wired up. **Test Connection** uses your unsaved form
values — your changes are only persisted when you click **Save**.
4. **Generate**. Return to the home page, type a music idea, click 4. **Generate**. Return to the home page, type a music idea, click
**Generate Song Assets**. **Generate Song Assets** (or press `Cmd/Ctrl + Enter`).
## Build for production ## Build for production
@@ -112,8 +139,11 @@ The configured endpoint must expose an OpenAI-compatible
- The API key is held in `localStorage` and is only sent to the endpoint you - The API key is held in `localStorage` and is only sent to the endpoint you
configure. No analytics, no telemetry, no third-party calls. configure. No analytics, no telemetry, no third-party calls.
- The `MelodyMuse-config` localStorage key contains your endpoint URL, model - Three localStorage keys are used:
name, and key. You can clear it at any time from your browser's devtools - `melodymuse-config` — `{ api_endpoint, api_key, model_name }`
- `melodymuse-history` — the last 6 generations (input + assets)
- `melodymuse-theme` — `'dark' | 'light'`
- You can clear them at any time from your browser's devtools
(Application → Local Storage). (Application → Local Storage).
## License ## License
+16
View File
@@ -0,0 +1,16 @@
export function Footer() {
return (
<footer className="mt-16 border-t border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-fg-muted">
<p>
<span className="gradient-text font-semibold">MelodyMuse</span> ·
Generate Suno song assets with AI
</p>
<p>
All generation runs in your browser your API key never leaves this
device.
</p>
</div>
</footer>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from 'react';
import { Clock, History, Trash2, X } from 'lucide-react';
import {
clearHistory,
formatRelative,
loadHistory,
removeFromHistory,
type HistoryEntry,
} from '../lib/history';
import type { InputValues } from '../lib/types';
interface HistoryPanelProps {
onLoad: (entry: HistoryEntry) => void;
}
export function HistoryPanel({ onLoad }: HistoryPanelProps) {
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<HistoryEntry[]>([]);
// Refresh the list whenever the panel is opened, so newly-saved generations
// appear without needing a full page refresh.
useEffect(() => {
if (!open) return;
setEntries(loadHistory());
}, [open]);
if (entries.length === 0 && !open) {
return null;
}
return (
<div className="mt-4">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-border bg-bg-card/50 hover:bg-bg-hover transition-colors text-sm"
aria-expanded={open}
>
<span className="flex items-center gap-2 text-fg">
<History className="w-4 h-4" />
Recent generations
{entries.length > 0 && (
<span className="text-xs text-fg-muted">({entries.length})</span>
)}
</span>
<span className="text-xs text-fg-muted">{open ? 'Hide' : 'Show'}</span>
</button>
{open && (
<div className="mt-2 rounded-lg border border-border bg-bg-card/30 divide-y divide-border">
{entries.length === 0 ? (
<p className="px-3 py-4 text-xs text-fg-muted text-center">
No recent generations yet.
</p>
) : (
<>
{entries.map((e) => (
<HistoryRow
key={e.id}
entry={e}
onLoad={() => {
onLoad(e);
setOpen(false);
}}
onRemove={() => setEntries(removeFromHistory(e.id))}
/>
))}
<div className="px-3 py-2 flex justify-end">
<button
type="button"
onClick={() => {
clearHistory();
setEntries([]);
}}
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-rose-300 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Clear all
</button>
</div>
</>
)}
</div>
)}
</div>
);
}
function HistoryRow({
entry,
onLoad,
onRemove,
}: {
entry: HistoryEntry;
onLoad: () => void;
onRemove: () => void;
}) {
const summary = summarizeInput(entry.input);
return (
<div className="px-3 py-2 flex items-center justify-between gap-2 text-sm hover:bg-bg-hover/40 transition-colors">
<button
type="button"
onClick={onLoad}
className="flex-1 text-left min-w-0"
>
<div className="truncate text-fg">{summary}</div>
<div className="flex items-center gap-2 text-[11px] text-fg-muted mt-0.5">
<Clock className="w-3 h-3" />
<span>{formatRelative(entry.timestamp)}</span>
{entry.input.mood && (
<>
<span>·</span>
<span className="truncate">{entry.input.mood}</span>
</>
)}
</div>
</button>
<button
type="button"
onClick={onRemove}
className="text-fg-muted hover:text-rose-300 transition-colors p-1"
aria-label="Remove from history"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
function summarizeInput(v: InputValues): string {
const idea = v.idea.trim();
const max = 90;
if (idea.length <= max) return idea || '(no idea text)';
return idea.slice(0, max - 1) + '…';
}
+154 -66
View File
@@ -1,27 +1,54 @@
import { useState } from 'react'; import { useState, type FormEvent, type KeyboardEvent } from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { ChevronDown, ChevronUp, Loader2, Settings as SettingsIcon, Sparkles } from 'lucide-react'; import {
import { EqualizerIcon } from './EqualizerIcon'; ChevronDown,
import type { Language, Vocals } from '../lib/types'; ChevronUp,
Loader2,
Settings as SettingsIcon,
Sparkles,
Shuffle,
} from "lucide-react";
import { EqualizerIcon } from "./EqualizerIcon";
import type { InputValues, Language, Vocals } from "../lib/types";
import { formatElapsed, useElapsed } from "../lib/useElapsed";
export type { InputValues };
const LANGUAGES: { value: Language; label: string }[] = [ const LANGUAGES: { value: Language; label: string }[] = [
{ value: 'English', label: 'English' }, { value: "English", label: "English" },
{ value: 'Deutsch', label: 'Deutsch' }, { value: "Deutsch", label: "Deutsch" },
{ value: 'Español', label: 'Español' }, { value: "Español", label: "Español" },
{ value: 'Français', label: 'Français' }, { value: "Français", label: "Français" },
{ value: 'Italiano', label: 'Italiano' }, { value: "Italiano", label: "Italiano" },
{ value: 'Português', label: 'Português' }, { value: "Português", label: "Português" },
{ value: 'Polski', label: 'Polski' }, { value: "Polski", label: "Polski" },
{ value: 'Other', label: 'Other' }, { value: "Other", label: "Other" },
]; ];
export interface InputValues { // A handful of starter ideas shown as quick-fill chips. Kept short and varied
idea: string; // so the user can see the kind of input the model expects.
language: Language | string; const EXAMPLE_IDEAS: { text: string; mood: string; vocals: Vocals }[] = [
customLanguage: string; {
mood: string; text: "melancholic lo-fi house beat for a rainy sunday morning",
vocals: Vocals; mood: "melancholic",
} vocals: "instrumental",
},
{
text: "euphoric summer pop anthem with a singalong chorus",
mood: "euphoric",
vocals: "vocals",
},
{
text: "tense cinematic underscore for a thriller chase scene",
mood: "tense",
vocals: "instrumental",
},
{
text: "dreamy ambient ballad about missing someone far away",
mood: "longing",
vocals: "vocals",
},
];
interface InputPanelProps { interface InputPanelProps {
values: InputValues; values: InputValues;
@@ -29,16 +56,48 @@ interface InputPanelProps {
onSubmit: () => void; onSubmit: () => void;
loading: boolean; loading: boolean;
disabled?: boolean; disabled?: boolean;
cancelButton?: React.ReactNode;
} }
export function InputPanel({ values, onChange, onSubmit, loading, disabled }: InputPanelProps) { export function InputPanel({
values,
onChange,
onSubmit,
loading,
disabled,
cancelButton,
}: InputPanelProps) {
const [optionsOpen, setOptionsOpen] = useState(false); const [optionsOpen, setOptionsOpen] = useState(false);
const elapsed = useElapsed(loading);
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) => const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
onChange({ ...values, [key]: value }); onChange({ ...values, [key]: value });
const canSubmit = !loading && !disabled && values.idea.trim().length > 0; const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
const submit = (e?: FormEvent) => {
e?.preventDefault();
if (canSubmit) onSubmit();
};
// Submit on Cmd/Ctrl+Enter, even from inside the textarea.
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
submit();
}
};
const fillExample = () => {
const ex = EXAMPLE_IDEAS[Math.floor(Math.random() * EXAMPLE_IDEAS.length)];
onChange({
...values,
idea: ex.text,
mood: values.mood || ex.mood,
vocals: ex.vocals,
});
};
return ( return (
<div className="relative"> <div className="relative">
{/* Animated gradient backdrop behind the header */} {/* Animated gradient backdrop behind the header */}
@@ -46,32 +105,52 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
<EqualizerIcon className="w-8 h-8 shrink-0" /> <EqualizerIcon className="w-8 h-8 shrink-0" />
<h1 className="text-3xl font-bold gradient-text leading-none">MelodyMuse</h1> <h1 className="text-3xl font-bold gradient-text leading-none">
MelodyMuse
</h1>
</div> </div>
<p className="text-sm text-fg-muted mb-6 ml-11"> <p className="text-sm text-fg-muted mb-6 ml-11">
Generate your Suno song assets with AI Generate your Suno song assets with AI
</p> </p>
<form <form onSubmit={submit} className="space-y-4">
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) onSubmit();
}}
className="space-y-4"
>
<div> <div>
<label htmlFor="idea" className="sr-only"> <div className="flex items-center justify-between mb-1.5">
Describe your music idea <label htmlFor="idea" className="sr-only">
</label> Describe your music idea
</label>
<span className="text-[11px] uppercase tracking-wider text-fg-muted">
Music idea
</span>
<button
type="button"
onClick={fillExample}
className="inline-flex items-center gap-1 text-[11px] text-fg-muted hover:text-fg transition-colors"
title="Fill the input with a random example"
>
<Shuffle className="w-3 h-3" />
Try an example
</button>
</div>
<textarea <textarea
id="idea" id="idea"
value={values.idea} value={values.idea}
onChange={(e) => set('idea', e.target.value)} onChange={(e) => set("idea", e.target.value)}
onKeyDown={onKeyDown}
rows={4} rows={4}
placeholder={`Describe your music idea...\ne.g. 'melancholic house beat for a rainy night'`} placeholder={`Describe your music idea...\ne.g. 'melancholic house beat for a rainy night'`}
className="textarea text-base leading-relaxed" className="textarea text-base leading-relaxed"
required required
/> />
<p className="mt-1 text-[11px] text-fg-muted">
<kbd className="px-1 py-0.5 rounded bg-bg-hover border border-border font-mono">
{navigator?.platform?.toLowerCase().includes("mac")
? "⌘"
: "Ctrl"}
+Enter
</kbd>{" "}
to generate
</p>
</div> </div>
<button <button
@@ -91,13 +170,16 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
{optionsOpen && ( {optionsOpen && (
<div className="space-y-4 p-4 rounded-lg border border-border bg-bg-card/50"> <div className="space-y-4 p-4 rounded-lg border border-border bg-bg-card/50">
<div> <div>
<label htmlFor="language" className="block text-xs uppercase tracking-wider text-fg-muted mb-1"> <label
htmlFor="language"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Language Language
</label> </label>
<select <select
id="language" id="language"
value={values.language} value={values.language}
onChange={(e) => set('language', e.target.value as Language)} onChange={(e) => set("language", e.target.value as Language)}
className="input" className="input"
> >
{LANGUAGES.map((l) => ( {LANGUAGES.map((l) => (
@@ -106,19 +188,22 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
</option> </option>
))} ))}
</select> </select>
{values.language === 'Other' && ( {values.language === "Other" && (
<input <input
type="text" type="text"
className="input mt-2" className="input mt-2"
placeholder="e.g. 日本語, Türkçe, Русский" placeholder="e.g. 日本語, Türkçe, Русский"
value={values.customLanguage} value={values.customLanguage}
onChange={(e) => set('customLanguage', e.target.value)} onChange={(e) => set("customLanguage", e.target.value)}
/> />
)} )}
</div> </div>
<div> <div>
<label htmlFor="mood" className="block text-xs uppercase tracking-wider text-fg-muted mb-1"> <label
htmlFor="mood"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Mood Mood
</label> </label>
<input <input
@@ -126,7 +211,7 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
type="text" type="text"
placeholder="e.g. melancholic, euphoric, tense…" placeholder="e.g. melancholic, euphoric, tense…"
value={values.mood} value={values.mood}
onChange={(e) => set('mood', e.target.value)} onChange={(e) => set("mood", e.target.value)}
className="input" className="input"
/> />
</div> </div>
@@ -138,25 +223,25 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => set('vocals', 'vocals')} onClick={() => set("vocals", "vocals")}
className={ className={
values.vocals === 'vocals' values.vocals === "vocals"
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors' ? "px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors"
: 'chip justify-center py-2' : "chip justify-center py-2"
} }
aria-pressed={values.vocals === 'vocals'} aria-pressed={values.vocals === "vocals"}
> >
🎤 Vocals 🎤 Vocals
</button> </button>
<button <button
type="button" type="button"
onClick={() => set('vocals', 'instrumental')} onClick={() => set("vocals", "instrumental")}
className={ className={
values.vocals === 'instrumental' values.vocals === "instrumental"
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors' ? "px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors"
: 'chip justify-center py-2' : "chip justify-center py-2"
} }
aria-pressed={values.vocals === 'instrumental'} aria-pressed={values.vocals === "instrumental"}
> >
🎹 Instrumental 🎹 Instrumental
</button> </button>
@@ -165,23 +250,26 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
</div> </div>
)} )}
<button <div className="space-y-2">
type="submit" <button
disabled={!canSubmit} type="submit"
className="w-full py-4 rounded-xl text-base font-semibold text-white shadow-lg shadow-violet-900/30 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2 gradient-bg hover:brightness-110 active:brightness-95" disabled={!canSubmit}
> className="w-full py-4 rounded-xl text-base font-semibold text-white shadow-lg shadow-violet-900/30 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2 gradient-bg hover:brightness-110 active:brightness-95"
{loading ? ( >
<> {loading ? (
<Loader2 className="w-5 h-5 animate-spin" /> <>
Generating <Loader2 className="w-5 h-5 animate-spin" />
</> <span>Generating {formatElapsed(elapsed)}</span>
) : ( </>
<> ) : (
<Sparkles className="w-5 h-5" /> <>
Generate Song Assets <Sparkles className="w-5 h-5" />
</> Generate Song Assets
)} </>
</button> )}
</button>
{loading && cancelButton}
</div>
<div className="text-center"> <div className="text-center">
<Link <Link
+68 -16
View File
@@ -1,4 +1,4 @@
import { RefreshCw } from "lucide-react"; import { Music, RefreshCw, Sparkles } from "lucide-react";
import type { SongAssets } from "../lib/types"; import type { SongAssets } from "../lib/types";
import { SkeletonCard } from "./SkeletonCard"; import { SkeletonCard } from "./SkeletonCard";
import { TitlesCard } from "./cards/TitlesCard"; import { TitlesCard } from "./cards/TitlesCard";
@@ -18,18 +18,22 @@ export type RegeneratingMap = Partial<Record<SectionKey | "all", boolean>>;
interface ResultsPanelProps { interface ResultsPanelProps {
assets: SongAssets | null; assets: SongAssets | null;
loading: boolean; // true during the initial "Generate all" call originalAssets: SongAssets | null; // last *generated* values, for Revert
loading: boolean; // initial "Generate all" skeleton
selectedTitleIndex: number; selectedTitleIndex: number;
onSelectTitle: (i: number) => void; onSelectTitle: (i: number) => void;
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) => void;
onCancel?: () => void; // cancel the in-flight generation
anyRegenerating: boolean;
regenerating: RegeneratingMap; regenerating: RegeneratingMap;
} }
export function ResultsPanel({ export function ResultsPanel({
assets, assets,
originalAssets,
loading, loading,
selectedTitleIndex, selectedTitleIndex,
onSelectTitle, onSelectTitle,
@@ -37,26 +41,41 @@ export function ResultsPanel({
onChangeVideoPrompt, onChangeVideoPrompt,
onRegenerateAll, onRegenerateAll,
onRegenerateSection, onRegenerateSection,
onCancel,
anyRegenerating,
regenerating, regenerating,
}: ResultsPanelProps) { }: ResultsPanelProps) {
const showSkeletons = loading && !assets; const showSkeletons = loading && !assets;
const regenAll = Boolean(regenerating.all);
return ( return (
<div> <div>
<div className="sticky top-0 z-30 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3 bg-bg/80 backdrop-blur border-b border-border mb-6 flex items-center justify-between gap-3"> <div className="sticky top-0 z-30 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3 bg-bg/80 backdrop-blur border-b border-border mb-6 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-fg">Results</h2> <h2 className="text-lg font-semibold text-fg">Results</h2>
<button <div className="flex items-center gap-1">
type="button" {anyRegenerating && onCancel && (
onClick={onRegenerateAll} <button
disabled={loading || regenAll} type="button"
className="btn-ghost" onClick={onCancel}
> className="btn-ghost"
<RefreshCw aria-label="Cancel generation"
className={`w-4 h-4 ${regenAll || loading ? "animate-spin" : ""}`} >
/> <span className="text-rose-300">Cancel</span>
<span>Regenerate All</span> </button>
</button> )}
{assets && (
<button
type="button"
onClick={onRegenerateAll}
disabled={anyRegenerating}
className="btn-ghost"
>
<RefreshCw
className={`w-4 h-4 ${regenerating.all ? "animate-spin" : ""}`}
/>
<span>Regenerate All</span>
</button>
)}
</div>
</div> </div>
{showSkeletons ? ( {showSkeletons ? (
@@ -67,7 +86,7 @@ export function ResultsPanel({
<SkeletonCard height="h-32" rows={4} /> <SkeletonCard height="h-32" rows={4} />
<SkeletonCard height="h-56" rows={3} /> <SkeletonCard height="h-56" rows={3} />
</div> </div>
) : assets ? ( ) : assets && originalAssets ? (
<div className="space-y-4"> <div className="space-y-4">
<TitlesCard <TitlesCard
titles={assets.titles} titles={assets.titles}
@@ -78,6 +97,7 @@ export function ResultsPanel({
/> />
<LyricsCard <LyricsCard
lyrics={assets.lyrics} lyrics={assets.lyrics}
originalLyrics={originalAssets.lyrics}
onChange={(v) => onChange("lyrics", v)} onChange={(v) => onChange("lyrics", v)}
onRegenerate={() => onRegenerateSection("lyrics")} onRegenerate={() => onRegenerateSection("lyrics")}
regenerating={Boolean(regenerating.lyrics)} regenerating={Boolean(regenerating.lyrics)}
@@ -85,6 +105,8 @@ export function ResultsPanel({
<StyleCard <StyleCard
style={assets.style} style={assets.style}
negativeStyle={assets.negative_style} negativeStyle={assets.negative_style}
originalStyle={originalAssets.style}
originalNegativeStyle={originalAssets.negative_style}
onStyleChange={(v) => onChange("style", v)} onStyleChange={(v) => onChange("style", v)}
onNegativeChange={(v) => onChange("negative_style", v)} onNegativeChange={(v) => onChange("negative_style", v)}
onRegenerate={() => onRegenerateSection("style")} onRegenerate={() => onRegenerateSection("style")}
@@ -92,18 +114,48 @@ export function ResultsPanel({
/> />
<VideoPromptsCard <VideoPromptsCard
prompts={assets.video_prompts} prompts={assets.video_prompts}
originalPrompts={originalAssets.video_prompts}
onChange={onChangeVideoPrompt} onChange={onChangeVideoPrompt}
onRegenerate={() => onRegenerateSection("video_prompts")} onRegenerate={() => onRegenerateSection("video_prompts")}
regenerating={Boolean(regenerating.video_prompts)} regenerating={Boolean(regenerating.video_prompts)}
/> />
<YouTubeCard <YouTubeCard
description={assets.youtube_description} description={assets.youtube_description}
originalDescription={originalAssets.youtube_description}
onChange={(v) => onChange("youtube_description", v)} onChange={(v) => onChange("youtube_description", v)}
onRegenerate={() => onRegenerateSection("youtube_description")} onRegenerate={() => onRegenerateSection("youtube_description")}
regenerating={Boolean(regenerating.youtube_description)} regenerating={Boolean(regenerating.youtube_description)}
/> />
</div> </div>
) : null} ) : (
<EmptyState />
)}
</div>
);
}
function EmptyState() {
return (
<div className="card p-10 text-center">
<div className="mx-auto w-14 h-14 rounded-full gradient-bg-soft flex items-center justify-center mb-4">
<Music className="w-7 h-7 text-violet-300" />
</div>
<h3 className="text-base font-semibold text-fg mb-1">
Your song will appear here
</h3>
<p className="text-sm text-fg-muted max-w-sm mx-auto">
Describe a music idea on the left and press{" "}
<kbd className="px-1.5 py-0.5 rounded bg-bg-hover border border-border text-[11px] font-mono">
Generate
</kbd>{" "}
to create titles, lyrics, style, video prompts, and a YouTube
description all in one go.
</p>
<div className="mt-5 flex items-center justify-center gap-2 text-xs text-fg-muted">
<Sparkles className="w-3.5 h-3.5" />
Tip: open <span className="text-fg">Settings</span> to add your API key
first.
</div>
</div> </div>
); );
} }
+30 -18
View File
@@ -1,29 +1,25 @@
import { useEffect, useRef } from 'react'; import { ResultCard } from "../ResultCard";
import { ResultCard } from '../ResultCard'; import { CopyButton } from "../CopyButton";
import { CopyButton } from '../CopyButton'; import { RotateCcw } from "lucide-react";
import { useAutoHeight } from "../../lib/useAutoHeight";
interface LyricsCardProps { interface LyricsCardProps {
lyrics: string; lyrics: string;
originalLyrics: string;
onChange: (next: string) => void; onChange: (next: string) => void;
onRegenerate: () => void; onRegenerate: () => void;
regenerating: boolean; regenerating: boolean;
} }
// Auto-grow the textarea to fit its content. Min height is enforced in CSS via export function LyricsCard({
// `min-height`; we only ever grow. lyrics,
function useAutoHeight(value: string, minHeight = 200) { originalLyrics,
const ref = useRef<HTMLTextAreaElement | null>(null); onChange,
useEffect(() => { onRegenerate,
const el = ref.current; regenerating,
if (!el) return; }: LyricsCardProps) {
el.style.height = 'auto';
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [value, minHeight]);
return ref;
}
export function LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: LyricsCardProps) {
const ref = useAutoHeight(lyrics, 200); const ref = useAutoHeight(lyrics, 200);
const dirty = lyrics !== originalLyrics;
return ( return (
<ResultCard <ResultCard
@@ -32,7 +28,23 @@ export function LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: Lyr
title="Lyrics" title="Lyrics"
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
regenerating={regenerating} regenerating={regenerating}
headerExtra={lyrics ? <CopyButton value={lyrics} label="Copy" /> : null} headerExtra={
<>
{dirty && (
<button
type="button"
onClick={() => onChange(originalLyrics)}
className="btn-ghost"
aria-label="Revert lyrics to last generated version"
title="Revert to last generated"
>
<RotateCcw className="w-4 h-4" />
<span>Revert</span>
</button>
)}
{lyrics && <CopyButton value={lyrics} label="Copy" />}
</>
}
> >
<textarea <textarea
ref={ref} ref={ref}
+31 -15
View File
@@ -1,30 +1,24 @@
import { useEffect, useRef } from 'react'; import { RotateCcw } from "lucide-react";
import { ResultCard } from '../ResultCard'; import { ResultCard } from "../ResultCard";
import { CopyButton } from '../CopyButton'; import { CopyButton } from "../CopyButton";
import { useAutoHeight } from "../../lib/useAutoHeight";
interface StyleCardProps { interface StyleCardProps {
style: string; style: string;
negativeStyle: string; negativeStyle: string;
originalStyle: string;
originalNegativeStyle: string;
onStyleChange: (next: string) => void; onStyleChange: (next: string) => void;
onNegativeChange: (next: string) => void; onNegativeChange: (next: string) => void;
onRegenerate: () => void; onRegenerate: () => void;
regenerating: boolean; regenerating: boolean;
} }
function useAutoHeight(value: string, minHeight: number) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [value, minHeight]);
return ref;
}
export function StyleCard({ export function StyleCard({
style, style,
negativeStyle, negativeStyle,
originalStyle,
originalNegativeStyle,
onStyleChange, onStyleChange,
onNegativeChange, onNegativeChange,
onRegenerate, onRegenerate,
@@ -33,6 +27,9 @@ export function StyleCard({
const styleRef = useAutoHeight(style, 96); const styleRef = useAutoHeight(style, 96);
const negRef = useAutoHeight(negativeStyle, 64); const negRef = useAutoHeight(negativeStyle, 64);
const dirty =
style !== originalStyle || negativeStyle !== originalNegativeStyle;
return ( return (
<ResultCard <ResultCard
index={2} index={2}
@@ -40,6 +37,23 @@ export function StyleCard({
title="Style" title="Style"
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
regenerating={regenerating} regenerating={regenerating}
headerExtra={
dirty && (
<button
type="button"
onClick={() => {
onStyleChange(originalStyle);
onNegativeChange(originalNegativeStyle);
}}
className="btn-ghost"
aria-label="Revert style to last generated version"
title="Revert to last generated"
>
<RotateCcw className="w-4 h-4" />
<span>Revert</span>
</button>
)
}
> >
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -59,7 +73,9 @@ export function StyleCard({
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-fg">Negative Style</label> <label className="text-sm font-medium text-fg">
Negative Style
</label>
<CopyButton value={negativeStyle} label="Copy" /> <CopyButton value={negativeStyle} label="Copy" />
</div> </div>
<textarea <textarea
+48 -19
View File
@@ -1,34 +1,38 @@
import { useEffect, useRef, useState } from 'react'; import { useState } from "react";
import { ResultCard } from '../ResultCard'; import { RotateCcw } from "lucide-react";
import { CopyButton } from '../CopyButton'; import { ResultCard } from "../ResultCard";
import { NEGATIVE_VIDEO_PROMPT, type VideoPrompt, type VideoPromptType } from '../../lib/types'; import { CopyButton } from "../CopyButton";
import { useAutoHeight } from "../../lib/useAutoHeight";
import {
NEGATIVE_VIDEO_PROMPT,
type VideoPrompt,
type VideoPromptType,
} from "../../lib/types";
interface VideoPromptsCardProps { interface VideoPromptsCardProps {
prompts: VideoPrompt[]; prompts: VideoPrompt[];
originalPrompts: VideoPrompt[];
onChange: (index: number, next: string) => void; onChange: (index: number, next: string) => void;
onRegenerate: () => void; onRegenerate: () => void;
regenerating: boolean; regenerating: boolean;
} }
const TABS: VideoPromptType[] = ['Abstract', 'Cinematic', 'Hybrid']; const TABS: VideoPromptType[] = ["Abstract", "Cinematic", "Hybrid"];
export function VideoPromptsCard({ export function VideoPromptsCard({
prompts, prompts,
originalPrompts,
onChange, onChange,
onRegenerate, onRegenerate,
regenerating, regenerating,
}: VideoPromptsCardProps) { }: VideoPromptsCardProps) {
const [tab, setTab] = useState<VideoPromptType>('Abstract'); const [tab, setTab] = useState<VideoPromptType>("Abstract");
const idx = prompts.findIndex((p) => p.type === tab); const idx = prompts.findIndex((p) => p.type === tab);
const current = idx >= 0 ? prompts[idx] : undefined; const current = idx >= 0 ? prompts[idx] : undefined;
const originalCurrent = originalPrompts.find((p) => p.type === tab);
const dirty = current?.prompt !== originalCurrent?.prompt;
const ref = useRef<HTMLTextAreaElement | null>(null); const ref = useAutoHeight(current?.prompt ?? "", 120);
useEffect(() => {
const el = ref.current;
if (!el || !current) return;
el.style.height = 'auto';
el.style.height = `${Math.max(120, el.scrollHeight)}px`;
}, [current?.prompt, tab]);
return ( return (
<ResultCard <ResultCard
@@ -37,7 +41,25 @@ export function VideoPromptsCard({
title="Video Prompts" title="Video Prompts"
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
regenerating={regenerating} regenerating={regenerating}
headerExtra={current ? <CopyButton value={current.prompt} label="Copy" /> : null} headerExtra={
current ? (
<>
{dirty && originalCurrent && (
<button
type="button"
onClick={() => onChange(idx, originalCurrent.prompt)}
className="btn-ghost"
aria-label="Revert video prompt to last generated version"
title="Revert to last generated"
>
<RotateCcw className="w-4 h-4" />
<span>Revert</span>
</button>
)}
<CopyButton value={current.prompt} label="Copy" />
</>
) : null
}
> >
<div className="flex items-center gap-1 mb-4 p-1 rounded-lg bg-bg-hover/40 border border-border w-fit"> <div className="flex items-center gap-1 mb-4 p-1 rounded-lg bg-bg-hover/40 border border-border w-fit">
{TABS.map((t) => { {TABS.map((t) => {
@@ -49,8 +71,8 @@ export function VideoPromptsCard({
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={ className={
isActive isActive
? 'px-3 py-1.5 rounded-md text-sm font-medium bg-accent-primary text-white transition-colors' ? "px-3 py-1.5 rounded-md text-sm font-medium bg-accent-primary text-white transition-colors"
: 'px-3 py-1.5 rounded-md text-sm font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors' : "px-3 py-1.5 rounded-md text-sm font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
} }
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
@@ -78,12 +100,19 @@ export function VideoPromptsCard({
</div> </div>
<p className="text-sm italic text-fg-muted"> <p className="text-sm italic text-fg-muted">
Best for: <span className="not-italic text-fg">{current.tool_recommendation}</span> Best for:{" "}
<span className="not-italic text-fg">
{current.tool_recommendation}
</span>
</p> </p>
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border"> <div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">Negative</p> <p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">
<p className="text-xs text-fg-muted leading-relaxed">{NEGATIVE_VIDEO_PROMPT}</p> Negative
</p>
<p className="text-xs text-fg-muted leading-relaxed">
{NEGATIVE_VIDEO_PROMPT}
</p>
</div> </div>
</div> </div>
) : ( ) : (
+25 -11
View File
@@ -1,9 +1,11 @@
import { useEffect, useRef } from 'react'; import { RotateCcw } from "lucide-react";
import { ResultCard } from '../ResultCard'; import { ResultCard } from "../ResultCard";
import { CopyButton } from '../CopyButton'; import { CopyButton } from "../CopyButton";
import { useAutoHeight } from "../../lib/useAutoHeight";
interface YouTubeCardProps { interface YouTubeCardProps {
description: string; description: string;
originalDescription: string;
onChange: (next: string) => void; onChange: (next: string) => void;
onRegenerate: () => void; onRegenerate: () => void;
regenerating: boolean; regenerating: boolean;
@@ -11,17 +13,13 @@ interface YouTubeCardProps {
export function YouTubeCard({ export function YouTubeCard({
description, description,
originalDescription,
onChange, onChange,
onRegenerate, onRegenerate,
regenerating, regenerating,
}: YouTubeCardProps) { }: YouTubeCardProps) {
const ref = useRef<HTMLTextAreaElement | null>(null); const ref = useAutoHeight(description, 300);
useEffect(() => { const dirty = description !== originalDescription;
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(300, el.scrollHeight)}px`;
}, [description]);
return ( return (
<ResultCard <ResultCard
@@ -30,7 +28,23 @@ export function YouTubeCard({
title="YouTube Description" title="YouTube Description"
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
regenerating={regenerating} regenerating={regenerating}
headerExtra={description ? <CopyButton value={description} label="Copy" /> : null} headerExtra={
<>
{dirty && (
<button
type="button"
onClick={() => onChange(originalDescription)}
className="btn-ghost"
aria-label="Revert description to last generated version"
title="Revert to last generated"
>
<RotateCcw className="w-4 h-4" />
<span>Revert</span>
</button>
)}
{description && <CopyButton value={description} label="Copy" />}
</>
}
> >
<textarea <textarea
ref={ref} ref={ref}
+82
View File
@@ -0,0 +1,82 @@
// Recent-generations history. Persists the last N successful generations in
// localStorage so the user can revisit a song without re-running the model.
import type { InputValues } from '../components/InputPanel';
import type { SongAssets } from './types';
const HISTORY_KEY = 'melodymuse-history';
const MAX_HISTORY = 6;
export interface HistoryEntry {
id: string;
timestamp: number;
input: InputValues;
assets: SongAssets;
}
function readAll(): HistoryEntry[] {
if (typeof window === 'undefined') return [];
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',
);
} catch {
return [];
}
}
function writeAll(entries: HistoryEntry[]): void {
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
}
export function loadHistory(): HistoryEntry[] {
return readAll();
}
export function addToHistory(
input: InputValues,
assets: SongAssets,
): HistoryEntry[] {
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;
}
export function removeFromHistory(id: string): HistoryEntry[] {
const next = readAll().filter((e) => e.id !== id);
writeAll(next);
return next;
}
export function clearHistory(): void {
writeAll([]);
}
export function formatRelative(timestamp: number, now: number = Date.now()): string {
const diff = Math.max(0, now - timestamp);
const s = Math.floor(diff / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(timestamp).toLocaleDateString();
}
+65 -3
View File
@@ -13,12 +13,13 @@ import { buildSystemPrompt, buildUserMessage } from "./prompts";
import type { import type {
ConfigDisplay, ConfigDisplay,
GenerateRequest, GenerateRequest,
InputValues,
SongAssets, SongAssets,
TestConnectionResult, TestConnectionResult,
} from "./types"; } from "./types";
const STORAGE_KEY = "melodymuse-config"; const STORAGE_KEY = "melodymuse-config";
const DEFAULT_MODEL = "MiniMax-M3"; export const DEFAULT_MODEL = "MiniMax-M3";
export interface StoredConfig { export interface StoredConfig {
api_endpoint: string; api_endpoint: string;
@@ -97,6 +98,32 @@ export function setConfig(patch: Partial<StoredConfig>): ConfigDisplay {
return getConfigDisplay(); return getConfigDisplay();
} }
/**
* Persist *only* the fields the caller passed that are non-empty. Unlike
* `setConfig`, this overwrites the endpoint/model and replaces the API key if
* a new one is provided. Used by /settings's Test Connection path when the
* user is in the middle of editing and wants to try values before committing.
*/
export function previewConfig(patch: Partial<StoredConfig>): StoredConfig {
const current = loadConfig();
const candidate: StoredConfig = {
api_endpoint:
typeof patch.api_endpoint === "string" &&
patch.api_endpoint.trim().length > 0
? patch.api_endpoint.trim()
: current.api_endpoint,
api_key:
typeof patch.api_key === "string" && patch.api_key.length > 0
? patch.api_key
: current.api_key,
model_name:
typeof patch.model_name === "string" && patch.model_name.trim().length > 0
? patch.model_name.trim()
: current.model_name,
};
return candidate;
}
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
// JSON extraction + shape validation // JSON extraction + shape validation
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -183,6 +210,7 @@ interface ProviderRequest {
async function callProvider( async function callProvider(
body: ProviderRequest, body: ProviderRequest,
cfg: StoredConfig, cfg: StoredConfig,
signal?: AbortSignal,
): Promise<string> { ): Promise<string> {
const base = cfg.api_endpoint.replace(/\/+$/, ""); const base = cfg.api_endpoint.replace(/\/+$/, "");
const url = `${base}/chat/completions`; const url = `${base}/chat/completions`;
@@ -196,8 +224,13 @@ async function callProvider(
Authorization: `Bearer ${cfg.api_key}`, Authorization: `Bearer ${cfg.api_key}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal,
}); });
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Re-throw so the caller can recognize a cancellation distinctly.
throw err;
}
// fetch() throws TypeError for network/CORS failures. Surface a helpful // fetch() throws TypeError for network/CORS failures. Surface a helpful
// message — the user is running the app standalone, so CORS is the most // message — the user is running the app standalone, so CORS is the most
// likely culprit. // likely culprit.
@@ -242,15 +275,25 @@ type PartialRequest = Omit<GenerateRequest, "section"> & {
section: Exclude<GenerateRequest["section"], "all">; section: Exclude<GenerateRequest["section"], "all">;
}; };
export async function generateSong(req: AllRequest): Promise<SongAssets>; export interface GenerateOptions {
signal?: AbortSignal;
}
export async function generateSong(
req: AllRequest,
options?: GenerateOptions,
): Promise<SongAssets>;
export async function generateSong( export async function generateSong(
req: PartialRequest, req: PartialRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>>; ): Promise<Partial<SongAssets>>;
export async function generateSong( export async function generateSong(
req: GenerateRequest, req: GenerateRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>>; ): Promise<Partial<SongAssets>>;
export async function generateSong( export async function generateSong(
req: GenerateRequest, req: GenerateRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>> { ): Promise<Partial<SongAssets>> {
const cfg = loadConfig(); const cfg = loadConfig();
if (!isConfigured(cfg)) { if (!isConfigured(cfg)) {
@@ -267,6 +310,7 @@ export async function generateSong(
], ],
}, },
cfg, cfg,
options?.signal,
); );
let parsed: unknown; let parsed: unknown;
@@ -291,8 +335,20 @@ export async function generateSong(
return parsed as Partial<SongAssets>; return parsed as Partial<SongAssets>;
} }
/**
* Test the connection using the *currently saved* configuration.
*/
export async function testConnection(): Promise<TestConnectionResult> { export async function testConnection(): Promise<TestConnectionResult> {
const cfg = loadConfig(); return testConnectionWithConfig(loadConfig());
}
/**
* Test the connection using an arbitrary (in-memory) configuration. Used by
* the Settings page to try out a candidate key/endpoint before persisting it.
*/
export async function testConnectionWithConfig(
cfg: StoredConfig,
): Promise<TestConnectionResult> {
if (!isConfigured(cfg)) { if (!isConfigured(cfg)) {
return { return {
success: false, success: false,
@@ -311,9 +367,15 @@ export async function testConnection(): Promise<TestConnectionResult> {
); );
return { success: true, message: "Connection successful" }; return { success: true, message: "Connection successful" };
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return { success: false, message: "Cancelled" };
}
return { return {
success: false, success: false,
message: err instanceof Error ? err.message : "Connection test failed", message: err instanceof Error ? err.message : "Connection test failed",
}; };
} }
} }
// Re-export so consumers can import everything from one place.
export type { InputValues };
+38 -25
View File
@@ -1,27 +1,27 @@
// Domain types shared across the app. These mirror the JSON shape produced by // Domain types shared across the app. These mirror the JSON shape produced by
// the `generate-song` edge function. // the LLM module.
export type Language = export type Language =
| 'English' | "English"
| 'Deutsch' | "Deutsch"
| 'Español' | "Español"
| 'Français' | "Français"
| 'Italiano' | "Italiano"
| 'Português' | "Português"
| 'Polski' | "Polski"
| 'Other'; | "Other";
export type Vocals = 'vocals' | 'instrumental'; export type Vocals = "vocals" | "instrumental";
export type GenerationSection = export type GenerationSection =
| 'all' | "all"
| 'lyrics' | "lyrics"
| 'style' | "style"
| 'titles' | "titles"
| 'video_prompts' | "video_prompts"
| 'youtube_description'; | "youtube_description";
export type VideoPromptType = 'Abstract' | 'Cinematic' | 'Hybrid'; export type VideoPromptType = "Abstract" | "Cinematic" | "Hybrid";
export interface VideoPrompt { export interface VideoPrompt {
type: VideoPromptType; type: VideoPromptType;
@@ -40,7 +40,7 @@ export interface SongAssets {
export interface GenerateRequest { export interface GenerateRequest {
input: string; input: string;
language: string; // 'English' or free-text from "Other" language: string; // 'English' or free-text from "Other"
mood?: string; mood?: string;
vocals: Vocals; vocals: Vocals;
section: GenerationSection; section: GenerationSection;
@@ -53,16 +53,29 @@ export interface ConfigDisplay {
api_key_set: boolean; api_key_set: boolean;
} }
export interface SaveConfigPayload {
api_endpoint: string;
api_key: string; // empty string => keep existing
model_name: string;
}
export interface TestConnectionResult { export interface TestConnectionResult {
success: boolean; success: boolean;
message: string; message: string;
} }
export interface InputValues {
idea: string;
language: Language | string;
customLanguage: string;
mood: string;
vocals: Vocals;
}
export const NEGATIVE_VIDEO_PROMPT = export const NEGATIVE_VIDEO_PROMPT =
'text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur'; "text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur";
// Human-readable labels for each regenerable section — used by toasts and the
// history panel. Keep in sync with `GenerationSection`.
export const SECTION_LABELS: Record<GenerationSection, string> = {
all: "everything",
titles: "titles",
lyrics: "lyrics",
style: "style",
video_prompts: "video prompts",
youtube_description: "description",
};
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react';
// Auto-grow a textarea to fit its content, with a configurable minimum height.
// Use this anywhere a free-form text field should grow with the user's input.
export function useAutoHeight(
value: string,
minHeight = 0,
) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [value, minHeight]);
return ref;
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
// Returns the number of seconds elapsed since `active` became true. The returned
// value ticks every 250 ms while active, freezes when `active` is false.
export function useElapsed(active: boolean): number {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!active) {
setSeconds(0);
return;
}
const start = Date.now();
setSeconds(0);
const id = window.setInterval(() => {
setSeconds(Math.floor((Date.now() - start) / 1000));
}, 250);
return () => window.clearInterval(id);
}, [active]);
return seconds;
}
export function formatElapsed(s: number): string {
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}m ${r.toString().padStart(2, '0')}s`;
}
+159 -82
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { InputPanel } from "../components/InputPanel";
import { InputPanel, type InputValues } from "../components/InputPanel"; import type { InputValues } from "../components/InputPanel";
import { import {
ResultsPanel, ResultsPanel,
type RegeneratingMap, type RegeneratingMap,
@@ -9,10 +9,17 @@ import {
import { StickyZipBar } from "../components/StickyZipBar"; import { StickyZipBar } from "../components/StickyZipBar";
import { ConfigBanner } from "../components/ConfigBanner"; import { ConfigBanner } from "../components/ConfigBanner";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { HistoryPanel } from "../components/HistoryPanel";
import { Footer } from "../components/Footer";
import { useToast } from "../lib/toast"; import { useToast } from "../lib/toast";
import { generateSong, getConfigDisplay } from "../lib/llm"; import { generateSong, getConfigDisplay } from "../lib/llm";
import { buildSongZip, downloadBlob } from "../lib/zip"; import { addToHistory, type HistoryEntry } from "../lib/history";
import type { SongAssets, VideoPrompt } from "../lib/types"; import { buildSongZip, downloadBlob, sanitizeFilename } from "../lib/zip";
import {
SECTION_LABELS,
type SongAssets,
type VideoPrompt,
} from "../lib/types";
const DEFAULT_INPUT: InputValues = { const DEFAULT_INPUT: InputValues = {
idea: "", idea: "",
@@ -28,16 +35,32 @@ function resolveLanguage(v: InputValues): string {
: v.language; : v.language;
} }
// `true` when any in-flight generation is running, false otherwise.
function isAnyBusy(loading: boolean, regenerating: RegeneratingMap): boolean {
if (loading) return true;
for (const v of Object.values(regenerating)) if (v) return true;
return false;
}
export function HomePage() { export function HomePage() {
const toast = useToast(); const toast = useToast();
const [input, setInput] = useState<InputValues>(DEFAULT_INPUT); const [input, setInput] = useState<InputValues>(DEFAULT_INPUT);
const [assets, setAssets] = useState<SongAssets | null>(null); const [assets, setAssets] = useState<SongAssets | null>(null);
// The "last generated" snapshot. The cards compare their current editable
// values against these to decide whether to show the Revert button.
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [regenerating, setRegenerating] = useState<RegeneratingMap>({}); const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0); const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
const [apiKeySet, setApiKeySet] = useState<boolean>(false); const [apiKeySet, setApiKeySet] = useState<boolean>(false);
// AbortController for the *current* network request. Refs are used so the
// cancel handler always sees the latest value without re-binding.
const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating);
// Check localStorage on mount: if the API key isn't set, show a banner // Check localStorage on mount: if the API key isn't set, show a banner
// pointing the user at Settings. // pointing the user at Settings.
useEffect(() => { useEffect(() => {
@@ -45,6 +68,9 @@ export function HomePage() {
setApiKeySet(cfg.api_key_set); setApiKeySet(cfg.api_key_set);
}, []); }, []);
// Clean up any in-flight request on unmount.
useEffect(() => () => abortRef.current?.abort(), []);
const selectedTitle = useMemo(() => { const selectedTitle = useMemo(() => {
if (!assets || assets.titles.length === 0) return undefined; if (!assets || assets.titles.length === 0) return undefined;
return assets.titles[ return assets.titles[
@@ -52,94 +78,129 @@ export function HomePage() {
]; ];
}, [assets, selectedTitleIndex]); }, [assets, selectedTitleIndex]);
const cancel = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
const runGeneration = useCallback(
async (
buildRequest: (
signal: AbortSignal,
) => Promise<GenerateCall> | GenerateCall,
onSuccess: (result: Partial<SongAssets>) => void,
busySetter: (b: boolean) => void,
successMsg: string,
) => {
// Cancel any in-flight request before starting a new one.
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
busySetter(true);
try {
const req = await buildRequest(ctrl.signal);
const result = await generateSong(req, { signal: ctrl.signal });
onSuccess(result);
toast.success(successMsg);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
toast.info("Generation cancelled");
} else {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
);
}
} finally {
busySetter(false);
if (abortRef.current === ctrl) abortRef.current = null;
}
},
[toast],
);
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return; if (!input.idea.trim()) return;
setLoading(true); await runGeneration(
setAssets(null); () => ({
try {
const result = await generateSong({
input: input.idea.trim(), input: input.idea.trim(),
language: resolveLanguage(input), language: resolveLanguage(input),
mood: input.mood.trim() || undefined, mood: input.mood.trim() || undefined,
vocals: input.vocals, vocals: input.vocals,
section: "all", section: "all" as const,
}); }),
setAssets(result); (result) => {
setSelectedTitleIndex(0); const next = result as SongAssets;
toast.success("Song assets generated"); setAssets(next);
} catch (err) { setOriginalAssets(next);
toast.error( setSelectedTitleIndex(0);
err instanceof Error // Persist to history. Don't `await` — the user shouldn't wait.
? err.message addToHistory(input, next);
: "Generation failed — please try again", },
); setLoading,
} finally { "Song assets generated",
setLoading(false); );
} }, [input, runGeneration]);
}, [input, toast]);
const handleRegenerateAll = useCallback(async () => { const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return; if (!input.idea.trim()) return;
setLoading(true); await runGeneration(
setRegenerating((m) => ({ ...m, all: true })); () => ({
try {
const result = await generateSong({
input: input.idea.trim(), input: input.idea.trim(),
language: resolveLanguage(input), language: resolveLanguage(input),
mood: input.mood.trim() || undefined, mood: input.mood.trim() || undefined,
vocals: input.vocals, vocals: input.vocals,
section: "all", section: "all" as const,
}); }),
setAssets(result); (result) => {
setSelectedTitleIndex(0); const next = result as SongAssets;
toast.success("Regenerated all sections"); setAssets(next);
} catch (err) { setOriginalAssets(next);
toast.error( setSelectedTitleIndex(0);
err instanceof Error addToHistory(input, next);
? err.message },
: "Generation failed — please try again", (b) => {
); setLoading(b);
} finally { setRegenerating((m) => ({ ...m, all: b }));
setLoading(false); },
setRegenerating((m) => ({ ...m, all: false })); "Regenerated all sections",
} );
}, [input, toast]); }, [input, runGeneration]);
const handleRegenerateSection = useCallback( const handleRegenerateSection = useCallback(
async (section: SectionKey) => { async (section: SectionKey) => {
if (!input.idea.trim() || !assets) return; if (!input.idea.trim() || !assets) return;
setRegenerating((m) => ({ ...m, [section]: true })); await runGeneration(
try { () => ({
const result = await generateSong({
input: input.idea.trim(), input: input.idea.trim(),
language: resolveLanguage(input), language: resolveLanguage(input),
mood: input.mood.trim() || undefined, mood: input.mood.trim() || undefined,
vocals: input.vocals, vocals: input.vocals,
section, section,
// Pass the rest of the song as context so the model can stay consistent.
context: assets, context: assets,
}); }),
setAssets({ ...assets, ...result }); (result) => {
toast.success(`Regenerated ${humanizeSection(section)}`); // Merge partial into current assets, and update the original snapshot
} catch (err) { // for the keys that the model returned. Any keys the user has
toast.error( // hand-edited that the model *didn't* return stay as-is.
err instanceof Error const merged: SongAssets = { ...assets, ...result };
? err.message setAssets(merged);
: "Generation failed — please try again", setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
); },
} finally { (b) => setRegenerating((m) => ({ ...m, [section]: b })),
setRegenerating((m) => ({ ...m, [section]: false })); `Regenerated ${SECTION_LABELS[section]}`,
} );
}, },
[input, assets, toast], [input, assets, runGeneration],
); );
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return; if (!assets || !selectedTitle) return;
try { try {
const blob = await buildSongZip(assets, selectedTitle); const blob = await buildSongZip(assets, selectedTitle);
const filename = `${selectedTitle.replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_-]/g, "")}.zip`; downloadBlob(blob, `${sanitizeFilename(selectedTitle)}.zip`);
downloadBlob(blob, filename);
} catch { } catch {
toast.error("Could not generate ZIP — please try again"); toast.error("Could not generate ZIP — please try again");
} }
@@ -166,16 +227,23 @@ export function HomePage() {
[], [],
); );
// Restore a previous generation from history.
const handleLoadHistory = useCallback(
(entry: HistoryEntry) => {
setInput(entry.input);
setAssets(entry.assets);
setOriginalAssets(entry.assets);
setSelectedTitleIndex(0);
toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`);
},
[toast],
);
return ( return (
<div className="min-h-screen pb-32"> <div className="min-h-screen pb-32">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border"> <header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
<Link <span className="text-sm text-fg-muted">MelodyMuse</span>
to="/"
className="text-sm text-fg-muted hover:text-fg transition-colors"
>
MelodyMuse
</Link>
<ThemeToggle /> <ThemeToggle />
</div> </div>
</header> </header>
@@ -196,12 +264,23 @@ export function HomePage() {
onChange={setInput} onChange={setInput}
onSubmit={handleGenerate} onSubmit={handleGenerate}
loading={loading} loading={loading}
cancelButton={
<button
type="button"
onClick={cancel}
className="btn-secondary w-full"
>
Cancel generation
</button>
}
/> />
<HistoryPanel onLoad={handleLoadHistory} />
</div> </div>
<div> <div>
<ResultsPanel <ResultsPanel
assets={assets} assets={assets}
originalAssets={originalAssets}
loading={loading} loading={loading}
selectedTitleIndex={selectedTitleIndex} selectedTitleIndex={selectedTitleIndex}
onSelectTitle={setSelectedTitleIndex} onSelectTitle={setSelectedTitleIndex}
@@ -209,6 +288,8 @@ export function HomePage() {
onChangeVideoPrompt={handleVideoPromptChange} onChangeVideoPrompt={handleVideoPromptChange}
onRegenerateAll={handleRegenerateAll} onRegenerateAll={handleRegenerateAll}
onRegenerateSection={handleRegenerateSection} onRegenerateSection={handleRegenerateSection}
onCancel={cancel}
anyRegenerating={anyRegenerating}
regenerating={regenerating} regenerating={regenerating}
/> />
</div> </div>
@@ -222,21 +303,17 @@ export function HomePage() {
onDownload={handleDownload} onDownload={handleDownload}
/> />
)} )}
<Footer />
</div> </div>
); );
} }
function humanizeSection(s: SectionKey): string { function truncate(s: string, n: number): string {
switch (s) { const t = s.trim();
case "titles": if (t.length <= n) return t || "previous generation";
return "titles"; return t.slice(0, n - 1) + "…";
case "lyrics":
return "lyrics";
case "style":
return "style";
case "video_prompts":
return "video prompts";
case "youtube_description":
return "description";
}
} }
// Internal alias so the `runGeneration` helper stays readable.
type GenerateCall = Parameters<typeof generateSong>[0];
+56 -19
View File
@@ -1,14 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ArrowLeft, Eye, EyeOff, Loader2, Save, Plug } from "lucide-react"; import {
ArrowLeft,
Eye,
EyeOff,
Loader2,
Save,
Plug,
Trash2,
} from "lucide-react";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast"; import { useToast } from "../lib/toast";
import { getConfigDisplay, setConfig, testConnection } from "../lib/llm"; import {
getConfigDisplay,
setConfig,
previewConfig,
testConnectionWithConfig,
saveConfig,
loadConfig,
DEFAULT_MODEL,
} from "../lib/llm";
const DEFAULTS = { const DEFAULTS = {
api_endpoint: "", api_endpoint: "",
api_key: "", api_key: "",
model_name: "MiniMax-M3", model_name: DEFAULT_MODEL,
}; };
export function SettingsPage() { export function SettingsPage() {
@@ -34,7 +50,7 @@ export function SettingsPage() {
setLoading(false); setLoading(false);
}, []); }, []);
const onSave = async (e: React.FormEvent) => { const onSave = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
try { try {
@@ -55,27 +71,28 @@ export function SettingsPage() {
} }
}; };
// Test using *in-memory* form values — don't persist until the user clicks
// Save. An empty API key field still falls back to the previously saved key,
// matching the behaviour of Save.
const onTest = async () => { const onTest = async () => {
setTesting(true); setTesting(true);
// Persist current form values first so the test reflects them. setConfig const candidate = previewConfig({
// preserves the existing key when the field is empty, so this is safe. api_endpoint: endpoint.trim(),
try { api_key: apiKey,
const result = setConfig({ model_name: model.trim(),
api_endpoint: endpoint.trim(), });
api_key: apiKey,
model_name: model.trim(), if (
}); !candidate.api_endpoint ||
setApiKey(""); !candidate.api_key ||
setApiKeySet(result.api_key_set); !candidate.model_name
} catch (err) { ) {
setTesting(false); setTesting(false);
toast.error( toast.error("❌ Fill in endpoint, key, and model before testing");
err instanceof Error ? err.message : "Failed to save configuration",
);
return; return;
} }
const result = await testConnection(); const result = await testConnectionWithConfig(candidate);
if (result.success) { if (result.success) {
toast.success(`${result.message}`); toast.success(`${result.message}`);
} else { } else {
@@ -84,6 +101,16 @@ export function SettingsPage() {
setTesting(false); setTesting(false);
}; };
const onClearKey = () => {
if (!apiKeySet) return;
if (!window.confirm("Clear the saved API key from this browser?")) return;
const current = loadConfig();
saveConfig({ ...current, api_key: "" });
setApiKeySet(false);
setApiKey("");
toast.info("Saved API key cleared");
};
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border"> <header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
@@ -175,6 +202,16 @@ export function SettingsPage() {
A key is currently saved. A key is currently saved.
</p> </p>
)} )}
{apiKeySet && (
<button
type="button"
onClick={onClearKey}
className="mt-2 inline-flex items-center gap-1 text-xs text-fg-muted hover:text-rose-300 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Clear saved key
</button>
)}
</div> </div>
<div> <div>