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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Music, RefreshCw, Sparkles } from "lucide-react";
|
||||
import type { SongAssets } from "../lib/types";
|
||||
import { SkeletonCard } from "./SkeletonCard";
|
||||
import { TitlesCard } from "./cards/TitlesCard";
|
||||
@@ -18,18 +18,22 @@ export type RegeneratingMap = Partial<Record<SectionKey | "all", boolean>>;
|
||||
|
||||
interface ResultsPanelProps {
|
||||
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;
|
||||
onSelectTitle: (i: number) => void;
|
||||
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
|
||||
onChangeVideoPrompt: (index: number, value: string) => void;
|
||||
onRegenerateAll: () => void;
|
||||
onRegenerateSection: (section: SectionKey) => void;
|
||||
onCancel?: () => void; // cancel the in-flight generation
|
||||
anyRegenerating: boolean;
|
||||
regenerating: RegeneratingMap;
|
||||
}
|
||||
|
||||
export function ResultsPanel({
|
||||
assets,
|
||||
originalAssets,
|
||||
loading,
|
||||
selectedTitleIndex,
|
||||
onSelectTitle,
|
||||
@@ -37,26 +41,41 @@ export function ResultsPanel({
|
||||
onChangeVideoPrompt,
|
||||
onRegenerateAll,
|
||||
onRegenerateSection,
|
||||
onCancel,
|
||||
anyRegenerating,
|
||||
regenerating,
|
||||
}: ResultsPanelProps) {
|
||||
const showSkeletons = loading && !assets;
|
||||
const regenAll = Boolean(regenerating.all);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold text-fg">Results</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateAll}
|
||||
disabled={loading || regenAll}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${regenAll || loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>Regenerate All</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{anyRegenerating && onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="btn-ghost"
|
||||
aria-label="Cancel generation"
|
||||
>
|
||||
<span className="text-rose-300">Cancel</span>
|
||||
</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>
|
||||
|
||||
{showSkeletons ? (
|
||||
@@ -67,7 +86,7 @@ export function ResultsPanel({
|
||||
<SkeletonCard height="h-32" rows={4} />
|
||||
<SkeletonCard height="h-56" rows={3} />
|
||||
</div>
|
||||
) : assets ? (
|
||||
) : assets && originalAssets ? (
|
||||
<div className="space-y-4">
|
||||
<TitlesCard
|
||||
titles={assets.titles}
|
||||
@@ -78,6 +97,7 @@ export function ResultsPanel({
|
||||
/>
|
||||
<LyricsCard
|
||||
lyrics={assets.lyrics}
|
||||
originalLyrics={originalAssets.lyrics}
|
||||
onChange={(v) => onChange("lyrics", v)}
|
||||
onRegenerate={() => onRegenerateSection("lyrics")}
|
||||
regenerating={Boolean(regenerating.lyrics)}
|
||||
@@ -85,6 +105,8 @@ export function ResultsPanel({
|
||||
<StyleCard
|
||||
style={assets.style}
|
||||
negativeStyle={assets.negative_style}
|
||||
originalStyle={originalAssets.style}
|
||||
originalNegativeStyle={originalAssets.negative_style}
|
||||
onStyleChange={(v) => onChange("style", v)}
|
||||
onNegativeChange={(v) => onChange("negative_style", v)}
|
||||
onRegenerate={() => onRegenerateSection("style")}
|
||||
@@ -92,18 +114,48 @@ export function ResultsPanel({
|
||||
/>
|
||||
<VideoPromptsCard
|
||||
prompts={assets.video_prompts}
|
||||
originalPrompts={originalAssets.video_prompts}
|
||||
onChange={onChangeVideoPrompt}
|
||||
onRegenerate={() => onRegenerateSection("video_prompts")}
|
||||
regenerating={Boolean(regenerating.video_prompts)}
|
||||
/>
|
||||
<YouTubeCard
|
||||
description={assets.youtube_description}
|
||||
originalDescription={originalAssets.youtube_description}
|
||||
onChange={(v) => onChange("youtube_description", v)}
|
||||
onRegenerate={() => onRegenerateSection("youtube_description")}
|
||||
regenerating={Boolean(regenerating.youtube_description)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ResultCard } from '../ResultCard';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { ResultCard } from "../ResultCard";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||
|
||||
interface LyricsCardProps {
|
||||
lyrics: string;
|
||||
originalLyrics: string;
|
||||
onChange: (next: string) => void;
|
||||
onRegenerate: () => void;
|
||||
regenerating: boolean;
|
||||
}
|
||||
|
||||
// Auto-grow the textarea to fit its content. Min height is enforced in CSS via
|
||||
// `min-height`; we only ever grow.
|
||||
function useAutoHeight(value: string, minHeight = 200) {
|
||||
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 LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: LyricsCardProps) {
|
||||
export function LyricsCard({
|
||||
lyrics,
|
||||
originalLyrics,
|
||||
onChange,
|
||||
onRegenerate,
|
||||
regenerating,
|
||||
}: LyricsCardProps) {
|
||||
const ref = useAutoHeight(lyrics, 200);
|
||||
const dirty = lyrics !== originalLyrics;
|
||||
|
||||
return (
|
||||
<ResultCard
|
||||
@@ -32,7 +28,23 @@ export function LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: Lyr
|
||||
title="Lyrics"
|
||||
onRegenerate={onRegenerate}
|
||||
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
|
||||
ref={ref}
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ResultCard } from '../ResultCard';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { ResultCard } from "../ResultCard";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||
|
||||
interface StyleCardProps {
|
||||
style: string;
|
||||
negativeStyle: string;
|
||||
originalStyle: string;
|
||||
originalNegativeStyle: string;
|
||||
onStyleChange: (next: string) => void;
|
||||
onNegativeChange: (next: string) => void;
|
||||
onRegenerate: () => void;
|
||||
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({
|
||||
style,
|
||||
negativeStyle,
|
||||
originalStyle,
|
||||
originalNegativeStyle,
|
||||
onStyleChange,
|
||||
onNegativeChange,
|
||||
onRegenerate,
|
||||
@@ -33,6 +27,9 @@ export function StyleCard({
|
||||
const styleRef = useAutoHeight(style, 96);
|
||||
const negRef = useAutoHeight(negativeStyle, 64);
|
||||
|
||||
const dirty =
|
||||
style !== originalStyle || negativeStyle !== originalNegativeStyle;
|
||||
|
||||
return (
|
||||
<ResultCard
|
||||
index={2}
|
||||
@@ -40,6 +37,23 @@ export function StyleCard({
|
||||
title="Style"
|
||||
onRegenerate={onRegenerate}
|
||||
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>
|
||||
@@ -59,7 +73,9 @@ export function StyleCard({
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<textarea
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ResultCard } from '../ResultCard';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { NEGATIVE_VIDEO_PROMPT, type VideoPrompt, type VideoPromptType } from '../../lib/types';
|
||||
import { useState } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { ResultCard } from "../ResultCard";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||
import {
|
||||
NEGATIVE_VIDEO_PROMPT,
|
||||
type VideoPrompt,
|
||||
type VideoPromptType,
|
||||
} from "../../lib/types";
|
||||
|
||||
interface VideoPromptsCardProps {
|
||||
prompts: VideoPrompt[];
|
||||
originalPrompts: VideoPrompt[];
|
||||
onChange: (index: number, next: string) => void;
|
||||
onRegenerate: () => void;
|
||||
regenerating: boolean;
|
||||
}
|
||||
|
||||
const TABS: VideoPromptType[] = ['Abstract', 'Cinematic', 'Hybrid'];
|
||||
const TABS: VideoPromptType[] = ["Abstract", "Cinematic", "Hybrid"];
|
||||
|
||||
export function VideoPromptsCard({
|
||||
prompts,
|
||||
originalPrompts,
|
||||
onChange,
|
||||
onRegenerate,
|
||||
regenerating,
|
||||
}: VideoPromptsCardProps) {
|
||||
const [tab, setTab] = useState<VideoPromptType>('Abstract');
|
||||
const [tab, setTab] = useState<VideoPromptType>("Abstract");
|
||||
const idx = prompts.findIndex((p) => p.type === tab);
|
||||
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);
|
||||
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]);
|
||||
const ref = useAutoHeight(current?.prompt ?? "", 120);
|
||||
|
||||
return (
|
||||
<ResultCard
|
||||
@@ -37,7 +41,25 @@ export function VideoPromptsCard({
|
||||
title="Video Prompts"
|
||||
onRegenerate={onRegenerate}
|
||||
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">
|
||||
{TABS.map((t) => {
|
||||
@@ -49,8 +71,8 @@ export function VideoPromptsCard({
|
||||
onClick={() => setTab(t)}
|
||||
className={
|
||||
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 text-fg-muted hover:text-fg hover:bg-bg-hover 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"
|
||||
}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
@@ -78,12 +100,19 @@ export function VideoPromptsCard({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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-xs text-fg-muted leading-relaxed">{NEGATIVE_VIDEO_PROMPT}</p>
|
||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">
|
||||
Negative
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
{NEGATIVE_VIDEO_PROMPT}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ResultCard } from '../ResultCard';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { ResultCard } from "../ResultCard";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { useAutoHeight } from "../../lib/useAutoHeight";
|
||||
|
||||
interface YouTubeCardProps {
|
||||
description: string;
|
||||
originalDescription: string;
|
||||
onChange: (next: string) => void;
|
||||
onRegenerate: () => void;
|
||||
regenerating: boolean;
|
||||
@@ -11,17 +13,13 @@ interface YouTubeCardProps {
|
||||
|
||||
export function YouTubeCard({
|
||||
description,
|
||||
originalDescription,
|
||||
onChange,
|
||||
onRegenerate,
|
||||
regenerating,
|
||||
}: YouTubeCardProps) {
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.max(300, el.scrollHeight)}px`;
|
||||
}, [description]);
|
||||
const ref = useAutoHeight(description, 300);
|
||||
const dirty = description !== originalDescription;
|
||||
|
||||
return (
|
||||
<ResultCard
|
||||
@@ -30,7 +28,23 @@ export function YouTubeCard({
|
||||
title="YouTube Description"
|
||||
onRegenerate={onRegenerate}
|
||||
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
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user