@vmz/ui 0.3.5 → 0.3.6

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 (43) hide show
  1. package/README.md +51 -51
  2. package/package.json +2 -2
  3. package/presets/web-surface.v0.json +19 -7
  4. package/src/components/Accordion.vmz +94 -94
  5. package/src/components/AppShell.vmz +77 -33
  6. package/src/components/Autocomplete.vmz +154 -154
  7. package/src/components/Badge.vmz +70 -70
  8. package/src/components/Breadcrumb.vmz +72 -72
  9. package/src/components/BulkActions.vmz +51 -51
  10. package/src/components/Button.vmz +211 -192
  11. package/src/components/Callout.vmz +77 -77
  12. package/src/components/Card.vmz +120 -106
  13. package/src/components/Checkbox.vmz +55 -55
  14. package/src/components/CodeBlock.vmz +77 -18
  15. package/src/components/ConsoleShell.vmz +251 -251
  16. package/src/components/DataTable.vmz +194 -194
  17. package/src/components/DatePicker.vmz +674 -674
  18. package/src/components/Dialog.vmz +10 -7
  19. package/src/components/Drawer.vmz +427 -427
  20. package/src/components/Dropdown.vmz +135 -16
  21. package/src/components/Empty.vmz +54 -54
  22. package/src/components/Field.vmz +115 -115
  23. package/src/components/FormItem.vmz +79 -79
  24. package/src/components/Link.vmz +34 -4
  25. package/src/components/List.vmz +102 -102
  26. package/src/components/Menu.vmz +243 -243
  27. package/src/components/Pagination.vmz +69 -69
  28. package/src/components/Popover.vmz +223 -223
  29. package/src/components/Prose.vmz +81 -81
  30. package/src/components/RadioGroup.vmz +124 -124
  31. package/src/components/Segmented.vmz +51 -51
  32. package/src/components/Select.vmz +533 -533
  33. package/src/components/Steps.vmz +103 -103
  34. package/src/components/Switch.vmz +98 -98
  35. package/src/components/Table.vmz +165 -133
  36. package/src/components/Tabs.vmz +110 -110
  37. package/src/components/Tag.vmz +43 -40
  38. package/src/components/TextArea.vmz +118 -118
  39. package/src/components/Timeline.vmz +76 -76
  40. package/src/components/Toc.vmz +74 -74
  41. package/src/components/Tooltip.vmz +58 -58
  42. package/src/components/Tree.vmz +191 -191
  43. package/src/components/Upload.vmz +1289 -1289
@@ -1,1289 +1,1289 @@
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.35rem;
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.15rem 0;
88
- color: inherit;
89
- outline: none;
90
- }
91
-
92
- .vmz-ui-upload__control:focus-visible {
93
- box-shadow: 0 0 0 2px 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.35rem 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.35rem;
114
- }
115
-
116
- .vmz-ui-upload__item {
117
- display: grid;
118
- grid-template-columns: 1fr auto;
119
- gap: 0.15rem 0.75rem;
120
- padding: 0.35rem 0.45rem;
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.35rem 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 2px 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
+ <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>