@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/backend/CrudForm.js +187 -39
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/Page.js +12 -4
- package/dist/backend/Page.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +7 -4
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/CollapsibleGroup.js +88 -0
- package/dist/backend/crud/CollapsibleGroup.js.map +7 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js +178 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +7 -0
- package/dist/backend/crud/useGroupCollapse.js +24 -0
- package/dist/backend/crud/useGroupCollapse.js.map +7 -0
- package/dist/backend/crud/useGroupOrder.js +61 -0
- package/dist/backend/crud/useGroupOrder.js.map +7 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js +29 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js.map +7 -0
- package/dist/backend/crud/useZoneCollapse.js +24 -0
- package/dist/backend/crud/useZoneCollapse.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +77 -33
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +82 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +16 -2
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js +3 -1
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/primitives/avatar.js +59 -0
- package/dist/primitives/avatar.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +230 -21
- package/src/backend/Page.tsx +20 -4
- package/src/backend/__tests__/AttachmentsSection.test.tsx +82 -0
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +171 -0
- package/src/backend/__tests__/CrudForm.validation.test.tsx +4 -4
- package/src/backend/__tests__/NotesSection.test.tsx +63 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +9 -4
- package/src/backend/crud/CollapsibleGroup.tsx +111 -0
- package/src/backend/crud/CollapsibleZoneLayout.tsx +234 -0
- package/src/backend/crud/__tests__/useGroupCollapse.test.ts +38 -0
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +63 -0
- package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +49 -0
- package/src/backend/crud/__tests__/useZoneCollapse.test.ts +31 -0
- package/src/backend/crud/useGroupCollapse.ts +22 -0
- package/src/backend/crud/useGroupOrder.ts +74 -0
- package/src/backend/crud/usePersistedBooleanFlag.ts +35 -0
- package/src/backend/crud/useZoneCollapse.ts +22 -0
- package/src/backend/detail/AttachmentsSection.tsx +81 -38
- package/src/backend/detail/NotesSection.tsx +99 -6
- package/src/backend/icons/lucideRegistry.generated.tsx +16 -2
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +3 -1
- package/src/primitives/__tests__/avatar.test.tsx +64 -0
- 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
|
|
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
|
-
|
|
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
|
-
<
|
|
270
|
+
<div
|
|
254
271
|
key={item.id}
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
</
|
|
287
|
-
|
|
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}
|