@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.
- package/dist/generate-components.d.ts +5 -0
- package/dist/generate-components.d.ts.map +1 -0
- package/dist/generate-components.js +9 -0
- package/dist/generate-components.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/multipart.d.ts +25 -0
- package/dist/multipart.d.ts.map +1 -0
- package/dist/multipart.js +130 -0
- package/dist/multipart.js.map +1 -0
- package/dist/progress-tracker.d.ts +13 -0
- package/dist/progress-tracker.d.ts.map +1 -0
- package/dist/progress-tracker.js +93 -0
- package/dist/progress-tracker.js.map +1 -0
- package/dist/provider.d.ts +14 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +12 -0
- package/dist/provider.js.map +1 -0
- package/dist/retry.d.ts +5 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +47 -0
- package/dist/retry.js.map +1 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/upload-button.d.ts +4 -0
- package/dist/upload-button.d.ts.map +1 -0
- package/dist/upload-button.js +25 -0
- package/dist/upload-button.js.map +1 -0
- package/dist/upload-dropzone.d.ts +4 -0
- package/dist/upload-dropzone.d.ts.map +1 -0
- package/dist/upload-dropzone.js +47 -0
- package/dist/upload-dropzone.js.map +1 -0
- package/dist/use-uploadbox.d.ts +4 -0
- package/dist/use-uploadbox.d.ts.map +1 -0
- package/dist/use-uploadbox.js +246 -0
- package/dist/use-uploadbox.js.map +1 -0
- package/package.json +56 -0
- package/src/generate-components.ts +20 -0
- package/src/index.ts +22 -0
- package/src/multipart.ts +189 -0
- package/src/progress-tracker.ts +107 -0
- package/src/provider.tsx +34 -0
- package/src/retry.ts +62 -0
- package/src/styles.css +126 -0
- package/src/types.ts +96 -0
- package/src/upload-button.tsx +76 -0
- package/src/upload-dropzone.tsx +138 -0
- package/src/use-uploadbox.ts +333 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { FileProgress, FileUploadStatus, EnhancedUploadProgressEvent } from "./types.js";
|
|
2
|
+
|
|
3
|
+
interface SpeedSample {
|
|
4
|
+
timestamp: number;
|
|
5
|
+
loaded: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SPEED_WINDOW_MS = 3000;
|
|
9
|
+
|
|
10
|
+
export class ProgressTracker {
|
|
11
|
+
private files = new Map<string, FileProgress>();
|
|
12
|
+
private speedSamples = new Map<string, SpeedSample[]>();
|
|
13
|
+
|
|
14
|
+
init(fileId: string, name: string, size: number, type: string): void {
|
|
15
|
+
this.files.set(fileId, {
|
|
16
|
+
fileId,
|
|
17
|
+
name,
|
|
18
|
+
size,
|
|
19
|
+
type,
|
|
20
|
+
status: "pending",
|
|
21
|
+
loaded: 0,
|
|
22
|
+
percent: 0,
|
|
23
|
+
speed: 0,
|
|
24
|
+
eta: 0,
|
|
25
|
+
retryCount: 0,
|
|
26
|
+
});
|
|
27
|
+
this.speedSamples.set(fileId, []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
updateProgress(fileId: string, loaded: number): void {
|
|
31
|
+
const file = this.files.get(fileId);
|
|
32
|
+
if (!file) return;
|
|
33
|
+
|
|
34
|
+
file.loaded = loaded;
|
|
35
|
+
file.percent = file.size > 0 ? Math.round((loaded / file.size) * 100) : 0;
|
|
36
|
+
file.status = "uploading";
|
|
37
|
+
|
|
38
|
+
// Update speed samples
|
|
39
|
+
const samples = this.speedSamples.get(fileId) ?? [];
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
samples.push({ timestamp: now, loaded });
|
|
42
|
+
|
|
43
|
+
// Prune old samples
|
|
44
|
+
const cutoff = now - SPEED_WINDOW_MS;
|
|
45
|
+
const validSamples = samples.filter((s) => s.timestamp >= cutoff);
|
|
46
|
+
this.speedSamples.set(fileId, validSamples);
|
|
47
|
+
|
|
48
|
+
// Calculate speed from sliding window
|
|
49
|
+
if (validSamples.length >= 2) {
|
|
50
|
+
const oldest = validSamples[0]!;
|
|
51
|
+
const newest = validSamples[validSamples.length - 1]!;
|
|
52
|
+
const timeDiff = (newest.timestamp - oldest.timestamp) / 1000;
|
|
53
|
+
if (timeDiff > 0) {
|
|
54
|
+
file.speed = (newest.loaded - oldest.loaded) / timeDiff;
|
|
55
|
+
const remaining = file.size - loaded;
|
|
56
|
+
file.eta = file.speed > 0 ? remaining / file.speed : 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setStatus(fileId: string, status: FileUploadStatus, error?: string): void {
|
|
62
|
+
const file = this.files.get(fileId);
|
|
63
|
+
if (!file) return;
|
|
64
|
+
file.status = status;
|
|
65
|
+
if (error) file.error = error;
|
|
66
|
+
if (status === "complete") {
|
|
67
|
+
file.loaded = file.size;
|
|
68
|
+
file.percent = 100;
|
|
69
|
+
file.eta = 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setKey(fileId: string, key: string): void {
|
|
74
|
+
const file = this.files.get(fileId);
|
|
75
|
+
if (file) file.key = key;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
incrementRetry(fileId: string): void {
|
|
79
|
+
const file = this.files.get(fileId);
|
|
80
|
+
if (!file) return;
|
|
81
|
+
file.retryCount++;
|
|
82
|
+
file.status = "retrying";
|
|
83
|
+
file.loaded = 0;
|
|
84
|
+
file.percent = 0;
|
|
85
|
+
file.speed = 0;
|
|
86
|
+
file.eta = 0;
|
|
87
|
+
this.speedSamples.set(fileId, []);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getSnapshot(): EnhancedUploadProgressEvent {
|
|
91
|
+
const allFiles = Array.from(this.files.values());
|
|
92
|
+
const totalSize = allFiles.reduce((sum, f) => sum + f.size, 0);
|
|
93
|
+
const totalLoaded = allFiles.reduce((sum, f) => sum + f.loaded, 0);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
loaded: totalLoaded,
|
|
97
|
+
total: totalSize,
|
|
98
|
+
percent: totalSize > 0 ? Math.round((totalLoaded / totalSize) * 100) : 0,
|
|
99
|
+
fileProgress: allFiles.map((f) => ({ ...f })),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
reset(): void {
|
|
104
|
+
this.files.clear();
|
|
105
|
+
this.speedSamples.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
export interface UploadboxContextValue {
|
|
6
|
+
/** Base URL for the upload API. Defaults to "/api/uploadbox". */
|
|
7
|
+
apiUrl?: string;
|
|
8
|
+
/** API key for hosted mode — sent as Authorization: Bearer. */
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
/** Extra headers to include with every request. */
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const UploadboxContext = createContext<UploadboxContextValue>({});
|
|
15
|
+
|
|
16
|
+
export function UploadboxProvider({
|
|
17
|
+
children,
|
|
18
|
+
...config
|
|
19
|
+
}: UploadboxContextValue & { children: React.ReactNode }) {
|
|
20
|
+
const value = useMemo(
|
|
21
|
+
() => ({ apiUrl: config.apiUrl, apiKey: config.apiKey, headers: config.headers }),
|
|
22
|
+
[config.apiUrl, config.apiKey, config.headers]
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<UploadboxContext.Provider value={value}>
|
|
27
|
+
{children}
|
|
28
|
+
</UploadboxContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useUploadboxConfig(): UploadboxContextValue {
|
|
33
|
+
return useContext(UploadboxContext);
|
|
34
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { RetryConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
4
|
+
maxRetries: 3,
|
|
5
|
+
initialDelayMs: 1000,
|
|
6
|
+
backoffMultiplier: 2,
|
|
7
|
+
maxDelayMs: 30000,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function isRetryableError(status: number): boolean {
|
|
11
|
+
if (status === 0 || status === 408 || status === 429) return true;
|
|
12
|
+
return status >= 500 && status < 600;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function withRetry<T>(
|
|
16
|
+
fn: (attempt: number) => Promise<T>,
|
|
17
|
+
config: RetryConfig,
|
|
18
|
+
onRetry?: (attempt: number, error: Error) => void,
|
|
19
|
+
signal?: AbortSignal
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
let lastError: Error | undefined;
|
|
22
|
+
|
|
23
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
24
|
+
if (signal?.aborted) {
|
|
25
|
+
throw new Error("Upload aborted");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
return await fn(attempt);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
32
|
+
|
|
33
|
+
if (attempt >= config.maxRetries) break;
|
|
34
|
+
|
|
35
|
+
// Check if the error contains a retryable status
|
|
36
|
+
const statusMatch = lastError.message.match(/status (\d+)/);
|
|
37
|
+
if (statusMatch) {
|
|
38
|
+
const status = parseInt(statusMatch[1]!, 10);
|
|
39
|
+
if (!isRetryableError(status)) break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const delay = Math.min(
|
|
43
|
+
config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt),
|
|
44
|
+
config.maxDelayMs
|
|
45
|
+
);
|
|
46
|
+
// Add jitter: 0.5x to 1.5x of delay
|
|
47
|
+
const jitteredDelay = delay * (0.5 + Math.random());
|
|
48
|
+
|
|
49
|
+
onRetry?.(attempt + 1, lastError);
|
|
50
|
+
|
|
51
|
+
await new Promise<void>((resolve, reject) => {
|
|
52
|
+
const timeout = setTimeout(resolve, jitteredDelay);
|
|
53
|
+
signal?.addEventListener("abort", () => {
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
reject(new Error("Upload aborted"));
|
|
56
|
+
}, { once: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw lastError ?? new Error("Retry failed");
|
|
62
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
.uploadbox-button-wrapper {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: 0.5rem;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.uploadbox-button {
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
padding: 0.5rem 1.25rem;
|
|
13
|
+
font-size: 0.875rem;
|
|
14
|
+
font-weight: 500;
|
|
15
|
+
line-height: 1.25rem;
|
|
16
|
+
color: #fff;
|
|
17
|
+
background-color: #2563eb;
|
|
18
|
+
border: none;
|
|
19
|
+
border-radius: 0.5rem;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
transition: background-color 0.15s ease;
|
|
22
|
+
min-width: 120px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.uploadbox-button:hover:not(:disabled) {
|
|
26
|
+
background-color: #1d4ed8;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.uploadbox-button:disabled {
|
|
30
|
+
opacity: 0.5;
|
|
31
|
+
cursor: not-allowed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.uploadbox-progress-bar {
|
|
35
|
+
width: 100%;
|
|
36
|
+
max-width: 200px;
|
|
37
|
+
height: 4px;
|
|
38
|
+
background-color: #e5e7eb;
|
|
39
|
+
border-radius: 2px;
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.uploadbox-progress-bar-fill {
|
|
44
|
+
height: 100%;
|
|
45
|
+
background-color: #2563eb;
|
|
46
|
+
border-radius: 2px;
|
|
47
|
+
transition: width 0.2s ease;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Dropzone */
|
|
51
|
+
.uploadbox-dropzone {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
padding: 2rem;
|
|
57
|
+
border: 2px dashed #d1d5db;
|
|
58
|
+
border-radius: 0.75rem;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
transition: all 0.15s ease;
|
|
61
|
+
text-align: center;
|
|
62
|
+
min-height: 200px;
|
|
63
|
+
background-color: #fafafa;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.uploadbox-dropzone:hover {
|
|
67
|
+
border-color: #9ca3af;
|
|
68
|
+
background-color: #f5f5f5;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.uploadbox-dropzone--dragover {
|
|
72
|
+
border-color: #2563eb;
|
|
73
|
+
background-color: #eff6ff;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.uploadbox-dropzone--uploading {
|
|
77
|
+
border-color: #2563eb;
|
|
78
|
+
cursor: default;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.uploadbox-dropzone--complete {
|
|
82
|
+
border-color: #16a34a;
|
|
83
|
+
background-color: #f0fdf4;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.uploadbox-dropzone--error {
|
|
87
|
+
border-color: #dc2626;
|
|
88
|
+
background-color: #fef2f2;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.uploadbox-dropzone-content {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: 0.75rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.uploadbox-dropzone-icon {
|
|
99
|
+
color: #9ca3af;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.uploadbox-dropzone-label {
|
|
103
|
+
font-size: 0.875rem;
|
|
104
|
+
color: #6b7280;
|
|
105
|
+
margin: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.uploadbox-dropzone-allowed {
|
|
109
|
+
font-size: 0.75rem;
|
|
110
|
+
color: #9ca3af;
|
|
111
|
+
margin: 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.uploadbox-dropzone-selected {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 0.5rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.uploadbox-dropzone-selected p {
|
|
122
|
+
font-size: 0.8125rem;
|
|
123
|
+
color: #374151;
|
|
124
|
+
margin: 0;
|
|
125
|
+
font-weight: 500;
|
|
126
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { FileRouter, RouterConfig } from "@uploadbox/core";
|
|
2
|
+
|
|
3
|
+
export type EndpointHelper<TRouter extends FileRouter> = keyof TRouter & string;
|
|
4
|
+
|
|
5
|
+
export interface UploadProgressEvent {
|
|
6
|
+
loaded: number;
|
|
7
|
+
total: number;
|
|
8
|
+
percent: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UploadedFile {
|
|
12
|
+
key: string;
|
|
13
|
+
name: string;
|
|
14
|
+
size: number;
|
|
15
|
+
type: string;
|
|
16
|
+
url: string;
|
|
17
|
+
customMetadata?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type FileUploadStatus = "pending" | "uploading" | "retrying" | "complete" | "error";
|
|
21
|
+
|
|
22
|
+
export interface FileProgress {
|
|
23
|
+
fileId: string;
|
|
24
|
+
name: string;
|
|
25
|
+
size: number;
|
|
26
|
+
type: string;
|
|
27
|
+
status: FileUploadStatus;
|
|
28
|
+
loaded: number;
|
|
29
|
+
percent: number;
|
|
30
|
+
speed: number;
|
|
31
|
+
eta: number;
|
|
32
|
+
error?: string;
|
|
33
|
+
retryCount: number;
|
|
34
|
+
key?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EnhancedUploadProgressEvent extends UploadProgressEvent {
|
|
38
|
+
fileProgress: FileProgress[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RetryConfig {
|
|
42
|
+
maxRetries: number;
|
|
43
|
+
initialDelayMs: number;
|
|
44
|
+
backoffMultiplier: number;
|
|
45
|
+
maxDelayMs: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UseUploadboxOpts<TRouter extends FileRouter, TEndpoint extends keyof TRouter & string> {
|
|
49
|
+
endpoint: TEndpoint;
|
|
50
|
+
onClientUploadComplete?: (files: UploadedFile[]) => void;
|
|
51
|
+
onUploadError?: (error: Error) => void;
|
|
52
|
+
onUploadProgress?: (progress: EnhancedUploadProgressEvent) => void;
|
|
53
|
+
onBeforeUploadBegin?: (files: File[]) => File[];
|
|
54
|
+
headers?: Record<string, string>;
|
|
55
|
+
getFileMetadata?: (file: File) => Record<string, string>;
|
|
56
|
+
ttlSeconds?: number;
|
|
57
|
+
retry?: RetryConfig | false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UseUploadboxReturn {
|
|
61
|
+
startUpload: (files: File[]) => Promise<UploadedFile[] | undefined>;
|
|
62
|
+
isUploading: boolean;
|
|
63
|
+
progress: number;
|
|
64
|
+
routeConfig: RouterConfig[string] | undefined;
|
|
65
|
+
fileProgress: FileProgress[];
|
|
66
|
+
abort: () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UploadButtonProps<TRouter extends FileRouter, TEndpoint extends keyof TRouter & string> {
|
|
70
|
+
endpoint: TEndpoint;
|
|
71
|
+
onClientUploadComplete?: (files: UploadedFile[]) => void;
|
|
72
|
+
onUploadError?: (error: Error) => void;
|
|
73
|
+
onUploadProgress?: (progress: UploadProgressEvent) => void;
|
|
74
|
+
onBeforeUploadBegin?: (files: File[]) => File[];
|
|
75
|
+
className?: string;
|
|
76
|
+
disabled?: boolean;
|
|
77
|
+
content?: {
|
|
78
|
+
button?: string;
|
|
79
|
+
allowedContent?: string;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface UploadDropzoneProps<TRouter extends FileRouter, TEndpoint extends keyof TRouter & string> {
|
|
84
|
+
endpoint: TEndpoint;
|
|
85
|
+
onClientUploadComplete?: (files: UploadedFile[]) => void;
|
|
86
|
+
onUploadError?: (error: Error) => void;
|
|
87
|
+
onUploadProgress?: (progress: UploadProgressEvent) => void;
|
|
88
|
+
onBeforeUploadBegin?: (files: File[]) => File[];
|
|
89
|
+
className?: string;
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
content?: {
|
|
92
|
+
label?: string;
|
|
93
|
+
allowedContent?: string;
|
|
94
|
+
button?: string;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useCallback } from "react";
|
|
4
|
+
import type { FileRouter } from "@uploadbox/core";
|
|
5
|
+
import type { UploadButtonProps } from "./types.js";
|
|
6
|
+
import { useUploadbox } from "./use-uploadbox.js";
|
|
7
|
+
|
|
8
|
+
export function UploadButton<
|
|
9
|
+
TRouter extends FileRouter,
|
|
10
|
+
TEndpoint extends keyof TRouter & string
|
|
11
|
+
>(props: UploadButtonProps<TRouter, TEndpoint>) {
|
|
12
|
+
const {
|
|
13
|
+
endpoint,
|
|
14
|
+
onClientUploadComplete,
|
|
15
|
+
onUploadError,
|
|
16
|
+
onUploadProgress,
|
|
17
|
+
onBeforeUploadBegin,
|
|
18
|
+
className,
|
|
19
|
+
disabled,
|
|
20
|
+
content,
|
|
21
|
+
} = props;
|
|
22
|
+
|
|
23
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
|
+
|
|
25
|
+
const { startUpload, isUploading, progress } = useUploadbox<TRouter, TEndpoint>(
|
|
26
|
+
endpoint,
|
|
27
|
+
{ onClientUploadComplete, onUploadError, onUploadProgress, onBeforeUploadBegin }
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const handleClick = useCallback(() => {
|
|
31
|
+
inputRef.current?.click();
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const handleChange = useCallback(
|
|
35
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
36
|
+
const files = Array.from(e.target.files ?? []);
|
|
37
|
+
if (files.length > 0) {
|
|
38
|
+
await startUpload(files);
|
|
39
|
+
}
|
|
40
|
+
// Reset input so the same file can be selected again
|
|
41
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
42
|
+
},
|
|
43
|
+
[startUpload]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={`uploadbox-button-wrapper ${className ?? ""}`}>
|
|
48
|
+
<input
|
|
49
|
+
ref={inputRef}
|
|
50
|
+
type="file"
|
|
51
|
+
multiple
|
|
52
|
+
onChange={handleChange}
|
|
53
|
+
style={{ display: "none" }}
|
|
54
|
+
disabled={disabled || isUploading}
|
|
55
|
+
/>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className="uploadbox-button"
|
|
59
|
+
onClick={handleClick}
|
|
60
|
+
disabled={disabled || isUploading}
|
|
61
|
+
>
|
|
62
|
+
{isUploading
|
|
63
|
+
? `Uploading... ${progress}%`
|
|
64
|
+
: content?.button ?? "Upload Files"}
|
|
65
|
+
</button>
|
|
66
|
+
{isUploading && (
|
|
67
|
+
<div className="uploadbox-progress-bar">
|
|
68
|
+
<div
|
|
69
|
+
className="uploadbox-progress-bar-fill"
|
|
70
|
+
style={{ width: `${progress}%` }}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback } from "react";
|
|
4
|
+
import { useDropzone } from "react-dropzone";
|
|
5
|
+
import type { FileRouter } from "@uploadbox/core";
|
|
6
|
+
import type { UploadDropzoneProps } from "./types.js";
|
|
7
|
+
import { useUploadbox } from "./use-uploadbox.js";
|
|
8
|
+
|
|
9
|
+
type DropzoneState = "idle" | "dragover" | "uploading" | "complete" | "error";
|
|
10
|
+
|
|
11
|
+
export function UploadDropzone<
|
|
12
|
+
TRouter extends FileRouter,
|
|
13
|
+
TEndpoint extends keyof TRouter & string
|
|
14
|
+
>(props: UploadDropzoneProps<TRouter, TEndpoint>) {
|
|
15
|
+
const {
|
|
16
|
+
endpoint,
|
|
17
|
+
onClientUploadComplete,
|
|
18
|
+
onUploadError,
|
|
19
|
+
onUploadProgress,
|
|
20
|
+
onBeforeUploadBegin,
|
|
21
|
+
className,
|
|
22
|
+
disabled,
|
|
23
|
+
content,
|
|
24
|
+
} = props;
|
|
25
|
+
|
|
26
|
+
const [state, setState] = useState<DropzoneState>("idle");
|
|
27
|
+
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
28
|
+
|
|
29
|
+
const { startUpload, isUploading, progress } = useUploadbox<TRouter, TEndpoint>(
|
|
30
|
+
endpoint,
|
|
31
|
+
{
|
|
32
|
+
onClientUploadComplete: (files) => {
|
|
33
|
+
setState("complete");
|
|
34
|
+
setSelectedFiles([]);
|
|
35
|
+
onClientUploadComplete?.(files);
|
|
36
|
+
setTimeout(() => setState("idle"), 2000);
|
|
37
|
+
},
|
|
38
|
+
onUploadError: (error) => {
|
|
39
|
+
setState("error");
|
|
40
|
+
onUploadError?.(error);
|
|
41
|
+
setTimeout(() => setState("idle"), 3000);
|
|
42
|
+
},
|
|
43
|
+
onUploadProgress,
|
|
44
|
+
onBeforeUploadBegin,
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
49
|
+
setSelectedFiles(acceptedFiles);
|
|
50
|
+
setState("idle");
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
54
|
+
onDrop,
|
|
55
|
+
disabled: disabled || isUploading,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const handleUpload = useCallback(async () => {
|
|
59
|
+
if (selectedFiles.length === 0) return;
|
|
60
|
+
setState("uploading");
|
|
61
|
+
await startUpload(selectedFiles);
|
|
62
|
+
}, [selectedFiles, startUpload]);
|
|
63
|
+
|
|
64
|
+
const stateClass = isDragActive ? "dragover" : state;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
{...getRootProps()}
|
|
69
|
+
className={`uploadbox-dropzone uploadbox-dropzone--${stateClass} ${className ?? ""}`}
|
|
70
|
+
>
|
|
71
|
+
<input {...getInputProps()} />
|
|
72
|
+
|
|
73
|
+
{state === "complete" ? (
|
|
74
|
+
<div className="uploadbox-dropzone-content">
|
|
75
|
+
<p className="uploadbox-dropzone-label">Upload complete!</p>
|
|
76
|
+
</div>
|
|
77
|
+
) : state === "error" ? (
|
|
78
|
+
<div className="uploadbox-dropzone-content">
|
|
79
|
+
<p className="uploadbox-dropzone-label">Upload failed. Try again.</p>
|
|
80
|
+
</div>
|
|
81
|
+
) : (
|
|
82
|
+
<div className="uploadbox-dropzone-content">
|
|
83
|
+
<svg
|
|
84
|
+
className="uploadbox-dropzone-icon"
|
|
85
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
86
|
+
width="40"
|
|
87
|
+
height="40"
|
|
88
|
+
viewBox="0 0 24 24"
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
strokeWidth="1.5"
|
|
92
|
+
strokeLinecap="round"
|
|
93
|
+
strokeLinejoin="round"
|
|
94
|
+
>
|
|
95
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
96
|
+
<polyline points="17 8 12 3 7 8" />
|
|
97
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
98
|
+
</svg>
|
|
99
|
+
|
|
100
|
+
<p className="uploadbox-dropzone-label">
|
|
101
|
+
{isDragActive
|
|
102
|
+
? "Drop files here"
|
|
103
|
+
: content?.label ?? "Drag & drop files here, or click to browse"}
|
|
104
|
+
</p>
|
|
105
|
+
|
|
106
|
+
{content?.allowedContent && (
|
|
107
|
+
<p className="uploadbox-dropzone-allowed">{content.allowedContent}</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{selectedFiles.length > 0 && !isUploading && (
|
|
111
|
+
<div className="uploadbox-dropzone-selected">
|
|
112
|
+
<p>{selectedFiles.length} file(s) selected</p>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
className="uploadbox-button"
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
handleUpload();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{content?.button ?? "Upload"}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{isUploading && (
|
|
127
|
+
<div className="uploadbox-progress-bar">
|
|
128
|
+
<div
|
|
129
|
+
className="uploadbox-progress-bar-fill"
|
|
130
|
+
style={{ width: `${progress}%` }}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|