@contember/bindx-uploader 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.
- package/dist/UploaderError.d.ts +6 -0
- package/dist/UploaderError.d.ts.map +1 -0
- package/dist/components/MultiUploader.d.ts +43 -0
- package/dist/components/MultiUploader.d.ts.map +1 -0
- package/dist/components/Uploader.d.ts +58 -0
- package/dist/components/Uploader.d.ts.map +1 -0
- package/dist/components/UploaderDropzoneArea.d.ts +6 -0
- package/dist/components/UploaderDropzoneArea.d.ts.map +1 -0
- package/dist/components/UploaderDropzoneRoot.d.ts +8 -0
- package/dist/components/UploaderDropzoneRoot.d.ts.map +1 -0
- package/dist/components/UploaderEachFile.d.ts +14 -0
- package/dist/components/UploaderEachFile.d.ts.map +1 -0
- package/dist/components/UploaderFileStateSwitch.d.ts +24 -0
- package/dist/components/UploaderFileStateSwitch.d.ts.map +1 -0
- package/dist/components/UploaderHasFile.d.ts +14 -0
- package/dist/components/UploaderHasFile.d.ts.map +1 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/contexts.d.ts +32 -0
- package/dist/contexts.d.ts.map +1 -0
- package/dist/extractors/getAudioFileDataExtractor.d.ts +9 -0
- package/dist/extractors/getAudioFileDataExtractor.d.ts.map +1 -0
- package/dist/extractors/getFileUrlDataExtractor.d.ts +9 -0
- package/dist/extractors/getFileUrlDataExtractor.d.ts.map +1 -0
- package/dist/extractors/getGenericFileMetadataExtractor.d.ts +12 -0
- package/dist/extractors/getGenericFileMetadataExtractor.d.ts.map +1 -0
- package/dist/extractors/getImageFileDataExtractor.d.ts +10 -0
- package/dist/extractors/getImageFileDataExtractor.d.ts.map +1 -0
- package/dist/extractors/getVideoFileDataExtractor.d.ts +11 -0
- package/dist/extractors/getVideoFileDataExtractor.d.ts.map +1 -0
- package/dist/extractors/index.d.ts +6 -0
- package/dist/extractors/index.d.ts.map +1 -0
- package/dist/extractors/types.d.ts +13 -0
- package/dist/extractors/types.d.ts.map +1 -0
- package/dist/fileTypes/createAnyFileType.d.ts +27 -0
- package/dist/fileTypes/createAnyFileType.d.ts.map +1 -0
- package/dist/fileTypes/createAudioFileType.d.ts +29 -0
- package/dist/fileTypes/createAudioFileType.d.ts.map +1 -0
- package/dist/fileTypes/createImageFileType.d.ts +47 -0
- package/dist/fileTypes/createImageFileType.d.ts.map +1 -0
- package/dist/fileTypes/createVideoFileType.d.ts +33 -0
- package/dist/fileTypes/createVideoFileType.d.ts.map +1 -0
- package/dist/fileTypes/index.d.ts +5 -0
- package/dist/fileTypes/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useS3Client.d.ts +7 -0
- package/dist/hooks/useS3Client.d.ts.map +1 -0
- package/dist/hooks/useUploaderStateFiles.d.ts +8 -0
- package/dist/hooks/useUploaderStateFiles.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/internal/hooks/index.d.ts +5 -0
- package/dist/internal/hooks/index.d.ts.map +1 -0
- package/dist/internal/hooks/useFillEntity.d.ts +17 -0
- package/dist/internal/hooks/useFillEntity.d.ts.map +1 -0
- package/dist/internal/hooks/useGetPreviewUrls.d.ts +6 -0
- package/dist/internal/hooks/useGetPreviewUrls.d.ts.map +1 -0
- package/dist/internal/hooks/useUploadState.d.ts +12 -0
- package/dist/internal/hooks/useUploadState.d.ts.map +1 -0
- package/dist/internal/hooks/useUploaderDoUpload.d.ts +7 -0
- package/dist/internal/hooks/useUploaderDoUpload.d.ts.map +1 -0
- package/dist/internal/utils/executeExtractors.d.ts +16 -0
- package/dist/internal/utils/executeExtractors.d.ts.map +1 -0
- package/dist/internal/utils/index.d.ts +4 -0
- package/dist/internal/utils/index.d.ts.map +1 -0
- package/dist/internal/utils/resolveAccept.d.ts +7 -0
- package/dist/internal/utils/resolveAccept.d.ts.map +1 -0
- package/dist/internal/utils/uploaderErrorHandler.d.ts +7 -0
- package/dist/internal/utils/uploaderErrorHandler.d.ts.map +1 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/uploadClient/S3UploadClient.d.ts +19 -0
- package/dist/uploadClient/S3UploadClient.d.ts.map +1 -0
- package/dist/uploadClient/index.d.ts +3 -0
- package/dist/uploadClient/index.d.ts.map +1 -0
- package/dist/uploadClient/types.d.ts +29 -0
- package/dist/uploadClient/types.d.ts.map +1 -0
- package/dist/utils/attrAccept.d.ts +16 -0
- package/dist/utils/attrAccept.d.ts.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/urlSigner.d.ts +8 -0
- package/dist/utils/urlSigner.d.ts.map +1 -0
- package/package.json +35 -0
- package/src/UploaderError.ts +7 -0
- package/src/components/MultiUploader.tsx +210 -0
- package/src/components/Uploader.tsx +174 -0
- package/src/components/UploaderDropzoneArea.tsx +29 -0
- package/src/components/UploaderDropzoneRoot.tsx +32 -0
- package/src/components/UploaderEachFile.tsx +46 -0
- package/src/components/UploaderFileStateSwitch.tsx +66 -0
- package/src/components/UploaderHasFile.tsx +35 -0
- package/src/components/index.ts +7 -0
- package/src/contexts.ts +76 -0
- package/src/extractors/getAudioFileDataExtractor.ts +44 -0
- package/src/extractors/getFileUrlDataExtractor.ts +22 -0
- package/src/extractors/getGenericFileMetadataExtractor.ts +40 -0
- package/src/extractors/getImageFileDataExtractor.ts +50 -0
- package/src/extractors/getVideoFileDataExtractor.ts +60 -0
- package/src/extractors/index.ts +5 -0
- package/src/extractors/types.ts +14 -0
- package/src/fileTypes/createAnyFileType.ts +56 -0
- package/src/fileTypes/createAudioFileType.ts +67 -0
- package/src/fileTypes/createImageFileType.ts +87 -0
- package/src/fileTypes/createVideoFileType.ts +75 -0
- package/src/fileTypes/index.ts +4 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useS3Client.ts +26 -0
- package/src/hooks/useUploaderStateFiles.ts +22 -0
- package/src/index.ts +126 -0
- package/src/internal/hooks/index.ts +4 -0
- package/src/internal/hooks/useFillEntity.ts +89 -0
- package/src/internal/hooks/useGetPreviewUrls.ts +26 -0
- package/src/internal/hooks/useUploadState.ts +159 -0
- package/src/internal/hooks/useUploaderDoUpload.ts +129 -0
- package/src/internal/utils/executeExtractors.ts +46 -0
- package/src/internal/utils/index.ts +3 -0
- package/src/internal/utils/resolveAccept.ts +26 -0
- package/src/internal/utils/uploaderErrorHandler.ts +15 -0
- package/src/types.ts +242 -0
- package/src/uploadClient/S3UploadClient.ts +119 -0
- package/src/uploadClient/index.ts +2 -0
- package/src/uploadClient/types.ts +30 -0
- package/src/utils/attrAccept.ts +87 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/urlSigner.ts +117 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, type ReactNode } from 'react'
|
|
2
|
+
import type { EntityRef, HasManyRef, SelectionFieldMeta, SelectionMeta } from '@contember/bindx'
|
|
3
|
+
import { FIELD_REF_META } from '@contember/bindx'
|
|
4
|
+
import { BINDX_COMPONENT, type SelectionProvider, createEmptySelection } from '@contember/bindx-react'
|
|
5
|
+
import type { FileType, UploaderEvents } from '../types.js'
|
|
6
|
+
import {
|
|
7
|
+
MultiUploaderEntityToFileStateMapContext,
|
|
8
|
+
UploaderOptionsContext,
|
|
9
|
+
UploaderStateContext,
|
|
10
|
+
UploaderUploadFilesContext,
|
|
11
|
+
} from '../contexts.js'
|
|
12
|
+
import { useUploadState } from '../internal/hooks/useUploadState.js'
|
|
13
|
+
import { useUploaderDoUpload } from '../internal/hooks/useUploaderDoUpload.js'
|
|
14
|
+
import { resolveAcceptingSingleType } from '../internal/utils/resolveAccept.js'
|
|
15
|
+
import { executeExtractors } from '../internal/utils/executeExtractors.js'
|
|
16
|
+
import { uploaderErrorHandler } from '../internal/utils/uploaderErrorHandler.js'
|
|
17
|
+
|
|
18
|
+
export interface MultiUploaderProps<TEntity = Record<string, unknown>> {
|
|
19
|
+
/**
|
|
20
|
+
* The has-many relation to add uploaded files to.
|
|
21
|
+
*/
|
|
22
|
+
field: HasManyRef<TEntity>
|
|
23
|
+
/**
|
|
24
|
+
* File type configuration defining accepted files and extractors.
|
|
25
|
+
* Must be created with the same entity type as the field prop.
|
|
26
|
+
*/
|
|
27
|
+
fileType: FileType<TEntity>
|
|
28
|
+
/**
|
|
29
|
+
* Children to render within the uploader context.
|
|
30
|
+
*/
|
|
31
|
+
children?: ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Multi-file upload component for bindx.
|
|
36
|
+
* Creates new items in a has-many relation for each uploaded file.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* <MultiUploader field={article.images} fileType={imageFileType}>
|
|
41
|
+
* <DropZone multiple />
|
|
42
|
+
* <UploaderEachFile>
|
|
43
|
+
* <UploaderFileStateSwitch
|
|
44
|
+
* uploading={<ProgressBar />}
|
|
45
|
+
* success={<SuccessMessage />}
|
|
46
|
+
* error={<ErrorMessage />}
|
|
47
|
+
* />
|
|
48
|
+
* </UploaderEachFile>
|
|
49
|
+
* </MultiUploader>
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function MultiUploader<TEntity extends Record<string, unknown>>({
|
|
53
|
+
field,
|
|
54
|
+
fileType,
|
|
55
|
+
children,
|
|
56
|
+
}: MultiUploaderProps<TEntity>): ReactNode {
|
|
57
|
+
// Map file ID -> entity ID
|
|
58
|
+
const fileToEntityMapRef = useRef(new Map<string, string>())
|
|
59
|
+
// Map entity ID -> file ID (for reverse lookup)
|
|
60
|
+
const entityToFileMapRef = useRef(new Map<string, string>())
|
|
61
|
+
|
|
62
|
+
const getEntityForFile = useCallback(
|
|
63
|
+
(fileId: string): EntityRef<unknown> | undefined => {
|
|
64
|
+
const entityId = fileToEntityMapRef.current.get(fileId)
|
|
65
|
+
if (!entityId) return undefined
|
|
66
|
+
return field.items.find((item: { id: string }) => item.id === entityId) as EntityRef<unknown> | undefined
|
|
67
|
+
},
|
|
68
|
+
[field.items],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// Create entity for file and track mapping
|
|
72
|
+
const useCreateRepeaterEntityEvents = useCallback(
|
|
73
|
+
(events: UploaderEvents): UploaderEvents => ({
|
|
74
|
+
...events,
|
|
75
|
+
onBeforeUpload: async event => {
|
|
76
|
+
if (!(await resolveAcceptingSingleType(event.file, fileType as FileType))) {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create new entity in the has-many relation
|
|
81
|
+
const entityId = field.add()
|
|
82
|
+
fileToEntityMapRef.current.set(event.file.id, entityId)
|
|
83
|
+
entityToFileMapRef.current.set(entityId, event.file.id)
|
|
84
|
+
|
|
85
|
+
return (await events.onBeforeUpload?.(event)) ?? (fileType as FileType)
|
|
86
|
+
},
|
|
87
|
+
onAfterUpload: async event => {
|
|
88
|
+
await Promise.all([
|
|
89
|
+
(async () => {
|
|
90
|
+
const entity = getEntityForFile(event.file.id)
|
|
91
|
+
if (!entity) return
|
|
92
|
+
|
|
93
|
+
const extractionResult = await executeExtractors({
|
|
94
|
+
fileType: fileType as FileType,
|
|
95
|
+
result: event.result,
|
|
96
|
+
file: event.file,
|
|
97
|
+
})
|
|
98
|
+
extractionResult?.({ entity })
|
|
99
|
+
})(),
|
|
100
|
+
events.onAfterUpload?.(event),
|
|
101
|
+
])
|
|
102
|
+
},
|
|
103
|
+
onError: event => {
|
|
104
|
+
// Remove entity on error
|
|
105
|
+
const entityId = fileToEntityMapRef.current.get(event.file.id)
|
|
106
|
+
if (entityId) {
|
|
107
|
+
field.remove(entityId)
|
|
108
|
+
fileToEntityMapRef.current.delete(event.file.id)
|
|
109
|
+
entityToFileMapRef.current.delete(entityId)
|
|
110
|
+
}
|
|
111
|
+
events.onError?.(event)
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
[field, fileType, getEntityForFile],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const baseEvents: UploaderEvents = useMemo(
|
|
118
|
+
() => ({
|
|
119
|
+
onError: uploaderErrorHandler,
|
|
120
|
+
onStartUpload: () => {},
|
|
121
|
+
onBeforeUpload: async () => undefined,
|
|
122
|
+
onProgress: () => {},
|
|
123
|
+
onAfterUpload: async () => {},
|
|
124
|
+
onSuccess: () => {},
|
|
125
|
+
}),
|
|
126
|
+
[],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const fillEntityEvents = useCreateRepeaterEntityEvents(baseEvents)
|
|
130
|
+
const { files, ...stateEvents } = useUploadState(fillEntityEvents)
|
|
131
|
+
const onDrop = useUploaderDoUpload(stateEvents)
|
|
132
|
+
|
|
133
|
+
const options = useMemo(
|
|
134
|
+
() => ({
|
|
135
|
+
accept: fileType.accept,
|
|
136
|
+
multiple: true,
|
|
137
|
+
}),
|
|
138
|
+
[fileType.accept],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<MultiUploaderEntityToFileStateMapContext.Provider value={entityToFileMapRef.current}>
|
|
143
|
+
<UploaderStateContext.Provider value={files}>
|
|
144
|
+
<UploaderUploadFilesContext.Provider value={onDrop}>
|
|
145
|
+
<UploaderOptionsContext.Provider value={options}>
|
|
146
|
+
{children}
|
|
147
|
+
</UploaderOptionsContext.Provider>
|
|
148
|
+
</UploaderUploadFilesContext.Provider>
|
|
149
|
+
</UploaderStateContext.Provider>
|
|
150
|
+
</MultiUploaderEntityToFileStateMapContext.Provider>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Collects field names from all extractors in a file type.
|
|
156
|
+
*/
|
|
157
|
+
function collectExtractorFieldNames(fileType: FileType): string[] {
|
|
158
|
+
const fieldNames: string[] = []
|
|
159
|
+
for (const extractor of fileType.extractors ?? []) {
|
|
160
|
+
fieldNames.push(...extractor.getFieldNames())
|
|
161
|
+
}
|
|
162
|
+
return fieldNames
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Builds nested selection meta from field names.
|
|
167
|
+
*/
|
|
168
|
+
function buildNestedSelection(fieldNames: string[]): SelectionMeta {
|
|
169
|
+
const selection = createEmptySelection()
|
|
170
|
+
for (const fieldName of fieldNames) {
|
|
171
|
+
selection.fields.set(fieldName, {
|
|
172
|
+
fieldName,
|
|
173
|
+
alias: fieldName,
|
|
174
|
+
path: [],
|
|
175
|
+
isArray: false,
|
|
176
|
+
isRelation: false,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
return selection
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Static method for selection extraction
|
|
183
|
+
const multiUploaderWithSelection = MultiUploader as typeof MultiUploader & SelectionProvider & { [BINDX_COMPONENT]: true }
|
|
184
|
+
|
|
185
|
+
multiUploaderWithSelection.getSelection = (
|
|
186
|
+
props: MultiUploaderProps,
|
|
187
|
+
_collectNested: (children: ReactNode) => SelectionMeta,
|
|
188
|
+
): SelectionFieldMeta | null => {
|
|
189
|
+
const fieldNames = collectExtractorFieldNames(props.fileType)
|
|
190
|
+
|
|
191
|
+
if (fieldNames.length === 0) {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const meta = props.field[FIELD_REF_META]
|
|
196
|
+
|
|
197
|
+
// MultiUploader always works with a has-many relation
|
|
198
|
+
return {
|
|
199
|
+
fieldName: meta.fieldName,
|
|
200
|
+
alias: meta.fieldName,
|
|
201
|
+
path: meta.path,
|
|
202
|
+
isArray: true,
|
|
203
|
+
isRelation: true,
|
|
204
|
+
nested: buildNestedSelection(fieldNames),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
multiUploaderWithSelection[BINDX_COMPONENT] = true
|
|
209
|
+
|
|
210
|
+
export { multiUploaderWithSelection as MultiUploaderWithMeta }
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from 'react'
|
|
2
|
+
import type { EntityRef, HasOneRef, SelectionFieldMeta, SelectionMeta } from '@contember/bindx'
|
|
3
|
+
import { FIELD_REF_META } from '@contember/bindx'
|
|
4
|
+
import { BINDX_COMPONENT, type SelectionProvider, createEmptySelection } from '@contember/bindx-react'
|
|
5
|
+
import type { FileType } from '../types.js'
|
|
6
|
+
import {
|
|
7
|
+
UploaderOptionsContext,
|
|
8
|
+
UploaderStateContext,
|
|
9
|
+
UploaderUploadFilesContext,
|
|
10
|
+
} from '../contexts.js'
|
|
11
|
+
import { useUploadState } from '../internal/hooks/useUploadState.js'
|
|
12
|
+
import { useUploaderDoUpload } from '../internal/hooks/useUploaderDoUpload.js'
|
|
13
|
+
import { useFillEntity } from '../internal/hooks/useFillEntity.js'
|
|
14
|
+
import { uploaderErrorHandler } from '../internal/utils/uploaderErrorHandler.js'
|
|
15
|
+
|
|
16
|
+
export interface UploaderProps<TEntity = Record<string, unknown>> {
|
|
17
|
+
/**
|
|
18
|
+
* The entity to fill with uploaded file data.
|
|
19
|
+
* Can be an EntityRef or HasOneRef.
|
|
20
|
+
*/
|
|
21
|
+
entity: EntityRef<TEntity> | HasOneRef<TEntity>
|
|
22
|
+
/**
|
|
23
|
+
* File type configuration defining accepted files and extractors.
|
|
24
|
+
* Must be created with the same entity type as the entity prop.
|
|
25
|
+
*/
|
|
26
|
+
fileType: FileType<TEntity>
|
|
27
|
+
/**
|
|
28
|
+
* Children to render within the uploader context.
|
|
29
|
+
*/
|
|
30
|
+
children?: ReactNode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const noop = (): Promise<undefined> => Promise.resolve(undefined)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Single file upload component for bindx.
|
|
37
|
+
* Provides upload state and upload function to children via context.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* interface Image {
|
|
42
|
+
* id: string
|
|
43
|
+
* url: string
|
|
44
|
+
* width: number
|
|
45
|
+
* height: number
|
|
46
|
+
* }
|
|
47
|
+
*
|
|
48
|
+
* const imageFileType = createImageFileType<Image>({
|
|
49
|
+
* urlField: 'url',
|
|
50
|
+
* widthField: 'width',
|
|
51
|
+
* heightField: 'height',
|
|
52
|
+
* })
|
|
53
|
+
*
|
|
54
|
+
* // article.image is HasOneRef<Image>
|
|
55
|
+
* <Uploader entity={article.image} fileType={imageFileType}>
|
|
56
|
+
* <DropZone />
|
|
57
|
+
* <UploaderEachFile>
|
|
58
|
+
* <UploaderFileStateSwitch
|
|
59
|
+
* uploading={<ProgressBar />}
|
|
60
|
+
* success={<SuccessMessage />}
|
|
61
|
+
* error={<ErrorMessage />}
|
|
62
|
+
* />
|
|
63
|
+
* </UploaderEachFile>
|
|
64
|
+
* </Uploader>
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function Uploader<TEntity extends Record<string, unknown>>({
|
|
68
|
+
entity,
|
|
69
|
+
fileType,
|
|
70
|
+
children,
|
|
71
|
+
}: UploaderProps<TEntity>): ReactNode {
|
|
72
|
+
const fillEntityEvents = useFillEntity({
|
|
73
|
+
entity,
|
|
74
|
+
fileType,
|
|
75
|
+
onError: uploaderErrorHandler,
|
|
76
|
+
onStartUpload: noop,
|
|
77
|
+
onBeforeUpload: noop,
|
|
78
|
+
onProgress: noop,
|
|
79
|
+
onAfterUpload: noop,
|
|
80
|
+
onSuccess: noop,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const { files, ...stateEvents } = useUploadState(fillEntityEvents)
|
|
84
|
+
const onDrop = useUploaderDoUpload(stateEvents)
|
|
85
|
+
|
|
86
|
+
const options = useMemo(
|
|
87
|
+
() => ({
|
|
88
|
+
accept: fileType.accept,
|
|
89
|
+
multiple: false,
|
|
90
|
+
}),
|
|
91
|
+
[fileType.accept],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<UploaderStateContext.Provider value={files}>
|
|
96
|
+
<UploaderUploadFilesContext.Provider value={onDrop}>
|
|
97
|
+
<UploaderOptionsContext.Provider value={options}>
|
|
98
|
+
{children}
|
|
99
|
+
</UploaderOptionsContext.Provider>
|
|
100
|
+
</UploaderUploadFilesContext.Provider>
|
|
101
|
+
</UploaderStateContext.Provider>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Type guard to check if an entity has FIELD_REF_META (is a relation handle).
|
|
107
|
+
*/
|
|
108
|
+
function hasFieldRefMeta(entity: unknown): entity is { [FIELD_REF_META]: { fieldName: string; path: string[]; isRelation: boolean } } {
|
|
109
|
+
return entity !== null && typeof entity === 'object' && FIELD_REF_META in entity
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collects field names from all extractors in a file type.
|
|
114
|
+
*/
|
|
115
|
+
function collectExtractorFieldNames(fileType: FileType): string[] {
|
|
116
|
+
const fieldNames: string[] = []
|
|
117
|
+
for (const extractor of fileType.extractors ?? []) {
|
|
118
|
+
fieldNames.push(...extractor.getFieldNames())
|
|
119
|
+
}
|
|
120
|
+
return fieldNames
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds nested selection meta from field names.
|
|
125
|
+
*/
|
|
126
|
+
function buildNestedSelection(fieldNames: string[]): SelectionMeta {
|
|
127
|
+
const selection = createEmptySelection()
|
|
128
|
+
for (const fieldName of fieldNames) {
|
|
129
|
+
selection.fields.set(fieldName, {
|
|
130
|
+
fieldName,
|
|
131
|
+
alias: fieldName,
|
|
132
|
+
path: [],
|
|
133
|
+
isArray: false,
|
|
134
|
+
isRelation: false,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
return selection
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Static method for selection extraction
|
|
141
|
+
const uploaderWithSelection = Uploader as typeof Uploader & SelectionProvider & { [BINDX_COMPONENT]: true }
|
|
142
|
+
|
|
143
|
+
uploaderWithSelection.getSelection = (
|
|
144
|
+
props: UploaderProps,
|
|
145
|
+
_collectNested: (children: ReactNode) => SelectionMeta,
|
|
146
|
+
): SelectionFieldMeta | null => {
|
|
147
|
+
const fieldNames = collectExtractorFieldNames(props.fileType)
|
|
148
|
+
|
|
149
|
+
if (fieldNames.length === 0) {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Only HasOneRef has FIELD_REF_META - EntityRef doesn't have it
|
|
154
|
+
// If passed an EntityRef directly, we can't build selection (return null)
|
|
155
|
+
if (!hasFieldRefMeta(props.entity)) {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const meta = props.entity[FIELD_REF_META]
|
|
160
|
+
|
|
161
|
+
// Return nested selection under the relation
|
|
162
|
+
return {
|
|
163
|
+
fieldName: meta.fieldName,
|
|
164
|
+
alias: meta.fieldName,
|
|
165
|
+
path: meta.path,
|
|
166
|
+
isArray: false,
|
|
167
|
+
isRelation: true,
|
|
168
|
+
nested: buildNestedSelection(fieldNames),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
uploaderWithSelection[BINDX_COMPONENT] = true
|
|
173
|
+
|
|
174
|
+
export { uploaderWithSelection as UploaderWithMeta }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactNode, useMemo } from 'react'
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
3
|
+
import { useUploaderDropzoneState, useUploaderState } from '../contexts.js'
|
|
4
|
+
|
|
5
|
+
const dataAttribute = (value: unknown): '' | undefined => value ? '' : undefined
|
|
6
|
+
|
|
7
|
+
export interface UploaderDropzoneAreaProps {
|
|
8
|
+
children: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const UploaderDropzoneArea = ({ children }: UploaderDropzoneAreaProps): ReactNode => {
|
|
12
|
+
const { getRootProps, isDragActive, isDragAccept, isDragReject, isFocused, isFileDialogActive } = useUploaderDropzoneState()
|
|
13
|
+
const files = useUploaderState()
|
|
14
|
+
const isUploading = useMemo(() => files.some(it => it.state === 'uploading' || it.state === 'initial'), [files])
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Slot
|
|
18
|
+
{...getRootProps()}
|
|
19
|
+
data-dropzone-active={dataAttribute(isDragActive)}
|
|
20
|
+
data-dropzone-accept={dataAttribute(isDragAccept)}
|
|
21
|
+
data-dropzone-reject={dataAttribute(isDragReject)}
|
|
22
|
+
data-dropzone-focused={dataAttribute(isFocused)}
|
|
23
|
+
data-dropzone-file-dialog-active={dataAttribute(isFileDialogActive)}
|
|
24
|
+
data-dropzone-uploading={dataAttribute(isUploading)}
|
|
25
|
+
>
|
|
26
|
+
{children}
|
|
27
|
+
</Slot>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { useDropzone } from 'react-dropzone'
|
|
3
|
+
import { UploaderDropzoneStateContext } from '../contexts.js'
|
|
4
|
+
import { useUploaderOptions, useUploaderUploadFiles } from '../contexts.js'
|
|
5
|
+
|
|
6
|
+
export interface UploaderDropzoneRootProps {
|
|
7
|
+
children: ReactNode
|
|
8
|
+
noInput?: boolean
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const UploaderDropzoneRoot = ({ children, noInput, disabled }: UploaderDropzoneRootProps): ReactNode => {
|
|
13
|
+
const onDrop = useUploaderUploadFiles()
|
|
14
|
+
const { multiple, accept } = useUploaderOptions()
|
|
15
|
+
|
|
16
|
+
const dropzoneState = useDropzone({
|
|
17
|
+
onDrop,
|
|
18
|
+
disabled,
|
|
19
|
+
accept,
|
|
20
|
+
multiple,
|
|
21
|
+
noKeyboard: true, // Keyboard navigation handled by button inside
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<UploaderDropzoneStateContext.Provider value={dropzoneState}>
|
|
26
|
+
{noInput ? null : (
|
|
27
|
+
<input {...dropzoneState.getInputProps()} />
|
|
28
|
+
)}
|
|
29
|
+
{children}
|
|
30
|
+
</UploaderDropzoneStateContext.Provider>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from 'react'
|
|
2
|
+
import type { UploaderFileState } from '../types.js'
|
|
3
|
+
import { UploaderFileStateContext, useUploaderState } from '../contexts.js'
|
|
4
|
+
import type { StateFilter } from '../hooks/useUploaderStateFiles.js'
|
|
5
|
+
|
|
6
|
+
export interface UploaderEachFileProps {
|
|
7
|
+
children: ReactNode
|
|
8
|
+
/** Filter by specific state(s) */
|
|
9
|
+
state?: StateFilter
|
|
10
|
+
/** Fallback when no files match */
|
|
11
|
+
fallback?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Iterates over files in the upload state, providing file state context to children.
|
|
16
|
+
*/
|
|
17
|
+
export function UploaderEachFile({
|
|
18
|
+
children,
|
|
19
|
+
state: stateFilter,
|
|
20
|
+
fallback,
|
|
21
|
+
}: UploaderEachFileProps): ReactNode {
|
|
22
|
+
const files = useUploaderState()
|
|
23
|
+
|
|
24
|
+
const filteredFiles = stateFilter
|
|
25
|
+
? files.filter(file => {
|
|
26
|
+
const filterArray = Array.isArray(stateFilter) ? stateFilter : [stateFilter]
|
|
27
|
+
return filterArray.includes(file.state)
|
|
28
|
+
})
|
|
29
|
+
: files
|
|
30
|
+
|
|
31
|
+
if (filteredFiles.length === 0 && fallback !== undefined) {
|
|
32
|
+
return fallback
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
{filteredFiles.map(file => (
|
|
38
|
+
<Fragment key={file.file.id}>
|
|
39
|
+
<UploaderFileStateContext.Provider value={file}>
|
|
40
|
+
{children}
|
|
41
|
+
</UploaderFileStateContext.Provider>
|
|
42
|
+
</Fragment>
|
|
43
|
+
))}
|
|
44
|
+
</>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode, ComponentType } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
UploaderFileState,
|
|
4
|
+
UploaderFileStateInitial,
|
|
5
|
+
UploaderFileStateUploading,
|
|
6
|
+
UploaderFileStateFinalizing,
|
|
7
|
+
UploaderFileStateSuccess,
|
|
8
|
+
UploaderFileStateError,
|
|
9
|
+
} from '../types.js'
|
|
10
|
+
import { useUploaderFileState } from '../contexts.js'
|
|
11
|
+
|
|
12
|
+
type StateRenderer<TState> = ReactNode | ComponentType<{ state: TState }>
|
|
13
|
+
|
|
14
|
+
export interface UploaderFileStateSwitchProps {
|
|
15
|
+
/** Render for initial state (file selected, pre-upload) */
|
|
16
|
+
initial?: StateRenderer<UploaderFileStateInitial>
|
|
17
|
+
/** Render for uploading state */
|
|
18
|
+
uploading?: StateRenderer<UploaderFileStateUploading>
|
|
19
|
+
/** Render for finalizing state (upload complete, processing) */
|
|
20
|
+
finalizing?: StateRenderer<UploaderFileStateFinalizing>
|
|
21
|
+
/** Render for success state */
|
|
22
|
+
success?: StateRenderer<UploaderFileStateSuccess>
|
|
23
|
+
/** Render for error state */
|
|
24
|
+
error?: StateRenderer<UploaderFileStateError>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderState<TState>(
|
|
28
|
+
renderer: StateRenderer<TState> | undefined,
|
|
29
|
+
state: TState,
|
|
30
|
+
): ReactNode {
|
|
31
|
+
if (renderer === undefined) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
if (typeof renderer === 'function') {
|
|
35
|
+
const Component = renderer as ComponentType<{ state: TState }>
|
|
36
|
+
return <Component state={state} />
|
|
37
|
+
}
|
|
38
|
+
return renderer
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Renders different content based on the current file upload state.
|
|
43
|
+
* Must be used within UploaderEachFile or another component that provides UploaderFileStateContext.
|
|
44
|
+
*/
|
|
45
|
+
export function UploaderFileStateSwitch({
|
|
46
|
+
initial,
|
|
47
|
+
uploading,
|
|
48
|
+
finalizing,
|
|
49
|
+
success,
|
|
50
|
+
error,
|
|
51
|
+
}: UploaderFileStateSwitchProps): ReactNode {
|
|
52
|
+
const state = useUploaderFileState()
|
|
53
|
+
|
|
54
|
+
switch (state.state) {
|
|
55
|
+
case 'initial':
|
|
56
|
+
return renderState(initial, state)
|
|
57
|
+
case 'uploading':
|
|
58
|
+
return renderState(uploading, state)
|
|
59
|
+
case 'finalizing':
|
|
60
|
+
return renderState(finalizing, state)
|
|
61
|
+
case 'success':
|
|
62
|
+
return renderState(success, state)
|
|
63
|
+
case 'error':
|
|
64
|
+
return renderState(error, state)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useUploaderState } from '../contexts.js'
|
|
3
|
+
import type { StateFilter } from '../hooks/useUploaderStateFiles.js'
|
|
4
|
+
|
|
5
|
+
export interface UploaderHasFileProps {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
/** Fallback when no files match */
|
|
8
|
+
fallback?: ReactNode
|
|
9
|
+
/** Filter by specific state(s) */
|
|
10
|
+
state?: StateFilter
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Conditionally renders children if there are files in the upload state.
|
|
15
|
+
*/
|
|
16
|
+
export function UploaderHasFile({
|
|
17
|
+
children,
|
|
18
|
+
fallback,
|
|
19
|
+
state: stateFilter,
|
|
20
|
+
}: UploaderHasFileProps): ReactNode {
|
|
21
|
+
const files = useUploaderState()
|
|
22
|
+
|
|
23
|
+
const hasFiles = stateFilter
|
|
24
|
+
? files.some(file => {
|
|
25
|
+
const filterArray = Array.isArray(stateFilter) ? stateFilter : [stateFilter]
|
|
26
|
+
return filterArray.includes(file.state)
|
|
27
|
+
})
|
|
28
|
+
: files.length > 0
|
|
29
|
+
|
|
30
|
+
if (!hasFiles) {
|
|
31
|
+
return fallback ?? null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return children
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Uploader, UploaderWithMeta, type UploaderProps } from './Uploader.js'
|
|
2
|
+
export { MultiUploader, MultiUploaderWithMeta, type MultiUploaderProps } from './MultiUploader.js'
|
|
3
|
+
export { UploaderEachFile, type UploaderEachFileProps } from './UploaderEachFile.js'
|
|
4
|
+
export { UploaderHasFile, type UploaderHasFileProps } from './UploaderHasFile.js'
|
|
5
|
+
export { UploaderFileStateSwitch, type UploaderFileStateSwitchProps } from './UploaderFileStateSwitch.js'
|
|
6
|
+
export { UploaderDropzoneRoot, type UploaderDropzoneRootProps } from './UploaderDropzoneRoot.js'
|
|
7
|
+
export { UploaderDropzoneArea, type UploaderDropzoneAreaProps } from './UploaderDropzoneArea.js'
|
package/src/contexts.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { DropzoneState } from 'react-dropzone'
|
|
3
|
+
import type { UploaderFileState, UploaderOptions, UploaderState, UploadClient } from './types.js'
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Context Creation Helpers
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
function createRequiredContext<T>(name: string): [React.Context<T | null>, () => T] {
|
|
10
|
+
const Context = createContext<T | null>(null)
|
|
11
|
+
Context.displayName = name
|
|
12
|
+
|
|
13
|
+
const useContextHook = (): T => {
|
|
14
|
+
const value = useContext(Context)
|
|
15
|
+
if (value === null) {
|
|
16
|
+
throw new Error(`${name} must be used within its Provider`)
|
|
17
|
+
}
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return [Context, useContextHook]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Public Contexts
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Context for the upload function
|
|
30
|
+
*/
|
|
31
|
+
export const [UploaderUploadFilesContext, useUploaderUploadFiles] =
|
|
32
|
+
createRequiredContext<(files: File[]) => void>('UploaderUploadFiles')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Context for current upload state
|
|
36
|
+
*/
|
|
37
|
+
export const [UploaderStateContext, useUploaderState] =
|
|
38
|
+
createRequiredContext<UploaderState>('UploaderState')
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Context for uploader options (accept, multiple)
|
|
42
|
+
*/
|
|
43
|
+
export const [UploaderOptionsContext, useUploaderOptions] =
|
|
44
|
+
createRequiredContext<UploaderOptions>('UploaderOptions')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Context for current file state (within UploaderEachFile)
|
|
48
|
+
*/
|
|
49
|
+
export const [UploaderFileStateContext, useUploaderFileState] =
|
|
50
|
+
createRequiredContext<UploaderFileState>('UploaderFileState')
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Context for the default upload client
|
|
54
|
+
*/
|
|
55
|
+
export const UploaderClientContext = createContext<UploadClient<unknown> | null>(null)
|
|
56
|
+
UploaderClientContext.displayName = 'UploaderClient'
|
|
57
|
+
|
|
58
|
+
export const useUploaderClient = (): UploadClient<unknown> | null => {
|
|
59
|
+
return useContext(UploaderClientContext)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Internal Contexts
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Context for multi-uploader entity to file state mapping
|
|
68
|
+
*/
|
|
69
|
+
export const MultiUploaderEntityToFileStateMapContext = createContext<Map<string, string> | null>(null)
|
|
70
|
+
MultiUploaderEntityToFileStateMapContext.displayName = 'MultiUploaderEntityToFileStateMap'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Context for dropzone state from react-dropzone
|
|
74
|
+
*/
|
|
75
|
+
export const [UploaderDropzoneStateContext, useUploaderDropzoneState] =
|
|
76
|
+
createRequiredContext<DropzoneState>('UploaderDropzoneState')
|