@open-mercato/ui 0.5.1-develop.2663.2c29774b5b → 0.5.1-develop.2681.c559bb2bc3

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 (53) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/CrudForm.js +187 -39
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/Page.js +12 -4
  5. package/dist/backend/Page.js.map +2 -2
  6. package/dist/backend/confirm-dialog/ConfirmDialog.js +7 -4
  7. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  8. package/dist/backend/crud/CollapsibleGroup.js +88 -0
  9. package/dist/backend/crud/CollapsibleGroup.js.map +7 -0
  10. package/dist/backend/crud/CollapsibleZoneLayout.js +178 -0
  11. package/dist/backend/crud/CollapsibleZoneLayout.js.map +7 -0
  12. package/dist/backend/crud/useGroupCollapse.js +24 -0
  13. package/dist/backend/crud/useGroupCollapse.js.map +7 -0
  14. package/dist/backend/crud/useGroupOrder.js +61 -0
  15. package/dist/backend/crud/useGroupOrder.js.map +7 -0
  16. package/dist/backend/crud/usePersistedBooleanFlag.js +29 -0
  17. package/dist/backend/crud/usePersistedBooleanFlag.js.map +7 -0
  18. package/dist/backend/crud/useZoneCollapse.js +24 -0
  19. package/dist/backend/crud/useZoneCollapse.js.map +7 -0
  20. package/dist/backend/detail/AttachmentsSection.js +77 -33
  21. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  22. package/dist/backend/detail/NotesSection.js +82 -6
  23. package/dist/backend/detail/NotesSection.js.map +2 -2
  24. package/dist/backend/icons/lucideRegistry.generated.js +16 -2
  25. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  26. package/dist/backend/inputs/SwitchableMarkdownInput.js +3 -1
  27. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  28. package/dist/primitives/avatar.js +59 -0
  29. package/dist/primitives/avatar.js.map +7 -0
  30. package/package.json +3 -3
  31. package/src/backend/CrudForm.tsx +230 -21
  32. package/src/backend/Page.tsx +20 -4
  33. package/src/backend/__tests__/AttachmentsSection.test.tsx +82 -0
  34. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +171 -0
  35. package/src/backend/__tests__/CrudForm.validation.test.tsx +4 -4
  36. package/src/backend/__tests__/NotesSection.test.tsx +63 -0
  37. package/src/backend/confirm-dialog/ConfirmDialog.tsx +9 -4
  38. package/src/backend/crud/CollapsibleGroup.tsx +111 -0
  39. package/src/backend/crud/CollapsibleZoneLayout.tsx +234 -0
  40. package/src/backend/crud/__tests__/useGroupCollapse.test.ts +38 -0
  41. package/src/backend/crud/__tests__/useGroupOrder.test.ts +63 -0
  42. package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +49 -0
  43. package/src/backend/crud/__tests__/useZoneCollapse.test.ts +31 -0
  44. package/src/backend/crud/useGroupCollapse.ts +22 -0
  45. package/src/backend/crud/useGroupOrder.ts +74 -0
  46. package/src/backend/crud/usePersistedBooleanFlag.ts +35 -0
  47. package/src/backend/crud/useZoneCollapse.ts +22 -0
  48. package/src/backend/detail/AttachmentsSection.tsx +81 -38
  49. package/src/backend/detail/NotesSection.tsx +99 -6
  50. package/src/backend/icons/lucideRegistry.generated.tsx +16 -2
  51. package/src/backend/inputs/SwitchableMarkdownInput.tsx +3 -1
  52. package/src/primitives/__tests__/avatar.test.tsx +64 -0
  53. package/src/primitives/avatar.tsx +75 -0
@@ -0,0 +1,63 @@
1
+ /** @jest-environment jsdom */
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { useGroupOrder } from '../useGroupOrder'
4
+
5
+ describe('useGroupOrder', () => {
6
+ beforeEach(() => { localStorage.clear() })
7
+
8
+ it('returns defaults when storage is empty', () => {
9
+ const defaults = ['a', 'b', 'c']
10
+ const { result } = renderHook(() => useGroupOrder('people', defaults))
11
+ expect(result.current.orderedIds).toEqual(defaults)
12
+ })
13
+
14
+ it('hydrates saved order from om:group-order:<pageType>', () => {
15
+ localStorage.setItem('om:group-order:people', JSON.stringify(['c', 'a', 'b']))
16
+ const { result } = renderHook(() => useGroupOrder('people', ['a', 'b', 'c']))
17
+ expect(result.current.orderedIds).toEqual(['c', 'a', 'b'])
18
+ })
19
+
20
+ it('filters out stale IDs no longer in defaults and appends new IDs', () => {
21
+ localStorage.setItem('om:group-order:people', JSON.stringify(['x', 'a', 'y', 'b']))
22
+ const { result } = renderHook(() => useGroupOrder('people', ['a', 'b', 'c']))
23
+ expect(result.current.orderedIds).toEqual(['a', 'b', 'c'])
24
+ })
25
+
26
+ it('reorder() moves items and persists the result', () => {
27
+ const { result } = renderHook(() => useGroupOrder('people', ['a', 'b', 'c']))
28
+ act(() => { result.current.reorder(0, 2) })
29
+ expect(result.current.orderedIds).toEqual(['b', 'c', 'a'])
30
+ expect(JSON.parse(localStorage.getItem('om:group-order:people')!)).toEqual(['b', 'c', 'a'])
31
+ })
32
+
33
+ it('reorder() handles insertion in the middle', () => {
34
+ const { result } = renderHook(() => useGroupOrder('p', ['a', 'b', 'c', 'd']))
35
+ act(() => { result.current.reorder(3, 1) })
36
+ expect(result.current.orderedIds).toEqual(['a', 'd', 'b', 'c'])
37
+ })
38
+
39
+ it('updates ordering when defaults change to include a new id', () => {
40
+ const { result, rerender } = renderHook(
41
+ ({ ids }) => useGroupOrder('p', ids),
42
+ { initialProps: { ids: ['a', 'b'] } },
43
+ )
44
+ rerender({ ids: ['a', 'b', 'c'] })
45
+ expect(result.current.orderedIds).toEqual(['a', 'b', 'c'])
46
+ })
47
+
48
+ it('removes ids from state when they disappear from defaults', () => {
49
+ const { result, rerender } = renderHook(
50
+ ({ ids }) => useGroupOrder('p', ids),
51
+ { initialProps: { ids: ['a', 'b', 'c'] } },
52
+ )
53
+ rerender({ ids: ['a', 'c'] })
54
+ expect(result.current.orderedIds).toEqual(['a', 'c'])
55
+ })
56
+
57
+ it('does not rewrite storage on initial mount', () => {
58
+ const spy = jest.spyOn(Storage.prototype, 'setItem')
59
+ renderHook(() => useGroupOrder('p', ['a', 'b']))
60
+ expect(spy).not.toHaveBeenCalledWith('om:group-order:p', expect.anything())
61
+ spy.mockRestore()
62
+ })
63
+ })
@@ -0,0 +1,49 @@
1
+ /** @jest-environment jsdom */
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { usePersistedBooleanFlag } from '../usePersistedBooleanFlag'
4
+
5
+ describe('usePersistedBooleanFlag', () => {
6
+ beforeEach(() => {
7
+ localStorage.clear()
8
+ })
9
+
10
+ it('returns the default value when storage is empty', () => {
11
+ const { result } = renderHook(() => usePersistedBooleanFlag('test:a', true))
12
+ expect(result.current.value).toBe(true)
13
+ })
14
+
15
+ it('hydrates from localStorage on mount when a value is saved', () => {
16
+ localStorage.setItem('test:b', JSON.stringify('1'))
17
+ const { result } = renderHook(() => usePersistedBooleanFlag('test:b', false))
18
+ expect(result.current.value).toBe(true)
19
+ })
20
+
21
+ it('hydrates "0" as false regardless of default', () => {
22
+ localStorage.setItem('test:c', JSON.stringify('0'))
23
+ const { result } = renderHook(() => usePersistedBooleanFlag('test:c', true))
24
+ expect(result.current.value).toBe(false)
25
+ })
26
+
27
+ it('persists toggled value to localStorage', () => {
28
+ const { result } = renderHook(() => usePersistedBooleanFlag('test:d', false))
29
+ act(() => { result.current.toggle() })
30
+ expect(result.current.value).toBe(true)
31
+ expect(localStorage.getItem('test:d')).toBe(JSON.stringify('1'))
32
+ })
33
+
34
+ it('persists setValue writes', () => {
35
+ const { result } = renderHook(() => usePersistedBooleanFlag('test:e', false))
36
+ act(() => { result.current.setValue(true) })
37
+ expect(localStorage.getItem('test:e')).toBe(JSON.stringify('1'))
38
+ act(() => { result.current.setValue(false) })
39
+ expect(localStorage.getItem('test:e')).toBe(JSON.stringify('0'))
40
+ })
41
+
42
+ it('writes initial value to storage on mount', () => {
43
+ // React runs all effects on the same render cycle, so the "set mounted=true"
44
+ // effect fires before the write effect checks mounted.current. The hook DOES
45
+ // write the initial value on mount — this test locks in that observed behavior.
46
+ renderHook(() => usePersistedBooleanFlag('test:f', true))
47
+ expect(localStorage.getItem('test:f')).toBe(JSON.stringify('1'))
48
+ })
49
+ })
@@ -0,0 +1,31 @@
1
+ /** @jest-environment jsdom */
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { useZoneCollapse } from '../useZoneCollapse'
4
+
5
+ describe('useZoneCollapse', () => {
6
+ beforeEach(() => { localStorage.clear() })
7
+
8
+ it('defaults to collapsed=false', () => {
9
+ const { result } = renderHook(() => useZoneCollapse('person'))
10
+ expect(result.current.collapsed).toBe(false)
11
+ })
12
+
13
+ it('writes to om:zone1-collapsed:<pageType> on toggle', () => {
14
+ const { result } = renderHook(() => useZoneCollapse('deal'))
15
+ act(() => { result.current.toggle() })
16
+ expect(result.current.collapsed).toBe(true)
17
+ expect(localStorage.getItem('om:zone1-collapsed:deal')).toBe(JSON.stringify('1'))
18
+ })
19
+
20
+ it('hydrates collapsed=true from storage on mount', () => {
21
+ localStorage.setItem('om:zone1-collapsed:company', JSON.stringify('1'))
22
+ const { result } = renderHook(() => useZoneCollapse('company'))
23
+ expect(result.current.collapsed).toBe(true)
24
+ })
25
+
26
+ it('accepts functional setCollapsed', () => {
27
+ const { result } = renderHook(() => useZoneCollapse('person'))
28
+ act(() => { result.current.setCollapsed((prev) => !prev) })
29
+ expect(result.current.collapsed).toBe(true)
30
+ })
31
+ })
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+ import { useCallback } from 'react'
3
+ import { usePersistedBooleanFlag } from './usePersistedBooleanFlag'
4
+
5
+ function getStorageKey(pageType: string, groupId: string) {
6
+ return `om:collapsible:${pageType}:${groupId}`
7
+ }
8
+
9
+ export function useGroupCollapse(pageType: string, groupId: string, defaultExpanded = true) {
10
+ const { value: expanded, toggle, setValue } = usePersistedBooleanFlag(
11
+ getStorageKey(pageType, groupId),
12
+ defaultExpanded,
13
+ )
14
+ const setExpanded = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
15
+ if (typeof next === 'function') {
16
+ setValue((prev) => (next as (prev: boolean) => boolean)(prev))
17
+ } else {
18
+ setValue(next)
19
+ }
20
+ }, [setValue])
21
+ return { expanded, toggle, setExpanded }
22
+ }
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import {
4
+ readJsonFromLocalStorage,
5
+ writeJsonToLocalStorage,
6
+ } from '@open-mercato/shared/lib/browser/safeLocalStorage'
7
+
8
+ const STORAGE_PREFIX = 'om:group-order:'
9
+
10
+ function getStorageKey(pageType: string) {
11
+ return `${STORAGE_PREFIX}${pageType}`
12
+ }
13
+
14
+ function mergeOrder(saved: string[], defaults: string[]): string[] {
15
+ const known = new Set(defaults)
16
+ const result = saved.filter((id) => known.has(id))
17
+ for (const id of defaults) {
18
+ if (!result.includes(id)) result.push(id)
19
+ }
20
+ return result
21
+ }
22
+
23
+ function arraysEqual(a: string[], b: string[]): boolean {
24
+ if (a.length !== b.length) return false
25
+ for (let i = 0; i < a.length; i += 1) {
26
+ if (a[i] !== b[i]) return false
27
+ }
28
+ return true
29
+ }
30
+
31
+ /**
32
+ * Returns the group IDs in the user's preferred order.
33
+ * Falls back to the default order when no preference is stored.
34
+ */
35
+ export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
36
+ const [orderedIds, setOrderedIds] = React.useState<string[]>(defaultGroupIds)
37
+ const mounted = React.useRef(false)
38
+
39
+ React.useEffect(() => {
40
+ mounted.current = true
41
+ const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)
42
+ if (Array.isArray(saved)) {
43
+ setOrderedIds(mergeOrder(saved, defaultGroupIds))
44
+ }
45
+ // Intentionally only runs on mount (per pageType); defaultGroupIds changes are
46
+ // handled by the sync effect below.
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ }, [pageType])
49
+
50
+ // Sync when defaultGroupIds changes (e.g. new groups added dynamically)
51
+ React.useEffect(() => {
52
+ setOrderedIds((prev) => {
53
+ const merged = mergeOrder(prev, defaultGroupIds)
54
+ return arraysEqual(prev, merged) ? prev : merged
55
+ })
56
+ }, [defaultGroupIds])
57
+
58
+ const reorder = React.useCallback(
59
+ (fromIndex: number, toIndex: number) => {
60
+ setOrderedIds((prev) => {
61
+ const next = [...prev]
62
+ const [moved] = next.splice(fromIndex, 1)
63
+ next.splice(toIndex, 0, moved)
64
+ if (mounted.current) {
65
+ writeJsonToLocalStorage(getStorageKey(pageType), next)
66
+ }
67
+ return next
68
+ })
69
+ },
70
+ [pageType],
71
+ )
72
+
73
+ return { orderedIds, reorder }
74
+ }
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+ import { useState, useEffect, useCallback, useRef } from 'react'
3
+ import {
4
+ readJsonFromLocalStorage,
5
+ writeJsonToLocalStorage,
6
+ } from '@open-mercato/shared/lib/browser/safeLocalStorage'
7
+
8
+ /**
9
+ * Persists a boolean flag in localStorage under a given key.
10
+ * Reads once on mount; writes on every change after mount.
11
+ * Designed to back collapse/expand state for crud form groups and zones.
12
+ */
13
+ export function usePersistedBooleanFlag(storageKey: string, defaultValue: boolean) {
14
+ const [value, setValue] = useState(defaultValue)
15
+ const mounted = useRef(false)
16
+
17
+ useEffect(() => {
18
+ mounted.current = true
19
+ const saved = readJsonFromLocalStorage<string | null>(storageKey, null)
20
+ if (saved !== null) {
21
+ setValue(saved === '1')
22
+ }
23
+ }, [storageKey])
24
+
25
+ useEffect(() => {
26
+ if (!mounted.current) return
27
+ writeJsonToLocalStorage(storageKey, value ? '1' : '0')
28
+ }, [storageKey, value])
29
+
30
+ const toggle = useCallback(() => {
31
+ setValue((prev) => !prev)
32
+ }, [])
33
+
34
+ return { value, toggle, setValue }
35
+ }
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+ import { useCallback } from 'react'
3
+ import { usePersistedBooleanFlag } from './usePersistedBooleanFlag'
4
+
5
+ function getStorageKey(pageType: string) {
6
+ return `om:zone1-collapsed:${pageType}`
7
+ }
8
+
9
+ export function useZoneCollapse(pageType: string) {
10
+ const { value: collapsed, toggle, setValue } = usePersistedBooleanFlag(
11
+ getStorageKey(pageType),
12
+ false,
13
+ )
14
+ const setCollapsed = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
15
+ if (typeof next === 'function') {
16
+ setValue((prev) => (next as (prev: boolean) => boolean)(prev))
17
+ } else {
18
+ setValue(next)
19
+ }
20
+ }, [setValue])
21
+ return { collapsed, toggle, setCollapsed }
22
+ }
@@ -14,6 +14,10 @@ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
14
14
 
15
15
  type AttachmentsResponse = {
16
16
  items?: AttachmentItem[]
17
+ total?: number
18
+ page?: number
19
+ pageSize?: number
20
+ totalPages?: number
17
21
  error?: string
18
22
  }
19
23
 
@@ -40,6 +44,8 @@ function AttachmentsSectionImpl({
40
44
  }: Props) {
41
45
  const t = useT()
42
46
  const [items, setItems] = React.useState<AttachmentItem[]>([])
47
+ const [page, setPage] = React.useState(1)
48
+ const [totalPages, setTotalPages] = React.useState(1)
43
49
  const [loading, setLoading] = React.useState(false)
44
50
  const [error, setError] = React.useState<string | null>(null)
45
51
  const [isUploading, setIsUploading] = React.useState(false)
@@ -50,13 +56,19 @@ function AttachmentsSectionImpl({
50
56
  const [deleteTarget, setDeleteTarget] = React.useState<AttachmentItem | null>(null)
51
57
  const fileInputRef = React.useRef<HTMLInputElement | null>(null)
52
58
 
53
- const load = React.useCallback(async () => {
59
+ const load = React.useCallback(async (targetPage = 1, replace = true) => {
54
60
  if (!recordId) return
55
61
  setLoading(true)
56
62
  setError(null)
57
63
  try {
64
+ const params = new URLSearchParams({
65
+ entityId,
66
+ recordId,
67
+ page: String(targetPage),
68
+ pageSize: '24',
69
+ })
58
70
  const call = await apiCall<AttachmentsResponse>(
59
- `/api/attachments?entityId=${encodeURIComponent(entityId)}&recordId=${encodeURIComponent(recordId)}`,
71
+ `/api/attachments?${params.toString()}`,
60
72
  undefined,
61
73
  { fallback: { items: [] } },
62
74
  )
@@ -65,7 +77,10 @@ function AttachmentsSectionImpl({
65
77
  throw new Error(message)
66
78
  }
67
79
  const payload = call.result ?? { items: [] }
68
- setItems(Array.isArray(payload.items) ? payload.items : [])
80
+ const nextItems = Array.isArray(payload.items) ? payload.items : []
81
+ setItems((current) => (replace ? nextItems : [...current, ...nextItems]))
82
+ setPage(typeof payload.page === 'number' ? payload.page : targetPage)
83
+ setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : 1)
69
84
  } catch (err: any) {
70
85
  setError(err?.message || t('attachments.library.errors.load', 'Failed to load attachments.'))
71
86
  } finally {
@@ -78,6 +93,8 @@ function AttachmentsSectionImpl({
78
93
  void load()
79
94
  } else {
80
95
  setItems([])
96
+ setPage(1)
97
+ setTotalPages(1)
81
98
  setError(null)
82
99
  }
83
100
  }, [load, recordId])
@@ -103,7 +120,7 @@ function AttachmentsSectionImpl({
103
120
  throw new Error(message)
104
121
  }
105
122
  }
106
- await load()
123
+ await load(1, true)
107
124
  onChanged?.()
108
125
  } catch (err: any) {
109
126
  setError(err?.message || t('attachments.library.upload.failed', 'Upload failed.'))
@@ -162,7 +179,7 @@ function AttachmentsSectionImpl({
162
179
  }
163
180
  setDeleteOpen(false)
164
181
  setDeleteTarget(null)
165
- await load()
182
+ await load(1, true)
166
183
  onChanged?.()
167
184
  } catch (err: any) {
168
185
  setError(err?.message || t('attachments.library.errors.delete', 'Failed to delete attachment.'))
@@ -188,7 +205,7 @@ function AttachmentsSectionImpl({
188
205
  throw new Error(message)
189
206
  }
190
207
  setMetadataOpen(false)
191
- await load()
208
+ await load(1, true)
192
209
  onChanged?.()
193
210
  },
194
211
  [load, onChanged, t],
@@ -250,41 +267,53 @@ function AttachmentsSectionImpl({
250
267
  )}>
251
268
  {items.map((item) => {
252
269
  return (
253
- <button
270
+ <div
254
271
  key={item.id}
255
- type="button"
256
- onClick={() => openMetadataDialog(item)}
257
- className="group flex flex-col overflow-hidden rounded-lg border bg-card text-left cursor-pointer transition-shadow hover:shadow-sm"
272
+ role="group"
273
+ className="group relative flex flex-col overflow-hidden rounded-lg border bg-card text-left transition-shadow hover:shadow-sm focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
258
274
  >
259
- <AttachmentVisualPreview
260
- fileName={item.fileName}
261
- mimeType={item.mimeType}
262
- thumbnailUrl={item.thumbnailUrl}
263
- className={compact ? 'aspect-[2/1]' : 'aspect-[4/3]'}
264
- overlay={(
265
- <Button
266
- type="button"
267
- variant="ghost"
268
- size="icon"
269
- className="absolute right-2 top-2 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
270
- onClick={(event) => {
271
- event.stopPropagation()
272
- openDeleteDialog(item)
273
- }}
274
- >
275
- <Trash2 className="h-4 w-4 text-destructive" />
276
- </Button>
277
- )}
278
- />
279
- <div className={cn('space-y-1', compact ? 'p-2' : 'p-3')}>
280
- <div className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')} title={item.fileName}>
281
- {item.fileName}
282
- </div>
283
- <div className="text-xs text-muted-foreground">
284
- {formatAttachmentFileSize(item.fileSize)}
275
+ <Button
276
+ type="button"
277
+ variant="ghost"
278
+ aria-label={item.fileName}
279
+ onClick={() => openMetadataDialog(item)}
280
+ onKeyDown={(event) => {
281
+ if (event.key === 'Enter' || event.key === ' ') {
282
+ event.preventDefault()
283
+ openMetadataDialog(item)
284
+ }
285
+ }}
286
+ className="flex h-auto w-full flex-col items-stretch rounded-lg p-0 text-left hover:bg-transparent focus-visible:outline-none focus-visible:ring-0"
287
+ >
288
+ <AttachmentVisualPreview
289
+ fileName={item.fileName}
290
+ mimeType={item.mimeType}
291
+ thumbnailUrl={item.thumbnailUrl}
292
+ className={compact ? 'aspect-[2/1] w-full' : 'aspect-[4/3] w-full'}
293
+ />
294
+ <div className={cn('space-y-1 w-full', compact ? 'p-2' : 'p-3')}>
295
+ <div className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')} title={item.fileName}>
296
+ {item.fileName}
297
+ </div>
298
+ <div className="text-xs text-muted-foreground">
299
+ {formatAttachmentFileSize(item.fileSize)}
300
+ </div>
285
301
  </div>
286
- </div>
287
- </button>
302
+ </Button>
303
+ <Button
304
+ type="button"
305
+ variant="ghost"
306
+ size="icon"
307
+ className="absolute right-2 top-2 z-10 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
308
+ onClick={(event) => {
309
+ event.stopPropagation()
310
+ openDeleteDialog(item)
311
+ }}
312
+ aria-label={t('attachments.library.delete', 'Delete attachment')}
313
+ >
314
+ <Trash2 className="h-4 w-4 text-destructive" />
315
+ </Button>
316
+ </div>
288
317
  )
289
318
  })}
290
319
  </div>
@@ -294,6 +323,20 @@ function AttachmentsSectionImpl({
294
323
  </div>
295
324
  )}
296
325
 
326
+ {items.length > 0 && page < totalPages ? (
327
+ <div className="flex justify-center">
328
+ <Button
329
+ type="button"
330
+ variant="outline"
331
+ size="sm"
332
+ onClick={() => { void load(page + 1, false) }}
333
+ disabled={loading}
334
+ >
335
+ {t('attachments.library.loadMore', 'Load more')}
336
+ </Button>
337
+ </div>
338
+ ) : null}
339
+
297
340
  <AttachmentMetadataDialog
298
341
  open={metadataOpen}
299
342
  onOpenChange={setMetadataOpen}