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:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user