uplofile 0.1.1 → 0.1.2
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/README.md +48 -12
- package/dist/index.cjs +36 -36
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -36
- package/dist/index.mjs +36 -36
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Composable file upload component for React. Build your own UI with small, access
|
|
|
23
23
|
Client component (e.g. in Next.js add "use client"):
|
|
24
24
|
|
|
25
25
|
### 1) Minimal usage
|
|
26
|
+
|
|
26
27
|
The smallest working setup with a single button that opens the file picker.
|
|
27
28
|
|
|
28
29
|
```tsx
|
|
@@ -32,7 +33,9 @@ import * as FileUploader from "uplofile";
|
|
|
32
33
|
|
|
33
34
|
export default function Basic() {
|
|
34
35
|
return (
|
|
35
|
-
<FileUploader.Root
|
|
36
|
+
<FileUploader.Root
|
|
37
|
+
upload={async (file) => ({ url: URL.createObjectURL(file) })}
|
|
38
|
+
>
|
|
36
39
|
<FileUploader.Trigger>
|
|
37
40
|
<button type="button">Select file</button>
|
|
38
41
|
</FileUploader.Trigger>
|
|
@@ -47,6 +50,7 @@ export default function Basic() {
|
|
|
47
50
|
---
|
|
48
51
|
|
|
49
52
|
### 2) Multiple files and a hidden input (form friendly)
|
|
53
|
+
|
|
50
54
|
Add `multiple` and a `name` so successful uploads are available as JSON in a hidden input for regular form posts.
|
|
51
55
|
|
|
52
56
|
```tsx
|
|
@@ -63,13 +67,18 @@ Add `multiple` and a `name` so successful uploads are available as JSON in a hid
|
|
|
63
67
|
---
|
|
64
68
|
|
|
65
69
|
### 3) Providing a real upload function
|
|
70
|
+
|
|
66
71
|
Use fetch for a simple upload, or XHR to report progress. You can keep this in a separate module.
|
|
67
72
|
|
|
68
73
|
```ts
|
|
69
74
|
import type { UploadResult } from "uplofile";
|
|
70
75
|
|
|
71
76
|
export function makeFetchUploader(endpoint: string, fieldName = "file") {
|
|
72
|
-
return async function upload(
|
|
77
|
+
return async function upload(
|
|
78
|
+
file: File,
|
|
79
|
+
signal: AbortSignal,
|
|
80
|
+
setProgress?: (pct: number) => void,
|
|
81
|
+
): Promise<UploadResult> {
|
|
73
82
|
if (setProgress) {
|
|
74
83
|
// XHR branch for progress
|
|
75
84
|
const form = new FormData();
|
|
@@ -82,8 +91,12 @@ export function makeFetchUploader(endpoint: string, fieldName = "file") {
|
|
|
82
91
|
};
|
|
83
92
|
xhr.onload = () => {
|
|
84
93
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
85
|
-
try {
|
|
86
|
-
|
|
94
|
+
try {
|
|
95
|
+
const json = JSON.parse(xhr.responseText);
|
|
96
|
+
resolve({ url: json.url, id: json.id });
|
|
97
|
+
} catch {
|
|
98
|
+
resolve({ url: xhr.responseText });
|
|
99
|
+
}
|
|
87
100
|
} else reject(new Error(`Upload failed (${xhr.status})`));
|
|
88
101
|
};
|
|
89
102
|
xhr.onerror = () => reject(new Error("Network error"));
|
|
@@ -100,8 +113,13 @@ export function makeFetchUploader(endpoint: string, fieldName = "file") {
|
|
|
100
113
|
form.append(fieldName, file);
|
|
101
114
|
const res = await fetch(endpoint, { method: "POST", body: form, signal });
|
|
102
115
|
if (!res.ok) throw new Error(`Upload failed (${res.status})`);
|
|
103
|
-
try {
|
|
104
|
-
|
|
116
|
+
try {
|
|
117
|
+
const json = await res.json();
|
|
118
|
+
return { url: json.url, id: json.id };
|
|
119
|
+
} catch {
|
|
120
|
+
const text = await res.text();
|
|
121
|
+
return { url: text };
|
|
122
|
+
}
|
|
105
123
|
};
|
|
106
124
|
}
|
|
107
125
|
|
|
@@ -111,6 +129,7 @@ export const upload = makeFetchUploader("/api/upload");
|
|
|
111
129
|
---
|
|
112
130
|
|
|
113
131
|
### 4) A nicer trigger with render props
|
|
132
|
+
|
|
114
133
|
Show live progress and counts without building a full preview.
|
|
115
134
|
|
|
116
135
|
```tsx
|
|
@@ -129,6 +148,7 @@ Show live progress and counts without building a full preview.
|
|
|
129
148
|
---
|
|
130
149
|
|
|
131
150
|
### 5) Custom preview UI with actions
|
|
151
|
+
|
|
132
152
|
Render thumbnails, a progress bar, and actions like cancel/retry/remove.
|
|
133
153
|
|
|
134
154
|
```tsx
|
|
@@ -141,20 +161,32 @@ Render thumbnails, a progress bar, and actions like cancel/retry/remove.
|
|
|
141
161
|
|
|
142
162
|
{item.status === "uploading" && (
|
|
143
163
|
<div>
|
|
144
|
-
<div
|
|
164
|
+
<div
|
|
165
|
+
style={{
|
|
166
|
+
width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
145
169
|
</div>
|
|
146
170
|
)}
|
|
147
171
|
|
|
148
|
-
{item.status === "error" &&
|
|
172
|
+
{item.status === "error" && (
|
|
173
|
+
<div>{item.error ?? "Upload failed"}</div>
|
|
174
|
+
)}
|
|
149
175
|
|
|
150
176
|
<div>
|
|
151
177
|
{item.status === "uploading" && (
|
|
152
|
-
<button type="button" onClick={() => actions.cancel(item.uid)}>
|
|
178
|
+
<button type="button" onClick={() => actions.cancel(item.uid)}>
|
|
179
|
+
Cancel
|
|
180
|
+
</button>
|
|
153
181
|
)}
|
|
154
182
|
{(item.status === "error" || item.status === "canceled") && (
|
|
155
|
-
<button type="button" onClick={() => actions.retry(item.uid)}>
|
|
183
|
+
<button type="button" onClick={() => actions.retry(item.uid)}>
|
|
184
|
+
Retry
|
|
185
|
+
</button>
|
|
156
186
|
)}
|
|
157
|
-
<button type="button" onClick={() => actions.remove(item.uid)}>
|
|
187
|
+
<button type="button" onClick={() => actions.remove(item.uid)}>
|
|
188
|
+
Remove
|
|
189
|
+
</button>
|
|
158
190
|
</div>
|
|
159
191
|
</div>
|
|
160
192
|
))}
|
|
@@ -169,6 +201,7 @@ Render thumbnails, a progress bar, and actions like cancel/retry/remove.
|
|
|
169
201
|
---
|
|
170
202
|
|
|
171
203
|
### 6) Drag-and-drop (Dropzone)
|
|
204
|
+
|
|
172
205
|
If your package includes a `Dropzone` primitive, surface it here. If not, you can skip this section.
|
|
173
206
|
|
|
174
207
|
```tsx
|
|
@@ -182,6 +215,7 @@ If your package includes a `Dropzone` primitive, surface it here. If not, you ca
|
|
|
182
215
|
---
|
|
183
216
|
|
|
184
217
|
### 7) Putting it together (complete example)
|
|
218
|
+
|
|
185
219
|
A compact, end-to-end example using multiple files, custom trigger, preview, and a hidden input for form submit.
|
|
186
220
|
|
|
187
221
|
```tsx
|
|
@@ -197,7 +231,9 @@ export default function Example() {
|
|
|
197
231
|
<FileUploader.Trigger
|
|
198
232
|
render={({ isUploading, totalProgress, items }) => (
|
|
199
233
|
<button type="button" data-loading={isUploading || undefined}>
|
|
200
|
-
{isUploading
|
|
234
|
+
{isUploading
|
|
235
|
+
? `Uploading ${totalProgress ?? 0}%`
|
|
236
|
+
: "Select Images"}
|
|
201
237
|
<span> ({items.length})</span>
|
|
202
238
|
</button>
|
|
203
239
|
)}
|
package/dist/index.cjs
CHANGED
|
@@ -7,7 +7,7 @@ var react = require('react');
|
|
|
7
7
|
const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
|
|
8
8
|
|
|
9
9
|
const UploaderCtx = /*#__PURE__*/ react.createContext(null);
|
|
10
|
-
const Root = ({ multiple = true, initial = [], onChange, upload, removeMode =
|
|
10
|
+
const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
|
|
11
11
|
const [items, setItems] = react.useState([]);
|
|
12
12
|
const controllers = react.useRef(new Map());
|
|
13
13
|
const removeControllers = react.useRef(new Map());
|
|
@@ -22,7 +22,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
22
22
|
id: it.id,
|
|
23
23
|
name: it.name,
|
|
24
24
|
url: it.url,
|
|
25
|
-
status:
|
|
25
|
+
status: "done"
|
|
26
26
|
};
|
|
27
27
|
});
|
|
28
28
|
// Only hydrate if the user hasn't already added/modified items locally
|
|
@@ -31,14 +31,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
31
31
|
initial
|
|
32
32
|
]);
|
|
33
33
|
const hiddenInputValue = react.useMemo(()=>{
|
|
34
|
-
const done = items.filter((i)=>i.status ===
|
|
34
|
+
const done = items.filter((i)=>i.status === "done" && i.url);
|
|
35
35
|
return JSON.stringify(done.map(({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest })=>rest));
|
|
36
36
|
}, [
|
|
37
37
|
items
|
|
38
38
|
]);
|
|
39
39
|
const emitChange = react.useCallback((next)=>{
|
|
40
40
|
setItems((prev)=>{
|
|
41
|
-
const nextState = typeof next ===
|
|
41
|
+
const nextState = typeof next === "function" ? next(prev) : next;
|
|
42
42
|
if (onChange) Promise.resolve(onChange(nextState)).catch(()=>{});
|
|
43
43
|
return nextState;
|
|
44
44
|
});
|
|
@@ -57,7 +57,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
57
57
|
};
|
|
58
58
|
emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
|
|
59
59
|
...it,
|
|
60
|
-
status:
|
|
60
|
+
status: "uploading",
|
|
61
61
|
error: undefined
|
|
62
62
|
} : it));
|
|
63
63
|
try {
|
|
@@ -65,7 +65,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
65
65
|
emitChange((items)=>items.map((it)=>{
|
|
66
66
|
if (it.uid !== item.uid) return it;
|
|
67
67
|
// Revoke the local objectURL preview to avoid memory leaks
|
|
68
|
-
if (it.previewUrl && it.previewUrl.startsWith(
|
|
68
|
+
if (it.previewUrl && it.previewUrl.startsWith("blob:")) {
|
|
69
69
|
try {
|
|
70
70
|
URL.revokeObjectURL(it.previewUrl);
|
|
71
71
|
} catch {
|
|
@@ -77,7 +77,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
77
77
|
});
|
|
78
78
|
return {
|
|
79
79
|
...it,
|
|
80
|
-
status:
|
|
80
|
+
status: "done",
|
|
81
81
|
url: result.url,
|
|
82
82
|
id: result.id,
|
|
83
83
|
previewUrl: serverPreview,
|
|
@@ -88,8 +88,8 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
88
88
|
const wasAborted = controller.signal.aborted;
|
|
89
89
|
emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
|
|
90
90
|
...it,
|
|
91
|
-
status: wasAborted ?
|
|
92
|
-
error: wasAborted ? undefined : err?.message ||
|
|
91
|
+
status: wasAborted ? "canceled" : "error",
|
|
92
|
+
error: wasAborted ? undefined : err?.message || "Upload failed"
|
|
93
93
|
} : it));
|
|
94
94
|
} finally{
|
|
95
95
|
controllers.current.delete(item.uid);
|
|
@@ -101,14 +101,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
101
101
|
const selectFiles = react.useCallback((files)=>{
|
|
102
102
|
if (!files || files.length === 0) return;
|
|
103
103
|
const selected = Array.from(files);
|
|
104
|
-
const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !==
|
|
105
|
-
const toUse = typeof remaining ===
|
|
104
|
+
const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== "canceled").length) : undefined;
|
|
105
|
+
const toUse = typeof remaining === "number" ? selected.slice(0, remaining) : selected;
|
|
106
106
|
const newItems = toUse.map((file)=>({
|
|
107
107
|
uid: uid(),
|
|
108
108
|
name: file.name,
|
|
109
109
|
file,
|
|
110
110
|
previewUrl: URL.createObjectURL(file),
|
|
111
|
-
status:
|
|
111
|
+
status: "idle",
|
|
112
112
|
progress: 0
|
|
113
113
|
}));
|
|
114
114
|
emitChange([
|
|
@@ -124,7 +124,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
124
124
|
]);
|
|
125
125
|
const onInputChange = react.useCallback((e)=>{
|
|
126
126
|
selectFiles(e.target.files);
|
|
127
|
-
e.currentTarget.value =
|
|
127
|
+
e.currentTarget.value = "";
|
|
128
128
|
}, [
|
|
129
129
|
selectFiles
|
|
130
130
|
]);
|
|
@@ -148,13 +148,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
148
148
|
// abort any in-flight upload first
|
|
149
149
|
controllers.current.get(uidStr)?.abort();
|
|
150
150
|
// If no server-side removal needed or not uploaded yet, just remove
|
|
151
|
-
if (!onRemove || item.status !==
|
|
151
|
+
if (!onRemove || item.status !== "done") {
|
|
152
152
|
emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
155
|
const ctrl = new AbortController();
|
|
156
156
|
removeControllers.current.set(uidStr, ctrl);
|
|
157
|
-
if (removeMode ===
|
|
157
|
+
if (removeMode === "optimistic") {
|
|
158
158
|
const prev = items;
|
|
159
159
|
// remove from UI immediately
|
|
160
160
|
emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
|
|
@@ -170,7 +170,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
170
170
|
// strict: mark as removing, wait for API, then remove
|
|
171
171
|
emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
|
|
172
172
|
...it,
|
|
173
|
-
status:
|
|
173
|
+
status: "removing"
|
|
174
174
|
} : it));
|
|
175
175
|
try {
|
|
176
176
|
await onRemove(item, ctrl.signal);
|
|
@@ -179,7 +179,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
179
179
|
// revert to done if delete fails
|
|
180
180
|
emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
|
|
181
181
|
...it,
|
|
182
|
-
status:
|
|
182
|
+
status: "done"
|
|
183
183
|
} : it));
|
|
184
184
|
} finally{
|
|
185
185
|
removeControllers.current.delete(uidStr);
|
|
@@ -192,13 +192,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
192
192
|
if (item.file) {
|
|
193
193
|
void startUpload({
|
|
194
194
|
...item,
|
|
195
|
-
status:
|
|
195
|
+
status: "idle",
|
|
196
196
|
error: undefined,
|
|
197
197
|
progress: 0
|
|
198
198
|
});
|
|
199
199
|
emitChange((items)=>items.map((it)=>it.uid === uidStr ? {
|
|
200
200
|
...it,
|
|
201
|
-
status:
|
|
201
|
+
status: "idle",
|
|
202
202
|
error: undefined,
|
|
203
203
|
progress: 0
|
|
204
204
|
} : it));
|
|
@@ -230,16 +230,16 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
230
230
|
disabled
|
|
231
231
|
},
|
|
232
232
|
getDropzoneProps: ()=>({
|
|
233
|
-
role:
|
|
233
|
+
role: "button",
|
|
234
234
|
tabIndex: 0,
|
|
235
235
|
onDrop,
|
|
236
236
|
onDragOver,
|
|
237
237
|
onKeyDown: (e)=>{
|
|
238
238
|
if (disabled) return;
|
|
239
|
-
if (e.key ===
|
|
239
|
+
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
|
240
240
|
},
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
"data-disabled": disabled ? "" : undefined,
|
|
242
|
+
"data-multiple": multiple ? "" : undefined
|
|
243
243
|
}),
|
|
244
244
|
hiddenInputValue,
|
|
245
245
|
name
|
|
@@ -278,7 +278,7 @@ const Dropzone = ({ asChild, ...rest })=>{
|
|
|
278
278
|
|
|
279
279
|
const Preview = ({ render, className })=>{
|
|
280
280
|
const { items, actions } = useImageUploader();
|
|
281
|
-
if (render && typeof render ===
|
|
281
|
+
if (render && typeof render === "function") {
|
|
282
282
|
return render({
|
|
283
283
|
items,
|
|
284
284
|
actions
|
|
@@ -302,7 +302,7 @@ const Preview = ({ render, className })=>{
|
|
|
302
302
|
className: "flex h-32 w-full items-center justify-center text-xs text-gray-500",
|
|
303
303
|
children: "No preview"
|
|
304
304
|
}),
|
|
305
|
-
item.status ===
|
|
305
|
+
item.status === "uploading" && /*#__PURE__*/ jsxRuntime.jsx("div", {
|
|
306
306
|
className: "absolute bottom-0 left-0 right-0 h-1 bg-gray-200",
|
|
307
307
|
children: /*#__PURE__*/ jsxRuntime.jsx("div", {
|
|
308
308
|
className: "h-full bg-black/80",
|
|
@@ -314,13 +314,13 @@ const Preview = ({ render, className })=>{
|
|
|
314
314
|
/*#__PURE__*/ jsxRuntime.jsxs("div", {
|
|
315
315
|
className: "absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2",
|
|
316
316
|
children: [
|
|
317
|
-
item.status ===
|
|
317
|
+
item.status === "uploading" && /*#__PURE__*/ jsxRuntime.jsx("button", {
|
|
318
318
|
type: "button",
|
|
319
319
|
className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
|
|
320
320
|
onClick: ()=>actions.cancel(item.uid),
|
|
321
321
|
children: "Cancel"
|
|
322
322
|
}),
|
|
323
|
-
(item.status ===
|
|
323
|
+
(item.status === "error" || item.status === "canceled") && /*#__PURE__*/ jsxRuntime.jsx("button", {
|
|
324
324
|
type: "button",
|
|
325
325
|
className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
|
|
326
326
|
onClick: ()=>actions.retry(item.uid),
|
|
@@ -349,7 +349,7 @@ const HiddenInput = ({ name })=>{
|
|
|
349
349
|
};
|
|
350
350
|
const Cancel = ({ uid, asChild, ...rest })=>{
|
|
351
351
|
const { actions } = useImageUploader();
|
|
352
|
-
const Comp = asChild ? reactSlot.Slot :
|
|
352
|
+
const Comp = asChild ? reactSlot.Slot : "button";
|
|
353
353
|
return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
|
|
354
354
|
onClick: (e)=>{
|
|
355
355
|
e.stopPropagation();
|
|
@@ -360,7 +360,7 @@ const Cancel = ({ uid, asChild, ...rest })=>{
|
|
|
360
360
|
};
|
|
361
361
|
const Retry = ({ uid, asChild, ...rest })=>{
|
|
362
362
|
const { actions } = useImageUploader();
|
|
363
|
-
const Comp = asChild ? reactSlot.Slot :
|
|
363
|
+
const Comp = asChild ? reactSlot.Slot : "button";
|
|
364
364
|
return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
|
|
365
365
|
onClick: (e)=>{
|
|
366
366
|
e.stopPropagation();
|
|
@@ -371,7 +371,7 @@ const Retry = ({ uid, asChild, ...rest })=>{
|
|
|
371
371
|
};
|
|
372
372
|
const Remove = ({ uid, asChild, ...rest })=>{
|
|
373
373
|
const { actions } = useImageUploader();
|
|
374
|
-
const Comp = asChild ? reactSlot.Slot :
|
|
374
|
+
const Comp = asChild ? reactSlot.Slot : "button";
|
|
375
375
|
return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
|
|
376
376
|
onClick: (e)=>{
|
|
377
377
|
e.stopPropagation();
|
|
@@ -383,12 +383,12 @@ const Remove = ({ uid, asChild, ...rest })=>{
|
|
|
383
383
|
|
|
384
384
|
const Trigger = ({ asChild, children, render, ...rest })=>{
|
|
385
385
|
const { openFileDialog, disabled, items } = useImageUploader();
|
|
386
|
-
const Comp = asChild ? reactSlot.Slot :
|
|
387
|
-
const uploading = items.filter((i)=>i.status ===
|
|
386
|
+
const Comp = asChild ? reactSlot.Slot : "button";
|
|
387
|
+
const uploading = items.filter((i)=>i.status === "uploading");
|
|
388
388
|
const uploadingCount = uploading.length;
|
|
389
|
-
const doneCount = items.filter((i)=>i.status ===
|
|
390
|
-
const errorCount = items.filter((i)=>i.status ===
|
|
391
|
-
const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress ===
|
|
389
|
+
const doneCount = items.filter((i)=>i.status === "done").length;
|
|
390
|
+
const errorCount = items.filter((i)=>i.status === "error").length;
|
|
391
|
+
const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === "number" ? it.progress : 0), 0) / uploadingCount) : undefined;
|
|
392
392
|
const api = {
|
|
393
393
|
items,
|
|
394
394
|
isUploading: uploadingCount > 0,
|
|
@@ -399,7 +399,7 @@ const Trigger = ({ asChild, children, render, ...rest })=>{
|
|
|
399
399
|
open: openFileDialog
|
|
400
400
|
};
|
|
401
401
|
return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
|
|
402
|
-
type: asChild ? undefined :
|
|
402
|
+
type: asChild ? undefined : "button",
|
|
403
403
|
"aria-disabled": disabled,
|
|
404
404
|
"data-part": "trigger",
|
|
405
405
|
onClick: (e)=>{
|
package/dist/index.d.mts
CHANGED
|
@@ -87,7 +87,7 @@ type Props = {
|
|
|
87
87
|
render?: (api: PreviewRenderProps) => React.ReactNode;
|
|
88
88
|
className?: string;
|
|
89
89
|
};
|
|
90
|
-
declare const Preview: ({ render, className }: Props) => string | number | boolean |
|
|
90
|
+
declare const Preview: ({ render, className }: Props) => string | number | boolean | Iterable<React.ReactNode> | react_jsx_runtime.JSX.Element | null | undefined;
|
|
91
91
|
declare const HiddenInput: ({ name }: {
|
|
92
92
|
name?: string;
|
|
93
93
|
}) => react_jsx_runtime.JSX.Element;
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { ButtonHTMLAttributes } from 'react'\n\nimport { useImageUploader } from '../hook'\n\nimport type { PreviewRenderProps } from '../types'\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode\n className?: string\n}\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader()\n\n if (render && typeof render === 'function') {\n return render({ items, actions })\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === 'uploading' && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{ width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%` }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === 'uploading' && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === 'error' || item.status === 'canceled') && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n )\n}\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader()\n return <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n}\n\ntype ButtonProps = {\n uid: string\n asChild?: boolean\n} & ButtonHTMLAttributes<HTMLButtonElement>\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.cancel(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.retry(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.remove(uid)\n }}\n {...rest}\n />\n )\n}\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { PropsWithChildren } from 'react'\n\nimport { useImageUploader } from '../hook'\nimport type { TriggerRenderProps } from '../types'\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean\n render?: (api: TriggerRenderProps) => React.ReactNode\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode)\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n\n const uploading = items.filter((i) => i.status === 'uploading')\n const uploadingCount = uploading.length\n const doneCount = items.filter((i) => i.status === 'done').length\n const errorCount = items.filter((i) => i.status === 'error').length\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) => acc + (typeof it.progress === 'number' ? it.progress : 0),\n 0\n ) / uploadingCount\n )\n : undefined\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n }\n\n return (\n <Comp\n type={asChild ? undefined : 'button'}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return\n ;(rest as any).onClick?.(e)\n openFileDialog()\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n )\n}\n","import type { DragEvent, RefObject } from 'react'\nimport React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'\n\nimport type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem } from './types'\nimport { uid } from './utils'\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(null)\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = 'optimistic',\n onRemove,\n accept = 'image/*',\n name = 'images',\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([])\n const controllers = useRef(new Map<string, AbortController>())\n const removeControllers = useRef(new Map<string, AbortController>())\n const inputRef = useRef<HTMLInputElement | null>(null)\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? []\n if (!Array.isArray(arr)) return\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: 'done',\n } as UploadFileItem\n })\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev))\n }, [initial])\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === 'done' && i.url)\n return JSON.stringify(\n done.map(\n ({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest }) =>\n rest\n )\n )\n }, [items])\n\n const emitChange = useCallback(\n (next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[])) => {\n setItems((prev) => {\n const nextState = typeof next === 'function' ? (next as any)(prev) : next\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {})\n return nextState\n })\n },\n [onChange]\n )\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return\n const controller = new AbortController()\n controllers.current.set(item.uid, controller)\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, progress: Math.max(0, Math.min(100, pct)) } : it\n )\n )\n }\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, status: 'uploading', error: undefined } : it\n )\n )\n\n try {\n const result = await upload(item.file, controller.signal, setProgress)\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith('blob:')) {\n try {\n URL.revokeObjectURL(it.previewUrl)\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url\n console.log({ result })\n return {\n ...it,\n status: 'done',\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n }\n })\n )\n } catch (err: any) {\n const wasAborted = controller.signal.aborted\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? 'canceled' : 'error',\n error: wasAborted ? undefined : err?.message || 'Upload failed',\n }\n : it\n )\n )\n } finally {\n controllers.current.delete(item.uid)\n }\n },\n [emitChange, upload]\n )\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return\n const selected = Array.from(files)\n const remaining = maxCount\n ? Math.max(0, maxCount - items.filter((i) => i.status !== 'canceled').length)\n : undefined\n const toUse = typeof remaining === 'number' ? selected.slice(0, remaining) : selected\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: 'idle',\n progress: 0,\n }))\n\n emitChange([...items, ...newItems])\n newItems.forEach((it) => startUpload(it))\n },\n [emitChange, items, maxCount, startUpload]\n )\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files)\n e.currentTarget.value = ''\n },\n [selectFiles]\n )\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault()\n if (disabled) return\n selectFiles(e.dataTransfer.files)\n },\n [disabled, selectFiles]\n )\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), [])\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr)\n ctrl?.abort()\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort()\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== 'done') {\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n return\n }\n\n const ctrl = new AbortController()\n removeControllers.current.set(uidStr, ctrl)\n\n if (removeMode === 'optimistic') {\n const prev = items\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n try {\n await onRemove(item, ctrl.signal)\n } catch {\n // rollback UI if server delete fails\n emitChange(prev)\n } finally {\n removeControllers.current.delete(uidStr)\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'removing' as const } : it))\n )\n try {\n await onRemove(item, ctrl.signal)\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'done' as const } : it))\n )\n } finally {\n removeControllers.current.delete(uidStr)\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n if (item.file) {\n void startUpload({ ...item, status: 'idle', error: undefined, progress: 0 })\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr ? { ...it, status: 'idle', error: undefined, progress: 0 } : it\n )\n )\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload]\n )\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl))\n controllers.current.forEach((c) => c.abort())\n },\n []\n )\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: 'button',\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return\n if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click()\n },\n 'data-disabled': disabled ? '' : undefined,\n 'data-multiple': multiple ? '' : undefined,\n }),\n hiddenInputValue,\n name,\n }\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n )\n}\n","import { useContext } from 'react'\n\nimport { UploaderCtx } from './context'\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx) throw new Error(\"ImageUploader components must be used within <ImageUploader.Root>\");\n return ctx;\n};"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
|
|
1
|
+
{"version":3,"file":"index.d.mts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { ButtonHTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nimport type { PreviewRenderProps } from \"../types\";\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode;\n className?: string;\n};\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader();\n\n if (render && typeof render === \"function\") {\n return render({ items, actions });\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === \"uploading\" && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{\n width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,\n }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === \"uploading\" && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === \"error\" || item.status === \"canceled\") && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n};\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader();\n return (\n <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n );\n};\n\ntype ButtonProps = {\n uid: string;\n asChild?: boolean;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.cancel(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.retry(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.remove(uid);\n }}\n {...rest}\n />\n );\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { PropsWithChildren } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\nimport type { TriggerRenderProps } from \"../types\";\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean;\n render?: (api: TriggerRenderProps) => React.ReactNode;\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode);\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n\n const uploading = items.filter((i) => i.status === \"uploading\");\n const uploadingCount = uploading.length;\n const doneCount = items.filter((i) => i.status === \"done\").length;\n const errorCount = items.filter((i) => i.status === \"error\").length;\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) =>\n acc + (typeof it.progress === \"number\" ? it.progress : 0),\n 0,\n ) / uploadingCount,\n )\n : undefined;\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n };\n\n return (\n <Comp\n type={asChild ? undefined : \"button\"}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return;\n (rest as any).onClick?.(e);\n openFileDialog();\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n );\n};\n","import type { DragEvent, RefObject } from \"react\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n ImageUploaderContextValue,\n ItemActions,\n RootProps,\n UploadFileItem,\n} from \"./types\";\nimport { uid } from \"./utils\";\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(\n null,\n);\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = \"optimistic\",\n onRemove,\n accept = \"image/*\",\n name = \"images\",\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([]);\n const controllers = useRef(new Map<string, AbortController>());\n const removeControllers = useRef(new Map<string, AbortController>());\n const inputRef = useRef<HTMLInputElement | null>(null);\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? [];\n if (!Array.isArray(arr)) return;\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: \"done\",\n } as UploadFileItem;\n });\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev));\n }, [initial]);\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === \"done\" && i.url);\n return JSON.stringify(\n done.map(\n ({\n uid: _u,\n previewUrl: _p,\n file: _f,\n status: _s,\n progress: _pr,\n error: _e,\n ...rest\n }) => rest,\n ),\n );\n }, [items]);\n\n const emitChange = useCallback(\n (\n next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[]),\n ) => {\n setItems((prev) => {\n const nextState =\n typeof next === \"function\" ? (next as any)(prev) : next;\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {});\n return nextState;\n });\n },\n [onChange],\n );\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return;\n const controller = new AbortController();\n controllers.current.set(item.uid, controller);\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, progress: Math.max(0, Math.min(100, pct)) }\n : it,\n ),\n );\n };\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, status: \"uploading\", error: undefined }\n : it,\n ),\n );\n\n try {\n const result = await upload(item.file, controller.signal, setProgress);\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it;\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith(\"blob:\")) {\n try {\n URL.revokeObjectURL(it.previewUrl);\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url;\n console.log({ result });\n return {\n ...it,\n status: \"done\",\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n };\n }),\n );\n } catch (err: any) {\n const wasAborted = controller.signal.aborted;\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? \"canceled\" : \"error\",\n error: wasAborted\n ? undefined\n : err?.message || \"Upload failed\",\n }\n : it,\n ),\n );\n } finally {\n controllers.current.delete(item.uid);\n }\n },\n [emitChange, upload],\n );\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return;\n const selected = Array.from(files);\n const remaining = maxCount\n ? Math.max(\n 0,\n maxCount - items.filter((i) => i.status !== \"canceled\").length,\n )\n : undefined;\n const toUse =\n typeof remaining === \"number\" ? selected.slice(0, remaining) : selected;\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: \"idle\",\n progress: 0,\n }));\n\n emitChange([...items, ...newItems]);\n newItems.forEach((it) => startUpload(it));\n },\n [emitChange, items, maxCount, startUpload],\n );\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files);\n e.currentTarget.value = \"\";\n },\n [selectFiles],\n );\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault();\n if (disabled) return;\n selectFiles(e.dataTransfer.files);\n },\n [disabled, selectFiles],\n );\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), []);\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr);\n ctrl?.abort();\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort();\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== \"done\") {\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n return;\n }\n\n const ctrl = new AbortController();\n removeControllers.current.set(uidStr, ctrl);\n\n if (removeMode === \"optimistic\") {\n const prev = items;\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n try {\n await onRemove(item, ctrl.signal);\n } catch {\n // rollback UI if server delete fails\n emitChange(prev);\n } finally {\n removeControllers.current.delete(uidStr);\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"removing\" as const } : it,\n ),\n );\n try {\n await onRemove(item, ctrl.signal);\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"done\" as const } : it,\n ),\n );\n } finally {\n removeControllers.current.delete(uidStr);\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n if (item.file) {\n void startUpload({\n ...item,\n status: \"idle\",\n error: undefined,\n progress: 0,\n });\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr\n ? { ...it, status: \"idle\", error: undefined, progress: 0 }\n : it,\n ),\n );\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload],\n );\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl));\n controllers.current.forEach((c) => c.abort());\n },\n [],\n );\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: \"button\",\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return;\n if (e.key === \"Enter\" || e.key === \" \") inputRef.current?.click();\n },\n \"data-disabled\": disabled ? \"\" : undefined,\n \"data-multiple\": multiple ? \"\" : undefined,\n }),\n hiddenInputValue,\n name,\n };\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n );\n};\n","import { useContext } from \"react\";\n\nimport { UploaderCtx } from \"./context\";\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx)\n throw new Error(\n \"ImageUploader components must be used within <ImageUploader.Root>\",\n );\n return ctx;\n};\n"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
|
package/dist/index.d.ts
CHANGED
|
@@ -87,7 +87,7 @@ type Props = {
|
|
|
87
87
|
render?: (api: PreviewRenderProps) => React.ReactNode;
|
|
88
88
|
className?: string;
|
|
89
89
|
};
|
|
90
|
-
declare const Preview: ({ render, className }: Props) => string | number | boolean |
|
|
90
|
+
declare const Preview: ({ render, className }: Props) => string | number | boolean | Iterable<React.ReactNode> | react_jsx_runtime.JSX.Element | null | undefined;
|
|
91
91
|
declare const HiddenInput: ({ name }: {
|
|
92
92
|
name?: string;
|
|
93
93
|
}) => react_jsx_runtime.JSX.Element;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { ButtonHTMLAttributes } from 'react'\n\nimport { useImageUploader } from '../hook'\n\nimport type { PreviewRenderProps } from '../types'\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode\n className?: string\n}\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader()\n\n if (render && typeof render === 'function') {\n return render({ items, actions })\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === 'uploading' && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{ width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%` }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === 'uploading' && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === 'error' || item.status === 'canceled') && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n )\n}\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader()\n return <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n}\n\ntype ButtonProps = {\n uid: string\n asChild?: boolean\n} & ButtonHTMLAttributes<HTMLButtonElement>\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.cancel(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.retry(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.remove(uid)\n }}\n {...rest}\n />\n )\n}\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { PropsWithChildren } from 'react'\n\nimport { useImageUploader } from '../hook'\nimport type { TriggerRenderProps } from '../types'\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean\n render?: (api: TriggerRenderProps) => React.ReactNode\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode)\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n\n const uploading = items.filter((i) => i.status === 'uploading')\n const uploadingCount = uploading.length\n const doneCount = items.filter((i) => i.status === 'done').length\n const errorCount = items.filter((i) => i.status === 'error').length\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) => acc + (typeof it.progress === 'number' ? it.progress : 0),\n 0\n ) / uploadingCount\n )\n : undefined\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n }\n\n return (\n <Comp\n type={asChild ? undefined : 'button'}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return\n ;(rest as any).onClick?.(e)\n openFileDialog()\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n )\n}\n","import type { DragEvent, RefObject } from 'react'\nimport React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'\n\nimport type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem } from './types'\nimport { uid } from './utils'\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(null)\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = 'optimistic',\n onRemove,\n accept = 'image/*',\n name = 'images',\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([])\n const controllers = useRef(new Map<string, AbortController>())\n const removeControllers = useRef(new Map<string, AbortController>())\n const inputRef = useRef<HTMLInputElement | null>(null)\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? []\n if (!Array.isArray(arr)) return\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: 'done',\n } as UploadFileItem\n })\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev))\n }, [initial])\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === 'done' && i.url)\n return JSON.stringify(\n done.map(\n ({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest }) =>\n rest\n )\n )\n }, [items])\n\n const emitChange = useCallback(\n (next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[])) => {\n setItems((prev) => {\n const nextState = typeof next === 'function' ? (next as any)(prev) : next\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {})\n return nextState\n })\n },\n [onChange]\n )\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return\n const controller = new AbortController()\n controllers.current.set(item.uid, controller)\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, progress: Math.max(0, Math.min(100, pct)) } : it\n )\n )\n }\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, status: 'uploading', error: undefined } : it\n )\n )\n\n try {\n const result = await upload(item.file, controller.signal, setProgress)\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith('blob:')) {\n try {\n URL.revokeObjectURL(it.previewUrl)\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url\n console.log({ result })\n return {\n ...it,\n status: 'done',\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n }\n })\n )\n } catch (err: any) {\n const wasAborted = controller.signal.aborted\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? 'canceled' : 'error',\n error: wasAborted ? undefined : err?.message || 'Upload failed',\n }\n : it\n )\n )\n } finally {\n controllers.current.delete(item.uid)\n }\n },\n [emitChange, upload]\n )\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return\n const selected = Array.from(files)\n const remaining = maxCount\n ? Math.max(0, maxCount - items.filter((i) => i.status !== 'canceled').length)\n : undefined\n const toUse = typeof remaining === 'number' ? selected.slice(0, remaining) : selected\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: 'idle',\n progress: 0,\n }))\n\n emitChange([...items, ...newItems])\n newItems.forEach((it) => startUpload(it))\n },\n [emitChange, items, maxCount, startUpload]\n )\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files)\n e.currentTarget.value = ''\n },\n [selectFiles]\n )\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault()\n if (disabled) return\n selectFiles(e.dataTransfer.files)\n },\n [disabled, selectFiles]\n )\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), [])\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr)\n ctrl?.abort()\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort()\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== 'done') {\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n return\n }\n\n const ctrl = new AbortController()\n removeControllers.current.set(uidStr, ctrl)\n\n if (removeMode === 'optimistic') {\n const prev = items\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n try {\n await onRemove(item, ctrl.signal)\n } catch {\n // rollback UI if server delete fails\n emitChange(prev)\n } finally {\n removeControllers.current.delete(uidStr)\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'removing' as const } : it))\n )\n try {\n await onRemove(item, ctrl.signal)\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'done' as const } : it))\n )\n } finally {\n removeControllers.current.delete(uidStr)\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n if (item.file) {\n void startUpload({ ...item, status: 'idle', error: undefined, progress: 0 })\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr ? { ...it, status: 'idle', error: undefined, progress: 0 } : it\n )\n )\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload]\n )\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl))\n controllers.current.forEach((c) => c.abort())\n },\n []\n )\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: 'button',\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return\n if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click()\n },\n 'data-disabled': disabled ? '' : undefined,\n 'data-multiple': multiple ? '' : undefined,\n }),\n hiddenInputValue,\n name,\n }\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n )\n}\n","import { useContext } from 'react'\n\nimport { UploaderCtx } from './context'\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx) throw new Error(\"ImageUploader components must be used within <ImageUploader.Root>\");\n return ctx;\n};"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { ButtonHTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nimport type { PreviewRenderProps } from \"../types\";\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode;\n className?: string;\n};\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader();\n\n if (render && typeof render === \"function\") {\n return render({ items, actions });\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === \"uploading\" && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{\n width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,\n }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === \"uploading\" && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === \"error\" || item.status === \"canceled\") && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n};\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader();\n return (\n <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n );\n};\n\ntype ButtonProps = {\n uid: string;\n asChild?: boolean;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.cancel(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.retry(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.remove(uid);\n }}\n {...rest}\n />\n );\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { PropsWithChildren } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\nimport type { TriggerRenderProps } from \"../types\";\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean;\n render?: (api: TriggerRenderProps) => React.ReactNode;\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode);\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n\n const uploading = items.filter((i) => i.status === \"uploading\");\n const uploadingCount = uploading.length;\n const doneCount = items.filter((i) => i.status === \"done\").length;\n const errorCount = items.filter((i) => i.status === \"error\").length;\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) =>\n acc + (typeof it.progress === \"number\" ? it.progress : 0),\n 0,\n ) / uploadingCount,\n )\n : undefined;\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n };\n\n return (\n <Comp\n type={asChild ? undefined : \"button\"}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return;\n (rest as any).onClick?.(e);\n openFileDialog();\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n );\n};\n","import type { DragEvent, RefObject } from \"react\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n ImageUploaderContextValue,\n ItemActions,\n RootProps,\n UploadFileItem,\n} from \"./types\";\nimport { uid } from \"./utils\";\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(\n null,\n);\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = \"optimistic\",\n onRemove,\n accept = \"image/*\",\n name = \"images\",\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([]);\n const controllers = useRef(new Map<string, AbortController>());\n const removeControllers = useRef(new Map<string, AbortController>());\n const inputRef = useRef<HTMLInputElement | null>(null);\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? [];\n if (!Array.isArray(arr)) return;\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: \"done\",\n } as UploadFileItem;\n });\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev));\n }, [initial]);\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === \"done\" && i.url);\n return JSON.stringify(\n done.map(\n ({\n uid: _u,\n previewUrl: _p,\n file: _f,\n status: _s,\n progress: _pr,\n error: _e,\n ...rest\n }) => rest,\n ),\n );\n }, [items]);\n\n const emitChange = useCallback(\n (\n next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[]),\n ) => {\n setItems((prev) => {\n const nextState =\n typeof next === \"function\" ? (next as any)(prev) : next;\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {});\n return nextState;\n });\n },\n [onChange],\n );\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return;\n const controller = new AbortController();\n controllers.current.set(item.uid, controller);\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, progress: Math.max(0, Math.min(100, pct)) }\n : it,\n ),\n );\n };\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, status: \"uploading\", error: undefined }\n : it,\n ),\n );\n\n try {\n const result = await upload(item.file, controller.signal, setProgress);\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it;\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith(\"blob:\")) {\n try {\n URL.revokeObjectURL(it.previewUrl);\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url;\n console.log({ result });\n return {\n ...it,\n status: \"done\",\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n };\n }),\n );\n } catch (err: any) {\n const wasAborted = controller.signal.aborted;\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? \"canceled\" : \"error\",\n error: wasAborted\n ? undefined\n : err?.message || \"Upload failed\",\n }\n : it,\n ),\n );\n } finally {\n controllers.current.delete(item.uid);\n }\n },\n [emitChange, upload],\n );\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return;\n const selected = Array.from(files);\n const remaining = maxCount\n ? Math.max(\n 0,\n maxCount - items.filter((i) => i.status !== \"canceled\").length,\n )\n : undefined;\n const toUse =\n typeof remaining === \"number\" ? selected.slice(0, remaining) : selected;\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: \"idle\",\n progress: 0,\n }));\n\n emitChange([...items, ...newItems]);\n newItems.forEach((it) => startUpload(it));\n },\n [emitChange, items, maxCount, startUpload],\n );\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files);\n e.currentTarget.value = \"\";\n },\n [selectFiles],\n );\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault();\n if (disabled) return;\n selectFiles(e.dataTransfer.files);\n },\n [disabled, selectFiles],\n );\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), []);\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr);\n ctrl?.abort();\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort();\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== \"done\") {\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n return;\n }\n\n const ctrl = new AbortController();\n removeControllers.current.set(uidStr, ctrl);\n\n if (removeMode === \"optimistic\") {\n const prev = items;\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n try {\n await onRemove(item, ctrl.signal);\n } catch {\n // rollback UI if server delete fails\n emitChange(prev);\n } finally {\n removeControllers.current.delete(uidStr);\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"removing\" as const } : it,\n ),\n );\n try {\n await onRemove(item, ctrl.signal);\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"done\" as const } : it,\n ),\n );\n } finally {\n removeControllers.current.delete(uidStr);\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n if (item.file) {\n void startUpload({\n ...item,\n status: \"idle\",\n error: undefined,\n progress: 0,\n });\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr\n ? { ...it, status: \"idle\", error: undefined, progress: 0 }\n : it,\n ),\n );\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload],\n );\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl));\n controllers.current.forEach((c) => c.abort());\n },\n [],\n );\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: \"button\",\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return;\n if (e.key === \"Enter\" || e.key === \" \") inputRef.current?.click();\n },\n \"data-disabled\": disabled ? \"\" : undefined,\n \"data-multiple\": multiple ? \"\" : undefined,\n }),\n hiddenInputValue,\n name,\n };\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n );\n};\n","import { useContext } from \"react\";\n\nimport { UploaderCtx } from \"./context\";\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx)\n throw new Error(\n \"ImageUploader components must be used within <ImageUploader.Root>\",\n );\n return ctx;\n};\n"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { useState, useRef, useEffect, useMemo, useCallback, createContext, useCo
|
|
|
5
5
|
const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
|
|
6
6
|
|
|
7
7
|
const UploaderCtx = /*#__PURE__*/ createContext(null);
|
|
8
|
-
const Root = ({ multiple = true, initial = [], onChange, upload, removeMode =
|
|
8
|
+
const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
|
|
9
9
|
const [items, setItems] = useState([]);
|
|
10
10
|
const controllers = useRef(new Map());
|
|
11
11
|
const removeControllers = useRef(new Map());
|
|
@@ -20,7 +20,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
20
20
|
id: it.id,
|
|
21
21
|
name: it.name,
|
|
22
22
|
url: it.url,
|
|
23
|
-
status:
|
|
23
|
+
status: "done"
|
|
24
24
|
};
|
|
25
25
|
});
|
|
26
26
|
// Only hydrate if the user hasn't already added/modified items locally
|
|
@@ -29,14 +29,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
29
29
|
initial
|
|
30
30
|
]);
|
|
31
31
|
const hiddenInputValue = useMemo(()=>{
|
|
32
|
-
const done = items.filter((i)=>i.status ===
|
|
32
|
+
const done = items.filter((i)=>i.status === "done" && i.url);
|
|
33
33
|
return JSON.stringify(done.map(({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest })=>rest));
|
|
34
34
|
}, [
|
|
35
35
|
items
|
|
36
36
|
]);
|
|
37
37
|
const emitChange = useCallback((next)=>{
|
|
38
38
|
setItems((prev)=>{
|
|
39
|
-
const nextState = typeof next ===
|
|
39
|
+
const nextState = typeof next === "function" ? next(prev) : next;
|
|
40
40
|
if (onChange) Promise.resolve(onChange(nextState)).catch(()=>{});
|
|
41
41
|
return nextState;
|
|
42
42
|
});
|
|
@@ -55,7 +55,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
55
55
|
};
|
|
56
56
|
emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
|
|
57
57
|
...it,
|
|
58
|
-
status:
|
|
58
|
+
status: "uploading",
|
|
59
59
|
error: undefined
|
|
60
60
|
} : it));
|
|
61
61
|
try {
|
|
@@ -63,7 +63,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
63
63
|
emitChange((items)=>items.map((it)=>{
|
|
64
64
|
if (it.uid !== item.uid) return it;
|
|
65
65
|
// Revoke the local objectURL preview to avoid memory leaks
|
|
66
|
-
if (it.previewUrl && it.previewUrl.startsWith(
|
|
66
|
+
if (it.previewUrl && it.previewUrl.startsWith("blob:")) {
|
|
67
67
|
try {
|
|
68
68
|
URL.revokeObjectURL(it.previewUrl);
|
|
69
69
|
} catch {
|
|
@@ -75,7 +75,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
75
75
|
});
|
|
76
76
|
return {
|
|
77
77
|
...it,
|
|
78
|
-
status:
|
|
78
|
+
status: "done",
|
|
79
79
|
url: result.url,
|
|
80
80
|
id: result.id,
|
|
81
81
|
previewUrl: serverPreview,
|
|
@@ -86,8 +86,8 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
86
86
|
const wasAborted = controller.signal.aborted;
|
|
87
87
|
emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
|
|
88
88
|
...it,
|
|
89
|
-
status: wasAborted ?
|
|
90
|
-
error: wasAborted ? undefined : err?.message ||
|
|
89
|
+
status: wasAborted ? "canceled" : "error",
|
|
90
|
+
error: wasAborted ? undefined : err?.message || "Upload failed"
|
|
91
91
|
} : it));
|
|
92
92
|
} finally{
|
|
93
93
|
controllers.current.delete(item.uid);
|
|
@@ -99,14 +99,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
99
99
|
const selectFiles = useCallback((files)=>{
|
|
100
100
|
if (!files || files.length === 0) return;
|
|
101
101
|
const selected = Array.from(files);
|
|
102
|
-
const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !==
|
|
103
|
-
const toUse = typeof remaining ===
|
|
102
|
+
const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== "canceled").length) : undefined;
|
|
103
|
+
const toUse = typeof remaining === "number" ? selected.slice(0, remaining) : selected;
|
|
104
104
|
const newItems = toUse.map((file)=>({
|
|
105
105
|
uid: uid(),
|
|
106
106
|
name: file.name,
|
|
107
107
|
file,
|
|
108
108
|
previewUrl: URL.createObjectURL(file),
|
|
109
|
-
status:
|
|
109
|
+
status: "idle",
|
|
110
110
|
progress: 0
|
|
111
111
|
}));
|
|
112
112
|
emitChange([
|
|
@@ -122,7 +122,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
122
122
|
]);
|
|
123
123
|
const onInputChange = useCallback((e)=>{
|
|
124
124
|
selectFiles(e.target.files);
|
|
125
|
-
e.currentTarget.value =
|
|
125
|
+
e.currentTarget.value = "";
|
|
126
126
|
}, [
|
|
127
127
|
selectFiles
|
|
128
128
|
]);
|
|
@@ -146,13 +146,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
146
146
|
// abort any in-flight upload first
|
|
147
147
|
controllers.current.get(uidStr)?.abort();
|
|
148
148
|
// If no server-side removal needed or not uploaded yet, just remove
|
|
149
|
-
if (!onRemove || item.status !==
|
|
149
|
+
if (!onRemove || item.status !== "done") {
|
|
150
150
|
emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
153
|
const ctrl = new AbortController();
|
|
154
154
|
removeControllers.current.set(uidStr, ctrl);
|
|
155
|
-
if (removeMode ===
|
|
155
|
+
if (removeMode === "optimistic") {
|
|
156
156
|
const prev = items;
|
|
157
157
|
// remove from UI immediately
|
|
158
158
|
emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
|
|
@@ -168,7 +168,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
168
168
|
// strict: mark as removing, wait for API, then remove
|
|
169
169
|
emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
|
|
170
170
|
...it,
|
|
171
|
-
status:
|
|
171
|
+
status: "removing"
|
|
172
172
|
} : it));
|
|
173
173
|
try {
|
|
174
174
|
await onRemove(item, ctrl.signal);
|
|
@@ -177,7 +177,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
177
177
|
// revert to done if delete fails
|
|
178
178
|
emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
|
|
179
179
|
...it,
|
|
180
|
-
status:
|
|
180
|
+
status: "done"
|
|
181
181
|
} : it));
|
|
182
182
|
} finally{
|
|
183
183
|
removeControllers.current.delete(uidStr);
|
|
@@ -190,13 +190,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
190
190
|
if (item.file) {
|
|
191
191
|
void startUpload({
|
|
192
192
|
...item,
|
|
193
|
-
status:
|
|
193
|
+
status: "idle",
|
|
194
194
|
error: undefined,
|
|
195
195
|
progress: 0
|
|
196
196
|
});
|
|
197
197
|
emitChange((items)=>items.map((it)=>it.uid === uidStr ? {
|
|
198
198
|
...it,
|
|
199
|
-
status:
|
|
199
|
+
status: "idle",
|
|
200
200
|
error: undefined,
|
|
201
201
|
progress: 0
|
|
202
202
|
} : it));
|
|
@@ -228,16 +228,16 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
228
228
|
disabled
|
|
229
229
|
},
|
|
230
230
|
getDropzoneProps: ()=>({
|
|
231
|
-
role:
|
|
231
|
+
role: "button",
|
|
232
232
|
tabIndex: 0,
|
|
233
233
|
onDrop,
|
|
234
234
|
onDragOver,
|
|
235
235
|
onKeyDown: (e)=>{
|
|
236
236
|
if (disabled) return;
|
|
237
|
-
if (e.key ===
|
|
237
|
+
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
|
238
238
|
},
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
"data-disabled": disabled ? "" : undefined,
|
|
240
|
+
"data-multiple": multiple ? "" : undefined
|
|
241
241
|
}),
|
|
242
242
|
hiddenInputValue,
|
|
243
243
|
name
|
|
@@ -276,7 +276,7 @@ const Dropzone = ({ asChild, ...rest })=>{
|
|
|
276
276
|
|
|
277
277
|
const Preview = ({ render, className })=>{
|
|
278
278
|
const { items, actions } = useImageUploader();
|
|
279
|
-
if (render && typeof render ===
|
|
279
|
+
if (render && typeof render === "function") {
|
|
280
280
|
return render({
|
|
281
281
|
items,
|
|
282
282
|
actions
|
|
@@ -300,7 +300,7 @@ const Preview = ({ render, className })=>{
|
|
|
300
300
|
className: "flex h-32 w-full items-center justify-center text-xs text-gray-500",
|
|
301
301
|
children: "No preview"
|
|
302
302
|
}),
|
|
303
|
-
item.status ===
|
|
303
|
+
item.status === "uploading" && /*#__PURE__*/ jsx("div", {
|
|
304
304
|
className: "absolute bottom-0 left-0 right-0 h-1 bg-gray-200",
|
|
305
305
|
children: /*#__PURE__*/ jsx("div", {
|
|
306
306
|
className: "h-full bg-black/80",
|
|
@@ -312,13 +312,13 @@ const Preview = ({ render, className })=>{
|
|
|
312
312
|
/*#__PURE__*/ jsxs("div", {
|
|
313
313
|
className: "absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2",
|
|
314
314
|
children: [
|
|
315
|
-
item.status ===
|
|
315
|
+
item.status === "uploading" && /*#__PURE__*/ jsx("button", {
|
|
316
316
|
type: "button",
|
|
317
317
|
className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
|
|
318
318
|
onClick: ()=>actions.cancel(item.uid),
|
|
319
319
|
children: "Cancel"
|
|
320
320
|
}),
|
|
321
|
-
(item.status ===
|
|
321
|
+
(item.status === "error" || item.status === "canceled") && /*#__PURE__*/ jsx("button", {
|
|
322
322
|
type: "button",
|
|
323
323
|
className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
|
|
324
324
|
onClick: ()=>actions.retry(item.uid),
|
|
@@ -347,7 +347,7 @@ const HiddenInput = ({ name })=>{
|
|
|
347
347
|
};
|
|
348
348
|
const Cancel = ({ uid, asChild, ...rest })=>{
|
|
349
349
|
const { actions } = useImageUploader();
|
|
350
|
-
const Comp = asChild ? Slot :
|
|
350
|
+
const Comp = asChild ? Slot : "button";
|
|
351
351
|
return /*#__PURE__*/ jsx(Comp, {
|
|
352
352
|
onClick: (e)=>{
|
|
353
353
|
e.stopPropagation();
|
|
@@ -358,7 +358,7 @@ const Cancel = ({ uid, asChild, ...rest })=>{
|
|
|
358
358
|
};
|
|
359
359
|
const Retry = ({ uid, asChild, ...rest })=>{
|
|
360
360
|
const { actions } = useImageUploader();
|
|
361
|
-
const Comp = asChild ? Slot :
|
|
361
|
+
const Comp = asChild ? Slot : "button";
|
|
362
362
|
return /*#__PURE__*/ jsx(Comp, {
|
|
363
363
|
onClick: (e)=>{
|
|
364
364
|
e.stopPropagation();
|
|
@@ -369,7 +369,7 @@ const Retry = ({ uid, asChild, ...rest })=>{
|
|
|
369
369
|
};
|
|
370
370
|
const Remove = ({ uid, asChild, ...rest })=>{
|
|
371
371
|
const { actions } = useImageUploader();
|
|
372
|
-
const Comp = asChild ? Slot :
|
|
372
|
+
const Comp = asChild ? Slot : "button";
|
|
373
373
|
return /*#__PURE__*/ jsx(Comp, {
|
|
374
374
|
onClick: (e)=>{
|
|
375
375
|
e.stopPropagation();
|
|
@@ -381,12 +381,12 @@ const Remove = ({ uid, asChild, ...rest })=>{
|
|
|
381
381
|
|
|
382
382
|
const Trigger = ({ asChild, children, render, ...rest })=>{
|
|
383
383
|
const { openFileDialog, disabled, items } = useImageUploader();
|
|
384
|
-
const Comp = asChild ? Slot :
|
|
385
|
-
const uploading = items.filter((i)=>i.status ===
|
|
384
|
+
const Comp = asChild ? Slot : "button";
|
|
385
|
+
const uploading = items.filter((i)=>i.status === "uploading");
|
|
386
386
|
const uploadingCount = uploading.length;
|
|
387
|
-
const doneCount = items.filter((i)=>i.status ===
|
|
388
|
-
const errorCount = items.filter((i)=>i.status ===
|
|
389
|
-
const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress ===
|
|
387
|
+
const doneCount = items.filter((i)=>i.status === "done").length;
|
|
388
|
+
const errorCount = items.filter((i)=>i.status === "error").length;
|
|
389
|
+
const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === "number" ? it.progress : 0), 0) / uploadingCount) : undefined;
|
|
390
390
|
const api = {
|
|
391
391
|
items,
|
|
392
392
|
isUploading: uploadingCount > 0,
|
|
@@ -397,7 +397,7 @@ const Trigger = ({ asChild, children, render, ...rest })=>{
|
|
|
397
397
|
open: openFileDialog
|
|
398
398
|
};
|
|
399
399
|
return /*#__PURE__*/ jsx(Comp, {
|
|
400
|
-
type: asChild ? undefined :
|
|
400
|
+
type: asChild ? undefined : "button",
|
|
401
401
|
"aria-disabled": disabled,
|
|
402
402
|
"data-part": "trigger",
|
|
403
403
|
onClick: (e)=>{
|
package/dist/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { useState, useRef, useEffect, useMemo, useCallback, createContext, useCo
|
|
|
5
5
|
const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
|
|
6
6
|
|
|
7
7
|
const UploaderCtx = /*#__PURE__*/ createContext(null);
|
|
8
|
-
const Root = ({ multiple = true, initial = [], onChange, upload, removeMode =
|
|
8
|
+
const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
|
|
9
9
|
const [items, setItems] = useState([]);
|
|
10
10
|
const controllers = useRef(new Map());
|
|
11
11
|
const removeControllers = useRef(new Map());
|
|
@@ -20,7 +20,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
20
20
|
id: it.id,
|
|
21
21
|
name: it.name,
|
|
22
22
|
url: it.url,
|
|
23
|
-
status:
|
|
23
|
+
status: "done"
|
|
24
24
|
};
|
|
25
25
|
});
|
|
26
26
|
// Only hydrate if the user hasn't already added/modified items locally
|
|
@@ -29,14 +29,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
29
29
|
initial
|
|
30
30
|
]);
|
|
31
31
|
const hiddenInputValue = useMemo(()=>{
|
|
32
|
-
const done = items.filter((i)=>i.status ===
|
|
32
|
+
const done = items.filter((i)=>i.status === "done" && i.url);
|
|
33
33
|
return JSON.stringify(done.map(({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest })=>rest));
|
|
34
34
|
}, [
|
|
35
35
|
items
|
|
36
36
|
]);
|
|
37
37
|
const emitChange = useCallback((next)=>{
|
|
38
38
|
setItems((prev)=>{
|
|
39
|
-
const nextState = typeof next ===
|
|
39
|
+
const nextState = typeof next === "function" ? next(prev) : next;
|
|
40
40
|
if (onChange) Promise.resolve(onChange(nextState)).catch(()=>{});
|
|
41
41
|
return nextState;
|
|
42
42
|
});
|
|
@@ -55,7 +55,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
55
55
|
};
|
|
56
56
|
emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
|
|
57
57
|
...it,
|
|
58
|
-
status:
|
|
58
|
+
status: "uploading",
|
|
59
59
|
error: undefined
|
|
60
60
|
} : it));
|
|
61
61
|
try {
|
|
@@ -63,7 +63,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
63
63
|
emitChange((items)=>items.map((it)=>{
|
|
64
64
|
if (it.uid !== item.uid) return it;
|
|
65
65
|
// Revoke the local objectURL preview to avoid memory leaks
|
|
66
|
-
if (it.previewUrl && it.previewUrl.startsWith(
|
|
66
|
+
if (it.previewUrl && it.previewUrl.startsWith("blob:")) {
|
|
67
67
|
try {
|
|
68
68
|
URL.revokeObjectURL(it.previewUrl);
|
|
69
69
|
} catch {
|
|
@@ -75,7 +75,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
75
75
|
});
|
|
76
76
|
return {
|
|
77
77
|
...it,
|
|
78
|
-
status:
|
|
78
|
+
status: "done",
|
|
79
79
|
url: result.url,
|
|
80
80
|
id: result.id,
|
|
81
81
|
previewUrl: serverPreview,
|
|
@@ -86,8 +86,8 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
86
86
|
const wasAborted = controller.signal.aborted;
|
|
87
87
|
emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
|
|
88
88
|
...it,
|
|
89
|
-
status: wasAborted ?
|
|
90
|
-
error: wasAborted ? undefined : err?.message ||
|
|
89
|
+
status: wasAborted ? "canceled" : "error",
|
|
90
|
+
error: wasAborted ? undefined : err?.message || "Upload failed"
|
|
91
91
|
} : it));
|
|
92
92
|
} finally{
|
|
93
93
|
controllers.current.delete(item.uid);
|
|
@@ -99,14 +99,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
99
99
|
const selectFiles = useCallback((files)=>{
|
|
100
100
|
if (!files || files.length === 0) return;
|
|
101
101
|
const selected = Array.from(files);
|
|
102
|
-
const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !==
|
|
103
|
-
const toUse = typeof remaining ===
|
|
102
|
+
const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== "canceled").length) : undefined;
|
|
103
|
+
const toUse = typeof remaining === "number" ? selected.slice(0, remaining) : selected;
|
|
104
104
|
const newItems = toUse.map((file)=>({
|
|
105
105
|
uid: uid(),
|
|
106
106
|
name: file.name,
|
|
107
107
|
file,
|
|
108
108
|
previewUrl: URL.createObjectURL(file),
|
|
109
|
-
status:
|
|
109
|
+
status: "idle",
|
|
110
110
|
progress: 0
|
|
111
111
|
}));
|
|
112
112
|
emitChange([
|
|
@@ -122,7 +122,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
122
122
|
]);
|
|
123
123
|
const onInputChange = useCallback((e)=>{
|
|
124
124
|
selectFiles(e.target.files);
|
|
125
|
-
e.currentTarget.value =
|
|
125
|
+
e.currentTarget.value = "";
|
|
126
126
|
}, [
|
|
127
127
|
selectFiles
|
|
128
128
|
]);
|
|
@@ -146,13 +146,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
146
146
|
// abort any in-flight upload first
|
|
147
147
|
controllers.current.get(uidStr)?.abort();
|
|
148
148
|
// If no server-side removal needed or not uploaded yet, just remove
|
|
149
|
-
if (!onRemove || item.status !==
|
|
149
|
+
if (!onRemove || item.status !== "done") {
|
|
150
150
|
emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
153
|
const ctrl = new AbortController();
|
|
154
154
|
removeControllers.current.set(uidStr, ctrl);
|
|
155
|
-
if (removeMode ===
|
|
155
|
+
if (removeMode === "optimistic") {
|
|
156
156
|
const prev = items;
|
|
157
157
|
// remove from UI immediately
|
|
158
158
|
emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
|
|
@@ -168,7 +168,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
168
168
|
// strict: mark as removing, wait for API, then remove
|
|
169
169
|
emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
|
|
170
170
|
...it,
|
|
171
|
-
status:
|
|
171
|
+
status: "removing"
|
|
172
172
|
} : it));
|
|
173
173
|
try {
|
|
174
174
|
await onRemove(item, ctrl.signal);
|
|
@@ -177,7 +177,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
177
177
|
// revert to done if delete fails
|
|
178
178
|
emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
|
|
179
179
|
...it,
|
|
180
|
-
status:
|
|
180
|
+
status: "done"
|
|
181
181
|
} : it));
|
|
182
182
|
} finally{
|
|
183
183
|
removeControllers.current.delete(uidStr);
|
|
@@ -190,13 +190,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
190
190
|
if (item.file) {
|
|
191
191
|
void startUpload({
|
|
192
192
|
...item,
|
|
193
|
-
status:
|
|
193
|
+
status: "idle",
|
|
194
194
|
error: undefined,
|
|
195
195
|
progress: 0
|
|
196
196
|
});
|
|
197
197
|
emitChange((items)=>items.map((it)=>it.uid === uidStr ? {
|
|
198
198
|
...it,
|
|
199
|
-
status:
|
|
199
|
+
status: "idle",
|
|
200
200
|
error: undefined,
|
|
201
201
|
progress: 0
|
|
202
202
|
} : it));
|
|
@@ -228,16 +228,16 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
|
|
|
228
228
|
disabled
|
|
229
229
|
},
|
|
230
230
|
getDropzoneProps: ()=>({
|
|
231
|
-
role:
|
|
231
|
+
role: "button",
|
|
232
232
|
tabIndex: 0,
|
|
233
233
|
onDrop,
|
|
234
234
|
onDragOver,
|
|
235
235
|
onKeyDown: (e)=>{
|
|
236
236
|
if (disabled) return;
|
|
237
|
-
if (e.key ===
|
|
237
|
+
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
|
238
238
|
},
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
"data-disabled": disabled ? "" : undefined,
|
|
240
|
+
"data-multiple": multiple ? "" : undefined
|
|
241
241
|
}),
|
|
242
242
|
hiddenInputValue,
|
|
243
243
|
name
|
|
@@ -276,7 +276,7 @@ const Dropzone = ({ asChild, ...rest })=>{
|
|
|
276
276
|
|
|
277
277
|
const Preview = ({ render, className })=>{
|
|
278
278
|
const { items, actions } = useImageUploader();
|
|
279
|
-
if (render && typeof render ===
|
|
279
|
+
if (render && typeof render === "function") {
|
|
280
280
|
return render({
|
|
281
281
|
items,
|
|
282
282
|
actions
|
|
@@ -300,7 +300,7 @@ const Preview = ({ render, className })=>{
|
|
|
300
300
|
className: "flex h-32 w-full items-center justify-center text-xs text-gray-500",
|
|
301
301
|
children: "No preview"
|
|
302
302
|
}),
|
|
303
|
-
item.status ===
|
|
303
|
+
item.status === "uploading" && /*#__PURE__*/ jsx("div", {
|
|
304
304
|
className: "absolute bottom-0 left-0 right-0 h-1 bg-gray-200",
|
|
305
305
|
children: /*#__PURE__*/ jsx("div", {
|
|
306
306
|
className: "h-full bg-black/80",
|
|
@@ -312,13 +312,13 @@ const Preview = ({ render, className })=>{
|
|
|
312
312
|
/*#__PURE__*/ jsxs("div", {
|
|
313
313
|
className: "absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2",
|
|
314
314
|
children: [
|
|
315
|
-
item.status ===
|
|
315
|
+
item.status === "uploading" && /*#__PURE__*/ jsx("button", {
|
|
316
316
|
type: "button",
|
|
317
317
|
className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
|
|
318
318
|
onClick: ()=>actions.cancel(item.uid),
|
|
319
319
|
children: "Cancel"
|
|
320
320
|
}),
|
|
321
|
-
(item.status ===
|
|
321
|
+
(item.status === "error" || item.status === "canceled") && /*#__PURE__*/ jsx("button", {
|
|
322
322
|
type: "button",
|
|
323
323
|
className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
|
|
324
324
|
onClick: ()=>actions.retry(item.uid),
|
|
@@ -347,7 +347,7 @@ const HiddenInput = ({ name })=>{
|
|
|
347
347
|
};
|
|
348
348
|
const Cancel = ({ uid, asChild, ...rest })=>{
|
|
349
349
|
const { actions } = useImageUploader();
|
|
350
|
-
const Comp = asChild ? Slot :
|
|
350
|
+
const Comp = asChild ? Slot : "button";
|
|
351
351
|
return /*#__PURE__*/ jsx(Comp, {
|
|
352
352
|
onClick: (e)=>{
|
|
353
353
|
e.stopPropagation();
|
|
@@ -358,7 +358,7 @@ const Cancel = ({ uid, asChild, ...rest })=>{
|
|
|
358
358
|
};
|
|
359
359
|
const Retry = ({ uid, asChild, ...rest })=>{
|
|
360
360
|
const { actions } = useImageUploader();
|
|
361
|
-
const Comp = asChild ? Slot :
|
|
361
|
+
const Comp = asChild ? Slot : "button";
|
|
362
362
|
return /*#__PURE__*/ jsx(Comp, {
|
|
363
363
|
onClick: (e)=>{
|
|
364
364
|
e.stopPropagation();
|
|
@@ -369,7 +369,7 @@ const Retry = ({ uid, asChild, ...rest })=>{
|
|
|
369
369
|
};
|
|
370
370
|
const Remove = ({ uid, asChild, ...rest })=>{
|
|
371
371
|
const { actions } = useImageUploader();
|
|
372
|
-
const Comp = asChild ? Slot :
|
|
372
|
+
const Comp = asChild ? Slot : "button";
|
|
373
373
|
return /*#__PURE__*/ jsx(Comp, {
|
|
374
374
|
onClick: (e)=>{
|
|
375
375
|
e.stopPropagation();
|
|
@@ -381,12 +381,12 @@ const Remove = ({ uid, asChild, ...rest })=>{
|
|
|
381
381
|
|
|
382
382
|
const Trigger = ({ asChild, children, render, ...rest })=>{
|
|
383
383
|
const { openFileDialog, disabled, items } = useImageUploader();
|
|
384
|
-
const Comp = asChild ? Slot :
|
|
385
|
-
const uploading = items.filter((i)=>i.status ===
|
|
384
|
+
const Comp = asChild ? Slot : "button";
|
|
385
|
+
const uploading = items.filter((i)=>i.status === "uploading");
|
|
386
386
|
const uploadingCount = uploading.length;
|
|
387
|
-
const doneCount = items.filter((i)=>i.status ===
|
|
388
|
-
const errorCount = items.filter((i)=>i.status ===
|
|
389
|
-
const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress ===
|
|
387
|
+
const doneCount = items.filter((i)=>i.status === "done").length;
|
|
388
|
+
const errorCount = items.filter((i)=>i.status === "error").length;
|
|
389
|
+
const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === "number" ? it.progress : 0), 0) / uploadingCount) : undefined;
|
|
390
390
|
const api = {
|
|
391
391
|
items,
|
|
392
392
|
isUploading: uploadingCount > 0,
|
|
@@ -397,7 +397,7 @@ const Trigger = ({ asChild, children, render, ...rest })=>{
|
|
|
397
397
|
open: openFileDialog
|
|
398
398
|
};
|
|
399
399
|
return /*#__PURE__*/ jsx(Comp, {
|
|
400
|
-
type: asChild ? undefined :
|
|
400
|
+
type: asChild ? undefined : "button",
|
|
401
401
|
"aria-disabled": disabled,
|
|
402
402
|
"data-part": "trigger",
|
|
403
403
|
onClick: (e)=>{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uplofile",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Composable file upload component for React.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Chris Josh <KristofaJosh>",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/KristofaJosh/uplofile.git"
|
|
10
10
|
},
|
|
11
|
+
"homepage": "https://uplofile.kristofajosh.dev",
|
|
11
12
|
"keywords": [
|
|
12
13
|
"react",
|
|
13
14
|
"file-upload",
|