upload-motion 0.0.0-stage → 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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +198 -2
- package/dist/core-BUWs_ckM.js +1256 -0
- package/dist/core-jiCtXcMo.cjs +1393 -0
- package/dist/core.cjs +26 -0
- package/dist/core.d.cts +3 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.js +2 -0
- package/dist/index-BZJA26zI.d.cts +471 -0
- package/dist/index-BZJA26zI.d.ts +471 -0
- package/dist/index.cjs +4017 -0
- package/dist/index.d.cts +617 -0
- package/dist/index.d.ts +617 -0
- package/dist/index.js +3927 -0
- package/dist/styles.css +3417 -0
- package/dist/styles.css.d.ts +1 -0
- package/package.json +109 -4
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/core/format.ts
|
|
3
|
+
const UNITS = [
|
|
4
|
+
"B",
|
|
5
|
+
"KB",
|
|
6
|
+
"MB",
|
|
7
|
+
"GB",
|
|
8
|
+
"TB"
|
|
9
|
+
];
|
|
10
|
+
/**
|
|
11
|
+
* `25 * 1024 * 1024` → `"25 MB"`. Uses 1024-based units so limits written the
|
|
12
|
+
* usual way (`n * 1024 * 1024`) read back exactly as configured.
|
|
13
|
+
*/
|
|
14
|
+
function formatBytes(bytes, fractionDigits = 1) {
|
|
15
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
16
|
+
const exponent = Math.min(Math.floor(Math.log2(bytes) / 10), UNITS.length - 1);
|
|
17
|
+
const value = bytes / 1024 ** exponent;
|
|
18
|
+
const digits = exponent === 0 || value >= 100 || Number.isInteger(value) ? 0 : fractionDigits;
|
|
19
|
+
return `${value.toFixed(digits)} ${UNITS[exponent]}`;
|
|
20
|
+
}
|
|
21
|
+
function formatSpeed(bytesPerSecond) {
|
|
22
|
+
return `${formatBytes(bytesPerSecond)}/s`;
|
|
23
|
+
}
|
|
24
|
+
/** `5` → `"5 seconds"`, `90` → `"2 minutes"`, `3900` → `"1 hour"` */
|
|
25
|
+
function formatDuration(seconds) {
|
|
26
|
+
if (!Number.isFinite(seconds) || seconds < 0) return "";
|
|
27
|
+
const s = Math.max(1, Math.round(seconds));
|
|
28
|
+
if (s < 60) return `${s} ${s === 1 ? "second" : "seconds"}`;
|
|
29
|
+
const m = Math.round(s / 60);
|
|
30
|
+
if (m < 60) return `${m} ${m === 1 ? "minute" : "minutes"}`;
|
|
31
|
+
const h = Math.round(m / 60);
|
|
32
|
+
return `${h} ${h === 1 ? "hour" : "hours"}`;
|
|
33
|
+
}
|
|
34
|
+
function formatPercent(fraction) {
|
|
35
|
+
return `${Math.round(clamp01(fraction) * 100)}%`;
|
|
36
|
+
}
|
|
37
|
+
function clamp01(value) {
|
|
38
|
+
return value < 0 ? 0 : value > 1 ? 1 : Number.isFinite(value) ? value : 0;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/core/file-kind.ts
|
|
42
|
+
const EXTENSION_KIND = {
|
|
43
|
+
jpg: "image",
|
|
44
|
+
jpeg: "image",
|
|
45
|
+
png: "image",
|
|
46
|
+
webp: "image",
|
|
47
|
+
gif: "image",
|
|
48
|
+
svg: "image",
|
|
49
|
+
avif: "image",
|
|
50
|
+
bmp: "image",
|
|
51
|
+
ico: "image",
|
|
52
|
+
heic: "image",
|
|
53
|
+
heif: "image",
|
|
54
|
+
tif: "image",
|
|
55
|
+
tiff: "image",
|
|
56
|
+
mp4: "video",
|
|
57
|
+
mov: "video",
|
|
58
|
+
webm: "video",
|
|
59
|
+
mkv: "video",
|
|
60
|
+
avi: "video",
|
|
61
|
+
m4v: "video",
|
|
62
|
+
ogv: "video",
|
|
63
|
+
mp3: "audio",
|
|
64
|
+
wav: "audio",
|
|
65
|
+
ogg: "audio",
|
|
66
|
+
oga: "audio",
|
|
67
|
+
m4a: "audio",
|
|
68
|
+
aac: "audio",
|
|
69
|
+
flac: "audio",
|
|
70
|
+
opus: "audio",
|
|
71
|
+
pdf: "pdf",
|
|
72
|
+
doc: "document",
|
|
73
|
+
docx: "document",
|
|
74
|
+
odt: "document",
|
|
75
|
+
rtf: "document",
|
|
76
|
+
pages: "document",
|
|
77
|
+
xls: "spreadsheet",
|
|
78
|
+
xlsx: "spreadsheet",
|
|
79
|
+
ods: "spreadsheet",
|
|
80
|
+
csv: "spreadsheet",
|
|
81
|
+
tsv: "spreadsheet",
|
|
82
|
+
numbers: "spreadsheet",
|
|
83
|
+
ppt: "presentation",
|
|
84
|
+
pptx: "presentation",
|
|
85
|
+
odp: "presentation",
|
|
86
|
+
key: "presentation",
|
|
87
|
+
zip: "archive",
|
|
88
|
+
rar: "archive",
|
|
89
|
+
"7z": "archive",
|
|
90
|
+
tar: "archive",
|
|
91
|
+
gz: "archive",
|
|
92
|
+
tgz: "archive",
|
|
93
|
+
bz2: "archive",
|
|
94
|
+
xz: "archive",
|
|
95
|
+
js: "code",
|
|
96
|
+
mjs: "code",
|
|
97
|
+
cjs: "code",
|
|
98
|
+
jsx: "code",
|
|
99
|
+
ts: "code",
|
|
100
|
+
tsx: "code",
|
|
101
|
+
py: "code",
|
|
102
|
+
rb: "code",
|
|
103
|
+
go: "code",
|
|
104
|
+
rs: "code",
|
|
105
|
+
java: "code",
|
|
106
|
+
kt: "code",
|
|
107
|
+
swift: "code",
|
|
108
|
+
c: "code",
|
|
109
|
+
cpp: "code",
|
|
110
|
+
h: "code",
|
|
111
|
+
cs: "code",
|
|
112
|
+
php: "code",
|
|
113
|
+
html: "code",
|
|
114
|
+
css: "code",
|
|
115
|
+
scss: "code",
|
|
116
|
+
sh: "code",
|
|
117
|
+
sql: "code",
|
|
118
|
+
vue: "code",
|
|
119
|
+
svelte: "code",
|
|
120
|
+
json: "data",
|
|
121
|
+
xml: "data",
|
|
122
|
+
yaml: "data",
|
|
123
|
+
yml: "data",
|
|
124
|
+
toml: "data",
|
|
125
|
+
ndjson: "data",
|
|
126
|
+
parquet: "data",
|
|
127
|
+
txt: "text",
|
|
128
|
+
md: "text",
|
|
129
|
+
log: "text",
|
|
130
|
+
ini: "text"
|
|
131
|
+
};
|
|
132
|
+
const MIME_KIND = [
|
|
133
|
+
[/^image\//, "image"],
|
|
134
|
+
[/^video\//, "video"],
|
|
135
|
+
[/^audio\//, "audio"],
|
|
136
|
+
[/^application\/pdf$/, "pdf"],
|
|
137
|
+
[/(msword|wordprocessingml|opendocument\.text|rtf)/, "document"],
|
|
138
|
+
[/(ms-excel|spreadsheetml|opendocument\.spreadsheet|text\/csv)/, "spreadsheet"],
|
|
139
|
+
[/(ms-powerpoint|presentationml|opendocument\.presentation)/, "presentation"],
|
|
140
|
+
[/(zip|x-rar|x-7z|x-tar|gzip|x-bzip)/, "archive"],
|
|
141
|
+
[/(javascript|typescript|x-python|x-sh)/, "code"],
|
|
142
|
+
[/(json|xml|yaml)/, "data"],
|
|
143
|
+
[/^text\//, "text"]
|
|
144
|
+
];
|
|
145
|
+
function getExtension(name) {
|
|
146
|
+
const dot = name.lastIndexOf(".");
|
|
147
|
+
if (dot <= 0 || dot === name.length - 1) return "";
|
|
148
|
+
return name.slice(dot + 1).toLowerCase();
|
|
149
|
+
}
|
|
150
|
+
/** Classifies a file by extension first (more specific), then MIME type. */
|
|
151
|
+
function getFileKind(file) {
|
|
152
|
+
const fromExt = EXTENSION_KIND[getExtension(file.name)];
|
|
153
|
+
if (fromExt) return fromExt;
|
|
154
|
+
const type = (file.type ?? "").toLowerCase();
|
|
155
|
+
if (type) {
|
|
156
|
+
for (const [pattern, kind] of MIME_KIND) if (pattern.test(type)) return kind;
|
|
157
|
+
}
|
|
158
|
+
return "unknown";
|
|
159
|
+
}
|
|
160
|
+
/** Short badge label such as `PDF`, `DOCX`, `MP4`. Falls back to the kind. */
|
|
161
|
+
function getFileLabel(file) {
|
|
162
|
+
const ext = getExtension(file.name);
|
|
163
|
+
if (ext && ext.length <= 5) return ext.toUpperCase();
|
|
164
|
+
const kind = getFileKind(file);
|
|
165
|
+
return kind === "unknown" ? "FILE" : kind.slice(0, 4).toUpperCase();
|
|
166
|
+
}
|
|
167
|
+
/** Kinds that browsers can render as an `<img>` without extra libraries. */
|
|
168
|
+
function isPreviewableImage(file) {
|
|
169
|
+
const type = (file.type ?? "").toLowerCase();
|
|
170
|
+
if (type) return /^image\/(png|jpe?g|webp|gif|svg\+xml|avif|bmp|x-icon|vnd\.microsoft\.icon)$/.test(type);
|
|
171
|
+
return [
|
|
172
|
+
"png",
|
|
173
|
+
"jpg",
|
|
174
|
+
"jpeg",
|
|
175
|
+
"webp",
|
|
176
|
+
"gif",
|
|
177
|
+
"svg",
|
|
178
|
+
"avif",
|
|
179
|
+
"bmp",
|
|
180
|
+
"ico"
|
|
181
|
+
].includes(getExtension(file.name));
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/core/preset-catalog.ts
|
|
185
|
+
const info = (entry) => entry;
|
|
186
|
+
const PRESET_INFO = {
|
|
187
|
+
"card-fan": info({
|
|
188
|
+
id: "card-fan",
|
|
189
|
+
name: "Card Fan",
|
|
190
|
+
description: "Three media cards fan out as files approach the window, curved arrows draw themselves toward the zone, and the deck re-deals on drop. Manual upload button with inline progress.",
|
|
191
|
+
category: "reference",
|
|
192
|
+
bestFor: [
|
|
193
|
+
"Media libraries",
|
|
194
|
+
"CMS",
|
|
195
|
+
"Marketing tools"
|
|
196
|
+
],
|
|
197
|
+
defaults: { autoUpload: false }
|
|
198
|
+
}),
|
|
199
|
+
"wave-bar": info({
|
|
200
|
+
id: "wave-bar",
|
|
201
|
+
name: "Wave Bar",
|
|
202
|
+
description: "A compact upload pill: a soft fill sweeps behind the summary while a sine wave travels along the edge, then flattens into a line on success. Pause, cancel, expand and more actions built in.",
|
|
203
|
+
category: "reference",
|
|
204
|
+
bestFor: [
|
|
205
|
+
"Dashboards",
|
|
206
|
+
"Cloud storage",
|
|
207
|
+
"Background uploads"
|
|
208
|
+
],
|
|
209
|
+
defaults: { progressVariant: "wave" }
|
|
210
|
+
}),
|
|
211
|
+
"hoop-shot": info({
|
|
212
|
+
id: "hoop-shot",
|
|
213
|
+
name: "Hoop Shot",
|
|
214
|
+
description: "A playful, physics-driven uploader: pull a file back, watch the dotted trajectory, release to shoot it into the hoop. Real gravity, rim bounces and bank shots – plus keyboard and \"Shoot all\" for instant perfect shots.",
|
|
215
|
+
category: "reference",
|
|
216
|
+
bestFor: [
|
|
217
|
+
"Consumer apps",
|
|
218
|
+
"Onboarding",
|
|
219
|
+
"Portfolios",
|
|
220
|
+
"Delight moments"
|
|
221
|
+
],
|
|
222
|
+
defaults: { autoUpload: false }
|
|
223
|
+
}),
|
|
224
|
+
essential: info({
|
|
225
|
+
id: "essential",
|
|
226
|
+
name: "Essential",
|
|
227
|
+
description: "Restrained and professional: a quiet dashed zone, thin progress bars, gentle list transitions. Motion that informs rather than performs.",
|
|
228
|
+
category: "professional",
|
|
229
|
+
bestFor: [
|
|
230
|
+
"Admin panels",
|
|
231
|
+
"Forms",
|
|
232
|
+
"Enterprise SaaS"
|
|
233
|
+
]
|
|
234
|
+
}),
|
|
235
|
+
"segment-ring": info({
|
|
236
|
+
id: "segment-ring",
|
|
237
|
+
name: "Segment Ring",
|
|
238
|
+
description: "One dial, many files: each upload owns an arc sized by its bytes and coloured by type, filling independently. The ring morphs into a checkmark when the batch completes.",
|
|
239
|
+
category: "professional",
|
|
240
|
+
bestFor: [
|
|
241
|
+
"Batch uploads",
|
|
242
|
+
"Analytics",
|
|
243
|
+
"Cloud storage"
|
|
244
|
+
],
|
|
245
|
+
defaults: { progressVariant: "ring" }
|
|
246
|
+
}),
|
|
247
|
+
beam: info({
|
|
248
|
+
id: "beam",
|
|
249
|
+
name: "Gradient Beam",
|
|
250
|
+
description: "A light beam circles the zone’s border and speeds up when files approach. Progress bars are gradient beams with a travelling highlight. Made for dark, premium UIs.",
|
|
251
|
+
category: "expressive",
|
|
252
|
+
bestFor: [
|
|
253
|
+
"Developer tools",
|
|
254
|
+
"AI products",
|
|
255
|
+
"Dark dashboards"
|
|
256
|
+
],
|
|
257
|
+
defaults: { progressVariant: "beam" }
|
|
258
|
+
}),
|
|
259
|
+
magnetic: info({
|
|
260
|
+
id: "magnetic",
|
|
261
|
+
name: "Magnetic",
|
|
262
|
+
description: "A dot field that reacts to your cursor: the core is pulled toward the pointer, a spotlight follows it and dots swell nearby. Dropped files snap into the list with a spring.",
|
|
263
|
+
category: "expressive",
|
|
264
|
+
bestFor: [
|
|
265
|
+
"Landing pages",
|
|
266
|
+
"Creative tools",
|
|
267
|
+
"Design apps"
|
|
268
|
+
]
|
|
269
|
+
}),
|
|
270
|
+
portal: info({
|
|
271
|
+
id: "portal",
|
|
272
|
+
name: "Portal",
|
|
273
|
+
description: "Files spiral into a rotating portal from wherever you drop them. The portal’s rim fills with aggregate progress and flashes when everything lands.",
|
|
274
|
+
category: "expressive",
|
|
275
|
+
bestFor: [
|
|
276
|
+
"Consumer apps",
|
|
277
|
+
"Gaming",
|
|
278
|
+
"Creative platforms"
|
|
279
|
+
]
|
|
280
|
+
}),
|
|
281
|
+
liquid: info({
|
|
282
|
+
id: "liquid",
|
|
283
|
+
name: "Liquid Fill",
|
|
284
|
+
description: "The zone is a vessel that fills with liquid as bytes arrive – layered waves, rising bubbles, and text that inverts as the level passes it. Turns to success colour when full.",
|
|
285
|
+
category: "expressive",
|
|
286
|
+
bestFor: [
|
|
287
|
+
"Consumer apps",
|
|
288
|
+
"Single large uploads",
|
|
289
|
+
"Mobile"
|
|
290
|
+
]
|
|
291
|
+
}),
|
|
292
|
+
stack: info({
|
|
293
|
+
id: "stack",
|
|
294
|
+
name: "Dimensional Stack",
|
|
295
|
+
description: "Files form a 3D deck: the front card uploads while the rest wait in perspective. Finished cards fly to a tray; hover to fan the deck. A full list view is one click away.",
|
|
296
|
+
category: "expressive",
|
|
297
|
+
bestFor: [
|
|
298
|
+
"Document workflows",
|
|
299
|
+
"Portfolios",
|
|
300
|
+
"Review queues"
|
|
301
|
+
]
|
|
302
|
+
}),
|
|
303
|
+
orbit: info({
|
|
304
|
+
id: "orbit",
|
|
305
|
+
name: "Orbit",
|
|
306
|
+
description: "Files orbit an upload core in three lanes. Orbits tighten as bytes transfer, and each finished file falls into the core with a pulse. Failed files drift to a halt.",
|
|
307
|
+
category: "expressive",
|
|
308
|
+
bestFor: [
|
|
309
|
+
"Landing pages",
|
|
310
|
+
"Multi-file batches",
|
|
311
|
+
"Consumer apps"
|
|
312
|
+
]
|
|
313
|
+
}),
|
|
314
|
+
bloom: info({
|
|
315
|
+
id: "bloom",
|
|
316
|
+
name: "Image Bloom",
|
|
317
|
+
description: "A gallery grid where each image starts blurred and desaturated, then blooms into full colour from the centre as it uploads – finishing with a petal burst.",
|
|
318
|
+
category: "media",
|
|
319
|
+
bestFor: [
|
|
320
|
+
"Photo galleries",
|
|
321
|
+
"Social apps",
|
|
322
|
+
"E-commerce"
|
|
323
|
+
]
|
|
324
|
+
}),
|
|
325
|
+
"page-reveal": info({
|
|
326
|
+
id: "page-reveal",
|
|
327
|
+
name: "Page Reveal",
|
|
328
|
+
description: "Documents become pages: a scan line inks each line as bytes upload – showing the real first lines of text, CSV, JSON and code files – then an “Uploaded” stamp lands.",
|
|
329
|
+
category: "media",
|
|
330
|
+
bestFor: [
|
|
331
|
+
"Document management",
|
|
332
|
+
"Legal & finance",
|
|
333
|
+
"Knowledge bases"
|
|
334
|
+
]
|
|
335
|
+
}),
|
|
336
|
+
filmstrip: info({
|
|
337
|
+
id: "filmstrip",
|
|
338
|
+
name: "Filmstrip",
|
|
339
|
+
description: "Videos turn into filmstrips using frames extracted in the browser. Frames light up as the upload advances while a playhead tracks progress.",
|
|
340
|
+
category: "media",
|
|
341
|
+
bestFor: [
|
|
342
|
+
"Video platforms",
|
|
343
|
+
"Course builders",
|
|
344
|
+
"Social media tools"
|
|
345
|
+
]
|
|
346
|
+
}),
|
|
347
|
+
waveform: info({
|
|
348
|
+
id: "waveform",
|
|
349
|
+
name: "Audio Waveform",
|
|
350
|
+
description: "Audio files show their true waveform, decoded locally, filling bar by bar with an equaliser bounce at the playhead. Includes local preview playback.",
|
|
351
|
+
category: "media",
|
|
352
|
+
bestFor: [
|
|
353
|
+
"Podcasts",
|
|
354
|
+
"Music platforms",
|
|
355
|
+
"Voice notes"
|
|
356
|
+
]
|
|
357
|
+
}),
|
|
358
|
+
pixel: info({
|
|
359
|
+
id: "pixel",
|
|
360
|
+
name: "Pixel Assembly",
|
|
361
|
+
description: "Every file assembles from 64 pixels flying into place in a shuffled order driven by real progress. Images use their own pixels; other files build a tinted mosaic.",
|
|
362
|
+
category: "media",
|
|
363
|
+
bestFor: [
|
|
364
|
+
"Creative tools",
|
|
365
|
+
"Games",
|
|
366
|
+
"Retro / playful brands"
|
|
367
|
+
]
|
|
368
|
+
}),
|
|
369
|
+
adaptive: info({
|
|
370
|
+
id: "adaptive",
|
|
371
|
+
name: "Adaptive Media",
|
|
372
|
+
description: "Picks the right motion per file: images bloom, videos become filmstrips, audio draws its waveform, documents reveal their pages. Override the mapping with `fileAnimations`.",
|
|
373
|
+
category: "media",
|
|
374
|
+
bestFor: [
|
|
375
|
+
"Mixed-media inboxes",
|
|
376
|
+
"Asset managers",
|
|
377
|
+
"Chat attachments"
|
|
378
|
+
]
|
|
379
|
+
})
|
|
380
|
+
};
|
|
381
|
+
/** Built-in presets in gallery order. */
|
|
382
|
+
const presetCatalog = [
|
|
383
|
+
PRESET_INFO["card-fan"],
|
|
384
|
+
PRESET_INFO["wave-bar"],
|
|
385
|
+
PRESET_INFO["hoop-shot"],
|
|
386
|
+
PRESET_INFO.essential,
|
|
387
|
+
PRESET_INFO["segment-ring"],
|
|
388
|
+
PRESET_INFO.beam,
|
|
389
|
+
PRESET_INFO.magnetic,
|
|
390
|
+
PRESET_INFO.portal,
|
|
391
|
+
PRESET_INFO.liquid,
|
|
392
|
+
PRESET_INFO.stack,
|
|
393
|
+
PRESET_INFO.orbit,
|
|
394
|
+
PRESET_INFO.bloom,
|
|
395
|
+
PRESET_INFO["page-reveal"],
|
|
396
|
+
PRESET_INFO.filmstrip,
|
|
397
|
+
PRESET_INFO.waveform,
|
|
398
|
+
PRESET_INFO.pixel,
|
|
399
|
+
PRESET_INFO.adaptive
|
|
400
|
+
];
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/core/validation.ts
|
|
403
|
+
/** Normalises `accept` to lower-case tokens: `['image/*', '.pdf', 'application/json']`. */
|
|
404
|
+
function parseAccept(accept) {
|
|
405
|
+
if (!accept) return [];
|
|
406
|
+
return (typeof accept === "string" ? accept.split(",") : accept.flatMap((a) => a.split(","))).map((token) => token.trim().toLowerCase()).filter(Boolean);
|
|
407
|
+
}
|
|
408
|
+
/** Mirrors the browser's `accept` matching rules. An empty list accepts everything. */
|
|
409
|
+
function isFileAccepted(file, accept) {
|
|
410
|
+
if (accept.length === 0) return true;
|
|
411
|
+
const type = (file.type ?? "").toLowerCase();
|
|
412
|
+
const ext = getExtension(file.name);
|
|
413
|
+
return accept.some((token) => {
|
|
414
|
+
if (token.startsWith(".")) return ext !== "" && token.slice(1) === ext;
|
|
415
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
416
|
+
return type === token;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const MIME_NAMES = {
|
|
420
|
+
"application/pdf": "PDF",
|
|
421
|
+
"application/json": "JSON",
|
|
422
|
+
"application/zip": "ZIP",
|
|
423
|
+
"text/csv": "CSV",
|
|
424
|
+
"text/plain": "TXT",
|
|
425
|
+
"application/msword": "DOC",
|
|
426
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
|
|
427
|
+
"application/vnd.ms-excel": "XLS",
|
|
428
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
|
|
429
|
+
"application/vnd.ms-powerpoint": "PPT",
|
|
430
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX"
|
|
431
|
+
};
|
|
432
|
+
/** Human description of an accept list: `"Images, PDF"`. */
|
|
433
|
+
function describeAccept(accept) {
|
|
434
|
+
const words = accept.map((token) => {
|
|
435
|
+
if (token === "image/*") return "Images";
|
|
436
|
+
if (token === "video/*") return "Videos";
|
|
437
|
+
if (token === "audio/*") return "Audio";
|
|
438
|
+
if (token === "text/*") return "Text files";
|
|
439
|
+
if (token.startsWith(".")) return token.slice(1).toUpperCase();
|
|
440
|
+
if (MIME_NAMES[token]) return MIME_NAMES[token];
|
|
441
|
+
return (token.split("/")[1] ?? token).replace(/^(x-|vnd\.)/, "").split(/[.+-]/)[0].toUpperCase();
|
|
442
|
+
});
|
|
443
|
+
return Array.from(new Set(words)).join(", ");
|
|
444
|
+
}
|
|
445
|
+
function validateFile(file, rules, items) {
|
|
446
|
+
const errors = [];
|
|
447
|
+
if (!isFileAccepted(file, rules.accept)) errors.push({
|
|
448
|
+
code: "file-invalid-type",
|
|
449
|
+
message: `${file.name} isn't an accepted file type (${describeAccept(rules.accept)}).`
|
|
450
|
+
});
|
|
451
|
+
if (rules.maxSize !== void 0 && file.size > rules.maxSize) errors.push({
|
|
452
|
+
code: "file-too-large",
|
|
453
|
+
message: `${file.name} is ${formatBytes(file.size)}; the limit is ${formatBytes(rules.maxSize)}.`
|
|
454
|
+
});
|
|
455
|
+
if (rules.minSize !== void 0 && file.size < rules.minSize) errors.push({
|
|
456
|
+
code: "file-too-small",
|
|
457
|
+
message: `${file.name} is smaller than the ${formatBytes(rules.minSize)} minimum.`
|
|
458
|
+
});
|
|
459
|
+
if (rules.validate) {
|
|
460
|
+
const custom = rules.validate(file, { items });
|
|
461
|
+
if (custom) errors.push({
|
|
462
|
+
code: "custom",
|
|
463
|
+
message: custom
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return errors;
|
|
467
|
+
}
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/typeof.js
|
|
470
|
+
function _typeof(o) {
|
|
471
|
+
"@babel/helpers - typeof";
|
|
472
|
+
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
473
|
+
return typeof o;
|
|
474
|
+
} : function(o) {
|
|
475
|
+
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
476
|
+
}, _typeof(o);
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/toPrimitive.js
|
|
480
|
+
function toPrimitive(t, r) {
|
|
481
|
+
if ("object" != _typeof(t) || !t) return t;
|
|
482
|
+
var e = t[Symbol.toPrimitive];
|
|
483
|
+
if (void 0 !== e) {
|
|
484
|
+
var i = e.call(t, r || "default");
|
|
485
|
+
if ("object" != _typeof(i)) return i;
|
|
486
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
487
|
+
}
|
|
488
|
+
return ("string" === r ? String : Number)(t);
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/toPropertyKey.js
|
|
492
|
+
function toPropertyKey(t) {
|
|
493
|
+
var i = toPrimitive(t, "string");
|
|
494
|
+
return "symbol" == _typeof(i) ? i : i + "";
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/defineProperty.js
|
|
498
|
+
function _defineProperty(e, r, t) {
|
|
499
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
500
|
+
value: t,
|
|
501
|
+
enumerable: !0,
|
|
502
|
+
configurable: !0,
|
|
503
|
+
writable: !0
|
|
504
|
+
}) : e[r] = t, e;
|
|
505
|
+
}
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/core/errors.ts
|
|
508
|
+
const BRAND = Symbol.for("upload-motion.UploadError");
|
|
509
|
+
/** Error type produced by built-in transports and by the store when wrapping handler failures. */
|
|
510
|
+
var UploadError = class extends Error {
|
|
511
|
+
constructor(code, message, init = {}) {
|
|
512
|
+
super(message, init.cause === void 0 ? void 0 : { cause: init.cause });
|
|
513
|
+
_defineProperty(this, "code", void 0);
|
|
514
|
+
_defineProperty(this, "status", void 0);
|
|
515
|
+
_defineProperty(this, "retryable", void 0);
|
|
516
|
+
_defineProperty(this, "response", void 0);
|
|
517
|
+
_defineProperty(this, BRAND, true);
|
|
518
|
+
this.name = "UploadError";
|
|
519
|
+
this.code = code;
|
|
520
|
+
this.status = init.status;
|
|
521
|
+
this.response = init.response;
|
|
522
|
+
this.retryable = init.retryable ?? defaultRetryable(code, init.status);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
function defaultRetryable(code, status) {
|
|
526
|
+
if (code === "network" || code === "timeout") return true;
|
|
527
|
+
if (code === "http" && status !== void 0) return status >= 500 || status === 408 || status === 429;
|
|
528
|
+
return code === "handler";
|
|
529
|
+
}
|
|
530
|
+
/** Works across bundle copies (no `instanceof` pitfalls). */
|
|
531
|
+
function isUploadError(value) {
|
|
532
|
+
return typeof value === "object" && value !== null && value[BRAND] === true;
|
|
533
|
+
}
|
|
534
|
+
function httpErrorMessage(status) {
|
|
535
|
+
if (status === 401 || status === 403) return `Not allowed to upload (HTTP ${status})`;
|
|
536
|
+
if (status === 404) return "Upload endpoint not found (HTTP 404)";
|
|
537
|
+
if (status === 408) return "The server timed out (HTTP 408)";
|
|
538
|
+
if (status === 413) return "File is too large for the server (HTTP 413)";
|
|
539
|
+
if (status === 415) return "The server rejected this file type (HTTP 415)";
|
|
540
|
+
if (status === 429) return "Too many uploads – try again shortly (HTTP 429)";
|
|
541
|
+
if (status >= 500) return `Server error (HTTP ${status})`;
|
|
542
|
+
return `Upload failed (HTTP ${status})`;
|
|
543
|
+
}
|
|
544
|
+
function toUploadError(error) {
|
|
545
|
+
if (isUploadError(error)) return error;
|
|
546
|
+
if (error instanceof DOMException && error.name === "AbortError") return new UploadError("aborted", "Upload aborted", {
|
|
547
|
+
cause: error,
|
|
548
|
+
retryable: false
|
|
549
|
+
});
|
|
550
|
+
return new UploadError("handler", error instanceof Error && error.message ? error.message : "Upload failed", { cause: error });
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/core/store.ts
|
|
554
|
+
let idCounter = 0;
|
|
555
|
+
function createId(prefix) {
|
|
556
|
+
idCounter += 1;
|
|
557
|
+
return `${prefix}_${idCounter.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
|
558
|
+
}
|
|
559
|
+
const now = () => Date.now();
|
|
560
|
+
const scheduleFrame = typeof requestAnimationFrame === "function" ? (cb) => {
|
|
561
|
+
const handle = requestAnimationFrame(cb);
|
|
562
|
+
return () => cancelAnimationFrame(handle);
|
|
563
|
+
} : (cb) => {
|
|
564
|
+
const handle = setTimeout(cb, 16);
|
|
565
|
+
return () => clearTimeout(handle);
|
|
566
|
+
};
|
|
567
|
+
const IN_FLIGHT = /* @__PURE__ */ new Set([
|
|
568
|
+
"queued",
|
|
569
|
+
"uploading",
|
|
570
|
+
"paused",
|
|
571
|
+
"success"
|
|
572
|
+
]);
|
|
573
|
+
function computeTotals(items) {
|
|
574
|
+
const totals = {
|
|
575
|
+
count: items.length,
|
|
576
|
+
progress: 0,
|
|
577
|
+
loaded: 0,
|
|
578
|
+
size: 0,
|
|
579
|
+
speed: 0,
|
|
580
|
+
eta: null,
|
|
581
|
+
pending: 0,
|
|
582
|
+
queued: 0,
|
|
583
|
+
uploading: 0,
|
|
584
|
+
paused: 0,
|
|
585
|
+
success: 0,
|
|
586
|
+
error: 0,
|
|
587
|
+
canceled: 0,
|
|
588
|
+
active: 0,
|
|
589
|
+
isUploading: false,
|
|
590
|
+
isComplete: false
|
|
591
|
+
};
|
|
592
|
+
let weighted = 0;
|
|
593
|
+
let weightedLoaded = 0;
|
|
594
|
+
for (const item of items) {
|
|
595
|
+
totals[item.status] += 1;
|
|
596
|
+
if (!IN_FLIGHT.has(item.status)) continue;
|
|
597
|
+
const weight = Math.max(item.size, 1);
|
|
598
|
+
weighted += weight;
|
|
599
|
+
weightedLoaded += item.status === "success" ? weight : item.progress * weight;
|
|
600
|
+
totals.size += item.size;
|
|
601
|
+
totals.loaded += item.status === "success" ? item.size : item.loaded;
|
|
602
|
+
if (item.status === "uploading") totals.speed += item.speed;
|
|
603
|
+
}
|
|
604
|
+
totals.progress = weighted > 0 ? clamp01(weightedLoaded / weighted) : 0;
|
|
605
|
+
totals.active = totals.queued + totals.uploading;
|
|
606
|
+
totals.isUploading = totals.uploading > 0;
|
|
607
|
+
totals.eta = totals.speed > 0 ? Math.max(0, (totals.size - totals.loaded) / totals.speed) : null;
|
|
608
|
+
totals.isComplete = totals.success > 0 && totals.pending + totals.queued + totals.uploading + totals.paused === 0;
|
|
609
|
+
return totals;
|
|
610
|
+
}
|
|
611
|
+
function normalizeIds(ids, all) {
|
|
612
|
+
if (ids === void 0) return [...all];
|
|
613
|
+
return typeof ids === "string" ? [ids] : [...ids];
|
|
614
|
+
}
|
|
615
|
+
function createUploadStore(initialOptions = {}) {
|
|
616
|
+
let options = initialOptions;
|
|
617
|
+
const items = /* @__PURE__ */ new Map();
|
|
618
|
+
let order = [];
|
|
619
|
+
const runtimes = /* @__PURE__ */ new Map();
|
|
620
|
+
let rejections = [];
|
|
621
|
+
let snapshot = null;
|
|
622
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
623
|
+
const eventHandlers = /* @__PURE__ */ new Map();
|
|
624
|
+
let cancelScheduled = null;
|
|
625
|
+
let completeArmed = false;
|
|
626
|
+
function notify() {
|
|
627
|
+
if (cancelScheduled) {
|
|
628
|
+
cancelScheduled();
|
|
629
|
+
cancelScheduled = null;
|
|
630
|
+
}
|
|
631
|
+
for (const listener of [...listeners]) listener();
|
|
632
|
+
}
|
|
633
|
+
/** `sync` for state transitions, batched for progress ticks. */
|
|
634
|
+
function commit(sync) {
|
|
635
|
+
snapshot = null;
|
|
636
|
+
if (sync) notify();
|
|
637
|
+
else if (!cancelScheduled) cancelScheduled = scheduleFrame(() => {
|
|
638
|
+
cancelScheduled = null;
|
|
639
|
+
notify();
|
|
640
|
+
});
|
|
641
|
+
if (options.onChange) options.onChange(order.map((id) => items.get(id)));
|
|
642
|
+
}
|
|
643
|
+
function emit(event, payload) {
|
|
644
|
+
const handlers = eventHandlers.get(event);
|
|
645
|
+
if (handlers) for (const handler of [...handlers]) handler(payload);
|
|
646
|
+
}
|
|
647
|
+
function patch(id, changes) {
|
|
648
|
+
const current = items.get(id);
|
|
649
|
+
if (!current) return void 0;
|
|
650
|
+
const next = {
|
|
651
|
+
...current,
|
|
652
|
+
...changes
|
|
653
|
+
};
|
|
654
|
+
items.set(id, next);
|
|
655
|
+
return next;
|
|
656
|
+
}
|
|
657
|
+
function runtime(id) {
|
|
658
|
+
let rt = runtimes.get(id);
|
|
659
|
+
if (!rt) {
|
|
660
|
+
rt = {
|
|
661
|
+
controller: null,
|
|
662
|
+
reason: null,
|
|
663
|
+
lastTime: 0,
|
|
664
|
+
lastLoaded: 0,
|
|
665
|
+
autoRetries: 0,
|
|
666
|
+
retryTimer: null
|
|
667
|
+
};
|
|
668
|
+
runtimes.set(id, rt);
|
|
669
|
+
}
|
|
670
|
+
return rt;
|
|
671
|
+
}
|
|
672
|
+
function clearRetryTimer(id) {
|
|
673
|
+
const rt = runtimes.get(id);
|
|
674
|
+
if (rt?.retryTimer) {
|
|
675
|
+
clearTimeout(rt.retryTimer);
|
|
676
|
+
rt.retryTimer = null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function retryConfig() {
|
|
680
|
+
const retry = options.retry;
|
|
681
|
+
if (typeof retry === "number") return {
|
|
682
|
+
attempts: retry,
|
|
683
|
+
delay: 800
|
|
684
|
+
};
|
|
685
|
+
return {
|
|
686
|
+
attempts: retry?.attempts ?? 0,
|
|
687
|
+
delay: retry?.delay ?? 800
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function pump() {
|
|
691
|
+
if (!options.upload) return;
|
|
692
|
+
const limit = Math.max(1, Math.floor(options.concurrency ?? 3));
|
|
693
|
+
let active = 0;
|
|
694
|
+
for (const id of order) if (items.get(id)?.status === "uploading") active += 1;
|
|
695
|
+
const t = now();
|
|
696
|
+
for (const id of order) {
|
|
697
|
+
if (active >= limit) break;
|
|
698
|
+
const item = items.get(id);
|
|
699
|
+
if (item?.status === "queued" && (item.retryAt === null || item.retryAt <= t)) {
|
|
700
|
+
run(id);
|
|
701
|
+
active += 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function checkComplete() {
|
|
706
|
+
if (!completeArmed) return;
|
|
707
|
+
const list = order.map((id) => items.get(id));
|
|
708
|
+
if (list.some((item) => item.status === "queued" || item.status === "uploading")) return;
|
|
709
|
+
completeArmed = false;
|
|
710
|
+
const settled = list.filter((item) => item.startedAt !== null);
|
|
711
|
+
emit("complete", settled);
|
|
712
|
+
options.onQueueComplete?.(settled);
|
|
713
|
+
}
|
|
714
|
+
function run(id) {
|
|
715
|
+
const handler = options.upload;
|
|
716
|
+
const initial = items.get(id);
|
|
717
|
+
if (!handler || !initial) return;
|
|
718
|
+
completeArmed = true;
|
|
719
|
+
const rt = runtime(id);
|
|
720
|
+
const controller = new AbortController();
|
|
721
|
+
rt.controller = controller;
|
|
722
|
+
rt.reason = null;
|
|
723
|
+
rt.lastTime = now();
|
|
724
|
+
rt.lastLoaded = 0;
|
|
725
|
+
const started = patch(id, {
|
|
726
|
+
status: "uploading",
|
|
727
|
+
progress: 0,
|
|
728
|
+
loaded: 0,
|
|
729
|
+
speed: 0,
|
|
730
|
+
eta: null,
|
|
731
|
+
attempts: initial.attempts + 1,
|
|
732
|
+
error: null,
|
|
733
|
+
startedAt: now(),
|
|
734
|
+
completedAt: null,
|
|
735
|
+
retryAt: null
|
|
736
|
+
});
|
|
737
|
+
commit(true);
|
|
738
|
+
emit("start", started);
|
|
739
|
+
options.onUploadStart?.(started);
|
|
740
|
+
let settled = false;
|
|
741
|
+
const onProgress = (loaded, total) => {
|
|
742
|
+
if (settled || controller.signal.aborted) return;
|
|
743
|
+
const current = items.get(id);
|
|
744
|
+
if (!current || current.status !== "uploading") return;
|
|
745
|
+
const denominator = total && total > 0 ? total : Math.max(current.size, 1);
|
|
746
|
+
const fraction = Math.max(current.progress, clamp01(loaded / denominator));
|
|
747
|
+
const fileLoaded = Math.round(fraction * current.size);
|
|
748
|
+
const t = now();
|
|
749
|
+
let speed = current.speed;
|
|
750
|
+
const dt = t - rt.lastTime;
|
|
751
|
+
if (dt >= 250) {
|
|
752
|
+
const instant = (fileLoaded - rt.lastLoaded) / dt * 1e3;
|
|
753
|
+
speed = speed > 0 ? speed * .6 + instant * .4 : instant;
|
|
754
|
+
rt.lastTime = t;
|
|
755
|
+
rt.lastLoaded = fileLoaded;
|
|
756
|
+
}
|
|
757
|
+
const eta = speed > 0 ? Math.max(0, (current.size - fileLoaded) / speed) : null;
|
|
758
|
+
const next = patch(id, {
|
|
759
|
+
progress: fraction,
|
|
760
|
+
loaded: fileLoaded,
|
|
761
|
+
speed,
|
|
762
|
+
eta
|
|
763
|
+
});
|
|
764
|
+
commit(false);
|
|
765
|
+
options.onUploadProgress?.(next);
|
|
766
|
+
};
|
|
767
|
+
const finish = (outcome) => {
|
|
768
|
+
if (settled) return;
|
|
769
|
+
settled = true;
|
|
770
|
+
rt.controller = null;
|
|
771
|
+
const reason = rt.reason;
|
|
772
|
+
rt.reason = null;
|
|
773
|
+
const current = items.get(id);
|
|
774
|
+
if (!current) {
|
|
775
|
+
pump();
|
|
776
|
+
checkComplete();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (reason === "pause") patch(id, {
|
|
780
|
+
status: "paused",
|
|
781
|
+
speed: 0,
|
|
782
|
+
eta: null
|
|
783
|
+
});
|
|
784
|
+
else if (reason === "cancel" || reason === "dispose") {
|
|
785
|
+
const canceled = patch(id, {
|
|
786
|
+
status: "canceled",
|
|
787
|
+
speed: 0,
|
|
788
|
+
eta: null
|
|
789
|
+
});
|
|
790
|
+
emit("cancel", canceled);
|
|
791
|
+
options.onUploadCancel?.(canceled);
|
|
792
|
+
} else if (outcome.ok) {
|
|
793
|
+
const done = patch(id, {
|
|
794
|
+
status: "success",
|
|
795
|
+
progress: 1,
|
|
796
|
+
loaded: current.size,
|
|
797
|
+
speed: 0,
|
|
798
|
+
eta: 0,
|
|
799
|
+
response: outcome.response,
|
|
800
|
+
completedAt: now()
|
|
801
|
+
});
|
|
802
|
+
rt.autoRetries = 0;
|
|
803
|
+
emit("success", done);
|
|
804
|
+
options.onUploadSuccess?.(done, outcome.response);
|
|
805
|
+
} else {
|
|
806
|
+
const error = toUploadError(outcome.error);
|
|
807
|
+
const retry = retryConfig();
|
|
808
|
+
if (error.code === "aborted") {
|
|
809
|
+
const canceled = patch(id, {
|
|
810
|
+
status: "canceled",
|
|
811
|
+
speed: 0,
|
|
812
|
+
eta: null,
|
|
813
|
+
error: null
|
|
814
|
+
});
|
|
815
|
+
emit("cancel", canceled);
|
|
816
|
+
options.onUploadCancel?.(canceled);
|
|
817
|
+
} else if (error.retryable && rt.autoRetries < retry.attempts) {
|
|
818
|
+
rt.autoRetries += 1;
|
|
819
|
+
const delay = retry.delay * 2 ** (rt.autoRetries - 1);
|
|
820
|
+
patch(id, {
|
|
821
|
+
status: "queued",
|
|
822
|
+
error,
|
|
823
|
+
speed: 0,
|
|
824
|
+
eta: null,
|
|
825
|
+
retryAt: now() + delay
|
|
826
|
+
});
|
|
827
|
+
rt.retryTimer = setTimeout(() => {
|
|
828
|
+
rt.retryTimer = null;
|
|
829
|
+
if (items.get(id)?.status === "queued") {
|
|
830
|
+
patch(id, { retryAt: null });
|
|
831
|
+
pump();
|
|
832
|
+
commit(true);
|
|
833
|
+
}
|
|
834
|
+
}, delay);
|
|
835
|
+
} else {
|
|
836
|
+
const failed = patch(id, {
|
|
837
|
+
status: "error",
|
|
838
|
+
error,
|
|
839
|
+
speed: 0,
|
|
840
|
+
eta: null
|
|
841
|
+
});
|
|
842
|
+
emit("error", failed);
|
|
843
|
+
options.onUploadError?.(failed, error);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
commit(true);
|
|
847
|
+
pump();
|
|
848
|
+
checkComplete();
|
|
849
|
+
};
|
|
850
|
+
const context = {
|
|
851
|
+
file: started.file,
|
|
852
|
+
item: started,
|
|
853
|
+
signal: controller.signal,
|
|
854
|
+
attempt: started.attempts,
|
|
855
|
+
onProgress
|
|
856
|
+
};
|
|
857
|
+
Promise.resolve().then(() => handler(context)).then((response) => finish({
|
|
858
|
+
ok: true,
|
|
859
|
+
response
|
|
860
|
+
}), (error) => finish({
|
|
861
|
+
ok: false,
|
|
862
|
+
error
|
|
863
|
+
}));
|
|
864
|
+
}
|
|
865
|
+
function abortItem(id, reason) {
|
|
866
|
+
const rt = runtimes.get(id);
|
|
867
|
+
if (rt?.controller) {
|
|
868
|
+
rt.reason = reason;
|
|
869
|
+
rt.controller.abort(reason);
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
const store = {
|
|
875
|
+
getSnapshot() {
|
|
876
|
+
if (!snapshot) {
|
|
877
|
+
const list = order.map((id) => items.get(id));
|
|
878
|
+
const handler = options.upload;
|
|
879
|
+
snapshot = {
|
|
880
|
+
items: list,
|
|
881
|
+
rejections,
|
|
882
|
+
totals: computeTotals(list),
|
|
883
|
+
transport: handler ? handler.simulated ? "simulated" : "network" : "none"
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
return snapshot;
|
|
887
|
+
},
|
|
888
|
+
subscribe(listener) {
|
|
889
|
+
listeners.add(listener);
|
|
890
|
+
return () => listeners.delete(listener);
|
|
891
|
+
},
|
|
892
|
+
on(event, handler) {
|
|
893
|
+
let set = eventHandlers.get(event);
|
|
894
|
+
if (!set) {
|
|
895
|
+
set = /* @__PURE__ */ new Set();
|
|
896
|
+
eventHandlers.set(event, set);
|
|
897
|
+
}
|
|
898
|
+
set.add(handler);
|
|
899
|
+
return () => set.delete(handler);
|
|
900
|
+
},
|
|
901
|
+
setOptions(next) {
|
|
902
|
+
const transportChanged = Boolean(next.upload) !== Boolean(options.upload) || next.upload?.simulated !== options.upload?.simulated;
|
|
903
|
+
options = next;
|
|
904
|
+
if (transportChanged) commit(true);
|
|
905
|
+
pump();
|
|
906
|
+
},
|
|
907
|
+
getOptions() {
|
|
908
|
+
return options;
|
|
909
|
+
},
|
|
910
|
+
getInitialOptions() {
|
|
911
|
+
return initialOptions;
|
|
912
|
+
},
|
|
913
|
+
addFiles(input) {
|
|
914
|
+
const files = Array.from(input);
|
|
915
|
+
const accept = parseAccept(options.accept);
|
|
916
|
+
const single = options.multiple === false;
|
|
917
|
+
const maxFiles = single ? 1 : options.maxFiles ?? Infinity;
|
|
918
|
+
const currentList = order.map((id) => items.get(id));
|
|
919
|
+
let slots = single ? 1 : maxFiles - currentList.length;
|
|
920
|
+
const acceptedFiles = [];
|
|
921
|
+
const rejected = [];
|
|
922
|
+
files.forEach((file, index) => {
|
|
923
|
+
const errors = validateFile(file, {
|
|
924
|
+
accept,
|
|
925
|
+
maxSize: options.maxSize,
|
|
926
|
+
minSize: options.minSize,
|
|
927
|
+
validate: options.validate
|
|
928
|
+
}, currentList);
|
|
929
|
+
if (errors.length === 0 && (slots <= 0 || single && index > 0)) errors.push({
|
|
930
|
+
code: "too-many-files",
|
|
931
|
+
message: single ? `Only one file can be uploaded – ${file.name} was skipped.` : `You can add up to ${maxFiles} ${maxFiles === 1 ? "file" : "files"} – ${file.name} was skipped.`
|
|
932
|
+
});
|
|
933
|
+
if (errors.length > 0) rejected.push({
|
|
934
|
+
id: createId("rej"),
|
|
935
|
+
file,
|
|
936
|
+
errors,
|
|
937
|
+
at: now()
|
|
938
|
+
});
|
|
939
|
+
else {
|
|
940
|
+
acceptedFiles.push(file);
|
|
941
|
+
slots -= 1;
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
if (single && acceptedFiles.length > 0 && order.length > 0) store.remove([...order]);
|
|
945
|
+
const autoStart = Boolean(options.upload) && options.autoUpload !== false;
|
|
946
|
+
const accepted = acceptedFiles.map((file) => {
|
|
947
|
+
const item = {
|
|
948
|
+
id: createId("file"),
|
|
949
|
+
file,
|
|
950
|
+
name: file.name,
|
|
951
|
+
size: file.size,
|
|
952
|
+
type: file.type,
|
|
953
|
+
extension: getExtension(file.name),
|
|
954
|
+
kind: getFileKind(file),
|
|
955
|
+
status: autoStart ? "queued" : "pending",
|
|
956
|
+
progress: 0,
|
|
957
|
+
loaded: 0,
|
|
958
|
+
speed: 0,
|
|
959
|
+
eta: null,
|
|
960
|
+
attempts: 0,
|
|
961
|
+
error: null,
|
|
962
|
+
response: void 0,
|
|
963
|
+
addedAt: now(),
|
|
964
|
+
startedAt: null,
|
|
965
|
+
completedAt: null,
|
|
966
|
+
retryAt: null
|
|
967
|
+
};
|
|
968
|
+
items.set(item.id, item);
|
|
969
|
+
order.push(item.id);
|
|
970
|
+
return item;
|
|
971
|
+
});
|
|
972
|
+
if (rejected.length > 0) rejections = [...rejections, ...rejected].slice(-20);
|
|
973
|
+
if (accepted.length > 0 || rejected.length > 0) commit(true);
|
|
974
|
+
if (accepted.length > 0) {
|
|
975
|
+
emit("added", accepted);
|
|
976
|
+
options.onFilesAdded?.(accepted);
|
|
977
|
+
}
|
|
978
|
+
if (rejected.length > 0) {
|
|
979
|
+
emit("rejected", rejected);
|
|
980
|
+
options.onFilesRejected?.(rejected);
|
|
981
|
+
}
|
|
982
|
+
pump();
|
|
983
|
+
return {
|
|
984
|
+
accepted,
|
|
985
|
+
rejected
|
|
986
|
+
};
|
|
987
|
+
},
|
|
988
|
+
start(ids) {
|
|
989
|
+
let changed = false;
|
|
990
|
+
for (const id of normalizeIds(ids, order)) if (items.get(id)?.status === "pending" && options.upload) {
|
|
991
|
+
patch(id, { status: "queued" });
|
|
992
|
+
changed = true;
|
|
993
|
+
}
|
|
994
|
+
if (changed) commit(true);
|
|
995
|
+
pump();
|
|
996
|
+
},
|
|
997
|
+
cancel(ids) {
|
|
998
|
+
let changed = false;
|
|
999
|
+
for (const id of normalizeIds(ids, order)) {
|
|
1000
|
+
const item = items.get(id);
|
|
1001
|
+
if (!item) continue;
|
|
1002
|
+
clearRetryTimer(id);
|
|
1003
|
+
if (item.status === "uploading") abortItem(id, "cancel");
|
|
1004
|
+
else if (item.status === "queued" || item.status === "paused" || item.status === "pending") {
|
|
1005
|
+
const canceled = patch(id, {
|
|
1006
|
+
status: "canceled",
|
|
1007
|
+
speed: 0,
|
|
1008
|
+
eta: null,
|
|
1009
|
+
retryAt: null
|
|
1010
|
+
});
|
|
1011
|
+
emit("cancel", canceled);
|
|
1012
|
+
options.onUploadCancel?.(canceled);
|
|
1013
|
+
changed = true;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (changed) {
|
|
1017
|
+
commit(true);
|
|
1018
|
+
checkComplete();
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
retry(ids) {
|
|
1022
|
+
let changed = false;
|
|
1023
|
+
for (const id of normalizeIds(ids, order)) {
|
|
1024
|
+
const item = items.get(id);
|
|
1025
|
+
if (!item || item.status !== "error" && item.status !== "canceled") continue;
|
|
1026
|
+
clearRetryTimer(id);
|
|
1027
|
+
runtime(id).autoRetries = 0;
|
|
1028
|
+
patch(id, {
|
|
1029
|
+
status: options.upload ? "queued" : "pending",
|
|
1030
|
+
error: null,
|
|
1031
|
+
progress: 0,
|
|
1032
|
+
loaded: 0,
|
|
1033
|
+
speed: 0,
|
|
1034
|
+
eta: null,
|
|
1035
|
+
retryAt: null
|
|
1036
|
+
});
|
|
1037
|
+
changed = true;
|
|
1038
|
+
}
|
|
1039
|
+
if (changed) commit(true);
|
|
1040
|
+
pump();
|
|
1041
|
+
},
|
|
1042
|
+
pause(ids) {
|
|
1043
|
+
let changed = false;
|
|
1044
|
+
for (const id of normalizeIds(ids, order)) {
|
|
1045
|
+
const item = items.get(id);
|
|
1046
|
+
if (!item) continue;
|
|
1047
|
+
if (item.status === "uploading") abortItem(id, "pause");
|
|
1048
|
+
else if (item.status === "queued") {
|
|
1049
|
+
clearRetryTimer(id);
|
|
1050
|
+
patch(id, {
|
|
1051
|
+
status: "paused",
|
|
1052
|
+
retryAt: null
|
|
1053
|
+
});
|
|
1054
|
+
changed = true;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (changed) commit(true);
|
|
1058
|
+
},
|
|
1059
|
+
resume(ids) {
|
|
1060
|
+
let changed = false;
|
|
1061
|
+
for (const id of normalizeIds(ids, order)) if (items.get(id)?.status === "paused") {
|
|
1062
|
+
patch(id, { status: options.upload ? "queued" : "pending" });
|
|
1063
|
+
changed = true;
|
|
1064
|
+
}
|
|
1065
|
+
if (changed) commit(true);
|
|
1066
|
+
pump();
|
|
1067
|
+
},
|
|
1068
|
+
remove(ids) {
|
|
1069
|
+
const list = typeof ids === "string" ? [ids] : ids;
|
|
1070
|
+
const removed = [];
|
|
1071
|
+
for (const id of list) {
|
|
1072
|
+
const item = items.get(id);
|
|
1073
|
+
if (!item) continue;
|
|
1074
|
+
clearRetryTimer(id);
|
|
1075
|
+
abortItem(id, "remove");
|
|
1076
|
+
items.delete(id);
|
|
1077
|
+
runtimes.delete(id);
|
|
1078
|
+
removed.push(item);
|
|
1079
|
+
}
|
|
1080
|
+
if (removed.length === 0) return;
|
|
1081
|
+
const removedIds = new Set(removed.map((item) => item.id));
|
|
1082
|
+
order = order.filter((id) => !removedIds.has(id));
|
|
1083
|
+
commit(true);
|
|
1084
|
+
for (const item of removed) {
|
|
1085
|
+
emit("removed", item);
|
|
1086
|
+
options.onFileRemoved?.(item);
|
|
1087
|
+
}
|
|
1088
|
+
pump();
|
|
1089
|
+
checkComplete();
|
|
1090
|
+
},
|
|
1091
|
+
clear(filter = "all") {
|
|
1092
|
+
const match = {
|
|
1093
|
+
all: () => true,
|
|
1094
|
+
completed: (item) => item.status === "success",
|
|
1095
|
+
failed: (item) => item.status === "error",
|
|
1096
|
+
canceled: (item) => item.status === "canceled"
|
|
1097
|
+
};
|
|
1098
|
+
const ids = order.filter((id) => match[filter](items.get(id)));
|
|
1099
|
+
if (filter === "all") rejections = [];
|
|
1100
|
+
if (ids.length > 0) store.remove(ids);
|
|
1101
|
+
else if (filter === "all") commit(true);
|
|
1102
|
+
},
|
|
1103
|
+
dismissRejection(id) {
|
|
1104
|
+
const next = id === void 0 ? [] : rejections.filter((rejection) => rejection.id !== id);
|
|
1105
|
+
if (next.length !== rejections.length) {
|
|
1106
|
+
rejections = next;
|
|
1107
|
+
commit(true);
|
|
1108
|
+
}
|
|
1109
|
+
},
|
|
1110
|
+
getItem(id) {
|
|
1111
|
+
return items.get(id);
|
|
1112
|
+
},
|
|
1113
|
+
dispose() {
|
|
1114
|
+
for (const id of order) {
|
|
1115
|
+
clearRetryTimer(id);
|
|
1116
|
+
abortItem(id, "dispose");
|
|
1117
|
+
}
|
|
1118
|
+
if (cancelScheduled) {
|
|
1119
|
+
cancelScheduled();
|
|
1120
|
+
cancelScheduled = null;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
return store;
|
|
1125
|
+
}
|
|
1126
|
+
//#endregion
|
|
1127
|
+
//#region src/core/transports.ts
|
|
1128
|
+
function resolve(value, item) {
|
|
1129
|
+
return typeof value === "function" ? value(item) : value;
|
|
1130
|
+
}
|
|
1131
|
+
function defaultParse(xhr) {
|
|
1132
|
+
const text = xhr.responseText;
|
|
1133
|
+
if ((xhr.getResponseHeader("content-type") ?? "").includes("json") || /^\s*[[{]/.test(text)) try {
|
|
1134
|
+
return JSON.parse(text);
|
|
1135
|
+
} catch {
|
|
1136
|
+
return text;
|
|
1137
|
+
}
|
|
1138
|
+
return text;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Real network transport built on `XMLHttpRequest`, the only browser API that
|
|
1142
|
+
* reports upload progress for request bodies on HTTP/1.1.
|
|
1143
|
+
*/
|
|
1144
|
+
function createXhrUploader(options) {
|
|
1145
|
+
const { method = "POST", body = "form-data", fieldName = "file", withCredentials = false, timeout = 0, parseResponse = defaultParse, isSuccess = (status) => status >= 200 && status < 300 } = options;
|
|
1146
|
+
return async function xhrUpload({ file, item, signal, onProgress }) {
|
|
1147
|
+
const [url, headers, fields] = await Promise.all([
|
|
1148
|
+
resolve(options.url, item),
|
|
1149
|
+
options.headers ? resolve(options.headers, item) : void 0,
|
|
1150
|
+
options.formData ? resolve(options.formData, item) : void 0
|
|
1151
|
+
]);
|
|
1152
|
+
if (signal.aborted) throw new UploadError("aborted", "Upload aborted", { retryable: false });
|
|
1153
|
+
return new Promise((resolvePromise, reject) => {
|
|
1154
|
+
const xhr = new XMLHttpRequest();
|
|
1155
|
+
xhr.open(method, url, true);
|
|
1156
|
+
xhr.withCredentials = withCredentials;
|
|
1157
|
+
xhr.timeout = timeout;
|
|
1158
|
+
for (const [key, value] of Object.entries(headers ?? {})) xhr.setRequestHeader(key, value);
|
|
1159
|
+
const onAbort = () => xhr.abort();
|
|
1160
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1161
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
1162
|
+
xhr.upload.onprogress = (event) => {
|
|
1163
|
+
if (event.lengthComputable) onProgress(event.loaded, event.total);
|
|
1164
|
+
};
|
|
1165
|
+
xhr.onload = () => {
|
|
1166
|
+
cleanup();
|
|
1167
|
+
if (isSuccess(xhr.status)) {
|
|
1168
|
+
try {
|
|
1169
|
+
resolvePromise(parseResponse(xhr));
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
reject(new UploadError("invalid-response", "Could not read the server response", {
|
|
1172
|
+
cause: error,
|
|
1173
|
+
retryable: false
|
|
1174
|
+
}));
|
|
1175
|
+
}
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
let response;
|
|
1179
|
+
try {
|
|
1180
|
+
response = defaultParse(xhr);
|
|
1181
|
+
} catch {
|
|
1182
|
+
response = void 0;
|
|
1183
|
+
}
|
|
1184
|
+
reject(new UploadError("http", (typeof response === "object" && response !== null && typeof response.error === "string" ? response.error : void 0) ?? httpErrorMessage(xhr.status), {
|
|
1185
|
+
status: xhr.status,
|
|
1186
|
+
response
|
|
1187
|
+
}));
|
|
1188
|
+
};
|
|
1189
|
+
xhr.onerror = () => {
|
|
1190
|
+
cleanup();
|
|
1191
|
+
reject(new UploadError("network", "Network error – check your connection and try again"));
|
|
1192
|
+
};
|
|
1193
|
+
xhr.ontimeout = () => {
|
|
1194
|
+
cleanup();
|
|
1195
|
+
reject(new UploadError("timeout", "The upload timed out"));
|
|
1196
|
+
};
|
|
1197
|
+
xhr.onabort = () => {
|
|
1198
|
+
cleanup();
|
|
1199
|
+
reject(new UploadError("aborted", "Upload aborted", { retryable: false }));
|
|
1200
|
+
};
|
|
1201
|
+
if (body === "raw") xhr.send(file);
|
|
1202
|
+
else {
|
|
1203
|
+
const form = new FormData();
|
|
1204
|
+
for (const [key, value] of Object.entries(fields ?? {})) form.append(key, value);
|
|
1205
|
+
form.append(fieldName, file, file.name);
|
|
1206
|
+
xhr.send(form);
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Demo transport: no bytes leave the browser. The returned handler is flagged
|
|
1213
|
+
* `simulated: true` so built-in components display a "Simulated" badge.
|
|
1214
|
+
*/
|
|
1215
|
+
function createSimulatedUploader(options = {}) {
|
|
1216
|
+
const { speed = [15e5, 6e6], minDuration = 1400, failureRate = 0, latency = 250 } = options;
|
|
1217
|
+
const handler = ({ file, signal, onProgress }) => new Promise((resolvePromise, reject) => {
|
|
1218
|
+
const bytesPerSecond = Array.isArray(speed) ? speed[0] + Math.random() * (speed[1] - speed[0]) : speed;
|
|
1219
|
+
const total = Math.max(file.size, 1);
|
|
1220
|
+
const duration = Math.max(minDuration, total / bytesPerSecond * 1e3);
|
|
1221
|
+
const failAt = Math.random() < failureRate ? .25 + Math.random() * .5 : Infinity;
|
|
1222
|
+
let elapsed = 0;
|
|
1223
|
+
let timer;
|
|
1224
|
+
const tickMs = 80;
|
|
1225
|
+
const abort = () => {
|
|
1226
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1227
|
+
reject(new UploadError("aborted", "Upload aborted", { retryable: false }));
|
|
1228
|
+
};
|
|
1229
|
+
if (signal.aborted) return abort();
|
|
1230
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1231
|
+
const tick = () => {
|
|
1232
|
+
elapsed += tickMs * (.55 + Math.random() * .9);
|
|
1233
|
+
const fraction = Math.min(elapsed / duration, 1);
|
|
1234
|
+
if (fraction >= failAt) {
|
|
1235
|
+
signal.removeEventListener("abort", abort);
|
|
1236
|
+
reject(new UploadError("network", "Simulated network failure"));
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
onProgress(Math.round(fraction * total), total);
|
|
1240
|
+
if (fraction >= 1) {
|
|
1241
|
+
signal.removeEventListener("abort", abort);
|
|
1242
|
+
resolvePromise({
|
|
1243
|
+
simulated: true,
|
|
1244
|
+
name: file.name,
|
|
1245
|
+
size: file.size
|
|
1246
|
+
});
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
timer = setTimeout(tick, tickMs);
|
|
1250
|
+
};
|
|
1251
|
+
timer = setTimeout(tick, latency);
|
|
1252
|
+
});
|
|
1253
|
+
return Object.assign(handler, { simulated: true });
|
|
1254
|
+
}
|
|
1255
|
+
//#endregion
|
|
1256
|
+
export { formatSpeed as C, formatPercent as S, getFileLabel as _, UploadError as a, formatBytes as b, toUploadError as c, parseAccept as d, validateFile as f, getFileKind as g, getExtension as h, createUploadStore as i, describeAccept as l, presetCatalog as m, createXhrUploader as n, httpErrorMessage as o, PRESET_INFO as p, computeTotals as r, isUploadError as s, createSimulatedUploader as t, isFileAccepted as u, isPreviewableImage as v, formatDuration as x, clamp01 as y };
|