From b945417773d36f3165f6539b7616eadd551aa856 Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Wed, 3 Jun 2026 01:47:11 +0200 Subject: [PATCH] Add cancel, history, revert, and quality-of-life features UX - Add elapsed-time counter and Cancel button to in-flight generations, wired through AbortController so partial responses are discarded cleanly. The cancel action is available from both the input panel and the results header. - Add a Recent generations panel below the input. The last 6 successful generations are saved to localStorage; one click reloads both the input fields and the generated assets. - Add a Revert button to every editable card. It appears the moment the current value diverges from the last generated value and restores the field with a single click. - Add an Empty state to the right panel with a friendly hint pointing at the Generate button and the Settings page. - Add a 'Try an example' button that fills the input with a random starter idea (idea + mood + vocals). - Add Cmd/Ctrl+Enter as a keyboard shortcut to generate. - Add a Footer with project info and a privacy reminder. - Add a 'Clear saved key' button to the Settings page so the user can remove the API key without overwriting it. Bug fix - Settings > Test Connection used to save the in-progress form values to localStorage before testing. It now uses the in-memory candidate config, so failed tests don't pollute the saved config. Code quality - Extract the duplicated useAutoHeight hook to src/lib/useAutoHeight.ts. - Extract a useElapsed hook for the loading timer. - Move InputValues into src/lib/types.ts (was duplicated in InputPanel.tsx) and add SECTION_LABELS, replacing the humanizeSection switch in HomePage. - Centralize the filename sanitization: HomePage now calls sanitizeFilename from zip.ts instead of duplicating the regex. - Add previewConfig / testConnectionWithConfig helpers to llm.ts to support in-memory connection tests. --- README.md | 46 ++++- src/components/Footer.tsx | 16 ++ src/components/HistoryPanel.tsx | 135 ++++++++++++ src/components/InputPanel.tsx | 220 ++++++++++++++------ src/components/ResultsPanel.tsx | 84 ++++++-- src/components/cards/LyricsCard.tsx | 48 +++-- src/components/cards/StyleCard.tsx | 46 +++-- src/components/cards/VideoPromptsCard.tsx | 67 ++++-- src/components/cards/YouTubeCard.tsx | 36 +++- src/lib/history.ts | 82 ++++++++ src/lib/llm.ts | 68 +++++- src/lib/types.ts | 63 +++--- src/lib/useAutoHeight.ts | 17 ++ src/lib/useElapsed.ts | 29 +++ src/pages/HomePage.tsx | 241 ++++++++++++++-------- src/pages/SettingsPage.tsx | 75 +++++-- 16 files changed, 991 insertions(+), 282 deletions(-) create mode 100644 src/components/Footer.tsx create mode 100644 src/components/HistoryPanel.tsx create mode 100644 src/lib/history.ts create mode 100644 src/lib/useAutoHeight.ts create mode 100644 src/lib/useElapsed.ts diff --git a/README.md b/README.md index 529cdcf..d57a353 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ OpenAI-compatible `/chat/completions` endpoint, using credentials that you paste into the Settings page. Those credentials are kept in this browser's `localStorage`. +## Features + +- 🎡 **All 5 Suno assets in one click** β€” titles, lyrics, style, video prompts, YouTube description +- ✏️ **Inline editing** β€” every generated field is editable; revert any edit with one click +- πŸ”„ **Per-section regeneration** β€” regenerate just one section; the rest of the song is passed as context for consistency +- ⏱ **Elapsed-time counter** + ❌ **Cancel** button for long generations +- πŸ•˜ **Recent generations** β€” last 6 are kept in localStorage; one click to reload +- ⌨️ **Keyboard shortcut** β€” `Cmd/Ctrl + Enter` to generate +- 🎲 **Try an example** β€” fill the input with a random sample idea +- πŸŒ— **Dark / light mode** β€” toggle in the header, persisted +- πŸ’Ύ **Standalone** β€” no backend, no signup, no telemetry + ## Tech stack - React 18 + TypeScript + Vite @@ -27,15 +39,29 @@ MelodyMuse/ β”‚ β”œβ”€β”€ main.tsx β”‚ β”œβ”€β”€ index.css # Tailwind layers + design-system components β”‚ β”œβ”€β”€ components/ # UI building blocks -β”‚ β”‚ └── cards/ # The five result cards +β”‚ β”‚ β”œβ”€β”€ cards/ # The five result cards +β”‚ β”‚ β”œβ”€β”€ ConfigBanner.tsx +β”‚ β”‚ β”œβ”€β”€ CopyButton.tsx +β”‚ β”‚ β”œβ”€β”€ EqualizerIcon.tsx +β”‚ β”‚ β”œβ”€β”€ Footer.tsx +β”‚ β”‚ β”œβ”€β”€ HistoryPanel.tsx +β”‚ β”‚ β”œβ”€β”€ InputPanel.tsx +β”‚ β”‚ β”œβ”€β”€ ResultCard.tsx +β”‚ β”‚ β”œβ”€β”€ ResultsPanel.tsx +β”‚ β”‚ β”œβ”€β”€ SkeletonCard.tsx +β”‚ β”‚ β”œβ”€β”€ StickyZipBar.tsx +β”‚ β”‚ └── ThemeToggle.tsx β”‚ β”œβ”€β”€ pages/ # HomePage, SettingsPage β”‚ β”œβ”€β”€ lib/ +β”‚ β”‚ β”œβ”€β”€ history.ts # recent-generations persistence β”‚ β”‚ β”œβ”€β”€ llm.ts # direct fetch β†’ provider, JSON extraction, config persistence β”‚ β”‚ β”œβ”€β”€ prompts.ts # system + user prompt construction -β”‚ β”‚ β”œβ”€β”€ types.ts # shared TypeScript types -β”‚ β”‚ β”œβ”€β”€ zip.ts # JSZip layout β”‚ β”‚ β”œβ”€β”€ theme.ts # dark/light mode -β”‚ β”‚ └── toast.tsx # toast context +β”‚ β”‚ β”œβ”€β”€ toast.tsx # toast context +β”‚ β”‚ β”œβ”€β”€ types.ts # shared TypeScript types + SECTION_LABELS +β”‚ β”‚ β”œβ”€β”€ useAutoHeight.ts # shared textarea auto-grow hook +β”‚ β”‚ β”œβ”€β”€ useElapsed.ts # shared "X seconds elapsed" hook +β”‚ β”‚ └── zip.ts # JSZip layout β”‚ └── vite-env.d.ts β”œβ”€β”€ public/favicon.svg β”œβ”€β”€ index.html @@ -68,10 +94,11 @@ MelodyMuse/ - **Model Name** β€” e.g. `MiniMax-M3` Click **Save Configuration**, then **Test Connection** to confirm - everything is wired up. + everything is wired up. **Test Connection** uses your unsaved form + values β€” your changes are only persisted when you click **Save**. 4. **Generate**. Return to the home page, type a music idea, click - **Generate Song Assets**. + **Generate Song Assets** (or press `Cmd/Ctrl + Enter`). ## Build for production @@ -112,8 +139,11 @@ The configured endpoint must expose an OpenAI-compatible - The API key is held in `localStorage` and is only sent to the endpoint you configure. No analytics, no telemetry, no third-party calls. -- The `MelodyMuse-config` localStorage key contains your endpoint URL, model - name, and key. You can clear it at any time from your browser's devtools +- Three localStorage keys are used: + - `melodymuse-config` β€” `{ api_endpoint, api_key, model_name }` + - `melodymuse-history` β€” the last 6 generations (input + assets) + - `melodymuse-theme` β€” `'dark' | 'light'` +- You can clear them at any time from your browser's devtools (Application β†’ Local Storage). ## License diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..b0b8324 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,16 @@ +export function Footer() { + return ( + + ); +} diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx new file mode 100644 index 0000000..429e80a --- /dev/null +++ b/src/components/HistoryPanel.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react'; +import { Clock, History, Trash2, X } from 'lucide-react'; +import { + clearHistory, + formatRelative, + loadHistory, + removeFromHistory, + type HistoryEntry, +} from '../lib/history'; +import type { InputValues } from '../lib/types'; + +interface HistoryPanelProps { + onLoad: (entry: HistoryEntry) => void; +} + +export function HistoryPanel({ onLoad }: HistoryPanelProps) { + const [open, setOpen] = useState(false); + const [entries, setEntries] = useState([]); + + // Refresh the list whenever the panel is opened, so newly-saved generations + // appear without needing a full page refresh. + useEffect(() => { + if (!open) return; + setEntries(loadHistory()); + }, [open]); + + if (entries.length === 0 && !open) { + return null; + } + + return ( +
+ + + {open && ( +
+ {entries.length === 0 ? ( +

+ No recent generations yet. +

+ ) : ( + <> + {entries.map((e) => ( + { + onLoad(e); + setOpen(false); + }} + onRemove={() => setEntries(removeFromHistory(e.id))} + /> + ))} +
+ +
+ + )} +
+ )} +
+ ); +} + +function HistoryRow({ + entry, + onLoad, + onRemove, +}: { + entry: HistoryEntry; + onLoad: () => void; + onRemove: () => void; +}) { + const summary = summarizeInput(entry.input); + return ( +
+ + +
+ ); +} + +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) + '…'; +} diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx index 56825b1..ea7d316 100644 --- a/src/components/InputPanel.tsx +++ b/src/components/InputPanel.tsx @@ -1,27 +1,54 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { ChevronDown, ChevronUp, Loader2, Settings as SettingsIcon, Sparkles } from 'lucide-react'; -import { EqualizerIcon } from './EqualizerIcon'; -import type { Language, Vocals } from '../lib/types'; +import { useState, type FormEvent, type KeyboardEvent } from "react"; +import { Link } from "react-router-dom"; +import { + ChevronDown, + ChevronUp, + Loader2, + Settings as SettingsIcon, + Sparkles, + Shuffle, +} from "lucide-react"; +import { EqualizerIcon } from "./EqualizerIcon"; +import type { InputValues, Language, Vocals } from "../lib/types"; +import { formatElapsed, useElapsed } from "../lib/useElapsed"; + +export type { InputValues }; const LANGUAGES: { value: Language; label: string }[] = [ - { value: 'English', label: 'English' }, - { value: 'Deutsch', label: 'Deutsch' }, - { value: 'EspaΓ±ol', label: 'EspaΓ±ol' }, - { value: 'FranΓ§ais', label: 'FranΓ§ais' }, - { value: 'Italiano', label: 'Italiano' }, - { value: 'PortuguΓͺs', label: 'PortuguΓͺs' }, - { value: 'Polski', label: 'Polski' }, - { value: 'Other', label: 'Other' }, + { value: "English", label: "English" }, + { value: "Deutsch", label: "Deutsch" }, + { value: "EspaΓ±ol", label: "EspaΓ±ol" }, + { value: "FranΓ§ais", label: "FranΓ§ais" }, + { value: "Italiano", label: "Italiano" }, + { value: "PortuguΓͺs", label: "PortuguΓͺs" }, + { value: "Polski", label: "Polski" }, + { value: "Other", label: "Other" }, ]; -export interface InputValues { - idea: string; - language: Language | string; - customLanguage: string; - mood: string; - vocals: Vocals; -} +// A handful of starter ideas shown as quick-fill chips. Kept short and varied +// so the user can see the kind of input the model expects. +const EXAMPLE_IDEAS: { text: string; mood: string; vocals: Vocals }[] = [ + { + text: "melancholic lo-fi house beat for a rainy sunday morning", + mood: "melancholic", + vocals: "instrumental", + }, + { + text: "euphoric summer pop anthem with a singalong chorus", + mood: "euphoric", + vocals: "vocals", + }, + { + text: "tense cinematic underscore for a thriller chase scene", + mood: "tense", + vocals: "instrumental", + }, + { + text: "dreamy ambient ballad about missing someone far away", + mood: "longing", + vocals: "vocals", + }, +]; interface InputPanelProps { values: InputValues; @@ -29,16 +56,48 @@ interface InputPanelProps { onSubmit: () => void; loading: boolean; disabled?: boolean; + cancelButton?: React.ReactNode; } -export function InputPanel({ values, onChange, onSubmit, loading, disabled }: InputPanelProps) { +export function InputPanel({ + values, + onChange, + onSubmit, + loading, + disabled, + cancelButton, +}: InputPanelProps) { const [optionsOpen, setOptionsOpen] = useState(false); + const elapsed = useElapsed(loading); const set = (key: K, value: InputValues[K]) => onChange({ ...values, [key]: value }); const canSubmit = !loading && !disabled && values.idea.trim().length > 0; + const submit = (e?: FormEvent) => { + e?.preventDefault(); + if (canSubmit) onSubmit(); + }; + + // Submit on Cmd/Ctrl+Enter, even from inside the textarea. + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + submit(); + } + }; + + const fillExample = () => { + const ex = EXAMPLE_IDEAS[Math.floor(Math.random() * EXAMPLE_IDEAS.length)]; + onChange({ + ...values, + idea: ex.text, + mood: values.mood || ex.mood, + vocals: ex.vocals, + }); + }; + return (
{/* Animated gradient backdrop behind the header */} @@ -46,32 +105,52 @@ export function InputPanel({ values, onChange, onSubmit, loading, disabled }: In
-

MelodyMuse

+

+ MelodyMuse +

Generate your Suno song assets with AI

-
{ - e.preventDefault(); - if (canSubmit) onSubmit(); - }} - className="space-y-4" - > +
- +
+ + + Music idea + + +