Build standalone MelodyMuse SPA

Drop the Supabase backend and call the AI provider directly from the
browser. The endpoint URL, API key, and model name are stored in
localStorage and used for direct /chat/completions requests.

- src/lib/llm.ts: config persistence, direct fetch, JSON extraction,
  shape validation, connection test
- src/lib/prompts.ts: full + partial-regeneration system prompts and
  user-message builder
- src/lib/{api,supabase}.ts removed
- supabase/ directory removed
- @supabase/supabase-js dropped from package.json
- README updated to describe the standalone architecture and CORS caveats
- .gitignore: drop Supabase entries, exclude *.tsbuildinfo
This commit is contained in:
2026-06-03 01:25:22 +02:00
parent f2401d0c57
commit d42410560f
37 changed files with 5443 additions and 376 deletions
+35
View File
@@ -0,0 +1,35 @@
# dependencies
node_modules
.pnp
.pnp.js
# production
dist
dist-ssr
# tsc incremental build info
*.tsbuildinfo
# local env
.env
.env.local
.env.*.local
# logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# editor
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+87 -376
View File
@@ -1,410 +1,121 @@
\# 🎵 MelodyMuse
# MelodyMuse
Generate all assets needed for a Suno AI song from a free-text description —
titles, lyrics, style prompt, video prompts (Abstract / Cinematic / Hybrid),
and a YouTube description, then download everything as a ZIP.
The app runs **entirely in your browser** as a single static SPA. There is no
backend, no database, no third-party services: the only network calls are
direct `fetch` requests from your browser to the AI provider's
OpenAI-compatible `/chat/completions` endpoint, using credentials that you
paste into the Settings page. Those credentials are kept in this browser's
`localStorage`.
> AI-powered song asset generator for \[Suno AI](https://suno.com) — lyrics, style prompts, video loops, and YouTube descriptions in one click.
\---
\## What is MelodyMuse?
MelodyMuse is a personal web app that takes a music idea as input and uses an AI (via any OpenAI-compatible API) to generate everything you need to publish a Suno AI song:
\- ✍️ \*\*Lyrics\*\* — rhyming, structured with Suno section tags, free of genre references
\- 🎵 \*\*Style Prompt\*\* — ready to paste into Suno's "Style of Music" field
\- 🚫 \*\*Negative Style Prompt\*\* — what Suno should avoid
\- 🏷️ \*\*3 Song Title Suggestions\*\* — atmospheric, direct, and abstract
\- 🎬 \*\*3 Video Loop Prompts\*\* — abstract, cinematic, and hybrid, with AI tool recommendations
\- 📺 \*\*YouTube Description\*\* — with embedded lyrics, hashtags, and a call to action
All results are editable before download. Everything packages into a single `.zip` file named after your chosen song title.
\---
\## Features
| Feature | Details |
|---|---|
| Free-text input | Describe your music idea in natural language |
| Optional parameters | Language, mood, vocals vs. instrumental |
| Inline editing | Edit any generated section before downloading |
| Per-section regeneration | Regenerate only lyrics, style, titles, etc. |
| Full regeneration | Re-run everything with one click |
| ZIP download | `Style.txt`, `Text.txt`, `Videodescription.txt`, `Videoprompt.txt` |
| Dark / Light mode | Toggle in the top-right corner |
| Secure API key storage | Key lives on the server only — never in the browser |
| Provider-agnostic | Works with any OpenAI-compatible API (MiniMax, OpenAI, Mistral, etc.) |
\---
\## Tech Stack
\- \*\*Frontend:\*\* React 18 + TypeScript + Tailwind CSS
\- \*\*Backend:\*\* Supabase Edge Functions (Deno)
\- \*\*Database:\*\* Supabase PostgreSQL (app config only)
\- \*\*ZIP generation:\*\* JSZip (client-side)
\---
\## Project Structure
## Tech stack
- React 18 + TypeScript + Vite
- Tailwind CSS (dark by default, light theme via `html.light`)
- JSZip for client-side ZIP generation
- `react-router-dom` for the two routes (`/`, `/settings`)
## Repository layout
```
/
MelodyMuse/
├── src/
│ ├── pages/
│ ├── Index.tsx # Main generator page
│ └── Settings.tsx # API configuration page
│ ├── components/
│ │ ├── InputPanel.tsx # Left panel: text input + options
│ │ ├── ResultsPanel.tsx # Right panel: all result cards
│ ├── TitlesCard.tsx # Song title chips + selection
│ │ ├── LyricsCard.tsx # Editable lyrics textarea
│ │ ├── StyleCard.tsx # Style + negative style
│ │ ├── VideoPromptsCard.tsx # Tabbed video prompt variations
│ │ ├── YoutubeCard.tsx # YouTube description
│ │ └── DownloadBar.tsx # Sticky ZIP download bar
│ └── lib/
│ └── zip.ts # ZIP file generation logic
└── supabase/
  └── functions/
  ├── save-config/ # Saves API credentials server-side
  ├── get-config-display/ # Returns masked config for settings UI
  ├── test-connection/ # Tests the configured API
  └── generate-song/ # Calls AI and returns generated assets
│ ├── App.tsx
│ ├── main.tsx
│ ├── index.css # Tailwind layers + design-system components
│ ├── components/ # UI building blocks
│ │ └── cards/ # The five result cards
├── pages/ # HomePage, SettingsPage
│ ├── lib/
│ ├── 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
── vite-env.d.ts
├── public/favicon.svg
├── index.html
├── tailwind.config.js
├── vite.config.ts
├── tsconfig*.json
└── package.json
```
## Getting started
1. **Install dependencies**
\---
```sh
npm install
```
2. **Start the dev server**
```sh
npm run dev
```
\## Setup
Open <http://localhost:5173> in your browser.
3. **Open Settings** and fill in:
- **API Endpoint URL** — e.g. `https://api.minimax.chat/v1`
- **API Key** — your provider's secret key
- **Model Name** — e.g. `MiniMax-M3`
\### 1. Prerequisites
Click **Save Configuration**, then **Test Connection** to confirm
everything is wired up.
4. **Generate**. Return to the home page, type a music idea, click
**Generate Song Assets**.
## Build for production
\- A \[Supabase](https://supabase.com) project (free tier is sufficient)
\- An API key from any OpenAI-compatible provider (e.g. MiniMax, OpenAI, Mistral)
\---
\### 2. Supabase — Create the config table
In your Supabase project, open the \*\*SQL Editor\*\* and run:
```sql
CREATE TABLE app\_settings (
&#x20; id INT PRIMARY KEY DEFAULT 1,
&#x20; api\_endpoint TEXT NOT NULL DEFAULT '',
&#x20; api\_key TEXT NOT NULL DEFAULT '',
&#x20; model\_name TEXT NOT NULL DEFAULT '',
&#x20; updated\_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO app\_settings (id, api\_endpoint, api\_key, model\_name)
VALUES (1, '', '', '')
ON CONFLICT DO NOTHING;
```sh
npm run build
```
Outputs static assets in `dist/`. The `dist/` folder is a normal SPA — serve
it from any static host (GitHub Pages, Netlify, Vercel, `python -m http.server`,
…).
> ⚠️ **Heads up about deployment.** Because the API key is stored in the
> browser's `localStorage`, you should not serve a public deployment of this
> app and use it with a real key on a shared device. For personal/local use
> this is fine.
> \*\*Important:\*\* Row Level Security (RLS) must be \*\*disabled\*\* for this table.
## CORS
> The table is only ever accessed by Edge Functions using the service\_role key —
The app makes direct cross-origin requests from the browser to your
provider. If your provider does not send the right `Access-Control-Allow-*`
headers for your origin, the request will fail with a CORS error. The
generated error message will explicitly call this out. Workarounds:
> never directly from the browser.
- Pick a provider/endpoint that already permits browser CORS (most managed
OpenAI-compatible services do).
- Run the app on the same origin as the API (i.e. front it with a tiny
proxy).
- Use a CORS-permissive browser extension during local development.
## Provider format
The configured endpoint must expose an OpenAI-compatible
`POST {api_endpoint}/chat/completions` route that accepts
`{ model, messages, max_tokens }` and returns
`{ choices: [{ message: { content } }] }`.
\---
\### 3. Configure your API key in the app
1\. Open the deployed app and navigate to \*\*Settings\*\* (link below the Generate button)
2\. Enter your:
&#x20; - \*\*API Endpoint URL\*\* — e.g. `https://api.minimax.chat/v1`
&#x20; - \*\*API Key\*\* — your provider's secret key
&#x20; - \*\*Model Name\*\* — e.g. `MiniMax-Text-01` or `gpt-4o`
3\. Click \*\*Save Configuration\*\*
4\. Use \*\*Test Connection\*\* to verify everything works
Your API key is sent to the server via an Edge Function and stored in the database.
It is \*\*never\*\* returned to the browser or stored in localStorage.
\---
\## Usage
1\. Open the app at your URL
2\. Enter a music idea in the text field
&#x20; > \_Example: "Melancholic lo-fi house beat for a rainy Sunday morning"\_
3\. Optionally expand \*\*Options\*\* to set language, mood, and vocal preference
4\. Click \*\*✦ Generate Song Assets\*\*
5\. Review and edit any section inline
6\. Select your preferred song title from the 3 suggestions
7\. Click \*\*⬇ Download ZIP\*\* — you'll get `YourTitle.zip` containing:
| File | Contents |
|---|---|
| `Style.txt` | Suno style prompt + negative style (always English) |
| `Text.txt` | Song lyrics with Suno section tags |
| `Videodescription.txt` | Full YouTube description (in lyrics language) |
| `Videoprompt.txt` | 3 video loop prompts with tool recommendations |
\---
\## AI Output Rules
The AI follows these rules automatically — no manual configuration needed:
\*\*Lyrics\*\*
\- Must rhyme (AABB or ABAB scheme)
\- Must NOT reference the genre, instruments, or music production
&#x20; \_(e.g. a classical piano piece will not have lyrics about pianos or orchestras)\_
\- Structured with Suno section tags: `\[Verse]`, `\[Chorus]`, `\[Bridge]`, `\[Outro]`, etc.
\*\*Style Prompt\*\*
\- Max 120 words, comma-separated
\- Includes: tempo (BPM), instruments, production style, texture, energy, mood, vocal style
\- Never references artists or bands by name
\*\*Video Prompts\*\*
\- 3 variations: Abstract, Cinematic, Hybrid
\- Optimized for 5-second seamless loops at 16:9
\- Looping logic: if reverse playback is used, only physically sensible elements
&#x20; \_(e.g. pulsing light — never reversed rainfall)\_
\- No text, logos, faces, or hands in any prompt
\- Includes tool recommendation (Runway, Kling, Luma, Pika, Haiper — never Sora)
\*\*Language\*\*
\- Style, negative style, and video prompts → always \*\*English\*\*
\- Lyrics, titles, YouTube description → in the \*\*user's selected language\*\*
\---
\## Supported API Providers
MelodyMuse works with any OpenAI-compatible API:
| Provider | Endpoint | Notes |
|---|---|---|
| MiniMax | `https://api.minimax.chat/v1` | Recommended default |
| OpenAI | `https://api.openai.com/v1` | GPT-4o works well |
| Mistral | `https://api.mistral.ai/v1` | Fast and cost-effective |
| Groq | `https://api.groq.com/openai/v1` | Very fast inference |
| Any other OpenAI-compatible provider | custom URL | Should work out of the box |
\---
\## Security Notes
\- The API key is stored in the Supabase database and is only accessible
&#x20; to Edge Functions via the `service\_role` key
\- The browser client never queries the `app\_settings` table directly
\- The `get-config-display` endpoint returns only the endpoint URL, model name,
&#x20; and a boolean `api\_key\_set` — the actual key is never returned
\- There is no user authentication (this app is intended for personal/single-user use)
\- If you deploy this publicly, consider adding basic auth or Supabase Auth
\---
\## License
## Security notes
- 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
(Application → Local Storage).
## License
Personal use. Do whatever you want with it.
\---
\*Powered by Supabase · Made for \[Suno AI](https://suno.com)\*
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MelodyMuse Suno Song Asset Generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2864
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "melodymuse",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Generate all assets needed for a Suno AI song from a free-text input. Runs entirely in the browser — no backend.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc -b --noEmit"
},
"dependencies": {
"jszip": "^3.10.1",
"lucide-react": "^0.451.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7c3aed"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="#080810"/>
<g stroke="url(#g)" stroke-width="2" stroke-linecap="round">
<line x1="6" y1="11" x2="6" y2="21"/>
<line x1="11" y1="7" x2="11" y2="25"/>
<line x1="16" y1="12" x2="16" y2="20"/>
<line x1="21" y1="9" x2="21" y2="23"/>
<line x1="26" y1="13" x2="26" y2="19"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 643 B

+16
View File
@@ -0,0 +1,16 @@
import { Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { SettingsPage } from './pages/SettingsPage';
import { ToastProvider } from './lib/toast';
export default function App() {
return (
<ToastProvider>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<HomePage />} />
</Routes>
</ToastProvider>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
interface ConfigBannerProps {
message: string;
linkTo: string;
linkLabel: string;
}
export function ConfigBanner({ message, linkTo, linkLabel }: ConfigBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
<p className="flex-1">
{message}{' '}
<Link to={linkTo} className="font-semibold underline underline-offset-2 hover:text-amber-100">
{linkLabel}
</Link>
</p>
<button
type="button"
onClick={() => setDismissed(true)}
className="text-amber-200/70 hover:text-amber-100 transition-colors"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
interface CopyButtonProps {
value: string;
label?: string;
className?: string;
}
export function CopyButton({ value, label = 'Copy', className = '' }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const onClick = async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
// Fallback: use a hidden textarea + execCommand for older browsers.
const ta = document.createElement('textarea');
ta.value = value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {
/* swallow */
}
document.body.removeChild(ta);
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
return (
<button
type="button"
onClick={onClick}
className={`btn-ghost ${className}`}
aria-label={copied ? 'Copied' : label}
>
{copied ? (
<>
<Check className="w-4 h-4 text-emerald-400" />
<span>Copied!</span>
</>
) : (
<>
<Copy className="w-4 h-4" />
<span>{label}</span>
</>
)}
</button>
);
}
+37
View File
@@ -0,0 +1,37 @@
// Animated equalizer/waveform icon for the MelodyMuse header.
export function EqualizerIcon({ className = 'w-7 h-7' }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id="eq-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#a78bfa" />
<stop offset="1" stopColor="#22d3ee" />
</linearGradient>
</defs>
<g stroke="url(#eq-grad)" strokeWidth="2.4" strokeLinecap="round">
<line x1="5" y1="13" x2="5" y2="19">
<animate attributeName="y1" values="13;8;13" dur="1.2s" repeatCount="indefinite" />
<animate attributeName="y2" values="19;24;19" dur="1.2s" repeatCount="indefinite" />
</line>
<line x1="11" y1="9" x2="11" y2="23">
<animate attributeName="y1" values="9;14;9" dur="1.4s" repeatCount="indefinite" />
<animate attributeName="y2" values="23;18;23" dur="1.4s" repeatCount="indefinite" />
</line>
<line x1="17" y1="11" x2="17" y2="21">
<animate attributeName="y1" values="11;6;11" dur="1.0s" repeatCount="indefinite" />
<animate attributeName="y2" values="21;26;21" dur="1.0s" repeatCount="indefinite" />
</line>
<line x1="23" y1="8" x2="23" y2="24">
<animate attributeName="y1" values="8;13;8" dur="1.6s" repeatCount="indefinite" />
<animate attributeName="y2" values="24;19;24" dur="1.6s" repeatCount="indefinite" />
</line>
</g>
</svg>
);
}
+198
View File
@@ -0,0 +1,198 @@
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';
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' },
];
export interface InputValues {
idea: string;
language: Language | string;
customLanguage: string;
mood: string;
vocals: Vocals;
}
interface InputPanelProps {
values: InputValues;
onChange: (next: InputValues) => void;
onSubmit: () => void;
loading: boolean;
disabled?: boolean;
}
export function InputPanel({ values, onChange, onSubmit, loading, disabled }: InputPanelProps) {
const [optionsOpen, setOptionsOpen] = useState(false);
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
onChange({ ...values, [key]: value });
const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
return (
<div className="relative">
{/* Animated gradient backdrop behind the header */}
<div className="absolute -top-10 -left-10 -right-10 h-64 animated-gradient-bg rounded-3xl -z-10 opacity-70 pointer-events-none" />
<div className="flex items-center gap-3 mb-1">
<EqualizerIcon className="w-8 h-8 shrink-0" />
<h1 className="text-3xl font-bold gradient-text leading-none">MelodyMuse</h1>
</div>
<p className="text-sm text-fg-muted mb-6 ml-11">
Generate your Suno song assets with AI
</p>
<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) onSubmit();
}}
className="space-y-4"
>
<div>
<label htmlFor="idea" className="sr-only">
Describe your music idea
</label>
<textarea
id="idea"
value={values.idea}
onChange={(e) => set('idea', e.target.value)}
rows={4}
placeholder={`Describe your music idea...\ne.g. 'melancholic house beat for a rainy night'`}
className="textarea text-base leading-relaxed"
required
/>
</div>
<button
type="button"
onClick={() => setOptionsOpen((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-border bg-bg-card hover:bg-bg-hover transition-colors text-sm"
aria-expanded={optionsOpen}
>
<span className="font-medium text-fg"> Options</span>
{optionsOpen ? (
<ChevronUp className="w-4 h-4 text-fg-muted" />
) : (
<ChevronDown className="w-4 h-4 text-fg-muted" />
)}
</button>
{optionsOpen && (
<div className="space-y-4 p-4 rounded-lg border border-border bg-bg-card/50">
<div>
<label htmlFor="language" className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
Language
</label>
<select
id="language"
value={values.language}
onChange={(e) => set('language', e.target.value as Language)}
className="input"
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
{values.language === 'Other' && (
<input
type="text"
className="input mt-2"
placeholder="e.g. 日本語, Türkçe, Русский"
value={values.customLanguage}
onChange={(e) => set('customLanguage', e.target.value)}
/>
)}
</div>
<div>
<label htmlFor="mood" className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
Mood
</label>
<input
id="mood"
type="text"
placeholder="e.g. melancholic, euphoric, tense…"
value={values.mood}
onChange={(e) => set('mood', e.target.value)}
className="input"
/>
</div>
<div>
<span className="block text-xs uppercase tracking-wider text-fg-muted mb-1">
Vocals
</span>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => set('vocals', 'vocals')}
className={
values.vocals === 'vocals'
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
: 'chip justify-center py-2'
}
aria-pressed={values.vocals === 'vocals'}
>
🎤 Vocals
</button>
<button
type="button"
onClick={() => set('vocals', 'instrumental')}
className={
values.vocals === 'instrumental'
? 'px-3 py-2 rounded-lg font-medium bg-accent-primary text-white transition-colors'
: 'chip justify-center py-2'
}
aria-pressed={values.vocals === 'instrumental'}
>
🎹 Instrumental
</button>
</div>
</div>
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full py-4 rounded-xl text-base font-semibold text-white shadow-lg shadow-violet-900/30 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2 gradient-bg hover:brightness-110 active:brightness-95"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Generating
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Generate Song Assets
</>
)}
</button>
<div className="text-center">
<Link
to="/settings"
className="inline-flex items-center gap-1.5 text-xs text-fg-muted hover:text-fg transition-colors"
>
<SettingsIcon className="w-3.5 h-3.5" />
Settings
</Link>
</div>
</form>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
interface ResultCardProps {
index: number; // 0..4 — controls the stagger delay
icon: ReactNode;
title: string;
onRegenerate?: () => void;
regenerating?: boolean;
headerExtra?: ReactNode;
children: ReactNode;
}
export function ResultCard({
index,
icon,
title,
onRegenerate,
regenerating,
headerExtra,
children,
}: ResultCardProps) {
return (
<section
className={`card p-5 opacity-0 animate-fade-up stagger-${index}`}
style={{ animationFillMode: 'forwards' }}
>
<header className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-2">
<span className="text-lg leading-none">{icon}</span>
<h2 className="text-base font-semibold text-fg">{title}</h2>
</div>
<div className="flex items-center gap-1">
{headerExtra}
{onRegenerate && (
<button
type="button"
onClick={onRegenerate}
disabled={regenerating}
className="btn-ghost"
aria-label={`Regenerate ${title}`}
>
<RefreshCw className={`w-4 h-4 ${regenerating ? 'animate-spin' : ''}`} />
<span>Regenerate</span>
</button>
)}
</div>
</header>
<div className={regenerating ? 'opacity-60 pointer-events-none transition-opacity' : ''}>
{children}
</div>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { RefreshCw } from "lucide-react";
import type { SongAssets } from "../lib/types";
import { SkeletonCard } from "./SkeletonCard";
import { TitlesCard } from "./cards/TitlesCard";
import { LyricsCard } from "./cards/LyricsCard";
import { StyleCard } from "./cards/StyleCard";
import { VideoPromptsCard } from "./cards/VideoPromptsCard";
import { YouTubeCard } from "./cards/YouTubeCard";
export type SectionKey =
| "titles"
| "lyrics"
| "style"
| "video_prompts"
| "youtube_description";
export type RegeneratingMap = Partial<Record<SectionKey | "all", boolean>>;
interface ResultsPanelProps {
assets: SongAssets | null;
loading: boolean; // true during the initial "Generate all" call
selectedTitleIndex: number;
onSelectTitle: (i: number) => void;
onChange: <K extends keyof SongAssets>(key: K, value: SongAssets[K]) => void;
onChangeVideoPrompt: (index: number, value: string) => void;
onRegenerateAll: () => void;
onRegenerateSection: (section: SectionKey) => void;
regenerating: RegeneratingMap;
}
export function ResultsPanel({
assets,
loading,
selectedTitleIndex,
onSelectTitle,
onChange,
onChangeVideoPrompt,
onRegenerateAll,
onRegenerateSection,
regenerating,
}: ResultsPanelProps) {
const showSkeletons = loading && !assets;
const regenAll = Boolean(regenerating.all);
return (
<div>
<div className="sticky top-0 z-30 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3 bg-bg/80 backdrop-blur border-b border-border mb-6 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-fg">Results</h2>
<button
type="button"
onClick={onRegenerateAll}
disabled={loading || regenAll}
className="btn-ghost"
>
<RefreshCw
className={`w-4 h-4 ${regenAll || loading ? "animate-spin" : ""}`}
/>
<span>Regenerate All</span>
</button>
</div>
{showSkeletons ? (
<div className="space-y-4">
<SkeletonCard height="h-16" rows={1} />
<SkeletonCard height="h-40" rows={4} />
<SkeletonCard height="h-24" rows={3} />
<SkeletonCard height="h-32" rows={4} />
<SkeletonCard height="h-56" rows={3} />
</div>
) : assets ? (
<div className="space-y-4">
<TitlesCard
titles={assets.titles}
selectedIndex={selectedTitleIndex}
onSelect={onSelectTitle}
onRegenerate={() => onRegenerateSection("titles")}
regenerating={Boolean(regenerating.titles)}
/>
<LyricsCard
lyrics={assets.lyrics}
onChange={(v) => onChange("lyrics", v)}
onRegenerate={() => onRegenerateSection("lyrics")}
regenerating={Boolean(regenerating.lyrics)}
/>
<StyleCard
style={assets.style}
negativeStyle={assets.negative_style}
onStyleChange={(v) => onChange("style", v)}
onNegativeChange={(v) => onChange("negative_style", v)}
onRegenerate={() => onRegenerateSection("style")}
regenerating={Boolean(regenerating.style)}
/>
<VideoPromptsCard
prompts={assets.video_prompts}
onChange={onChangeVideoPrompt}
onRegenerate={() => onRegenerateSection("video_prompts")}
regenerating={Boolean(regenerating.video_prompts)}
/>
<YouTubeCard
description={assets.youtube_description}
onChange={(v) => onChange("youtube_description", v)}
onRegenerate={() => onRegenerateSection("youtube_description")}
regenerating={Boolean(regenerating.youtube_description)}
/>
</div>
) : null}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
interface SkeletonCardProps {
height?: string;
rows?: number;
className?: string;
}
// Generic skeleton used while a single section regenerates, or for the entire
// results panel during the initial "Generate all" call. The shape loosely
// matches a result card so the layout doesn't jump when content arrives.
export function SkeletonCard({ height = 'h-40', rows = 2, className = '' }: SkeletonCardProps) {
return (
<div className={`card p-5 animate-pulse ${className}`}>
<div className="h-4 w-1/3 bg-bg-hover rounded mb-4" />
<div className={`${height} bg-bg-hover rounded-lg mb-3`} />
{Array.from({ length: Math.max(0, rows - 1) }).map((_, i) => (
<div key={i} className="h-3 w-full bg-bg-hover rounded mb-2" />
))}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { FolderArchive, Download } from 'lucide-react';
import { sanitizeFilename } from '../lib/zip';
interface StickyZipBarProps {
title: string | undefined;
busy: boolean;
onDownload: () => void;
}
export function StickyZipBar({ title, busy, onDownload }: StickyZipBarProps) {
const filename = title && title.trim().length > 0
? `${sanitizeFilename(title)}.zip`
: null;
return (
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-bg/90 backdrop-blur supports-[backdrop-filter]:bg-bg/70">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-fg min-w-0">
<FolderArchive className="w-4 h-4 text-fg-muted shrink-0" />
<span className="truncate font-mono">
{filename ?? <span className="text-fg-muted">Select a title to name your download</span>}
</span>
</div>
<button
type="button"
onClick={onDownload}
disabled={!filename || busy}
className="btn-primary py-2.5 px-5"
>
<Download className="w-4 h-4" />
{filename ? 'Download ZIP' : 'Select a title first'}
</button>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
import { getStoredTheme, toggleTheme, type Theme } from '../lib/theme';
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
setTheme(getStoredTheme());
}, []);
const onClick = () => {
const next = toggleTheme();
setTheme(next);
};
return (
<button
type="button"
onClick={onClick}
className="btn-ghost p-2"
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import { ResultCard } from '../ResultCard';
import { CopyButton } from '../CopyButton';
interface LyricsCardProps {
lyrics: string;
onChange: (next: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
// Auto-grow the textarea to fit its content. Min height is enforced in CSS via
// `min-height`; we only ever grow.
function useAutoHeight(value: string, minHeight = 200) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [value, minHeight]);
return ref;
}
export function LyricsCard({ lyrics, onChange, onRegenerate, regenerating }: LyricsCardProps) {
const ref = useAutoHeight(lyrics, 200);
return (
<ResultCard
index={1}
icon="📝"
title="Lyrics"
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={lyrics ? <CopyButton value={lyrics} label="Copy" /> : null}
>
<textarea
ref={ref}
value={lyrics}
onChange={(e) => onChange(e.target.value)}
placeholder="Lyrics will appear here…"
spellCheck
className="textarea font-mono text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 200 }}
/>
</ResultCard>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react';
import { ResultCard } from '../ResultCard';
import { CopyButton } from '../CopyButton';
interface StyleCardProps {
style: string;
negativeStyle: string;
onStyleChange: (next: string) => void;
onNegativeChange: (next: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
function useAutoHeight(value: string, minHeight: number) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [value, minHeight]);
return ref;
}
export function StyleCard({
style,
negativeStyle,
onStyleChange,
onNegativeChange,
onRegenerate,
regenerating,
}: StyleCardProps) {
const styleRef = useAutoHeight(style, 96);
const negRef = useAutoHeight(negativeStyle, 64);
return (
<ResultCard
index={2}
icon="🎵"
title="Style"
onRegenerate={onRegenerate}
regenerating={regenerating}
>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-fg">Style Prompt</label>
<CopyButton value={style} label="Copy" />
</div>
<textarea
ref={styleRef}
value={style}
onChange={(e) => onStyleChange(e.target.value)}
rows={4}
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 96 }}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-fg">Negative Style</label>
<CopyButton value={negativeStyle} label="Copy" />
</div>
<textarea
ref={negRef}
value={negativeStyle}
onChange={(e) => onNegativeChange(e.target.value)}
rows={2}
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 64 }}
/>
</div>
</div>
</ResultCard>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { ResultCard } from "../ResultCard";
import { RefreshCw } from "lucide-react";
interface TitlesCardProps {
titles: string[];
selectedIndex: number;
onSelect: (index: number) => void;
onRegenerate: () => void;
regenerating: boolean;
}
export function TitlesCard({
titles,
selectedIndex,
onSelect,
onRegenerate,
regenerating,
}: TitlesCardProps) {
// Clamp selectedIndex so it always points at a real title (defensive against
// list shrinkage when regenerating).
const initial = Math.min(
Math.max(0, selectedIndex),
Math.max(0, titles.length - 1),
);
const [localIndex, setLocalIndex] = useState(initial);
const active =
titles.length > 0 ? Math.min(localIndex, titles.length - 1) : 0;
// Bubble the local selection up to the parent so the ZIP bar can use it.
useEffect(() => {
onSelect(active);
}, [active, onSelect]);
return (
<ResultCard
index={0}
icon="🏷️"
title="Song Titles"
onRegenerate={onRegenerate}
regenerating={regenerating}
>
<div className="flex flex-wrap gap-2">
{titles.map((t, i) => {
const isSelected = i === active;
return (
<button
key={`${t}-${i}`}
type="button"
onClick={() => setLocalIndex(i)}
className={
isSelected
? "px-4 py-2 rounded-full text-sm sm:text-base font-semibold bg-accent-primary text-white shadow-md shadow-violet-900/30 transition-colors"
: "chip text-sm sm:text-base"
}
aria-pressed={isSelected}
>
{t}
</button>
);
})}
{regenerating && (
<span className="chip text-fg-muted">
<RefreshCw className="w-4 h-4 animate-spin" />
Generating new titles
</span>
)}
</div>
<p className="mt-3 text-xs text-fg-muted">
Selected: <span className="text-fg">{titles[active] ?? "—"}</span>
</p>
</ResultCard>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from 'react';
import { ResultCard } from '../ResultCard';
import { CopyButton } from '../CopyButton';
import { NEGATIVE_VIDEO_PROMPT, type VideoPrompt, type VideoPromptType } from '../../lib/types';
interface VideoPromptsCardProps {
prompts: VideoPrompt[];
onChange: (index: number, next: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
const TABS: VideoPromptType[] = ['Abstract', 'Cinematic', 'Hybrid'];
export function VideoPromptsCard({
prompts,
onChange,
onRegenerate,
regenerating,
}: VideoPromptsCardProps) {
const [tab, setTab] = useState<VideoPromptType>('Abstract');
const idx = prompts.findIndex((p) => p.type === tab);
const current = idx >= 0 ? prompts[idx] : undefined;
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el || !current) return;
el.style.height = 'auto';
el.style.height = `${Math.max(120, el.scrollHeight)}px`;
}, [current?.prompt, tab]);
return (
<ResultCard
index={3}
icon="🎬"
title="Video Prompts"
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={current ? <CopyButton value={current.prompt} label="Copy" /> : null}
>
<div className="flex items-center gap-1 mb-4 p-1 rounded-lg bg-bg-hover/40 border border-border w-fit">
{TABS.map((t) => {
const isActive = t === tab;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={
isActive
? 'px-3 py-1.5 rounded-md text-sm font-medium bg-accent-primary text-white transition-colors'
: 'px-3 py-1.5 rounded-md text-sm font-medium text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors'
}
role="tab"
aria-selected={isActive}
>
{t}
</button>
);
})}
</div>
{current ? (
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-fg">Main prompt</label>
</div>
<textarea
ref={ref}
value={current.prompt}
onChange={(e) => onChange(idx, e.target.value)}
rows={5}
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 120 }}
/>
</div>
<p className="text-sm italic text-fg-muted">
Best for: <span className="not-italic text-fg">{current.tool_recommendation}</span>
</p>
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">Negative</p>
<p className="text-xs text-fg-muted leading-relaxed">{NEGATIVE_VIDEO_PROMPT}</p>
</div>
</div>
) : (
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
)}
</ResultCard>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react';
import { ResultCard } from '../ResultCard';
import { CopyButton } from '../CopyButton';
interface YouTubeCardProps {
description: string;
onChange: (next: string) => void;
onRegenerate: () => void;
regenerating: boolean;
}
export function YouTubeCard({
description,
onChange,
onRegenerate,
regenerating,
}: YouTubeCardProps) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(300, el.scrollHeight)}px`;
}, [description]);
return (
<ResultCard
index={4}
icon="📺"
title="YouTube Description"
onRegenerate={onRegenerate}
regenerating={regenerating}
headerExtra={description ? <CopyButton value={description} label="Copy" /> : null}
>
<textarea
ref={ref}
value={description}
onChange={(e) => onChange(e.target.value)}
rows={10}
className="textarea text-sm leading-relaxed scrollbar-thin"
style={{ minHeight: 300 }}
/>
</ResultCard>
);
}
+149
View File
@@ -0,0 +1,149 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: dark;
}
html.dark {
color-scheme: dark;
}
html:not(.dark) {
color-scheme: light;
}
html, body, #root {
height: 100%;
}
body {
@apply bg-bg text-fg antialiased font-sans;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html:not(.dark) body {
@apply bg-bgLight text-fg-dark;
}
}
@layer components {
.card {
@apply bg-bg-card border border-border rounded-2xl;
}
html:not(.dark) .card {
@apply bg-bgLight-card border-borderLight;
}
.card-hover {
@apply hover:bg-bg-hover transition-colors duration-150;
}
html:not(.dark) .card-hover:hover {
@apply bg-slate-50;
}
.btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium text-white
bg-accent-primary hover:bg-violet-500 active:bg-violet-700
transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium
bg-bg-hover border border-border text-fg hover:bg-bg
transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
}
html:not(.dark) .btn-secondary {
@apply bg-white border-borderLight text-fg-dark hover:bg-slate-50;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm
text-fg-muted hover:text-fg hover:bg-bg-hover
transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
}
html:not(.dark) .btn-ghost {
@apply text-slate-500 hover:text-fg-dark hover:bg-slate-100;
}
.input {
@apply w-full bg-bg-card border border-border rounded-lg px-3 py-2 text-fg
placeholder-fg-muted focus:outline-none focus:border-accent-primary
focus:ring-1 focus:ring-accent-primary/40 transition-colors duration-150;
}
html:not(.dark) .input {
@apply bg-white border-borderLight text-fg-dark placeholder:text-slate-400;
}
.textarea {
@apply input resize-y;
}
.chip {
@apply inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border
bg-bg-card text-sm font-medium text-fg hover:bg-bg-hover
transition-colors duration-150;
}
html:not(.dark) .chip {
@apply bg-white border-borderLight text-fg-dark hover:bg-slate-50;
}
.gradient-text {
@apply bg-gradient-to-r from-violet-400 via-fuchsia-400 to-cyan-400 bg-clip-text text-transparent;
}
.gradient-bg {
background: linear-gradient(135deg, #7c3aed 0%, #06b6d4 100%);
}
.gradient-bg-soft {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.18) 0%, rgba(6, 182, 212, 0.12) 50%, rgba(8, 8, 16, 0) 100%);
}
.animated-gradient-bg {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.25) 0%, rgba(6, 182, 212, 0.20) 35%, rgba(8, 8, 16, 0) 70%, rgba(124, 58, 237, 0.15) 100%);
background-size: 200% 200%;
animation: gradient-shift 8s ease infinite;
}
.stagger-0 { animation-delay: 0ms; }
.stagger-1 { animation-delay: 80ms; }
.stagger-2 { animation-delay: 160ms; }
.stagger-3 { animation-delay: 240ms; }
.stagger-4 { animation-delay: 320ms; }
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
html:not(.dark) .scrollbar-thin {
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
html:not(.dark) .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.15);
}
}
+319
View File
@@ -0,0 +1,319 @@
// Direct browser → LLM client.
//
// The app runs as a standalone SPA: there is no backend. The user enters their
// provider endpoint, API key, and model name in /settings; those values are
// persisted in localStorage and used to call the provider's OpenAI-compatible
// /chat/completions route directly from the browser.
//
// SECURITY NOTE: because the API key lives in the browser, this app is intended
// for personal/local use. Do NOT deploy it to a public URL with a real key in
// the same browser's storage.
import { buildSystemPrompt, buildUserMessage } from "./prompts";
import type {
ConfigDisplay,
GenerateRequest,
SongAssets,
TestConnectionResult,
} from "./types";
const STORAGE_KEY = "melodymuse-config";
const DEFAULT_MODEL = "MiniMax-M3";
export interface StoredConfig {
api_endpoint: string;
api_key: string;
model_name: string;
}
function defaultConfig(): StoredConfig {
return { api_endpoint: "", api_key: "", model_name: DEFAULT_MODEL };
}
export function loadConfig(): StoredConfig {
if (typeof window === "undefined") return defaultConfig();
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultConfig();
const parsed = JSON.parse(raw) as Partial<StoredConfig>;
return {
api_endpoint:
typeof parsed.api_endpoint === "string" ? parsed.api_endpoint : "",
api_key: typeof parsed.api_key === "string" ? parsed.api_key : "",
model_name:
typeof parsed.model_name === "string" && parsed.model_name.length > 0
? parsed.model_name
: DEFAULT_MODEL,
};
} catch {
return defaultConfig();
}
}
export function saveConfig(next: StoredConfig): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
export function isConfigured(cfg: StoredConfig): boolean {
return Boolean(cfg.api_endpoint && cfg.api_key && cfg.model_name);
}
// ──────────────────────────────────────────────────────────────────────────────
// Public settings API used by the UI
// ──────────────────────────────────────────────────────────────────────────────
export function getConfigDisplay(): ConfigDisplay {
const cfg = loadConfig();
return {
api_endpoint: cfg.api_endpoint,
model_name: cfg.model_name,
api_key_set: Boolean(cfg.api_key),
};
}
/**
* Save the configuration. If `api_key` is the empty string the previously
* stored key is preserved.
*/
export function setConfig(patch: Partial<StoredConfig>): ConfigDisplay {
const current = loadConfig();
const next: StoredConfig = {
api_endpoint:
typeof patch.api_endpoint === "string"
? patch.api_endpoint.trim()
: current.api_endpoint,
api_key:
typeof patch.api_key === "string" ? patch.api_key : current.api_key,
model_name:
typeof patch.model_name === "string" && patch.model_name.trim().length > 0
? patch.model_name.trim()
: current.model_name,
};
// If the caller passed an empty api_key explicitly, keep the existing one.
if (typeof patch.api_key === "string" && patch.api_key.length === 0) {
next.api_key = current.api_key;
}
saveConfig(next);
return getConfigDisplay();
}
// ──────────────────────────────────────────────────────────────────────────────
// JSON extraction + shape validation
// ──────────────────────────────────────────────────────────────────────────────
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function extractJson(text: string): unknown {
const trimmed = text.trim();
// 1. Direct parse.
try {
return JSON.parse(trimmed);
} catch {
/* fall through */
}
// 2. First ```json ... ``` fenced block.
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) {
try {
return JSON.parse(fenced[1].trim());
} catch {
/* fall through */
}
}
// 3. First balanced { ... } region in the text.
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace > firstBrace) {
const slice = trimmed.slice(firstBrace, lastBrace + 1);
try {
return JSON.parse(slice);
} catch {
/* fall through */
}
}
throw new Error("Model response did not contain a parseable JSON object");
}
function validateShape(parsed: unknown): asserts parsed is Partial<SongAssets> {
if (!isObject(parsed)) throw new Error("Model response is not an object");
for (const k of [
"titles",
"lyrics",
"style",
"negative_style",
"youtube_description",
"video_prompts",
] as const) {
if (!(k in parsed)) continue;
const v = parsed[k];
if (k === "titles") {
if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
throw new Error('"titles" must be an array of strings');
}
} else if (k === "video_prompts") {
if (!Array.isArray(v))
throw new Error('"video_prompts" must be an array');
} else if (typeof v !== "string") {
throw new Error(`"${k}" must be a string`);
}
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Provider call
// ──────────────────────────────────────────────────────────────────────────────
interface ProviderMessage {
role: "system" | "user";
content: string;
}
interface ProviderRequest {
model: string;
max_tokens: number;
messages: ProviderMessage[];
[k: string]: unknown;
}
async function callProvider(
body: ProviderRequest,
cfg: StoredConfig,
): Promise<string> {
const base = cfg.api_endpoint.replace(/\/+$/, "");
const url = `${base}/chat/completions`;
let resp: Response;
try {
resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cfg.api_key}`,
},
body: JSON.stringify(body),
});
} catch (err) {
// fetch() throws TypeError for network/CORS failures. Surface a helpful
// message — the user is running the app standalone, so CORS is the most
// likely culprit.
const detail = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not reach ${url}. This is often a CORS restriction on the provider's endpoint — ` +
`check the provider's browser-access policy, or use a CORS proxy / browser extension. ` +
`(Details: ${detail})`,
);
}
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(
`Provider returned ${resp.status} ${resp.statusText}${text ? `: ${text.slice(0, 400)}` : ""}`,
);
}
let data: { choices?: Array<{ message?: { content?: unknown } }> };
try {
data = await resp.json();
} catch {
throw new Error("Provider returned a non-JSON response");
}
const content = data.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content.trim()) {
throw new Error("Provider response did not include a message");
}
return content;
}
// ──────────────────────────────────────────────────────────────────────────────
// High-level operations
// ──────────────────────────────────────────────────────────────────────────────
// `section: "all"` is guaranteed to return every key. Per-section regeneration
// only returns the keys the model chose to update. Model this with overloads so
// the client gets accurate typing.
type AllRequest = Omit<GenerateRequest, "section"> & { section: "all" };
type PartialRequest = Omit<GenerateRequest, "section"> & {
section: Exclude<GenerateRequest["section"], "all">;
};
export async function generateSong(req: AllRequest): Promise<SongAssets>;
export async function generateSong(
req: PartialRequest,
): Promise<Partial<SongAssets>>;
export async function generateSong(
req: GenerateRequest,
): Promise<Partial<SongAssets>>;
export async function generateSong(
req: GenerateRequest,
): Promise<Partial<SongAssets>> {
const cfg = loadConfig();
if (!isConfigured(cfg)) {
throw new Error("API not configured. Please go to Settings.");
}
const content = await callProvider(
{
model: cfg.model_name,
max_tokens: 4000,
messages: [
{ role: "system", content: buildSystemPrompt(req) },
{ role: "user", content: buildUserMessage(req) },
],
},
cfg,
);
let parsed: unknown;
try {
parsed = extractJson(content);
} catch (err) {
throw new Error(
err instanceof Error ? err.message : "Model returned unparseable JSON",
);
}
try {
validateShape(parsed);
} catch (err) {
throw new Error(
err instanceof Error
? err.message
: "Model JSON did not match expected shape",
);
}
return parsed as Partial<SongAssets>;
}
export async function testConnection(): Promise<TestConnectionResult> {
const cfg = loadConfig();
if (!isConfigured(cfg)) {
return {
success: false,
message: "API not configured. Please go to Settings.",
};
}
try {
await callProvider(
{
model: cfg.model_name,
max_tokens: 5,
messages: [{ role: "user", content: "Say hello" }],
},
cfg,
);
return { success: true, message: "Connection successful" };
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Connection test failed",
};
}
}
+103
View File
@@ -0,0 +1,103 @@
// System + user prompt construction for the LLM. Keeping it in its own module
// avoids a 200-line string literal in the llm module entry point.
import type { GenerateRequest } from './types';
export const SYSTEM_PROMPT_ALL = `You are a music production assistant specializing in Suno AI song creation.
The user will describe a music idea. Generate all song assets as a single JSON
object. Output ONLY valid JSON no markdown, no backticks, no preamble.
LANGUAGE RULES (strict):
- "lyrics", "titles", and "youtube_description" use the "language" field value
(default: English if not specified)
- "style", "negative_style", and all "video_prompts" ALWAYS English, regardless
of the language field
REQUIRED JSON STRUCTURE:
{
"titles": [
"Title 1: atmospheric/poetic (max 4 words)",
"Title 2: direct/strong (max 4 words)",
"Title 3: abstract/intriguing (max 4 words)"
],
"lyrics": "Full lyrics with Suno section tags [Verse], [Chorus], [Bridge], [Outro], etc. Use AABB or ABAB rhyme scheme consistently. Lines must be rhythmically singable. CRITICAL: The lyrics must NOT reference the genre, instruments, or music production. No references to pianos, guitars, beats, orchestras, or any musical elements. Write about universal human themes: emotion, nature, love, time, memory, journey, longing.",
"style": "Comma-separated Suno style description, max 120 words. Include: tempo with BPM range, key instruments, production style, texture, energy level, mood, vocal style (or 'instrumental, no vocals'). NEVER name any artist, band, or use 'sounds like'.",
"negative_style": "Comma-separated list of genres, instruments, moods, and styles that should NOT appear.",
"youtube_description": "In the same language as the lyrics. Follow this exact structure:\\n\\n[One evocative hook sentence tied to the mood]\\n\\n[2-3 sentences describing the music, mood, and emotional experience]\\n\\n────────────────────────────\\n\\n[Full lyrics, copied exactly]\\n\\n────────────────────────────\\n\\n[Warm, genuine call to action: ask viewers to like, subscribe, and comment what the music makes them feel or imagine]\\n\\n#hashtag1 #hashtag2 ... (12-15 hashtags: mix of genre, mood, and tool tags like #SunoAI #AIMusic)",
"video_prompts": [
{
"type": "Abstract",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Abstract visuals: particles, fluid colors, geometric shapes, motion graphics. Specify: scene, camera angle, camera movement (slow pan/zoom/static), color palette (2-3 colors only), light source, motion quality. Must loop seamlessly (first frame = last frame). If using reverse playback, only use elements that make physical sense in reverse (e.g. pulsing light, expanding rings — NOT falling rain or rising smoke). Mood must match the song. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason why they suit this prompt"
},
{
"type": "Cinematic",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Real-world environment: landscape, weather, architecture, nature. Specify: scene, camera angle, camera movement, color palette (2-3 colors), light source, motion quality. Loopable (first frame = last frame). If elements move (rain, wind, water, fire), they must move in a physically correct direction — do NOT suggest rain or smoke as reversible loops. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason"
},
{
"type": "Hybrid",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Blend of real and abstract: e.g. real environment with overlaid light effects, particle overlays on landscape, or a semi-abstract architectural scene. Loopable. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason"
}
]
}
Keep each video_prompts[].prompt under 900 characters (excluding the Negative line).
Recommended video tools (choose the best fit per prompt): Runway Gen-3 Alpha, Kling AI,
Luma Dream Machine, Pika 2.0, Haiper. Never recommend Sora.`;
const PARTIAL_SYSTEM_PROMPT = `You are a music production assistant specializing in Suno AI song creation.
The user has an existing song and wants to regenerate ONE section. Output ONLY
valid JSON no markdown, no backticks, no preamble.
LANGUAGE RULES (strict):
- When regenerating "lyrics", "titles", or "youtube_description" use the
"language" field value (default: English if not specified).
- When regenerating "style", "negative_style", or "video_prompts" ALWAYS
English, regardless of the language field.
You will be given:
- the user's original idea, language, mood, and vocals preference
- the current state of the other sections (so you can keep the tone, length,
and vocabulary consistent)
Output the requested section(s) using the same JSON shape as the full prompt
(titles array, lyrics string, style string, negative_style string,
youtube_description string, video_prompts array). Include only the keys that
are needed for the requested section; omit keys that belong to other sections
unless they are required for context.
QUALITY CONSTRAINTS:
- lyrics: Suno section tags [Verse], [Chorus], [Bridge], [Outro], etc. AABB or
ABAB rhyme. No references to instruments, genre, or music production.
- style: comma-separated, max 120 words, include tempo + BPM range, never
name an artist, no "sounds like" phrases.
- video_prompts: 5-second seamless loops, 16:9. Include the "Negative" line
inside each prompt. Keep prompts under 900 characters.
- youtube_description: follow the hook description divider lyrics
divider CTA hashtags structure.`;
export function buildSystemPrompt(req: GenerateRequest): string {
if (req.section === 'all') return SYSTEM_PROMPT_ALL;
return PARTIAL_SYSTEM_PROMPT;
}
export function buildUserMessage(req: GenerateRequest): string {
const lines: string[] = [];
lines.push(`Music idea: ${req.input}`);
lines.push(`Language: ${req.language}`);
if (req.mood) lines.push(`Mood: ${req.mood}`);
lines.push(`Vocals: ${req.vocals}`);
lines.push(`Section to generate: ${req.section}`);
if (req.context && Object.keys(req.context).length > 0) {
lines.push('');
lines.push('Context — the current values of the other sections of this song:');
lines.push(JSON.stringify(req.context, null, 2));
}
return lines.join('\n');
}
+36
View File
@@ -0,0 +1,36 @@
// Theme management (dark / light) — single source of truth backed by localStorage.
const STORAGE_KEY = 'melodymuse-theme';
export type Theme = 'dark' | 'light';
export function getStoredTheme(): Theme {
if (typeof window === 'undefined') return 'dark';
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light') return stored;
return 'dark';
}
export function applyTheme(theme: Theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
export function setStoredTheme(theme: Theme) {
window.localStorage.setItem(STORAGE_KEY, theme);
applyTheme(theme);
}
export function toggleTheme(): Theme {
const next: Theme = getStoredTheme() === 'dark' ? 'light' : 'dark';
setStoredTheme(next);
return next;
}
// Used in main.tsx before React mounts.
export function initTheme() {
applyTheme(getStoredTheme());
}
+94
View File
@@ -0,0 +1,94 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { CheckCircle2, XCircle, Info, X } from 'lucide-react';
type ToastKind = 'success' | 'error' | 'info';
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
interface ToastContextValue {
show: (message: string, kind?: ToastKind) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return ctx;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0);
const dismiss = useCallback((id: number) => {
setToasts((curr) => curr.filter((t) => t.id !== id));
}, []);
const show = useCallback(
(message: string, kind: ToastKind = 'info') => {
const id = ++idRef.current;
setToasts((curr) => [...curr, { id, kind, message }]);
window.setTimeout(() => dismiss(id), 4000);
},
[dismiss],
);
const value = useMemo<ToastContextValue>(
() => ({
show,
success: (m) => show(m, 'success'),
error: (m) => show(m, 'error'),
info: (m) => show(m, 'info'),
}),
[show],
);
return (
<ToastContext.Provider value={value}>
{children}
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm pointer-events-none">
{toasts.map((t) => (
<ToastView key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
))}
</div>
</ToastContext.Provider>
);
}
function ToastView({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const icon =
toast.kind === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
) : toast.kind === 'error' ? (
<XCircle className="w-5 h-5 text-rose-400 shrink-0" />
) : (
<Info className="w-5 h-5 text-cyan-400 shrink-0" />
);
return (
<div
role="status"
className="card pointer-events-auto flex items-start gap-3 px-4 py-3 shadow-xl shadow-black/40 animate-fade-up"
>
{icon}
<p className="text-sm text-fg flex-1 break-words">{toast.message}</p>
<button
type="button"
onClick={onDismiss}
className="text-fg-muted hover:text-fg transition-colors"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
// Domain types shared across the app. These mirror the JSON shape produced by
// the `generate-song` edge function.
export type Language =
| 'English'
| 'Deutsch'
| 'Español'
| 'Français'
| 'Italiano'
| 'Português'
| 'Polski'
| 'Other';
export type Vocals = 'vocals' | 'instrumental';
export type GenerationSection =
| 'all'
| 'lyrics'
| 'style'
| 'titles'
| 'video_prompts'
| 'youtube_description';
export type VideoPromptType = 'Abstract' | 'Cinematic' | 'Hybrid';
export interface VideoPrompt {
type: VideoPromptType;
prompt: string;
tool_recommendation: string;
}
export interface SongAssets {
titles: string[];
lyrics: string;
style: string;
negative_style: string;
youtube_description: string;
video_prompts: VideoPrompt[];
}
export interface GenerateRequest {
input: string;
language: string; // 'English' or free-text from "Other"
mood?: string;
vocals: Vocals;
section: GenerationSection;
context?: Partial<SongAssets>;
}
export interface ConfigDisplay {
api_endpoint: string;
model_name: string;
api_key_set: boolean;
}
export interface SaveConfigPayload {
api_endpoint: string;
api_key: string; // empty string => keep existing
model_name: string;
}
export interface TestConnectionResult {
success: boolean;
message: string;
}
export const NEGATIVE_VIDEO_PROMPT =
'text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur';
+84
View File
@@ -0,0 +1,84 @@
import JSZip from "jszip";
import { NEGATIVE_VIDEO_PROMPT, type SongAssets } from "./types";
const VIDEO_TYPE_HEADERS: Record<string, string> = {
Abstract: "=== VARIATION 1 ABSTRACT ===",
Cinematic: "=== VARIATION 2 CINEMATIC ===",
Hybrid: "=== VARIATION 3 HYBRID ===",
};
// Filename sanitization per the spec:
// "trim whitespace, replace spaces with `_`, remove characters not in [a-zA-Z0-9_-]"
export function sanitizeFilename(name: string): string {
return name
.trim()
.replace(/\s+/g, "_")
.replace(/[^a-zA-Z0-9_-]/g, "");
}
function buildStyleTxt(assets: SongAssets): string {
return `STYLE PROMPT:
${assets.style}
NEGATIVE STYLE:
${assets.negative_style}
`;
}
function buildTextTxt(assets: SongAssets): string {
return `${assets.lyrics}
`;
}
function buildYouTubeDescriptionTxt(assets: SongAssets): string {
return `${assets.youtube_description}
`;
}
function buildVideoPromptTxt(assets: SongAssets): string {
// Order: Abstract, Cinematic, Hybrid — matches the spec template even if the
// model returned them in a different order.
const order: Array<keyof typeof VIDEO_TYPE_HEADERS> = [
"Abstract",
"Cinematic",
"Hybrid",
];
const blocks = order.map((key) => {
const vp = assets.video_prompts.find((p) => p.type === key);
if (!vp) return "";
return [
VIDEO_TYPE_HEADERS[key],
vp.prompt,
"",
`Negative: ${NEGATIVE_VIDEO_PROMPT}`,
"",
`→ Best for: ${vp.tool_recommendation}`,
"",
].join("\n");
});
return blocks.join("\n");
}
export async function buildSongZip(
assets: SongAssets,
_selectedTitle: string,
): Promise<Blob> {
const zip = new JSZip();
zip.file("Style.txt", buildStyleTxt(assets));
zip.file("Text.txt", buildTextTxt(assets));
zip.file("Videodescription.txt", buildYouTubeDescriptionTxt(assets));
zip.file("Videoprompt.txt", buildVideoPromptTxt(assets));
return zip.generateAsync({ type: "blob" });
}
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke a moment later to give the browser time to start the download.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
import { initTheme } from './lib/theme';
// Apply theme as early as possible to avoid a flash.
initTheme();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+242
View File
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { InputPanel, type InputValues } from "../components/InputPanel";
import {
ResultsPanel,
type RegeneratingMap,
type SectionKey,
} from "../components/ResultsPanel";
import { StickyZipBar } from "../components/StickyZipBar";
import { ConfigBanner } from "../components/ConfigBanner";
import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast";
import { generateSong, getConfigDisplay } from "../lib/llm";
import { buildSongZip, downloadBlob } from "../lib/zip";
import type { SongAssets, VideoPrompt } from "../lib/types";
const DEFAULT_INPUT: InputValues = {
idea: "",
language: "English",
customLanguage: "",
mood: "",
vocals: "vocals",
};
function resolveLanguage(v: InputValues): string {
return v.language === "Other"
? v.customLanguage.trim() || "English"
: v.language;
}
export function HomePage() {
const toast = useToast();
const [input, setInput] = useState<InputValues>(DEFAULT_INPUT);
const [assets, setAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false);
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
const [apiKeySet, setApiKeySet] = useState<boolean>(false);
// Check localStorage on mount: if the API key isn't set, show a banner
// pointing the user at Settings.
useEffect(() => {
const cfg = getConfigDisplay();
setApiKeySet(cfg.api_key_set);
}, []);
const selectedTitle = useMemo(() => {
if (!assets || assets.titles.length === 0) return undefined;
return assets.titles[
Math.min(selectedTitleIndex, assets.titles.length - 1)
];
}, [assets, selectedTitleIndex]);
const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return;
setLoading(true);
setAssets(null);
try {
const result = await generateSong({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all",
});
setAssets(result);
setSelectedTitleIndex(0);
toast.success("Song assets generated");
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
);
} finally {
setLoading(false);
}
}, [input, toast]);
const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return;
setLoading(true);
setRegenerating((m) => ({ ...m, all: true }));
try {
const result = await generateSong({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all",
});
setAssets(result);
setSelectedTitleIndex(0);
toast.success("Regenerated all sections");
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
);
} finally {
setLoading(false);
setRegenerating((m) => ({ ...m, all: false }));
}
}, [input, toast]);
const handleRegenerateSection = useCallback(
async (section: SectionKey) => {
if (!input.idea.trim() || !assets) return;
setRegenerating((m) => ({ ...m, [section]: true }));
try {
const result = await generateSong({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section,
// Pass the rest of the song as context so the model can stay consistent.
context: assets,
});
setAssets({ ...assets, ...result });
toast.success(`Regenerated ${humanizeSection(section)}`);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
);
} finally {
setRegenerating((m) => ({ ...m, [section]: false }));
}
},
[input, assets, toast],
);
const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return;
try {
const blob = await buildSongZip(assets, selectedTitle);
const filename = `${selectedTitle.replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_-]/g, "")}.zip`;
downloadBlob(blob, filename);
} catch {
toast.error("Could not generate ZIP — please try again");
}
}, [assets, selectedTitle, toast]);
// Asset editing handlers
const handleAssetChange = useCallback(
<K extends keyof SongAssets>(key: K, value: SongAssets[K]) => {
setAssets((curr) => (curr ? { ...curr, [key]: value } : curr));
},
[],
);
const handleVideoPromptChange = useCallback(
(index: number, value: string) => {
setAssets((curr) => {
if (!curr) return curr;
const next: VideoPrompt[] = curr.video_prompts.map((p, i) =>
i === index ? { ...p, prompt: value } : p,
);
return { ...curr, video_prompts: next };
});
},
[],
);
return (
<div className="min-h-screen pb-32">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
<Link
to="/"
className="text-sm text-fg-muted hover:text-fg transition-colors"
>
MelodyMuse
</Link>
<ThemeToggle />
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-10">
{!apiKeySet && (
<ConfigBanner
message="No API key configured —"
linkTo="/settings"
linkLabel="Go to Settings →"
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-[35%_minmax(0,1fr)] gap-8">
<div className="lg:sticky lg:top-20 lg:self-start space-y-4">
<InputPanel
values={input}
onChange={setInput}
onSubmit={handleGenerate}
loading={loading}
/>
</div>
<div>
<ResultsPanel
assets={assets}
loading={loading}
selectedTitleIndex={selectedTitleIndex}
onSelectTitle={setSelectedTitleIndex}
onChange={handleAssetChange}
onChangeVideoPrompt={handleVideoPromptChange}
onRegenerateAll={handleRegenerateAll}
onRegenerateSection={handleRegenerateSection}
regenerating={regenerating}
/>
</div>
</div>
</main>
{assets && (
<StickyZipBar
title={selectedTitle}
busy={loading}
onDownload={handleDownload}
/>
)}
</div>
);
}
function humanizeSection(s: SectionKey): string {
switch (s) {
case "titles":
return "titles";
case "lyrics":
return "lyrics";
case "style":
return "style";
case "video_prompts":
return "video prompts";
case "youtube_description":
return "description";
}
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowLeft, Eye, EyeOff, Loader2, Save, Plug } from "lucide-react";
import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast";
import { getConfigDisplay, setConfig, testConnection } from "../lib/llm";
const DEFAULTS = {
api_endpoint: "",
api_key: "",
model_name: "MiniMax-M3",
};
export function SettingsPage() {
const toast = useToast();
const navigate = useNavigate();
const [endpoint, setEndpoint] = useState(DEFAULTS.api_endpoint);
const [model, setModel] = useState(DEFAULTS.model_name);
const [apiKey, setApiKey] = useState(""); // never pre-populated from storage
const [apiKeySet, setApiKeySet] = useState(false);
const [showKey, setShowKey] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
// Load current (non-secret) values on mount.
useEffect(() => {
const cfg = getConfigDisplay();
setEndpoint(cfg.api_endpoint);
setModel(cfg.model_name);
setApiKeySet(cfg.api_key_set);
setLoading(false);
}, []);
const onSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const result = setConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
setApiKey("");
setApiKeySet(result.api_key_set);
toast.success("Configuration saved");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to save configuration",
);
} finally {
setSaving(false);
}
};
const onTest = async () => {
setTesting(true);
// Persist current form values first so the test reflects them. setConfig
// preserves the existing key when the field is empty, so this is safe.
try {
const result = setConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
setApiKey("");
setApiKeySet(result.api_key_set);
} catch (err) {
setTesting(false);
toast.error(
err instanceof Error ? err.message : "Failed to save configuration",
);
return;
}
const result = await testConnection();
if (result.success) {
toast.success(`${result.message}`);
} else {
toast.error(`${result.message}`);
}
setTesting(false);
};
return (
<div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-md mx-auto px-4 h-14 flex items-center justify-between">
<button
type="button"
onClick={() => navigate("/")}
className="btn-ghost p-2"
aria-label="Back"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-sm font-semibold text-fg">API Configuration</h1>
<ThemeToggle />
</div>
</header>
<main className="max-w-md mx-auto px-4 mt-16">
<div className="card p-6">
{loading ? (
<div className="space-y-4 animate-pulse">
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
</div>
) : (
<form onSubmit={onSave} className="space-y-4">
<div>
<label
htmlFor="endpoint"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Endpoint URL
</label>
<input
id="endpoint"
type="text"
className="input"
placeholder="https://api.minimax.chat/v1"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
required
/>
</div>
<div>
<label
htmlFor="apiKey"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Key
</label>
<div className="relative">
<input
id="apiKey"
type={showKey ? "text" : "password"}
className="input pr-10 font-mono"
placeholder={
apiKeySet
? "API key saved — enter a new one to replace it"
: "sk-..."
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-fg-muted hover:text-fg transition-colors"
aria-label={showKey ? "Hide API key" : "Show API key"}
tabIndex={-1}
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{apiKeySet && !apiKey && (
<p className="mt-1 text-xs text-emerald-400">
A key is currently saved.
</p>
)}
</div>
<div>
<label
htmlFor="model"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Model Name
</label>
<input
id="model"
type="text"
className="input"
placeholder="MiniMax-M3"
value={model}
onChange={(e) => setModel(e.target.value)}
required
/>
</div>
<button
type="submit"
disabled={saving || testing}
className="btn-primary w-full py-3"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save Configuration
</button>
<button
type="button"
onClick={onTest}
disabled={saving || testing}
className="btn-secondary w-full py-3"
>
{testing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
Test Connection
</button>
</form>
)}
<p className="mt-6 text-center text-xs text-fg-muted">
Your settings are stored in this browser's local storage. Do not use
this app on a device that other people have access to.
</p>
<div className="mt-4 text-center">
<Link
to="/"
className="text-xs text-fg-muted hover:text-fg transition-colors"
>
Back to generator
</Link>
</div>
</div>
</main>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+58
View File
@@ -0,0 +1,58 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// Dark mode (default) palette
bg: {
DEFAULT: '#080810',
card: '#111118',
hover: '#16161f',
},
// Light mode palette (applied via :root when not .dark)
bgLight: {
DEFAULT: '#f8fafc',
card: '#ffffff',
},
border: {
DEFAULT: 'rgba(255, 255, 255, 0.06)',
},
borderLight: {
DEFAULT: 'rgba(0, 0, 0, 0.08)',
},
accent: {
primary: '#7c3aed',
secondary: '#06b6d4',
secondaryLight: '#0891b2',
},
fg: {
DEFAULT: '#e2e8f0',
muted: '#64748b',
dark: '#0f172a',
},
tag: '#a78bfa',
},
animation: {
'gradient-shift': 'gradient-shift 8s ease infinite',
'fade-up': 'fade-up 300ms ease-out forwards',
'pulse-slow': 'pulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
'gradient-shift': {
'0%, 100%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
},
'fade-up': {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
fontFamily: {
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
},
},
},
plugins: [],
};
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});