Polish: draft autosave, history refresh, outdated footer, more

Bug fixes / UX

- Footer: the privacy line still claimed 'generation runs in your
  browser'. Now correctly says the generation runs on the server and
  the key never reaches the browser.

- History panel: when the panel was open while a new generation
  completed, the new entry didn't appear until the user closed and
  re-opened the panel. The history module now dispatches a custom
  'melodymuse-history-updated' event on every write; the panel
  listens and re-reads. removeFromHistory/clearHistory emit too.
  writeAll is also now try/catch'd against quota errors.

- Auto-save the input form: the input is persisted to localStorage
  ('melodymuse-draft', debounced 400ms) so a refresh doesn't wipe
  what the user was typing. The initial state hydrates from the
  draft on mount.

- Cmd/Ctrl+Enter now works in the Music style field as well as
  the idea field — shared onKeyDown handler, propagated through the
  StyleField sub-component.

- Full regen ('Generate' / 'Regenerate All') now clears the previous
  assets before kicking off the new request, so the skeleton state
  shows during the round-trip instead of stale cards with a spinner
  on top. Per-section regen still leaves the other cards intact.

- Settings → Local data: the in-progress draft is now listed
  alongside history and theme, and has its own 'Clear in-progress
  draft' button. The 'Clear all local data' button clears it too.
This commit is contained in:
2026-06-03 08:59:31 +02:00
parent 1f82825849
commit 3096669a13
6 changed files with 137 additions and 34 deletions
+43 -2
View File
@@ -20,6 +20,11 @@ import {
type VideoPrompt,
} from "../lib/types";
// localStorage key for the in-progress input form. Lets the user survive
// an accidental refresh without losing what they were typing.
const DRAFT_KEY = "melodymuse-draft";
const DRAFT_DEBOUNCE_MS = 400;
const DEFAULT_INPUT: InputValues = {
idea: "",
language: "English",
@@ -41,10 +46,27 @@ function isAnyBusy(loading: boolean, regenerating: RegeneratingMap): boolean {
return false;
}
function loadDraft(): InputValues | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<InputValues> | null;
if (!parsed || typeof parsed !== "object") return null;
return { ...DEFAULT_INPUT, ...parsed };
} catch {
return null;
}
}
export function HomePage() {
const toast = useToast();
const [input, setInput] = useState<InputValues>(DEFAULT_INPUT);
// Hydrate the input from any previously-saved draft so the user can recover
// an accidental refresh.
const [input, setInput] = useState<InputValues>(
() => loadDraft() ?? DEFAULT_INPUT,
);
const [assets, setAssets] = useState<SongAssets | null>(null);
// The "last generated" snapshot. The cards compare their current editable
// values against these to decide whether to show the Revert button.
@@ -60,7 +82,20 @@ export function HomePage() {
const anyRegenerating = isAnyBusy(loading, regenerating);
// Check the server on mount. If unreachable, surface a quiet banner.
// Debounced auto-save: persist the input form ~400ms after the last edit so
// a refresh doesn't wipe what the user was typing.
useEffect(() => {
const id = window.setTimeout(() => {
try {
window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input));
} catch {
// Quota or disabled — fine, history is the more durable store.
}
}, DRAFT_DEBOUNCE_MS);
return () => window.clearTimeout(id);
}, [input]);
// Check the server on mount. If unreachable, surface a quiet header hint.
useEffect(() => {
const ctrl = new AbortController();
(async () => {
@@ -161,6 +196,10 @@ export function HomePage() {
const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return;
// Clear the old assets so the skeleton state shows during generation
// instead of the previous song's cards.
setAssets(null);
setOriginalAssets(null);
await runGeneration(
() => buildRequest("all"),
(result) => {
@@ -177,6 +216,8 @@ export function HomePage() {
const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return;
setAssets(null);
setOriginalAssets(null);
await runGeneration(
() => buildRequest("all"),
(result) => {