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:
+35
@@ -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?
|
||||
@@ -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 (
|
||||
|
||||
  id INT PRIMARY KEY DEFAULT 1,
|
||||
|
||||
  api\_endpoint TEXT NOT NULL DEFAULT '',
|
||||
|
||||
  api\_key TEXT NOT NULL DEFAULT '',
|
||||
|
||||
  model\_name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
  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:
|
||||
|
||||
  - \*\*API Endpoint URL\*\* — e.g. `https://api.minimax.chat/v1`
|
||||
|
||||
  - \*\*API Key\*\* — your provider's secret key
|
||||
|
||||
  - \*\*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
|
||||
|
||||
  > \_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
|
||||
|
||||
  \_(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
|
||||
|
||||
  \_(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
|
||||
|
||||
  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,
|
||||
|
||||
  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
@@ -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>
|
||||
Generated
+2864
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user