import { useEffect, useState } from 'react';
import {
type ClaudeAccountSummary,
type CodexAccountSummary,
type FastModeSnapshot,
type ModelConfigSnapshot,
type ModelRoleConfig,
addClaudeAccount,
deleteAccount,
fetchAccounts,
fetchFastMode,
fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi,
setCurrentCodexAccount as setCurrentCodexAccountApi,
updateFastMode,
updateModels,
} from './api';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
type AccountProvider = 'claude' | 'codex';
interface AccountData {
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
codexCurrentIndex?: number;
}
export interface SettingsPanelProps {
locale: Locale;
nickname: string;
onLocaleChange: (locale: Locale) => void;
onNicknameChange: (next: string) => void;
onRestartStack: () => void;
t: Messages;
}
export function SettingsPanel({
locale,
nickname,
onLocaleChange,
onNicknameChange,
onRestartStack,
t,
}: SettingsPanelProps) {
return (
);
}
function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
const [config, setConfig] = useState(null);
const [draft, setDraft] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [savedAt, setSavedAt] = useState(null);
useEffect(() => {
let cancelled = false;
setBusy(true);
fetchModelConfig()
.then((c) => {
if (cancelled) return;
setConfig(c);
setDraft(c);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, []);
async function save() {
if (!draft || !config) return;
setBusy(true);
setError(null);
try {
const next = await updateModels(draft);
setConfig(next);
setDraft(next);
setSavedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
function setRole(
role: keyof ModelConfigSnapshot,
patch: Partial,
) {
setDraft((prev) =>
prev
? {
...prev,
[role]: { ...prev[role], ...patch },
}
: prev,
);
}
const dirty =
draft !== null &&
config !== null &&
JSON.stringify(draft) !== JSON.stringify(config);
return (
);
}
function FastModeSettings() {
const [state, setState] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetchFastMode()
.then((s) => {
if (cancelled) return;
setState(s);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggle(provider: keyof FastModeSnapshot) {
if (!state) return;
const next = !state[provider];
const optimistic = { ...state, [provider]: next };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateFastMode({ [provider]: next });
setState(fresh);
} catch (err) {
setState(state);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
);
}
function formatExpiry(
iso: string | null,
): { label: string; cls: string } | null {
if (!iso) return null;
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return null;
const days = (dt.getTime() - Date.now()) / 86400000;
const dateStr = dt.toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit',
});
if (days < 0) {
const ago = Math.ceil(-days);
return {
label: `결제 만료 ${dateStr} (${ago}일 전)`,
cls: 'is-expired',
};
}
if (days < 7) {
return {
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
cls: 'is-soon',
};
}
return {
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
cls: 'is-active',
};
}
function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
const [data, setData] = useState(null);
const [busy, setBusy] = useState(false);
const [perRowBusy, setPerRowBusy] = useState(null);
const [error, setError] = useState(null);
const [tokenInput, setTokenInput] = useState('');
async function refresh() {
setBusy(true);
setError(null);
try {
const fresh = await fetchAccounts();
setData(fresh);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function handleDelete(provider: AccountProvider, index: number) {
if (
!window.confirm(
`${provider} 계정 #${index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?`,
)
) {
return;
}
setBusy(true);
setError(null);
try {
await deleteAccount(provider, index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleAdd() {
const token = tokenInput.trim();
if (!token) return;
setBusy(true);
setError(null);
try {
await addClaudeAccount(token);
setTokenInput('');
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleCodexRefresh(index: number) {
setPerRowBusy(`refresh:${index}`);
setError(null);
try {
await refreshCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
async function handleRefreshAllCodex() {
setBusy(true);
setError(null);
try {
const result = await refreshAllCodexAccountsApi();
await refresh();
if (result.failed.length > 0) {
setError(
`일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`,
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleSwitchCodex(index: number) {
setPerRowBusy(`switch:${index}`);
setError(null);
try {
await setCurrentCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
return (
계정
{error ? {error}
: null}
void handleAdd()}
onDelete={(index) => void handleDelete('claude', index)}
onTokenInputChange={setTokenInput}
tokenInput={tokenInput}
/>
void handleDelete('codex', index)}
onRefresh={(index) => void handleCodexRefresh(index)}
onRefreshAll={() => void handleRefreshAllCodex()}
onSwitch={(index) => void handleSwitchCodex(index)}
perRowBusy={perRowBusy}
/>
);
}
function ClaudeAccounts({
busy,
data,
onAdd,
onDelete,
onTokenInputChange,
tokenInput,
}: {
busy: boolean;
data: AccountData | null;
onAdd: () => void;
onDelete: (index: number) => void;
onTokenInputChange: (value: string) => void;
tokenInput: string;
}) {
return (
Claude
{!data ? (
{busy ? '불러오는 중…' : '없음'}
) : data.claude.length === 0 ? (
계정 없음
) : (
)}
);
}
function CodexAccounts({
busy,
data,
onDelete,
onRefresh,
onRefreshAll,
onSwitch,
perRowBusy,
}: {
busy: boolean;
data: AccountData | null;
onDelete: (index: number) => void;
onRefresh: (index: number) => void;
onRefreshAll: () => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
}) {
return (
Codex
{!data ? (
{busy ? '불러오는 중…' : '없음'}
) : data.codex.length === 0 ? (
계정 없음
) : (
{data.codex.map((acc) => (
))}
)}
OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
하려면 수동으로 “전체 갱신”을 누르세요.
);
}
function CodexAccountRow({
acc,
busy,
isActive,
onDelete,
onRefresh,
onSwitch,
perRowBusy,
}: {
acc: CodexAccountSummary;
busy: boolean;
isActive: boolean;
onDelete: (index: number) => void;
onRefresh: (index: number) => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
}) {
const expiry = formatExpiry(acc.subscriptionUntil);
const refreshing = perRowBusy === `refresh:${acc.index}`;
const switching = perRowBusy === `switch:${acc.index}`;
return (
{isActive ? '●' : ''}#{acc.index}
{acc.email ? (
{acc.email}
) : null}
{acc.planType ?? 'unknown'}
{expiry ? (
{expiry.label}
) : null}
{!isActive ? (
) : (
사용중
)}
{acc.index > 0 ? (
) : null}
);
}