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
+48 -19
View File
@@ -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>
) : (