diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js index e0f24f36..60245e31 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js @@ -1,38 +1,76 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { readKeyPresets, upsertKeyPreset, deleteKeyPreset, subscribeKeyPresets } from "./cliEndpointPresets"; const CUSTOM_VALUE = "__custom__"; +const SAVE_VALUE = "__save_key__"; export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) { - const isCustom = !apiKeys.some((k) => k.key === value) && value !== ""; - const [mode, setMode] = useState(() => { - if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE; - if (apiKeys.some((k) => k.key === value)) return value; - return CUSTOM_VALUE; - }); - const [customInput, setCustomInput] = useState(isCustom ? value : ""); + const [savedKeys, setSavedKeys] = useState([]); + // Custom mode is sticky once the user types, so an emptied input doesn't jump back to a dropdown option + const [customMode, setCustomMode] = useState(false); + const [customInput, setCustomInput] = useState(""); + + useEffect(() => { + const sync = () => setSavedKeys(readKeyPresets()); + sync(); + return subscribeKeyPresets(sync); + }, []); + + const options = useMemo( + () => [ + ...apiKeys.map((k) => ({ value: k.key, label: k.key })), + ...savedKeys.map((p) => ({ value: `saved:${p.name}`, label: p.key, url: p.key, saved: true })), + { value: CUSTOM_VALUE, label: "Custom...", url: "" }, + ], + [apiKeys, savedKeys] + ); + + // Derive the active option from value — no sync effects needed when the parent updates it + const matched = value ? options.find((o) => o.value === value || o.url === value) : null; + const mode = matched ? matched.value : (customMode || value ? CUSTOM_VALUE : (options[0]?.value ?? CUSTOM_VALUE)); + const inputValue = customMode ? customInput : (value || ""); + const isSaved = typeof mode === "string" && mode.startsWith("saved:"); + const isCustom = mode === CUSTOM_VALUE; + const canSave = isCustom && (value || "").trim().length > 0 && !apiKeys.some((k) => k.key === value); + const noKeys = apiKeys.length === 0 && savedKeys.length === 0 && !customMode && !value; const handleSelect = (e) => { const next = e.target.value; - setMode(next); + if (next === SAVE_VALUE) { + upsertKeyPreset((value || "").trim()); + return; + } if (next === CUSTOM_VALUE) { + setCustomMode(true); setCustomInput(""); onChange(""); - } else { - onChange(next); + return; } + setCustomMode(false); + setCustomInput(""); + const opt = options.find((o) => o.value === next); + if (opt) onChange(opt.url ?? opt.value); }; const handleCustomInput = (e) => { const v = e.target.value; + setCustomMode(true); setCustomInput(v); onChange(v); }; - const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE; + const handleDeleteSaved = () => { + if (!isSaved) return; + deleteKeyPreset(mode.slice(6)); + setCustomMode(false); + setCustomInput(""); + const fallback = options.find((o) => o.value !== CUSTOM_VALUE && o.value !== mode); + onChange(fallback ? (fallback.url ?? fallback.value) : ""); + }; - if (noKeys && mode !== CUSTOM_VALUE) { + if (noKeys) { return ( {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} @@ -42,20 +80,27 @@ export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabl return (
- - {mode === CUSTOM_VALUE && ( +
+ + {isSaved && ( + + )} +
+ {isCustom && ( k.key === tokenFromFile)) { + if (tokenFromFile) { setSelectedApiKey(tokenFromFile); } } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js index 53cd0d43..beb78855 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js @@ -1,55 +1,79 @@ import { UPDATER_CONFIG } from "@/shared/constants/config"; -// Browser-local endpoint presets shared by every CLI tool card -const STORAGE_KEY = "9router.cliToolEndpointPresets"; -const CHANGE_EVENT = "9router:endpoint-presets-changed"; +// Browser-local preset stores (endpoints, API keys) shared by every CLI tool card +function createStore({ storageKey, changeEvent, itemField, normalize = (v) => v, defaultName = (v) => v }) { + const read = () => { + if (typeof window === "undefined") return []; + try { + const raw = JSON.parse(window.localStorage.getItem(storageKey) || "[]"); + if (!Array.isArray(raw)) return []; + return raw.filter((p) => p?.name && p?.[itemField]); + } catch { + return []; + } + }; + + const write = (items) => { + if (typeof window === "undefined") return; + window.localStorage.setItem(storageKey, JSON.stringify(items)); + window.dispatchEvent(new CustomEvent(changeEvent)); + }; + + return { + read, + subscribe: (handler) => { + if (typeof window === "undefined") return () => {}; + window.addEventListener(changeEvent, handler); + return () => window.removeEventListener(changeEvent, handler); + }, + // Adds or replaces a preset; returns the stored name, or null when skipped + upsert: (value, name) => { + const v = normalize(value); + if (!v) return null; + + const items = read(); + const existing = items.find((p) => normalize(p[itemField]) === v); + if (existing && !name) return existing.name; + + const finalName = (name || defaultName(v)).trim(); + if (!finalName) return null; + + const next = [...items.filter((p) => p.name !== finalName && normalize(p[itemField]) !== v), { name: finalName, [itemField]: v }] + .sort((a, b) => a.name.localeCompare(b.name)); + write(next); + return finalName; + }, + remove: (name) => write(read().filter((p) => p.name !== name)), + }; +} const stripSlash = (url) => (url || "").replace(/\/+$/, ""); -export function readPresets() { - if (typeof window === "undefined") return []; - try { - const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"); - if (!Array.isArray(raw)) return []; - return raw.filter((p) => p?.name && p?.baseUrl); - } catch { - return []; - } -} +const endpoints = createStore({ + storageKey: "9router.cliToolEndpointPresets", + changeEvent: "9router:endpoint-presets-changed", + itemField: "baseUrl", + normalize: stripSlash, + defaultName: (url) => { + try { return new URL(url).host; } catch { return url; } + }, +}); -function writePresets(presets) { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); - window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); -} +const apiKeys = createStore({ + storageKey: "9router.cliToolApiKeyPresets", + changeEvent: "9router:api-key-presets-changed", + itemField: "key", +}); -export function subscribePresets(handler) { - if (typeof window === "undefined") return () => {}; - window.addEventListener(CHANGE_EVENT, handler); - return () => window.removeEventListener(CHANGE_EVENT, handler); -} +export const readPresets = endpoints.read; +export const subscribePresets = endpoints.subscribe; +export const upsertPreset = endpoints.upsert; +export const deletePreset = endpoints.remove; -function defaultNameFor(url) { - try { return new URL(url).host; } catch { return url; } -} - -// Adds or replaces a preset; returns the stored name, or null when skipped -export function upsertPreset(baseUrl, name) { - const url = stripSlash(baseUrl); - if (!url) return null; - - const presets = readPresets(); - const existing = presets.find((p) => stripSlash(p.baseUrl) === url); - if (existing && !name) return existing.name; - - const finalName = (name || defaultNameFor(url)).trim(); - if (!finalName) return null; - - const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }] - .sort((a, b) => a.name.localeCompare(b.name)); - writePresets(next); - return finalName; -} +export const readKeyPresets = apiKeys.read; +export const subscribeKeyPresets = apiKeys.subscribe; +export const upsertKeyPreset = apiKeys.upsert; +export const deleteKeyPreset = apiKeys.remove; // Save an applied endpoint unless it exactly matches a built-in dropdown option export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) { @@ -64,8 +88,4 @@ export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloud return upsertPreset(url); } -export function deletePreset(name) { - writePresets(readPresets().filter((p) => p.name !== name)); -} - export { stripSlash };