Files
srbhr--resume-matcher/apps/frontend/components/settings/api-key-menu.tsx
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

153 lines
5.2 KiB
TypeScript

'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { fetchLlmApiKey, updateLlmApiKey } from '@/lib/api/config';
import { ChevronDown } from 'lucide-react';
import { useTranslations } from '@/lib/i18n';
type Status = 'idle' | 'loading' | 'saving' | 'saved' | 'error';
const MASK_THRESHOLD = 6;
export default function ApiKeyMenu(): React.ReactElement {
const { t } = useTranslations();
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<Status>('loading');
const [apiKey, setApiKey] = useState('');
const [draft, setDraft] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const value = await fetchLlmApiKey();
if (cancelled) return;
setApiKey(value);
setDraft(value);
setStatus('idle');
} catch (err) {
console.error('Failed to load LLM API key', err);
if (!cancelled) {
setError(t('settings.apiKeyMenu.loadError'));
setStatus('error');
}
}
}
load();
return () => {
cancelled = true;
};
}, [t]);
const maskedKey = useMemo(() => {
if (!apiKey) return t('settings.statusValues.notSet');
if (apiKey.length <= MASK_THRESHOLD) return apiKey;
return `${apiKey.slice(0, MASK_THRESHOLD)}••••`;
}, [apiKey, t]);
const handleToggle = () => {
setIsOpen((prev) => {
const next = !prev;
if (!prev) {
setDraft(apiKey);
setError(null);
}
return next;
});
};
const handleSave = async () => {
setStatus('saving');
setError(null);
try {
const trimmed = draft.trim();
const saved = await updateLlmApiKey(trimmed);
setApiKey(saved);
setDraft(saved);
setStatus('saved');
setTimeout(() => setStatus('idle'), 1800);
} catch (err) {
console.error('Failed to update LLM API key', err);
setError((err as Error).message || t('settings.apiKeyMenu.updateError'));
setStatus('error');
}
};
const handleClose = () => {
setIsOpen(false);
setDraft(apiKey);
setError(null);
};
return (
<div className="relative text-sm">
<button
type="button"
onClick={handleToggle}
className="inline-flex items-center gap-2 rounded-none border-2 border-black bg-white px-3 py-2 text-black shadow-sw-sm transition-all hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-none"
>
<span className="font-semibold">{t('settings.apiKeyMenu.buttonLabel')}</span>
<span className="font-mono text-xs text-ink-soft">{maskedKey}</span>
<ChevronDown className="h-4 w-4" />
</button>
{isOpen ? (
<>
<div
className="fixed inset-0 z-40 bg-black/20"
onClick={handleClose}
aria-hidden="true"
/>
<div className="absolute right-0 z-50 mt-2 w-80 rounded-none border-2 border-black bg-white p-4 shadow-sw-default">
<h3 className="font-serif text-base font-semibold text-black mb-2">
{t('settings.apiKeyMenu.title')}
</h3>
<p className="text-xs text-ink-soft mb-3">{t('settings.apiKeyMenu.description')}</p>
<label
htmlFor="llmKey"
className="font-mono text-xs font-medium uppercase tracking-wider text-ink-soft"
>
{t('settings.apiKey')}
</label>
<input
id="llmKey"
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={t('settings.llmConfiguration.apiKeyPlaceholder')}
className="mt-1 w-full rounded-none border-2 border-black bg-background px-3 py-2 text-sm text-black focus:border-blue-700 focus:outline-none focus:ring-1 focus:ring-blue-700"
/>
{error ? <p className="mt-2 text-xs text-red-600">{error}</p> : null}
<div className="mt-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-none border-2 border-black px-3 py-2 text-xs font-semibold text-black hover:bg-background"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={status === 'saving'}
className={`rounded-none border-2 border-black px-4 py-2 text-xs font-semibold transition-all ${
status === 'saving'
? 'bg-steel-grey text-ink-soft cursor-wait'
: 'bg-blue-700 text-white shadow-sw-sm hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-none'
}`}
>
{status === 'saving' ? t('common.saving') : t('common.save')}
</button>
</div>
{status === 'saved' ? (
<p className="mt-2 text-xs text-green-700 font-medium">
{t('settings.apiKeyMenu.savedMessage')}
</p>
) : null}
</div>
</>
) : null}
</div>
);
}