@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.
Files changed (127) hide show
  1. package/dist/UploaderError.d.ts +6 -0
  2. package/dist/UploaderError.d.ts.map +1 -0
  3. package/dist/components/MultiUploader.d.ts +43 -0
  4. package/dist/components/MultiUploader.d.ts.map +1 -0
  5. package/dist/components/Uploader.d.ts +58 -0
  6. package/dist/components/Uploader.d.ts.map +1 -0
  7. package/dist/components/UploaderDropzoneArea.d.ts +6 -0
  8. package/dist/components/UploaderDropzoneArea.d.ts.map +1 -0
  9. package/dist/components/UploaderDropzoneRoot.d.ts +8 -0
  10. package/dist/components/UploaderDropzoneRoot.d.ts.map +1 -0
  11. package/dist/components/UploaderEachFile.d.ts +14 -0
  12. package/dist/components/UploaderEachFile.d.ts.map +1 -0
  13. package/dist/components/UploaderFileStateSwitch.d.ts +24 -0
  14. package/dist/components/UploaderFileStateSwitch.d.ts.map +1 -0
  15. package/dist/components/UploaderHasFile.d.ts +14 -0
  16. package/dist/components/UploaderHasFile.d.ts.map +1 -0
  17. package/dist/components/index.d.ts +8 -0
  18. package/dist/components/index.d.ts.map +1 -0
  19. package/dist/contexts.d.ts +32 -0
  20. package/dist/contexts.d.ts.map +1 -0
  21. package/dist/extractors/getAudioFileDataExtractor.d.ts +9 -0
  22. package/dist/extractors/getAudioFileDataExtractor.d.ts.map +1 -0
  23. package/dist/extractors/getFileUrlDataExtractor.d.ts +9 -0
  24. package/dist/extractors/getFileUrlDataExtractor.d.ts.map +1 -0
  25. package/dist/extractors/getGenericFileMetadataExtractor.d.ts +12 -0
  26. package/dist/extractors/getGenericFileMetadataExtractor.d.ts.map +1 -0
  27. package/dist/extractors/getImageFileDataExtractor.d.ts +10 -0
  28. package/dist/extractors/getImageFileDataExtractor.d.ts.map +1 -0
  29. package/dist/extractors/getVideoFileDataExtractor.d.ts +11 -0
  30. package/dist/extractors/getVideoFileDataExtractor.d.ts.map +1 -0
  31. package/dist/extractors/index.d.ts +6 -0
  32. package/dist/extractors/index.d.ts.map +1 -0
  33. package/dist/extractors/types.d.ts +13 -0
  34. package/dist/extractors/types.d.ts.map +1 -0
  35. package/dist/fileTypes/createAnyFileType.d.ts +27 -0
  36. package/dist/fileTypes/createAnyFileType.d.ts.map +1 -0
  37. package/dist/fileTypes/createAudioFileType.d.ts +29 -0
  38. package/dist/fileTypes/createAudioFileType.d.ts.map +1 -0
  39. package/dist/fileTypes/createImageFileType.d.ts +47 -0
  40. package/dist/fileTypes/createImageFileType.d.ts.map +1 -0
  41. package/dist/fileTypes/createVideoFileType.d.ts +33 -0
  42. package/dist/fileTypes/createVideoFileType.d.ts.map +1 -0
  43. package/dist/fileTypes/index.d.ts +5 -0
  44. package/dist/fileTypes/index.d.ts.map +1 -0
  45. package/dist/hooks/index.d.ts +3 -0
  46. package/dist/hooks/index.d.ts.map +1 -0
  47. package/dist/hooks/useS3Client.d.ts +7 -0
  48. package/dist/hooks/useS3Client.d.ts.map +1 -0
  49. package/dist/hooks/useUploaderStateFiles.d.ts +8 -0
  50. package/dist/hooks/useUploaderStateFiles.d.ts.map +1 -0
  51. package/dist/index.d.ts +10 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/internal/hooks/index.d.ts +5 -0
  54. package/dist/internal/hooks/index.d.ts.map +1 -0
  55. package/dist/internal/hooks/useFillEntity.d.ts +17 -0
  56. package/dist/internal/hooks/useFillEntity.d.ts.map +1 -0
  57. package/dist/internal/hooks/useGetPreviewUrls.d.ts +6 -0
  58. package/dist/internal/hooks/useGetPreviewUrls.d.ts.map +1 -0
  59. package/dist/internal/hooks/useUploadState.d.ts +12 -0
  60. package/dist/internal/hooks/useUploadState.d.ts.map +1 -0
  61. package/dist/internal/hooks/useUploaderDoUpload.d.ts +7 -0
  62. package/dist/internal/hooks/useUploaderDoUpload.d.ts.map +1 -0
  63. package/dist/internal/utils/executeExtractors.d.ts +16 -0
  64. package/dist/internal/utils/executeExtractors.d.ts.map +1 -0
  65. package/dist/internal/utils/index.d.ts +4 -0
  66. package/dist/internal/utils/index.d.ts.map +1 -0
  67. package/dist/internal/utils/resolveAccept.d.ts +7 -0
  68. package/dist/internal/utils/resolveAccept.d.ts.map +1 -0
  69. package/dist/internal/utils/uploaderErrorHandler.d.ts +7 -0
  70. package/dist/internal/utils/uploaderErrorHandler.d.ts.map +1 -0
  71. package/dist/types.d.ts +168 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/uploadClient/S3UploadClient.d.ts +19 -0
  74. package/dist/uploadClient/S3UploadClient.d.ts.map +1 -0
  75. package/dist/uploadClient/index.d.ts +3 -0
  76. package/dist/uploadClient/index.d.ts.map +1 -0
  77. package/dist/uploadClient/types.d.ts +29 -0
  78. package/dist/uploadClient/types.d.ts.map +1 -0
  79. package/dist/utils/attrAccept.d.ts +16 -0
  80. package/dist/utils/attrAccept.d.ts.map +1 -0
  81. package/dist/utils/index.d.ts +3 -0
  82. package/dist/utils/index.d.ts.map +1 -0
  83. package/dist/utils/urlSigner.d.ts +8 -0
  84. package/dist/utils/urlSigner.d.ts.map +1 -0
  85. package/package.json +35 -0
  86. package/src/UploaderError.ts +7 -0
  87. package/src/components/MultiUploader.tsx +210 -0
  88. package/src/components/Uploader.tsx +174 -0
  89. package/src/components/UploaderDropzoneArea.tsx +29 -0
  90. package/src/components/UploaderDropzoneRoot.tsx +32 -0
  91. package/src/components/UploaderEachFile.tsx +46 -0
  92. package/src/components/UploaderFileStateSwitch.tsx +66 -0
  93. package/src/components/UploaderHasFile.tsx +35 -0
  94. package/src/components/index.ts +7 -0
  95. package/src/contexts.ts +76 -0
  96. package/src/extractors/getAudioFileDataExtractor.ts +44 -0
  97. package/src/extractors/getFileUrlDataExtractor.ts +22 -0
  98. package/src/extractors/getGenericFileMetadataExtractor.ts +40 -0
  99. package/src/extractors/getImageFileDataExtractor.ts +50 -0
  100. package/src/extractors/getVideoFileDataExtractor.ts +60 -0
  101. package/src/extractors/index.ts +5 -0
  102. package/src/extractors/types.ts +14 -0
  103. package/src/fileTypes/createAnyFileType.ts +56 -0
  104. package/src/fileTypes/createAudioFileType.ts +67 -0
  105. package/src/fileTypes/createImageFileType.ts +87 -0
  106. package/src/fileTypes/createVideoFileType.ts +75 -0
  107. package/src/fileTypes/index.ts +4 -0
  108. package/src/hooks/index.ts +2 -0
  109. package/src/hooks/useS3Client.ts +26 -0
  110. package/src/hooks/useUploaderStateFiles.ts +22 -0
  111. package/src/index.ts +126 -0
  112. package/src/internal/hooks/index.ts +4 -0
  113. package/src/internal/hooks/useFillEntity.ts +89 -0
  114. package/src/internal/hooks/useGetPreviewUrls.ts +26 -0
  115. package/src/internal/hooks/useUploadState.ts +159 -0
  116. package/src/internal/hooks/useUploaderDoUpload.ts +129 -0
  117. package/src/internal/utils/executeExtractors.ts +46 -0
  118. package/src/internal/utils/index.ts +3 -0
  119. package/src/internal/utils/resolveAccept.ts +26 -0
  120. package/src/internal/utils/uploaderErrorHandler.ts +15 -0
  121. package/src/types.ts +242 -0
  122. package/src/uploadClient/S3UploadClient.ts +119 -0
  123. package/src/uploadClient/index.ts +2 -0
  124. package/src/uploadClient/types.ts +30 -0
  125. package/src/utils/attrAccept.ts +87 -0
  126. package/src/utils/index.ts +2 -0
  127. 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'
@@ -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')