UX - Add elapsed-time counter and Cancel button to in-flight generations, wired through AbortController so partial responses are discarded cleanly. The cancel action is available from both the input panel and the results header. - Add a Recent generations panel below the input. The last 6 successful generations are saved to localStorage; one click reloads both the input fields and the generated assets. - Add a Revert button to every editable card. It appears the moment the current value diverges from the last generated value and restores the field with a single click. - Add an Empty state to the right panel with a friendly hint pointing at the Generate button and the Settings page. - Add a 'Try an example' button that fills the input with a random starter idea (idea + mood + vocals). - Add Cmd/Ctrl+Enter as a keyboard shortcut to generate. - Add a Footer with project info and a privacy reminder. - Add a 'Clear saved key' button to the Settings page so the user can remove the API key without overwriting it. Bug fix - Settings > Test Connection used to save the in-progress form values to localStorage before testing. It now uses the in-memory candidate config, so failed tests don't pollute the saved config. Code quality - Extract the duplicated useAutoHeight hook to src/lib/useAutoHeight.ts. - Extract a useElapsed hook for the loading timer. - Move InputValues into src/lib/types.ts (was duplicated in InputPanel.tsx) and add SECTION_LABELS, replacing the humanizeSection switch in HomePage. - Centralize the filename sanitization: HomePage now calls sanitizeFilename from zip.ts instead of duplicating the regex. - Add previewConfig / testConnectionWithConfig helpers to llm.ts to support in-memory connection tests.
282 lines
8.8 KiB
TypeScript
282 lines
8.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import {
|
|
ArrowLeft,
|
|
Eye,
|
|
EyeOff,
|
|
Loader2,
|
|
Save,
|
|
Plug,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { ThemeToggle } from "../components/ThemeToggle";
|
|
import { useToast } from "../lib/toast";
|
|
import {
|
|
getConfigDisplay,
|
|
setConfig,
|
|
previewConfig,
|
|
testConnectionWithConfig,
|
|
saveConfig,
|
|
loadConfig,
|
|
DEFAULT_MODEL,
|
|
} from "../lib/llm";
|
|
|
|
const DEFAULTS = {
|
|
api_endpoint: "",
|
|
api_key: "",
|
|
model_name: DEFAULT_MODEL,
|
|
};
|
|
|
|
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 = (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);
|
|
}
|
|
};
|
|
|
|
// Test using *in-memory* form values — don't persist until the user clicks
|
|
// Save. An empty API key field still falls back to the previously saved key,
|
|
// matching the behaviour of Save.
|
|
const onTest = async () => {
|
|
setTesting(true);
|
|
const candidate = previewConfig({
|
|
api_endpoint: endpoint.trim(),
|
|
api_key: apiKey,
|
|
model_name: model.trim(),
|
|
});
|
|
|
|
if (
|
|
!candidate.api_endpoint ||
|
|
!candidate.api_key ||
|
|
!candidate.model_name
|
|
) {
|
|
setTesting(false);
|
|
toast.error("❌ Fill in endpoint, key, and model before testing");
|
|
return;
|
|
}
|
|
|
|
const result = await testConnectionWithConfig(candidate);
|
|
if (result.success) {
|
|
toast.success(`✅ ${result.message}`);
|
|
} else {
|
|
toast.error(`❌ ${result.message}`);
|
|
}
|
|
setTesting(false);
|
|
};
|
|
|
|
const onClearKey = () => {
|
|
if (!apiKeySet) return;
|
|
if (!window.confirm("Clear the saved API key from this browser?")) return;
|
|
const current = loadConfig();
|
|
saveConfig({ ...current, api_key: "" });
|
|
setApiKeySet(false);
|
|
setApiKey("");
|
|
toast.info("Saved API key cleared");
|
|
};
|
|
|
|
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>
|
|
)}
|
|
{apiKeySet && (
|
|
<button
|
|
type="button"
|
|
onClick={onClearKey}
|
|
className="mt-2 inline-flex items-center gap-1 text-xs text-fg-muted hover:text-rose-300 transition-colors"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
Clear saved key
|
|
</button>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|