@vmz/ui 0.3.6 → 0.3.8

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.
Files changed (68) hide show
  1. package/README.md +53 -51
  2. package/contracts/token-requirements.v0.json +58 -1
  3. package/package.json +5 -2
  4. package/presets/web-surface.v0.json +14 -12
  5. package/src/components/Accordion.vmz +127 -94
  6. package/src/components/Alert.vmz +125 -28
  7. package/src/components/AppShell.vmz +125 -57
  8. package/src/components/Autocomplete.vmz +261 -154
  9. package/src/components/Avatar.vmz +20 -12
  10. package/src/components/Badge.vmz +62 -70
  11. package/src/components/Breadcrumb.vmz +86 -72
  12. package/src/components/BulkActions.vmz +56 -51
  13. package/src/components/Button.vmz +237 -211
  14. package/src/components/Callout.vmz +178 -77
  15. package/src/components/Card.vmz +141 -120
  16. package/src/components/Checkbox.vmz +131 -55
  17. package/src/components/CodeBlock.vmz +77 -30
  18. package/src/components/Collapse.vmz +64 -11
  19. package/src/components/ConsoleShell.vmz +405 -251
  20. package/src/components/Content.vmz +15 -1
  21. package/src/components/DataTable.vmz +294 -194
  22. package/src/components/DatePicker.vmz +766 -674
  23. package/src/components/Descriptions.vmz +90 -0
  24. package/src/components/Dialog.vmz +137 -38
  25. package/src/components/Divider.vmz +8 -1
  26. package/src/components/Drawer.vmz +518 -427
  27. package/src/components/Dropdown.vmz +195 -51
  28. package/src/components/Empty.vmz +96 -54
  29. package/src/components/Field.vmz +170 -115
  30. package/src/components/FilterBar.vmz +34 -4
  31. package/src/components/Flex.vmz +3 -1
  32. package/src/components/Footer.vmz +6 -3
  33. package/src/components/Form.vmz +41 -0
  34. package/src/components/FormItem.vmz +78 -79
  35. package/src/components/Grid.vmz +6 -3
  36. package/src/components/Header.vmz +193 -6
  37. package/src/components/Layout.vmz +3 -1
  38. package/src/components/Link.vmz +25 -10
  39. package/src/components/List.vmz +124 -102
  40. package/src/components/Menu.vmz +416 -243
  41. package/src/components/Message.vmz +167 -0
  42. package/src/components/Notification.vmz +132 -37
  43. package/src/components/Pagination.vmz +237 -69
  44. package/src/components/Popconfirm.vmz +287 -0
  45. package/src/components/Popover.vmz +341 -223
  46. package/src/components/Progress.vmz +16 -5
  47. package/src/components/Prose.vmz +180 -81
  48. package/src/components/QueryForm.vmz +31 -9
  49. package/src/components/RadioGroup.vmz +204 -124
  50. package/src/components/Result.vmz +60 -27
  51. package/src/components/Segmented.vmz +86 -51
  52. package/src/components/Select.vmz +620 -533
  53. package/src/components/Sider.vmz +120 -7
  54. package/src/components/Skeleton.vmz +9 -7
  55. package/src/components/Space.vmz +9 -3
  56. package/src/components/Spinner.vmz +5 -4
  57. package/src/components/Steps.vmz +118 -103
  58. package/src/components/Switch.vmz +131 -98
  59. package/src/components/Table.vmz +270 -165
  60. package/src/components/Tabs.vmz +125 -110
  61. package/src/components/Tag.vmz +60 -43
  62. package/src/components/TextArea.vmz +125 -118
  63. package/src/components/Timeline.vmz +111 -76
  64. package/src/components/Toc.vmz +90 -74
  65. package/src/components/Tooltip.vmz +107 -58
  66. package/src/components/Tree.vmz +214 -191
  67. package/src/components/Typography.vmz +23 -8
  68. package/src/components/Upload.vmz +1394 -1289
@@ -1,1289 +1,1394 @@
1
- <template>
2
- <div data-vmz-ui="upload" :data-vmz-upload-destination="destination" :data-vmz-upload-mode="selectionMode" :data-vmz-upload-directory="directoryAttr" :data-vmz-upload-chunk="chunkAttr" :data-vmz-upload-session="sessionAttr" :data-invalid="invalidAttr" :data-vmz-upload-status="status" :data-progress="progress" :onDragEnter="onDragEnter" :onDragOver="onDragOver" :onDragLeave="onDragLeave" :onDrop="onDrop" :class='"vmz-ui-upload" + (dragOver ? " is-dragover" : "")'>
3
- <label v-if="label" :for="controlId" class="vmz-ui-upload__label">
4
- {{ label }}
5
- </label>
6
- <div data-vmz-upload="zone" class="vmz-ui-upload__zone">
7
- <input :id="controlId" type="file" :accept="accept" :multiple="multiple" :disabled="inputDisabled" :aria-invalid="error ? 'true' : 'false'" :aria-describedby="describedBy" :data-vmz-upload-directory="directoryAttr" @change="onPick" class="vmz-ui-upload__control" />
8
- <p data-vmz-upload="hint" class="vmz-ui-upload__hint">
9
- {{ zoneHint }}
10
- </p>
11
- </div>
12
- <ul v-if="hasItems" data-vmz-upload="queue" class="vmz-ui-upload__queue">
13
- <li v-for="item in queue" :key="item.key" :data-vmz-upload-item="item.key" :data-status="item.status" class="vmz-ui-upload__item">
14
- <span data-vmz-upload="file" class="vmz-ui-upload__item-name">
15
- {{ item.name }}
16
- </span>
17
- <span class="vmz-ui-upload__item-meta">
18
- {{ item.meta }}
19
- </span>
20
- <span v-if="item.showBar" aria-hidden="true" class="vmz-ui-upload__item-bar" :style="item.barStyle" />
21
- </li>
22
- </ul>
23
- <p v-if="emptyHint" data-vmz-upload="empty" class="vmz-ui-upload__empty">
24
- {{ emptyText }}
25
- </p>
26
- <div v-if="showProgress" data-vmz-upload="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progress" :aria-label="progressLabel" class="vmz-ui-upload__progress">
27
- <div class="vmz-ui-upload__bar" :style="progressStyle" />
28
- <span data-vmz-upload="pct" class="vmz-ui-upload__pct">
29
- {{ progressLabel }}
30
- </span>
31
- </div>
32
- <p v-if="statusText" data-vmz-upload="status" class="vmz-ui-upload__status">
33
- {{ statusText }}
34
- </p>
35
- <p v-if="resultText" data-vmz-upload="result" class="vmz-ui-upload__result">
36
- {{ resultText }}
37
- </p>
38
- <div v-if="showActions" data-vmz-upload="actions" class="vmz-ui-upload__actions">
39
- <button v-if="canCancel" type="button" data-vmz-upload="cancel" @click="onCancelClick" class="vmz-ui-upload__cancel">
40
- {{ cancelLabel }}
41
- </button>
42
- <button v-if="canResume" type="button" data-vmz-upload="resume" @click="onResumeClick" class="vmz-ui-upload__resume">
43
- {{ resumeLabel }}
44
- </button>
45
- </div>
46
- <p v-if="description" :id="descriptionId" class="vmz-ui-upload__description">
47
- {{ description }}
48
- </p>
49
- <p v-if="error" :id="errorId" role="alert" class="vmz-ui-upload__error">
50
- {{ error }}
51
- </p>
52
- </div>
53
- </template>
54
- <style>
55
- .vmz-ui-upload {
56
- display: flex;
57
- flex-direction: column;
58
- gap: 0.375rem;
59
- max-width: 28rem;
60
- }
61
-
62
- .vmz-ui-upload__label {
63
- font: inherit;
64
- font-weight: 600;
65
- }
66
-
67
- .vmz-ui-upload__zone {
68
- position: relative;
69
- border: 1px dashed var(--vmz-line-default);
70
- border-radius: var(--vmz-radius-md, 6px);
71
- background: var(--vmz-surface-paper);
72
- padding: var(--vmz-density-control-padding-y) var(--vmz-density-control-padding-x);
73
- }
74
-
75
- .vmz-ui-upload.is-dragover .vmz-ui-upload__zone {
76
- border-color: var(--vmz-action-primary-background);
77
- background: color-mix(
78
- in srgb,
79
- var(--vmz-action-primary-background) 8%,
80
- var(--vmz-surface-paper)
81
- );
82
- }
83
-
84
- .vmz-ui-upload__control {
85
- font: inherit;
86
- width: 100%;
87
- padding: 0.125rem 0;
88
- color: inherit;
89
- outline: none;
90
- }
91
-
92
- .vmz-ui-upload__control:focus-visible {
93
- box-shadow: 0 0 0 3px var(--vmz-focus-ring);
94
- }
95
-
96
- .vmz-ui-upload__control:disabled {
97
- opacity: 0.55;
98
- cursor: not-allowed;
99
- }
100
-
101
- .vmz-ui-upload__hint {
102
- margin: 0.375rem 0 0;
103
- font-size: 0.88rem;
104
- color: var(--vmz-text-steel);
105
- }
106
-
107
- .vmz-ui-upload__queue {
108
- list-style: none;
109
- margin: 0;
110
- padding: 0;
111
- display: flex;
112
- flex-direction: column;
113
- gap: 0.375rem;
114
- }
115
-
116
- .vmz-ui-upload__item {
117
- display: grid;
118
- grid-template-columns: 1fr auto;
119
- gap: 0.125rem 0.75rem;
120
- padding: 0.375rem 0.5rem;
121
- border: 1px solid var(--vmz-line-default);
122
- border-radius: var(--vmz-radius-md, 6px);
123
- background: var(--vmz-surface-paper);
124
- }
125
-
126
- .vmz-ui-upload__item-name {
127
- font-family: var(--vmz-font-mono);
128
- font-size: 0.88rem;
129
- color: var(--vmz-text-ink);
130
- overflow: hidden;
131
- text-overflow: ellipsis;
132
- white-space: nowrap;
133
- }
134
-
135
- .vmz-ui-upload__item-meta {
136
- font-size: 0.8rem;
137
- color: var(--vmz-text-steel);
138
- white-space: nowrap;
139
- }
140
-
141
- .vmz-ui-upload__item-bar {
142
- grid-column: 1 / -1;
143
- height: 0.35rem;
144
- border-radius: var(--vmz-radius-md, 6px);
145
- background: var(--vmz-action-primary-background);
146
- min-width: 0.25rem;
147
- transition: width var(--vmz-motion-control-duration) var(--vmz-motion-control-easing);
148
- }
149
-
150
- .vmz-ui-upload[data-invalid="true"] .vmz-ui-upload__label {
151
- color: var(--vmz-status-danger-foreground);
152
- }
153
-
154
- .vmz-ui-upload__empty {
155
- margin: 0;
156
- color: var(--vmz-text-steel);
157
- font-size: 0.9em;
158
- }
159
-
160
- .vmz-ui-upload__progress {
161
- display: grid;
162
- grid-template-columns: 1fr auto;
163
- align-items: center;
164
- gap: 0.5rem;
165
- padding: 0.375rem 0;
166
- }
167
-
168
- .vmz-ui-upload__bar {
169
- height: 0.45rem;
170
- border-radius: var(--vmz-radius-md, 6px);
171
- background: var(--vmz-action-primary-background);
172
- min-width: 0.25rem;
173
- transition: width var(--vmz-motion-control-duration) var(--vmz-motion-control-easing);
174
- }
175
-
176
- .vmz-ui-upload__pct {
177
- font-family: var(--vmz-font-mono);
178
- font-size: 0.8rem;
179
- color: var(--vmz-text-steel);
180
- white-space: nowrap;
181
- }
182
-
183
- .vmz-ui-upload__status {
184
- margin: 0;
185
- font-size: 0.9em;
186
- color: var(--vmz-text-steel);
187
- }
188
-
189
- .vmz-ui-upload[data-vmz-upload-status="done"] .vmz-ui-upload__status,
190
- .vmz-ui-upload[data-vmz-upload-status="done"] .vmz-ui-upload__result {
191
- color: var(--vmz-status-success-foreground);
192
- }
193
-
194
- .vmz-ui-upload[data-vmz-upload-status="error"] .vmz-ui-upload__status {
195
- color: var(--vmz-status-danger-foreground);
196
- }
197
-
198
- .vmz-ui-upload__result {
199
- margin: 0;
200
- font-family: var(--vmz-font-mono);
201
- font-size: 0.88rem;
202
- color: var(--vmz-text-ink);
203
- }
204
-
205
- .vmz-ui-upload__actions {
206
- display: flex;
207
- gap: 0.5rem;
208
- }
209
-
210
- .vmz-ui-upload__cancel,
211
- .vmz-ui-upload__resume {
212
- font: inherit;
213
- padding: var(--vmz-density-compact-padding-y) var(--vmz-density-compact-padding-x);
214
- border: 1px solid var(--vmz-line-default);
215
- border-radius: var(--vmz-radius-md, 6px);
216
- background: var(--vmz-surface-paper);
217
- color: var(--vmz-text-ink);
218
- cursor: pointer;
219
- outline: none;
220
- }
221
-
222
- .vmz-ui-upload__cancel:focus-visible,
223
- .vmz-ui-upload__resume:focus-visible {
224
- box-shadow: 0 0 0 3px var(--vmz-focus-ring);
225
- }
226
-
227
- .vmz-ui-upload__resume {
228
- border-color: var(--vmz-action-primary-background);
229
- color: var(--vmz-action-primary-background);
230
- }
231
-
232
- .vmz-ui-upload__description {
233
- margin: 0;
234
- opacity: 0.75;
235
- font-size: 0.9em;
236
- }
237
-
238
- .vmz-ui-upload__error {
239
- margin: 0;
240
- color: var(--vmz-status-danger-foreground);
241
- font-size: 0.9em;
242
- }
243
-
244
- @media (prefers-reduced-motion: reduce) {
245
- .vmz-ui-upload__bar,
246
- .vmz-ui-upload__item-bar {
247
- transition: none;
248
- }
249
- }
250
- </style>
251
- <script client>
252
- /**
253
- * VMZ UI — Upload (all-in-one).
254
- * Owns selection UI, constraints, queue, progress/cancel, and destination transport
255
- * (client | server | object). Parent owns result value + destination config only.
256
- * No useUpload / createUpload; no cloud SDK inside @vmz/ui.
257
- * destination=server + chunkSize>0 → init / chunk PUT / complete + same-session pause/resume.
258
- *
259
- * Only destination config + result callbacks are `public` props.
260
- * Presentation/queue/xhr state stay private so parent re-renders cannot wipe them.
261
- */
262
- export default class Upload {
263
- public label: string = "";
264
- public description: string = "";
265
- /** Form-level validation message from parent. */
266
- public error: string = "";
267
- public emptyText: string = "No file selected";
268
- public accept: string = "";
269
- public disabled: boolean = false;
270
- public invalidAttr: string = "false";
271
- public controlId: string = "vmz-upload";
272
- public descriptionId: string = "vmz-upload-desc";
273
- public errorId: string = "vmz-upload-err";
274
- public describedBy: string = "";
275
- /** client | server | object */
276
- public destination: string = "server";
277
- /** ServerRoute / URL for destination=server (multipart) or resumable prefix (chunkSize>0) */
278
- public action: string = "";
279
- /** multipart field name */
280
- public fieldName: string = "file";
281
- public multiple: boolean = false;
282
- public directory: boolean = false;
283
- public maxCount: number = 1;
284
- public maxBytes: number = 0;
285
- /**
286
- * When >0 and destination=server: chunked resumable transport against action prefix
287
- * (`/init`, `/chunk`, `/status`, `/complete`). Thin gate is single-file same-session.
288
- */
289
- public chunkSize: number = 0;
290
- /**
291
- * object destination: (file) => Promise<{ uploadUrl, headers?, objectKey? }>
292
- * Must bind #server-issued short-lived credentials only.
293
- */
294
- public onPresign:
295
- | ((file: File) => Promise<{
296
- uploadUrl: string;
297
- headers?: Record<string, string>;
298
- objectKey?: string;
299
- }>)
300
- | null = null;
301
- /** Parent receives serializable results (no File handles required). */
302
- public onValue: ((items: UploadValueItem[]) => void) | null = null;
303
- /** Parent mirrors overall status for form fixture / submit gates. */
304
- public onStatus: ((status: string) => void) | null = null;
305
- public cancelLabel: string = "Cancel upload";
306
- public resumeLabel: string = "Resume upload";
307
- // presentation (component-owned — NOT public props)
308
- status: string = "idle";
309
- progress: string = "0";
310
- progressLabel: string = "";
311
- progressStyle: string = "width:0%";
312
- showProgress: boolean = false;
313
- statusText: string = "";
314
- resultText: string = "";
315
- canCancel: boolean = false;
316
- canResume: boolean = false;
317
- showActions: boolean = false;
318
- emptyHint: boolean = true;
319
- hasItems: boolean = false;
320
- inputDisabled: boolean = false;
321
- dragOver: boolean = false;
322
- selectionMode: string = "single";
323
- directoryAttr: string = "false";
324
- chunkAttr: string = "0";
325
- sessionAttr: string = "";
326
- zoneHint: string = "Choose a file or drop it here";
327
- /** @type {{ key: string, name: string, meta: string, status: string, showBar: boolean, barStyle: string }[]} */
328
- queue: {
329
- key: string;
330
- name: string;
331
- meta: string;
332
- status: string;
333
- showBar: boolean;
334
- barStyle: string;
335
- }[] = [];
336
- _seenClearToken = 0;
337
- _clearToken = 0;
338
- /** Parent increments to clear internal queue (form reset). Binder assignment must clear immediately. */
339
- get clearToken() {
340
- return this._clearToken;
341
- }
342
- set clearToken(v) {
343
- const n = Number(v) || 0;
344
- this._clearToken = n;
345
- if (n !== this._seenClearToken) {
346
- this._seenClearToken = n;
347
- this.clearInternal(true);
348
- }
349
- }
350
- /** @type {XMLHttpRequest | null} */
351
- _xhr = null;
352
- /** @type {File[]} */
353
- _files = [];
354
- /** @type {UploadValueItem[]} */
355
- _values = [];
356
- _gen = 0;
357
- /** Resumable session (same File handle / same page). */
358
- _uploadId = "";
359
- _chunkTotal = 0;
360
- /** @type {number[]} */
361
- _received = [];
362
- _activeChunkSize = 0;
363
- onMount() {
364
- this.applyDirectoryAttr();
365
- this.syncChunkAttr();
366
- }
367
- onPick(ev) {
368
- this.syncClearToken();
369
- this.applyDirectoryAttr();
370
- const input = ev && ev.target;
371
- const list = input && input.files ? input.files : null;
372
- // Ignore empty change (including the synthetic event from clearing input.value
373
- // after a successful pick) — otherwise in-flight uploads get abort/cleared.
374
- if (!list || !list.length) return;
375
- this.ingestFileList(list);
376
- // Allow re-selecting the same path without retaining the FileList on the input.
377
- if (input) input.value = "";
378
- }
379
- onDragEnter(ev) {
380
- if (this.inputDisabled || this.disabled) return;
381
- if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
382
- this.dragOver = true;
383
- }
384
- onDragOver(ev) {
385
- if (this.inputDisabled || this.disabled) return;
386
- if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
387
- this.dragOver = true;
388
- }
389
- onDragLeave() {
390
- this.dragOver = false;
391
- }
392
- onDrop(ev) {
393
- if (this.inputDisabled || this.disabled) return;
394
- if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
395
- this.dragOver = false;
396
- const list = ev && ev.dataTransfer ? ev.dataTransfer.files : null;
397
- this.ingestFileList(list);
398
- }
399
- onCancelClick() {
400
- this.cancelActive(true);
401
- }
402
- onResumeClick() {
403
- if (this.status !== "paused" || !this._uploadId || !this._files.length) return;
404
- this._gen += 1;
405
- const gen = this._gen;
406
- this.runDestination(gen);
407
- }
408
- syncClearToken() {
409
- const token = Number(this.clearToken) || 0;
410
- if (token !== this._seenClearToken) {
411
- this._seenClearToken = token;
412
- this.clearInternal(false);
413
- }
414
- }
415
- syncChunkAttr() {
416
- const n = Math.max(0, Number(this.chunkSize) || 0);
417
- this.chunkAttr = String(n);
418
- this.sessionAttr = this._uploadId
419
- ? `${this._received.length}/${this._chunkTotal || 0}:${this._uploadId}`
420
- : "";
421
- }
422
- applyDirectoryAttr() {
423
- this.directoryAttr = this.directory ? "true" : "false";
424
- this.selectionMode = this.directory ? "directory" : this.multiple ? "multiple" : "single";
425
- this.zoneHint = this.directory
426
- ? "Choose a folder or drop files here"
427
- : this.multiple
428
- ? "Choose files or drop them here"
429
- : "Choose a file or drop it here";
430
- // Native directory picker (UA-specific).
431
- const el = typeof document !== "undefined" ? document.getElementById(this.controlId) : null;
432
- if (el && "webkitdirectory" in el) {
433
- try {
434
- el.webkitdirectory = !!this.directory;
435
- el.multiple = !!(this.multiple || this.directory);
436
- } catch {}
437
- }
438
- }
439
- /**
440
- * @param {FileList | File[] | null} list
441
- */
442
- ingestFileList(list) {
443
- this.syncClearToken();
444
- this.applyDirectoryAttr();
445
- this.syncChunkAttr();
446
- if (this.disabled) return;
447
- const files = listToFiles(list);
448
- if (!files.length) {
449
- this.clearInternal(false);
450
- this.emitValue([]);
451
- return;
452
- }
453
- const maxCount = Math.max(1, Number(this.maxCount) || 1);
454
- const capped =
455
- this.multiple || this.directory ? files.slice(0, maxCount) : files.slice(0, 1);
456
- if ((this.multiple || this.directory) && files.length > maxCount) {
457
- this.failAll(`At most ${maxCount} file(s)`);
458
- return;
459
- }
460
- const maxBytes = Number(this.maxBytes) || 0;
461
- for (const f of capped) {
462
- const acceptErr = acceptError(f, this.accept);
463
- if (acceptErr) {
464
- this.failAll(acceptErr);
465
- return;
466
- }
467
- if (maxBytes > 0 && Number(f.size) > maxBytes) {
468
- this.failAll(`File too large (max ${maxBytes} bytes)`);
469
- return;
470
- }
471
- }
472
- const chunkSize = Math.max(0, Number(this.chunkSize) || 0);
473
- if (chunkSize > 0 && capped.length !== 1) {
474
- this.failAll("Resumable chunk upload supports one file per session");
475
- return;
476
- }
477
- this.cancelActive(false);
478
- this.resetSession();
479
- this._files = capped;
480
- this._gen += 1;
481
- const gen = this._gen;
482
- this._values = [];
483
- this.queue = capped.map((f, i) => ({
484
- key: `f-${gen}-${i}`,
485
- name: String(f.name || `file-${i}`),
486
- meta: formatBytes(f.size),
487
- status: "pending",
488
- showBar: false,
489
- barStyle: "width:0%",
490
- }));
491
- this.hasItems = this.queue.length > 0;
492
- this.emptyHint = false;
493
- this.inputDisabled = true;
494
- this.canResume = false;
495
- this.showActions = false;
496
- this.runDestination(gen);
497
- }
498
- /**
499
- * @param {number} gen
500
- */
501
- async runDestination(gen) {
502
- const dest = String(this.destination || "server").toLowerCase();
503
- if (dest === "client") {
504
- await this.runClient(gen);
505
- return;
506
- }
507
- if (dest === "object") {
508
- await this.runObject(gen);
509
- return;
510
- }
511
- const chunkSize = Math.max(0, Number(this.chunkSize) || 0);
512
- if (chunkSize > 0) {
513
- await this.runServerResumable(gen, chunkSize);
514
- return;
515
- }
516
- await this.runServer(gen);
517
- }
518
- /**
519
- * @param {number} gen
520
- */
521
- async runClient(gen) {
522
- if (gen !== this._gen) return;
523
- this.setOverall("uploading", "Preparing…", true, false);
524
- const values = [];
525
- for (let i = 0; i < this._files.length; i++) {
526
- if (gen !== this._gen) return;
527
- const file = this._files[i];
528
- const pct = Math.round(((i + 1) / this._files.length) * 100);
529
- this.patchItem(i, {
530
- status: "done",
531
- showBar: true,
532
- barStyle: `width:${pct}%`,
533
- meta: `${formatBytes(file.size)} · local`,
534
- });
535
- this.setProgress(pct);
536
- values.push({
537
- name: String(file.name || ""),
538
- size: Number(file.size) || 0,
539
- type: String(file.type || ""),
540
- destination: "client",
541
- id: `client-${gen}-${i}`,
542
- bytes: Number(file.size) || 0,
543
- /** Local File handle for in-tab tools (checksum, etc.). Not serialized. */
544
- file,
545
- });
546
- }
547
- if (gen !== this._gen) return;
548
- this._values = values;
549
- this.finishOk(values);
550
- }
551
- /**
552
- * @param {number} gen
553
- */
554
- async runServer(gen) {
555
- if (gen !== this._gen) return;
556
- const action = String(this.action || "").trim();
557
- if (!action) {
558
- this.failAll("Upload action URL required");
559
- return;
560
- }
561
- // Sequential single-XHR for thin gate; multi-file appends all parts then one request.
562
- this.setOverall("uploading", "Uploading…", true, true);
563
- this.setProgress(0);
564
- for (let i = 0; i < this.queue.length; i++) {
565
- this.patchItem(i, {
566
- status: "uploading",
567
- showBar: true,
568
- barStyle: "width:0%",
569
- });
570
- }
571
- const form = new FormData();
572
- const field = String(this.fieldName || "file");
573
- if (this._files.length === 1) {
574
- const file = this._files[0];
575
- form.append(field, file, file.name);
576
- form.append("name", file.name);
577
- } else {
578
- for (const file of this._files) {
579
- form.append(field, file, file.name);
580
- }
581
- }
582
- const xhr = new XMLHttpRequest();
583
- this._xhr = xhr;
584
- const result = await xhrSend(xhr, "POST", action, form, (pct) => {
585
- if (gen !== this._gen) return;
586
- this.setProgress(pct);
587
- for (let i = 0; i < this.queue.length; i++) {
588
- this.patchItem(i, { barStyle: `width:${pct}%` });
589
- }
590
- });
591
- if (gen !== this._gen) return;
592
- this._xhr = null;
593
- if (result.aborted) {
594
- this.markCancelled();
595
- return;
596
- }
597
- if (result.error) {
598
- this.failAll(result.error);
599
- return;
600
- }
601
- const body = result.body;
602
- if (!(result.status >= 200 && result.status < 300) || !body || !body.ok) {
603
- this.failAll((body && body.error) || `Upload failed (${result.status || "network"})`);
604
- return;
605
- }
606
- // Single-file shape { ok, id, bytes, name } or multi { ok, items: [...] }
607
- const values = [];
608
- if (Array.isArray(body.items)) {
609
- for (let i = 0; i < body.items.length; i++) {
610
- const it = body.items[i] || {};
611
- values.push({
612
- name: String(it.name || (this._files[i] && this._files[i].name) || ""),
613
- size: Number(
614
- it.bytes || it.size || (this._files[i] && this._files[i].size) || 0,
615
- ),
616
- type: String(it.contentType || ""),
617
- destination: "server",
618
- id: String(it.id || ""),
619
- bytes: Number(it.bytes || it.size || 0),
620
- headHex: it.headHex ? String(it.headHex) : "",
621
- });
622
- }
623
- } else {
624
- values.push({
625
- name: String(body.name || (this._files[0] && this._files[0].name) || ""),
626
- size: Number(
627
- body.bytes || body.reportedSize || (this._files[0] && this._files[0].size) || 0,
628
- ),
629
- type: String(body.contentType || ""),
630
- destination: "server",
631
- id: String(body.id || ""),
632
- bytes: Number(body.bytes || 0),
633
- headHex: body.headHex ? String(body.headHex) : "",
634
- });
635
- }
636
- for (let i = 0; i < this.queue.length; i++) {
637
- this.patchItem(i, {
638
- status: "done",
639
- showBar: true,
640
- barStyle: "width:100%",
641
- meta: `${formatBytes(values[i] && values[i].bytes)} · uploaded`,
642
- });
643
- }
644
- this._values = values;
645
- this.finishOk(values);
646
- }
647
- /**
648
- * Chunked server upload: POST init → PUT chunk(s) → POST complete.
649
- * Cancel → paused (keeps uploadId + received); Resume continues missing indexes.
650
- * @param {number} gen
651
- * @param {number} chunkSize
652
- */
653
- async runServerResumable(gen, chunkSize) {
654
- if (gen !== this._gen) return;
655
- const base = String(this.action || "")
656
- .trim()
657
- .replace(/\/+$/, "");
658
- if (!base) {
659
- this.failAll("Upload action URL required");
660
- return;
661
- }
662
- const file = this._files[0];
663
- if (!file) {
664
- this.failAll("No file for resumable upload");
665
- return;
666
- }
667
- this._activeChunkSize = chunkSize;
668
- this.setOverall("uploading", this._uploadId ? "Resuming…" : "Uploading…", true, true);
669
- this.canResume = false;
670
- this.showActions = true;
671
- this.patchItem(0, {
672
- status: "uploading",
673
- showBar: true,
674
- barStyle: "width:0%",
675
- });
676
- if (!this._uploadId) {
677
- const initXhr = new XMLHttpRequest();
678
- this._xhr = initXhr;
679
- const initResult = await xhrSendJson(initXhr, "POST", `${base}/init`, {
680
- name: String(file.name || ""),
681
- size: Number(file.size) || 0,
682
- type: String(file.type || ""),
683
- chunkSize,
684
- });
685
- if (gen !== this._gen) return;
686
- this._xhr = null;
687
- if (initResult.aborted) {
688
- this.markPaused();
689
- return;
690
- }
691
- if (
692
- initResult.error ||
693
- !(initResult.status >= 200 && initResult.status < 300) ||
694
- !initResult.body ||
695
- !initResult.body.ok
696
- ) {
697
- this.failAll(
698
- (initResult.body && initResult.body.error) ||
699
- initResult.error ||
700
- `Init failed (${initResult.status || "network"})`,
701
- );
702
- return;
703
- }
704
- this._uploadId = String(initResult.body.uploadId || "");
705
- this._chunkTotal =
706
- Number(initResult.body.chunkTotal) ||
707
- Math.ceil((Number(file.size) || 0) / chunkSize) ||
708
- 1;
709
- this._received = [];
710
- this.syncChunkAttr();
711
- if (!this._uploadId) {
712
- this.failAll("Init returned no uploadId");
713
- return;
714
- }
715
- } else {
716
- const statusXhr = new XMLHttpRequest();
717
- this._xhr = statusXhr;
718
- const statusResult = await xhrSendJson(statusXhr, "POST", `${base}/status`, {
719
- uploadId: this._uploadId,
720
- });
721
- if (gen !== this._gen) return;
722
- this._xhr = null;
723
- if (statusResult.aborted) {
724
- this.markPaused();
725
- return;
726
- }
727
- if (
728
- statusResult.error ||
729
- !(statusResult.status >= 200 && statusResult.status < 300) ||
730
- !statusResult.body ||
731
- !statusResult.body.ok
732
- ) {
733
- this.failAll(
734
- (statusResult.body && statusResult.body.error) ||
735
- statusResult.error ||
736
- `Status failed (${statusResult.status || "network"})`,
737
- );
738
- return;
739
- }
740
- this._received = Array.isArray(statusResult.body.received)
741
- ? statusResult.body.received.map((n) => Number(n)).filter((n) => Number.isFinite(n))
742
- : [];
743
- this._chunkTotal = Number(statusResult.body.chunkTotal) || this._chunkTotal;
744
- this.syncChunkAttr();
745
- }
746
- const total = Math.max(
747
- 1,
748
- this._chunkTotal || Math.ceil((Number(file.size) || 0) / chunkSize) || 1,
749
- );
750
- this._chunkTotal = total;
751
- const receivedSet = new Set(this._received);
752
- this.setProgress(Math.round((receivedSet.size / total) * 100));
753
- this.patchItem(0, {
754
- barStyle: `width:${Math.round((receivedSet.size / total) * 100)}%`,
755
- meta: `${receivedSet.size}/${total} chunks`,
756
- });
757
- for (let index = 0; index < total; index++) {
758
- if (gen !== this._gen) return;
759
- if (receivedSet.has(index)) continue;
760
- const start = index * chunkSize;
761
- const end = Math.min(Number(file.size) || 0, start + chunkSize);
762
- const blob = file.slice(start, end);
763
- const xhr = new XMLHttpRequest();
764
- this._xhr = xhr;
765
- const result = await xhrSend(
766
- xhr,
767
- "PUT",
768
- `${base}/chunk`,
769
- blob,
770
- (pct) => {
771
- if (gen !== this._gen) return;
772
- const overall = Math.round(((receivedSet.size + pct / 100) / total) * 100);
773
- this.setProgress(overall);
774
- this.patchItem(0, { barStyle: `width:${overall}%` });
775
- },
776
- {
777
- "content-type": "application/octet-stream",
778
- "x-vmz-upload-id": this._uploadId,
779
- "x-vmz-chunk-index": String(index),
780
- "x-vmz-chunk-total": String(total),
781
- },
782
- );
783
- if (gen !== this._gen) return;
784
- this._xhr = null;
785
- if (result.aborted) {
786
- this.markPaused();
787
- return;
788
- }
789
- if (
790
- result.error ||
791
- !(result.status >= 200 && result.status < 300) ||
792
- !result.body ||
793
- !result.body.ok
794
- ) {
795
- this.failAll(
796
- (result.body && result.body.error) ||
797
- result.error ||
798
- `Chunk ${index} failed (${result.status || "network"})`,
799
- );
800
- return;
801
- }
802
- this._received = Array.isArray(result.body.received)
803
- ? result.body.received.map((n) => Number(n)).filter((n) => Number.isFinite(n))
804
- : this._received.concat([index]);
805
- receivedSet.add(index);
806
- for (const n of this._received) receivedSet.add(n);
807
- this.syncChunkAttr();
808
- const pct = Math.round((receivedSet.size / total) * 100);
809
- this.setProgress(pct);
810
- this.patchItem(0, {
811
- barStyle: `width:${pct}%`,
812
- meta: `${receivedSet.size}/${total} chunks`,
813
- });
814
- }
815
- if (gen !== this._gen) return;
816
- const completeXhr = new XMLHttpRequest();
817
- this._xhr = completeXhr;
818
- const completeResult = await xhrSendJson(completeXhr, "POST", `${base}/complete`, {
819
- uploadId: this._uploadId,
820
- });
821
- if (gen !== this._gen) return;
822
- this._xhr = null;
823
- if (completeResult.aborted) {
824
- this.markPaused();
825
- return;
826
- }
827
- if (
828
- completeResult.error ||
829
- !(completeResult.status >= 200 && completeResult.status < 300) ||
830
- !completeResult.body ||
831
- !completeResult.body.ok
832
- ) {
833
- this.failAll(
834
- (completeResult.body && completeResult.body.error) ||
835
- completeResult.error ||
836
- `Complete failed (${completeResult.status || "network"})`,
837
- );
838
- return;
839
- }
840
- const body = completeResult.body;
841
- const values = [
842
- {
843
- name: String(body.name || file.name || ""),
844
- size: Number(body.bytes || body.size || file.size || 0),
845
- type: String(body.contentType || file.type || ""),
846
- destination: "server",
847
- id: String(body.id || ""),
848
- bytes: Number(body.bytes || body.size || 0),
849
- headHex: body.headHex ? String(body.headHex) : "",
850
- uploadId: this._uploadId,
851
- chunks: total,
852
- },
853
- ];
854
- this.patchItem(0, {
855
- status: "done",
856
- showBar: true,
857
- barStyle: "width:100%",
858
- meta: `${formatBytes(values[0].bytes)} · ${total} chunks`,
859
- });
860
- this.resetSession();
861
- this._values = values;
862
- this.finishOk(values);
863
- }
864
- /**
865
- * @param {number} gen
866
- */
867
- async runObject(gen) {
868
- if (gen !== this._gen) return;
869
- if (typeof this.onPresign !== "function") {
870
- this.failAll("onPresign callback required for object destination");
871
- return;
872
- }
873
- this.setOverall("uploading", "Uploading…", true, true);
874
- const values = [];
875
- for (let i = 0; i < this._files.length; i++) {
876
- if (gen !== this._gen) return;
877
- const file = this._files[i];
878
- this.patchItem(i, {
879
- status: "uploading",
880
- showBar: true,
881
- barStyle: "width:0%",
882
- });
883
- let signed;
884
- try {
885
- signed = await this.onPresign(file);
886
- } catch (err) {
887
- this.failAll(err instanceof Error ? err.message : String(err || "presign failed"));
888
- return;
889
- }
890
- if (gen !== this._gen) return;
891
- const url = signed && signed.uploadUrl ? String(signed.uploadUrl) : "";
892
- if (!url) {
893
- this.failAll("presign returned no uploadUrl");
894
- return;
895
- }
896
- const xhr = new XMLHttpRequest();
897
- this._xhr = xhr;
898
- const headers = (signed && signed.headers) || {};
899
- const result = await xhrSend(
900
- xhr,
901
- "PUT",
902
- url,
903
- file,
904
- (pct) => {
905
- if (gen !== this._gen) return;
906
- const overall = Math.round(((i + pct / 100) / this._files.length) * 100);
907
- this.setProgress(overall);
908
- this.patchItem(i, { barStyle: `width:${pct}%` });
909
- },
910
- headers,
911
- );
912
- if (gen !== this._gen) return;
913
- this._xhr = null;
914
- if (result.aborted) {
915
- this.markCancelled();
916
- return;
917
- }
918
- if (result.error || !(result.status >= 200 && result.status < 300)) {
919
- this.failAll(
920
- result.error || `Object upload failed (${result.status || "network"})`,
921
- );
922
- return;
923
- }
924
- const objectKey = String((signed && signed.objectKey) || file.name || "");
925
- values.push({
926
- name: String(file.name || ""),
927
- size: Number(file.size) || 0,
928
- type: String(file.type || ""),
929
- destination: "object",
930
- id: objectKey,
931
- bytes: Number(file.size) || 0,
932
- objectKey,
933
- });
934
- this.patchItem(i, {
935
- status: "done",
936
- showBar: true,
937
- barStyle: "width:100%",
938
- meta: `${formatBytes(file.size)} · object`,
939
- });
940
- }
941
- this._values = values;
942
- this.finishOk(values);
943
- }
944
- /**
945
- * @param {UploadValueItem[]} values
946
- */
947
- finishOk(values) {
948
- this.setProgress(100);
949
- this.status = "done";
950
- this.statusText = "Upload complete";
951
- this.showProgress = true;
952
- this.canCancel = false;
953
- this.canResume = false;
954
- this.showActions = false;
955
- this.inputDisabled = false;
956
- const first = values[0];
957
- if (values.length === 1 && first) {
958
- const prefix =
959
- first.destination === "client"
960
- ? "client"
961
- : first.destination === "object"
962
- ? "object"
963
- : "attachment";
964
- const head = first.headHex ? ` head:${first.headHex}` : "";
965
- const chunkNote = first.chunks ? ` chunks:${first.chunks}` : "";
966
- this.resultText = `${prefix}:${first.id} (${first.bytes || 0} bytes)${head}${chunkNote}`;
967
- } else {
968
- this.resultText = `${values.length} file(s) ready`;
969
- }
970
- this.emitStatus();
971
- this.emitValue(values);
972
- }
973
- /**
974
- * @param {string} message
975
- */
976
- failAll(message) {
977
- this.cancelActive(false);
978
- this.resetSession();
979
- this.status = "error";
980
- this.statusText = message || "Upload failed";
981
- this.showProgress = false;
982
- this.canCancel = false;
983
- this.canResume = false;
984
- this.showActions = false;
985
- this.inputDisabled = false;
986
- this.resultText = "";
987
- this._values = [];
988
- for (let i = 0; i < this.queue.length; i++) {
989
- this.patchItem(i, {
990
- status: "error",
991
- showBar: false,
992
- });
993
- }
994
- this.emitStatus();
995
- this.emitValue([]);
996
- }
997
- markCancelled() {
998
- this.status = "cancelled";
999
- this.statusText = "Upload cancelled";
1000
- this.showProgress = false;
1001
- this.canCancel = false;
1002
- this.canResume = false;
1003
- this.showActions = false;
1004
- this.inputDisabled = false;
1005
- this.resultText = "";
1006
- this._values = [];
1007
- this.resetSession();
1008
- this.emitStatus();
1009
- this.emitValue([]);
1010
- }
1011
- markPaused() {
1012
- this.status = "paused";
1013
- this.statusText = "Upload paused — resume to continue";
1014
- this.showProgress = true;
1015
- this.canCancel = false;
1016
- this.canResume = !!this._uploadId;
1017
- this.showActions = this.canResume;
1018
- this.inputDisabled = false;
1019
- this.resultText = "";
1020
- this._values = [];
1021
- this.syncChunkAttr();
1022
- this.patchItem(0, {
1023
- status: "paused",
1024
- meta: `${this._received.length}/${this._chunkTotal || 0} chunks`,
1025
- });
1026
- this.emitStatus();
1027
- this.emitValue([]);
1028
- }
1029
- resetSession() {
1030
- this._uploadId = "";
1031
- this._chunkTotal = 0;
1032
- this._received = [];
1033
- this._activeChunkSize = 0;
1034
- this.syncChunkAttr();
1035
- }
1036
- /**
1037
- * @param {boolean} mark
1038
- */
1039
- cancelActive(mark) {
1040
- const xhr = this._xhr;
1041
- this._xhr = null;
1042
- this._gen += 1;
1043
- if (xhr) {
1044
- try {
1045
- xhr.abort();
1046
- } catch {}
1047
- }
1048
- if (mark && this.status === "uploading") {
1049
- if (this._uploadId && this._activeChunkSize > 0) {
1050
- this.markPaused();
1051
- } else {
1052
- this.markCancelled();
1053
- }
1054
- }
1055
- }
1056
- /**
1057
- * @param {boolean} emit
1058
- */
1059
- clearInternal(emit) {
1060
- this.cancelActive(false);
1061
- this.resetSession();
1062
- this._files = [];
1063
- this._values = [];
1064
- this.queue = [];
1065
- this.hasItems = false;
1066
- this.emptyHint = true;
1067
- this.status = "idle";
1068
- this.statusText = "";
1069
- this.resultText = "";
1070
- this.showProgress = false;
1071
- this.canCancel = false;
1072
- this.canResume = false;
1073
- this.showActions = false;
1074
- this.inputDisabled = !!this.disabled;
1075
- this.setProgress(0);
1076
- this.dragOver = false;
1077
- this.applyDirectoryAttr();
1078
- this.emitStatus();
1079
- if (emit) this.emitValue([]);
1080
- }
1081
- /**
1082
- * @param {string} status
1083
- * @param {string} text
1084
- * @param {boolean} showProgress
1085
- * @param {boolean} canCancel
1086
- */
1087
- setOverall(status, text, showProgress, canCancel) {
1088
- this.status = status;
1089
- this.statusText = text;
1090
- this.showProgress = showProgress;
1091
- this.canCancel = canCancel;
1092
- this.canResume = false;
1093
- this.showActions = canCancel;
1094
- this.inputDisabled = status === "uploading" || !!this.disabled;
1095
- this.emitStatus();
1096
- }
1097
- /**
1098
- * @param {number} pct
1099
- */
1100
- setProgress(pct) {
1101
- const n = Math.max(0, Math.min(100, Number(pct) || 0));
1102
- this.progress = String(n);
1103
- this.progressLabel = `${n}%`;
1104
- this.progressStyle = `width:${n}%`;
1105
- }
1106
- /**
1107
- * @param {number} index
1108
- * @param {Partial<{ status: string, meta: string, showBar: boolean, barStyle: string }>} patch
1109
- */
1110
- patchItem(index, patch) {
1111
- const cur = this.queue[index];
1112
- if (!cur) return;
1113
- const next = Object.assign({}, cur, patch);
1114
- const copy = this.queue.slice();
1115
- copy[index] = next;
1116
- this.queue = copy;
1117
- }
1118
- /**
1119
- * @param {UploadValueItem[]} items
1120
- */
1121
- emitValue(items) {
1122
- if (typeof this.onValue === "function") this.onValue(items);
1123
- }
1124
- emitStatus() {
1125
- if (typeof this.onStatus === "function") this.onStatus(this.status);
1126
- }
1127
- }
1128
- /**
1129
- * @typedef {{
1130
- * name: string,
1131
- * size: number,
1132
- * type: string,
1133
- * destination: string,
1134
- * id: string,
1135
- * bytes: number,
1136
- * objectKey?: string,
1137
- * headHex?: string,
1138
- * uploadId?: string,
1139
- * chunks?: number
1140
- * }} UploadValueItem
1141
- */
1142
- /**
1143
- * @param {FileList | File[] | null | undefined} list
1144
- * @returns {File[]}
1145
- */
1146
- function listToFiles(list) {
1147
- if (!list) return [];
1148
- if (Array.isArray(list)) return list.filter(Boolean);
1149
- const out = [];
1150
- for (let i = 0; i < list.length; i++) out.push(list[i]);
1151
- return out;
1152
- }
1153
- /**
1154
- * @param {File} file
1155
- * @param {string} accept
1156
- */
1157
- function acceptError(file, accept) {
1158
- const raw = String(accept || "").trim();
1159
- if (!raw) return "";
1160
- const name = String(file.name || "").toLowerCase();
1161
- const type = String(file.type || "").toLowerCase();
1162
- const parts = raw
1163
- .split(",")
1164
- .map((s) => s.trim().toLowerCase())
1165
- .filter(Boolean);
1166
- for (const p of parts) {
1167
- if (p.startsWith(".")) {
1168
- if (name.endsWith(p)) return "";
1169
- } else if (p.endsWith("/*")) {
1170
- const prefix = p.slice(0, -1);
1171
- if (type.startsWith(prefix)) return "";
1172
- } else if (p === type) {
1173
- return "";
1174
- }
1175
- }
1176
- return `File type not allowed (${raw})`;
1177
- }
1178
- /**
1179
- * @param {number} n
1180
- */
1181
- function formatBytes(n) {
1182
- const v = Number(n) || 0;
1183
- if (v < 1024) return `${v} B`;
1184
- if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
1185
- return `${(v / (1024 * 1024)).toFixed(1)} MB`;
1186
- }
1187
- /**
1188
- * @param {XMLHttpRequest} xhr
1189
- * @param {string} method
1190
- * @param {string} url
1191
- * @param {FormData | Blob | File} body
1192
- * @param {(pct: number) => void} onProgress
1193
- * @param {Record<string, string>} [headers]
1194
- */
1195
- function xhrSend(xhr, method, url, body, onProgress, headers) {
1196
- return new Promise((resolve) => {
1197
- xhr.open(method, url);
1198
- if (headers) {
1199
- for (const k of Object.keys(headers)) {
1200
- try {
1201
- xhr.setRequestHeader(k, headers[k]);
1202
- } catch {}
1203
- }
1204
- }
1205
- xhr.upload.onprogress = (ev) => {
1206
- if (!ev || !ev.lengthComputable || !ev.total) return;
1207
- onProgress(Math.round((100 * ev.loaded) / ev.total));
1208
- };
1209
- xhr.onabort = () =>
1210
- resolve({
1211
- aborted: true,
1212
- status: 0,
1213
- body: null,
1214
- error: "",
1215
- });
1216
- xhr.onerror = () =>
1217
- resolve({
1218
- aborted: false,
1219
- status: 0,
1220
- body: null,
1221
- error: "Network upload failed",
1222
- });
1223
- xhr.onload = () => {
1224
- let parsed = null;
1225
- const text = String(xhr.responseText || "");
1226
- if (text) {
1227
- try {
1228
- parsed = JSON.parse(text);
1229
- } catch {
1230
- parsed = null;
1231
- }
1232
- }
1233
- resolve({
1234
- aborted: false,
1235
- status: xhr.status,
1236
- body: parsed,
1237
- error: "",
1238
- });
1239
- };
1240
- xhr.send(body);
1241
- });
1242
- }
1243
- /**
1244
- * @param {XMLHttpRequest} xhr
1245
- * @param {string} method
1246
- * @param {string} url
1247
- * @param {Record<string, unknown>} payload
1248
- */
1249
- function xhrSendJson(xhr, method, url, payload) {
1250
- return new Promise((resolve) => {
1251
- xhr.open(method, url);
1252
- try {
1253
- xhr.setRequestHeader("content-type", "application/json");
1254
- } catch {}
1255
- xhr.onabort = () =>
1256
- resolve({
1257
- aborted: true,
1258
- status: 0,
1259
- body: null,
1260
- error: "",
1261
- });
1262
- xhr.onerror = () =>
1263
- resolve({
1264
- aborted: false,
1265
- status: 0,
1266
- body: null,
1267
- error: "Network upload failed",
1268
- });
1269
- xhr.onload = () => {
1270
- let parsed = null;
1271
- const text = String(xhr.responseText || "");
1272
- if (text) {
1273
- try {
1274
- parsed = JSON.parse(text);
1275
- } catch {
1276
- parsed = null;
1277
- }
1278
- }
1279
- resolve({
1280
- aborted: false,
1281
- status: xhr.status,
1282
- body: parsed,
1283
- error: "",
1284
- });
1285
- };
1286
- xhr.send(JSON.stringify(payload || {}));
1287
- });
1288
- }
1289
- </script>
1
+ <template>
2
+ <div data-vmz-ui="upload" :data-vmz-upload-destination="destination" :data-vmz-upload-mode="selectionMode" :data-vmz-upload-directory="directoryAttr" :data-vmz-upload-chunk="chunkAttr" :data-vmz-upload-session="sessionAttr" :data-invalid="invalidAttr" :data-vmz-upload-status="status" :data-progress="progress" :onDragEnter="onDragEnter" :onDragOver="onDragOver" :onDragLeave="onDragLeave" :onDrop="onDrop" :class='"vmz-ui-upload" + (dragOver ? " is-dragover" : "")'>
3
+ <label v-if="label" :for="controlId" class="vmz-ui-upload__label">
4
+ {{ label }}
5
+ </label>
6
+ <div data-vmz-upload="zone" class="vmz-ui-upload__zone">
7
+ <input :id="controlId" type="file" :accept="accept" :multiple="multiple" :disabled="inputDisabled" :aria-invalid="error ? 'true' : 'false'" :aria-describedby="describedBy" :data-vmz-upload-directory="directoryAttr" @change="onPick" class="vmz-ui-upload__control" />
8
+ <Icon name="action.upload" size="lg" decorative />
9
+ <p v-if="hint" data-vmz-upload="hint" class="vmz-ui-upload__hint">
10
+ {{ hint }}
11
+ </p>
12
+ </div>
13
+ <ul v-if="hasItems" data-vmz-upload="queue" class="vmz-ui-upload__queue">
14
+ <li v-for="item in queue" :key="item.key" :data-vmz-upload-item="item.key" :data-status="item.status" class="vmz-ui-upload__item">
15
+ <span data-vmz-upload="file" class="vmz-ui-upload__item-name">
16
+ {{ item.name }}
17
+ </span>
18
+ <span class="vmz-ui-upload__item-meta">
19
+ {{ item.meta }}
20
+ </span>
21
+ <span v-if="item.showBar" aria-hidden="true" class="vmz-ui-upload__item-bar" :style="item.barStyle" />
22
+ </li>
23
+ </ul>
24
+ <p v-if="emptyHint" data-vmz-upload="empty" class="vmz-ui-upload__empty">
25
+ {{ emptyText }}
26
+ </p>
27
+ <div v-if="showProgress" data-vmz-upload="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progress" :aria-label="progressLabel" class="vmz-ui-upload__progress">
28
+ <div class="vmz-ui-upload__track">
29
+ <div class="vmz-ui-upload__bar" :style="progressStyle" />
30
+ </div>
31
+ <span data-vmz-upload="pct" class="vmz-ui-upload__pct">
32
+ {{ progressLabel }}
33
+ </span>
34
+ </div>
35
+ <p v-if="statusText" data-vmz-upload="status" class="vmz-ui-upload__status">
36
+ {{ statusText }}
37
+ </p>
38
+ <p v-if="resultText" data-vmz-upload="result" class="vmz-ui-upload__result">
39
+ {{ resultText }}
40
+ </p>
41
+ <div v-if="showActions" data-vmz-upload="actions" class="vmz-ui-upload__actions">
42
+ <button v-if="canCancel" type="button" data-vmz-upload="cancel" :aria-label="cancelLabel" @click="onCancelClick" class="vmz-ui-upload__cancel">
43
+ <Icon name="action.close" size="sm" decorative />
44
+ <span v-if="cancelLabel">
45
+ {{ cancelLabel }}
46
+ </span>
47
+ </button>
48
+ <button v-if="canResume" type="button" data-vmz-upload="resume" :aria-label="resumeLabel" @click="onResumeClick" class="vmz-ui-upload__resume">
49
+ <Icon name="action.chevron-right" size="sm" decorative />
50
+ <span v-if="resumeLabel">
51
+ {{ resumeLabel }}
52
+ </span>
53
+ </button>
54
+ </div>
55
+ <p v-if="description" :id="descriptionId" class="vmz-ui-upload__description">
56
+ {{ description }}
57
+ </p>
58
+ <p v-if="error" :id="errorId" role="alert" class="vmz-ui-upload__error">
59
+ {{ error }}
60
+ </p>
61
+ </div>
62
+ </template>
63
+ <style>
64
+ .vmz-ui-upload {
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 6px;
68
+ width: 100%;
69
+ max-width: 28rem;
70
+ }
71
+
72
+ .vmz-ui-upload__label {
73
+ font-size: 14px;
74
+ font-weight: 600;
75
+ line-height: 22px;
76
+ color: var(--vmz-text-ink);
77
+ }
78
+
79
+ .vmz-ui-upload__zone {
80
+ position: relative;
81
+ display: flex;
82
+ flex-direction: column;
83
+ align-items: center;
84
+ justify-content: center;
85
+ gap: 8px;
86
+ box-sizing: border-box;
87
+ min-height: 96px;
88
+ padding: 20px 24px;
89
+ border: 1px dashed var(--vmz-line-default);
90
+ border-radius: var(--vmz-radius-lg);
91
+ background: var(--vmz-surface-mist);
92
+ text-align: center;
93
+ transition:
94
+ border-color var(--vmz-motion-control-duration) var(--vmz-motion-control-easing),
95
+ background-color var(--vmz-motion-control-duration) var(--vmz-motion-control-easing);
96
+ }
97
+
98
+ .vmz-ui-upload__zone:hover {
99
+ border-color: var(--vmz-action-primary-background);
100
+ background: color-mix(
101
+ in srgb,
102
+ var(--vmz-action-primary-background) 4%,
103
+ var(--vmz-surface-paper)
104
+ );
105
+ }
106
+
107
+ .vmz-ui-upload__zone:focus-within {
108
+ border-color: var(--vmz-action-primary-background);
109
+ box-shadow: 0 0 0 3px var(--vmz-focus-ring);
110
+ }
111
+
112
+ .vmz-ui-upload.is-dragover .vmz-ui-upload__zone {
113
+ border-color: var(--vmz-action-primary-background);
114
+ background: color-mix(
115
+ in srgb,
116
+ var(--vmz-action-primary-background) 8%,
117
+ var(--vmz-surface-paper)
118
+ );
119
+ }
120
+
121
+ .vmz-ui-upload__control {
122
+ position: absolute;
123
+ inset: 0;
124
+ width: 100%;
125
+ height: 100%;
126
+ margin: 0;
127
+ padding: 0;
128
+ border: 0;
129
+ opacity: 0;
130
+ cursor: pointer;
131
+ outline: none;
132
+ }
133
+
134
+ .vmz-ui-upload__control:disabled {
135
+ cursor: not-allowed;
136
+ }
137
+
138
+ .vmz-ui-upload__zone .vmz-ui-icon {
139
+ color: var(--vmz-text-tertiary);
140
+ pointer-events: none;
141
+ }
142
+
143
+ .vmz-ui-upload__hint {
144
+ margin: 0;
145
+ font-size: 14px;
146
+ line-height: 22px;
147
+ color: var(--vmz-text-steel);
148
+ pointer-events: none;
149
+ }
150
+
151
+ .vmz-ui-upload__queue {
152
+ list-style: none;
153
+ margin: 0;
154
+ padding: 0;
155
+ display: flex;
156
+ flex-direction: column;
157
+ border: 1px solid var(--vmz-line-subtle);
158
+ border-radius: var(--vmz-radius-lg);
159
+ background: var(--vmz-surface-paper);
160
+ overflow: hidden;
161
+ }
162
+
163
+ .vmz-ui-upload__item {
164
+ display: grid;
165
+ grid-template-columns: 1fr auto;
166
+ align-items: center;
167
+ gap: 2px 12px;
168
+ min-height: 40px;
169
+ box-sizing: border-box;
170
+ padding: var(--vmz-density-compact-padding-y) var(--vmz-density-compact-padding-x);
171
+ border-bottom: 1px solid var(--vmz-line-subtle);
172
+ background: var(--vmz-surface-paper);
173
+ }
174
+
175
+ .vmz-ui-upload__item:last-child {
176
+ border-bottom: none;
177
+ }
178
+
179
+ .vmz-ui-upload__item-name {
180
+ font-size: 14px;
181
+ line-height: 22px;
182
+ color: var(--vmz-text-ink);
183
+ overflow: hidden;
184
+ text-overflow: ellipsis;
185
+ white-space: nowrap;
186
+ }
187
+
188
+ .vmz-ui-upload__item-meta {
189
+ font-size: 12px;
190
+ line-height: 20px;
191
+ color: var(--vmz-text-tertiary);
192
+ white-space: nowrap;
193
+ }
194
+
195
+ .vmz-ui-upload__item-bar {
196
+ grid-column: 1 / -1;
197
+ height: 2px;
198
+ border-radius: 1px;
199
+ background: var(--vmz-action-primary-background);
200
+ min-width: 2px;
201
+ transition: width var(--vmz-motion-control-duration) var(--vmz-motion-control-easing);
202
+ }
203
+
204
+ .vmz-ui-upload[data-invalid="true"] .vmz-ui-upload__label {
205
+ color: var(--vmz-status-danger-foreground);
206
+ }
207
+
208
+ .vmz-ui-upload[data-vmz-upload-status="error"] .vmz-ui-upload__zone {
209
+ border-color: var(--vmz-status-danger-accent);
210
+ }
211
+
212
+ /* The native file input already announces "no file chosen"; keep this node for
213
+ assistive tech without repeating the same line visually. */
214
+ .vmz-ui-upload__empty {
215
+ position: absolute;
216
+ width: 1px;
217
+ height: 1px;
218
+ margin: -1px;
219
+ padding: 0;
220
+ overflow: hidden;
221
+ clip-path: inset(50%);
222
+ white-space: nowrap;
223
+ border: 0;
224
+ }
225
+
226
+ .vmz-ui-upload__progress {
227
+ display: grid;
228
+ grid-template-columns: 1fr auto;
229
+ align-items: center;
230
+ gap: 8px;
231
+ padding: 4px 0;
232
+ }
233
+
234
+ .vmz-ui-upload__track {
235
+ height: 6px;
236
+ border-radius: 999px;
237
+ background: var(--vmz-surface-mist);
238
+ overflow: hidden;
239
+ }
240
+
241
+ .vmz-ui-upload__bar {
242
+ height: 100%;
243
+ border-radius: inherit;
244
+ background: var(--vmz-action-primary-background);
245
+ min-width: 2px;
246
+ transition: width var(--vmz-motion-control-duration) var(--vmz-motion-control-easing);
247
+ }
248
+
249
+ .vmz-ui-upload__pct {
250
+ font-size: 12px;
251
+ line-height: 20px;
252
+ color: var(--vmz-text-steel);
253
+ font-variant-numeric: tabular-nums;
254
+ white-space: nowrap;
255
+ }
256
+
257
+ .vmz-ui-upload__status {
258
+ margin: 0;
259
+ font-size: 14px;
260
+ line-height: 22px;
261
+ color: var(--vmz-text-steel);
262
+ }
263
+
264
+ .vmz-ui-upload[data-vmz-upload-status="done"] .vmz-ui-upload__status,
265
+ .vmz-ui-upload[data-vmz-upload-status="done"] .vmz-ui-upload__result {
266
+ color: var(--vmz-status-success-foreground);
267
+ }
268
+
269
+ .vmz-ui-upload[data-vmz-upload-status="error"] .vmz-ui-upload__status {
270
+ color: var(--vmz-status-danger-foreground);
271
+ }
272
+
273
+ .vmz-ui-upload__result {
274
+ margin: 0;
275
+ font-family: var(--vmz-font-mono);
276
+ font-size: 12px;
277
+ line-height: 20px;
278
+ color: var(--vmz-text-ink);
279
+ }
280
+
281
+ .vmz-ui-upload__actions {
282
+ display: flex;
283
+ flex-wrap: wrap;
284
+ gap: 8px;
285
+ }
286
+
287
+ .vmz-ui-upload__cancel,
288
+ .vmz-ui-upload__resume {
289
+ display: inline-flex;
290
+ align-items: center;
291
+ justify-content: center;
292
+ gap: 6px;
293
+ min-height: var(--vmz-control-height, 32px);
294
+ box-sizing: border-box;
295
+ font: inherit;
296
+ font-size: 14px;
297
+ line-height: 22px;
298
+ padding: var(--vmz-density-control-padding-y) var(--vmz-density-control-padding-x);
299
+ border: 1px solid var(--vmz-line-default);
300
+ border-radius: var(--vmz-radius-md);
301
+ background: var(--vmz-surface-paper);
302
+ color: var(--vmz-text-ink);
303
+ cursor: pointer;
304
+ outline: none;
305
+ transition:
306
+ color var(--vmz-motion-control-duration) var(--vmz-motion-control-easing),
307
+ border-color var(--vmz-motion-control-duration) var(--vmz-motion-control-easing);
308
+ }
309
+
310
+ .vmz-ui-upload__cancel:hover,
311
+ .vmz-ui-upload__resume:hover {
312
+ border-color: var(--vmz-action-primary-background);
313
+ color: var(--vmz-action-primary-background);
314
+ }
315
+
316
+ .vmz-ui-upload__cancel:focus-visible,
317
+ .vmz-ui-upload__resume:focus-visible {
318
+ box-shadow: 0 0 0 3px var(--vmz-focus-ring);
319
+ }
320
+
321
+ .vmz-ui-upload__resume {
322
+ border-color: var(--vmz-action-primary-background);
323
+ color: var(--vmz-action-primary-background);
324
+ }
325
+
326
+ .vmz-ui-upload__description {
327
+ margin: 0;
328
+ font-size: 14px;
329
+ line-height: 22px;
330
+ color: var(--vmz-text-tertiary);
331
+ }
332
+
333
+ .vmz-ui-upload__error {
334
+ margin: 0;
335
+ font-size: 14px;
336
+ line-height: 22px;
337
+ color: var(--vmz-status-danger-foreground);
338
+ }
339
+
340
+ @media (prefers-reduced-motion: reduce) {
341
+ .vmz-ui-upload__bar,
342
+ .vmz-ui-upload__item-bar,
343
+ .vmz-ui-upload__zone,
344
+ .vmz-ui-upload__cancel,
345
+ .vmz-ui-upload__resume {
346
+ transition: none;
347
+ }
348
+ }
349
+ </style>
350
+ <script client>
351
+ /**
352
+ * VMZ UI — Upload (all-in-one).
353
+ * Owns selection UI, constraints, queue, progress/cancel, and destination transport
354
+ * (client | server | object). Parent owns result value + destination config only.
355
+ * No useUpload / createUpload; no cloud SDK inside @vmz/ui.
356
+ * destination=server + chunkSize>0 → init / chunk PUT / complete + same-session pause/resume.
357
+ *
358
+ * Only destination config + result callbacks are `public` props.
359
+ * Presentation/queue/xhr state stay private so parent re-renders cannot wipe them.
360
+ */
361
+ export default class Upload {
362
+ public label: string = "";
363
+ public description: string = "";
364
+ /** Form-level validation message from parent. */
365
+ public error: string = "";
366
+ public emptyText: string = "";
367
+ public accept: string = "";
368
+ public disabled: boolean = false;
369
+ public invalidAttr: string = "false";
370
+ public controlId: string = "vmz-upload";
371
+ public descriptionId: string = "vmz-upload-desc";
372
+ public errorId: string = "vmz-upload-err";
373
+ public describedBy: string = "";
374
+ /** client | server | object */
375
+ public destination: string = "server";
376
+ /** ServerRoute / URL for destination=server (multipart) or resumable prefix (chunkSize>0) */
377
+ public action: string = "";
378
+ /** multipart field name */
379
+ public fieldName: string = "file";
380
+ public multiple: boolean = false;
381
+ public directory: boolean = false;
382
+ public maxCount: number = 1;
383
+ public maxBytes: number = 0;
384
+ /**
385
+ * When >0 and destination=server: chunked resumable transport against action prefix
386
+ * (`/init`, `/chunk`, `/status`, `/complete`). Thin gate is single-file same-session.
387
+ */
388
+ public chunkSize: number = 0;
389
+ /**
390
+ * object destination: (file) => Promise<{ uploadUrl, headers?, objectKey? }>
391
+ * Must bind #server-issued short-lived credentials only.
392
+ */
393
+ public onPresign:
394
+ | ((file: File) => Promise<{
395
+ uploadUrl: string;
396
+ headers?: Record<string, string>;
397
+ objectKey?: string;
398
+ }>)
399
+ | null = null;
400
+ /** Parent receives serializable results (no File handles required). */
401
+ public onValue: ((items: UploadValueItem[]) => void) | null = null;
402
+ /** Parent mirrors overall status for form fixture / submit gates. */
403
+ public onStatus: ((status: string) => void) | null = null;
404
+ public cancelLabel: string = "";
405
+ public resumeLabel: string = "";
406
+ /** Caller-supplied drop-zone hint; empty hides the hint line. */
407
+ public hint: string = "";
408
+ /** Localized status / diagnostic copy. Empty keys hide the status line. */
409
+ public messages: Record<string, string> = {};
410
+ /** Suffix for multi-file success (`N <readyText>`); part of the stable result text. */
411
+ public readyText: string = "file(s) ready";
412
+ // presentation (component-owned — NOT public props)
413
+ status: string = "idle";
414
+ progress: string = "0";
415
+ progressLabel: string = "";
416
+ progressStyle: string = "width:0%";
417
+ showProgress: boolean = false;
418
+ statusText: string = "";
419
+ resultText: string = "";
420
+ canCancel: boolean = false;
421
+ canResume: boolean = false;
422
+ showActions: boolean = false;
423
+ emptyHint: boolean = true;
424
+ hasItems: boolean = false;
425
+ inputDisabled: boolean = false;
426
+ dragOver: boolean = false;
427
+ selectionMode: string = "single";
428
+ directoryAttr: string = "false";
429
+ chunkAttr: string = "0";
430
+ sessionAttr: string = "";
431
+ /** @type {{ key: string, name: string, meta: string, status: string, showBar: boolean, barStyle: string }[]} */
432
+ queue: {
433
+ key: string;
434
+ name: string;
435
+ meta: string;
436
+ status: string;
437
+ showBar: boolean;
438
+ barStyle: string;
439
+ }[] = [];
440
+ _seenClearToken = 0;
441
+ _clearToken = 0;
442
+ /** Parent increments to clear internal queue (form reset). Binder assignment must clear immediately. */
443
+ get clearToken() {
444
+ return this._clearToken;
445
+ }
446
+ set clearToken(v) {
447
+ const n = Number(v) || 0;
448
+ this._clearToken = n;
449
+ if (n !== this._seenClearToken) {
450
+ this._seenClearToken = n;
451
+ this.clearInternal(true);
452
+ }
453
+ }
454
+ /** @type {XMLHttpRequest | null} */
455
+ _xhr = null;
456
+ /** @type {File[]} */
457
+ _files = [];
458
+ /** @type {UploadValueItem[]} */
459
+ _values = [];
460
+ _gen = 0;
461
+ /** Resumable session (same File handle / same page). */
462
+ _uploadId = "";
463
+ _chunkTotal = 0;
464
+ /** @type {number[]} */
465
+ _received = [];
466
+ _activeChunkSize = 0;
467
+ onMount() {
468
+ this.applyDirectoryAttr();
469
+ this.syncChunkAttr();
470
+ }
471
+ msg(key: string) {
472
+ const messages = this.messages || {};
473
+ const value = messages[key];
474
+ return typeof value === "string" ? value : "";
475
+ }
476
+ onPick(ev) {
477
+ this.syncClearToken();
478
+ this.applyDirectoryAttr();
479
+ const input = ev && ev.target;
480
+ const list = input && input.files ? input.files : null;
481
+ // Ignore empty change (including the synthetic event from clearing input.value
482
+ // after a successful pick) — otherwise in-flight uploads get abort/cleared.
483
+ if (!list || !list.length) return;
484
+ this.ingestFileList(list);
485
+ // Allow re-selecting the same path without retaining the FileList on the input.
486
+ if (input) input.value = "";
487
+ }
488
+ onDragEnter(ev) {
489
+ if (this.inputDisabled || this.disabled) return;
490
+ if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
491
+ this.dragOver = true;
492
+ }
493
+ onDragOver(ev) {
494
+ if (this.inputDisabled || this.disabled) return;
495
+ if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
496
+ this.dragOver = true;
497
+ }
498
+ onDragLeave() {
499
+ this.dragOver = false;
500
+ }
501
+ onDrop(ev) {
502
+ if (this.inputDisabled || this.disabled) return;
503
+ if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
504
+ this.dragOver = false;
505
+ const list = ev && ev.dataTransfer ? ev.dataTransfer.files : null;
506
+ this.ingestFileList(list);
507
+ }
508
+ onCancelClick() {
509
+ this.cancelActive(true);
510
+ }
511
+ onResumeClick() {
512
+ if (this.status !== "paused" || !this._uploadId || !this._files.length) return;
513
+ this._gen += 1;
514
+ const gen = this._gen;
515
+ this.runDestination(gen);
516
+ }
517
+ syncClearToken() {
518
+ const token = Number(this.clearToken) || 0;
519
+ if (token !== this._seenClearToken) {
520
+ this._seenClearToken = token;
521
+ this.clearInternal(false);
522
+ }
523
+ }
524
+ syncChunkAttr() {
525
+ const n = Math.max(0, Number(this.chunkSize) || 0);
526
+ this.chunkAttr = String(n);
527
+ this.sessionAttr = this._uploadId
528
+ ? `${this._received.length}/${this._chunkTotal || 0}:${this._uploadId}`
529
+ : "";
530
+ }
531
+ applyDirectoryAttr() {
532
+ this.directoryAttr = this.directory ? "true" : "false";
533
+ this.selectionMode = this.directory ? "directory" : this.multiple ? "multiple" : "single";
534
+ // Native directory picker (UA-specific).
535
+ const el = typeof document !== "undefined" ? document.getElementById(this.controlId) : null;
536
+ if (el && "webkitdirectory" in el) {
537
+ try {
538
+ el.webkitdirectory = !!this.directory;
539
+ el.multiple = !!(this.multiple || this.directory);
540
+ } catch {}
541
+ }
542
+ }
543
+ /**
544
+ * @param {FileList | File[] | null} list
545
+ */
546
+ ingestFileList(list) {
547
+ this.syncClearToken();
548
+ this.applyDirectoryAttr();
549
+ this.syncChunkAttr();
550
+ if (this.disabled) return;
551
+ const files = listToFiles(list);
552
+ if (!files.length) {
553
+ this.clearInternal(false);
554
+ this.emitValue([]);
555
+ return;
556
+ }
557
+ const maxCount = Math.max(1, Number(this.maxCount) || 1);
558
+ const capped =
559
+ this.multiple || this.directory ? files.slice(0, maxCount) : files.slice(0, 1);
560
+ if ((this.multiple || this.directory) && files.length > maxCount) {
561
+ this.failAll(this.msg("tooMany"));
562
+ return;
563
+ }
564
+ const maxBytes = Number(this.maxBytes) || 0;
565
+ for (const f of capped) {
566
+ const acceptErr = acceptError(f, this.accept);
567
+ if (acceptErr) {
568
+ this.failAll(this.msg("acceptType"));
569
+ return;
570
+ }
571
+ if (maxBytes > 0 && Number(f.size) > maxBytes) {
572
+ this.failAll(this.msg("fileTooLarge"));
573
+ return;
574
+ }
575
+ }
576
+ const chunkSize = Math.max(0, Number(this.chunkSize) || 0);
577
+ if (chunkSize > 0 && capped.length !== 1) {
578
+ this.failAll(this.msg("singleFileChunk"));
579
+ return;
580
+ }
581
+ this.cancelActive(false);
582
+ this.resetSession();
583
+ this._files = capped;
584
+ this._gen += 1;
585
+ const gen = this._gen;
586
+ this._values = [];
587
+ this.queue = capped.map((f, i) => ({
588
+ key: `f-${gen}-${i}`,
589
+ name: String(f.name || `file-${i}`),
590
+ meta: formatBytes(f.size),
591
+ status: "pending",
592
+ showBar: false,
593
+ barStyle: "width:0%",
594
+ }));
595
+ this.hasItems = this.queue.length > 0;
596
+ this.emptyHint = false;
597
+ this.inputDisabled = true;
598
+ this.canResume = false;
599
+ this.showActions = false;
600
+ this.runDestination(gen);
601
+ }
602
+ /**
603
+ * @param {number} gen
604
+ */
605
+ async runDestination(gen) {
606
+ const dest = String(this.destination || "server").toLowerCase();
607
+ if (dest === "client") {
608
+ await this.runClient(gen);
609
+ return;
610
+ }
611
+ if (dest === "object") {
612
+ await this.runObject(gen);
613
+ return;
614
+ }
615
+ const chunkSize = Math.max(0, Number(this.chunkSize) || 0);
616
+ if (chunkSize > 0) {
617
+ await this.runServerResumable(gen, chunkSize);
618
+ return;
619
+ }
620
+ await this.runServer(gen);
621
+ }
622
+ /**
623
+ * @param {number} gen
624
+ */
625
+ async runClient(gen) {
626
+ if (gen !== this._gen) return;
627
+ this.setOverall("uploading", this.msg("preparing"), true, false);
628
+ const values = [];
629
+ for (let i = 0; i < this._files.length; i++) {
630
+ if (gen !== this._gen) return;
631
+ const file = this._files[i];
632
+ const pct = Math.round(((i + 1) / this._files.length) * 100);
633
+ this.patchItem(i, {
634
+ status: "done",
635
+ showBar: true,
636
+ barStyle: `width:${pct}%`,
637
+ meta: formatBytes(file.size),
638
+ });
639
+ this.setProgress(pct);
640
+ values.push({
641
+ name: String(file.name || ""),
642
+ size: Number(file.size) || 0,
643
+ type: String(file.type || ""),
644
+ destination: "client",
645
+ id: `client-${gen}-${i}`,
646
+ bytes: Number(file.size) || 0,
647
+ /** Local File handle for in-tab tools (checksum, etc.). Not serialized. */
648
+ file,
649
+ });
650
+ }
651
+ if (gen !== this._gen) return;
652
+ this._values = values;
653
+ this.finishOk(values);
654
+ }
655
+ /**
656
+ * @param {number} gen
657
+ */
658
+ async runServer(gen) {
659
+ if (gen !== this._gen) return;
660
+ const action = String(this.action || "").trim();
661
+ if (!action) {
662
+ this.failAll(this.msg("actionRequired"));
663
+ return;
664
+ }
665
+ // Sequential single-XHR for thin gate; multi-file appends all parts then one request.
666
+ this.setOverall("uploading", this.msg("uploading"), true, true);
667
+ this.setProgress(0);
668
+ for (let i = 0; i < this.queue.length; i++) {
669
+ this.patchItem(i, {
670
+ status: "uploading",
671
+ showBar: true,
672
+ barStyle: "width:0%",
673
+ });
674
+ }
675
+ const form = new FormData();
676
+ const field = String(this.fieldName || "file");
677
+ if (this._files.length === 1) {
678
+ const file = this._files[0];
679
+ form.append(field, file, file.name);
680
+ form.append("name", file.name);
681
+ } else {
682
+ for (const file of this._files) {
683
+ form.append(field, file, file.name);
684
+ }
685
+ }
686
+ const xhr = new XMLHttpRequest();
687
+ this._xhr = xhr;
688
+ const result = await xhrSend(xhr, "POST", action, form, (pct) => {
689
+ if (gen !== this._gen) return;
690
+ this.setProgress(pct);
691
+ for (let i = 0; i < this.queue.length; i++) {
692
+ this.patchItem(i, { barStyle: `width:${pct}%` });
693
+ }
694
+ });
695
+ if (gen !== this._gen) return;
696
+ this._xhr = null;
697
+ if (result.aborted) {
698
+ this.markCancelled();
699
+ return;
700
+ }
701
+ if (result.error) {
702
+ this.failAll(result.error);
703
+ return;
704
+ }
705
+ const body = result.body;
706
+ if (!(result.status >= 200 && result.status < 300) || !body || !body.ok) {
707
+ this.failAll((body && body.error) || this.msg("failed"));
708
+ return;
709
+ }
710
+ // Single-file shape { ok, id, bytes, name } or multi { ok, items: [...] }
711
+ const values = [];
712
+ if (Array.isArray(body.items)) {
713
+ for (let i = 0; i < body.items.length; i++) {
714
+ const it = body.items[i] || {};
715
+ values.push({
716
+ name: String(it.name || (this._files[i] && this._files[i].name) || ""),
717
+ size: Number(
718
+ it.bytes || it.size || (this._files[i] && this._files[i].size) || 0,
719
+ ),
720
+ type: String(it.contentType || ""),
721
+ destination: "server",
722
+ id: String(it.id || ""),
723
+ bytes: Number(it.bytes || it.size || 0),
724
+ headHex: it.headHex ? String(it.headHex) : "",
725
+ });
726
+ }
727
+ } else {
728
+ values.push({
729
+ name: String(body.name || (this._files[0] && this._files[0].name) || ""),
730
+ size: Number(
731
+ body.bytes || body.reportedSize || (this._files[0] && this._files[0].size) || 0,
732
+ ),
733
+ type: String(body.contentType || ""),
734
+ destination: "server",
735
+ id: String(body.id || ""),
736
+ bytes: Number(body.bytes || 0),
737
+ headHex: body.headHex ? String(body.headHex) : "",
738
+ });
739
+ }
740
+ for (let i = 0; i < this.queue.length; i++) {
741
+ this.patchItem(i, {
742
+ status: "done",
743
+ showBar: true,
744
+ barStyle: "width:100%",
745
+ meta: formatBytes(values[i] && values[i].bytes),
746
+ });
747
+ }
748
+ this._values = values;
749
+ this.finishOk(values);
750
+ }
751
+ /**
752
+ * Chunked server upload: POST init → PUT chunk(s) → POST complete.
753
+ * Cancel → paused (keeps uploadId + received); Resume continues missing indexes.
754
+ * @param {number} gen
755
+ * @param {number} chunkSize
756
+ */
757
+ async runServerResumable(gen, chunkSize) {
758
+ if (gen !== this._gen) return;
759
+ const base = String(this.action || "")
760
+ .trim()
761
+ .replace(/\/+$/, "");
762
+ if (!base) {
763
+ this.failAll(this.msg("actionRequired"));
764
+ return;
765
+ }
766
+ const file = this._files[0];
767
+ if (!file) {
768
+ this.failAll(this.msg("noFile"));
769
+ return;
770
+ }
771
+ this._activeChunkSize = chunkSize;
772
+ this.setOverall("uploading", this._uploadId ? this.msg("resuming") : this.msg("uploading"), true, true);
773
+ this.canResume = false;
774
+ this.showActions = true;
775
+ this.patchItem(0, {
776
+ status: "uploading",
777
+ showBar: true,
778
+ barStyle: "width:0%",
779
+ });
780
+ if (!this._uploadId) {
781
+ const initXhr = new XMLHttpRequest();
782
+ this._xhr = initXhr;
783
+ const initResult = await xhrSendJson(initXhr, "POST", `${base}/init`, {
784
+ name: String(file.name || ""),
785
+ size: Number(file.size) || 0,
786
+ type: String(file.type || ""),
787
+ chunkSize,
788
+ });
789
+ if (gen !== this._gen) return;
790
+ this._xhr = null;
791
+ if (initResult.aborted) {
792
+ this.markPaused();
793
+ return;
794
+ }
795
+ if (
796
+ initResult.error ||
797
+ !(initResult.status >= 200 && initResult.status < 300) ||
798
+ !initResult.body ||
799
+ !initResult.body.ok
800
+ ) {
801
+ this.failAll(
802
+ (initResult.body && initResult.body.error) ||
803
+ initResult.error ||
804
+ this.msg("initFailed"),
805
+ );
806
+ return;
807
+ }
808
+ this._uploadId = String(initResult.body.uploadId || "");
809
+ this._chunkTotal =
810
+ Number(initResult.body.chunkTotal) ||
811
+ Math.ceil((Number(file.size) || 0) / chunkSize) ||
812
+ 1;
813
+ this._received = [];
814
+ this.syncChunkAttr();
815
+ if (!this._uploadId) {
816
+ this.failAll(this.msg("uploadIdMissing"));
817
+ return;
818
+ }
819
+ } else {
820
+ const statusXhr = new XMLHttpRequest();
821
+ this._xhr = statusXhr;
822
+ const statusResult = await xhrSendJson(statusXhr, "POST", `${base}/status`, {
823
+ uploadId: this._uploadId,
824
+ });
825
+ if (gen !== this._gen) return;
826
+ this._xhr = null;
827
+ if (statusResult.aborted) {
828
+ this.markPaused();
829
+ return;
830
+ }
831
+ if (
832
+ statusResult.error ||
833
+ !(statusResult.status >= 200 && statusResult.status < 300) ||
834
+ !statusResult.body ||
835
+ !statusResult.body.ok
836
+ ) {
837
+ this.failAll(
838
+ (statusResult.body && statusResult.body.error) ||
839
+ statusResult.error ||
840
+ this.msg("statusFailed"),
841
+ );
842
+ return;
843
+ }
844
+ this._received = Array.isArray(statusResult.body.received)
845
+ ? statusResult.body.received.map((n) => Number(n)).filter((n) => Number.isFinite(n))
846
+ : [];
847
+ this._chunkTotal = Number(statusResult.body.chunkTotal) || this._chunkTotal;
848
+ this.syncChunkAttr();
849
+ }
850
+ const total = Math.max(
851
+ 1,
852
+ this._chunkTotal || Math.ceil((Number(file.size) || 0) / chunkSize) || 1,
853
+ );
854
+ this._chunkTotal = total;
855
+ const receivedSet = new Set(this._received);
856
+ this.setProgress(Math.round((receivedSet.size / total) * 100));
857
+ this.patchItem(0, {
858
+ barStyle: `width:${Math.round((receivedSet.size / total) * 100)}%`,
859
+ meta: `${receivedSet.size}/${total}`,
860
+ });
861
+ for (let index = 0; index < total; index++) {
862
+ if (gen !== this._gen) return;
863
+ if (receivedSet.has(index)) continue;
864
+ const start = index * chunkSize;
865
+ const end = Math.min(Number(file.size) || 0, start + chunkSize);
866
+ const blob = file.slice(start, end);
867
+ const xhr = new XMLHttpRequest();
868
+ this._xhr = xhr;
869
+ const result = await xhrSend(
870
+ xhr,
871
+ "PUT",
872
+ `${base}/chunk`,
873
+ blob,
874
+ (pct) => {
875
+ if (gen !== this._gen) return;
876
+ const overall = Math.round(((receivedSet.size + pct / 100) / total) * 100);
877
+ this.setProgress(overall);
878
+ this.patchItem(0, { barStyle: `width:${overall}%` });
879
+ },
880
+ {
881
+ "content-type": "application/octet-stream",
882
+ "x-vmz-upload-id": this._uploadId,
883
+ "x-vmz-chunk-index": String(index),
884
+ "x-vmz-chunk-total": String(total),
885
+ },
886
+ );
887
+ if (gen !== this._gen) return;
888
+ this._xhr = null;
889
+ if (result.aborted) {
890
+ this.markPaused();
891
+ return;
892
+ }
893
+ if (
894
+ result.error ||
895
+ !(result.status >= 200 && result.status < 300) ||
896
+ !result.body ||
897
+ !result.body.ok
898
+ ) {
899
+ this.failAll(
900
+ (result.body && result.body.error) ||
901
+ result.error ||
902
+ this.msg("chunkFailed"),
903
+ );
904
+ return;
905
+ }
906
+ this._received = Array.isArray(result.body.received)
907
+ ? result.body.received.map((n) => Number(n)).filter((n) => Number.isFinite(n))
908
+ : this._received.concat([index]);
909
+ receivedSet.add(index);
910
+ for (const n of this._received) receivedSet.add(n);
911
+ this.syncChunkAttr();
912
+ const pct = Math.round((receivedSet.size / total) * 100);
913
+ this.setProgress(pct);
914
+ this.patchItem(0, {
915
+ barStyle: `width:${pct}%`,
916
+ meta: `${receivedSet.size}/${total}`,
917
+ });
918
+ }
919
+ if (gen !== this._gen) return;
920
+ const completeXhr = new XMLHttpRequest();
921
+ this._xhr = completeXhr;
922
+ const completeResult = await xhrSendJson(completeXhr, "POST", `${base}/complete`, {
923
+ uploadId: this._uploadId,
924
+ });
925
+ if (gen !== this._gen) return;
926
+ this._xhr = null;
927
+ if (completeResult.aborted) {
928
+ this.markPaused();
929
+ return;
930
+ }
931
+ if (
932
+ completeResult.error ||
933
+ !(completeResult.status >= 200 && completeResult.status < 300) ||
934
+ !completeResult.body ||
935
+ !completeResult.body.ok
936
+ ) {
937
+ this.failAll(
938
+ (completeResult.body && completeResult.body.error) ||
939
+ completeResult.error ||
940
+ this.msg("completeFailed"),
941
+ );
942
+ return;
943
+ }
944
+ const body = completeResult.body;
945
+ const values = [
946
+ {
947
+ name: String(body.name || file.name || ""),
948
+ size: Number(body.bytes || body.size || file.size || 0),
949
+ type: String(body.contentType || file.type || ""),
950
+ destination: "server",
951
+ id: String(body.id || ""),
952
+ bytes: Number(body.bytes || body.size || 0),
953
+ headHex: body.headHex ? String(body.headHex) : "",
954
+ uploadId: this._uploadId,
955
+ chunks: total,
956
+ },
957
+ ];
958
+ this.patchItem(0, {
959
+ status: "done",
960
+ showBar: true,
961
+ barStyle: "width:100%",
962
+ meta: `${formatBytes(values[0].bytes)} (${total})`,
963
+ });
964
+ this.resetSession();
965
+ this._values = values;
966
+ this.finishOk(values);
967
+ }
968
+ /**
969
+ * @param {number} gen
970
+ */
971
+ async runObject(gen) {
972
+ if (gen !== this._gen) return;
973
+ if (typeof this.onPresign !== "function") {
974
+ this.failAll(this.msg("presignRequired"));
975
+ return;
976
+ }
977
+ this.setOverall("uploading", this.msg("uploading"), true, true);
978
+ const values = [];
979
+ for (let i = 0; i < this._files.length; i++) {
980
+ if (gen !== this._gen) return;
981
+ const file = this._files[i];
982
+ this.patchItem(i, {
983
+ status: "uploading",
984
+ showBar: true,
985
+ barStyle: "width:0%",
986
+ });
987
+ let signed;
988
+ try {
989
+ signed = await this.onPresign(file);
990
+ } catch (err) {
991
+ this.failAll(err instanceof Error ? err.message : String(err || this.msg("presignFailed")));
992
+ return;
993
+ }
994
+ if (gen !== this._gen) return;
995
+ const url = signed && signed.uploadUrl ? String(signed.uploadUrl) : "";
996
+ if (!url) {
997
+ this.failAll(this.msg("presignUrl"));
998
+ return;
999
+ }
1000
+ const xhr = new XMLHttpRequest();
1001
+ this._xhr = xhr;
1002
+ const headers = (signed && signed.headers) || {};
1003
+ const result = await xhrSend(
1004
+ xhr,
1005
+ "PUT",
1006
+ url,
1007
+ file,
1008
+ (pct) => {
1009
+ if (gen !== this._gen) return;
1010
+ const overall = Math.round(((i + pct / 100) / this._files.length) * 100);
1011
+ this.setProgress(overall);
1012
+ this.patchItem(i, { barStyle: `width:${pct}%` });
1013
+ },
1014
+ headers,
1015
+ );
1016
+ if (gen !== this._gen) return;
1017
+ this._xhr = null;
1018
+ if (result.aborted) {
1019
+ this.markCancelled();
1020
+ return;
1021
+ }
1022
+ if (result.error || !(result.status >= 200 && result.status < 300)) {
1023
+ this.failAll(
1024
+ result.error || this.msg("objectFailed"),
1025
+ );
1026
+ return;
1027
+ }
1028
+ const objectKey = String((signed && signed.objectKey) || file.name || "");
1029
+ values.push({
1030
+ name: String(file.name || ""),
1031
+ size: Number(file.size) || 0,
1032
+ type: String(file.type || ""),
1033
+ destination: "object",
1034
+ id: objectKey,
1035
+ bytes: Number(file.size) || 0,
1036
+ objectKey,
1037
+ });
1038
+ this.patchItem(i, {
1039
+ status: "done",
1040
+ showBar: true,
1041
+ barStyle: "width:100%",
1042
+ meta: formatBytes(file.size),
1043
+ });
1044
+ }
1045
+ this._values = values;
1046
+ this.finishOk(values);
1047
+ }
1048
+ /**
1049
+ * @param {UploadValueItem[]} values
1050
+ */
1051
+ finishOk(values) {
1052
+ this.setProgress(100);
1053
+ this.status = "done";
1054
+ this.statusText = this.msg("complete");
1055
+ this.showProgress = true;
1056
+ this.canCancel = false;
1057
+ this.canResume = false;
1058
+ this.showActions = false;
1059
+ this.inputDisabled = false;
1060
+ const first = values[0];
1061
+ if (values.length === 1 && first) {
1062
+ const prefix =
1063
+ first.destination === "client"
1064
+ ? "client"
1065
+ : first.destination === "object"
1066
+ ? "object"
1067
+ : "attachment";
1068
+ const head = first.headHex ? ` head:${first.headHex}` : "";
1069
+ const chunkNote = first.chunks ? ` chunks:${first.chunks}` : "";
1070
+ this.resultText = `${prefix}:${first.id} (${first.bytes || 0} bytes)${head}${chunkNote}`;
1071
+ } else {
1072
+ this.resultText = `${values.length} ${this.readyText}`.trim();
1073
+ }
1074
+ this.emitStatus();
1075
+ this.emitValue(values);
1076
+ }
1077
+ /**
1078
+ * @param {string} message
1079
+ */
1080
+ failAll(message) {
1081
+ this.cancelActive(false);
1082
+ this.resetSession();
1083
+ const text = message || this.msg("failed");
1084
+ this.status = "error";
1085
+ this.statusText = text;
1086
+ this.showProgress = false;
1087
+ this.canCancel = false;
1088
+ this.canResume = false;
1089
+ this.showActions = false;
1090
+ this.inputDisabled = false;
1091
+ this.resultText = "";
1092
+ this._values = [];
1093
+ for (let i = 0; i < this.queue.length; i++) {
1094
+ this.patchItem(i, {
1095
+ status: "error",
1096
+ showBar: false,
1097
+ });
1098
+ }
1099
+ this.emitStatus();
1100
+ this.emitValue([]);
1101
+ }
1102
+ markCancelled() {
1103
+ this.status = "cancelled";
1104
+ this.statusText = this.msg("cancelled");
1105
+ this.showProgress = false;
1106
+ this.canCancel = false;
1107
+ this.canResume = false;
1108
+ this.showActions = false;
1109
+ this.inputDisabled = false;
1110
+ this.resultText = "";
1111
+ this._values = [];
1112
+ this.resetSession();
1113
+ this.emitStatus();
1114
+ this.emitValue([]);
1115
+ }
1116
+ markPaused() {
1117
+ this.status = "paused";
1118
+ this.statusText = this.msg("paused");
1119
+ this.showProgress = true;
1120
+ this.canCancel = false;
1121
+ this.canResume = !!this._uploadId;
1122
+ this.showActions = this.canResume;
1123
+ this.inputDisabled = false;
1124
+ this.resultText = "";
1125
+ this._values = [];
1126
+ this.syncChunkAttr();
1127
+ this.patchItem(0, {
1128
+ status: "paused",
1129
+ meta: `${this._received.length}/${this._chunkTotal || 0}`,
1130
+ });
1131
+ this.emitStatus();
1132
+ this.emitValue([]);
1133
+ }
1134
+ resetSession() {
1135
+ this._uploadId = "";
1136
+ this._chunkTotal = 0;
1137
+ this._received = [];
1138
+ this._activeChunkSize = 0;
1139
+ this.syncChunkAttr();
1140
+ }
1141
+ /**
1142
+ * @param {boolean} mark
1143
+ */
1144
+ cancelActive(mark) {
1145
+ const xhr = this._xhr;
1146
+ this._xhr = null;
1147
+ this._gen += 1;
1148
+ if (xhr) {
1149
+ try {
1150
+ xhr.abort();
1151
+ } catch {}
1152
+ }
1153
+ if (mark && this.status === "uploading") {
1154
+ if (this._uploadId && this._activeChunkSize > 0) {
1155
+ this.markPaused();
1156
+ } else {
1157
+ this.markCancelled();
1158
+ }
1159
+ }
1160
+ }
1161
+ /**
1162
+ * @param {boolean} emit
1163
+ */
1164
+ clearInternal(emit) {
1165
+ this.cancelActive(false);
1166
+ this.resetSession();
1167
+ this._files = [];
1168
+ this._values = [];
1169
+ this.queue = [];
1170
+ this.hasItems = false;
1171
+ this.emptyHint = true;
1172
+ this.status = "idle";
1173
+ this.statusText = "";
1174
+ this.resultText = "";
1175
+ this.showProgress = false;
1176
+ this.canCancel = false;
1177
+ this.canResume = false;
1178
+ this.showActions = false;
1179
+ this.inputDisabled = !!this.disabled;
1180
+ this.setProgress(0);
1181
+ this.dragOver = false;
1182
+ this.applyDirectoryAttr();
1183
+ this.emitStatus();
1184
+ if (emit) this.emitValue([]);
1185
+ }
1186
+ /**
1187
+ * @param {string} status
1188
+ * @param {string} text
1189
+ * @param {boolean} showProgress
1190
+ * @param {boolean} canCancel
1191
+ */
1192
+ setOverall(status, text, showProgress, canCancel) {
1193
+ this.status = status;
1194
+ this.statusText = text;
1195
+ this.showProgress = showProgress;
1196
+ this.canCancel = canCancel;
1197
+ this.canResume = false;
1198
+ this.showActions = canCancel;
1199
+ this.inputDisabled = status === "uploading" || !!this.disabled;
1200
+ this.emitStatus();
1201
+ }
1202
+ /**
1203
+ * @param {number} pct
1204
+ */
1205
+ setProgress(pct) {
1206
+ const n = Math.max(0, Math.min(100, Number(pct) || 0));
1207
+ this.progress = String(n);
1208
+ this.progressLabel = `${n}%`;
1209
+ this.progressStyle = `width:${n}%`;
1210
+ }
1211
+ /**
1212
+ * @param {number} index
1213
+ * @param {Partial<{ status: string, meta: string, showBar: boolean, barStyle: string }>} patch
1214
+ */
1215
+ patchItem(index, patch) {
1216
+ const cur = this.queue[index];
1217
+ if (!cur) return;
1218
+ const next = Object.assign({}, cur, patch);
1219
+ const copy = this.queue.slice();
1220
+ copy[index] = next;
1221
+ this.queue = copy;
1222
+ }
1223
+ /**
1224
+ * @param {UploadValueItem[]} items
1225
+ */
1226
+ emitValue(items) {
1227
+ if (typeof this.onValue === "function") this.onValue(items);
1228
+ }
1229
+ emitStatus() {
1230
+ if (typeof this.onStatus === "function") this.onStatus(this.status);
1231
+ }
1232
+ }
1233
+ /**
1234
+ * @typedef {{
1235
+ * name: string,
1236
+ * size: number,
1237
+ * type: string,
1238
+ * destination: string,
1239
+ * id: string,
1240
+ * bytes: number,
1241
+ * objectKey?: string,
1242
+ * headHex?: string,
1243
+ * uploadId?: string,
1244
+ * chunks?: number
1245
+ * }} UploadValueItem
1246
+ */
1247
+ /**
1248
+ * @param {FileList | File[] | null | undefined} list
1249
+ * @returns {File[]}
1250
+ */
1251
+ function listToFiles(list) {
1252
+ if (!list) return [];
1253
+ if (Array.isArray(list)) return list.filter(Boolean);
1254
+ const out = [];
1255
+ for (let i = 0; i < list.length; i++) out.push(list[i]);
1256
+ return out;
1257
+ }
1258
+ /**
1259
+ * @param {File} file
1260
+ * @param {string} accept
1261
+ */
1262
+ function acceptError(file, accept) {
1263
+ const raw = String(accept || "").trim();
1264
+ if (!raw) return "";
1265
+ const name = String(file.name || "").toLowerCase();
1266
+ const type = String(file.type || "").toLowerCase();
1267
+ const parts = raw
1268
+ .split(",")
1269
+ .map((s) => s.trim().toLowerCase())
1270
+ .filter(Boolean);
1271
+ for (const p of parts) {
1272
+ if (p.startsWith(".")) {
1273
+ if (name.endsWith(p)) return "";
1274
+ } else if (p.endsWith("/*")) {
1275
+ const prefix = p.slice(0, -1);
1276
+ if (type.startsWith(prefix)) return "";
1277
+ } else if (p === type) {
1278
+ return "";
1279
+ }
1280
+ }
1281
+ return "accept";
1282
+ }
1283
+ /**
1284
+ * @param {number} n
1285
+ */
1286
+ function formatBytes(n) {
1287
+ const v = Number(n) || 0;
1288
+ if (v < 1024) return `${v} B`;
1289
+ if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
1290
+ return `${(v / (1024 * 1024)).toFixed(1)} MB`;
1291
+ }
1292
+ /**
1293
+ * @param {XMLHttpRequest} xhr
1294
+ * @param {string} method
1295
+ * @param {string} url
1296
+ * @param {FormData | Blob | File} body
1297
+ * @param {(pct: number) => void} onProgress
1298
+ * @param {Record<string, string>} [headers]
1299
+ */
1300
+ function xhrSend(xhr, method, url, body, onProgress, headers) {
1301
+ return new Promise((resolve) => {
1302
+ xhr.open(method, url);
1303
+ if (headers) {
1304
+ for (const k of Object.keys(headers)) {
1305
+ try {
1306
+ xhr.setRequestHeader(k, headers[k]);
1307
+ } catch {}
1308
+ }
1309
+ }
1310
+ xhr.upload.onprogress = (ev) => {
1311
+ if (!ev || !ev.lengthComputable || !ev.total) return;
1312
+ onProgress(Math.round((100 * ev.loaded) / ev.total));
1313
+ };
1314
+ xhr.onabort = () =>
1315
+ resolve({
1316
+ aborted: true,
1317
+ status: 0,
1318
+ body: null,
1319
+ error: "",
1320
+ });
1321
+ xhr.onerror = () =>
1322
+ resolve({
1323
+ aborted: false,
1324
+ status: 0,
1325
+ body: null,
1326
+ error: "",
1327
+ });
1328
+ xhr.onload = () => {
1329
+ let parsed = null;
1330
+ const text = String(xhr.responseText || "");
1331
+ if (text) {
1332
+ try {
1333
+ parsed = JSON.parse(text);
1334
+ } catch {
1335
+ parsed = null;
1336
+ }
1337
+ }
1338
+ resolve({
1339
+ aborted: false,
1340
+ status: xhr.status,
1341
+ body: parsed,
1342
+ error: "",
1343
+ });
1344
+ };
1345
+ xhr.send(body);
1346
+ });
1347
+ }
1348
+ /**
1349
+ * @param {XMLHttpRequest} xhr
1350
+ * @param {string} method
1351
+ * @param {string} url
1352
+ * @param {Record<string, unknown>} payload
1353
+ */
1354
+ function xhrSendJson(xhr, method, url, payload) {
1355
+ return new Promise((resolve) => {
1356
+ xhr.open(method, url);
1357
+ try {
1358
+ xhr.setRequestHeader("content-type", "application/json");
1359
+ } catch {}
1360
+ xhr.onabort = () =>
1361
+ resolve({
1362
+ aborted: true,
1363
+ status: 0,
1364
+ body: null,
1365
+ error: "",
1366
+ });
1367
+ xhr.onerror = () =>
1368
+ resolve({
1369
+ aborted: false,
1370
+ status: 0,
1371
+ body: null,
1372
+ error: "",
1373
+ });
1374
+ xhr.onload = () => {
1375
+ let parsed = null;
1376
+ const text = String(xhr.responseText || "");
1377
+ if (text) {
1378
+ try {
1379
+ parsed = JSON.parse(text);
1380
+ } catch {
1381
+ parsed = null;
1382
+ }
1383
+ }
1384
+ resolve({
1385
+ aborted: false,
1386
+ status: xhr.status,
1387
+ body: parsed,
1388
+ error: "",
1389
+ });
1390
+ };
1391
+ xhr.send(JSON.stringify(payload || {}));
1392
+ });
1393
+ }
1394
+ </script>