Build standalone MelodyMuse SPA

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

- src/lib/llm.ts: config persistence, direct fetch, JSON extraction,
  shape validation, connection test
- src/lib/prompts.ts: full + partial-regeneration system prompts and
  user-message builder
- src/lib/{api,supabase}.ts removed
- supabase/ directory removed
- @supabase/supabase-js dropped from package.json
- README updated to describe the standalone architecture and CORS caveats
- .gitignore: drop Supabase entries, exclude *.tsbuildinfo
This commit is contained in:
2026-06-03 01:25:22 +02:00
parent f2401d0c57
commit d42410560f
37 changed files with 5443 additions and 376 deletions
+242
View File
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { InputPanel, type InputValues } from "../components/InputPanel";
import {
ResultsPanel,
type RegeneratingMap,
type SectionKey,
} from "../components/ResultsPanel";
import { StickyZipBar } from "../components/StickyZipBar";
import { ConfigBanner } from "../components/ConfigBanner";
import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast";
import { generateSong, getConfigDisplay } from "../lib/llm";
import { buildSongZip, downloadBlob } from "../lib/zip";
import type { SongAssets, VideoPrompt } from "../lib/types";
const DEFAULT_INPUT: InputValues = {
idea: "",
language: "English",
customLanguage: "",
mood: "",
vocals: "vocals",
};
function resolveLanguage(v: InputValues): string {
return v.language === "Other"
? v.customLanguage.trim() || "English"
: v.language;
}
export function HomePage() {
const toast = useToast();
const [input, setInput] = useState<InputValues>(DEFAULT_INPUT);
const [assets, setAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false);
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
const [apiKeySet, setApiKeySet] = useState<boolean>(false);
// Check localStorage on mount: if the API key isn't set, show a banner
// pointing the user at Settings.
useEffect(() => {
const cfg = getConfigDisplay();
setApiKeySet(cfg.api_key_set);
}, []);
const selectedTitle = useMemo(() => {
if (!assets || assets.titles.length === 0) return undefined;
return assets.titles[
Math.min(selectedTitleIndex, assets.titles.length - 1)
];
}, [assets, selectedTitleIndex]);
const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return;
setLoading(true);
setAssets(null);
try {
const result = await generateSong({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all",
});
setAssets(result);
setSelectedTitleIndex(0);
toast.success("Song assets generated");
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
);
} finally {
setLoading(false);
}
}, [input, toast]);
const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return;
setLoading(true);
setRegenerating((m) => ({ ...m, all: true }));
try {
const result = await generateSong({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all",
});
setAssets(result);
setSelectedTitleIndex(0);
toast.success("Regenerated all sections");
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
);
} finally {
setLoading(false);
setRegenerating((m) => ({ ...m, all: false }));
}
}, [input, toast]);
const handleRegenerateSection = useCallback(
async (section: SectionKey) => {
if (!input.idea.trim() || !assets) return;
setRegenerating((m) => ({ ...m, [section]: true }));
try {
const result = await generateSong({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section,
// Pass the rest of the song as context so the model can stay consistent.
context: assets,
});
setAssets({ ...assets, ...result });
toast.success(`Regenerated ${humanizeSection(section)}`);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Generation failed — please try again",
);
} finally {
setRegenerating((m) => ({ ...m, [section]: false }));
}
},
[input, assets, toast],
);
const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return;
try {
const blob = await buildSongZip(assets, selectedTitle);
const filename = `${selectedTitle.replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_-]/g, "")}.zip`;
downloadBlob(blob, filename);
} catch {
toast.error("Could not generate ZIP — please try again");
}
}, [assets, selectedTitle, toast]);
// Asset editing handlers
const handleAssetChange = useCallback(
<K extends keyof SongAssets>(key: K, value: SongAssets[K]) => {
setAssets((curr) => (curr ? { ...curr, [key]: value } : curr));
},
[],
);
const handleVideoPromptChange = useCallback(
(index: number, value: string) => {
setAssets((curr) => {
if (!curr) return curr;
const next: VideoPrompt[] = curr.video_prompts.map((p, i) =>
i === index ? { ...p, prompt: value } : p,
);
return { ...curr, video_prompts: next };
});
},
[],
);
return (
<div className="min-h-screen pb-32">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
<Link
to="/"
className="text-sm text-fg-muted hover:text-fg transition-colors"
>
MelodyMuse
</Link>
<ThemeToggle />
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-10">
{!apiKeySet && (
<ConfigBanner
message="No API key configured —"
linkTo="/settings"
linkLabel="Go to Settings →"
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-[35%_minmax(0,1fr)] gap-8">
<div className="lg:sticky lg:top-20 lg:self-start space-y-4">
<InputPanel
values={input}
onChange={setInput}
onSubmit={handleGenerate}
loading={loading}
/>
</div>
<div>
<ResultsPanel
assets={assets}
loading={loading}
selectedTitleIndex={selectedTitleIndex}
onSelectTitle={setSelectedTitleIndex}
onChange={handleAssetChange}
onChangeVideoPrompt={handleVideoPromptChange}
onRegenerateAll={handleRegenerateAll}
onRegenerateSection={handleRegenerateSection}
regenerating={regenerating}
/>
</div>
</div>
</main>
{assets && (
<StickyZipBar
title={selectedTitle}
busy={loading}
onDownload={handleDownload}
/>
)}
</div>
);
}
function humanizeSection(s: SectionKey): string {
switch (s) {
case "titles":
return "titles";
case "lyrics":
return "lyrics";
case "style":
return "style";
case "video_prompts":
return "video prompts";
case "youtube_description":
return "description";
}
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowLeft, Eye, EyeOff, Loader2, Save, Plug } from "lucide-react";
import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast";
import { getConfigDisplay, setConfig, testConnection } from "../lib/llm";
const DEFAULTS = {
api_endpoint: "",
api_key: "",
model_name: "MiniMax-M3",
};
export function SettingsPage() {
const toast = useToast();
const navigate = useNavigate();
const [endpoint, setEndpoint] = useState(DEFAULTS.api_endpoint);
const [model, setModel] = useState(DEFAULTS.model_name);
const [apiKey, setApiKey] = useState(""); // never pre-populated from storage
const [apiKeySet, setApiKeySet] = useState(false);
const [showKey, setShowKey] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
// Load current (non-secret) values on mount.
useEffect(() => {
const cfg = getConfigDisplay();
setEndpoint(cfg.api_endpoint);
setModel(cfg.model_name);
setApiKeySet(cfg.api_key_set);
setLoading(false);
}, []);
const onSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const result = setConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
setApiKey("");
setApiKeySet(result.api_key_set);
toast.success("Configuration saved");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to save configuration",
);
} finally {
setSaving(false);
}
};
const onTest = async () => {
setTesting(true);
// Persist current form values first so the test reflects them. setConfig
// preserves the existing key when the field is empty, so this is safe.
try {
const result = setConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
setApiKey("");
setApiKeySet(result.api_key_set);
} catch (err) {
setTesting(false);
toast.error(
err instanceof Error ? err.message : "Failed to save configuration",
);
return;
}
const result = await testConnection();
if (result.success) {
toast.success(`${result.message}`);
} else {
toast.error(`${result.message}`);
}
setTesting(false);
};
return (
<div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-md mx-auto px-4 h-14 flex items-center justify-between">
<button
type="button"
onClick={() => navigate("/")}
className="btn-ghost p-2"
aria-label="Back"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-sm font-semibold text-fg">API Configuration</h1>
<ThemeToggle />
</div>
</header>
<main className="max-w-md mx-auto px-4 mt-16">
<div className="card p-6">
{loading ? (
<div className="space-y-4 animate-pulse">
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
</div>
) : (
<form onSubmit={onSave} className="space-y-4">
<div>
<label
htmlFor="endpoint"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Endpoint URL
</label>
<input
id="endpoint"
type="text"
className="input"
placeholder="https://api.minimax.chat/v1"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
required
/>
</div>
<div>
<label
htmlFor="apiKey"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Key
</label>
<div className="relative">
<input
id="apiKey"
type={showKey ? "text" : "password"}
className="input pr-10 font-mono"
placeholder={
apiKeySet
? "API key saved — enter a new one to replace it"
: "sk-..."
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-fg-muted hover:text-fg transition-colors"
aria-label={showKey ? "Hide API key" : "Show API key"}
tabIndex={-1}
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{apiKeySet && !apiKey && (
<p className="mt-1 text-xs text-emerald-400">
A key is currently saved.
</p>
)}
</div>
<div>
<label
htmlFor="model"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Model Name
</label>
<input
id="model"
type="text"
className="input"
placeholder="MiniMax-M3"
value={model}
onChange={(e) => setModel(e.target.value)}
required
/>
</div>
<button
type="submit"
disabled={saving || testing}
className="btn-primary w-full py-3"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save Configuration
</button>
<button
type="button"
onClick={onTest}
disabled={saving || testing}
className="btn-secondary w-full py-3"
>
{testing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
Test Connection
</button>
</form>
)}
<p className="mt-6 text-center text-xs text-fg-muted">
Your settings are stored in this browser's local storage. Do not use
this app on a device that other people have access to.
</p>
<div className="mt-4 text-center">
<Link
to="/"
className="text-xs text-fg-muted hover:text-fg transition-colors"
>
Back to generator
</Link>
</div>
</div>
</main>
</div>
);
}