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
+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 { 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>
);
}