uplofile 1.1.1 → 2.0.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.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /// <reference lib="es2015.iterable" />
2
2
  import * as react_jsx_runtime from 'react/jsx-runtime';
3
- import React, { HTMLAttributes, PropsWithChildren, RefObject, ChangeEvent, DragEvent, ButtonHTMLAttributes } from 'react';
3
+ import React, { HTMLAttributes, DragEvent, RefObject, ChangeEvent, PropsWithChildren, ButtonHTMLAttributes } from 'react';
4
4
 
5
5
  declare const Dropzone: ({ asChild, ...rest }: {
6
6
  asChild?: boolean;
7
7
  } & HTMLAttributes<HTMLElement>) => react_jsx_runtime.JSX.Element;
8
8
 
9
9
  type UploadStatus = "idle" | "uploading" | "done" | "error" | "canceled" | "removing";
10
- type UploadFileItem = {
10
+ type UploadFileItem<TMeta = any> = {
11
11
  uid: string;
12
12
  id?: string;
13
13
  name: string;
@@ -17,15 +17,23 @@ type UploadFileItem = {
17
17
  status: UploadStatus;
18
18
  progress?: number;
19
19
  error?: string;
20
- data?: any;
20
+ meta?: TMeta;
21
21
  };
22
22
  type UploadResult = {
23
23
  url: string;
24
24
  id?: string;
25
25
  };
26
- type RootProps = PropsWithChildren<{
26
+ type UplofileRootRef<TMeta = any> = {
27
+ setItems: (items: UploadFileItem<TMeta>[] | ((prev: UploadFileItem<TMeta>[]) => UploadFileItem<TMeta>[])) => void;
28
+ getItems: () => UploadFileItem<TMeta>[];
29
+ onDrop: (e: DragEvent) => void;
30
+ onDragOver: (e: DragEvent) => void;
31
+ openFileDialog: () => void;
32
+ actions: ItemActions;
33
+ };
34
+ type RootProps<TMeta = any> = PropsWithChildren<{
27
35
  multiple?: boolean;
28
- initial?: Array<Pick<UploadFileItem, "uid" | "id" | "name" | "url">>;
36
+ initial?: Array<Pick<UploadFileItem<TMeta>, "uid" | "id" | "name" | "url" | "meta">>;
29
37
  /**
30
38
  * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.
31
39
  * strict: call onRemove first; only remove from UI if it succeeds.
@@ -35,18 +43,18 @@ type RootProps = PropsWithChildren<{
35
43
  maxCount?: number;
36
44
  disabled?: boolean;
37
45
  accept?: string;
38
- onChange?: (items: UploadFileItem[]) => Promise<void> | void;
46
+ onChange?: (items: UploadFileItem<TMeta>[]) => Promise<void> | void;
39
47
  upload: (file: File, signal: AbortSignal, setProgress?: (pct: number) => void) => Promise<UploadResult>;
40
- onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void | any>;
48
+ onRemove?: (item: UploadFileItem<TMeta>, signal: AbortSignal) => Promise<void | any>;
41
49
  }>;
42
50
  type ItemActions = {
43
51
  cancel: (uid: string) => void;
44
52
  remove: (uid: string) => void;
45
53
  retry: (uid: string) => void;
46
54
  };
47
- type ImageUploaderContextValue = {
48
- items: UploadFileItem[];
49
- setItems: (items: UploadFileItem[]) => void;
55
+ type ImageUploaderContextValue<TMeta = any> = {
56
+ items: UploadFileItem<TMeta>[];
57
+ setItems: (items: UploadFileItem<TMeta>[]) => void;
50
58
  disabled?: boolean;
51
59
  multiple: boolean;
52
60
  accept: string;
@@ -71,8 +79,8 @@ type ImageUploaderContextValue = {
71
79
  hiddenInputValue: string;
72
80
  name: string;
73
81
  };
74
- type TriggerRenderProps = {
75
- items: UploadFileItem[];
82
+ type TriggerRenderProps<TMeta = any> = {
83
+ items: UploadFileItem<TMeta>[];
76
84
  isUploading: boolean;
77
85
  uploadingCount: number;
78
86
  doneCount: number;
@@ -80,16 +88,16 @@ type TriggerRenderProps = {
80
88
  totalProgress?: number;
81
89
  open: () => void;
82
90
  };
83
- type PreviewRenderProps = {
84
- items: UploadFileItem[];
85
- setItems: (items: UploadFileItem[]) => void;
91
+ type PreviewRenderProps<TMeta = any> = {
92
+ items: UploadFileItem<TMeta>[];
93
+ setItems: (items: UploadFileItem<TMeta>[]) => void;
86
94
  actions: ItemActions;
87
95
  };
88
96
 
89
- type Props = {
90
- render?: (api: PreviewRenderProps) => React.ReactNode;
97
+ type Props<TMeta = any> = {
98
+ render?: (api: PreviewRenderProps<TMeta>) => React.ReactNode;
91
99
  };
92
- declare const Preview: ({ render }: Props) => string | number | boolean | Iterable<React.ReactNode> | react_jsx_runtime.JSX.Element | null | undefined;
100
+ declare const Preview: <TMeta = any>({ render }: Props<TMeta>) => string | number | boolean | Iterable<React.ReactNode> | react_jsx_runtime.JSX.Element | null | undefined;
93
101
  declare const HiddenInput: ({ name }: {
94
102
  name?: string;
95
103
  }) => react_jsx_runtime.JSX.Element;
@@ -102,17 +110,32 @@ declare const Cancel: ({ uid, asChild, alwaysVisible, ...rest }: ButtonProps) =>
102
110
  declare const Retry: ({ uid, asChild, ...rest }: ButtonProps) => react_jsx_runtime.JSX.Element;
103
111
  declare const Remove: ({ uid, asChild, ...rest }: ButtonProps) => react_jsx_runtime.JSX.Element;
104
112
 
105
- declare const Root: ({ multiple, initial, onChange, upload, removeMode, onRemove, accept, name, maxCount, disabled, children, }: RootProps) => react_jsx_runtime.JSX.Element;
113
+ declare const Root: React.ForwardRefExoticComponent<{
114
+ multiple?: boolean;
115
+ initial?: Pick<UploadFileItem<unknown>, "name" | "uid" | "id" | "url" | "meta">[] | undefined;
116
+ removeMode?: "optimistic" | "strict";
117
+ name?: string;
118
+ maxCount?: number;
119
+ disabled?: boolean;
120
+ accept?: string;
121
+ onChange?: ((items: UploadFileItem<unknown>[]) => Promise<void> | void) | undefined;
122
+ upload: (file: File, signal: AbortSignal, setProgress?: (pct: number) => void) => Promise<UploadResult>;
123
+ onRemove?: ((item: UploadFileItem<unknown>, signal: AbortSignal) => Promise<void | any>) | undefined;
124
+ } & {
125
+ children?: React.ReactNode | undefined;
126
+ } & React.RefAttributes<UplofileRootRef<unknown>>>;
106
127
 
107
- declare const Trigger: ({ asChild, children, render, ...rest }: PropsWithChildren<{
128
+ declare const Trigger: <TMeta = any>({ asChild, children, render, ...rest }: PropsWithChildren<{
108
129
  asChild?: boolean;
109
- render?: (api: TriggerRenderProps) => React.ReactNode;
110
- children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode);
130
+ render?: (api: TriggerRenderProps<TMeta>) => React.ReactNode;
131
+ children?: React.ReactNode | ((api: TriggerRenderProps<TMeta>) => React.ReactNode);
111
132
  } & React.HTMLAttributes<HTMLElement>>) => react_jsx_runtime.JSX.Element;
112
133
 
113
- declare const useUplofile: () => ImageUploaderContextValue;
134
+ declare const useUplofile: <TMeta = any>() => ImageUploaderContextValue<TMeta>;
114
135
 
115
- declare const isVideoFile: (item: UploadFileItem) => boolean;
136
+ declare const getExtension: (path: string) => string | undefined;
137
+ declare const isVideoFile: (item: UploadFileItem<any>, extraExtensions?: string[]) => boolean;
138
+ declare const isImageFile: (item: UploadFileItem<any>, extraExtensions?: string[]) => boolean;
116
139
 
117
- export { Cancel, Dropzone, HiddenInput, Preview, Remove, Retry, Root, Trigger, isVideoFile, useUplofile };
118
- export type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem, UploadResult, UploadStatus };
140
+ export { Cancel, Dropzone, HiddenInput, Preview, Remove, Retry, Root, Trigger, getExtension, isImageFile, isVideoFile, useUplofile };
141
+ export type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem, UploadResult, UploadStatus, UplofileRootRef };
package/dist/index.mjs CHANGED
@@ -1,45 +1,121 @@
1
1
  import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import { Slot } from '@radix-ui/react-slot';
3
- import { useState, useRef, useEffect, useMemo, useCallback, createContext, useContext } from 'react';
3
+ import { forwardRef, useState, useRef, useEffect, useMemo, useCallback, useImperativeHandle, createContext, useContext } from 'react';
4
4
 
5
5
  const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
6
- const isVideoFile = (item)=>{
6
+ const VIDEO_EXTENSIONS = [
7
+ "mp4",
8
+ "webm",
9
+ "ogg",
10
+ "mov",
11
+ "avi",
12
+ "mkv",
13
+ "wmv",
14
+ "flv",
15
+ "3gp",
16
+ "m4v",
17
+ "mpg",
18
+ "mpeg"
19
+ ];
20
+ const IMAGE_EXTENSIONS = [
21
+ "jpg",
22
+ "jpeg",
23
+ "png",
24
+ "gif",
25
+ "webp",
26
+ "svg",
27
+ "avif",
28
+ "bmp",
29
+ "ico",
30
+ "tiff"
31
+ ];
32
+ const getExtension = (path)=>{
33
+ try {
34
+ const url = new URL(path, "http://localhost");
35
+ const pathname = url.pathname;
36
+ const parts = pathname.split(".");
37
+ if (parts.length > 1) return parts.pop()?.toLowerCase();
38
+ return undefined;
39
+ } catch {
40
+ const parts = path.split("?")[0].split("#")[0].split(".");
41
+ if (parts.length > 1) return parts.pop()?.toLowerCase();
42
+ return undefined;
43
+ }
44
+ };
45
+ const isVideoFile = (item, extraExtensions = [])=>{
7
46
  if (item.file) {
8
47
  return item.file.type.startsWith("video/");
9
48
  }
49
+ const allExtensions = [
50
+ ...VIDEO_EXTENSIONS,
51
+ ...extraExtensions
52
+ ];
53
+ if (item.url) {
54
+ const extension = getExtension(item.url);
55
+ if (extension && allExtensions.includes(extension)) {
56
+ return true;
57
+ }
58
+ }
59
+ if (item.name) {
60
+ const extension = getExtension(item.name);
61
+ if (extension && allExtensions.includes(extension)) {
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ };
67
+ const isImageFile = (item, extraExtensions = [])=>{
68
+ if (item.file) {
69
+ return item.file.type.startsWith("image/");
70
+ }
71
+ const allExtensions = [
72
+ ...IMAGE_EXTENSIONS,
73
+ ...extraExtensions
74
+ ];
10
75
  if (item.url) {
11
- const extension = item.url.split(".").pop()?.toLowerCase();
12
- const videoExtensions = [
13
- "mp4",
14
- "webm",
15
- "ogg",
16
- "mov",
17
- "avi",
18
- "mkv"
19
- ];
20
- if (extension && videoExtensions.includes(extension)) {
76
+ const extension = getExtension(item.url);
77
+ if (extension && allExtensions.includes(extension)) {
21
78
  return true;
22
79
  }
23
80
  }
24
81
  if (item.name) {
25
- const extension = item.name.split(".").pop()?.toLowerCase();
26
- const videoExtensions = [
27
- "mp4",
28
- "webm",
29
- "ogg",
30
- "mov",
31
- "avi",
32
- "mkv"
33
- ];
34
- if (extension && videoExtensions.includes(extension)) {
82
+ const extension = getExtension(item.name);
83
+ if (extension && allExtensions.includes(extension)) {
35
84
  return true;
36
85
  }
37
86
  }
38
87
  return false;
39
88
  };
89
+ const acceptsFile = (file, accept)=>{
90
+ if (!accept || accept.trim() === "") return true;
91
+ const type = (file.type || "").toLowerCase();
92
+ const name = (file.name || "").toLowerCase();
93
+ const tokens = accept.split(",").map((t)=>t.trim().toLowerCase()).filter(Boolean);
94
+ // If accept is malformed, be permissive (browser file input also ignores)
95
+ if (tokens.length === 0) return true;
96
+ // Helper: match extension token like .jpg or jpg
97
+ const hasExt = (token)=>{
98
+ const extToken = token.startsWith(".") ? token.slice(1) : token;
99
+ const fileExt = name.includes(".") ? name.split(".").pop() : undefined;
100
+ return !!fileExt && fileExt === extToken;
101
+ };
102
+ // Check any token matches
103
+ return tokens.some((token)=>{
104
+ if (token === "*/*") return true;
105
+ // MIME type with wildcard, e.g., image/*
106
+ if (token.endsWith("/*")) {
107
+ const prefix = token.slice(0, -1); // keep the slash
108
+ return type.startsWith(prefix);
109
+ }
110
+ // Full MIME type e.g., image/png
111
+ if (token.includes("/")) return type === token;
112
+ // File extensions, with or without a leading dot
113
+ return hasExt(token);
114
+ });
115
+ };
40
116
 
41
117
  const UploaderCtx = /*#__PURE__*/ createContext(null);
42
- const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
118
+ const Root = /*#__PURE__*/ forwardRef(({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "image", maxCount, disabled, children }, ref)=>{
43
119
  const [items, setItems] = useState([]);
44
120
  const controllers = useRef(new Map());
45
121
  const removeControllers = useRef(new Map());
@@ -56,7 +132,8 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "o
56
132
  id: it.id,
57
133
  name: it.name,
58
134
  url: it.url,
59
- status: "done"
135
+ status: "done",
136
+ meta: it.meta
60
137
  };
61
138
  });
62
139
  // Only hydrate if the user hasn't already added/modified items locally
@@ -131,8 +208,9 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "o
131
208
  upload
132
209
  ]);
133
210
  const selectFiles = useCallback((files)=>{
134
- if (!files || files.length === 0) return;
135
- const selected = Array.from(files);
211
+ if (!files) return;
212
+ const selected = Array.isArray(files) ? files : Array.from(files);
213
+ if (selected.length === 0) return;
136
214
  const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== "canceled").length) : undefined;
137
215
  const toUse = typeof remaining === "number" ? selected.slice(0, remaining) : selected;
138
216
  const newItems = toUse.map((file)=>({
@@ -163,10 +241,15 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "o
163
241
  const onDrop = useCallback((e)=>{
164
242
  e.preventDefault();
165
243
  if (disabled) return;
166
- selectFiles(e.dataTransfer.files);
244
+ const dtFiles = e.dataTransfer?.files;
245
+ if (!dtFiles || dtFiles.length === 0) return;
246
+ const accepted = Array.from(dtFiles).filter((f)=>acceptsFile(f, accept));
247
+ if (accepted.length === 0) return;
248
+ selectFiles(accepted);
167
249
  }, [
168
250
  disabled,
169
- selectFiles
251
+ selectFiles,
252
+ accept
170
253
  ]);
171
254
  const onDragOver = useCallback((e)=>e.preventDefault(), []);
172
255
  const actions = useMemo(()=>({
@@ -271,6 +354,21 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "o
271
354
  hiddenInputValue,
272
355
  name
273
356
  };
357
+ // Expose imperative methods via ref
358
+ useImperativeHandle(ref, ()=>({
359
+ setItems: emitChange,
360
+ getItems: ()=>items,
361
+ onDrop,
362
+ onDragOver,
363
+ openFileDialog: ()=>inputRef.current?.click(),
364
+ actions
365
+ }), [
366
+ emitChange,
367
+ items,
368
+ onDrop,
369
+ onDragOver,
370
+ actions
371
+ ]);
274
372
  return /*#__PURE__*/ jsx(UploaderCtx.Provider, {
275
373
  value: ctx,
276
374
  children: /*#__PURE__*/ jsxs("div", {
@@ -285,7 +383,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "o
285
383
  ]
286
384
  })
287
385
  });
288
- };
386
+ });
289
387
 
290
388
  const useUplofile = ()=>{
291
389
  const ctx = useContext(UploaderCtx);
@@ -582,33 +680,36 @@ const Cancel = ({ uid, asChild, alwaysVisible = false, ...rest })=>{
582
680
  const Comp = asChild ? Slot : "button";
583
681
  if (!isUploading && !alwaysVisible) return null;
584
682
  return /*#__PURE__*/ jsx(Comp, {
683
+ ...rest,
585
684
  onClick: (e)=>{
586
685
  e.stopPropagation();
587
686
  actions.cancel(uid);
588
- },
589
- ...rest
687
+ rest.onClick?.(e);
688
+ }
590
689
  });
591
690
  };
592
691
  const Retry = ({ uid, asChild, ...rest })=>{
593
692
  const { actions } = useUplofile();
594
693
  const Comp = asChild ? Slot : "button";
595
694
  return /*#__PURE__*/ jsx(Comp, {
695
+ ...rest,
596
696
  onClick: (e)=>{
597
697
  e.stopPropagation();
598
698
  actions.retry(uid);
599
- },
600
- ...rest
699
+ rest.onClick?.(e);
700
+ }
601
701
  });
602
702
  };
603
703
  const Remove = ({ uid, asChild, ...rest })=>{
604
704
  const { actions } = useUplofile();
605
705
  const Comp = asChild ? Slot : "button";
606
706
  return /*#__PURE__*/ jsx(Comp, {
707
+ ...rest,
607
708
  onClick: (e)=>{
608
709
  e.stopPropagation();
609
710
  actions.remove(uid);
610
- },
611
- ...rest
711
+ rest.onClick?.(e);
712
+ }
612
713
  });
613
714
  };
614
715
 
@@ -643,4 +744,4 @@ const Trigger = ({ asChild, children, render, ...rest })=>{
643
744
  });
644
745
  };
645
746
 
646
- export { Cancel, Dropzone, HiddenInput, Preview, Remove, Retry, Root, Trigger, isVideoFile, useUplofile };
747
+ export { Cancel, Dropzone, HiddenInput, Preview, Remove, Retry, Root, Trigger, getExtension, isImageFile, isVideoFile, useUplofile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplofile",
3
- "version": "1.1.1",
3
+ "version": "2.0.0",
4
4
  "description": "Composable file‑upload components for React.",
5
5
  "license": "MIT",
6
6
  "author": "Chris Josh <KristofaJosh>",