@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,322 @@
1
+ 'use client'
2
+
3
+ import { openUrl } from '@aphrody/plugin-opener'
4
+ import { FilePlus2, FolderOpen, LoaderCircle, Settings } from 'lucide-react'
5
+ import { useState, type ComponentProps } from 'react'
6
+ import { useTranslation } from 'react-i18next'
7
+
8
+ import { AboutDialog } from '@aphrody/koharu-ui/components/app/AboutDialog'
9
+ import { useMacOS, WindowControls } from '@aphrody/koharu-ui/components/app/WindowChrome'
10
+ import { call } from '@aphrody/koharu-ui/lib/backend'
11
+ import { selectableLayer } from '@aphrody/koharu-ui/lib/geometry'
12
+ import {
13
+ pageKey,
14
+ pagesKey,
15
+ projectKey,
16
+ refresh,
17
+ useCommand,
18
+ useImportPages,
19
+ usePage,
20
+ usePages,
21
+ useProject,
22
+ } from '@aphrody/koharu-ui/lib/queries'
23
+ import { useKoharuStore } from '@aphrody/koharu-ui/lib/store'
24
+ import { titleBarMenuSections } from '@aphrody/koharu-ui/navigation'
25
+ import { commands, type Operation, type Scope, type Stage } from '@aphrody/koharu-bridge/protocol'
26
+ import {
27
+ Menubar,
28
+ MenubarContent as UiMenubarContent,
29
+ MenubarItem as UiMenubarItem,
30
+ MenubarMenu,
31
+ MenubarSeparator as UiMenubarSeparator,
32
+ MenubarShortcut as UiMenubarShortcut,
33
+ MenubarSub,
34
+ MenubarSubContent,
35
+ MenubarSubTrigger,
36
+ MenubarTrigger as UiMenubarTrigger,
37
+ } from '../../internal/components/menubar'
38
+ import { cn } from '../../internal/lib/utils'
39
+
40
+ export function TitleBar({ windowControls = true }: { windowControls?: boolean } = {}) {
41
+ const { t } = useTranslation()
42
+ const [aboutOpen, setAboutOpen] = useState(false)
43
+ const macOS = useMacOS()
44
+ const project = useProject().data
45
+ const pagesQuery = usePages(Boolean(project))
46
+ const pageQuery = usePage(Boolean(project))
47
+ const pages = project ? (pagesQuery.data ?? []) : []
48
+ const page = project ? (pageQuery.data ?? null) : null
49
+ const selectedPages = useKoharuStore((state) => state.selectedPages)
50
+ const selectedLayers = useKoharuStore((state) => state.selectedLayers)
51
+ const selectLayers = useKoharuStore((state) => state.selectLayers)
52
+ const setSettingsOpen = useKoharuStore((state) => state.setSettingsOpen)
53
+ const requestCanvasFit = useKoharuStore((state) => state.requestCanvasFit)
54
+ const { importPages, importing } = useImportPages()
55
+ const { run: exportProject, busy: exporting } = useCommand(
56
+ ['export-project'],
57
+ commands.export,
58
+ 'menu.export',
59
+ )
60
+
61
+ const run = (scope: Scope, operation: Operation = { operation: 'full' }) =>
62
+ void call(commands.process, scope, operation).catch(() => undefined)
63
+
64
+ const closeProject = () => void call(commands.closeProject).catch(() => undefined)
65
+
66
+ return (
67
+ <>
68
+ <header
69
+ data-tauri-drag-region='deep'
70
+ className='relative flex h-10 shrink-0 items-center bg-[var(--surface-titlebar)] text-[12px]'
71
+ >
72
+ {macOS ? (
73
+ <div className='w-[84px] shrink-0' />
74
+ ) : (
75
+ <div className='relative z-10 flex h-full w-10 shrink-0 items-center justify-center rounded-br-lg'>
76
+ <img
77
+ className='pointer-events-none'
78
+ src='/icon.png'
79
+ alt='Koharu'
80
+ width={17}
81
+ height={17}
82
+ draggable={false}
83
+ />
84
+ </div>
85
+ )}
86
+ <Menubar className='relative z-10 h-full shrink-0 gap-0 border-0 bg-transparent p-0 shadow-none'>
87
+ <MenubarMenu>
88
+ <MenubarTrigger>{t(titleBarMenuSections.file)}</MenubarTrigger>
89
+ <MenubarContent>
90
+ <MenubarSub>
91
+ <MenubarSubTrigger
92
+ disabled={!project || importing}
93
+ aria-busy={importing}
94
+ className='min-h-8 gap-1.5 px-2 py-1 text-xs'
95
+ >
96
+ {importing && <LoaderCircle className='animate-spin' aria-hidden='true' />}
97
+ {importing ? t('navigator.importing') : t('menu.import')}
98
+ </MenubarSubTrigger>
99
+ <MenubarSubContent className='min-w-40 p-1'>
100
+ <MenubarItem disabled={importing} onClick={() => importPages('files')}>
101
+ <FilePlus2 />
102
+ {t('navigator.importFiles')}
103
+ </MenubarItem>
104
+ <MenubarItem disabled={importing} onClick={() => importPages('folder')}>
105
+ <FolderOpen />
106
+ {t('navigator.importFolder')}
107
+ </MenubarItem>
108
+ </MenubarSubContent>
109
+ </MenubarSub>
110
+ <MenubarSub>
111
+ <MenubarSubTrigger
112
+ disabled={!project || pages.length === 0 || exporting}
113
+ aria-busy={exporting}
114
+ className='min-h-8 gap-1.5 px-2 py-1 text-xs'
115
+ >
116
+ {exporting && <LoaderCircle className='animate-spin' aria-hidden='true' />}
117
+ {t('menu.export')}
118
+ </MenubarSubTrigger>
119
+ <MenubarSubContent className='min-w-40 p-1'>
120
+ {(['png', 'psd', 'cbz'] as const).map((format) => (
121
+ <MenubarItem
122
+ key={format}
123
+ disabled={exporting}
124
+ onClick={() => exportProject(format)}
125
+ >
126
+ {format.toUpperCase()}…
127
+ </MenubarItem>
128
+ ))}
129
+ </MenubarSubContent>
130
+ </MenubarSub>
131
+ <MenubarSeparator />
132
+ <MenubarItem disabled={!project} onClick={closeProject}>
133
+ {t('menu.closeProject')}
134
+ </MenubarItem>
135
+ <MenubarSeparator />
136
+ <MenubarItem onClick={() => setSettingsOpen(true)}>
137
+ <Settings />
138
+ {t('menu.settings')}
139
+ </MenubarItem>
140
+ </MenubarContent>
141
+ </MenubarMenu>
142
+
143
+ <MenubarMenu>
144
+ <MenubarTrigger>{t(titleBarMenuSections.edit)}</MenubarTrigger>
145
+ <MenubarContent>
146
+ <MenubarItem
147
+ disabled={!project?.can_undo}
148
+ onClick={() =>
149
+ void call(commands.undo)
150
+ .then(() => refresh(projectKey, pagesKey, pageKey))
151
+ .catch(() => undefined)
152
+ }
153
+ >
154
+ {t('menu.undo')}
155
+ <MenubarShortcut>Ctrl+Z</MenubarShortcut>
156
+ </MenubarItem>
157
+ <MenubarItem
158
+ disabled={!project?.can_redo}
159
+ onClick={() =>
160
+ void call(commands.redo)
161
+ .then(() => refresh(projectKey, pagesKey, pageKey))
162
+ .catch(() => undefined)
163
+ }
164
+ >
165
+ {t('menu.redo')}
166
+ <MenubarShortcut>Ctrl+Shift+Z</MenubarShortcut>
167
+ </MenubarItem>
168
+ <MenubarSeparator />
169
+ <MenubarItem
170
+ disabled={!page}
171
+ onClick={() =>
172
+ selectLayers(page?.layers.filter(selectableLayer).map((layer) => layer.id) ?? [])
173
+ }
174
+ >
175
+ {t('menu.selectAllLayers')}
176
+ <MenubarShortcut>Ctrl+A</MenubarShortcut>
177
+ </MenubarItem>
178
+ <MenubarItem
179
+ disabled={selectedLayers.length === 0}
180
+ variant='destructive'
181
+ onClick={() =>
182
+ void call(commands.deleteLayers, selectedLayers)
183
+ .then(() => refresh(projectKey, pagesKey, pageKey))
184
+ .catch(() => undefined)
185
+ }
186
+ >
187
+ {t('menu.delete')}
188
+ <MenubarShortcut>Del</MenubarShortcut>
189
+ </MenubarItem>
190
+ </MenubarContent>
191
+ </MenubarMenu>
192
+
193
+ <MenubarMenu>
194
+ <MenubarTrigger>{t(titleBarMenuSections.process)}</MenubarTrigger>
195
+ <MenubarContent>
196
+ <MenubarItem
197
+ disabled={!project || pages.length === 0}
198
+ onClick={() => run({ scope: 'project' })}
199
+ >
200
+ {t('menu.processProject')}
201
+ </MenubarItem>
202
+ <MenubarItem
203
+ disabled={selectedPages.length === 0}
204
+ onClick={() => run({ scope: 'pages', value: selectedPages })}
205
+ >
206
+ {t('menu.processPages')}
207
+ </MenubarItem>
208
+ <MenubarItem
209
+ disabled={selectedLayers.length === 0}
210
+ onClick={() =>
211
+ run(
212
+ { scope: 'entities', value: selectedLayers },
213
+ { operation: 'stages', stages: ['ocr', 'translation'] },
214
+ )
215
+ }
216
+ >
217
+ {t('menu.processLayers')}
218
+ </MenubarItem>
219
+ <MenubarSeparator />
220
+ {(['detection', 'ocr', 'translation', 'inpainting'] as Stage[]).map((stage) => (
221
+ <MenubarItem
222
+ key={stage}
223
+ disabled={!project || pages.length === 0}
224
+ onClick={() => run({ scope: 'project' }, { operation: 'through', stage })}
225
+ >
226
+ {t('menu.runPhase', {
227
+ phase: t(`phase.${stage}`),
228
+ })}
229
+ </MenubarItem>
230
+ ))}
231
+ </MenubarContent>
232
+ </MenubarMenu>
233
+
234
+ <MenubarMenu>
235
+ <MenubarTrigger>{t(titleBarMenuSections.view)}</MenubarTrigger>
236
+ <MenubarContent>
237
+ <MenubarItem disabled={!page} onClick={requestCanvasFit}>
238
+ {t('menu.fit')}
239
+ </MenubarItem>
240
+ </MenubarContent>
241
+ </MenubarMenu>
242
+
243
+ <MenubarMenu>
244
+ <MenubarTrigger>{t(titleBarMenuSections.help)}</MenubarTrigger>
245
+ <MenubarContent>
246
+ <MenubarItem
247
+ onClick={() => void openUrl('https://discord.gg/mHvHkxGnUY').catch(() => undefined)}
248
+ >
249
+ {t('menu.discord')}
250
+ </MenubarItem>
251
+ <MenubarItem
252
+ onClick={() =>
253
+ void openUrl('https://github.com/koharu-rs/koharu').catch(() => undefined)
254
+ }
255
+ >
256
+ {t('menu.github')}
257
+ </MenubarItem>
258
+ <MenubarSeparator />
259
+ <MenubarItem onClick={() => setAboutOpen(true)}>{t('menu.about')}</MenubarItem>
260
+ </MenubarContent>
261
+ </MenubarMenu>
262
+ </Menubar>
263
+
264
+ <div className='min-w-0 flex-1' />
265
+ <div className='pointer-events-none absolute inset-y-0 left-1/2 flex max-w-[40vw] min-w-16 -translate-x-1/2 items-center justify-center px-3 text-[11px] text-muted-foreground select-none'>
266
+ {project ? (
267
+ <span className='truncate'>
268
+ <span className='font-medium text-foreground'>{project.name}</span>
269
+ {page && (
270
+ <>
271
+ <span className='mx-2'>/</span>
272
+ <span>{page.label}</span>
273
+ </>
274
+ )}
275
+ </span>
276
+ ) : (
277
+ <span>Koharu</span>
278
+ )}
279
+ </div>
280
+
281
+ {windowControls && !macOS && <WindowControls />}
282
+ </header>
283
+ <AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} />
284
+ </>
285
+ )
286
+ }
287
+
288
+ function MenubarTrigger({ className, ...props }: ComponentProps<typeof UiMenubarTrigger>) {
289
+ return (
290
+ <UiMenubarTrigger
291
+ className={cn(
292
+ 'h-6 px-1.5 text-[11px] text-muted-foreground transition-colors hover:bg-primary/10 hover:text-muted-foreground aria-expanded:bg-primary/10 aria-expanded:text-muted-foreground',
293
+ className,
294
+ )}
295
+ {...props}
296
+ />
297
+ )
298
+ }
299
+
300
+ function MenubarContent({ className, ...props }: ComponentProps<typeof UiMenubarContent>) {
301
+ return <UiMenubarContent className={cn('min-w-44 p-1', className)} {...props} />
302
+ }
303
+
304
+ function MenubarItem({ className, ...props }: ComponentProps<typeof UiMenubarItem>) {
305
+ return (
306
+ <UiMenubarItem
307
+ className={cn(
308
+ "min-h-8 gap-1.5 px-2 py-1 text-xs [&_svg:not([class*='size-'])]:size-3.5",
309
+ className,
310
+ )}
311
+ {...props}
312
+ />
313
+ )
314
+ }
315
+
316
+ function MenubarShortcut({ className, ...props }: ComponentProps<typeof UiMenubarShortcut>) {
317
+ return <UiMenubarShortcut className={cn('text-[11px]', className)} {...props} />
318
+ }
319
+
320
+ function MenubarSeparator({ className, ...props }: ComponentProps<typeof UiMenubarSeparator>) {
321
+ return <UiMenubarSeparator className={cn('my-0.5', className)} {...props} />
322
+ }
@@ -0,0 +1,143 @@
1
+ 'use client'
2
+
3
+ import { Download, RefreshCw } from 'lucide-react'
4
+ import { Trans, useTranslation } from 'react-i18next'
5
+ import ReactMarkdown from 'react-markdown'
6
+ import remarkGfm from 'remark-gfm'
7
+
8
+ import {
9
+ AlertDialog,
10
+ AlertDialogAction,
11
+ AlertDialogCancel,
12
+ AlertDialogContent,
13
+ AlertDialogDescription,
14
+ AlertDialogFooter,
15
+ AlertDialogHeader,
16
+ AlertDialogMedia,
17
+ AlertDialogTitle,
18
+ } from '../../internal/components/alert-dialog'
19
+ import { Progress } from '../../internal/components/progress'
20
+ import { ScrollArea } from '../../internal/components/scroll-area'
21
+
22
+ export interface UpdateInfo {
23
+ version: string
24
+ body?: string | null
25
+ }
26
+
27
+ export type UpdateState =
28
+ | { kind: 'available'; update: UpdateInfo }
29
+ | { kind: 'downloading'; update: UpdateInfo; downloaded: number; total: number | null }
30
+ | { kind: 'error'; update: UpdateInfo; message: string }
31
+
32
+ export function Updater({
33
+ state,
34
+ onDismiss,
35
+ onInstall,
36
+ }: {
37
+ state: UpdateState | null
38
+ onDismiss(): void
39
+ onInstall(update: UpdateInfo): void
40
+ }) {
41
+ const { t } = useTranslation()
42
+
43
+ if (!state) return null
44
+
45
+ const downloading = state.kind === 'downloading'
46
+ const percent =
47
+ downloading && state.total ? Math.min(100, (state.downloaded / state.total) * 100) : null
48
+
49
+ return (
50
+ <AlertDialog
51
+ open
52
+ onOpenChange={(open) => {
53
+ if (!open && !downloading) {
54
+ onDismiss()
55
+ }
56
+ }}
57
+ >
58
+ <AlertDialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden'>
59
+ <AlertDialogHeader className='flex shrink-0 flex-row items-start gap-4 text-left'>
60
+ <AlertDialogMedia className='mb-0 shrink-0'>
61
+ {state.kind === 'error' ? (
62
+ <RefreshCw className='size-5' />
63
+ ) : (
64
+ <Download className='size-5' />
65
+ )}
66
+ </AlertDialogMedia>
67
+ <div className='min-w-0 space-y-1.5'>
68
+ <AlertDialogTitle>
69
+ {state.kind === 'available'
70
+ ? t('updater.available.title')
71
+ : state.kind === 'downloading'
72
+ ? t('updater.downloading.title')
73
+ : t('updater.error.title')}
74
+ </AlertDialogTitle>
75
+ <AlertDialogDescription>
76
+ {state.kind === 'available' ? (
77
+ <Trans
78
+ i18nKey='updater.available.description'
79
+ values={{ version: state.update.version }}
80
+ components={{ strong: <strong className='font-medium text-foreground' /> }}
81
+ />
82
+ ) : state.kind === 'downloading' ? (
83
+ t('updater.downloading.subtitle', { version: state.update.version })
84
+ ) : (
85
+ t('updater.error.description')
86
+ )}
87
+ </AlertDialogDescription>
88
+ </div>
89
+ </AlertDialogHeader>
90
+
91
+ {state.kind === 'available' && (
92
+ <ScrollArea
93
+ className='h-64 max-h-[40dvh] min-h-0 rounded-lg bg-muted/45'
94
+ viewportClassName='p-3'
95
+ >
96
+ {state.update.body ? (
97
+ <div className='min-w-0 text-xs leading-5 break-words text-muted-foreground [&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:break-all [&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h1]:text-foreground [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:font-semibold [&_h3]:text-foreground [&_hr]:my-3 [&_hr]:border-border [&_img]:h-auto [&_img]:max-w-full [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:my-2 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-2 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:break-normal [&_table]:my-2 [&_table]:w-full [&_table]:table-fixed [&_td]:border [&_td]:border-border [&_td]:p-1.5 [&_th]:border [&_th]:border-border [&_th]:p-1.5 [&_th]:text-left [&_th]:font-medium [&_th]:text-foreground [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
98
+ <ReactMarkdown
99
+ remarkPlugins={[remarkGfm]}
100
+ components={{
101
+ a({ href, children }) {
102
+ return <a href={href} target='_blank' rel='noopener noreferrer'>{children}</a>
103
+ },
104
+ }}
105
+ >
106
+ {state.update.body}
107
+ </ReactMarkdown>
108
+ </div>
109
+ ) : (
110
+ <p className='text-xs leading-5 text-muted-foreground'>{t('updater.noNotes')}</p>
111
+ )}
112
+ </ScrollArea>
113
+ )}
114
+ {state.kind === 'downloading' && (
115
+ <Progress value={percent} aria-label={t('updater.downloading.title')}>
116
+ <span className='ml-auto text-xs text-muted-foreground tabular-nums'>
117
+ {percent === null ? '…' : `${Math.round(percent)}%`}
118
+ </span>
119
+ </Progress>
120
+ )}
121
+ {state.kind === 'error' && (
122
+ <ScrollArea
123
+ className='max-h-28'
124
+ viewportClassName='text-xs leading-5 text-muted-foreground'
125
+ >
126
+ <p>{state.message}</p>
127
+ </ScrollArea>
128
+ )}
129
+
130
+ {!downloading && (
131
+ <AlertDialogFooter className='shrink-0'>
132
+ <AlertDialogCancel>
133
+ {state.kind === 'available' ? t('updater.later') : t('updater.close')}
134
+ </AlertDialogCancel>
135
+ <AlertDialogAction onClick={() => onInstall(state.update)}>
136
+ {state.kind === 'available' ? t('updater.update') : t('updater.retry')}
137
+ </AlertDialogAction>
138
+ </AlertDialogFooter>
139
+ )}
140
+ </AlertDialogContent>
141
+ </AlertDialog>
142
+ )
143
+ }
@@ -0,0 +1,137 @@
1
+ 'use client'
2
+
3
+ import { getCurrentWindow } from '@aphrody/api/window'
4
+ import { Copy, Minus, Square, X } from 'lucide-react'
5
+ import { useEffect, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
6
+ import { useTranslation } from 'react-i18next'
7
+
8
+ const resizeHandles = [
9
+ { direction: 'North', className: 'top-0 right-2 left-2 h-1 cursor-n-resize' },
10
+ { direction: 'South', className: 'right-2 bottom-0 left-2 h-1 cursor-s-resize' },
11
+ { direction: 'East', className: 'top-2 right-0 bottom-2 w-1 cursor-e-resize' },
12
+ { direction: 'West', className: 'top-2 bottom-2 left-0 w-1 cursor-w-resize' },
13
+ { direction: 'NorthEast', className: 'top-0 right-0 size-2 cursor-ne-resize' },
14
+ { direction: 'NorthWest', className: 'top-0 left-0 size-2 cursor-nw-resize' },
15
+ { direction: 'SouthEast', className: 'right-0 bottom-0 size-2 cursor-se-resize' },
16
+ { direction: 'SouthWest', className: 'bottom-0 left-0 size-2 cursor-sw-resize' },
17
+ ] as const
18
+
19
+ export function useMacOS() {
20
+ const [macOS, setMacOS] = useState(false)
21
+
22
+ useEffect(() => {
23
+ setMacOS(
24
+ navigator.userAgent.includes('Macintosh') ||
25
+ navigator.platform.toLowerCase().startsWith('mac'),
26
+ )
27
+ }, [])
28
+
29
+ return macOS
30
+ }
31
+
32
+ export function WindowControls() {
33
+ const { t } = useTranslation()
34
+ const [maximized, setMaximized] = useState(false)
35
+
36
+ useEffect(() => {
37
+ const window = getCurrentWindow()
38
+ let disposed = false
39
+ let unlisten: (() => void) | undefined
40
+ const synchronize = () => {
41
+ void window.isMaximized().then((value) => {
42
+ if (!disposed) setMaximized(value)
43
+ })
44
+ }
45
+ synchronize()
46
+ queueMicrotask(() => {
47
+ if (disposed) return
48
+ void window.onResized(synchronize).then((stop) => {
49
+ if (disposed) void Promise.resolve(stop()).catch(() => undefined)
50
+ else unlisten = stop
51
+ })
52
+ })
53
+ return () => {
54
+ disposed = true
55
+ if (unlisten) void Promise.resolve(unlisten()).catch(() => undefined)
56
+ }
57
+ }, [])
58
+
59
+ const toggleMaximize = async () => {
60
+ const window = getCurrentWindow()
61
+ await window.toggleMaximize()
62
+ setMaximized(await window.isMaximized())
63
+ }
64
+
65
+ return (
66
+ <>
67
+ {!maximized && <WindowResizeHandles />}
68
+ <div className='flex h-full shrink-0'>
69
+ <WindowButton
70
+ label={t('window.minimize')}
71
+ onClick={() => void getCurrentWindow().minimize()}
72
+ >
73
+ <Minus />
74
+ </WindowButton>
75
+ <WindowButton
76
+ label={t(maximized ? 'window.restore' : 'window.maximize')}
77
+ onClick={() => void toggleMaximize()}
78
+ >
79
+ {maximized ? <Copy /> : <Square />}
80
+ </WindowButton>
81
+ <WindowButton
82
+ label={t('window.close')}
83
+ className='hover:text-destructive-foreground hover:bg-destructive'
84
+ onClick={() => void getCurrentWindow().close()}
85
+ >
86
+ <X />
87
+ </WindowButton>
88
+ </div>
89
+ </>
90
+ )
91
+ }
92
+
93
+ function WindowResizeHandles() {
94
+ const startResize =
95
+ (direction: (typeof resizeHandles)[number]['direction']) =>
96
+ (event: ReactPointerEvent<HTMLDivElement>) => {
97
+ if (event.button !== 0) return
98
+ event.preventDefault()
99
+ event.stopPropagation()
100
+ void getCurrentWindow()
101
+ .startResizeDragging(direction)
102
+ .catch(() => undefined)
103
+ }
104
+
105
+ return resizeHandles.map(({ direction, className }) => (
106
+ <div
107
+ key={direction}
108
+ aria-hidden='true'
109
+ data-window-resize-handle={direction}
110
+ className={`window-resize-handle fixed z-50 ${className}`}
111
+ onPointerDown={startResize(direction)}
112
+ />
113
+ ))
114
+ }
115
+
116
+ function WindowButton({
117
+ label,
118
+ className = '',
119
+ children,
120
+ onClick,
121
+ }: {
122
+ label: string
123
+ className?: string
124
+ children: ReactNode
125
+ onClick: () => void
126
+ }) {
127
+ return (
128
+ <button
129
+ type='button'
130
+ aria-label={label}
131
+ className={`grid h-full w-11 place-items-center text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground [&_svg]:size-3.5 ${className}`}
132
+ onClick={onClick}
133
+ >
134
+ {children}
135
+ </button>
136
+ )
137
+ }
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ } from 'react'
12
+
13
+ import { useKoharuStore, type CanvasTool } from '@aphrody/koharu-ui/lib/store'
14
+
15
+ type ApplyColor = (color: string) => void
16
+
17
+ export interface ColorSampling {
18
+ request: (apply: ApplyColor) => void
19
+ complete: (color: string) => boolean
20
+ cancel: () => void
21
+ }
22
+
23
+ const ColorSamplingContext = createContext<ColorSampling | null>(null)
24
+
25
+ export function ColorSamplingProvider({ children }: { children: ReactNode }) {
26
+ const pending = useRef<ApplyColor | null>(null)
27
+ const previousTool = useRef<CanvasTool>('select')
28
+ const tool = useKoharuStore((state) => state.tool)
29
+ const setTool = useKoharuStore((state) => state.setTool)
30
+
31
+ const request = useCallback(
32
+ (apply: ApplyColor) => {
33
+ if (!pending.current) previousTool.current = useKoharuStore.getState().tool
34
+ pending.current = apply
35
+ setTool('color_picker')
36
+ },
37
+ [setTool],
38
+ )
39
+
40
+ const cancel = useCallback(() => {
41
+ const active = pending.current !== null
42
+ pending.current = null
43
+ if (active && useKoharuStore.getState().tool === 'color_picker') {
44
+ setTool(previousTool.current)
45
+ }
46
+ }, [setTool])
47
+
48
+ const complete = useCallback(
49
+ (color: string) => {
50
+ const apply = pending.current
51
+ if (!apply) return false
52
+ pending.current = null
53
+ apply(color)
54
+ if (useKoharuStore.getState().tool === 'color_picker') setTool(previousTool.current)
55
+ return true
56
+ },
57
+ [setTool],
58
+ )
59
+
60
+ useEffect(() => {
61
+ if (tool !== 'color_picker') cancel()
62
+ }, [cancel, tool])
63
+
64
+ const value = useMemo(() => ({ request, complete, cancel }), [cancel, complete, request])
65
+ return <ColorSamplingContext.Provider value={value}>{children}</ColorSamplingContext.Provider>
66
+ }
67
+
68
+ export function useColorSampling(): ColorSampling | null {
69
+ return useContext(ColorSamplingContext)
70
+ }