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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user