uplofile 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +273 -34
- package/dist/index.d.mts +6 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +273 -35
- package/dist/index.mjs +273 -35
- package/dist/output.css +465 -83
- package/package.json +15 -22
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,40 @@ var reactSlot = require('@radix-ui/react-slot');
|
|
|
5
5
|
var react = require('react');
|
|
6
6
|
|
|
7
7
|
const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
|
|
8
|
+
const isVideoFile = (item)=>{
|
|
9
|
+
if (item.file) {
|
|
10
|
+
return item.file.type.startsWith("video/");
|
|
11
|
+
}
|
|
12
|
+
if (item.url) {
|
|
13
|
+
const extension = item.url.split(".").pop()?.toLowerCase();
|
|
14
|
+
const videoExtensions = [
|
|
15
|
+
"mp4",
|
|
16
|
+
"webm",
|
|
17
|
+
"ogg",
|
|
18
|
+
"mov",
|
|
19
|
+
"avi",
|
|
20
|
+
"mkv"
|
|
21
|
+
];
|
|
22
|
+
if (extension && videoExtensions.includes(extension)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (item.name) {
|
|
27
|
+
const extension = item.name.split(".").pop()?.toLowerCase();
|
|
28
|
+
const videoExtensions = [
|
|
29
|
+
"mp4",
|
|
30
|
+
"webm",
|
|
31
|
+
"ogg",
|
|
32
|
+
"mov",
|
|
33
|
+
"avi",
|
|
34
|
+
"mkv"
|
|
35
|
+
];
|
|
36
|
+
if (extension && videoExtensions.includes(extension)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
8
42
|
|
|
9
43
|
const UploaderCtx = /*#__PURE__*/ react.createContext(null);
|
|
10
44
|
const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
|
|
@@ -235,6 +269,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "o
|
|
|
235
269
|
"data-disabled": disabled ? "" : undefined,
|
|
236
270
|
"data-multiple": multiple ? "" : undefined
|
|
237
271
|
}),
|
|
272
|
+
setItems,
|
|
238
273
|
hiddenInputValue,
|
|
239
274
|
name
|
|
240
275
|
};
|
|
@@ -271,59 +306,262 @@ const Dropzone = ({ asChild, ...rest })=>{
|
|
|
271
306
|
};
|
|
272
307
|
|
|
273
308
|
const Preview = ({ render })=>{
|
|
274
|
-
const { items, actions } = useUplofile();
|
|
309
|
+
const { items, actions, setItems } = useUplofile();
|
|
275
310
|
if (render && typeof render === "function") return render({
|
|
276
311
|
items,
|
|
312
|
+
setItems,
|
|
277
313
|
actions
|
|
278
314
|
});
|
|
315
|
+
if (items.length === 0) return null;
|
|
279
316
|
return /*#__PURE__*/ jsxRuntime.jsx("div", {
|
|
280
317
|
"data-part": "preview",
|
|
281
318
|
className: "uplofile-preview",
|
|
282
319
|
children: /*#__PURE__*/ jsxRuntime.jsx("div", {
|
|
283
|
-
className: "uplofile-preview__wrapper grid grid-cols-2 gap-
|
|
320
|
+
className: "uplofile-preview__wrapper grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
|
|
284
321
|
children: items.map((item)=>/*#__PURE__*/ jsxRuntime.jsxs("div", {
|
|
285
322
|
onClick: (e)=>e.stopPropagation(),
|
|
286
|
-
className:
|
|
323
|
+
className: `uplofile-preview__item group relative aspect-square overflow-hidden rounded-xl border bg-muted/5 transition-all ${item.status === "error" ? "border-red-200 bg-red-50/30 hover:shadow-md" : "hover:shadow-md hover:ring-2 hover:ring-primary/20"}`,
|
|
287
324
|
"data-state": item.status,
|
|
288
325
|
children: [
|
|
289
|
-
item.
|
|
326
|
+
item.status === "error" && /*#__PURE__*/ jsxRuntime.jsx("div", {
|
|
327
|
+
className: "absolute right-2 top-2 z-10 flex size-5 items-center justify-center rounded-full bg-red-500 text-white shadow-sm transition-opacity group-hover:opacity-0",
|
|
328
|
+
children: /*#__PURE__*/ jsxRuntime.jsxs("svg", {
|
|
329
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
330
|
+
width: "12",
|
|
331
|
+
height: "12",
|
|
332
|
+
viewBox: "0 0 24 24",
|
|
333
|
+
fill: "none",
|
|
334
|
+
stroke: "currentColor",
|
|
335
|
+
strokeWidth: "3",
|
|
336
|
+
strokeLinecap: "round",
|
|
337
|
+
strokeLinejoin: "round",
|
|
338
|
+
children: [
|
|
339
|
+
/*#__PURE__*/ jsxRuntime.jsx("circle", {
|
|
340
|
+
cx: "12",
|
|
341
|
+
cy: "12",
|
|
342
|
+
r: "10"
|
|
343
|
+
}),
|
|
344
|
+
/*#__PURE__*/ jsxRuntime.jsx("line", {
|
|
345
|
+
x1: "12",
|
|
346
|
+
y1: "8",
|
|
347
|
+
x2: "12",
|
|
348
|
+
y2: "12"
|
|
349
|
+
}),
|
|
350
|
+
/*#__PURE__*/ jsxRuntime.jsx("line", {
|
|
351
|
+
x1: "12",
|
|
352
|
+
y1: "16",
|
|
353
|
+
x2: "12.01",
|
|
354
|
+
y2: "16"
|
|
355
|
+
})
|
|
356
|
+
]
|
|
357
|
+
})
|
|
358
|
+
}),
|
|
359
|
+
isVideoFile(item) ? /*#__PURE__*/ jsxRuntime.jsx("video", {
|
|
360
|
+
src: item.url || item.previewUrl,
|
|
361
|
+
className: "uplofile-preview__video w-full h-full object-cover transition-transform duration-500 group-hover:scale-110",
|
|
362
|
+
muted: true,
|
|
363
|
+
playsInline: true,
|
|
364
|
+
onMouseOver: (e)=>e.currentTarget.play(),
|
|
365
|
+
onMouseOut: (e)=>e.currentTarget.pause()
|
|
366
|
+
}) : item.url || item.previewUrl ? /*#__PURE__*/ jsxRuntime.jsx("img", {
|
|
290
367
|
src: item.url || item.previewUrl,
|
|
291
368
|
alt: item.name,
|
|
292
|
-
className: "uplofile-preview__image
|
|
293
|
-
}) : /*#__PURE__*/ jsxRuntime.
|
|
294
|
-
className: "uplofile-preview__no-preview flex
|
|
295
|
-
children:
|
|
369
|
+
className: "uplofile-preview__image w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
|
370
|
+
}) : /*#__PURE__*/ jsxRuntime.jsxs("div", {
|
|
371
|
+
className: "uplofile-preview__no-preview flex w-full h-full flex-col items-center justify-center gap-2 text-muted-foreground/40 bg-muted/20",
|
|
372
|
+
children: [
|
|
373
|
+
/*#__PURE__*/ jsxRuntime.jsxs("svg", {
|
|
374
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
375
|
+
width: "28",
|
|
376
|
+
height: "28",
|
|
377
|
+
viewBox: "0 0 24 24",
|
|
378
|
+
fill: "none",
|
|
379
|
+
stroke: "currentColor",
|
|
380
|
+
strokeWidth: "1.5",
|
|
381
|
+
strokeLinecap: "round",
|
|
382
|
+
strokeLinejoin: "round",
|
|
383
|
+
className: "opacity-40",
|
|
384
|
+
children: [
|
|
385
|
+
/*#__PURE__*/ jsxRuntime.jsx("rect", {
|
|
386
|
+
width: "18",
|
|
387
|
+
height: "18",
|
|
388
|
+
x: "3",
|
|
389
|
+
y: "3",
|
|
390
|
+
rx: "2",
|
|
391
|
+
ry: "2"
|
|
392
|
+
}),
|
|
393
|
+
/*#__PURE__*/ jsxRuntime.jsx("circle", {
|
|
394
|
+
cx: "9",
|
|
395
|
+
cy: "9",
|
|
396
|
+
r: "2"
|
|
397
|
+
}),
|
|
398
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
399
|
+
d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"
|
|
400
|
+
})
|
|
401
|
+
]
|
|
402
|
+
}),
|
|
403
|
+
/*#__PURE__*/ jsxRuntime.jsx("span", {
|
|
404
|
+
className: "max-w-[80%] truncate text-[10px] font-bold uppercase tracking-widest opacity-60",
|
|
405
|
+
children: item.name.split(".").pop()
|
|
406
|
+
})
|
|
407
|
+
]
|
|
296
408
|
}),
|
|
297
|
-
item.status === "uploading" && /*#__PURE__*/ jsxRuntime.
|
|
298
|
-
className: "uplofile-
|
|
299
|
-
children:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
409
|
+
item.status === "uploading" && /*#__PURE__*/ jsxRuntime.jsxs("div", {
|
|
410
|
+
className: "uplofile-preview__uploading-overlay absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-[2px]",
|
|
411
|
+
children: [
|
|
412
|
+
/*#__PURE__*/ jsxRuntime.jsxs("svg", {
|
|
413
|
+
className: "mb-2 size-6 animate-spin text-white",
|
|
414
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
415
|
+
fill: "none",
|
|
416
|
+
viewBox: "0 0 24 24",
|
|
417
|
+
children: [
|
|
418
|
+
/*#__PURE__*/ jsxRuntime.jsx("circle", {
|
|
419
|
+
className: "opacity-25",
|
|
420
|
+
cx: "12",
|
|
421
|
+
cy: "12",
|
|
422
|
+
r: "10",
|
|
423
|
+
stroke: "currentColor",
|
|
424
|
+
strokeWidth: "4"
|
|
425
|
+
}),
|
|
426
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
427
|
+
className: "opacity-75",
|
|
428
|
+
fill: "currentColor",
|
|
429
|
+
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
430
|
+
})
|
|
431
|
+
]
|
|
432
|
+
}),
|
|
433
|
+
/*#__PURE__*/ jsxRuntime.jsx("div", {
|
|
434
|
+
className: "uplofile-preview__progress h-1 w-12 overflow-hidden rounded-full bg-white/20",
|
|
435
|
+
children: /*#__PURE__*/ jsxRuntime.jsx("div", {
|
|
436
|
+
className: "uplofile-preview__progress-bar h-full bg-white transition-all duration-300",
|
|
437
|
+
style: {
|
|
438
|
+
width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
]
|
|
305
443
|
}),
|
|
306
444
|
/*#__PURE__*/ jsxRuntime.jsxs("div", {
|
|
307
|
-
className:
|
|
445
|
+
className: `uplofile-preview__overlay absolute inset-0 z-30 flex flex-col items-center justify-center gap-2 transition-all duration-200 group-hover:opacity-100 ${item.status === "error" ? "bg-red-950/70 opacity-0 backdrop-blur-[1px]" : "bg-black/40 opacity-0"}`,
|
|
308
446
|
children: [
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
447
|
+
/*#__PURE__*/ jsxRuntime.jsxs("div", {
|
|
448
|
+
className: "flex gap-2",
|
|
449
|
+
children: [
|
|
450
|
+
item.status === "uploading" && /*#__PURE__*/ jsxRuntime.jsx("button", {
|
|
451
|
+
type: "button",
|
|
452
|
+
className: "uplofile-preview__button uplofile-preview__button--cancel flex size-9 items-center justify-center rounded-xl bg-white/90 text-black shadow-lg transition-transform hover:scale-110 active:scale-95",
|
|
453
|
+
onClick: ()=>actions.cancel(item.uid),
|
|
454
|
+
title: "Cancel",
|
|
455
|
+
children: /*#__PURE__*/ jsxRuntime.jsxs("svg", {
|
|
456
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
457
|
+
width: "18",
|
|
458
|
+
height: "18",
|
|
459
|
+
viewBox: "0 0 24 24",
|
|
460
|
+
fill: "none",
|
|
461
|
+
stroke: "currentColor",
|
|
462
|
+
strokeWidth: "2.5",
|
|
463
|
+
strokeLinecap: "round",
|
|
464
|
+
strokeLinejoin: "round",
|
|
465
|
+
children: [
|
|
466
|
+
/*#__PURE__*/ jsxRuntime.jsx("circle", {
|
|
467
|
+
cx: "12",
|
|
468
|
+
cy: "12",
|
|
469
|
+
r: "10"
|
|
470
|
+
}),
|
|
471
|
+
/*#__PURE__*/ jsxRuntime.jsx("line", {
|
|
472
|
+
x1: "15",
|
|
473
|
+
y1: "9",
|
|
474
|
+
x2: "9",
|
|
475
|
+
y2: "15"
|
|
476
|
+
}),
|
|
477
|
+
/*#__PURE__*/ jsxRuntime.jsx("line", {
|
|
478
|
+
x1: "9",
|
|
479
|
+
y1: "9",
|
|
480
|
+
x2: "15",
|
|
481
|
+
y2: "15"
|
|
482
|
+
})
|
|
483
|
+
]
|
|
484
|
+
})
|
|
485
|
+
}),
|
|
486
|
+
(item.status === "error" || item.status === "canceled") && /*#__PURE__*/ jsxRuntime.jsx("button", {
|
|
487
|
+
type: "button",
|
|
488
|
+
className: "uplofile-preview__button uplofile-preview__button--retry flex size-9 items-center justify-center rounded-xl text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95",
|
|
489
|
+
onClick: ()=>actions.retry(item.uid),
|
|
490
|
+
title: "Retry",
|
|
491
|
+
children: /*#__PURE__*/ jsxRuntime.jsxs("svg", {
|
|
492
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
493
|
+
width: "18",
|
|
494
|
+
height: "18",
|
|
495
|
+
viewBox: "0 0 24 24",
|
|
496
|
+
fill: "none",
|
|
497
|
+
stroke: "currentColor",
|
|
498
|
+
strokeWidth: "2.5",
|
|
499
|
+
strokeLinecap: "round",
|
|
500
|
+
strokeLinejoin: "round",
|
|
501
|
+
children: [
|
|
502
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
503
|
+
d: "M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"
|
|
504
|
+
}),
|
|
505
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
506
|
+
d: "M21 3v5h-5"
|
|
507
|
+
})
|
|
508
|
+
]
|
|
509
|
+
})
|
|
510
|
+
}),
|
|
511
|
+
/*#__PURE__*/ jsxRuntime.jsx("button", {
|
|
512
|
+
type: "button",
|
|
513
|
+
className: `uplofile-preview__button uplofile-preview__button--remove flex size-9 items-center justify-center rounded-xl transition-all hover:scale-110 active:scale-95 text-red-600`,
|
|
514
|
+
onClick: ()=>actions.remove(item.uid),
|
|
515
|
+
disabled: item.status === "removing",
|
|
516
|
+
title: "Remove",
|
|
517
|
+
children: item.status === "removing" ? /*#__PURE__*/ jsxRuntime.jsxs("svg", {
|
|
518
|
+
className: "size-5 animate-spin text-inherit",
|
|
519
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
520
|
+
fill: "none",
|
|
521
|
+
viewBox: "0 0 24 24",
|
|
522
|
+
children: [
|
|
523
|
+
/*#__PURE__*/ jsxRuntime.jsx("circle", {
|
|
524
|
+
className: "opacity-25",
|
|
525
|
+
cx: "12",
|
|
526
|
+
cy: "12",
|
|
527
|
+
r: "10",
|
|
528
|
+
stroke: "currentColor",
|
|
529
|
+
strokeWidth: "4"
|
|
530
|
+
}),
|
|
531
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
532
|
+
className: "opacity-75",
|
|
533
|
+
fill: "currentColor",
|
|
534
|
+
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
535
|
+
})
|
|
536
|
+
]
|
|
537
|
+
}) : /*#__PURE__*/ jsxRuntime.jsxs("svg", {
|
|
538
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
539
|
+
width: "18",
|
|
540
|
+
height: "18",
|
|
541
|
+
viewBox: "0 0 24 24",
|
|
542
|
+
fill: "none",
|
|
543
|
+
stroke: "currentColor",
|
|
544
|
+
strokeWidth: "2.5",
|
|
545
|
+
strokeLinecap: "round",
|
|
546
|
+
strokeLinejoin: "round",
|
|
547
|
+
children: [
|
|
548
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
549
|
+
d: "M3 6h18"
|
|
550
|
+
}),
|
|
551
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
552
|
+
d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
|
553
|
+
}),
|
|
554
|
+
/*#__PURE__*/ jsxRuntime.jsx("path", {
|
|
555
|
+
d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
|
556
|
+
})
|
|
557
|
+
]
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
]
|
|
320
561
|
}),
|
|
321
|
-
/*#__PURE__*/ jsxRuntime.jsx("
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
onClick: ()=>actions.remove(item.uid),
|
|
325
|
-
disabled: item.status === "removing",
|
|
326
|
-
children: item.status === "removing" ? "Removing..." : "Remove"
|
|
562
|
+
item.status === "error" && /*#__PURE__*/ jsxRuntime.jsx("span", {
|
|
563
|
+
className: "mt-1 px-3 text-center text-[10px] font-bold text-white drop-shadow-md line-clamp-2",
|
|
564
|
+
children: item.error || "Upload failed"
|
|
327
565
|
})
|
|
328
566
|
]
|
|
329
567
|
})
|
|
@@ -415,4 +653,5 @@ exports.Remove = Remove;
|
|
|
415
653
|
exports.Retry = Retry;
|
|
416
654
|
exports.Root = Root;
|
|
417
655
|
exports.Trigger = Trigger;
|
|
656
|
+
exports.isVideoFile = isVideoFile;
|
|
418
657
|
exports.useUplofile = useUplofile;
|
package/dist/index.d.mts
CHANGED
|
@@ -45,6 +45,7 @@ type ItemActions = {
|
|
|
45
45
|
};
|
|
46
46
|
type ImageUploaderContextValue = {
|
|
47
47
|
items: UploadFileItem[];
|
|
48
|
+
setItems: (items: UploadFileItem[]) => void;
|
|
48
49
|
disabled?: boolean;
|
|
49
50
|
multiple: boolean;
|
|
50
51
|
accept: string;
|
|
@@ -80,13 +81,14 @@ type TriggerRenderProps = {
|
|
|
80
81
|
};
|
|
81
82
|
type PreviewRenderProps = {
|
|
82
83
|
items: UploadFileItem[];
|
|
84
|
+
setItems: (items: UploadFileItem[]) => void;
|
|
83
85
|
actions: ItemActions;
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
type Props = {
|
|
87
89
|
render?: (api: PreviewRenderProps) => React.ReactNode;
|
|
88
90
|
};
|
|
89
|
-
declare const Preview: ({ render }: Props) => string | number | boolean | Iterable<React.ReactNode> |
|
|
91
|
+
declare const Preview: ({ render }: Props) => string | number | boolean | react_jsx_runtime.JSX.Element | Iterable<React.ReactNode> | null | undefined;
|
|
90
92
|
declare const HiddenInput: ({ name }: {
|
|
91
93
|
name?: string;
|
|
92
94
|
}) => react_jsx_runtime.JSX.Element;
|
|
@@ -109,6 +111,8 @@ declare const Trigger: ({ asChild, children, render, ...rest }: PropsWithChildre
|
|
|
109
111
|
|
|
110
112
|
declare const useUplofile: () => ImageUploaderContextValue;
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
declare const isVideoFile: (item: UploadFileItem) => boolean;
|
|
115
|
+
|
|
116
|
+
export { Cancel, Dropzone, HiddenInput, Preview, Remove, Retry, Root, Trigger, isVideoFile, useUplofile };
|
|
113
117
|
export type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem, UploadResult, UploadStatus };
|
|
114
118
|
//# sourceMappingURL=index.d.mts.map
|
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/context.tsx","../src/components/trigger.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useUplofile } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useUplofile();\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 | any>;\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 { useUplofile } from \"../hook\";\n\nimport type { PreviewRenderProps } from \"../types\";\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode;\n};\n\nexport const Preview = ({ render }: Props) => {\n const { items, actions } = useUplofile();\n\n if (render && typeof render === \"function\") return render({ items, actions });\n\n return (\n <div data-part=\"preview\" className={\"uplofile-preview\"}>\n <div className=\"uplofile-preview__wrapper 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=\"uplofile-preview__item relative overflow-hidden rounded-xl border size-32\"\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=\"uplofile-preview__image size-full object-cover\"\n />\n ) : (\n <div className=\"uplofile-preview__no-preview 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=\"uplofile-preview__progress absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"uplofile-preview__progress-bar 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=\"uplofile-preview__actions 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=\"uplofile-preview__button uplofile-preview__button--cancel 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=\"uplofile-preview__button uplofile-preview__button--retry 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=\"uplofile-preview__button uplofile-preview__button--remove rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n disabled={item.status === \"removing\"}\n >\n {item.status === \"removing\" ? \"Removing...\" : \"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 } = useUplofile();\n return (\n <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n );\n};\n\ntype ButtonProps = {\n uid: string;\n alwaysVisible?: boolean;\n asChild?: boolean;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\nexport const Cancel = ({\n uid,\n asChild,\n alwaysVisible = false,\n ...rest\n}: ButtonProps) => {\n const { actions, items } = useUplofile();\n const isUploading = items.find((i) => i.uid === uid)?.status === \"uploading\";\n const Comp: any = asChild ? Slot : \"button\";\n\n if (!isUploading && !alwaysVisible) return null;\n\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 } = useUplofile();\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 } = useUplofile();\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 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 const hasHydratedInitialRef = useRef(false);\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n if (hasHydratedInitialRef.current) return;\n const arr = initial ?? [];\n if (!Array.isArray(arr) || arr.length === 0) 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 hasHydratedInitialRef.current = true;\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\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 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 }\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 { Slot } from \"@radix-ui/react-slot\";\nimport React, { PropsWithChildren } from \"react\";\n\nimport { useUplofile } 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 } = useUplofile();\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 { useContext } from \"react\";\n\nimport { UploaderCtx } from \"./context\";\n\nexport const useUplofile = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx)\n throw new Error(\n \"useUplofile hook must be used within <Uplofile.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;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACbA;;ACDA;AACP;AACA;AACA;AACA;;ACNO;;;"}
|
|
1
|
+
{"version":3,"file":"index.d.mts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/context.tsx","../src/components/trigger.tsx","../src/hook.ts","../src/utils.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useUplofile } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useUplofile();\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 | any>;\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 setItems: (items: UploadFileItem[]) => void;\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 setItems: (items: UploadFileItem[]) => void;\n actions: ItemActions;\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { ButtonHTMLAttributes } from \"react\";\n\nimport { useUplofile } from \"../hook\";\nimport { isVideoFile } from \"../utils\";\n\nimport type { PreviewRenderProps } from \"../types\";\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode;\n};\n\nexport const Preview = ({ render }: Props) => {\n const { items, actions, setItems } = useUplofile();\n\n if (render && typeof render === \"function\")\n return render({ items, setItems, actions });\n\n if (items.length === 0) return null;\n\n return (\n <div data-part=\"preview\" className=\"uplofile-preview\">\n <div className=\"uplofile-preview__wrapper grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className={`uplofile-preview__item group relative aspect-square overflow-hidden rounded-xl border bg-muted/5 transition-all ${\n item.status === \"error\"\n ? \"border-red-200 bg-red-50/30 hover:shadow-md\"\n : \"hover:shadow-md hover:ring-2 hover:ring-primary/20\"\n }`}\n data-state={item.status}\n >\n {item.status === \"error\" && (\n <div className=\"absolute right-2 top-2 z-10 flex size-5 items-center justify-center rounded-full bg-red-500 text-white shadow-sm transition-opacity group-hover:opacity-0\">\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"12\"\n height=\"12\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"3\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" />\n <line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\" />\n <line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\" />\n </svg>\n </div>\n )}\n {isVideoFile(item) ? (\n <video\n src={item.url || item.previewUrl}\n className=\"uplofile-preview__video w-full h-full object-cover transition-transform duration-500 group-hover:scale-110\"\n muted\n playsInline\n onMouseOver={(e) => e.currentTarget.play()}\n onMouseOut={(e) => e.currentTarget.pause()}\n />\n ) : item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"uplofile-preview__image w-full h-full object-cover transition-transform duration-500 group-hover:scale-110\"\n />\n ) : (\n <div className=\"uplofile-preview__no-preview flex w-full h-full flex-col items-center justify-center gap-2 text-muted-foreground/40 bg-muted/20\">\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"28\"\n height=\"28\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n className=\"opacity-40\"\n >\n <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" />\n <circle cx=\"9\" cy=\"9\" r=\"2\" />\n <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />\n </svg>\n <span className=\"max-w-[80%] truncate text-[10px] font-bold uppercase tracking-widest opacity-60\">\n {item.name.split(\".\").pop()}\n </span>\n </div>\n )}\n\n {item.status === \"uploading\" && (\n <div className=\"uplofile-preview__uploading-overlay absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-[2px]\">\n <svg\n className=\"mb-2 size-6 animate-spin text-white\"\n xmlns=\"http://www.w3.org/2000/svg\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n >\n <circle\n className=\"opacity-25\"\n cx=\"12\"\n cy=\"12\"\n r=\"10\"\n stroke=\"currentColor\"\n strokeWidth=\"4\"\n />\n <path\n className=\"opacity-75\"\n fill=\"currentColor\"\n d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n />\n </svg>\n <div className=\"uplofile-preview__progress h-1 w-12 overflow-hidden rounded-full bg-white/20\">\n <div\n className=\"uplofile-preview__progress-bar h-full bg-white transition-all duration-300\"\n style={{\n width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,\n }}\n />\n </div>\n </div>\n )}\n\n <div\n className={`uplofile-preview__overlay absolute inset-0 z-30 flex flex-col items-center justify-center gap-2 transition-all duration-200 group-hover:opacity-100 ${\n item.status === \"error\"\n ? \"bg-red-950/70 opacity-0 backdrop-blur-[1px]\"\n : \"bg-black/40 opacity-0\"\n }`}\n >\n <div className=\"flex gap-2\">\n {item.status === \"uploading\" && (\n <button\n type=\"button\"\n className=\"uplofile-preview__button uplofile-preview__button--cancel flex size-9 items-center justify-center rounded-xl bg-white/90 text-black shadow-lg transition-transform hover:scale-110 active:scale-95\"\n onClick={() => actions.cancel(item.uid)}\n title=\"Cancel\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"18\"\n height=\"18\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" />\n <line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\" />\n <line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\" />\n </svg>\n </button>\n )}\n {(item.status === \"error\" || item.status === \"canceled\") && (\n <button\n type=\"button\"\n className=\"uplofile-preview__button uplofile-preview__button--retry flex size-9 items-center justify-center rounded-xl text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95\"\n onClick={() => actions.retry(item.uid)}\n title=\"Retry\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"18\"\n height=\"18\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\" />\n <path d=\"M21 3v5h-5\" />\n </svg>\n </button>\n )}\n <button\n type=\"button\"\n className={`uplofile-preview__button uplofile-preview__button--remove flex size-9 items-center justify-center rounded-xl transition-all hover:scale-110 active:scale-95 text-red-600`}\n onClick={() => actions.remove(item.uid)}\n disabled={item.status === \"removing\"}\n title=\"Remove\"\n >\n {item.status === \"removing\" ? (\n <svg\n className=\"size-5 animate-spin text-inherit\"\n xmlns=\"http://www.w3.org/2000/svg\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n >\n <circle\n className=\"opacity-25\"\n cx=\"12\"\n cy=\"12\"\n r=\"10\"\n stroke=\"currentColor\"\n strokeWidth=\"4\"\n ></circle>\n <path\n className=\"opacity-75\"\n fill=\"currentColor\"\n d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n ></path>\n </svg>\n ) : (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"18\"\n height=\"18\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M3 6h18\" />\n <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\" />\n <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\" />\n </svg>\n )}\n </button>\n </div>\n {item.status === \"error\" && (\n <span className=\"mt-1 px-3 text-center text-[10px] font-bold text-white drop-shadow-md line-clamp-2\">\n {item.error || \"Upload failed\"}\n </span>\n )}\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n};\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useUplofile();\n return (\n <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n );\n};\n\ntype ButtonProps = {\n uid: string;\n alwaysVisible?: boolean;\n asChild?: boolean;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\nexport const Cancel = ({\n uid,\n asChild,\n alwaysVisible = false,\n ...rest\n}: ButtonProps) => {\n const { actions, items } = useUplofile();\n const isUploading = items.find((i) => i.uid === uid)?.status === \"uploading\";\n const Comp: any = asChild ? Slot : \"button\";\n\n if (!isUploading && !alwaysVisible) return null;\n\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 } = useUplofile();\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 } = useUplofile();\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 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 const hasHydratedInitialRef = useRef(false);\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n if (hasHydratedInitialRef.current) return;\n const arr = initial ?? [];\n if (!Array.isArray(arr) || arr.length === 0) 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 hasHydratedInitialRef.current = true;\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\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 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 }\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 setItems,\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 { Slot } from \"@radix-ui/react-slot\";\nimport React, { PropsWithChildren } from \"react\";\n\nimport { useUplofile } 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 } = useUplofile();\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 { useContext } from \"react\";\n\nimport { UploaderCtx } from \"./context\";\n\nexport const useUplofile = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx)\n throw new Error(\n \"useUplofile hook must be used within <Uplofile.Root>\",\n );\n return ctx;\n};\n","export const uid = () =>\n Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);\n\nimport { UploadFileItem } from \"./types\";\n\nexport const isVideoFile = (item: UploadFileItem): boolean => {\n if (item.file) {\n return item.file.type.startsWith(\"video/\");\n }\n\n if (item.url) {\n const extension = item.url.split(\".\").pop()?.toLowerCase();\n const videoExtensions = [\"mp4\", \"webm\", \"ogg\", \"mov\", \"avi\", \"mkv\"];\n if (extension && videoExtensions.includes(extension)) {\n return true;\n }\n }\n\n if (item.name) {\n const extension = item.name.split(\".\").pop()?.toLowerCase();\n const videoExtensions = [\"mp4\", \"webm\", \"ogg\", \"mov\", \"avi\", \"mkv\"];\n if (extension && videoExtensions.includes(extension)) {\n return true;\n }\n }\n\n return false;\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;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;;AC7EA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACbA;;ACDA;AACP;AACA;AACA;AACA;;ACNO;;ACEA;;;"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/// <reference lib="es2015.iterable" />
|
|
1
2
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
3
|
import React, { HTMLAttributes, PropsWithChildren, RefObject, ChangeEvent, DragEvent, ButtonHTMLAttributes } from 'react';
|
|
3
4
|
|
|
@@ -45,6 +46,7 @@ type ItemActions = {
|
|
|
45
46
|
};
|
|
46
47
|
type ImageUploaderContextValue = {
|
|
47
48
|
items: UploadFileItem[];
|
|
49
|
+
setItems: (items: UploadFileItem[]) => void;
|
|
48
50
|
disabled?: boolean;
|
|
49
51
|
multiple: boolean;
|
|
50
52
|
accept: string;
|
|
@@ -80,6 +82,7 @@ type TriggerRenderProps = {
|
|
|
80
82
|
};
|
|
81
83
|
type PreviewRenderProps = {
|
|
82
84
|
items: UploadFileItem[];
|
|
85
|
+
setItems: (items: UploadFileItem[]) => void;
|
|
83
86
|
actions: ItemActions;
|
|
84
87
|
};
|
|
85
88
|
|
|
@@ -109,6 +112,8 @@ declare const Trigger: ({ asChild, children, render, ...rest }: PropsWithChildre
|
|
|
109
112
|
|
|
110
113
|
declare const useUplofile: () => ImageUploaderContextValue;
|
|
111
114
|
|
|
112
|
-
|
|
115
|
+
declare const isVideoFile: (item: UploadFileItem) => boolean;
|
|
116
|
+
|
|
117
|
+
export { Cancel, Dropzone, HiddenInput, Preview, Remove, Retry, Root, Trigger, isVideoFile, useUplofile };
|
|
113
118
|
export type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem, UploadResult, UploadStatus };
|
|
114
119
|
//# sourceMappingURL=index.d.ts.map
|