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:
@@ -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>
|
||||
|
||||
@@ -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) + "…";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user