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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user