@uploadbox/react 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 (52) hide show
  1. package/dist/generate-components.d.ts +5 -0
  2. package/dist/generate-components.d.ts.map +1 -0
  3. package/dist/generate-components.js +9 -0
  4. package/dist/generate-components.js.map +1 -0
  5. package/dist/index.d.ts +11 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +9 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/multipart.d.ts +25 -0
  10. package/dist/multipart.d.ts.map +1 -0
  11. package/dist/multipart.js +130 -0
  12. package/dist/multipart.js.map +1 -0
  13. package/dist/progress-tracker.d.ts +13 -0
  14. package/dist/progress-tracker.d.ts.map +1 -0
  15. package/dist/progress-tracker.js +93 -0
  16. package/dist/progress-tracker.js.map +1 -0
  17. package/dist/provider.d.ts +14 -0
  18. package/dist/provider.d.ts.map +1 -0
  19. package/dist/provider.js +12 -0
  20. package/dist/provider.js.map +1 -0
  21. package/dist/retry.d.ts +5 -0
  22. package/dist/retry.d.ts.map +1 -0
  23. package/dist/retry.js +47 -0
  24. package/dist/retry.js.map +1 -0
  25. package/dist/types.d.ts +86 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/types.js +2 -0
  28. package/dist/types.js.map +1 -0
  29. package/dist/upload-button.d.ts +4 -0
  30. package/dist/upload-button.d.ts.map +1 -0
  31. package/dist/upload-button.js +25 -0
  32. package/dist/upload-button.js.map +1 -0
  33. package/dist/upload-dropzone.d.ts +4 -0
  34. package/dist/upload-dropzone.d.ts.map +1 -0
  35. package/dist/upload-dropzone.js +47 -0
  36. package/dist/upload-dropzone.js.map +1 -0
  37. package/dist/use-uploadbox.d.ts +4 -0
  38. package/dist/use-uploadbox.d.ts.map +1 -0
  39. package/dist/use-uploadbox.js +246 -0
  40. package/dist/use-uploadbox.js.map +1 -0
  41. package/package.json +56 -0
  42. package/src/generate-components.ts +20 -0
  43. package/src/index.ts +22 -0
  44. package/src/multipart.ts +189 -0
  45. package/src/progress-tracker.ts +107 -0
  46. package/src/provider.tsx +34 -0
  47. package/src/retry.ts +62 -0
  48. package/src/styles.css +126 -0
  49. package/src/types.ts +96 -0
  50. package/src/upload-button.tsx +76 -0
  51. package/src/upload-dropzone.tsx +138 -0
  52. package/src/use-uploadbox.ts +333 -0
@@ -0,0 +1,333 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
4
+ import type { FileRouter, RouterConfig } from "@uploadbox/core";
5
+ import type {
6
+ UseUploadboxOpts,
7
+ UseUploadboxReturn,
8
+ UploadedFile,
9
+ FileProgress,
10
+ EnhancedUploadProgressEvent,
11
+ } from "./types.js";
12
+ import { ProgressTracker } from "./progress-tracker.js";
13
+ import { withRetry, DEFAULT_RETRY_CONFIG } from "./retry.js";
14
+ import { shouldUseMultipart, uploadFileMultipart } from "./multipart.js";
15
+ import { useUploadboxConfig } from "./provider.js";
16
+
17
+ const DEFAULT_API_URL = "/api/uploadbox";
18
+
19
+ function buildHeaders(
20
+ providerHeaders?: Record<string, string>,
21
+ providerApiKey?: string,
22
+ optHeaders?: Record<string, string>
23
+ ): Record<string, string> {
24
+ const merged: Record<string, string> = {};
25
+ if (providerHeaders) Object.assign(merged, providerHeaders);
26
+ if (providerApiKey) merged["Authorization"] = `Bearer ${providerApiKey}`;
27
+ if (optHeaders) Object.assign(merged, optHeaders);
28
+ return merged;
29
+ }
30
+
31
+ async function fetchRouterConfig(apiUrl: string, headers?: Record<string, string>): Promise<RouterConfig> {
32
+ const res = await fetch(apiUrl, { headers });
33
+ if (!res.ok) throw new Error("Failed to fetch router config");
34
+ return res.json();
35
+ }
36
+
37
+ async function requestPresignedUrls(
38
+ apiUrl: string,
39
+ routeKey: string,
40
+ files: { name: string; size: number; type: string; customMetadata?: Record<string, string>; ttlSeconds?: number }[],
41
+ headers?: Record<string, string>
42
+ ): Promise<{ uploadId: string; key: string; url: string; name: string; size: number; type: string }[]> {
43
+ const res = await fetch(apiUrl, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json", ...headers },
46
+ body: JSON.stringify({ action: "upload", routeKey, files }),
47
+ });
48
+ if (!res.ok) {
49
+ const err = await res.json();
50
+ throw new Error(err.message || "Failed to get upload URLs");
51
+ }
52
+ return res.json();
53
+ }
54
+
55
+ async function confirmUploads(
56
+ apiUrl: string,
57
+ routeKey: string,
58
+ keys: string[],
59
+ headers?: Record<string, string>
60
+ ): Promise<any[]> {
61
+ const res = await fetch(apiUrl, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json", ...headers },
64
+ body: JSON.stringify({ action: "complete", routeKey, keys }),
65
+ });
66
+ if (!res.ok) {
67
+ const err = await res.json();
68
+ throw new Error(err.message || "Failed to confirm uploads");
69
+ }
70
+ return res.json();
71
+ }
72
+
73
+ function uploadFileWithProgress(
74
+ url: string,
75
+ file: File,
76
+ contentType: string,
77
+ onProgress?: (loaded: number) => void,
78
+ signal?: AbortSignal
79
+ ): Promise<void> {
80
+ return new Promise((resolve, reject) => {
81
+ const xhr = new XMLHttpRequest();
82
+ xhr.open("PUT", url);
83
+ xhr.setRequestHeader("Content-Type", contentType);
84
+
85
+ if (signal) {
86
+ signal.addEventListener("abort", () => xhr.abort(), { once: true });
87
+ }
88
+
89
+ xhr.upload.addEventListener("progress", (event) => {
90
+ if (event.lengthComputable) {
91
+ onProgress?.(event.loaded);
92
+ }
93
+ });
94
+
95
+ xhr.addEventListener("load", () => {
96
+ if (xhr.status >= 200 && xhr.status < 300) {
97
+ resolve();
98
+ } else {
99
+ reject(new Error(`Upload failed with status ${xhr.status}`));
100
+ }
101
+ });
102
+
103
+ xhr.addEventListener("error", () => reject(new Error("Upload failed")));
104
+ xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
105
+
106
+ xhr.send(file);
107
+ });
108
+ }
109
+
110
+ export function useUploadbox<
111
+ TRouter extends FileRouter,
112
+ TEndpoint extends keyof TRouter & string
113
+ >(
114
+ endpoint: TEndpoint,
115
+ opts?: Omit<UseUploadboxOpts<TRouter, TEndpoint>, "endpoint">
116
+ ): UseUploadboxReturn {
117
+ const providerConfig = useUploadboxConfig();
118
+ const apiUrl = providerConfig.apiUrl ?? DEFAULT_API_URL;
119
+ const mergedHeaders = useMemo(
120
+ () => buildHeaders(providerConfig.headers, providerConfig.apiKey, opts?.headers),
121
+ [providerConfig.headers, providerConfig.apiKey, opts?.headers]
122
+ );
123
+
124
+ const [isUploading, setIsUploading] = useState(false);
125
+ const [progress, setProgress] = useState(0);
126
+ const [fileProgress, setFileProgress] = useState<FileProgress[]>([]);
127
+ const [routeConfig, setRouteConfig] = useState<RouterConfig[string] | undefined>();
128
+
129
+ const trackerRef = useRef(new ProgressTracker());
130
+ const abortControllerRef = useRef<AbortController | null>(null);
131
+
132
+ useEffect(() => {
133
+ fetchRouterConfig(apiUrl, mergedHeaders).then((config) => {
134
+ setRouteConfig(config[endpoint]);
135
+ }).catch(console.error);
136
+ }, [endpoint, apiUrl, mergedHeaders]);
137
+
138
+ const abort = useCallback(() => {
139
+ abortControllerRef.current?.abort();
140
+ }, []);
141
+
142
+ const startUpload = useCallback(
143
+ async (inputFiles: File[]): Promise<UploadedFile[] | undefined> => {
144
+ const abortController = new AbortController();
145
+ abortControllerRef.current = abortController;
146
+ const tracker = trackerRef.current;
147
+ tracker.reset();
148
+
149
+ try {
150
+ setIsUploading(true);
151
+ setProgress(0);
152
+
153
+ let filesToUpload = inputFiles;
154
+ if (opts?.onBeforeUploadBegin) {
155
+ filesToUpload = opts.onBeforeUploadBegin(filesToUpload);
156
+ }
157
+
158
+ // Initialize progress tracker
159
+ filesToUpload.forEach((f, i) => {
160
+ tracker.init(String(i), f.name, f.size, f.type || "application/octet-stream");
161
+ });
162
+
163
+ // 1. Build file infos with metadata
164
+ const fileInfos = filesToUpload.map((f) => ({
165
+ name: f.name,
166
+ size: f.size,
167
+ type: f.type || "application/octet-stream",
168
+ ...(opts?.getFileMetadata ? { customMetadata: opts.getFileMetadata(f) } : {}),
169
+ ...(opts?.ttlSeconds != null ? { ttlSeconds: opts.ttlSeconds } : {}),
170
+ }));
171
+
172
+ // Split into small files (single-part) and large files (multipart)
173
+ const smallFileIndices: number[] = [];
174
+ const largeFileIndices: number[] = [];
175
+
176
+ filesToUpload.forEach((f, i) => {
177
+ if (shouldUseMultipart(f.size)) {
178
+ largeFileIndices.push(i);
179
+ } else {
180
+ smallFileIndices.push(i);
181
+ }
182
+ });
183
+
184
+ // 2. Request presigned URLs for small files
185
+ const smallFileInfos = smallFileIndices.map((i) => fileInfos[i]!);
186
+ let presignedResults: { uploadId: string; key: string; url: string; name: string; size: number; type: string }[] = [];
187
+
188
+ if (smallFileInfos.length > 0) {
189
+ presignedResults = await requestPresignedUrls(apiUrl, endpoint, smallFileInfos, mergedHeaders);
190
+ }
191
+
192
+ const retryConfig = opts?.retry === false ? undefined : (opts?.retry ?? DEFAULT_RETRY_CONFIG);
193
+
194
+ // Helper to emit progress
195
+ const emitProgress = () => {
196
+ const snapshot = tracker.getSnapshot();
197
+ setProgress(snapshot.percent);
198
+ setFileProgress(snapshot.fileProgress);
199
+ opts?.onUploadProgress?.(snapshot);
200
+ };
201
+
202
+ // 3. Upload small files with retry
203
+ const smallUploadPromises = presignedResults.map((result, idx) => {
204
+ const fileIndex = smallFileIndices[idx]!;
205
+ const file = filesToUpload[fileIndex]!;
206
+ const fileId = String(fileIndex);
207
+
208
+ tracker.setKey(fileId, result.key);
209
+ tracker.setStatus(fileId, "uploading");
210
+
211
+ const doUpload = async () => {
212
+ await uploadFileWithProgress(
213
+ result.url,
214
+ file,
215
+ file.type || "application/octet-stream",
216
+ (loaded) => {
217
+ tracker.updateProgress(fileId, loaded);
218
+ emitProgress();
219
+ },
220
+ abortController.signal
221
+ );
222
+ };
223
+
224
+ if (retryConfig) {
225
+ return withRetry(
226
+ () => doUpload(),
227
+ retryConfig,
228
+ (attempt) => {
229
+ tracker.incrementRetry(fileId);
230
+ emitProgress();
231
+ },
232
+ abortController.signal
233
+ ).then(() => {
234
+ tracker.setStatus(fileId, "complete");
235
+ emitProgress();
236
+ });
237
+ }
238
+
239
+ return doUpload().then(() => {
240
+ tracker.setStatus(fileId, "complete");
241
+ emitProgress();
242
+ });
243
+ });
244
+
245
+ // 4. Upload large files via multipart
246
+ const largeUploadPromises = largeFileIndices.map(async (fileIndex) => {
247
+ const file = filesToUpload[fileIndex]!;
248
+ const fileId = String(fileIndex);
249
+ const info = fileInfos[fileIndex]!;
250
+
251
+ tracker.setStatus(fileId, "uploading");
252
+
253
+ const result = await uploadFileMultipart({
254
+ file,
255
+ routeKey: endpoint,
256
+ fileInfo: info,
257
+ apiUrl,
258
+ headers: mergedHeaders,
259
+ retryConfig: retryConfig ?? undefined,
260
+ signal: abortController.signal,
261
+ onProgress: (loaded) => {
262
+ tracker.updateProgress(fileId, loaded);
263
+ emitProgress();
264
+ },
265
+ onRetry: () => {
266
+ tracker.incrementRetry(fileId);
267
+ emitProgress();
268
+ },
269
+ });
270
+
271
+ tracker.setKey(fileId, result.key);
272
+ tracker.setStatus(fileId, "complete");
273
+ emitProgress();
274
+
275
+ return result;
276
+ });
277
+
278
+ // Wait for all uploads
279
+ await Promise.all([...smallUploadPromises, ...largeUploadPromises]);
280
+
281
+ // 5. Confirm small file uploads
282
+ const smallKeys = presignedResults.map((r) => r.key);
283
+ let allResults: any[] = [];
284
+
285
+ if (smallKeys.length > 0) {
286
+ const smallConfirm = await confirmUploads(apiUrl, endpoint, smallKeys, mergedHeaders);
287
+ allResults.push(...smallConfirm);
288
+ }
289
+
290
+ // Large file results are already confirmed server-side during multipart complete
291
+ // Add their results too
292
+ for (const idx of largeFileIndices) {
293
+ const fileId = String(idx);
294
+ const fp = tracker.getSnapshot().fileProgress.find((f) => f.fileId === fileId);
295
+ if (fp?.key) {
296
+ allResults.push({
297
+ file: {
298
+ key: fp.key,
299
+ name: fp.name,
300
+ size: fp.size,
301
+ type: fp.type,
302
+ url: "", // URL will come from confirm
303
+ },
304
+ });
305
+ }
306
+ }
307
+
308
+ const uploadedFiles: UploadedFile[] = allResults.map((r) => ({
309
+ key: r.file.key,
310
+ name: r.file.name,
311
+ size: r.file.size,
312
+ type: r.file.type,
313
+ url: r.file.url,
314
+ customMetadata: r.file.customMetadata,
315
+ }));
316
+
317
+ setProgress(100);
318
+ opts?.onClientUploadComplete?.(uploadedFiles);
319
+ return uploadedFiles;
320
+ } catch (err) {
321
+ const error = err instanceof Error ? err : new Error("Upload failed");
322
+ opts?.onUploadError?.(error);
323
+ return undefined;
324
+ } finally {
325
+ setIsUploading(false);
326
+ abortControllerRef.current = null;
327
+ }
328
+ },
329
+ [apiUrl, endpoint, mergedHeaders, opts]
330
+ );
331
+
332
+ return { startUpload, isUploading, progress, routeConfig, fileProgress, abort };
333
+ }