mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Add reusable EndpointPresetControl for CLI tool Base URL/API key presets, stored in browser localStorage. Wire into Claude, Codex, OpenCode, Droid, OpenClaw, Hermes, and Copilot cards. Allow selecting preset API keys not in dashboard keys list. Thanks @dmdfami for the contribution! Co-authored-by: dmdfami <dmdfami@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
129 lines
4.0 KiB
JavaScript
129 lines
4.0 KiB
JavaScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
const STORAGE_KEY = "9router.cliToolEndpointPresets";
|
|
|
|
function maskApiKey(apiKey) {
|
|
if (!apiKey) return "No API key";
|
|
if (apiKey.length <= 12) return `${apiKey.slice(0, 4)}...`;
|
|
return `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`;
|
|
}
|
|
|
|
function normalizePresets(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter((preset) => preset?.name && preset?.baseUrl && preset?.apiKey);
|
|
}
|
|
|
|
function readPresets() {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
return normalizePresets(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writePresets(presets) {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizePresets(presets)));
|
|
}
|
|
|
|
export default function EndpointPresetControl({
|
|
baseUrl,
|
|
apiKey,
|
|
onBaseUrlChange,
|
|
onApiKeyChange,
|
|
}) {
|
|
const [presets, setPresets] = useState([]);
|
|
const [selectedName, setSelectedName] = useState("");
|
|
|
|
useEffect(() => {
|
|
setPresets(readPresets());
|
|
}, []);
|
|
|
|
const selectedPreset = useMemo(
|
|
() => presets.find((preset) => preset.name === selectedName) || null,
|
|
[presets, selectedName]
|
|
);
|
|
|
|
const handleSelect = (name) => {
|
|
setSelectedName(name);
|
|
const preset = presets.find((item) => item.name === name);
|
|
if (!preset) return;
|
|
onBaseUrlChange(preset.baseUrl);
|
|
onApiKeyChange(preset.apiKey);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const trimmedBaseUrl = (baseUrl || "").trim();
|
|
const trimmedApiKey = (apiKey || "").trim();
|
|
if (!trimmedBaseUrl || !trimmedApiKey) return;
|
|
|
|
let defaultName = selectedPreset?.name || trimmedBaseUrl;
|
|
try {
|
|
defaultName = selectedPreset?.name || new URL(trimmedBaseUrl).host;
|
|
} catch {
|
|
defaultName = selectedPreset?.name || trimmedBaseUrl;
|
|
}
|
|
const name = window.prompt("Preset name", defaultName);
|
|
if (!name?.trim()) return;
|
|
|
|
const nextPreset = { name: name.trim(), baseUrl: trimmedBaseUrl, apiKey: trimmedApiKey };
|
|
const nextPresets = [
|
|
...presets.filter((preset) => preset.name !== nextPreset.name),
|
|
nextPreset,
|
|
].sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
setPresets(nextPresets);
|
|
setSelectedName(nextPreset.name);
|
|
writePresets(nextPresets);
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (!selectedPreset) return;
|
|
const nextPresets = presets.filter((preset) => preset.name !== selectedPreset.name);
|
|
setPresets(nextPresets);
|
|
setSelectedName("");
|
|
writePresets(nextPresets);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Preset</span>
|
|
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
|
<select
|
|
value={selectedName}
|
|
onChange={(event) => handleSelect(event.target.value)}
|
|
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
>
|
|
<option value="">Manual / current endpoint</option>
|
|
{presets.map((preset) => (
|
|
<option key={preset.name} value={preset.name}>
|
|
{preset.name} - {preset.baseUrl} ({maskApiKey(preset.apiKey)})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={!baseUrl || !apiKey}
|
|
className="px-2 py-1.5 rounded border text-xs bg-surface border-border text-text-main hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
|
title="Save current Base URL and API key as a browser-local preset"
|
|
>
|
|
Save
|
|
</button>
|
|
{selectedPreset && (
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
|
|
title="Delete selected preset"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">delete</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|