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
+154 -66
View File
@@ -1,27 +1,54 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronUp, Loader2, Settings as SettingsIcon, Sparkles } from 'lucide-react';
import { EqualizerIcon } from './EqualizerIcon';
import type { Language, Vocals } from '../lib/types';
import { useState, type FormEvent, type KeyboardEvent } from "react";
import { Link } from "react-router-dom";
import {
ChevronDown,
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 }[] = [
{ value: 'English', label: 'English' },
{ value: 'Deutsch', label: 'Deutsch' },
{ value: 'Español', label: 'Español' },
{ value: 'Français', label: 'Français' },
{ value: 'Italiano', label: 'Italiano' },
{ value: 'Português', label: 'Português' },
{ value: 'Polski', label: 'Polski' },
{ value: 'Other', label: 'Other' },
{ value: "English", label: "English" },
{ value: "Deutsch", label: "Deutsch" },
{ value: "Español", label: "Español" },
{ value: "Français", label: "Français" },
{ value: "Italiano", label: "Italiano" },
{ value: "Português", label: "Português" },
{ value: "Polski", label: "Polski" },
{ value: "Other", label: "Other" },
];
export interface InputValues {
idea: string;
language: Language | string;
customLanguage: string;
mood: string;
vocals: Vocals;
}
// A handful of starter ideas shown as quick-fill chips. Kept short and varied
// so the user can see the kind of input the model expects.
const EXAMPLE_IDEAS: { text: string; mood: string; vocals: Vocals }[] = [
{
text: "melancholic lo-fi house beat for a rainy sunday morning",
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 {
values: InputValues;
@@ -29,16 +56,48 @@ interface InputPanelProps {
onSubmit: () => void;
loading: 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 elapsed = useElapsed(loading);
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
onChange({ ...values, [key]: value });
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 (
<div className="relative">
{/* 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">
<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>
<p className="text-sm text-fg-muted mb-6 ml-11">
Generate your Suno song assets with AI
</p>
<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) onSubmit();
}}
className="space-y-4"
>
<form onSubmit={submit} className="space-y-4">
<div>
<label htmlFor="idea" className="sr-only">
Describe your music idea
</label>
<div className="flex items-center justify-between mb-1.5">
<label htmlFor="idea" className="sr-only">
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
id="idea"
value={values.idea}
onChange={(e) => set('idea', e.target.value)}
onChange={(e) => set("idea", e.target.value)}
onKeyDown={onKeyDown}
rows={4}
placeholder={`Describe your music idea...\ne.g. 'melancholic house beat for a rainy night'`}
className="textarea text-base leading-relaxed"
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>
<button
@@ -91,13 +170,16 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
{optionsOpen && (
<div className="space-y-4 p-4 rounded-lg border border-border bg-bg-card/50">
<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
</label>
<select
id="language"
value={values.language}
onChange={(e) => set('language', e.target.value as Language)}
onChange={(e) => set("language", e.target.value as Language)}
className="input"
>
{LANGUAGES.map((l) => (
@@ -106,19 +188,22 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
</option>
))}
</select>
{values.language === 'Other' && (
{values.language === "Other" && (
<input
type="text"
className="input mt-2"
placeholder="e.g. 日本語, Türkçe, Русский"
value={values.customLanguage}
onChange={(e) => set('customLanguage', e.target.value)}
onChange={(e) => set("customLanguage", e.target.value)}
/>
)}
</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
</label>
<input
@@ -126,7 +211,7 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
type="text"
placeholder="e.g. melancholic, euphoric, tense…"
value={values.mood}
onChange={(e) => set('mood', e.target.value)}
onChange={(e) => set("mood", e.target.value)}
className="input"
/>
</div>
@@ -138,25 +223,25 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => set('vocals', 'vocals')}
onClick={() => set("vocals", "vocals")}
className={
values.vocals === 'vocals'
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
: 'chip justify-center py-2'
values.vocals === "vocals"
? "px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors"
: "chip justify-center py-2"
}
aria-pressed={values.vocals === 'vocals'}
aria-pressed={values.vocals === "vocals"}
>
🎤 Vocals
</button>
<button
type="button"
onClick={() => set('vocals', 'instrumental')}
onClick={() => set("vocals", "instrumental")}
className={
values.vocals === 'instrumental'
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
: 'chip justify-center py-2'
values.vocals === "instrumental"
? "px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors"
: "chip justify-center py-2"
}
aria-pressed={values.vocals === 'instrumental'}
aria-pressed={values.vocals === "instrumental"}
>
🎹 Instrumental
</button>
@@ -165,23 +250,26 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
</div>
)}
<button
type="submit"
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 ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Generating
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Generate Song Assets
</>
)}
</button>
<div className="space-y-2">
<button
type="submit"
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 ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Generating {formatElapsed(elapsed)}</span>
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Generate Song Assets
</>
)}
</button>
{loading && cancelButton}
</div>
<div className="text-center">
<Link