@uploadista/react 0.0.3

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.
@@ -0,0 +1,190 @@
1
+ "use client";
2
+ import type { UploadistaEvent } from "@uploadista/client-browser";
3
+ import type React from "react";
4
+ import { createContext, useCallback, useContext, useMemo, useRef } from "react";
5
+ import {
6
+ type UseUploadistaClientOptions,
7
+ type UseUploadistaClientReturn,
8
+ useUploadistaClient,
9
+ } from "../hooks/use-uploadista-client";
10
+
11
+ /**
12
+ * Props for the UploadistaProvider component.
13
+ * Combines client configuration options with React children.
14
+ *
15
+ * @property children - React components that will have access to the upload client context
16
+ * @property baseUrl - API base URL for uploads
17
+ * @property storageId - Default storage identifier
18
+ * @property chunkSize - Upload chunk size in bytes
19
+ * @property onEvent - Global event handler for all upload events
20
+ * @property ... - All other UploadistaClientOptions
21
+ */
22
+ export interface UploadistaProviderProps extends UseUploadistaClientOptions {
23
+ /**
24
+ * Children components that will have access to the upload client
25
+ */
26
+ children: React.ReactNode;
27
+ }
28
+
29
+ type UploadistaContextValue = UseUploadistaClientReturn & {
30
+ /**
31
+ * Subscribe to events (used internally by hooks)
32
+ * @internal
33
+ */
34
+ subscribeToEvents: (handler: (event: UploadistaEvent) => void) => () => void;
35
+ };
36
+
37
+ const UploadistaContext = createContext<UploadistaContextValue | null>(null);
38
+
39
+ /**
40
+ * Context provider that provides uploadista client functionality to child components.
41
+ * This eliminates the need to pass upload client configuration down through props
42
+ * and ensures a single, shared upload client instance across your application.
43
+ *
44
+ * @param props - Upload client options and children
45
+ * @returns Provider component with upload client context
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * // Wrap your app with the upload provider
50
+ * function App() {
51
+ * return (
52
+ * <UploadistaProvider
53
+ * baseUrl="https://api.example.com"
54
+ * storageId="my-storage"
55
+ * chunkSize={1024 * 1024} // 1MB chunks
56
+ * onEvent={(event) => {
57
+ * console.log('Global upload event:', event);
58
+ * }}
59
+ * >
60
+ * <UploadInterface />
61
+ * </UploadistaProvider>
62
+ * );
63
+ * }
64
+ *
65
+ * // Use the upload client in any child component
66
+ * function UploadInterface() {
67
+ * const uploadClient = useUploadistaContext();
68
+ * const upload = useUpload(uploadClient);
69
+ * const dragDrop = useDragDrop({
70
+ * onFilesReceived: (files) => {
71
+ * files.forEach(file => upload.upload(file));
72
+ * }
73
+ * });
74
+ *
75
+ * return (
76
+ * <div {...dragDrop.dragHandlers}>
77
+ * <p>Drop files here to upload</p>
78
+ * {upload.isUploading && <p>Progress: {upload.state.progress}%</p>}
79
+ * </div>
80
+ * );
81
+ * }
82
+ * ```
83
+ */
84
+ export function UploadistaProvider({
85
+ children,
86
+ ...options
87
+ }: UploadistaProviderProps) {
88
+ const eventSubscribersRef = useRef<Set<(event: UploadistaEvent) => void>>(
89
+ new Set(),
90
+ );
91
+
92
+ // Wrap the original onEvent to broadcast to subscribers
93
+ const wrappedOnEvent = useCallback(
94
+ (event: UploadistaEvent) => {
95
+ console.log("[UploadistaProvider] Received event:", event);
96
+
97
+ // Call original handler if provided
98
+ options.onEvent?.(event);
99
+
100
+ // Broadcast to all subscribers
101
+ console.log(
102
+ "[UploadistaProvider] Broadcasting to",
103
+ eventSubscribersRef.current.size,
104
+ "subscribers",
105
+ );
106
+ eventSubscribersRef.current.forEach((handler) => {
107
+ try {
108
+ handler(event);
109
+ } catch (err) {
110
+ console.error("Error in event subscriber:", err);
111
+ }
112
+ });
113
+ },
114
+ [options.onEvent],
115
+ );
116
+
117
+ const uploadClient = useUploadistaClient({
118
+ ...options,
119
+ onEvent: wrappedOnEvent,
120
+ });
121
+
122
+ const subscribeToEvents = useCallback(
123
+ (handler: (event: UploadistaEvent) => void) => {
124
+ eventSubscribersRef.current.add(handler);
125
+ return () => {
126
+ eventSubscribersRef.current.delete(handler);
127
+ };
128
+ },
129
+ [],
130
+ );
131
+
132
+ // Memoize the context value to prevent unnecessary re-renders
133
+ const contextValue = useMemo(
134
+ () => ({
135
+ ...uploadClient,
136
+ subscribeToEvents,
137
+ }),
138
+ [uploadClient, subscribeToEvents],
139
+ );
140
+
141
+ return (
142
+ <UploadistaContext.Provider value={contextValue}>
143
+ {children}
144
+ </UploadistaContext.Provider>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Hook to access the uploadista client from the UploadistaProvider context.
150
+ * Must be used within an UploadistaProvider component.
151
+ *
152
+ * @returns Upload client instance from context
153
+ * @throws Error if used outside of UploadistaProvider
154
+ *
155
+ * @example
156
+ * ```tsx
157
+ * function FileUploader() {
158
+ * const uploadClient = useUploadistaContext();
159
+ * const upload = useUpload(uploadClient);
160
+ *
161
+ * return (
162
+ * <button
163
+ * onClick={() => {
164
+ * const input = document.createElement('input');
165
+ * input.type = 'file';
166
+ * input.onchange = (e) => {
167
+ * const file = (e.target as HTMLInputElement).files?.[0];
168
+ * if (file) upload.upload(file);
169
+ * };
170
+ * input.click();
171
+ * }}
172
+ * >
173
+ * Upload File
174
+ * </button>
175
+ * );
176
+ * }
177
+ * ```
178
+ */
179
+ export function useUploadistaContext(): UploadistaContextValue {
180
+ const context = useContext(UploadistaContext);
181
+
182
+ if (context === null) {
183
+ throw new Error(
184
+ "useUploadistaContext must be used within an UploadistaProvider. " +
185
+ "Make sure to wrap your component tree with <UploadistaProvider>.",
186
+ );
187
+ }
188
+
189
+ return context;
190
+ }
@@ -0,0 +1,404 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+
3
+ export interface DragDropOptions {
4
+ /**
5
+ * Accept specific file types (MIME types or file extensions)
6
+ */
7
+ accept?: string[];
8
+
9
+ /**
10
+ * Maximum number of files allowed
11
+ */
12
+ maxFiles?: number;
13
+
14
+ /**
15
+ * Maximum file size in bytes
16
+ */
17
+ maxFileSize?: number;
18
+
19
+ /**
20
+ * Whether to allow multiple files
21
+ */
22
+ multiple?: boolean;
23
+
24
+ /**
25
+ * Custom validation function for files
26
+ */
27
+ validator?: (files: File[]) => string[] | null;
28
+
29
+ /**
30
+ * Called when files are dropped or selected
31
+ */
32
+ onFilesReceived?: (files: File[]) => void;
33
+
34
+ /**
35
+ * Called when validation fails
36
+ */
37
+ onValidationError?: (errors: string[]) => void;
38
+
39
+ /**
40
+ * Called when drag state changes
41
+ */
42
+ onDragStateChange?: (isDragging: boolean) => void;
43
+ }
44
+
45
+ export interface DragDropState {
46
+ /**
47
+ * Whether files are currently being dragged over the drop zone
48
+ */
49
+ isDragging: boolean;
50
+
51
+ /**
52
+ * Whether the drag is currently over the drop zone
53
+ */
54
+ isOver: boolean;
55
+
56
+ /**
57
+ * Whether the dragged items are valid files
58
+ */
59
+ isValid: boolean;
60
+
61
+ /**
62
+ * Current validation errors
63
+ */
64
+ errors: string[];
65
+ }
66
+
67
+ export interface UseDragDropReturn {
68
+ /**
69
+ * Current drag and drop state
70
+ */
71
+ state: DragDropState;
72
+
73
+ /**
74
+ * Event handlers for the drop zone element
75
+ */
76
+ dragHandlers: {
77
+ onDragEnter: (event: React.DragEvent) => void;
78
+ onDragOver: (event: React.DragEvent) => void;
79
+ onDragLeave: (event: React.DragEvent) => void;
80
+ onDrop: (event: React.DragEvent) => void;
81
+ };
82
+
83
+ /**
84
+ * Props for a file input element
85
+ */
86
+ inputProps: {
87
+ type: "file";
88
+ multiple: boolean;
89
+ accept?: string;
90
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
91
+ style: { display: "none" };
92
+ };
93
+
94
+ /**
95
+ * Open file picker dialog
96
+ */
97
+ openFilePicker: () => void;
98
+
99
+ /**
100
+ * Manually process files (useful for programmatic file handling)
101
+ */
102
+ processFiles: (files: File[]) => void;
103
+
104
+ /**
105
+ * Reset drag state
106
+ */
107
+ reset: () => void;
108
+ }
109
+
110
+ const initialState: DragDropState = {
111
+ isDragging: false,
112
+ isOver: false,
113
+ isValid: true,
114
+ errors: [],
115
+ };
116
+
117
+ /**
118
+ * React hook for handling drag and drop file uploads with validation.
119
+ * Provides drag state management, file validation, and file picker integration.
120
+ *
121
+ * @param options - Configuration and event handlers
122
+ * @returns Drag and drop state and handlers
123
+ *
124
+ * @example
125
+ * ```tsx
126
+ * const dragDrop = useDragDrop({
127
+ * accept: ['image/*', '.pdf'],
128
+ * maxFiles: 5,
129
+ * maxFileSize: 10 * 1024 * 1024, // 10MB
130
+ * multiple: true,
131
+ * onFilesReceived: (files) => {
132
+ * console.log('Received files:', files);
133
+ * // Process files with upload hooks
134
+ * },
135
+ * onValidationError: (errors) => {
136
+ * console.error('Validation errors:', errors);
137
+ * },
138
+ * });
139
+ *
140
+ * return (
141
+ * <div>
142
+ * <div
143
+ * {...dragDrop.dragHandlers}
144
+ * style={{
145
+ * border: dragDrop.state.isDragging ? '2px dashed #007bff' : '2px dashed #ccc',
146
+ * backgroundColor: dragDrop.state.isOver ? '#f8f9fa' : 'transparent',
147
+ * padding: '2rem',
148
+ * textAlign: 'center',
149
+ * cursor: 'pointer',
150
+ * }}
151
+ * onClick={dragDrop.openFilePicker}
152
+ * >
153
+ * {dragDrop.state.isDragging ? (
154
+ * <p>Drop files here...</p>
155
+ * ) : (
156
+ * <p>Drag files here or click to select</p>
157
+ * )}
158
+ *
159
+ * {dragDrop.state.errors.length > 0 && (
160
+ * <div style={{ color: 'red', marginTop: '1rem' }}>
161
+ * {dragDrop.state.errors.map((error, index) => (
162
+ * <p key={index}>{error}</p>
163
+ * ))}
164
+ * </div>
165
+ * )}
166
+ * </div>
167
+ *
168
+ * <input {...dragDrop.inputProps} />
169
+ * </div>
170
+ * );
171
+ * ```
172
+ */
173
+ export function useDragDrop(options: DragDropOptions = {}): UseDragDropReturn {
174
+ const {
175
+ accept,
176
+ maxFiles,
177
+ maxFileSize,
178
+ multiple = true,
179
+ validator,
180
+ onFilesReceived,
181
+ onValidationError,
182
+ onDragStateChange,
183
+ } = options;
184
+
185
+ const [state, setState] = useState<DragDropState>(initialState);
186
+ const inputRef = useRef<HTMLInputElement>(null);
187
+ const dragCounterRef = useRef(0);
188
+
189
+ const updateState = useCallback((update: Partial<DragDropState>) => {
190
+ setState((prev) => ({ ...prev, ...update }));
191
+ }, []);
192
+
193
+ const validateFiles = useCallback(
194
+ (files: File[]): string[] => {
195
+ const errors: string[] = [];
196
+
197
+ // Check file count
198
+ if (maxFiles && files.length > maxFiles) {
199
+ errors.push(
200
+ `Maximum ${maxFiles} files allowed. You selected ${files.length} files.`,
201
+ );
202
+ }
203
+
204
+ // Check individual files
205
+ for (const file of files) {
206
+ // Check file size
207
+ if (maxFileSize && file.size > maxFileSize) {
208
+ const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
209
+ const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1);
210
+ errors.push(
211
+ `File "${file.name}" (${fileSizeMB}MB) exceeds maximum size of ${maxSizeMB}MB.`,
212
+ );
213
+ }
214
+
215
+ // Check file type
216
+ if (accept && accept.length > 0) {
217
+ const isAccepted = accept.some((acceptType) => {
218
+ if (acceptType.startsWith(".")) {
219
+ // File extension check
220
+ return file.name.toLowerCase().endsWith(acceptType.toLowerCase());
221
+ } else {
222
+ // MIME type check (supports wildcards like image/*)
223
+ if (acceptType.endsWith("/*")) {
224
+ const baseType = acceptType.slice(0, -2);
225
+ return file.type.startsWith(baseType);
226
+ } else {
227
+ return file.type === acceptType;
228
+ }
229
+ }
230
+ });
231
+
232
+ if (!isAccepted) {
233
+ errors.push(
234
+ `File "${file.name}" type "${file.type}" is not accepted. Accepted types: ${accept.join(", ")}.`,
235
+ );
236
+ }
237
+ }
238
+ }
239
+
240
+ // Run custom validator
241
+ if (validator) {
242
+ const customErrors = validator(files);
243
+ if (customErrors) {
244
+ errors.push(...customErrors);
245
+ }
246
+ }
247
+
248
+ return errors;
249
+ },
250
+ [accept, maxFiles, maxFileSize, validator],
251
+ );
252
+
253
+ const processFiles = useCallback(
254
+ (files: File[]) => {
255
+ const fileArray = Array.from(files);
256
+ const errors = validateFiles(fileArray);
257
+
258
+ if (errors.length > 0) {
259
+ updateState({ errors, isValid: false });
260
+ onValidationError?.(errors);
261
+ } else {
262
+ updateState({ errors: [], isValid: true });
263
+ onFilesReceived?.(fileArray);
264
+ }
265
+ },
266
+ [validateFiles, updateState, onFilesReceived, onValidationError],
267
+ );
268
+
269
+ const getFilesFromDataTransfer = useCallback(
270
+ (dataTransfer: DataTransfer): File[] => {
271
+ const files: File[] = [];
272
+
273
+ if (dataTransfer.items) {
274
+ // Use DataTransferItemList interface
275
+ for (let i = 0; i < dataTransfer.items.length; i++) {
276
+ const item = dataTransfer.items[i];
277
+ if (item && item.kind === "file") {
278
+ const file = item.getAsFile();
279
+ if (file) {
280
+ files.push(file);
281
+ }
282
+ }
283
+ }
284
+ } else {
285
+ // Fallback to DataTransfer.files
286
+ for (let i = 0; i < dataTransfer.files.length; i++) {
287
+ const file = dataTransfer.files[i];
288
+ if (file) {
289
+ files.push(file);
290
+ }
291
+ }
292
+ }
293
+
294
+ return files;
295
+ },
296
+ [],
297
+ );
298
+
299
+ const onDragEnter = useCallback(
300
+ (event: React.DragEvent) => {
301
+ event.preventDefault();
302
+ event.stopPropagation();
303
+
304
+ dragCounterRef.current++;
305
+
306
+ if (dragCounterRef.current === 1) {
307
+ updateState({ isDragging: true, isOver: true });
308
+ onDragStateChange?.(true);
309
+ }
310
+ },
311
+ [updateState, onDragStateChange],
312
+ );
313
+
314
+ const onDragOver = useCallback((event: React.DragEvent) => {
315
+ event.preventDefault();
316
+ event.stopPropagation();
317
+
318
+ // Set dropEffect to indicate what operation is allowed
319
+ if (event.dataTransfer) {
320
+ event.dataTransfer.dropEffect = "copy";
321
+ }
322
+ }, []);
323
+
324
+ const onDragLeave = useCallback(
325
+ (event: React.DragEvent) => {
326
+ event.preventDefault();
327
+ event.stopPropagation();
328
+
329
+ dragCounterRef.current--;
330
+
331
+ if (dragCounterRef.current === 0) {
332
+ updateState({ isDragging: false, isOver: false, errors: [] });
333
+ onDragStateChange?.(false);
334
+ }
335
+ },
336
+ [updateState, onDragStateChange],
337
+ );
338
+
339
+ const onDrop = useCallback(
340
+ (event: React.DragEvent) => {
341
+ event.preventDefault();
342
+ event.stopPropagation();
343
+
344
+ dragCounterRef.current = 0;
345
+ updateState({ isDragging: false, isOver: false });
346
+ onDragStateChange?.(false);
347
+
348
+ if (event.dataTransfer) {
349
+ const files = getFilesFromDataTransfer(event.dataTransfer);
350
+ if (files.length > 0) {
351
+ processFiles(files);
352
+ }
353
+ }
354
+ },
355
+ [updateState, onDragStateChange, getFilesFromDataTransfer, processFiles],
356
+ );
357
+
358
+ const openFilePicker = useCallback(() => {
359
+ inputRef.current?.click();
360
+ }, []);
361
+
362
+ const onInputChange = useCallback(
363
+ (event: React.ChangeEvent<HTMLInputElement>) => {
364
+ if (event.target.files && event.target.files.length > 0) {
365
+ const files = Array.from(event.target.files);
366
+ processFiles(files);
367
+ }
368
+
369
+ // Reset input value to allow selecting the same files again
370
+ event.target.value = "";
371
+ },
372
+ [processFiles],
373
+ );
374
+
375
+ const reset = useCallback(() => {
376
+ setState(initialState);
377
+ dragCounterRef.current = 0;
378
+ }, []);
379
+
380
+ const dragHandlers = {
381
+ onDragEnter,
382
+ onDragOver,
383
+ onDragLeave,
384
+ onDrop,
385
+ };
386
+
387
+ const inputProps = {
388
+ type: "file" as const,
389
+ multiple,
390
+ accept: accept?.join(", "),
391
+ onChange: onInputChange,
392
+ style: { display: "none" as const },
393
+ ref: inputRef,
394
+ };
395
+
396
+ return {
397
+ state,
398
+ dragHandlers,
399
+ inputProps,
400
+ openFilePicker,
401
+ processFiles,
402
+ reset,
403
+ };
404
+ }