@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,581 @@
1
+ 'use client'
2
+
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { observeElementRect, useVirtualizer } from '@tanstack/react-virtual'
5
+ import {
6
+ FilePlus2,
7
+ FolderOpen,
8
+ LoaderCircle,
9
+ MoreHorizontal,
10
+ Search,
11
+ Settings,
12
+ Trash2,
13
+ } from 'lucide-react'
14
+ import { useEffect, useMemo, useRef, useState } from 'react'
15
+ import { useTranslation } from 'react-i18next'
16
+
17
+ import { ResourceMonitor } from '@aphrody/koharu-ui/components/editor/ResourceMonitor'
18
+ import { call } from '@aphrody/koharu-ui/lib/backend'
19
+ import {
20
+ pageKey,
21
+ pagesKey,
22
+ preparedPageKey,
23
+ projectKey,
24
+ queryClient,
25
+ refresh,
26
+ useImportPages,
27
+ usePage,
28
+ usePages,
29
+ } from '@aphrody/koharu-ui/lib/queries'
30
+ import { useKoharuStore } from '@aphrody/koharu-ui/lib/store'
31
+ import { prefetchCanvasPages, showCanvasPage } from '@aphrody/koharu-bridge/canvas'
32
+ import {
33
+ commands,
34
+ type CanvasPagePreparation,
35
+ type Page,
36
+ type PageImportSource,
37
+ type PageSummary,
38
+ type ProjectInfo,
39
+ } from '@aphrody/koharu-bridge/protocol'
40
+ import { Button } from '../../internal/components/button'
41
+ import {
42
+ Dialog,
43
+ DialogContent,
44
+ DialogDescription,
45
+ DialogFooter,
46
+ DialogHeader,
47
+ DialogTitle,
48
+ } from '../../internal/components/dialog'
49
+ import {
50
+ DropdownMenu,
51
+ DropdownMenuContent,
52
+ DropdownMenuItem,
53
+ DropdownMenuSeparator,
54
+ DropdownMenuTrigger,
55
+ } from '../../internal/components/dropdown-menu'
56
+ import { Input } from '@aphrody/m3-primitives'
57
+ import { ScrollArea } from '../../internal/components/scroll-area'
58
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../internal/components/tooltip'
59
+ import { cn } from '../../internal/lib/utils'
60
+
61
+ const emptyPages: PageSummary[] = []
62
+
63
+ interface IntentPrefetchState {
64
+ project: string
65
+ revision: number
66
+ pages: Set<string>
67
+ }
68
+
69
+ export function PageRail() {
70
+ const { t } = useTranslation()
71
+ const pages = usePages().data ?? emptyPages
72
+ const active = usePage().data?.id ?? null
73
+ const selected = useKoharuStore((state) => state.selectedPages)
74
+ const selectPages = useKoharuStore((state) => state.selectPages)
75
+ const selectLayers = useKoharuStore((state) => state.selectLayers)
76
+ const setSettingsOpen = useKoharuStore((state) => state.setSettingsOpen)
77
+ const { importPages, importing } = useImportPages()
78
+ const anchor = useRef<number | null>(null)
79
+ const selectionRequest = useRef(0)
80
+ const intentPrefetch = useRef<IntentPrefetchState | null>(null)
81
+ const [dragged, setDragged] = useState<string | null>(null)
82
+ const [query, setQuery] = useState('')
83
+ const [renaming, setRenaming] = useState<PageSummary | null>(null)
84
+ const [renameValue, setRenameValue] = useState('')
85
+ const normalized = query.trim().toLocaleLowerCase()
86
+ const visiblePages = useMemo(
87
+ () =>
88
+ pages
89
+ .map((page, index) => ({ page, index }))
90
+ .filter(({ page }) => !normalized || page.label.toLocaleLowerCase().includes(normalized)),
91
+ [normalized, pages],
92
+ )
93
+ const pageList = useRef<HTMLDivElement>(null)
94
+ const pageVirtualizer = useVirtualizer({
95
+ count: visiblePages.length,
96
+ getScrollElement: () => pageList.current,
97
+ getItemKey: (index) => visiblePages[index]?.page.id ?? index,
98
+ estimateSize: () => 72,
99
+ gap: 2,
100
+ overscan: 6,
101
+ initialRect: { width: 240, height: 600 },
102
+ observeElementRect: (instance, callback) =>
103
+ observeElementRect(instance, (rect) =>
104
+ callback({ width: rect.width || 240, height: rect.height || 600 }),
105
+ ),
106
+ })
107
+
108
+ useEffect(
109
+ () => () => {
110
+ selectionRequest.current += 1
111
+ },
112
+ [],
113
+ )
114
+
115
+ const select = (index: number, additive: boolean, range: boolean) => {
116
+ const page = pages[index]
117
+ if (!page) return
118
+ let next: string[]
119
+ if (range && anchor.current !== null) {
120
+ const start = Math.min(anchor.current, index)
121
+ const end = Math.max(anchor.current, index)
122
+ const rangeIds = pages.slice(start, end + 1).map((item) => item.id)
123
+ next = additive ? [...new Set([...selected, ...rangeIds])] : rangeIds
124
+ } else if (additive) {
125
+ next = selected.includes(page.id)
126
+ ? selected.filter((id) => id !== page.id)
127
+ : [...selected, page.id]
128
+ anchor.current = index
129
+ } else {
130
+ next = [page.id]
131
+ anchor.current = index
132
+ }
133
+ const previousProject = queryClient.getQueryData<ProjectInfo | null>(projectKey)
134
+ const previousPage = queryClient.getQueryData<Page | null>(pageKey)
135
+ const prepared = queryClient.getQueryData<CanvasPagePreparation>(preparedPageKey(page.id))
136
+ const activated = showCanvasPage(page.id, previousProject?.revision ?? null)
137
+ const request = ++selectionRequest.current
138
+ const synchronize = () => {
139
+ if (selectionRequest.current !== request) return
140
+ if (activated && previousProject && prepared?.revision === previousProject.revision) {
141
+ queryClient.setQueryData(projectKey, { ...previousProject, active_page: page.id })
142
+ queryClient.setQueryData(pageKey, prepared.page)
143
+ }
144
+ selectPages(next)
145
+ selectLayers([])
146
+ void call(commands.selectPage, page.id)
147
+ .then((selection) => {
148
+ if (selectionRequest.current !== request) return
149
+ queryClient.setQueryData(projectKey, selection.project)
150
+ queryClient.setQueryData(pageKey, selection.page)
151
+ })
152
+ .catch(() => {
153
+ if (selectionRequest.current !== request) return
154
+ if (queryClient.getQueryData<ProjectInfo | null>(projectKey)?.active_page === page.id) {
155
+ queryClient.setQueryData(projectKey, previousProject)
156
+ queryClient.setQueryData(pageKey, previousPage)
157
+ }
158
+ })
159
+ }
160
+ if (activated) {
161
+ requestAnimationFrame(() => window.setTimeout(synchronize, 0))
162
+ } else {
163
+ synchronize()
164
+ }
165
+ }
166
+
167
+ const prefetchOnIntent = (page: string) => {
168
+ const project = queryClient.getQueryData<ProjectInfo | null>(projectKey)
169
+ if (!project || project.active_page === page) return
170
+ let state = intentPrefetch.current
171
+ if (!state || state.project !== project.name || state.revision !== project.revision) {
172
+ state = { project: project.name, revision: project.revision, pages: new Set() }
173
+ intentPrefetch.current = state
174
+ }
175
+ const prepared = queryClient.getQueryData<CanvasPagePreparation>(preparedPageKey(page))
176
+ if (prepared?.revision === project.revision || state.pages.has(page)) return
177
+ state.pages.add(page)
178
+ void prefetchCanvasPages([page])
179
+ .then((pages) => {
180
+ const current = queryClient.getQueryData<ProjectInfo | null>(projectKey)
181
+ const preparedPage = pages.find(
182
+ (candidate) => candidate.page.id === page && candidate.revision === project.revision,
183
+ )
184
+ if (
185
+ preparedPage &&
186
+ current?.name === project.name &&
187
+ current.revision === preparedPage.revision
188
+ ) {
189
+ queryClient.setQueryData(preparedPageKey(page), preparedPage)
190
+ return
191
+ }
192
+ if (intentPrefetch.current === state) state.pages.delete(page)
193
+ })
194
+ .catch(() => {
195
+ if (intentPrefetch.current === state) state.pages.delete(page)
196
+ })
197
+ }
198
+
199
+ const deletePage = (page: string) =>
200
+ void call(commands.deletePages, [page])
201
+ .then(() => {
202
+ selectPages(selected.filter((selectedPage) => selectedPage !== page))
203
+ if (active === page) selectLayers([])
204
+ return refresh(projectKey, pagesKey, pageKey)
205
+ })
206
+ .catch(() => undefined)
207
+
208
+ const openRename = (page: PageSummary) => {
209
+ setRenaming(page)
210
+ setRenameValue(page.label)
211
+ }
212
+
213
+ const closeRename = () => {
214
+ setRenaming(null)
215
+ setRenameValue('')
216
+ }
217
+
218
+ const submitRename = () => {
219
+ const page = renaming
220
+ const label = renameValue.trim()
221
+ if (!page || !label || label === page.label) {
222
+ closeRename()
223
+ return
224
+ }
225
+ void call(commands.renamePage, page.id, label)
226
+ .then(() => refresh(projectKey, pagesKey, pageKey))
227
+ .catch(() => undefined)
228
+ .finally(closeRename)
229
+ }
230
+
231
+ return (
232
+ <>
233
+ <aside className='flex h-full min-h-0 flex-col bg-[var(--surface-sidebar)]'>
234
+ <header className='flex h-10 shrink-0 items-center px-2.5'>
235
+ <div className='flex min-w-0 items-center gap-2'>
236
+ <h2 className='text-[11px] font-semibold'>{t('navigator.pages')}</h2>
237
+ <span className='rounded-full bg-primary/[0.07] px-1.5 py-0.5 text-[9px] text-muted-foreground tabular-nums'>
238
+ {pages.length}
239
+ </span>
240
+ </div>
241
+ {/* One selected page behaves exactly like acting on the active
242
+ page, so saying so would be noise. */}
243
+ {selected.length > 1 && (
244
+ <span
245
+ role='status'
246
+ aria-live='polite'
247
+ className='ml-auto text-[9px] text-muted-foreground tabular-nums'
248
+ >
249
+ {t('navigator.selected', { count: selected.length })}
250
+ </span>
251
+ )}
252
+ </header>
253
+
254
+ {pages.length > 0 && (
255
+ <div className='border-b px-2 py-1.5'>
256
+ <label className='flex h-6 items-center gap-1.5 rounded-md border border-input bg-background/70 px-1.5'>
257
+ <Search className='size-3 text-muted-foreground' />
258
+ <Input
259
+ value={query}
260
+ aria-label={t('navigator.filter')}
261
+ placeholder={t('navigator.filter')}
262
+ className='h-5 min-w-0 border-0 bg-transparent p-0 text-[11px] shadow-none focus-visible:ring-0 md:text-[11px]'
263
+ onChange={(event) => setQuery(event.currentTarget.value)}
264
+ />
265
+ </label>
266
+ </div>
267
+ )}
268
+
269
+ {pages.length === 0 ? (
270
+ <div className='flex min-h-0 flex-1 flex-col items-center justify-center px-4 text-center'>
271
+ <p className='text-[11px] font-medium'>{t('navigator.emptyTitle')}</p>
272
+ <p className='mt-1 text-[10px] leading-4 text-muted-foreground'>
273
+ {t('navigator.emptyDescription')}
274
+ </p>
275
+ <div className='mt-3'>
276
+ <PageImportMenu importing={importing} onImport={importPages} />
277
+ </div>
278
+ </div>
279
+ ) : visiblePages.length === 0 ? (
280
+ <div className='grid flex-1 place-items-center px-4 text-center text-[10px] text-muted-foreground'>
281
+ {t('navigator.noResults')}
282
+ </div>
283
+ ) : (
284
+ <ScrollArea
285
+ className='min-h-0 flex-1'
286
+ viewportClassName='px-1.5 py-1.5'
287
+ viewportRef={pageList}
288
+ >
289
+ <div className='relative w-full' style={{ height: pageVirtualizer.getTotalSize() }}>
290
+ {pageVirtualizer.getVirtualItems().map((virtualRow) => {
291
+ const item = visiblePages[virtualRow.index]
292
+ if (!item) return null
293
+ const { page, index } = item
294
+ return (
295
+ <div
296
+ key={virtualRow.key}
297
+ data-index={virtualRow.index}
298
+ className='absolute top-0 left-0 w-full'
299
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
300
+ >
301
+ <PageItem
302
+ page={page}
303
+ active={active === page.id}
304
+ selected={selected.includes(page.id)}
305
+ dragged={dragged === page.id}
306
+ onIntent={active === page.id ? undefined : () => prefetchOnIntent(page.id)}
307
+ onSelect={(additive, range) => select(index, additive, range)}
308
+ onDragStart={() => setDragged(page.id)}
309
+ onDragEnd={() => setDragged(null)}
310
+ onRename={() => openRename(page)}
311
+ onDelete={() => deletePage(page.id)}
312
+ onDrop={() => {
313
+ if (dragged && dragged !== page.id) {
314
+ void call(commands.movePage, dragged, index)
315
+ .then(() => refresh(projectKey, pagesKey))
316
+ .catch(() => undefined)
317
+ }
318
+ setDragged(null)
319
+ }}
320
+ />
321
+ </div>
322
+ )
323
+ })}
324
+ </div>
325
+ </ScrollArea>
326
+ )}
327
+
328
+ <div className='mt-auto flex shrink-0 items-center gap-2 px-2 py-2'>
329
+ <Tooltip>
330
+ <TooltipTrigger
331
+ render={
332
+ <Button
333
+ type='button'
334
+ variant='ghost'
335
+ size='icon'
336
+ className='size-8 text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground'
337
+ aria-label={t('menu.settings')}
338
+ onClick={() => setSettingsOpen(true)}
339
+ />
340
+ }
341
+ >
342
+ <Settings className='size-4' />
343
+ </TooltipTrigger>
344
+ <TooltipContent side='right'>{t('menu.settings')}</TooltipContent>
345
+ </Tooltip>
346
+ <ResourceMonitor />
347
+ </div>
348
+ </aside>
349
+
350
+ <Dialog
351
+ open={renaming !== null}
352
+ onOpenChange={(open) => {
353
+ if (!open) closeRename()
354
+ }}
355
+ >
356
+ <DialogContent className='sm:max-w-md'>
357
+ <form
358
+ onSubmit={(event) => {
359
+ event.preventDefault()
360
+ submitRename()
361
+ }}
362
+ >
363
+ <DialogHeader>
364
+ <DialogTitle>{t('navigator.renameTitle')}</DialogTitle>
365
+ <DialogDescription>{t('navigator.renameDescription')}</DialogDescription>
366
+ </DialogHeader>
367
+ <Input
368
+
369
+ value={renameValue}
370
+ aria-label={t('navigator.pageName')}
371
+ className='mt-4'
372
+ onChange={(event) => setRenameValue(event.currentTarget.value)}
373
+ />
374
+ <DialogFooter className='mt-4'>
375
+ <Button type='button' variant='ghost' onClick={closeRename}>
376
+ {t('common.cancel')}
377
+ </Button>
378
+ <Button type='submit' disabled={!renameValue.trim()}>
379
+ {t('navigator.rename')}
380
+ </Button>
381
+ </DialogFooter>
382
+ </form>
383
+ </DialogContent>
384
+ </Dialog>
385
+ </>
386
+ )
387
+ }
388
+
389
+ function PageImportMenu({
390
+ importing,
391
+ onImport,
392
+ }: {
393
+ importing: boolean
394
+ onImport: (source: PageImportSource) => void
395
+ }) {
396
+ const { t } = useTranslation()
397
+ const icon = importing ? (
398
+ <LoaderCircle className='size-3.5 animate-spin' />
399
+ ) : (
400
+ <FilePlus2 className='size-3.5' />
401
+ )
402
+
403
+ return (
404
+ <DropdownMenu>
405
+ <DropdownMenuTrigger
406
+ render={
407
+ <Button
408
+ size='sm'
409
+ variant='outline'
410
+ disabled={importing}
411
+ aria-busy={importing}
412
+ className='rounded-lg text-[10px]'
413
+ />
414
+ }
415
+ >
416
+ {icon}
417
+ {importing ? t('navigator.importingAction') : t('navigator.importAction')}
418
+ </DropdownMenuTrigger>
419
+ <DropdownMenuContent
420
+ align='start'
421
+ className='w-auto min-w-20 border border-border/50 p-0.5 shadow-sm ring-0'
422
+ >
423
+ <DropdownMenuItem
424
+ disabled={importing}
425
+ className='min-h-7 gap-1 px-1.5 py-0.5 text-[11px] [&_svg:not([class*="size-"])]:size-3.5'
426
+ onClick={() => onImport('files')}
427
+ >
428
+ <FilePlus2 />
429
+ {t('navigator.importFiles')}
430
+ </DropdownMenuItem>
431
+ <DropdownMenuItem
432
+ disabled={importing}
433
+ className='min-h-7 gap-1 px-1.5 py-0.5 text-[11px] [&_svg:not([class*="size-"])]:size-3.5'
434
+ onClick={() => onImport('folder')}
435
+ >
436
+ <FolderOpen />
437
+ {t('navigator.importFolder')}
438
+ </DropdownMenuItem>
439
+ </DropdownMenuContent>
440
+ </DropdownMenu>
441
+ )
442
+ }
443
+
444
+ function PageItem({
445
+ page,
446
+ active,
447
+ selected,
448
+ dragged,
449
+ onIntent,
450
+ onSelect,
451
+ onDragStart,
452
+ onDragEnd,
453
+ onRename,
454
+ onDelete,
455
+ onDrop,
456
+ }: {
457
+ page: PageSummary
458
+ active: boolean
459
+ selected: boolean
460
+ dragged: boolean
461
+ onIntent?: () => void
462
+ onSelect: (additive: boolean, range: boolean) => void
463
+ onDragStart: () => void
464
+ onDragEnd: () => void
465
+ onRename: () => void
466
+ onDelete: () => void
467
+ onDrop: () => void
468
+ }) {
469
+ const { t } = useTranslation()
470
+
471
+ return (
472
+ <article
473
+ draggable
474
+ data-active={active}
475
+ data-selected={selected}
476
+ className={cn(
477
+ 'group grid cursor-default grid-cols-[48px_minmax(0,1fr)] gap-2.5 rounded-xl p-1.5 transition-colors select-none',
478
+ active
479
+ ? 'bg-primary/[0.09] hover:bg-primary/[0.09]'
480
+ : selected
481
+ ? 'bg-foreground/[0.06] hover:bg-foreground/[0.08]'
482
+ : 'hover:bg-foreground/[0.045]',
483
+ dragged && 'opacity-50',
484
+ )}
485
+ onPointerEnter={onIntent}
486
+ onFocus={onIntent}
487
+ onClick={(event) => {
488
+ if ((event.target as HTMLElement).closest('button,[role="menuitem"]')) return
489
+ onSelect(event.ctrlKey || event.metaKey, event.shiftKey)
490
+ }}
491
+ onDoubleClick={onRename}
492
+ onDragStart={onDragStart}
493
+ onDragEnd={onDragEnd}
494
+ onDragOver={(event) => event.preventDefault()}
495
+ onDrop={(event) => {
496
+ event.preventDefault()
497
+ onDrop()
498
+ }}
499
+ >
500
+ <div className='grid h-16 w-12 place-items-center overflow-hidden rounded-lg bg-[var(--surface-well)]'>
501
+ {page.source_asset ? (
502
+ <PageThumbnail page={page.id} asset={page.source_asset} label={page.label} />
503
+ ) : (
504
+ <span className='text-[9px] text-muted-foreground'>{t('navigator.noImage')}</span>
505
+ )}
506
+ </div>
507
+ <div className='min-w-0 py-0.5'>
508
+ <div className='flex items-start gap-1'>
509
+ <span className='min-w-0 flex-1 truncate text-[10px] font-medium'>{page.label}</span>
510
+ <DropdownMenu>
511
+ <DropdownMenuTrigger
512
+ render={
513
+ <Button
514
+ variant='ghost'
515
+ size='icon-xs'
516
+ aria-label={t('navigator.actionsFor', { page: page.label })}
517
+ className='-mt-1 shrink-0 opacity-0 shadow-none group-hover:opacity-100 aria-expanded:opacity-100'
518
+ />
519
+ }
520
+ >
521
+ <MoreHorizontal />
522
+ </DropdownMenuTrigger>
523
+ <DropdownMenuContent align='end'>
524
+ <DropdownMenuItem onClick={onRename}>{t('navigator.rename')}</DropdownMenuItem>
525
+ <DropdownMenuSeparator />
526
+ <DropdownMenuItem variant='destructive' onClick={onDelete}>
527
+ <Trash2 /> {t('navigator.delete')}
528
+ </DropdownMenuItem>
529
+ </DropdownMenuContent>
530
+ </DropdownMenu>
531
+ </div>
532
+ <p className='mt-1 text-[9px] leading-3.5 text-muted-foreground'>
533
+ {t('navigator.layerCount', { count: page.layer_count })}
534
+ </p>
535
+ <p className='mt-1 text-[9px] text-muted-foreground tabular-nums'>
536
+ {page.size.width} × {page.size.height}
537
+ </p>
538
+ </div>
539
+ </article>
540
+ )
541
+ }
542
+
543
+ function PageThumbnail({ page, asset, label }: { page: string; asset: string; label: string }) {
544
+ const { t } = useTranslation()
545
+ const [source, setSource] = useState<string | null>(null)
546
+ const [settled, setSettled] = useState(false)
547
+
548
+ useEffect(() => {
549
+ setSettled(false)
550
+ const timeout = window.setTimeout(() => setSettled(true), 100)
551
+ return () => window.clearTimeout(timeout)
552
+ }, [asset])
553
+
554
+ const thumbnail = useQuery({
555
+ queryKey: ['thumbnail', asset],
556
+ queryFn: async () => new Uint8Array(await call(commands.getThumbnail, page)),
557
+ enabled: settled,
558
+ staleTime: Number.POSITIVE_INFINITY,
559
+ notifyOnChangeProps: ['data'],
560
+ }).data
561
+
562
+ useEffect(() => {
563
+ if (!thumbnail) return
564
+ const url = URL.createObjectURL(new Blob([thumbnail.buffer], { type: 'image/webp' }))
565
+ setSource(url)
566
+ return () => {
567
+ URL.revokeObjectURL(url)
568
+ }
569
+ }, [thumbnail])
570
+
571
+ return (
572
+ <div className='grid size-full place-items-center'>
573
+ {source ? (
574
+ // eslint-disable-next-line @next/next/no-img-element
575
+ <img src={source} alt={label} draggable={false} className='size-full object-contain' />
576
+ ) : (
577
+ <span className='text-[9px] text-muted-foreground'>{t('common.loading')}</span>
578
+ )}
579
+ </div>
580
+ )
581
+ }