@vmz/ui 0.0.4 → 0.1.0

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