@aphrody/koharu-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +37 -2
  2. package/package.json +70 -4
  3. package/src/assets/NotoSansCJK-VF.otf +0 -0
  4. package/src/assets/OFL.txt +92 -0
  5. package/src/components/ClientOnly.tsx +21 -0
  6. package/src/components/ShenronExportPanel.tsx +213 -0
  7. package/src/components/app/AboutDialog.tsx +64 -0
  8. package/src/components/app/KoharuApp.tsx +58 -0
  9. package/src/components/app/StartupView.tsx +67 -0
  10. package/src/components/app/TitleBar.tsx +322 -0
  11. package/src/components/app/Updater.tsx +143 -0
  12. package/src/components/app/WindowChrome.tsx +137 -0
  13. package/src/components/controls/ColorSampling.tsx +70 -0
  14. package/src/components/controls/ColorWell.tsx +139 -0
  15. package/src/components/controls/CommitTextarea.tsx +63 -0
  16. package/src/components/controls/FontPicker.tsx +543 -0
  17. package/src/components/controls/ModelPicker.tsx +178 -0
  18. package/src/components/controls/OutputPicker.tsx +170 -0
  19. package/src/components/editor/ActivityCenter.tsx +234 -0
  20. package/src/components/editor/AgentPanel.tsx +352 -0
  21. package/src/components/editor/CanvasCommandBar.tsx +38 -0
  22. package/src/components/editor/CanvasOverlay.tsx +194 -0
  23. package/src/components/editor/CanvasWorkspace.tsx +947 -0
  24. package/src/components/editor/Editor.tsx +56 -0
  25. package/src/components/editor/InferenceControl.tsx +457 -0
  26. package/src/components/editor/Inspector.tsx +1073 -0
  27. package/src/components/editor/PageRail.tsx +581 -0
  28. package/src/components/editor/ResourceMonitor.tsx +151 -0
  29. package/src/components/editor/RightSidebar.tsx +50 -0
  30. package/src/components/editor/SelectionControls.tsx +184 -0
  31. package/src/components/editor/StatusBar.tsx +41 -0
  32. package/src/components/editor/ToolBar.tsx +182 -0
  33. package/src/components/editor/useCanvas.ts +194 -0
  34. package/src/components/preferences/GenerationPreferences.tsx +118 -0
  35. package/src/components/preferences/KoharuSettings.tsx +229 -0
  36. package/src/components/preferences/PipelinePreferences.tsx +173 -0
  37. package/src/components/preferences/PreferenceFields.tsx +149 -0
  38. package/src/components/preferences/ProviderPreferences.tsx +173 -0
  39. package/src/components/preferences/SettingsPage.tsx +336 -0
  40. package/src/components/preferences/TranslationPreferences.tsx +187 -0
  41. package/src/components/preferences/TypesettingPreferences.tsx +142 -0
  42. package/src/components/preferences/models.ts +103 -0
  43. package/src/components/start/StartView.tsx +277 -0
  44. package/src/index.ts +59 -0
  45. package/src/internal/components/accordion.tsx +72 -0
  46. package/src/internal/components/alert-dialog.tsx +189 -0
  47. package/src/internal/components/alert.tsx +76 -0
  48. package/src/internal/components/aspect-ratio.tsx +22 -0
  49. package/src/internal/components/attachment.tsx +207 -0
  50. package/src/internal/components/avatar.tsx +109 -0
  51. package/src/internal/components/badge.tsx +52 -0
  52. package/src/internal/components/breadcrumb.tsx +125 -0
  53. package/src/internal/components/bubble.tsx +128 -0
  54. package/src/internal/components/button-group.tsx +87 -0
  55. package/src/internal/components/button.tsx +58 -0
  56. package/src/internal/components/card.tsx +103 -0
  57. package/src/internal/components/checkbox.tsx +29 -0
  58. package/src/internal/components/collapsible.tsx +21 -0
  59. package/src/internal/components/combobox.tsx +298 -0
  60. package/src/internal/components/context-menu.tsx +272 -0
  61. package/src/internal/components/dialog.tsx +162 -0
  62. package/src/internal/components/direction.tsx +6 -0
  63. package/src/internal/components/drawer.tsx +230 -0
  64. package/src/internal/components/dropdown-menu.tsx +278 -0
  65. package/src/internal/components/empty.tsx +104 -0
  66. package/src/internal/components/field.tsx +238 -0
  67. package/src/internal/components/hover-card.tsx +52 -0
  68. package/src/internal/components/input-group.tsx +158 -0
  69. package/src/internal/components/input.tsx +20 -0
  70. package/src/internal/components/item.tsx +201 -0
  71. package/src/internal/components/kbd.tsx +26 -0
  72. package/src/internal/components/label.tsx +20 -0
  73. package/src/internal/components/marker.tsx +71 -0
  74. package/src/internal/components/menubar.tsx +280 -0
  75. package/src/internal/components/message.tsx +92 -0
  76. package/src/internal/components/native-select.tsx +61 -0
  77. package/src/internal/components/navigation-menu.tsx +168 -0
  78. package/src/internal/components/number-field.tsx +68 -0
  79. package/src/internal/components/pagination.tsx +130 -0
  80. package/src/internal/components/popover.tsx +91 -0
  81. package/src/internal/components/progress.tsx +83 -0
  82. package/src/internal/components/radio-group.tsx +38 -0
  83. package/src/internal/components/resizable.tsx +50 -0
  84. package/src/internal/components/scroll-area.tsx +88 -0
  85. package/src/internal/components/select.tsx +191 -0
  86. package/src/internal/components/separator.tsx +25 -0
  87. package/src/internal/components/sheet.tsx +140 -0
  88. package/src/internal/components/sidebar.tsx +723 -0
  89. package/src/internal/components/skeleton.tsx +13 -0
  90. package/src/internal/components/slider.tsx +56 -0
  91. package/src/internal/components/spinner.tsx +10 -0
  92. package/src/internal/components/switch.tsx +32 -0
  93. package/src/internal/components/table.tsx +116 -0
  94. package/src/internal/components/tabs.tsx +82 -0
  95. package/src/internal/components/textarea.tsx +18 -0
  96. package/src/internal/components/toast.tsx +235 -0
  97. package/src/internal/components/toggle-group.tsx +89 -0
  98. package/src/internal/components/toggle.tsx +45 -0
  99. package/src/internal/components/tooltip.tsx +67 -0
  100. package/src/internal/hooks/use-mobile.ts +19 -0
  101. package/src/internal/lib/utils.ts +6 -0
  102. package/src/internal/styles/globals.css +73 -0
  103. package/src/lib/backend.ts +85 -0
  104. package/src/lib/document.ts +46 -0
  105. package/src/lib/geometry.ts +272 -0
  106. package/src/lib/i18n.ts +54 -0
  107. package/src/lib/queries.ts +110 -0
  108. package/src/lib/store.ts +191 -0
  109. package/src/lib/translation.ts +35 -0
  110. package/src/locales/en-US/translation.json +536 -0
  111. package/src/locales/es-ES/translation.json +536 -0
  112. package/src/locales/ja-JP/translation.json +536 -0
  113. package/src/locales/ko-KR/translation.json +536 -0
  114. package/src/locales/pt-BR/translation.json +536 -0
  115. package/src/locales/ru-RU/translation.json +536 -0
  116. package/src/locales/tr-TR/translation.json +536 -0
  117. package/src/locales/zh-CN/translation.json +536 -0
  118. package/src/locales/zh-TW/translation.json +536 -0
  119. package/src/navigation.ts +28 -0
  120. package/src/providers.tsx +132 -0
  121. package/src/shenron-pipeline-env.d.ts +14 -0
  122. package/src/styles.css +246 -0
  123. package/tsconfig.json +21 -0
@@ -0,0 +1,336 @@
1
+ 'use client'
2
+
3
+ import {
4
+ ArrowLeft,
5
+ Monitor,
6
+ Moon,
7
+ Sun,
8
+ } from 'lucide-react'
9
+ import { useTheme } from 'next-themes'
10
+ import { useCallback, useEffect, useRef, useState } from 'react'
11
+ import { useTranslation } from 'react-i18next'
12
+
13
+ import { PipelinePreferences } from '@aphrody/koharu-ui/components/preferences/PipelinePreferences'
14
+ import {
15
+ PreferencePage,
16
+ PreferenceRow,
17
+ PreferenceSection,
18
+ } from '@aphrody/koharu-ui/components/preferences/PreferenceFields'
19
+ import { ProviderPreferences } from '@aphrody/koharu-ui/components/preferences/ProviderPreferences'
20
+ import { TranslationPreferences } from '@aphrody/koharu-ui/components/preferences/TranslationPreferences'
21
+ import { TypesettingPreferences } from '@aphrody/koharu-ui/components/preferences/TypesettingPreferences'
22
+ import { refreshPreferences, refreshTranslationModels, savePreferences } from '@aphrody/koharu-ui/lib/backend'
23
+ import { supportedLanguages } from '@aphrody/koharu-ui/lib/i18n'
24
+ import { receivePreferences, useKoharuStore, type ShortcutAction } from '@aphrody/koharu-ui/lib/store'
25
+ import { settingsTabs, type SettingsTab } from '@aphrody/koharu-ui/navigation'
26
+ import {
27
+ type PipelineConfig,
28
+ type Preferences,
29
+ type ProviderPreferences as ProviderSettings,
30
+ type TypesettingConfig,
31
+ } from '@aphrody/koharu-bridge/protocol'
32
+ import { Button } from '../../internal/components/button'
33
+ import { Input } from '@aphrody/m3-primitives'
34
+ import { ScrollArea } from '../../internal/components/scroll-area'
35
+ import {
36
+ Select,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectTrigger,
40
+ SelectValue,
41
+ } from '../../internal/components/select'
42
+
43
+ export function SettingsPage() {
44
+ const { t } = useTranslation()
45
+ const open = useKoharuStore((state) => state.settingsOpen)
46
+ const setOpen = useKoharuStore((state) => state.setSettingsOpen)
47
+ const preferences = useKoharuStore((state) => state.preferences)
48
+ const translationModels = useKoharuStore((state) => state.translationModels)
49
+ const [tab, setTab] = useState<SettingsTab>('appearance')
50
+ const [pipeline, setPipeline] = useState<PipelineConfig | null>(preferences?.pipeline ?? null)
51
+ const [providers, setProviders] = useState<ProviderSettings | null>(
52
+ preferences?.providers ?? null,
53
+ )
54
+ const [typesetting, setTypesetting] = useState<TypesettingConfig | null>(
55
+ preferences?.typesetting ?? null,
56
+ )
57
+ const translation = pipeline?.translation ?? null
58
+ const lastSaved = useRef<string | null>(null)
59
+ const lastSavedProviders = useRef<string | null>(null)
60
+ const saveGeneration = useRef(0)
61
+ const saveQueue = useRef<Promise<void>>(Promise.resolve())
62
+ const lastPending = useRef<{ serialized: string; promise: Promise<Preferences> } | null>(null)
63
+ const currentDraft = useRef<string | null>(null)
64
+ currentDraft.current =
65
+ pipeline && providers && typesetting ? JSON.stringify([pipeline, providers, typesetting]) : null
66
+
67
+ const saveDraft = useCallback(
68
+ async (
69
+ pipeline: PipelineConfig,
70
+ providers: ProviderSettings,
71
+ typesetting: TypesettingConfig,
72
+ ) => {
73
+ const serialized = JSON.stringify([pipeline, providers, typesetting])
74
+ if (serialized === lastSaved.current) {
75
+ const pending = lastPending.current
76
+ if (pending?.serialized === serialized) await pending.promise
77
+ return
78
+ }
79
+ lastSaved.current = serialized
80
+ const serializedProviders = JSON.stringify(providers)
81
+ const providersChanged = serializedProviders !== lastSavedProviders.current
82
+ const generation = ++saveGeneration.current
83
+ const pending = saveQueue.current
84
+ .catch(() => undefined)
85
+ .then(() => savePreferences(pipeline, providers, typesetting))
86
+ lastPending.current = { serialized, promise: pending }
87
+ saveQueue.current = pending.then(
88
+ () => undefined,
89
+ () => undefined,
90
+ )
91
+ let saved: Preferences
92
+ try {
93
+ saved = await pending
94
+ } catch (error) {
95
+ if (lastSaved.current === serialized) lastSaved.current = null
96
+ throw error
97
+ }
98
+ if (generation !== saveGeneration.current || currentDraft.current !== serialized) return
99
+ receivePreferences(saved)
100
+ if (providersChanged) {
101
+ lastSavedProviders.current = serializedProviders
102
+ void refreshTranslationModels(true).catch(() => undefined)
103
+ }
104
+ },
105
+ [],
106
+ )
107
+
108
+ useEffect(() => {
109
+ if (!open) return
110
+ void refreshPreferences().catch(() => undefined)
111
+ void refreshTranslationModels().catch(() => undefined)
112
+ }, [open])
113
+
114
+ useEffect(() => {
115
+ if (!open) return
116
+ setPipeline(preferences?.pipeline ?? null)
117
+ setProviders(preferences?.providers ?? null)
118
+ setTypesetting(preferences?.typesetting ?? null)
119
+ if (preferences) {
120
+ lastSaved.current = JSON.stringify([
121
+ preferences.pipeline,
122
+ preferences.providers,
123
+ preferences.typesetting,
124
+ ])
125
+ lastSavedProviders.current = JSON.stringify(preferences.providers)
126
+ }
127
+ }, [open, preferences])
128
+
129
+ useEffect(() => {
130
+ if (!open || !pipeline || !providers || !typesetting) return
131
+ const serialized = JSON.stringify([pipeline, providers, typesetting])
132
+ if (serialized === lastSaved.current) return
133
+ const timeout = window.setTimeout(() => {
134
+ void saveDraft(pipeline, providers, typesetting).catch(() => undefined)
135
+ }, 260)
136
+ return () => window.clearTimeout(timeout)
137
+ }, [open, pipeline, providers, saveDraft, typesetting])
138
+
139
+ if (!open) return null
140
+
141
+ return (
142
+ <section className='settings-page flex min-h-0 flex-1 bg-[var(--surface-sidebar)] text-foreground'>
143
+ <nav className='flex w-64 shrink-0 flex-col bg-[var(--surface-sidebar)] px-3 py-4'>
144
+ <Button
145
+ type='button'
146
+ variant='ghost'
147
+ className='mb-5 h-9 justify-start gap-2 rounded-lg px-2 text-[12px] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground'
148
+ onClick={() => {
149
+ if (!pipeline || !providers || !typesetting) {
150
+ setOpen(false)
151
+ return
152
+ }
153
+ void saveDraft(pipeline, providers, typesetting)
154
+ .then(() => setOpen(false))
155
+ .catch(() => undefined)
156
+ }}
157
+ >
158
+ <ArrowLeft className='size-4' /> {t('settings.backToEditor')}
159
+ </Button>
160
+ <p className='mb-2 px-2 text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase'>
161
+ {t('settings.title')}
162
+ </p>
163
+ <div className='grid gap-1'>
164
+ {settingsTabs.map(([id, Icon]) => (
165
+ <Button
166
+ key={id}
167
+ type='button'
168
+ variant='ghost'
169
+ size='sm'
170
+ data-active={tab === id}
171
+ className='h-10 w-full justify-start gap-3 rounded-lg px-3 text-left text-[12px] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground'
172
+ onClick={() => setTab(id)}
173
+ >
174
+ <Icon className='size-4' /> {t(`settings.tabs.${id}`)}
175
+ </Button>
176
+ ))}
177
+ </div>
178
+ </nav>
179
+ <main className='relative z-10 flex min-w-0 flex-1 flex-col overflow-hidden rounded-tl-2xl bg-[var(--surface-canvas)] shadow-[var(--shadow-content)]'>
180
+ <header className='flex h-14 shrink-0 items-center border-b border-border/80 px-8'>
181
+ <h1 className='text-[13px] font-semibold tracking-[-0.02em]'>{t('settings.title')}</h1>
182
+ </header>
183
+ <ScrollArea className='min-h-0 flex-1'>
184
+ <div className='mx-auto w-full max-w-4xl px-10 py-10'>
185
+ {tab === 'appearance' && <AppearancePreferences />}
186
+ {tab === 'pipeline' &&
187
+ (pipeline ? (
188
+ <PipelinePreferences value={pipeline} onChange={setPipeline} />
189
+ ) : (
190
+ <LoadingPreferences />
191
+ ))}
192
+ {tab === 'providers' &&
193
+ (providers ? (
194
+ <ProviderPreferences value={providers} onChange={setProviders} />
195
+ ) : (
196
+ <LoadingPreferences />
197
+ ))}
198
+ {tab === 'translation' &&
199
+ (translation ? (
200
+ <TranslationPreferences
201
+ value={translation}
202
+ modelChoices={translationModels}
203
+ providers={providers?.entries ?? []}
204
+ languages={preferences?.languages ?? []}
205
+ onChange={(translation) =>
206
+ setPipeline((current) => (current ? { ...current, translation } : current))
207
+ }
208
+ />
209
+ ) : (
210
+ <LoadingPreferences />
211
+ ))}
212
+ {tab === 'typesetting' &&
213
+ (typesetting ? (
214
+ <TypesettingPreferences value={typesetting} onChange={setTypesetting} />
215
+ ) : (
216
+ <LoadingPreferences />
217
+ ))}
218
+ {tab === 'shortcuts' && <ShortcutPreferences />}
219
+ </div>
220
+ </ScrollArea>
221
+ </main>
222
+ </section>
223
+ )
224
+ }
225
+
226
+ function AppearancePreferences() {
227
+ const { theme, setTheme } = useTheme()
228
+ const { t, i18n } = useTranslation()
229
+ const themes = [
230
+ ['light', Sun],
231
+ ['dark', Moon],
232
+ ['system', Monitor],
233
+ ] as const
234
+
235
+ return (
236
+ <PreferencePage
237
+ title={t('settings.appearance.title')}
238
+ description={t('settings.appearance.description')}
239
+ >
240
+ <PreferenceSection title={t('settings.appearance.interface')}>
241
+ <PreferenceRow
242
+ title={t('settings.appearance.theme')}
243
+ description={t('settings.appearance.themeDescription')}
244
+ >
245
+ <div className='grid grid-cols-3 rounded-xl border border-input bg-background p-0.5'>
246
+ {themes.map(([value, Icon]) => (
247
+ <Button
248
+ key={value}
249
+ type='button'
250
+ variant='ghost'
251
+ size='sm'
252
+ data-active={theme === value}
253
+ className='h-9 gap-1.5 text-[10px] text-muted-foreground hover:text-foreground data-[active=true]:bg-primary data-[active=true]:text-primary-foreground data-[active=true]:hover:bg-primary/90 data-[active=true]:hover:text-primary-foreground'
254
+ onClick={() => setTheme(value)}
255
+ >
256
+ <Icon className='size-3.5' /> {t(`settings.appearance.themes.${value}`)}
257
+ </Button>
258
+ ))}
259
+ </div>
260
+ </PreferenceRow>
261
+ <PreferenceRow title={t('settings.appearance.language')}>
262
+ <Select
263
+ value={i18n.language}
264
+ items={Object.fromEntries(
265
+ supportedLanguages.map((language) => [language, t(`languages.${language}`)]),
266
+ )}
267
+ onValueChange={(language) => language && i18n.changeLanguage(language)}
268
+ >
269
+ <SelectTrigger className='h-8 w-full text-[11px]'>
270
+ <SelectValue />
271
+ </SelectTrigger>
272
+ <SelectContent>
273
+ {supportedLanguages.map((language) => (
274
+ <SelectItem key={language} value={language}>
275
+ {t(`languages.${language}`)}
276
+ </SelectItem>
277
+ ))}
278
+ </SelectContent>
279
+ </Select>
280
+ </PreferenceRow>
281
+ </PreferenceSection>
282
+ </PreferencePage>
283
+ )
284
+ }
285
+
286
+ function ShortcutPreferences() {
287
+ const { t } = useTranslation()
288
+ const shortcuts = useKoharuStore((state) => state.shortcuts)
289
+ const setShortcut = useKoharuStore((state) => state.setShortcut)
290
+ const actions: ShortcutAction[] = [
291
+ 'select',
292
+ 'text',
293
+ 'draw',
294
+ 'eraser',
295
+ 'color_picker',
296
+ 'remove',
297
+ 'pan',
298
+ 'fit',
299
+ ]
300
+ return (
301
+ <PreferencePage
302
+ title={t('settings.shortcuts.title')}
303
+ description={t('settings.shortcuts.description')}
304
+ >
305
+ <PreferenceSection title={t('settings.shortcuts.tools')}>
306
+ {actions.map((action) => (
307
+ <PreferenceRow key={action} title={t(shortcutKeys[action])}>
308
+ <Input
309
+ aria-label={t('settings.shortcuts.inputLabel', { action: t(shortcutKeys[action]) })}
310
+ maxLength={1}
311
+ value={shortcuts[action]}
312
+ className='ml-auto h-8 w-14 text-center text-[12px] uppercase'
313
+ onChange={(event) => setShortcut(action, event.currentTarget.value)}
314
+ />
315
+ </PreferenceRow>
316
+ ))}
317
+ </PreferenceSection>
318
+ </PreferencePage>
319
+ )
320
+ }
321
+
322
+ function LoadingPreferences() {
323
+ const { t } = useTranslation()
324
+ return <p className='py-10 text-[12px] text-muted-foreground'>{t('settings.loading')}</p>
325
+ }
326
+
327
+ const shortcutKeys: Record<ShortcutAction, string> = {
328
+ select: 'tools.select',
329
+ text: 'tools.text',
330
+ draw: 'tools.draw',
331
+ eraser: 'tools.eraser',
332
+ color_picker: 'tools.color_picker',
333
+ remove: 'tools.remove',
334
+ pan: 'tools.pan',
335
+ fit: 'canvas.fit',
336
+ }
@@ -0,0 +1,187 @@
1
+ 'use client'
2
+
3
+ import { ChevronDown } from 'lucide-react'
4
+ import { useMemo, useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { ModelPicker } from '@aphrody/koharu-ui/components/controls/ModelPicker'
8
+ import { GenerationPreferences } from '@aphrody/koharu-ui/components/preferences/GenerationPreferences'
9
+ import {
10
+ PreferencePage,
11
+ PreferenceRow,
12
+ PreferenceSection,
13
+ } from '@aphrody/koharu-ui/components/preferences/PreferenceFields'
14
+ import { modelKey, orderedLanguageChoices, providerName } from '@aphrody/koharu-ui/lib/translation'
15
+ import type {
16
+ LanguageChoice,
17
+ Model,
18
+ ProviderPreference,
19
+ TranslationConfig as TranslationSettings,
20
+ } from '@aphrody/koharu-bridge/protocol'
21
+ import { Badge } from '@aphrody/m3-primitives'
22
+ import { Popover, PopoverContent, PopoverTrigger } from '../../internal/components/popover'
23
+ import {
24
+ Select,
25
+ SelectContent,
26
+ SelectItem,
27
+ SelectTrigger,
28
+ SelectValue,
29
+ } from '../../internal/components/select'
30
+ import { Textarea } from '../../internal/components/textarea'
31
+
32
+ export function TranslationPreferences({
33
+ value,
34
+ modelChoices,
35
+ providers,
36
+ languages,
37
+ onChange,
38
+ }: {
39
+ value: TranslationSettings
40
+ modelChoices: Model[]
41
+ providers: ProviderPreference[]
42
+ languages: LanguageChoice[]
43
+ onChange: (value: TranslationSettings) => void
44
+ }) {
45
+ const { t } = useTranslation()
46
+ const [modelOpen, setModelOpen] = useState(false)
47
+ const selected =
48
+ modelChoices.find((candidate) => modelKey(candidate) === modelKey(value.model)) ?? null
49
+ const current: Model = selected ?? {
50
+ ...value.model,
51
+ model: value.model.model ?? null,
52
+ name: value.model.model ?? providerName(providers, value.model.provider),
53
+ quantizations: [],
54
+ vision: value.model.vision ?? false,
55
+ reasoning: value.model.reasoning ?? false,
56
+ }
57
+ const choices = selected ? modelChoices : [current, ...modelChoices]
58
+ const quantization =
59
+ current.quantizations.find((quantization) => quantization.id === value.model.quantization) ??
60
+ current.quantizations[0]
61
+ const languageChoices = useMemo(() => orderedLanguageChoices(languages), [languages])
62
+ return (
63
+ <PreferencePage
64
+ title={t('settings.translation.title')}
65
+ description={t('settings.translation.description')}
66
+ >
67
+ <PreferenceSection
68
+ title={t('settings.translation.model')}
69
+ description={t('settings.translation.modelDescription')}
70
+ >
71
+ <PreferenceRow
72
+ title={t('settings.translation.translationModel')}
73
+ description={t('settings.translation.translationModelDescription')}
74
+ >
75
+ <Popover open={modelOpen} onOpenChange={setModelOpen}>
76
+ <PopoverTrigger
77
+ type='button'
78
+ aria-label={t('settings.translation.translationModel')}
79
+ className='flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 text-[11px] transition-colors outline-none hover:bg-foreground/[0.03] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50'
80
+ >
81
+ <span className='min-w-0 flex-1 text-left'>
82
+ <ModelLabel
83
+ model={current}
84
+ providers={providers}
85
+ quantization={quantization?.name ?? value.model.quantization}
86
+ />
87
+ </span>
88
+ <ChevronDown className='size-3.5 shrink-0 text-muted-foreground' />
89
+ </PopoverTrigger>
90
+ <PopoverContent
91
+ align='start'
92
+ sideOffset={4}
93
+ className='w-(--anchor-width) min-w-64 gap-0 overflow-hidden rounded-xl border border-border/50 p-1 shadow-sm ring-0'
94
+ >
95
+ <ModelPicker
96
+ value={value.model}
97
+ models={choices}
98
+ providers={providers}
99
+ onBack={() => setModelOpen(false)}
100
+ onSelect={(model) => {
101
+ onChange({
102
+ ...value,
103
+ model,
104
+ })
105
+ setModelOpen(false)
106
+ }}
107
+ />
108
+ </PopoverContent>
109
+ </Popover>
110
+ </PreferenceRow>
111
+ </PreferenceSection>
112
+
113
+ <GenerationPreferences
114
+ value={value.generation}
115
+ onChange={(generation) => onChange({ ...value, generation })}
116
+ />
117
+
118
+ <PreferenceSection title={t('settings.translation.output')}>
119
+ <PreferenceRow
120
+ title={t('model.targetLanguage')}
121
+ description={t('settings.translation.targetLanguageDescription')}
122
+ >
123
+ <Select
124
+ value={value.target_language}
125
+ items={Object.fromEntries(
126
+ languageChoices.map((language) => [language.tag, language.name]),
127
+ )}
128
+ onValueChange={(target_language) =>
129
+ target_language && onChange({ ...value, target_language })
130
+ }
131
+ >
132
+ <SelectTrigger
133
+ aria-label={t('model.targetLanguage')}
134
+ className='h-8 w-full text-[11px]'
135
+ >
136
+ <SelectValue />
137
+ </SelectTrigger>
138
+ <SelectContent>
139
+ {languageChoices.map((language) => (
140
+ <SelectItem key={language.tag} value={language.tag}>
141
+ {language.name}
142
+ </SelectItem>
143
+ ))}
144
+ </SelectContent>
145
+ </Select>
146
+ </PreferenceRow>
147
+ <PreferenceRow
148
+ title={t('model.instructions')}
149
+ description={t('settings.translation.instructionsDescription')}
150
+ align='start'
151
+ >
152
+ <Textarea
153
+ aria-label={t('settings.translation.instructionsLabel')}
154
+ value={value.instructions ?? ''}
155
+ className='field-sizing-fixed min-h-24 resize-y overflow-y-auto text-[12px] leading-5'
156
+ placeholder={t('settings.translation.instructionsPlaceholder')}
157
+ onChange={(event) =>
158
+ onChange({ ...value, instructions: event.currentTarget.value || null })
159
+ }
160
+ />
161
+ </PreferenceRow>
162
+ </PreferenceSection>
163
+ </PreferencePage>
164
+ )
165
+ }
166
+
167
+ function ModelLabel({
168
+ model,
169
+ providers,
170
+ quantization,
171
+ }: {
172
+ model: Model
173
+ providers: ProviderPreference[]
174
+ quantization: string | null | undefined
175
+ }) {
176
+ return (
177
+ <span className='flex min-w-0 items-center gap-2'>
178
+ <Badge variant='outline' className='shrink-0 px-1.5 py-0 text-[9px] font-medium'>
179
+ {providerName(providers, model.provider)}
180
+ </Badge>
181
+ <span className='truncate'>
182
+ {model.name}
183
+ {quantization && <span className='text-muted-foreground'> · {quantization}</span>}
184
+ </span>
185
+ </span>
186
+ )
187
+ }
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ import { Plus, Trash2 } from 'lucide-react'
4
+ import { useMemo } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { FontPicker } from '@aphrody/koharu-ui/components/controls/FontPicker'
8
+ import { PreferencePage } from '@aphrody/koharu-ui/components/preferences/PreferenceFields'
9
+ import { useFonts } from '@aphrody/koharu-ui/lib/queries'
10
+ import type { TypesettingConfig } from '@aphrody/koharu-bridge/protocol'
11
+ import { Button } from '../../internal/components/button'
12
+
13
+ export function TypesettingPreferences({
14
+ value,
15
+ onChange,
16
+ }: {
17
+ value: TypesettingConfig
18
+ onChange: (value: TypesettingConfig) => void
19
+ }) {
20
+ const { t } = useTranslation()
21
+ const fonts = useFonts()
22
+ const families = fonts.data ?? []
23
+ const fontFamilies = useMemo(() => value.font_families ?? [], [value.font_families])
24
+ const selectedNames = useMemo(() => new Set(fontFamilies.map(normalizeFontName)), [fontFamilies])
25
+ const availableFamilies = families.filter(
26
+ (family) => !selectedNames.has(normalizeFontName(family.name)),
27
+ )
28
+
29
+ const replaceFamily = (index: number, family: string) => {
30
+ const next = [...fontFamilies]
31
+ next[index] = family
32
+ onChange({ ...value, font_families: next })
33
+ }
34
+
35
+ const removeFamily = (index: number) => {
36
+ onChange({
37
+ ...value,
38
+ font_families: fontFamilies.filter((_, candidate) => candidate !== index),
39
+ })
40
+ }
41
+
42
+ return (
43
+ <PreferencePage
44
+ title={t('settings.typesetting.title')}
45
+ description={t('settings.typesetting.description')}
46
+ >
47
+ <section aria-labelledby='typesetting-font-stack'>
48
+ <header className='mb-3'>
49
+ <h3 id='typesetting-font-stack' className='text-[12px] font-semibold'>
50
+ {t('settings.typesetting.fontFallback')}
51
+ </h3>
52
+ <p className='mt-0.5 text-[11px] leading-4 text-muted-foreground'>
53
+ {t('settings.typesetting.fontFallbackDescription')}
54
+ </p>
55
+ </header>
56
+ <div className='overflow-hidden rounded-xl border border-border/80 bg-[var(--surface-panel)]'>
57
+ {fontFamilies.length > 0 ? (
58
+ <ol
59
+ className='divide-y divide-border/80'
60
+ aria-label={t('settings.typesetting.defaultFamilies')}
61
+ >
62
+ {fontFamilies.map((family, index) => {
63
+ const choices = families.filter(
64
+ (candidate) =>
65
+ normalizeFontName(candidate.name) === normalizeFontName(family) ||
66
+ !selectedNames.has(normalizeFontName(candidate.name)),
67
+ )
68
+ return (
69
+ <li
70
+ key={`${normalizeFontName(family)}-${index}`}
71
+ className='group flex min-w-0 items-center gap-3 px-3 py-2'
72
+ >
73
+ <span
74
+ aria-hidden='true'
75
+ className='flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-[9px] font-semibold text-muted-foreground tabular-nums'
76
+ >
77
+ {String(index + 1).padStart(2, '0')}
78
+ </span>
79
+ <div className='min-w-0 flex-1'>
80
+ <FontPicker
81
+ value={family}
82
+ families={choices}
83
+ ariaLabel={t('settings.typesetting.familyLabel', { index: index + 1 })}
84
+ onChange={(next) => replaceFamily(index, next)}
85
+ />
86
+ </div>
87
+ <Button
88
+ type='button'
89
+ variant='ghost'
90
+ size='icon-sm'
91
+ aria-label={t('settings.typesetting.removeFamily', { family })}
92
+ title={t('settings.typesetting.removeFamily', { family })}
93
+ className='shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
94
+ onClick={() => removeFamily(index)}
95
+ >
96
+ <Trash2 className='size-3.5' />
97
+ </Button>
98
+ </li>
99
+ )
100
+ })}
101
+ </ol>
102
+ ) : (
103
+ <p role='status' className='px-4 py-5 text-[11px] text-muted-foreground'>
104
+ {t('settings.typesetting.empty')}
105
+ </p>
106
+ )}
107
+ <div className='flex min-w-0 items-center gap-3 border-t border-border/80 bg-muted/20 px-3 py-2'>
108
+ <span className='flex size-6 shrink-0 items-center justify-center text-muted-foreground'>
109
+ <Plus className='size-3.5' />
110
+ </span>
111
+ <div className='min-w-0 flex-1'>
112
+ <FontPicker
113
+ value=''
114
+ families={availableFamilies}
115
+ disabled={fonts.isPending || availableFamilies.length === 0}
116
+ ariaLabel={t('settings.typesetting.addFamily')}
117
+ placeholder={
118
+ fonts.isPending
119
+ ? t('settings.typesetting.loadingFonts')
120
+ : t('settings.typesetting.addFamily')
121
+ }
122
+ onChange={(family) =>
123
+ onChange({ ...value, font_families: [...fontFamilies, family] })
124
+ }
125
+ />
126
+ </div>
127
+ <span aria-hidden='true' className='size-7 shrink-0' />
128
+ </div>
129
+ </div>
130
+ {fonts.isError && (
131
+ <p role='status' className='mt-2 text-[10px] leading-4 text-destructive'>
132
+ {t('settings.typesetting.loadError')}
133
+ </p>
134
+ )}
135
+ </section>
136
+ </PreferencePage>
137
+ )
138
+ }
139
+
140
+ function normalizeFontName(value: string): string {
141
+ return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase()
142
+ }