diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 075c5b4..d32f36b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,14 @@ -import { FormEvent, useCallback, useEffect, useState } from 'react' +import { ChangeEvent, FormEvent, KeyboardEvent, useCallback, useEffect, useId, useState } from 'react' import { useTranslation } from 'react-i18next' import { applySync, bindAliasTarget, deleteAlias, deleteProvider, + exportConfig, getMeta, getOverview, + importConfig, importProviders, listAliases, listProviders, @@ -68,6 +70,12 @@ type AliasFormState = { type TabKey = 'overview' | 'providers' | 'aliases' | 'sync' | 'settings' type FilterState = 'all' | 'enabled' | 'disabled' type ResolvedTheme = 'light' | 'dark' +type ModalKey = 'provider-form' | 'provider-import' | 'alias-form' | 'alias-target' | null +type ConfigImportMode = 'text' | 'file' +type ConfirmIntent = + | { kind: 'delete-provider'; id: string } + | { kind: 'delete-alias'; alias: string } + | { kind: 'unbind-target'; alias: string; provider: string; model: string } const tabs: TabKey[] = ['overview', 'providers', 'aliases', 'sync', 'settings'] @@ -118,6 +126,16 @@ function pretty(value: unknown): string { return JSON.stringify(value, null, 2) } +function overviewDebugSnapshot(overview: Overview | null) { + if (!overview) { + return null + } + return { + ...overview, + configPath: undefined, + } +} + function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -198,6 +216,31 @@ function resolveThemePreference(theme: ThemePreference, systemTheme: ResolvedThe return systemTheme } +function configFileName(configPath: string): string { + const parts = configPath.split(/[/\\]/).filter(Boolean) + return parts[parts.length - 1] || 'ocswitch-config.json' +} + +function downloadTextFile(filename: string, content: string) { + const blob = new Blob([content], { type: 'application/json;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.append(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} + +function focusableElements(root: HTMLElement): HTMLElement[] { + return Array.from( + root.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ) +} + function providerMatches(provider: ProviderView, query: string, filter: FilterState): boolean { if (filter === 'enabled' && provider.disabled) { return false @@ -251,6 +294,12 @@ export default function App() { const [editingAliasId, setEditingAliasId] = useState('') const [systemTheme, setSystemTheme] = useState('dark') const [systemLanguage, setSystemLanguage] = useState('en-US') + const [activeModal, setActiveModal] = useState(null) + const [configTransferStatus, setConfigTransferStatus] = useState('') + const [configImportMode, setConfigImportMode] = useState('text') + const [configImportText, setConfigImportText] = useState('') + const [configImportFileName, setConfigImportFileName] = useState('') + const [confirmIntent, setConfirmIntent] = useState(null) const refreshAll = useCallback(async () => { setLoading(true) @@ -335,6 +384,18 @@ export default function App() { void i18n.changeLanguage(resolvedLanguage) }, [resolvedLanguage]) + useEffect(() => { + if (!activeModal && !confirmIntent) { + return + } + const modal = document.querySelector('.modal-card') + if (!modal) { + return + } + const [firstFocusable] = focusableElements(modal) + ;(firstFocusable || modal).focus() + }, [activeModal, confirmIntent]) + function selectTab(tab: TabKey) { window.location.hash = tab } @@ -349,6 +410,71 @@ export default function App() { setAliasForm(emptyAliasForm) } + function closeModal() { + setActiveModal(null) + } + + function closeConfirmDialog() { + setConfirmIntent(null) + } + + function openProviderCreateModal() { + resetProviderForm() + setActiveModal('provider-form') + } + + function openProviderImportModal() { + setActiveModal('provider-import') + } + + function openAliasCreateModal() { + resetAliasForm() + setActiveModal('alias-form') + } + + function openAliasTargetModal(alias?: string) { + setTargetForm({ ...emptyTargetForm, alias: alias || '' }) + setActiveModal('alias-target') + } + + function trapModalFocus(event: KeyboardEvent) { + if (event.key !== 'Tab') { + return + } + const focusables = focusableElements(event.currentTarget) + if (focusables.length === 0) { + event.preventDefault() + event.currentTarget.focus() + return + } + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement + if (event.shiftKey && active === first) { + event.preventDefault() + last.focus() + return + } + if (!event.shiftKey && active === last) { + event.preventDefault() + first.focus() + } + } + + function onModalKeyDown(event: KeyboardEvent) { + trapModalFocus(event) + if (event.key === 'Escape') { + closeModal() + } + } + + function onConfirmKeyDown(event: KeyboardEvent) { + trapModalFocus(event) + if (event.key === 'Escape') { + closeConfirmDialog() + } + } + async function onSavePrefs(event: FormEvent) { event.preventDefault() setPrefsStatus(i18n.t('messages.saving')) @@ -439,6 +565,7 @@ export default function App() { } const result = await saveProvider(input) resetProviderForm() + closeModal() setProviderStatus(providerSaveStatus(result)) await refreshAll() } catch (error) { @@ -454,6 +581,8 @@ export default function App() { sourcePath: providerImportForm.sourcePath?.trim(), overwrite: providerImportForm.overwrite, }) + setProviderImportForm(emptyProviderImport) + closeModal() setProviderStatus(providerImportStatus(result)) await refreshAll() } catch (error) { @@ -474,6 +603,7 @@ export default function App() { clearHeaders: false, }) setProviderStatus(i18n.t('providers.statusEditing', { id: provider.id })) + setActiveModal('provider-form') } async function onToggleProvider(provider: ProviderView) { @@ -516,6 +646,7 @@ export default function App() { } await saveAlias(input) resetAliasForm() + closeModal() setAliasStatus(i18n.t('aliases.statusSaved', { alias: input.alias })) await refreshAll() } catch (error) { @@ -532,6 +663,7 @@ export default function App() { }) setTargetForm((current) => ({ ...current, alias: alias.alias })) setAliasStatus(i18n.t('aliases.statusEditing', { alias: alias.alias })) + setActiveModal('alias-form') } async function onDeleteAlias(alias: string) { @@ -560,6 +692,7 @@ export default function App() { } await bindAliasTarget(input) setTargetForm((current) => ({ ...emptyTargetForm, alias: current.alias })) + closeModal() setAliasStatus(i18n.t('aliases.statusBound', input)) await refreshAll() } catch (error) { @@ -593,19 +726,114 @@ export default function App() { } } + async function onToggleProxy() { + if (overview?.proxy.running) { + await onStopProxy() + return + } + await onStartProxy() + } + + async function onExportConfig() { + setConfigTransferStatus(i18n.t('messages.running')) + try { + const result = await exportConfig() + downloadTextFile(configFileName(result.configPath), result.content) + setConfigTransferStatus(i18n.t('settings.exportDone', { path: result.configPath })) + } catch (error) { + setConfigTransferStatus(formatError(error)) + } + } + + async function onImportConfig() { + const content = configImportText.trim() + if (!content) { + setConfigTransferStatus(i18n.t('settings.importEmpty')) + return + } + setConfigTransferStatus(i18n.t('messages.applying')) + try { + const result = await importConfig({ content }) + setConfigTransferStatus(withWarnings(i18n.t('settings.importDone', { path: result.configPath }), result.warnings)) + setConfigImportText('') + setConfigImportFileName('') + await refreshAll() + } catch (error) { + setConfigTransferStatus(formatError(error)) + } + } + + async function onSelectConfigFile(event: ChangeEvent) { + const file = event.target.files?.[0] + if (!file) { + return + } + try { + const text = await file.text() + setConfigImportText(text) + setConfigImportFileName(file.name) + setConfigTransferStatus(i18n.t('settings.fileLoaded', { name: file.name })) + } catch (error) { + setConfigTransferStatus(formatError(error)) + } finally { + event.target.value = '' + } + } + + async function onConfirmAction() { + const intent = confirmIntent + if (!intent) { + return + } + closeConfirmDialog() + if (intent.kind === 'delete-provider') { + await onDeleteProvider(intent.id) + return + } + if (intent.kind === 'delete-alias') { + await onDeleteAlias(intent.alias) + return + } + await onUnbindTarget(intent.alias, intent.provider, intent.model) + } + + const confirmTitle = confirmIntent + ? confirmIntent.kind === 'delete-provider' + ? t('confirm.deleteProviderTitle') + : confirmIntent.kind === 'delete-alias' + ? t('confirm.deleteAliasTitle') + : t('confirm.unbindTargetTitle') + : '' + + const confirmMessage = confirmIntent + ? confirmIntent.kind === 'delete-provider' + ? t('messages.confirmDeleteProvider', { id: confirmIntent.id }) + : confirmIntent.kind === 'delete-alias' + ? t('messages.confirmDeleteAlias', { alias: confirmIntent.alias }) + : t('messages.confirmUnbindTarget', { + alias: confirmIntent.alias, + provider: confirmIntent.provider, + model: confirmIntent.model, + }) + : '' + + const importModeLabelId = useId() + const providerFormTitleId = useId() + const providerImportTitleId = useId() + const aliasFormTitleId = useId() + const aliasTargetTitleId = useId() + const confirmTitleId = useId() + return (
-
+

{t(`nav.${activeTab}.title`)}

{t(`nav.${activeTab}.title`)}

{t(`nav.${activeTab}.description`)}

-
- - -
+ {activeTab === 'overview' ? ( +
+
+
+ {t('overview.proxy')} + {overview?.proxy.running ? t('status.proxyRunning') : t('status.proxyIdle')} +
+
+ {t('overview.lastError')} + {overview?.proxy.lastError || '-'} +
+
+
+ + + +
+
+ ) : null}
{activeTab === 'overview' ? ( @@ -688,11 +918,12 @@ export default function App() {
- -
-
-
{t('overview.shell')}
-
{meta.shell}
-
-
-
{t('overview.configPath')}
-
{overview?.configPath || t('app.loadingConfig')}
-
{t('overview.startedAt')}
{overview?.proxy.startedAt ? String(overview.proxy.startedAt) : '-'}
@@ -769,7 +992,7 @@ export default function App() {
{t('overview.debugSummary')} -
{overview ? pretty(overview) : t('messages.loading')}
+
{overview ? pretty(overviewDebugSnapshot(overview)) : t('messages.loading')}
@@ -783,210 +1006,105 @@ export default function App() {

{t('providers.title')}

{t('providers.subtitle')}

- - {providerStatus || - t('providers.listCount', { shown: filteredProviders.length, total: providers.length })} - +
+ + {providerStatus || + t('providers.listCount', { shown: filteredProviders.length, total: providers.length })} + + + +
-
-
-
-
-

- {editingProviderId - ? t('providers.formEditTitle', { id: editingProviderId }) - : t('providers.formCreateTitle')} -

- -
-
void onSaveProvider(event)}> - - - - -