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
+135
View File
@@ -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) + '…';
}