@thetechfossil/upfiles 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/dist/index.d.mts +59 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +266 -0
- package/dist/index.mjs +238 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @upfiles/uploader
|
|
2
|
+
|
|
3
|
+
Lightweight JavaScript client and React component to upload files to an UpFiles server (presigned S3 under the hood). Similar to UploadThing-like DX.
|
|
4
|
+
|
|
5
|
+
- Client: get presigned URLs and PUT to S3
|
|
6
|
+
- React `<Uploader />`: drag/drop, progress, single/multiple, accepts/types, size limits
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @upfiles/uploader
|
|
12
|
+
# or
|
|
13
|
+
pnpm add @upfiles/uploader
|
|
14
|
+
# or
|
|
15
|
+
yarn add @upfiles/uploader
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Server prerequisites
|
|
19
|
+
|
|
20
|
+
Your server must expose a presign endpoint compatible with:
|
|
21
|
+
|
|
22
|
+
- Method: `POST`
|
|
23
|
+
- Path: `/api/upload/presigned-url` (configurable via `clientOptions.presignPath`)
|
|
24
|
+
- Body: `{ fileName, fileType, fileSize? }`
|
|
25
|
+
- Response: `{ presignedUrl, fileKey, publicUrl }`
|
|
26
|
+
|
|
27
|
+
This repository already provides that at `src/app/api/upload/presigned-url/route.ts`.
|
|
28
|
+
|
|
29
|
+
If your server requires auth, pass cookies (withCredentials) or an Authorization header in `clientOptions.headers`.
|
|
30
|
+
|
|
31
|
+
## Basic usage (React)
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { Uploader } from '@upfiles/uploader';
|
|
35
|
+
|
|
36
|
+
export default function Page() {
|
|
37
|
+
return (
|
|
38
|
+
<Uploader
|
|
39
|
+
clientOptions={{
|
|
40
|
+
baseUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:4000',
|
|
41
|
+
// headers: { Authorization: `Bearer ${token}` },
|
|
42
|
+
withCredentials: true, // if your server uses NextAuth session cookies
|
|
43
|
+
}}
|
|
44
|
+
multiple
|
|
45
|
+
accept={["image/*", "application/pdf"]}
|
|
46
|
+
maxFileSize={100 * 1024 * 1024}
|
|
47
|
+
onComplete={(files) => {
|
|
48
|
+
console.log('uploaded:', files);
|
|
49
|
+
}}
|
|
50
|
+
onError={(err) => {
|
|
51
|
+
console.error(err);
|
|
52
|
+
}}
|
|
53
|
+
dropzoneClassName="border border-dashed rounded-md p-6 cursor-pointer"
|
|
54
|
+
buttonClassName="px-3 py-2 rounded bg-blue-600 text-white"
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Basic usage (vanilla JS)
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { UpfilesClient } from '@upfiles/uploader';
|
|
64
|
+
|
|
65
|
+
const client = new UpfilesClient({
|
|
66
|
+
baseUrl: 'http://localhost:4000',
|
|
67
|
+
withCredentials: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
async function upload(file) {
|
|
71
|
+
const { presignedUrl, publicUrl } = await client.getPresignedUrl({
|
|
72
|
+
fileName: file.name,
|
|
73
|
+
fileType: file.type,
|
|
74
|
+
fileSize: file.size,
|
|
75
|
+
});
|
|
76
|
+
const res = await client.uploadToS3(presignedUrl, file);
|
|
77
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
78
|
+
return publicUrl;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
- `new UpfilesClient(options)`
|
|
85
|
+
- `baseUrl` (required): your UpFiles app base URL
|
|
86
|
+
- `presignPath` (optional): default `/api/upload/presigned-url`
|
|
87
|
+
- `headers` (optional): headers for your server, e.g. Authorization
|
|
88
|
+
- `withCredentials` (optional): `true` to send cookies
|
|
89
|
+
- `client.getPresignedUrl({ fileName, fileType, fileSize? })`
|
|
90
|
+
- `client.uploadToS3(presignedUrl, file)`
|
|
91
|
+
|
|
92
|
+
- `<Uploader />` props
|
|
93
|
+
- `clientOptions`: same as `UpfilesClient` options
|
|
94
|
+
- `multiple` (default true)
|
|
95
|
+
- `accept` (array of MIME patterns)
|
|
96
|
+
- `maxFileSize` (bytes, default 100MB)
|
|
97
|
+
- `maxFiles` (default 10)
|
|
98
|
+
- `onComplete(files)` and `onError(error)`
|
|
99
|
+
- `className`, `buttonClassName`, `dropzoneClassName`
|
|
100
|
+
- `children`: custom dropzone inner content
|
|
101
|
+
|
|
102
|
+
## Notes
|
|
103
|
+
|
|
104
|
+
- Ensure CORS for your app domain (S3 bucket CORS and your server CORS as needed).
|
|
105
|
+
- For cross-origin usage with cookies (NextAuth), set `withCredentials: true` and configure your server to allow credentials.
|
|
106
|
+
- Consider adding API token auth if distributing the SDK publicly.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type PresignResponse = {
|
|
4
|
+
presignedUrl: string;
|
|
5
|
+
fileKey: string;
|
|
6
|
+
publicUrl: string;
|
|
7
|
+
};
|
|
8
|
+
type UpfilesClientOptions = {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
presignPath?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
withCredentials?: boolean;
|
|
13
|
+
};
|
|
14
|
+
declare class UpfilesClient {
|
|
15
|
+
private baseUrl;
|
|
16
|
+
private presignPath;
|
|
17
|
+
private headers?;
|
|
18
|
+
private withCredentials?;
|
|
19
|
+
constructor(opts: UpfilesClientOptions);
|
|
20
|
+
getPresignedUrl(params: {
|
|
21
|
+
fileName: string;
|
|
22
|
+
fileType: string;
|
|
23
|
+
fileSize?: number;
|
|
24
|
+
projectId?: string;
|
|
25
|
+
folderPath?: string;
|
|
26
|
+
}): Promise<PresignResponse>;
|
|
27
|
+
uploadToS3(presignedUrl: string, file: File): Promise<Response>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type UploaderFile = {
|
|
31
|
+
id: string;
|
|
32
|
+
file: File;
|
|
33
|
+
name: string;
|
|
34
|
+
size: number;
|
|
35
|
+
type: string;
|
|
36
|
+
progress: number;
|
|
37
|
+
status: 'pending' | 'uploading' | 'success' | 'error';
|
|
38
|
+
error?: string;
|
|
39
|
+
publicUrl?: string;
|
|
40
|
+
fileKey?: string;
|
|
41
|
+
};
|
|
42
|
+
type UploaderProps = {
|
|
43
|
+
clientOptions: UpfilesClientOptions;
|
|
44
|
+
multiple?: boolean;
|
|
45
|
+
accept?: string[];
|
|
46
|
+
maxFileSize?: number;
|
|
47
|
+
maxFiles?: number;
|
|
48
|
+
projectId?: string;
|
|
49
|
+
folderPath?: string;
|
|
50
|
+
onComplete?: (files: UploaderFile[]) => void;
|
|
51
|
+
onError?: (error: Error) => void;
|
|
52
|
+
className?: string;
|
|
53
|
+
buttonClassName?: string;
|
|
54
|
+
dropzoneClassName?: string;
|
|
55
|
+
children?: React.ReactNode;
|
|
56
|
+
};
|
|
57
|
+
declare const Uploader: React.FC<UploaderProps>;
|
|
58
|
+
|
|
59
|
+
export { type PresignResponse, UpfilesClient, type UpfilesClientOptions, Uploader, type UploaderFile, type UploaderProps };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type PresignResponse = {
|
|
4
|
+
presignedUrl: string;
|
|
5
|
+
fileKey: string;
|
|
6
|
+
publicUrl: string;
|
|
7
|
+
};
|
|
8
|
+
type UpfilesClientOptions = {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
presignPath?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
withCredentials?: boolean;
|
|
13
|
+
};
|
|
14
|
+
declare class UpfilesClient {
|
|
15
|
+
private baseUrl;
|
|
16
|
+
private presignPath;
|
|
17
|
+
private headers?;
|
|
18
|
+
private withCredentials?;
|
|
19
|
+
constructor(opts: UpfilesClientOptions);
|
|
20
|
+
getPresignedUrl(params: {
|
|
21
|
+
fileName: string;
|
|
22
|
+
fileType: string;
|
|
23
|
+
fileSize?: number;
|
|
24
|
+
projectId?: string;
|
|
25
|
+
folderPath?: string;
|
|
26
|
+
}): Promise<PresignResponse>;
|
|
27
|
+
uploadToS3(presignedUrl: string, file: File): Promise<Response>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type UploaderFile = {
|
|
31
|
+
id: string;
|
|
32
|
+
file: File;
|
|
33
|
+
name: string;
|
|
34
|
+
size: number;
|
|
35
|
+
type: string;
|
|
36
|
+
progress: number;
|
|
37
|
+
status: 'pending' | 'uploading' | 'success' | 'error';
|
|
38
|
+
error?: string;
|
|
39
|
+
publicUrl?: string;
|
|
40
|
+
fileKey?: string;
|
|
41
|
+
};
|
|
42
|
+
type UploaderProps = {
|
|
43
|
+
clientOptions: UpfilesClientOptions;
|
|
44
|
+
multiple?: boolean;
|
|
45
|
+
accept?: string[];
|
|
46
|
+
maxFileSize?: number;
|
|
47
|
+
maxFiles?: number;
|
|
48
|
+
projectId?: string;
|
|
49
|
+
folderPath?: string;
|
|
50
|
+
onComplete?: (files: UploaderFile[]) => void;
|
|
51
|
+
onError?: (error: Error) => void;
|
|
52
|
+
className?: string;
|
|
53
|
+
buttonClassName?: string;
|
|
54
|
+
dropzoneClassName?: string;
|
|
55
|
+
children?: React.ReactNode;
|
|
56
|
+
};
|
|
57
|
+
declare const Uploader: React.FC<UploaderProps>;
|
|
58
|
+
|
|
59
|
+
export { type PresignResponse, UpfilesClient, type UpfilesClientOptions, Uploader, type UploaderFile, type UploaderProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
UpfilesClient: () => UpfilesClient,
|
|
24
|
+
Uploader: () => Uploader
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/client.ts
|
|
29
|
+
var UpfilesClient = class {
|
|
30
|
+
constructor(opts) {
|
|
31
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
32
|
+
this.presignPath = opts.presignPath ?? "/api/upload/presigned-url";
|
|
33
|
+
this.headers = opts.headers;
|
|
34
|
+
this.withCredentials = opts.withCredentials;
|
|
35
|
+
}
|
|
36
|
+
async getPresignedUrl(params) {
|
|
37
|
+
const res = await fetch(`${this.baseUrl}${this.presignPath}`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
...this.headers || {}
|
|
42
|
+
},
|
|
43
|
+
credentials: this.withCredentials ? "include" : "same-origin",
|
|
44
|
+
body: JSON.stringify(params)
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
let msg = "Failed to get upload URL";
|
|
48
|
+
try {
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
msg = data.error || msg;
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
throw new Error(msg);
|
|
54
|
+
}
|
|
55
|
+
return res.json();
|
|
56
|
+
}
|
|
57
|
+
async uploadToS3(presignedUrl, file) {
|
|
58
|
+
return fetch(presignedUrl, {
|
|
59
|
+
method: "PUT",
|
|
60
|
+
body: file,
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": file.type
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/Uploader.tsx
|
|
69
|
+
var import_react = require("react");
|
|
70
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
71
|
+
var Uploader = ({
|
|
72
|
+
clientOptions,
|
|
73
|
+
multiple = true,
|
|
74
|
+
accept = ["image/*", "video/*", "audio/*", "application/pdf", "text/plain", "application/json"],
|
|
75
|
+
maxFileSize = 100 * 1024 * 1024,
|
|
76
|
+
maxFiles = 10,
|
|
77
|
+
onComplete,
|
|
78
|
+
onError,
|
|
79
|
+
className,
|
|
80
|
+
buttonClassName,
|
|
81
|
+
dropzoneClassName,
|
|
82
|
+
children,
|
|
83
|
+
projectId,
|
|
84
|
+
folderPath
|
|
85
|
+
}) => {
|
|
86
|
+
const inputRef = (0, import_react.useRef)(null);
|
|
87
|
+
const client = (0, import_react.useMemo)(() => new UpfilesClient(clientOptions), [clientOptions]);
|
|
88
|
+
const [files, setFiles] = (0, import_react.useState)([]);
|
|
89
|
+
const [isUploading, setIsUploading] = (0, import_react.useState)(false);
|
|
90
|
+
const [isDragOver, setIsDragOver] = (0, import_react.useState)(false);
|
|
91
|
+
const addFiles = (0, import_react.useCallback)((incoming) => {
|
|
92
|
+
const filtered = incoming.filter((f) => {
|
|
93
|
+
if (f.size > maxFileSize) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
const limited = filtered.slice(0, Math.max(0, maxFiles - files.length));
|
|
99
|
+
const mapped = limited.map((f) => ({
|
|
100
|
+
id: Math.random().toString(36).slice(2),
|
|
101
|
+
file: f,
|
|
102
|
+
name: f.name,
|
|
103
|
+
size: f.size,
|
|
104
|
+
type: f.type,
|
|
105
|
+
progress: 0,
|
|
106
|
+
status: "pending"
|
|
107
|
+
}));
|
|
108
|
+
setFiles((prev) => [...prev, ...mapped]);
|
|
109
|
+
}, [files.length, maxFileSize, maxFiles]);
|
|
110
|
+
const onInputChange = (0, import_react.useCallback)((e) => {
|
|
111
|
+
const fs = Array.from(e.target.files || []);
|
|
112
|
+
addFiles(fs);
|
|
113
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
114
|
+
}, [addFiles]);
|
|
115
|
+
const handleDrop = (0, import_react.useCallback)((e) => {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
setIsDragOver(false);
|
|
119
|
+
addFiles(Array.from(e.dataTransfer.files || []));
|
|
120
|
+
}, [addFiles]);
|
|
121
|
+
const uploadOne = (0, import_react.useCallback)(async (item) => {
|
|
122
|
+
const update = (patch) => {
|
|
123
|
+
setFiles((prev) => prev.map((f) => f.id === item.id ? { ...f, ...patch } : f));
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
update({ status: "uploading", progress: 1 });
|
|
127
|
+
const presign = await client.getPresignedUrl({
|
|
128
|
+
fileName: item.name,
|
|
129
|
+
fileType: item.type,
|
|
130
|
+
fileSize: item.size,
|
|
131
|
+
projectId,
|
|
132
|
+
folderPath
|
|
133
|
+
});
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
const xhr = new XMLHttpRequest();
|
|
136
|
+
xhr.open("PUT", presign.presignedUrl, true);
|
|
137
|
+
xhr.upload.onprogress = (ev) => {
|
|
138
|
+
if (ev.lengthComputable) {
|
|
139
|
+
const pct = Math.min(99, Math.floor(ev.loaded / ev.total * 100));
|
|
140
|
+
update({ progress: pct });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
xhr.onload = () => {
|
|
144
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
145
|
+
resolve();
|
|
146
|
+
} else {
|
|
147
|
+
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
xhr.onerror = () => reject(new Error("Network error during upload"));
|
|
151
|
+
xhr.setRequestHeader("Content-Type", item.type || "application/octet-stream");
|
|
152
|
+
xhr.send(item.file);
|
|
153
|
+
});
|
|
154
|
+
update({ status: "success", progress: 100, fileKey: presign.fileKey, publicUrl: presign.publicUrl });
|
|
155
|
+
return { ...item, status: "success", progress: 100, fileKey: presign.fileKey, publicUrl: presign.publicUrl };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const message = err instanceof Error ? err.message : "Upload failed";
|
|
158
|
+
update({ status: "error", error: message });
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}, [client]);
|
|
162
|
+
const uploadAll = (0, import_react.useCallback)(async () => {
|
|
163
|
+
const targets = files.filter((f) => f.status === "pending");
|
|
164
|
+
if (!targets.length) return;
|
|
165
|
+
setIsUploading(true);
|
|
166
|
+
try {
|
|
167
|
+
const results = [];
|
|
168
|
+
for (const f of targets) {
|
|
169
|
+
try {
|
|
170
|
+
const done = await uploadOne(f);
|
|
171
|
+
results.push(done);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
onComplete?.(results.filter((f) => f.status === "success"));
|
|
176
|
+
} catch (e) {
|
|
177
|
+
onError?.(e);
|
|
178
|
+
} finally {
|
|
179
|
+
setIsUploading(false);
|
|
180
|
+
}
|
|
181
|
+
}, [files, onComplete, onError, uploadOne]);
|
|
182
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className, children: [
|
|
183
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
184
|
+
"input",
|
|
185
|
+
{
|
|
186
|
+
ref: inputRef,
|
|
187
|
+
type: "file",
|
|
188
|
+
style: { display: "none" },
|
|
189
|
+
multiple,
|
|
190
|
+
accept: accept.join(","),
|
|
191
|
+
onChange: onInputChange
|
|
192
|
+
}
|
|
193
|
+
),
|
|
194
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
195
|
+
"div",
|
|
196
|
+
{
|
|
197
|
+
className: dropzoneClassName,
|
|
198
|
+
onDragOver: (e) => {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
e.stopPropagation();
|
|
201
|
+
setIsDragOver(true);
|
|
202
|
+
},
|
|
203
|
+
onDragLeave: (e) => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
e.stopPropagation();
|
|
206
|
+
setIsDragOver(false);
|
|
207
|
+
},
|
|
208
|
+
onDrop: handleDrop,
|
|
209
|
+
onClick: () => inputRef.current?.click(),
|
|
210
|
+
role: "button",
|
|
211
|
+
"aria-label": "Upload files",
|
|
212
|
+
children: children ?? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center", padding: 16 }, children: [
|
|
213
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontWeight: 600 }, children: "Drop files here or click to browse" }),
|
|
214
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { fontSize: 12, color: "#666" }, children: [
|
|
215
|
+
multiple ? `Up to ${maxFiles} files` : "Single file",
|
|
216
|
+
" \u2022 Max ",
|
|
217
|
+
(maxFileSize / (1024 * 1024)).toFixed(0),
|
|
218
|
+
"MB"
|
|
219
|
+
] })
|
|
220
|
+
] })
|
|
221
|
+
}
|
|
222
|
+
),
|
|
223
|
+
files.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 12 }, children: [
|
|
224
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: 8, marginBottom: 8 }, children: [
|
|
225
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
226
|
+
"button",
|
|
227
|
+
{
|
|
228
|
+
className: buttonClassName,
|
|
229
|
+
disabled: isUploading || files.every((f) => f.status !== "pending"),
|
|
230
|
+
onClick: uploadAll,
|
|
231
|
+
children: isUploading ? "Uploading..." : "Upload"
|
|
232
|
+
}
|
|
233
|
+
),
|
|
234
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
235
|
+
"button",
|
|
236
|
+
{
|
|
237
|
+
className: buttonClassName,
|
|
238
|
+
disabled: isUploading,
|
|
239
|
+
onClick: () => setFiles([]),
|
|
240
|
+
children: "Clear"
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
] }),
|
|
244
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: { display: "grid", gap: 8, listStyle: "none", padding: 0 }, children: files.map((f) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { style: { border: "1px solid #e5e7eb", borderRadius: 8, padding: 8 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
|
|
245
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { minWidth: 0 }, children: [
|
|
246
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis" }, children: f.name }),
|
|
247
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { fontSize: 12, color: "#666" }, children: [
|
|
248
|
+
(f.size / (1024 * 1024)).toFixed(2),
|
|
249
|
+
" MB"
|
|
250
|
+
] }),
|
|
251
|
+
f.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: "#b91c1c" }, children: f.error }),
|
|
252
|
+
f.publicUrl && f.status === "success" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", { href: f.publicUrl, target: "_blank", rel: "noreferrer", style: { fontSize: 12, color: "#2563eb" }, children: "View" })
|
|
253
|
+
] }),
|
|
254
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { minWidth: 120, textAlign: "right" }, children: [
|
|
255
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { height: 6, background: "#e5e7eb", borderRadius: 999, overflow: "hidden" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { width: `${f.progress}%`, height: "100%", background: f.status === "error" ? "#ef4444" : "#2563eb" } }) }),
|
|
256
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: "#666", marginTop: 4 }, children: f.status })
|
|
257
|
+
] })
|
|
258
|
+
] }) }, f.id)) })
|
|
259
|
+
] })
|
|
260
|
+
] });
|
|
261
|
+
};
|
|
262
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
263
|
+
0 && (module.exports = {
|
|
264
|
+
UpfilesClient,
|
|
265
|
+
Uploader
|
|
266
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
var UpfilesClient = class {
|
|
3
|
+
constructor(opts) {
|
|
4
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
5
|
+
this.presignPath = opts.presignPath ?? "/api/upload/presigned-url";
|
|
6
|
+
this.headers = opts.headers;
|
|
7
|
+
this.withCredentials = opts.withCredentials;
|
|
8
|
+
}
|
|
9
|
+
async getPresignedUrl(params) {
|
|
10
|
+
const res = await fetch(`${this.baseUrl}${this.presignPath}`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
...this.headers || {}
|
|
15
|
+
},
|
|
16
|
+
credentials: this.withCredentials ? "include" : "same-origin",
|
|
17
|
+
body: JSON.stringify(params)
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
let msg = "Failed to get upload URL";
|
|
21
|
+
try {
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
msg = data.error || msg;
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
throw new Error(msg);
|
|
27
|
+
}
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
async uploadToS3(presignedUrl, file) {
|
|
31
|
+
return fetch(presignedUrl, {
|
|
32
|
+
method: "PUT",
|
|
33
|
+
body: file,
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": file.type
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/Uploader.tsx
|
|
42
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
43
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
44
|
+
var Uploader = ({
|
|
45
|
+
clientOptions,
|
|
46
|
+
multiple = true,
|
|
47
|
+
accept = ["image/*", "video/*", "audio/*", "application/pdf", "text/plain", "application/json"],
|
|
48
|
+
maxFileSize = 100 * 1024 * 1024,
|
|
49
|
+
maxFiles = 10,
|
|
50
|
+
onComplete,
|
|
51
|
+
onError,
|
|
52
|
+
className,
|
|
53
|
+
buttonClassName,
|
|
54
|
+
dropzoneClassName,
|
|
55
|
+
children,
|
|
56
|
+
projectId,
|
|
57
|
+
folderPath
|
|
58
|
+
}) => {
|
|
59
|
+
const inputRef = useRef(null);
|
|
60
|
+
const client = useMemo(() => new UpfilesClient(clientOptions), [clientOptions]);
|
|
61
|
+
const [files, setFiles] = useState([]);
|
|
62
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
63
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
64
|
+
const addFiles = useCallback((incoming) => {
|
|
65
|
+
const filtered = incoming.filter((f) => {
|
|
66
|
+
if (f.size > maxFileSize) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
const limited = filtered.slice(0, Math.max(0, maxFiles - files.length));
|
|
72
|
+
const mapped = limited.map((f) => ({
|
|
73
|
+
id: Math.random().toString(36).slice(2),
|
|
74
|
+
file: f,
|
|
75
|
+
name: f.name,
|
|
76
|
+
size: f.size,
|
|
77
|
+
type: f.type,
|
|
78
|
+
progress: 0,
|
|
79
|
+
status: "pending"
|
|
80
|
+
}));
|
|
81
|
+
setFiles((prev) => [...prev, ...mapped]);
|
|
82
|
+
}, [files.length, maxFileSize, maxFiles]);
|
|
83
|
+
const onInputChange = useCallback((e) => {
|
|
84
|
+
const fs = Array.from(e.target.files || []);
|
|
85
|
+
addFiles(fs);
|
|
86
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
87
|
+
}, [addFiles]);
|
|
88
|
+
const handleDrop = useCallback((e) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
setIsDragOver(false);
|
|
92
|
+
addFiles(Array.from(e.dataTransfer.files || []));
|
|
93
|
+
}, [addFiles]);
|
|
94
|
+
const uploadOne = useCallback(async (item) => {
|
|
95
|
+
const update = (patch) => {
|
|
96
|
+
setFiles((prev) => prev.map((f) => f.id === item.id ? { ...f, ...patch } : f));
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
update({ status: "uploading", progress: 1 });
|
|
100
|
+
const presign = await client.getPresignedUrl({
|
|
101
|
+
fileName: item.name,
|
|
102
|
+
fileType: item.type,
|
|
103
|
+
fileSize: item.size,
|
|
104
|
+
projectId,
|
|
105
|
+
folderPath
|
|
106
|
+
});
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
const xhr = new XMLHttpRequest();
|
|
109
|
+
xhr.open("PUT", presign.presignedUrl, true);
|
|
110
|
+
xhr.upload.onprogress = (ev) => {
|
|
111
|
+
if (ev.lengthComputable) {
|
|
112
|
+
const pct = Math.min(99, Math.floor(ev.loaded / ev.total * 100));
|
|
113
|
+
update({ progress: pct });
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
xhr.onload = () => {
|
|
117
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
118
|
+
resolve();
|
|
119
|
+
} else {
|
|
120
|
+
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
xhr.onerror = () => reject(new Error("Network error during upload"));
|
|
124
|
+
xhr.setRequestHeader("Content-Type", item.type || "application/octet-stream");
|
|
125
|
+
xhr.send(item.file);
|
|
126
|
+
});
|
|
127
|
+
update({ status: "success", progress: 100, fileKey: presign.fileKey, publicUrl: presign.publicUrl });
|
|
128
|
+
return { ...item, status: "success", progress: 100, fileKey: presign.fileKey, publicUrl: presign.publicUrl };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const message = err instanceof Error ? err.message : "Upload failed";
|
|
131
|
+
update({ status: "error", error: message });
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}, [client]);
|
|
135
|
+
const uploadAll = useCallback(async () => {
|
|
136
|
+
const targets = files.filter((f) => f.status === "pending");
|
|
137
|
+
if (!targets.length) return;
|
|
138
|
+
setIsUploading(true);
|
|
139
|
+
try {
|
|
140
|
+
const results = [];
|
|
141
|
+
for (const f of targets) {
|
|
142
|
+
try {
|
|
143
|
+
const done = await uploadOne(f);
|
|
144
|
+
results.push(done);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
onComplete?.(results.filter((f) => f.status === "success"));
|
|
149
|
+
} catch (e) {
|
|
150
|
+
onError?.(e);
|
|
151
|
+
} finally {
|
|
152
|
+
setIsUploading(false);
|
|
153
|
+
}
|
|
154
|
+
}, [files, onComplete, onError, uploadOne]);
|
|
155
|
+
return /* @__PURE__ */ jsxs("div", { className, children: [
|
|
156
|
+
/* @__PURE__ */ jsx(
|
|
157
|
+
"input",
|
|
158
|
+
{
|
|
159
|
+
ref: inputRef,
|
|
160
|
+
type: "file",
|
|
161
|
+
style: { display: "none" },
|
|
162
|
+
multiple,
|
|
163
|
+
accept: accept.join(","),
|
|
164
|
+
onChange: onInputChange
|
|
165
|
+
}
|
|
166
|
+
),
|
|
167
|
+
/* @__PURE__ */ jsx(
|
|
168
|
+
"div",
|
|
169
|
+
{
|
|
170
|
+
className: dropzoneClassName,
|
|
171
|
+
onDragOver: (e) => {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
e.stopPropagation();
|
|
174
|
+
setIsDragOver(true);
|
|
175
|
+
},
|
|
176
|
+
onDragLeave: (e) => {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
e.stopPropagation();
|
|
179
|
+
setIsDragOver(false);
|
|
180
|
+
},
|
|
181
|
+
onDrop: handleDrop,
|
|
182
|
+
onClick: () => inputRef.current?.click(),
|
|
183
|
+
role: "button",
|
|
184
|
+
"aria-label": "Upload files",
|
|
185
|
+
children: children ?? /* @__PURE__ */ jsxs("div", { style: { textAlign: "center", padding: 16 }, children: [
|
|
186
|
+
/* @__PURE__ */ jsx("div", { style: { fontWeight: 600 }, children: "Drop files here or click to browse" }),
|
|
187
|
+
/* @__PURE__ */ jsxs("div", { style: { fontSize: 12, color: "#666" }, children: [
|
|
188
|
+
multiple ? `Up to ${maxFiles} files` : "Single file",
|
|
189
|
+
" \u2022 Max ",
|
|
190
|
+
(maxFileSize / (1024 * 1024)).toFixed(0),
|
|
191
|
+
"MB"
|
|
192
|
+
] })
|
|
193
|
+
] })
|
|
194
|
+
}
|
|
195
|
+
),
|
|
196
|
+
files.length > 0 && /* @__PURE__ */ jsxs("div", { style: { marginTop: 12 }, children: [
|
|
197
|
+
/* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8, marginBottom: 8 }, children: [
|
|
198
|
+
/* @__PURE__ */ jsx(
|
|
199
|
+
"button",
|
|
200
|
+
{
|
|
201
|
+
className: buttonClassName,
|
|
202
|
+
disabled: isUploading || files.every((f) => f.status !== "pending"),
|
|
203
|
+
onClick: uploadAll,
|
|
204
|
+
children: isUploading ? "Uploading..." : "Upload"
|
|
205
|
+
}
|
|
206
|
+
),
|
|
207
|
+
/* @__PURE__ */ jsx(
|
|
208
|
+
"button",
|
|
209
|
+
{
|
|
210
|
+
className: buttonClassName,
|
|
211
|
+
disabled: isUploading,
|
|
212
|
+
onClick: () => setFiles([]),
|
|
213
|
+
children: "Clear"
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
] }),
|
|
217
|
+
/* @__PURE__ */ jsx("ul", { style: { display: "grid", gap: 8, listStyle: "none", padding: 0 }, children: files.map((f) => /* @__PURE__ */ jsx("li", { style: { border: "1px solid #e5e7eb", borderRadius: 8, padding: 8 }, children: /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
|
|
218
|
+
/* @__PURE__ */ jsxs("div", { style: { minWidth: 0 }, children: [
|
|
219
|
+
/* @__PURE__ */ jsx("div", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis" }, children: f.name }),
|
|
220
|
+
/* @__PURE__ */ jsxs("div", { style: { fontSize: 12, color: "#666" }, children: [
|
|
221
|
+
(f.size / (1024 * 1024)).toFixed(2),
|
|
222
|
+
" MB"
|
|
223
|
+
] }),
|
|
224
|
+
f.error && /* @__PURE__ */ jsx("div", { style: { fontSize: 12, color: "#b91c1c" }, children: f.error }),
|
|
225
|
+
f.publicUrl && f.status === "success" && /* @__PURE__ */ jsx("a", { href: f.publicUrl, target: "_blank", rel: "noreferrer", style: { fontSize: 12, color: "#2563eb" }, children: "View" })
|
|
226
|
+
] }),
|
|
227
|
+
/* @__PURE__ */ jsxs("div", { style: { minWidth: 120, textAlign: "right" }, children: [
|
|
228
|
+
/* @__PURE__ */ jsx("div", { style: { height: 6, background: "#e5e7eb", borderRadius: 999, overflow: "hidden" }, children: /* @__PURE__ */ jsx("div", { style: { width: `${f.progress}%`, height: "100%", background: f.status === "error" ? "#ef4444" : "#2563eb" } }) }),
|
|
229
|
+
/* @__PURE__ */ jsx("div", { style: { fontSize: 12, color: "#666", marginTop: 4 }, children: f.status })
|
|
230
|
+
] })
|
|
231
|
+
] }) }, f.id)) })
|
|
232
|
+
] })
|
|
233
|
+
] });
|
|
234
|
+
};
|
|
235
|
+
export {
|
|
236
|
+
UpfilesClient,
|
|
237
|
+
Uploader
|
|
238
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thetechfossil/upfiles",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight client and React components to upload files to UpFiles API (presigned S3)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "UpFiles",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/your-org/upfiles.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.cjs",
|
|
12
|
+
"module": "dist/index.mjs",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"import": "./dist/index.mjs",
|
|
17
|
+
"require": "./dist/index.cjs",
|
|
18
|
+
"types": "./dist/index.d.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
30
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
31
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/react": "^19",
|
|
39
|
+
"tsup": "^8.3.0",
|
|
40
|
+
"typescript": "^5.6.2"
|
|
41
|
+
}
|
|
42
|
+
}
|