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
+2 -2
View File
@@ -7,8 +7,8 @@ export function Footer() {
Generate Suno song assets with AI
</p>
<p>
All generation runs in your browser your API key never leaves this
device.
Generation runs on the MelodyMuse server your API key never reaches
the browser.
</p>
</div>
</footer>
+20 -9
View File
@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react';
import { Clock, History, Trash2, X } from 'lucide-react';
import { useEffect, useState } from "react";
import { Clock, History, Trash2, X } from "lucide-react";
import {
clearHistory,
formatRelative,
HISTORY_UPDATED_EVENT,
loadHistory,
removeFromHistory,
type HistoryEntry,
} from '../lib/history';
import type { InputValues } from '../lib/types';
} from "../lib/history";
import type { InputValues } from "../lib/types";
interface HistoryPanelProps {
onLoad: (entry: HistoryEntry) => void;
@@ -17,13 +18,23 @@ 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.
// Refresh the list whenever the panel is opened.
useEffect(() => {
if (!open) return;
setEntries(loadHistory());
}, [open]);
// And whenever the history is updated from elsewhere in the app (after a
// successful generation, or a delete/clear from a different panel).
useEffect(() => {
const handler = () => {
if (!open) return;
setEntries(loadHistory());
};
window.addEventListener(HISTORY_UPDATED_EVENT, handler);
return () => window.removeEventListener(HISTORY_UPDATED_EVENT, handler);
}, [open]);
if (entries.length === 0 && !open) {
return null;
}
@@ -43,7 +54,7 @@ export function HistoryPanel({ onLoad }: HistoryPanelProps) {
<span className="text-xs text-fg-muted">({entries.length})</span>
)}
</span>
<span className="text-xs text-fg-muted">{open ? 'Hide' : 'Show'}</span>
<span className="text-xs text-fg-muted">{open ? "Hide" : "Show"}</span>
</button>
{open && (
@@ -130,6 +141,6 @@ function HistoryRow({
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) + '…';
if (idea.length <= max) return idea || "(no idea text)";
return idea.slice(0, max - 1) + "…";
}
+11 -1
View File
@@ -228,6 +228,7 @@ export function InputPanel({
loading={styleLoading}
onChange={(v) => set("style_hint", v)}
onRandom={handleStyleRandom}
onCmdEnter={onKeyDown}
/>
<div>
@@ -324,7 +325,15 @@ interface StyleFieldProps {
onRandom: (mode: "normal" | "crazy") => void;
}
function StyleField({ value, loading, onChange, onRandom }: StyleFieldProps) {
function StyleField({
value,
loading,
onChange,
onRandom,
onCmdEnter,
}: StyleFieldProps & {
onCmdEnter: (e: KeyboardEvent<HTMLTextAreaElement>) => void;
}) {
return (
<div>
<label
@@ -340,6 +349,7 @@ function StyleField({ value, loading, onChange, onRandom }: StyleFieldProps) {
placeholder="e.g. dark synthwave, analog pads, 110 BPM"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onCmdEnter}
className="textarea text-sm"
/>
<div className="mt-2 grid grid-cols-2 gap-2">
+31 -12
View File
@@ -1,12 +1,23 @@
// Recent-generations history. Persists the last N successful generations in
// localStorage so the user can revisit a song without re-running the model.
import type { InputValues } from '../components/InputPanel';
import type { SongAssets } from './types';
import type { InputValues } from "../components/InputPanel";
import type { SongAssets } from "./types";
const HISTORY_KEY = 'melodymuse-history';
const HISTORY_KEY = "melodymuse-history";
const MAX_HISTORY = 6;
// Fired on `window` whenever the history changes (add/remove/clear). The
// HistoryPanel listens for this so it can refresh its visible list without
// the user having to close and reopen it.
export const HISTORY_UPDATED_EVENT = "melodymuse-history-updated";
export function notifyHistoryUpdated(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(HISTORY_UPDATED_EVENT));
}
}
export interface HistoryEntry {
id: string;
timestamp: number;
@@ -15,7 +26,7 @@ export interface HistoryEntry {
}
function readAll(): HistoryEntry[] {
if (typeof window === 'undefined') return [];
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
@@ -24,11 +35,11 @@ function readAll(): HistoryEntry[] {
return parsed.filter(
(e): e is HistoryEntry =>
e &&
typeof e === 'object' &&
typeof e.id === 'string' &&
typeof e.timestamp === 'number' &&
typeof e.input === 'object' &&
typeof e.assets === 'object',
typeof e === "object" &&
typeof e.id === "string" &&
typeof e.timestamp === "number" &&
typeof e.input === "object" &&
typeof e.assets === "object",
);
} catch {
return [];
@@ -36,7 +47,12 @@ function readAll(): HistoryEntry[] {
}
function writeAll(entries: HistoryEntry[]): void {
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
try {
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
} catch {
// localStorage quota or disabled — fail silently; history is a nice-to-have.
}
notifyHistoryUpdated();
}
export function loadHistory(): HistoryEntry[] {
@@ -68,10 +84,13 @@ export function clearHistory(): void {
writeAll([]);
}
export function formatRelative(timestamp: number, now: number = Date.now()): string {
export function formatRelative(
timestamp: number,
now: number = Date.now(),
): string {
const diff = Math.max(0, now - timestamp);
const s = Math.floor(diff / 1000);
if (s < 60) return 'just now';
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
+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}