trans2 / ui /components /SettingsDialog.tsx
Mayo
feat: Codex integration
dab69e9 unverified
'use client'
import { useQueryClient } from '@tanstack/react-query'
import {
SunIcon,
MoonIcon,
MonitorIcon,
CheckCircleIcon,
AlertCircleIcon,
LoaderIcon,
PaletteIcon,
KeyIcon,
HardDriveIcon,
InfoIcon,
CpuIcon,
KeyboardIcon,
SaveIcon,
RotateCcwIcon,
AlertTriangleIcon,
CopyIcon,
ExternalLinkIcon,
LogInIcon,
LogOutIcon,
SparklesIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Kbd } from '@/components/ui/kbd'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useUpdater, type UpdaterStatus } from '@/components/Updater'
import {
getCatalog as getLlmCatalog,
getConfig,
getEngineCatalog,
getGetCatalogQueryKey as getGetLlmCatalogQueryKey,
getMeta,
patchConfig,
deleteCodexSession,
getGetCodexAuthStatusQueryKey,
startCodexDeviceLogin,
useGetCodexAuthStatus,
} from '@/lib/api/default/default'
import type {
AppConfig,
ConfigPatch,
CodexDeviceLogin,
EngineCatalog as GetEngineCatalog200,
LlmProviderCatalog,
ProviderConfig,
} from '@/lib/api/schemas'
import { isTauri, openExternalUrl } from '@/lib/backend'
import { supportedLanguages } from '@/lib/i18n'
import {
areShortcutsEqual,
formatShortcut,
formatModifierCombination,
getPlatform,
isKeyBlocked,
isModifierKey,
} from '@/lib/shortcutUtils'
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
// Dialog state models `AppConfig` (what `GET /config` returns β€” snake_case).
// But `PATCH /config` expects a `ConfigPatch` with camelCase fields because
// the patch schema derives `rename_all = "camelCase"` serde attrs. Translate
// at the boundary so the dialog internals stay unified.
type UpdateConfigBody = AppConfig
function appConfigToPatch(cfg: AppConfig): ConfigPatch {
const patch: ConfigPatch = {}
if (cfg.data?.path) {
patch.data = { path: cfg.data.path }
}
if (cfg.http) {
patch.http = {
connectTimeout: cfg.http.connect_timeout,
readTimeout: cfg.http.read_timeout,
maxRetries: cfg.http.max_retries,
}
}
if (cfg.pipeline) {
patch.pipeline = {
detector: cfg.pipeline.detector,
fontDetector: cfg.pipeline.font_detector,
segmenter: cfg.pipeline.segmenter,
bubbleSegmenter: cfg.pipeline.bubble_segmenter,
ocr: cfg.pipeline.ocr,
translator: cfg.pipeline.translator,
inpainter: cfg.pipeline.inpainter,
renderer: cfg.pipeline.renderer,
}
}
if (cfg.providers) {
patch.providers = cfg.providers.map((p) => ({
id: p.id,
baseUrl: p.base_url ?? null,
apiKey: p.api_key ?? null,
}))
}
return patch
}
async function updateConfig(next: UpdateConfigBody): Promise<AppConfig> {
return (await patchConfig(appConfigToPatch(next))) as AppConfig
}
const GITHUB_REPO = 'mayocream/koharu'
const TABS = [
{ id: 'appearance', icon: PaletteIcon, labelKey: 'settings.appearance' },
{ id: 'engines', icon: CpuIcon, labelKey: 'settings.engines' },
{ id: 'providers', icon: KeyIcon, labelKey: 'settings.apiKeys' },
{ id: 'ai', icon: SparklesIcon, labelKey: 'settings.ai' },
{ id: 'keybinds', icon: KeyboardIcon, labelKey: 'settings.keybinds' },
{ id: 'runtime', icon: HardDriveIcon, labelKey: 'settings.runtime' },
{ id: 'about', icon: InfoIcon, labelKey: 'settings.about' },
] as const
export type TabId = (typeof TABS)[number]['id']
type SettingsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
defaultTab?: TabId
}
const DEFAULT_HTTP_CONNECT_TIMEOUT = 20
const DEFAULT_HTTP_READ_TIMEOUT = 300
const DEFAULT_HTTP_MAX_RETRIES = 3
export function SettingsDialog({
open,
onOpenChange,
defaultTab = 'appearance',
}: SettingsDialogProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [tab, setTab] = useState<TabId>(defaultTab)
useEffect(() => {
if (open) setTab(defaultTab)
}, [defaultTab, open])
const [appConfig, setAppConfig] = useState<UpdateConfigBody | null>(null)
const [providerCatalogs, setProviderCatalogs] = useState<LlmProviderCatalog[]>([])
const [apiKeyDrafts, setApiKeyDrafts] = useState<Record<string, string>>({})
const [dataPathDraft, setDataPathDraft] = useState('')
const [httpConnectTimeoutDraft, setHttpConnectTimeoutDraft] = useState('')
const [httpReadTimeoutDraft, setHttpReadTimeoutDraft] = useState('')
const [httpMaxRetriesDraft, setHttpMaxRetriesDraft] = useState('')
const [storageSettingsError, setStorageSettingsError] = useState<string | null>(null)
const [isSavingStorageSettings, setIsSavingStorageSettings] = useState(false)
const [engineCatalog, setEngineCatalog] = useState<GetEngineCatalog200 | null>(null)
const [appVersion, setAppVersion] = useState<string>()
const updater = useUpdater()
useEffect(() => {
if (!open) return
void (async () => {
try {
const [config, catalog, engines] = await Promise.all([
getConfig(),
getLlmCatalog(),
getEngineCatalog(),
])
setAppConfig(config)
setProviderCatalogs(catalog.providers)
setEngineCatalog(engines)
} catch {}
})()
}, [open])
useEffect(() => {
if (!open) return
let cancelled = false
void (async () => {
try {
const meta = await getMeta()
if (cancelled) return
setAppVersion(meta.version)
} catch {
return
}
})()
return () => {
cancelled = true
}
}, [open])
const checkForUpdates = updater.checkForUpdates
useEffect(() => {
if (!open || !isTauri()) return
void checkForUpdates()
}, [open, checkForUpdates])
useEffect(() => {
if (!appConfig?.data) return
setDataPathDraft(appConfig.data.path)
setHttpConnectTimeoutDraft(
String(appConfig.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT),
)
setHttpReadTimeoutDraft(String(appConfig.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT))
setHttpMaxRetriesDraft(String(appConfig.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES))
setStorageSettingsError(null)
}, [appConfig])
const persistConfig = async (next: UpdateConfigBody) => {
try {
const saved = await updateConfig(next)
const catalog = await getLlmCatalog()
setAppConfig(saved)
setProviderCatalogs(catalog.providers)
queryClient.invalidateQueries({ queryKey: getGetLlmCatalogQueryKey() })
return saved
} catch {
return null
}
}
const upsertProvider = (id: string, updater: (p: ProviderConfig) => ProviderConfig) => {
if (!appConfig) return
const providers = [...(appConfig.providers ?? [])]
const idx = providers.findIndex((p) => p.id === id)
const current = idx >= 0 ? providers[idx] : { id }
if (idx >= 0) providers[idx] = updater(current)
else providers.push(updater(current))
setAppConfig({ ...appConfig, providers })
}
const handleApplyStorageSettings = async () => {
if (!appConfig) return
const path = dataPathDraft.trim()
if (!path) {
setStorageSettingsError('Required')
return
}
const connectTimeout = Number.parseInt(httpConnectTimeoutDraft.trim(), 10)
if (!Number.isInteger(connectTimeout) || connectTimeout <= 0) {
setStorageSettingsError('Invalid HTTP connect timeout')
return
}
const readTimeout = Number.parseInt(httpReadTimeoutDraft.trim(), 10)
if (!Number.isInteger(readTimeout) || readTimeout <= 0) {
setStorageSettingsError('Invalid HTTP read timeout')
return
}
const maxRetries = Number.parseInt(httpMaxRetriesDraft.trim(), 10)
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
setStorageSettingsError('Invalid HTTP max retries')
return
}
setIsSavingStorageSettings(true)
setStorageSettingsError(null)
const saved = await persistConfig({
...appConfig,
data: { path },
http: {
connect_timeout: connectTimeout,
read_timeout: readTimeout,
max_retries: maxRetries,
},
})
setIsSavingStorageSettings(false)
if (!saved) {
setStorageSettingsError('Failed')
return
}
if (!isTauri()) {
setStorageSettingsError('Restart manually')
return
}
try {
const { relaunch } = await import('@tauri-apps/plugin-process')
await relaunch()
} catch {
setStorageSettingsError('Restart manually')
}
}
const storageSettingsUnchanged =
dataPathDraft.trim() === appConfig?.data?.path &&
httpConnectTimeoutDraft.trim() ===
String(appConfig?.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT) &&
httpReadTimeoutDraft.trim() ===
String(appConfig?.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT) &&
httpMaxRetriesDraft.trim() === String(appConfig?.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[600px] max-h-[85vh] w-[760px] max-w-[92vw] flex-col gap-0 overflow-hidden p-0'>
<DialogTitle className='sr-only'>{t('settings.title')}</DialogTitle>
<DialogDescription className='sr-only'>Settings</DialogDescription>
<div className='flex h-full'>
{/* Sidebar */}
<nav className='flex w-[180px] shrink-0 flex-col gap-1 border-r border-border bg-muted/30 p-3'>
<p className='mb-3 px-3 text-[10px] font-semibold tracking-widest text-muted-foreground uppercase'>
{t('settings.title')}
</p>
{TABS.map(({ id, icon: Icon, labelKey }) => (
<button
key={id}
onClick={() => setTab(id)}
data-active={tab === id}
className='flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground'
>
<Icon className='size-4 shrink-0' />
{t(labelKey)}
</button>
))}
</nav>
{/* Content */}
<ScrollArea className='min-h-0 flex-1'>
<div className='p-6'>
{tab === 'appearance' && <AppearancePane />}
{tab === 'engines' && engineCatalog && appConfig && (
<EnginesPane
catalog={engineCatalog}
pipeline={appConfig.pipeline ?? {}}
onChange={(pipeline) => {
const next = { ...appConfig, pipeline }
setAppConfig(next)
void persistConfig(next)
}}
/>
)}
{tab === 'providers' && (
<ProvidersPane
catalogs={providerCatalogs}
config={appConfig}
drafts={apiKeyDrafts}
onBaseUrlChange={(id, v) =>
upsertProvider(id, (p) => ({
...p,
base_url: v || null,
}))
}
onBaseUrlBlur={() => appConfig && void persistConfig(appConfig)}
onApiKeyChange={(id, v) => setApiKeyDrafts((c) => ({ ...c, [id]: v }))}
onSaveKey={(id) => {
const key = apiKeyDrafts[id]?.trim()
if (!key || !appConfig) return
const providers = [...(appConfig.providers ?? [])]
const idx = providers.findIndex((p) => p.id === id)
const current = idx >= 0 ? providers[idx] : { id }
const updated = { ...current, api_key: key }
if (idx >= 0) providers[idx] = updated
else providers.push(updated)
void persistConfig({ ...appConfig, providers }).then(() =>
setApiKeyDrafts((c) => {
const n = { ...c }
delete n[id]
return n
}),
)
}}
onClearKey={(id) => {
if (!appConfig) return
const providers = [...(appConfig.providers ?? [])]
const idx = providers.findIndex((p) => p.id === id)
if (idx >= 0) providers[idx] = { ...providers[idx], api_key: null }
void persistConfig({ ...appConfig, providers }).then(() =>
setApiKeyDrafts((c) => {
const n = { ...c }
delete n[id]
return n
}),
)
}}
/>
)}
{tab === 'ai' && <CodexSettingsPane />}
{tab === 'runtime' && (
<StoragePane
dataPath={dataPathDraft}
httpConnectTimeout={httpConnectTimeoutDraft}
httpReadTimeout={httpReadTimeoutDraft}
httpMaxRetries={httpMaxRetriesDraft}
error={storageSettingsError}
saving={isSavingStorageSettings}
unchanged={storageSettingsUnchanged}
onPathChange={(v) => {
setDataPathDraft(v)
setStorageSettingsError(null)
}}
onHttpConnectTimeoutChange={(v) => {
setHttpConnectTimeoutDraft(v)
setStorageSettingsError(null)
}}
onHttpReadTimeoutChange={(v) => {
setHttpReadTimeoutDraft(v)
setStorageSettingsError(null)
}}
onHttpMaxRetriesChange={(v) => {
setHttpMaxRetriesDraft(v)
setStorageSettingsError(null)
}}
onApply={() => void handleApplyStorageSettings()}
/>
)}
{tab === 'keybinds' && <KeybindsPane />}
{tab === 'about' && (
<AboutPane
version={appVersion}
latestVersion={updater.latestVersion}
status={updater.status}
isInstallingUpdate={updater.isInstalling}
onInstallUpdate={() => void updater.installUpdate()}
/>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
)
}
// ── Appearance ────────────────────────────────────────────────────
const THEMES = [
{ value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' },
{ value: 'dark', icon: MoonIcon, labelKey: 'settings.themeDark' },
{ value: 'system', icon: MonitorIcon, labelKey: 'settings.themeSystem' },
] as const
function AppearancePane() {
const { t, i18n } = useTranslation()
const { theme, setTheme } = useTheme()
const locales = useMemo(() => supportedLanguages, [])
return (
<div className='space-y-8'>
<Section title={t('settings.theme')}>
<div className='grid grid-cols-3 gap-3'>
{THEMES.map(({ value, icon: Icon, labelKey }) => (
<button
key={value}
onClick={() => setTheme(value)}
data-active={theme === value}
className='flex flex-col items-center gap-2 rounded-xl border border-border bg-card px-4 py-4 text-muted-foreground transition hover:border-foreground/30 data-[active=true]:border-primary data-[active=true]:text-foreground'
>
<Icon className='size-5' />
<span className='text-xs font-medium'>{t(labelKey)}</span>
</button>
))}
</div>
</Section>
<Section title={t('settings.language')}>
<Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{locales.map((code) => (
<SelectItem key={code} value={code}>
{t(`menu.languages.${code}`, { defaultValue: code })}
</SelectItem>
))}
</SelectContent>
</Select>
</Section>
</div>
)
}
// ── Engines ──────────────────────────────────────────────────────
function EnginesPane({
catalog,
pipeline,
onChange,
}: {
catalog: GetEngineCatalog200
pipeline: import('@/lib/api/schemas').PipelineConfig
onChange: (pipeline: import('@/lib/api/schemas').PipelineConfig) => void
}) {
const { t } = useTranslation()
const sections = [
{
label: t('settings.detector'),
key: 'detector' as const,
engines: catalog.detectors,
},
{
label: t('settings.fontDetector'),
key: 'font_detector' as const,
engines: catalog.fontDetectors,
},
{
label: t('settings.segmenter'),
key: 'segmenter' as const,
engines: catalog.segmenters,
},
{
label: t('settings.bubbleSegmenter'),
key: 'bubble_segmenter' as const,
engines: catalog.bubbleSegmenters,
},
{ label: t('settings.ocr'), key: 'ocr' as const, engines: catalog.ocr },
{
label: t('settings.translator'),
key: 'translator' as const,
engines: catalog.translators,
},
{
label: t('settings.inpainter'),
key: 'inpainter' as const,
engines: catalog.inpainters,
},
{
label: t('settings.renderer'),
key: 'renderer' as const,
engines: catalog.renderers,
},
]
return (
<div className='space-y-4'>
<p className='text-xs text-muted-foreground'>{t('settings.enginesDescription')}</p>
{sections.map(({ label, key, engines }) => (
<div key={key} className='space-y-1.5'>
<Label className='text-xs'>{label}</Label>
<Select
value={pipeline[key] ?? engines[0]?.id ?? ''}
onValueChange={(v) => onChange({ ...pipeline, [key]: v })}
>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{engines.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
)
}
// ── Providers ─────────────────────────────────────────────────────
function ProvidersPane({
catalogs,
config,
drafts,
onBaseUrlChange,
onBaseUrlBlur,
onApiKeyChange,
onSaveKey,
onClearKey,
}: {
catalogs: LlmProviderCatalog[]
config: UpdateConfigBody | null
drafts: Record<string, string>
onBaseUrlChange: (id: string, v: string) => void
onBaseUrlBlur: () => void
onApiKeyChange: (id: string, v: string) => void
onSaveKey: (id: string) => void
onClearKey: (id: string) => void
}) {
const { t } = useTranslation()
if (!catalogs.length)
return (
<p className='py-12 text-center text-sm text-muted-foreground'>
{t('settings.loadingProviders')}
</p>
)
return (
<div className='space-y-6'>
<Section title={t('settings.apiKeys')} description={t('settings.providersDescription')}>
<Accordion type='multiple' className='-mx-1'>
{catalogs.map((provider) => {
const cfg = config?.providers?.find((p) => p.id === provider.id)
const draft = drafts[provider.id] ?? ''
const hasDraft = draft.trim().length > 0
const statusColor =
provider.status === 'ready'
? 'bg-green-500'
: provider.status === 'missing_configuration'
? 'bg-amber-400'
: provider.status === 'discovery_failed'
? 'bg-red-500'
: 'bg-muted-foreground'
return (
<AccordionItem key={provider.id} value={provider.id} className='border-border'>
<AccordionTrigger className='px-1 py-3 hover:no-underline'>
<div className='flex items-center gap-2.5'>
<span className={`size-2 shrink-0 rounded-full ${statusColor}`} />
<span className='text-sm font-medium'>{provider.name}</span>
</div>
</AccordionTrigger>
<AccordionContent className='space-y-4 px-1 pt-1 pb-4'>
{provider.error && (
<p className='text-xs text-muted-foreground'>{provider.error}</p>
)}
{provider.requiresBaseUrl && (
<div className='space-y-1.5'>
<Label className='text-xs'>{t('settings.localLlmBaseUrl')}</Label>
<Input
type='url'
value={cfg?.base_url ?? ''}
onChange={(e) => onBaseUrlChange(provider.id, e.target.value)}
onBlur={onBaseUrlBlur}
placeholder='https://api.example.com/v1'
/>
</div>
)}
<div className='space-y-1.5'>
<Label className='text-xs'>{t('settings.apiKey')}</Label>
<div className='flex gap-2'>
<Input
type='password'
value={draft}
onChange={(e) => onApiKeyChange(provider.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && hasDraft) onSaveKey(provider.id)
}}
placeholder={
cfg?.api_key === '[REDACTED]'
? t('settings.apiKeyPlaceholderStored')
: t('settings.apiKeyPlaceholderEmpty')
}
className='[&::-ms-reveal]:hidden'
/>
{hasDraft ? (
<Button size='sm' onClick={() => onSaveKey(provider.id)}>
{t('settings.apiKeySave')}
</Button>
) : cfg?.api_key === '[REDACTED]' ? (
<Button
variant='destructive'
size='sm'
onClick={() => onClearKey(provider.id)}
>
{t('settings.apiKeyClear')}
</Button>
) : null}
</div>
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</Section>
</div>
)
}
// ── Keybinds ──────────────────────────────────────────────────────
function CodexSettingsPane() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [login, setLogin] = useState<CodexDeviceLogin | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const { data: auth, refetch } = useGetCodexAuthStatus()
const loginStatus = auth?.login?.status
const signedIn = auth?.signedIn === true
useEffect(() => {
if (!loginOpen && loginStatus !== 'pending') return
const id = window.setInterval(() => void refetch(), 2000)
return () => window.clearInterval(id)
}, [loginOpen, loginStatus, refetch])
useEffect(() => {
if (loginOpen && (signedIn || loginStatus === 'succeeded')) {
const id = window.setTimeout(() => setLoginOpen(false), 700)
return () => window.clearTimeout(id)
}
}, [loginOpen, loginStatus, signedIn])
const statusLabel = useMemo(() => {
if (signedIn) return auth?.accountId ? auth.accountId : t('ai.signedIn')
if (loginStatus === 'failed') return t('ai.signInFailed')
if (loginStatus === 'pending') return t('ai.signInPending')
return t('ai.signedOut')
}, [auth?.accountId, loginStatus, signedIn, t])
const invalidateAuth = () =>
queryClient.invalidateQueries({ queryKey: getGetCodexAuthStatusQueryKey() })
const handleSignIn = async () => {
setBusy(true)
setActionError(null)
try {
const next = await startCodexDeviceLogin()
setLogin(next)
setCopied(false)
setLoginOpen(true)
void invalidateAuth()
void openExternalUrl(next.verificationUrl)
} catch (err) {
setActionError(String(err))
} finally {
setBusy(false)
}
}
const handleLogout = async () => {
setBusy(true)
setActionError(null)
try {
await deleteCodexSession()
await invalidateAuth()
} catch (err) {
setActionError(String(err))
} finally {
setBusy(false)
}
}
const handleCopyCode = async () => {
if (!login?.userCode || typeof navigator === 'undefined') return
await navigator.clipboard?.writeText(login.userCode)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
}
return (
<Section title={t('settings.codex')} description={t('settings.codexDescription')}>
<div className='rounded-md border border-amber-200/70 bg-amber-50/80 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'>
{t('settings.codexTwoFactorDescription')}
</div>
<div className='rounded-md border border-border bg-card p-3'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-2'>
<div className='flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary'>
<SparklesIcon className='size-4' />
</div>
<div className='min-w-0'>
<div className='text-sm font-medium text-foreground'>Codex</div>
<div className='truncate text-xs text-muted-foreground'>{statusLabel}</div>
</div>
</div>
{signedIn ? (
<Button
variant='outline'
size='sm'
className='gap-1.5'
disabled={busy}
onClick={() => void handleLogout()}
>
<LogOutIcon className='size-3.5' />
{t('ai.signOut')}
</Button>
) : (
<Button
variant='default'
size='sm'
className='gap-1.5'
disabled={busy}
onClick={() => void handleSignIn()}
>
{busy ? (
<LoaderIcon className='size-3.5 animate-spin' />
) : (
<LogInIcon className='size-3.5' />
)}
{t('ai.signIn')}
</Button>
)}
</div>
{(actionError || (auth?.login?.status === 'failed' && auth.login.error)) && (
<p className='mt-2 line-clamp-3 text-xs text-destructive'>
{actionError || auth?.login?.error}
</p>
)}
</div>
<Dialog open={loginOpen} onOpenChange={setLoginOpen}>
<DialogContent className='w-[340px] max-w-[92vw] gap-3 p-4'>
<DialogTitle className='text-sm'>{t('ai.signInTitle')}</DialogTitle>
<DialogDescription className='sr-only'>{t('ai.signIn')}</DialogDescription>
<div className='flex items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2'>
<div className='min-w-0'>
<div className='text-[10px] font-semibold tracking-wide text-muted-foreground uppercase'>
{t('ai.userCode')}
</div>
<div className='mt-0.5 font-mono text-xl font-semibold tracking-widest'>
{login?.userCode ?? '...'}
</div>
</div>
<Button
variant='outline'
size='icon-sm'
disabled={!login}
aria-label={copied ? t('common.copied') : t('common.copy')}
onClick={() => void handleCopyCode()}
>
<CopyIcon className='size-3.5' />
</Button>
</div>
<Button
variant='outline'
size='sm'
className='w-full gap-1.5'
disabled={!login}
onClick={() => login && void openExternalUrl(login.verificationUrl)}
>
<ExternalLinkIcon className='size-3.5' />
{t('ai.openBrowser')}
</Button>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
{signedIn || loginStatus === 'succeeded' ? (
<>
<CheckCircleIcon className='size-4 text-green-500' />
{t('ai.signInComplete')}
</>
) : loginStatus === 'failed' ? (
<>
<AlertCircleIcon className='size-4 text-destructive' />
<span className='line-clamp-2'>{auth?.login?.error ?? t('ai.signInFailed')}</span>
</>
) : (
<>
<LoaderIcon className='size-4 animate-spin' />
{t('ai.signInPending')}
</>
)}
</div>
</DialogContent>
</Dialog>
</Section>
)
}
const SHORTCUT_ITEMS = [
{ key: 'select', labelKey: 'toolRail.select' },
{ key: 'block', labelKey: 'toolRail.block' },
{ key: 'brush', labelKey: 'toolRail.brush' },
{ key: 'eraser', labelKey: 'toolRail.eraser' },
{ key: 'repairBrush', labelKey: 'toolRail.repairBrush' },
{
key: 'increaseBrushSize',
labelKey: 'settings.shortcutIncreaseBrushSize',
},
{
key: 'decreaseBrushSize',
labelKey: 'settings.shortcutDecreaseBrushSize',
},
{ key: 'undo', labelKey: 'menu.undo' },
{ key: 'redo', labelKey: 'menu.redo' },
] as const
function KeybindsPane() {
const { t } = useTranslation()
const shortcuts = usePreferencesStore((state) => state.shortcuts)
const setShortcuts = usePreferencesStore((state) => state.setShortcuts)
const resetShortcutsStore = usePreferencesStore((state) => state.resetShortcuts)
const [pendingShortcuts, setPendingShortcuts] = useState(shortcuts)
const [recordingKey, setRecordingKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isSaved, setIsSaved] = useState(false)
const [liveShortcut, setLiveShortcut] = useState<string | null>(null)
const isMac = useMemo(() => getPlatform() === 'mac', [])
// Optimized conflict detection
const conflictCounts = useMemo(() => {
const counts = new Map<string, number>()
Object.values(pendingShortcuts).forEach((val) => {
counts.set(val, (counts.get(val) || 0) + 1)
})
return counts
}, [pendingShortcuts])
const isDirty = useMemo(
() => !areShortcutsEqual(shortcuts, pendingShortcuts),
[shortcuts, pendingShortcuts],
)
// Sync from store if it changes (e.g. externally via Reset)
useEffect(() => {
setPendingShortcuts(shortcuts)
}, [shortcuts])
useEffect(() => {
if (!recordingKey) {
setError(null)
setLiveShortcut(null)
return
}
setError(null)
setLiveShortcut(null)
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
setError(null)
// Early exit for modifier-only events - but update preview!
if (isModifierKey(e.key)) {
setLiveShortcut(formatModifierCombination(e, isMac))
return
}
// Allow Escape to cancel recording
if (e.key === 'Escape') {
setRecordingKey(null)
setLiveShortcut(null)
return
}
// Block system/function keys
if (isKeyBlocked(e.key)) {
setError(t('settings.shortcutInvalid'))
return
}
const shortcut = formatShortcut(e, isMac)
if (!shortcut) return
setPendingShortcuts((prev) => ({ ...prev, [recordingKey]: shortcut }))
setRecordingKey(null)
setIsSaved(false)
setLiveShortcut(null)
}
const handleKeyUp = (e: KeyboardEvent) => {
if (isModifierKey(e.key)) {
const combo = formatModifierCombination(e, isMac)
setLiveShortcut(combo || null)
}
}
const handleClickOutside = () => {
setRecordingKey(null)
setLiveShortcut(null)
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
window.addEventListener('keyup', handleKeyUp, { capture: true })
window.addEventListener('click', handleClickOutside, { capture: true })
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true })
window.removeEventListener('keyup', handleKeyUp, { capture: true })
window.removeEventListener('click', handleClickOutside, {
capture: true,
})
}
}, [recordingKey, pendingShortcuts, t, isMac])
const [resetConfirmOpen, setResetConfirmOpen] = useState(false)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
const handleSave = () => {
setShortcuts(pendingShortcuts)
setIsSaved(true)
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
saveTimeoutRef.current = setTimeout(() => {
setIsSaved(false)
saveTimeoutRef.current = null
}, 2000)
}
const handleReset = () => {
setResetConfirmOpen(true)
}
const handleConfirmReset = () => {
resetShortcutsStore()
setResetConfirmOpen(false)
}
const renderShortcutKeys = (shortcutStr: string, kbdClass?: string) => {
const parts = shortcutStr.split('+')
return parts.map((part, i) => (
<Fragment key={i}>
<Kbd className={kbdClass}>{part}</Kbd>
{i < parts.length - 1 && <span className='text-muted-foreground'>+</span>}
</Fragment>
))
}
return (
<div className='flex h-full flex-col gap-6'>
<div className='grow space-y-6 overflow-y-auto pr-2'>
<Section title={t('settings.keybinds')} description={t('settings.keybindsDescription')}>
<div className='divide-y divide-border overflow-hidden rounded-xl border border-border bg-card'>
{SHORTCUT_ITEMS.map((item) => {
const currentVal = pendingShortcuts[item.key]
const hasConflict = currentVal && (conflictCounts.get(currentVal) || 0) > 1
const conflictingItem = hasConflict
? SHORTCUT_ITEMS.find(
(s) => s.key !== item.key && pendingShortcuts[s.key] === currentVal,
)
: null
return (
<div key={item.key} className='flex items-center justify-between px-4 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm'>{t(item.labelKey)}</span>
{hasConflict && (
<div
title={`${t('settings.shortcutConflict')}${
conflictingItem ? `: ${t(conflictingItem.labelKey)}` : ''
}`}
>
<AlertTriangleIcon className='size-3.5 text-amber-500' />
</div>
)}
</div>
<Button
variant={recordingKey === item.key ? 'secondary' : 'ghost'}
size='sm'
onClick={(e) => {
e.stopPropagation()
setRecordingKey(item.key)
}}
className='group h-8 w-fit px-2 font-mono uppercase'
>
<div className='flex items-center gap-1'>
{recordingKey === item.key ? (
error ? (
<span className='text-xs text-destructive'>{error}</span>
) : liveShortcut ? (
renderShortcutKeys(liveShortcut)
) : (
<span className='text-xs text-muted-foreground italic'>
{t('settings.shortcutPressKey')}
</span>
)
) : currentVal ? (
renderShortcutKeys(currentVal, 'bg-background')
) : (
<span className='text-xs text-muted-foreground'>NONE</span>
)}
</div>
</Button>
</div>
)
})}
</div>
</Section>
</div>
<div className='flex items-center justify-between border-t border-border pt-4'>
<Button
variant='ghost'
size='sm'
className='gap-2 text-muted-foreground hover:text-foreground'
onClick={handleReset}
>
<RotateCcwIcon className='size-4' />
{t('settings.shortcutReset')}
</Button>
<div className='flex items-center gap-2'>
<Button
variant='default'
size='sm'
disabled={!isDirty || isSaved}
onClick={handleSave}
className='min-w-32 gap-2'
>
{isSaved ? (
<>
<CheckCircleIcon className='size-4' />
{t('common.saved')}
</>
) : (
<>
<SaveIcon className='size-4' />
{t('common.save')}
</>
)}
</Button>
</div>
</div>
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
<AlertDialogContent>
<AlertDialogTitle>{t('settings.shortcutReset')}</AlertDialogTitle>
<AlertDialogDescription>{t('settings.shortcutResetDescription')}</AlertDialogDescription>
<div className='flex justify-end gap-2'>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmReset}>
{t('common.confirm')}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
// ── Storage ───────────────────────────────────────────────────────
function StoragePane({
dataPath,
httpConnectTimeout,
httpReadTimeout,
httpMaxRetries,
error,
saving,
unchanged,
onPathChange,
onHttpConnectTimeoutChange,
onHttpReadTimeoutChange,
onHttpMaxRetriesChange,
onApply,
}: {
dataPath: string
httpConnectTimeout: string
httpReadTimeout: string
httpMaxRetries: string
error: string | null
saving: boolean
unchanged: boolean
onPathChange: (v: string) => void
onHttpConnectTimeoutChange: (v: string) => void
onHttpReadTimeoutChange: (v: string) => void
onHttpMaxRetriesChange: (v: string) => void
onApply: () => void
}) {
const { t } = useTranslation()
const [confirmOpen, setConfirmOpen] = useState(false)
return (
<>
<Section title={t('settings.runtime')} description={t('settings.runtimeDescription')}>
<div className='space-y-1.5'>
<Label className='text-xs'>{t('settings.dataPath')}</Label>
<Input type='text' value={dataPath} onChange={(e) => onPathChange(e.target.value)} />
<p className='text-xs leading-relaxed text-muted-foreground'>
{t('settings.dataPathDescription')}
</p>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label className='text-xs'>{t('settings.httpConnectTimeout')}</Label>
<Input
type='number'
min='1'
step='1'
inputMode='numeric'
value={httpConnectTimeout}
onChange={(e) => onHttpConnectTimeoutChange(e.target.value)}
/>
<p className='text-xs leading-relaxed text-muted-foreground'>
{t('settings.httpConnectTimeoutDescription')}
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs'>{t('settings.httpReadTimeout')}</Label>
<Input
type='number'
min='1'
step='1'
inputMode='numeric'
value={httpReadTimeout}
onChange={(e) => onHttpReadTimeoutChange(e.target.value)}
/>
<p className='text-xs leading-relaxed text-muted-foreground'>
{t('settings.httpReadTimeoutDescription')}
</p>
</div>
</div>
<div className='space-y-1.5'>
<Label className='text-xs'>{t('settings.httpMaxRetries')}</Label>
<Input
type='number'
min='0'
step='1'
inputMode='numeric'
value={httpMaxRetries}
onChange={(e) => onHttpMaxRetriesChange(e.target.value)}
/>
<p className='text-xs leading-relaxed text-muted-foreground'>
{t('settings.httpMaxRetriesDescription')}
</p>
</div>
{error && <p className='text-xs text-destructive'>{error}</p>}
<div className='flex justify-end pt-1'>
<Button
onClick={() => setConfirmOpen(true)}
disabled={!dataPath.trim() || saving || unchanged}
>
{saving ? t('settings.restartApplying') : t('settings.restartApply')}
</Button>
</div>
</Section>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogTitle>{t('settings.restartApply')}</AlertDialogTitle>
<AlertDialogDescription>
{t('settings.restartRequiredDescription')}
</AlertDialogDescription>
<div className='flex justify-end gap-2'>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmOpen(false)
onApply()
}}
>
{t('settings.restartApply')}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
)
}
// ── About ─────────────────────────────────────────────────────────
function AboutPane({
version,
latestVersion,
status,
isInstallingUpdate,
onInstallUpdate,
}: {
version?: string
latestVersion?: string
status: UpdaterStatus
isInstallingUpdate: boolean
onInstallUpdate: () => void
}) {
const { t } = useTranslation()
return (
<div className='flex h-full flex-col items-center justify-center gap-5 py-8'>
<img src='/icon-large.png' alt='Koharu' className='size-20' draggable={false} />
<div className='text-center'>
<h2 className='text-lg font-bold tracking-wide text-foreground'>Koharu</h2>
<p className='mt-1 text-sm text-muted-foreground'>{t('settings.aboutTagline')}</p>
</div>
<div className='w-full max-w-sm rounded-xl border border-border bg-card p-4'>
<div className='space-y-3 text-sm'>
<InfoRow label={t('settings.aboutVersion')}>
<div className='flex flex-col items-end gap-0.5'>
<span className='font-mono text-xs font-medium'>{version || '...'}</span>
{status === 'loading' && (
<LoaderIcon className='size-3.5 animate-spin text-muted-foreground' />
)}
{status === 'latest' && (
<span className='flex items-center gap-1 text-xs text-green-500'>
<CheckCircleIcon className='size-3.5' />
{t('settings.aboutLatest')}
</span>
)}
{status === 'outdated' && (
<Button
variant='link'
size='xs'
onClick={onInstallUpdate}
disabled={isInstallingUpdate}
className='h-auto gap-1 p-0 text-amber-500'
>
{isInstallingUpdate ? (
<LoaderIcon className='size-3.5 animate-spin' />
) : (
<AlertCircleIcon className='size-3.5' />
)}
{t('settings.aboutUpdate', { version: latestVersion })}
</Button>
)}
</div>
</InfoRow>
<InfoRow label={t('settings.aboutAuthor')}>
<Button
variant='link'
size='xs'
onClick={() => void openExternalUrl('https://github.com/mayocream')}
>
Mayo
</Button>
</InfoRow>
<InfoRow label={t('settings.aboutRepository')}>
<Button
variant='link'
size='xs'
onClick={() => void openExternalUrl(`https://github.com/${GITHUB_REPO}`)}
>
GitHub
</Button>
</InfoRow>
</div>
</div>
</div>
)
}
// ── Shared ────────────────────────────────────────────────────────
function Section({
title,
description,
children,
}: {
title: string
description?: string
children: ReactNode
}) {
return (
<div className='space-y-3'>
<div>
<h3 className='text-sm font-semibold text-foreground'>{title}</h3>
{description && (
<p className='mt-0.5 text-xs leading-relaxed text-muted-foreground'>{description}</p>
)}
</div>
{children}
</div>
)
}
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{label}</span>
<div className='flex items-center'>{children}</div>
</div>
)
}