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) => {
+30 -8
View File
@@ -13,21 +13,21 @@ import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm";
const STORAGE_KEY = "melodymuse-config";
const HISTORY_KEY = "melodymuse-history";
const DRAFT_KEY = "melodymuse-draft";
const THEME_KEY = "melodymuse-theme";
interface LocalDataSummary {
historyEntries: number;
hasDraft: boolean;
hasTheme: boolean;
}
function readLocalDataSummary(): LocalDataSummary {
if (typeof window === "undefined") {
return { historyEntries: 0, hasTheme: false };
return { historyEntries: 0, hasDraft: false, hasTheme: false };
}
let historyEntries = 0;
let hasTheme = false;
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (raw) {
@@ -37,8 +37,9 @@ function readLocalDataSummary(): LocalDataSummary {
} catch {
/* ignore */
}
hasTheme = window.localStorage.getItem(THEME_KEY) !== null;
return { historyEntries, hasTheme };
const hasDraft = window.localStorage.getItem(DRAFT_KEY) !== null;
const hasTheme = window.localStorage.getItem(THEME_KEY) !== null;
return { historyEntries, hasDraft, hasTheme };
}
export function SettingsPage() {
@@ -50,6 +51,7 @@ export function SettingsPage() {
const [checking, setChecking] = useState(true);
const [summary, setSummary] = useState<LocalDataSummary>({
historyEntries: 0,
hasDraft: false,
hasTheme: false,
});
@@ -89,17 +91,24 @@ export function SettingsPage() {
toast.info("Recent generations cleared");
};
const onClearDraft = () => {
if (!summary.hasDraft) return;
window.localStorage.removeItem(DRAFT_KEY);
setSummary((s) => ({ ...s, hasDraft: false }));
toast.info("In-progress draft cleared");
};
const onClearAll = () => {
if (
!window.confirm(
"Clear all MelodyMuse data from this browser? This will remove the recent generations and the theme preference. The API key on the server is NOT affected.",
"Clear all MelodyMuse data from this browser? This will remove the recent generations, the in-progress draft, and the theme preference. The API key on the server is NOT affected.",
)
)
return;
window.localStorage.removeItem(STORAGE_KEY);
window.localStorage.removeItem(HISTORY_KEY);
window.localStorage.removeItem(DRAFT_KEY);
window.localStorage.removeItem(THEME_KEY);
setSummary({ historyEntries: 0, hasTheme: false });
setSummary({ historyEntries: 0, hasDraft: false, hasTheme: false });
toast.info("Local data cleared");
};
@@ -196,6 +205,10 @@ export function SettingsPage() {
{summary.historyEntries} recent generation
{summary.historyEntries === 1 ? "" : "s"}
</li>
<li>
<code className="font-mono">melodymuse-draft</code> {" "}
{summary.hasDraft ? "saved (in-progress input)" : "empty"}
</li>
<li>
<code className="font-mono">melodymuse-theme</code> {" "}
{summary.hasTheme ? "set" : "using default"}
@@ -211,6 +224,15 @@ export function SettingsPage() {
<Trash2 className="w-4 h-4" />
Clear recent generations
</button>
<button
type="button"
onClick={onClearDraft}
disabled={!summary.hasDraft}
className="btn-secondary"
>
<Trash2 className="w-4 h-4" />
Clear in-progress draft
</button>
<button
type="button"
onClick={onClearAll}