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">
|
||||
|
||||
Reference in New Issue
Block a user