'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 { 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(defaultTab) useEffect(() => { if (open) setTab(defaultTab) }, [defaultTab, open]) const [appConfig, setAppConfig] = useState(null) const [providerCatalogs, setProviderCatalogs] = useState([]) const [apiKeyDrafts, setApiKeyDrafts] = useState>({}) const [dataPathDraft, setDataPathDraft] = useState('') const [httpConnectTimeoutDraft, setHttpConnectTimeoutDraft] = useState('') const [httpReadTimeoutDraft, setHttpReadTimeoutDraft] = useState('') const [httpMaxRetriesDraft, setHttpMaxRetriesDraft] = useState('') const [storageSettingsError, setStorageSettingsError] = useState(null) const [isSavingStorageSettings, setIsSavingStorageSettings] = useState(false) const [engineCatalog, setEngineCatalog] = useState(null) const [appVersion, setAppVersion] = useState() 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 ( {t('settings.title')} Settings
{/* Sidebar */} {/* Content */}
{tab === 'appearance' && } {tab === 'engines' && engineCatalog && appConfig && ( { const next = { ...appConfig, pipeline } setAppConfig(next) void persistConfig(next) }} /> )} {tab === 'providers' && ( 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' && } {tab === 'runtime' && ( { 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' && } {tab === 'about' && ( void updater.installUpdate()} /> )}
) } // ── 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 (
{THEMES.map(({ value, icon: Icon, labelKey }) => ( ))}
) } // ── 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 (

{t('settings.enginesDescription')}

{sections.map(({ label, key, engines }) => (
))}
) } // ── Providers ───────────────────────────────────────────────────── function ProvidersPane({ catalogs, config, drafts, onBaseUrlChange, onBaseUrlBlur, onApiKeyChange, onSaveKey, onClearKey, }: { catalogs: LlmProviderCatalog[] config: UpdateConfigBody | null drafts: Record 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 (

{t('settings.loadingProviders')}

) return (
{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 (
{provider.name}
{provider.error && (

{provider.error}

)} {provider.requiresBaseUrl && (
onBaseUrlChange(provider.id, e.target.value)} onBlur={onBaseUrlBlur} placeholder='https://api.example.com/v1' />
)}
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 ? ( ) : cfg?.api_key === '[REDACTED]' ? ( ) : null}
) })}
) } // ── Keybinds ────────────────────────────────────────────────────── function CodexSettingsPane() { const { t } = useTranslation() const queryClient = useQueryClient() const [login, setLogin] = useState(null) const [loginOpen, setLoginOpen] = useState(false) const [busy, setBusy] = useState(false) const [copied, setCopied] = useState(false) const [actionError, setActionError] = useState(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 (
{t('settings.codexTwoFactorDescription')}
Codex
{statusLabel}
{signedIn ? ( ) : ( )}
{(actionError || (auth?.login?.status === 'failed' && auth.login.error)) && (

{actionError || auth?.login?.error}

)}
{t('ai.signInTitle')} {t('ai.signIn')}
{t('ai.userCode')}
{login?.userCode ?? '...'}
{signedIn || loginStatus === 'succeeded' ? ( <> {t('ai.signInComplete')} ) : loginStatus === 'failed' ? ( <> {auth?.login?.error ?? t('ai.signInFailed')} ) : ( <> {t('ai.signInPending')} )}
) } 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(null) const [error, setError] = useState(null) const [isSaved, setIsSaved] = useState(false) const [liveShortcut, setLiveShortcut] = useState(null) const isMac = useMemo(() => getPlatform() === 'mac', []) // Optimized conflict detection const conflictCounts = useMemo(() => { const counts = new Map() 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 | 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) => ( {part} {i < parts.length - 1 && +} )) } return (
{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 (
{t(item.labelKey)} {hasConflict && (
)}
) })}
{t('settings.shortcutReset')} {t('settings.shortcutResetDescription')}
{t('common.cancel')} {t('common.confirm')}
) } // ── 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 ( <>
onPathChange(e.target.value)} />

{t('settings.dataPathDescription')}

onHttpConnectTimeoutChange(e.target.value)} />

{t('settings.httpConnectTimeoutDescription')}

onHttpReadTimeoutChange(e.target.value)} />

{t('settings.httpReadTimeoutDescription')}

onHttpMaxRetriesChange(e.target.value)} />

{t('settings.httpMaxRetriesDescription')}

{error &&

{error}

}
{t('settings.restartApply')} {t('settings.restartRequiredDescription')}
{t('common.cancel')} { setConfirmOpen(false) onApply() }} > {t('settings.restartApply')}
) } // ── About ───────────────────────────────────────────────────────── function AboutPane({ version, latestVersion, status, isInstallingUpdate, onInstallUpdate, }: { version?: string latestVersion?: string status: UpdaterStatus isInstallingUpdate: boolean onInstallUpdate: () => void }) { const { t } = useTranslation() return (
Koharu

Koharu

{t('settings.aboutTagline')}

{version || '...'} {status === 'loading' && ( )} {status === 'latest' && ( {t('settings.aboutLatest')} )} {status === 'outdated' && ( )}
) } // ── Shared ──────────────────────────────────────────────────────── function Section({ title, description, children, }: { title: string description?: string children: ReactNode }) { return (

{title}

{description && (

{description}

)}
{children}
) } function InfoRow({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
) }