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:
+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