@vmz/ui 0.0.4 → 0.1.1

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 (66) hide show
  1. package/README.md +15 -2
  2. package/contracts/token-requirements.v0.json +45 -10
  3. package/package.json +18 -2
  4. package/presets/web-surface.v0.json +24 -10
  5. package/src/components/Accordion.vmz +88 -89
  6. package/src/components/Alert.vmz +64 -63
  7. package/src/components/AppShell.vmz +64 -45
  8. package/src/components/Autocomplete.vmz +152 -151
  9. package/src/components/Avatar.vmz +16 -0
  10. package/src/components/Badge.vmz +46 -46
  11. package/src/components/Breadcrumb.vmz +52 -48
  12. package/src/components/BulkActions.vmz +37 -37
  13. package/src/components/Button.vmz +91 -70
  14. package/src/components/Callout.vmz +61 -61
  15. package/src/components/Card.vmz +58 -52
  16. package/src/components/Checkbox.vmz +56 -53
  17. package/src/components/CodeBlock.vmz +51 -51
  18. package/src/components/Collapse.vmz +20 -0
  19. package/src/components/ConsoleShell.vmz +134 -99
  20. package/src/components/Content.vmz +11 -0
  21. package/src/components/DataTable.vmz +197 -196
  22. package/src/components/DatePicker.vmz +650 -75
  23. package/src/components/Dialog.vmz +365 -384
  24. package/src/components/Divider.vmz +19 -0
  25. package/src/components/Drawer.vmz +361 -378
  26. package/src/components/Dropdown.vmz +22 -0
  27. package/src/components/Empty.vmz +36 -36
  28. package/src/components/Field.vmz +69 -40
  29. package/src/components/FilterBar.vmz +15 -15
  30. package/src/components/Flex.vmz +12 -0
  31. package/src/components/Footer.vmz +11 -0
  32. package/src/components/Form.vmz +56 -58
  33. package/src/components/FormItem.vmz +59 -59
  34. package/src/components/Grid.vmz +12 -0
  35. package/src/components/Header.vmz +11 -0
  36. package/src/components/Icon.vmz +86 -0
  37. package/src/components/Layout.vmz +11 -0
  38. package/src/components/Link.vmz +33 -33
  39. package/src/components/List.vmz +96 -96
  40. package/src/components/Menu.vmz +229 -240
  41. package/src/components/Notification.vmz +62 -62
  42. package/src/components/Pagination.vmz +63 -65
  43. package/src/components/Popover.vmz +202 -219
  44. package/src/components/Progress.vmz +18 -0
  45. package/src/components/Prose.vmz +61 -61
  46. package/src/components/QueryForm.vmz +26 -27
  47. package/src/components/RadioGroup.vmz +116 -113
  48. package/src/components/Result.vmz +63 -63
  49. package/src/components/Segmented.vmz +29 -0
  50. package/src/components/Select.vmz +462 -286
  51. package/src/components/Sider.vmz +6 -0
  52. package/src/components/Skeleton.vmz +58 -58
  53. package/src/components/Space.vmz +15 -0
  54. package/src/components/Spinner.vmz +29 -29
  55. package/src/components/Steps.vmz +86 -86
  56. package/src/components/Switch.vmz +86 -88
  57. package/src/components/Table.vmz +116 -114
  58. package/src/components/Tabs.vmz +113 -112
  59. package/src/components/Tag.vmz +8 -0
  60. package/src/components/TextArea.vmz +76 -49
  61. package/src/components/Timeline.vmz +55 -50
  62. package/src/components/Toc.vmz +64 -60
  63. package/src/components/Tooltip.vmz +51 -51
  64. package/src/components/Tree.vmz +184 -190
  65. package/src/components/Typography.vmz +9 -0
  66. package/src/components/Upload.vmz +1192 -82
@@ -1,102 +1,1212 @@
1
1
  <template>
2
- <div class="vmz-ui-upload" data-vmz-ui="upload" data-invalid={invalidAttr}>
3
- <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>
15
- <p if={emptyHint} class="vmz-ui-upload__empty" data-vmz-upload="empty">{emptyText}</p>
16
- <p if={description} id={descriptionId} class="vmz-ui-upload__description">{description}</p>
17
- <p if={error} id={errorId} class="vmz-ui-upload__error" role="alert">{error}</p>
18
- </div>
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
+ >
18
+ <label if={label} class="vmz-ui-upload__label" for={controlId}>{label}</label>
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
+
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
+
70
+ <p if={description} id={descriptionId} class="vmz-ui-upload__description">{description}</p>
71
+ <p if={error} id={errorId} class="vmz-ui-upload__error" role="alert">{error}</p>
72
+ </div>
19
73
  </template>
20
74
 
21
75
  <style>
22
- .vmz-ui-upload {
23
- display: flex;
24
- flex-direction: column;
25
- gap: 0.35rem;
26
- max-width: 28rem;
27
- }
76
+ .vmz-ui-upload {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 0.35rem;
80
+ max-width: 28rem;
81
+ }
28
82
 
29
- .vmz-ui-upload__label {
30
- font: inherit;
31
- font-weight: 600;
32
- }
83
+ .vmz-ui-upload__label {
84
+ font: inherit;
85
+ font-weight: 600;
86
+ }
33
87
 
34
- .vmz-ui-upload__control {
35
- font: inherit;
36
- padding: var(--vmz-density-control-padding-y) 0;
37
- color: inherit;
38
- outline: none;
39
- }
88
+ .vmz-ui-upload__zone {
89
+ position: relative;
90
+ border: 1px dashed var(--vmz-line-default);
91
+ border-radius: var(--vmz-radius-md, 6px);
92
+ background: var(--vmz-surface-paper);
93
+ padding: var(--vmz-density-control-padding-y) var(--vmz-density-control-padding-x);
94
+ }
40
95
 
41
- .vmz-ui-upload__control:focus-visible {
42
- box-shadow: 0 0 0 2px var(--vmz-focus-ring);
43
- }
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
+ }
44
100
 
45
- .vmz-ui-upload__control:disabled {
46
- opacity: 0.55;
47
- cursor: not-allowed;
48
- }
101
+ .vmz-ui-upload__control {
102
+ font: inherit;
103
+ width: 100%;
104
+ padding: 0.15rem 0;
105
+ color: inherit;
106
+ outline: none;
107
+ }
49
108
 
50
- .vmz-ui-upload[data-invalid='true'] .vmz-ui-upload__label {
51
- color: var(--vmz-status-danger-foreground);
52
- }
109
+ .vmz-ui-upload__control:focus-visible {
110
+ box-shadow: 0 0 0 2px var(--vmz-focus-ring);
111
+ }
53
112
 
54
- .vmz-ui-upload__file {
55
- margin: 0;
56
- font-family: var(--vmz-font-mono);
57
- font-size: 0.88rem;
58
- color: var(--vmz-text-ink);
59
- }
113
+ .vmz-ui-upload__control:disabled {
114
+ opacity: 0.55;
115
+ cursor: not-allowed;
116
+ }
60
117
 
61
- .vmz-ui-upload__empty {
62
- margin: 0;
63
- color: var(--vmz-text-steel);
64
- font-size: 0.9em;
65
- }
118
+ .vmz-ui-upload__hint {
119
+ margin: 0.35rem 0 0;
120
+ font-size: 0.88rem;
121
+ color: var(--vmz-text-steel);
122
+ }
66
123
 
67
- .vmz-ui-upload__description {
68
- margin: 0;
69
- opacity: 0.75;
70
- font-size: 0.9em;
71
- }
124
+ .vmz-ui-upload__queue {
125
+ list-style: none;
126
+ margin: 0;
127
+ padding: 0;
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: 0.35rem;
131
+ }
72
132
 
73
- .vmz-ui-upload__error {
74
- margin: 0;
75
- color: var(--vmz-status-danger-foreground);
76
- font-size: 0.9em;
77
- }
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: var(--vmz-radius-md, 6px);
140
+ background: var(--vmz-surface-paper);
141
+ }
142
+
143
+ .vmz-ui-upload__item-name {
144
+ font-family: var(--vmz-font-mono);
145
+ font-size: 0.88rem;
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: var(--vmz-radius-md, 6px);
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);
169
+ }
170
+
171
+ .vmz-ui-upload__empty {
172
+ margin: 0;
173
+ color: var(--vmz-text-steel);
174
+ font-size: 0.9em;
175
+ }
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: var(--vmz-radius-md, 6px);
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: var(--vmz-radius-md, 6px);
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
+
249
+ .vmz-ui-upload__description {
250
+ margin: 0;
251
+ opacity: 0.75;
252
+ font-size: 0.9em;
253
+ }
254
+
255
+ .vmz-ui-upload__error {
256
+ margin: 0;
257
+ color: var(--vmz-status-danger-foreground);
258
+ font-size: 0.9em;
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.
85
- */
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.
279
+ */
86
280
  export default class Upload {
87
- public label: string = '';
88
- public description: string = '';
89
- public error: string = '';
90
- public fileName: string = '';
91
- public emptyText: string = 'No file selected';
92
- public emptyHint: boolean = true;
93
- public accept: string = '';
94
- public disabled: boolean = false;
95
- public invalidAttr: string = 'false';
96
- public controlId: string = 'vmz-upload';
97
- public descriptionId: string = 'vmz-upload-desc';
98
- public errorId: string = 'vmz-upload-err';
99
- public describedBy: string = '';
100
- public onChange: ((e: Event) => void) | null = null;
281
+ public label: string = "";
282
+ public description: string = "";
283
+ /** Form-level validation message from parent. */
284
+ public error: string = "";
285
+ public emptyText: string = "No file selected";
286
+ public accept: string = "";
287
+ public disabled: boolean = false;
288
+ public invalidAttr: string = "false";
289
+ public controlId: string = "vmz-upload";
290
+ public descriptionId: string = "vmz-upload-desc";
291
+ public errorId: string = "vmz-upload-err";
292
+ public describedBy: string = "";
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<{
313
+ uploadUrl: string;
314
+ headers?: Record<string, string>;
315
+ objectKey?: string;
316
+ }>) | null = null;
317
+ /** Parent receives serializable results (no File handles required). */
318
+ public onValue: ((items: UploadValueItem[]) => void) | null = null;
319
+ /** Parent mirrors overall status for form dogfood / submit gates. */
320
+ public onStatus: ((status: string) => void) | null = null;
321
+ public cancelLabel: string = "Cancel upload";
322
+ public resumeLabel: string = "Resume upload";
323
+ // presentation (component-owned — NOT public props)
324
+ status: string = "idle";
325
+ progress: string = "0";
326
+ progressLabel: string = "";
327
+ progressStyle: string = "width:0%";
328
+ showProgress: boolean = false;
329
+ statusText: string = "";
330
+ resultText: string = "";
331
+ canCancel: boolean = false;
332
+ canResume: boolean = false;
333
+ showActions: boolean = false;
334
+ emptyHint: boolean = true;
335
+ hasItems: boolean = false;
336
+ inputDisabled: boolean = false;
337
+ dragOver: boolean = false;
338
+ selectionMode: string = "single";
339
+ directoryAttr: string = "false";
340
+ chunkAttr: string = "0";
341
+ sessionAttr: string = "";
342
+ zoneHint: string = "Choose a file or drop it here";
343
+ /** @type {{ key: string, name: string, meta: string, status: string, showBar: boolean, barStyle: string }[]} */
344
+ queue: {
345
+ key: string;
346
+ name: string;
347
+ meta: string;
348
+ status: string;
349
+ showBar: boolean;
350
+ barStyle: string;
351
+ }[] = [];
352
+ _seenClearToken = 0;
353
+ _clearToken = 0;
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
+ onMount() {
380
+ this.applyDirectoryAttr();
381
+ this.syncChunkAttr();
382
+ }
383
+ onPick(ev) {
384
+ this.syncClearToken();
385
+ this.applyDirectoryAttr();
386
+ const input = ev && ev.target;
387
+ const list = input && input.files ? input.files : null;
388
+ // Ignore empty change (including the synthetic event from clearing input.value
389
+ // after a successful pick) — otherwise in-flight uploads get abort/cleared.
390
+ if (!list || !list.length) return;
391
+ this.ingestFileList(list);
392
+ // Allow re-selecting the same path without retaining the FileList on the input.
393
+ if (input) input.value = "";
394
+ }
395
+ onDragEnter(ev) {
396
+ if (this.inputDisabled || this.disabled) return;
397
+ if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
398
+ this.dragOver = true;
399
+ }
400
+ onDragOver(ev) {
401
+ if (this.inputDisabled || this.disabled) return;
402
+ if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
403
+ this.dragOver = true;
404
+ }
405
+ onDragLeave() {
406
+ this.dragOver = false;
407
+ }
408
+ onDrop(ev) {
409
+ if (this.inputDisabled || this.disabled) return;
410
+ if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
411
+ this.dragOver = false;
412
+ const list = ev && ev.dataTransfer ? ev.dataTransfer.files : null;
413
+ this.ingestFileList(list);
414
+ }
415
+ onCancelClick() {
416
+ this.cancelActive(true);
417
+ }
418
+ onResumeClick() {
419
+ if (this.status !== "paused" || !this._uploadId || !this._files.length) return;
420
+ this._gen += 1;
421
+ const gen = this._gen;
422
+ this.runDestination(gen);
423
+ }
424
+ syncClearToken() {
425
+ const token = Number(this.clearToken) || 0;
426
+ if (token !== this._seenClearToken) {
427
+ this._seenClearToken = token;
428
+ this.clearInternal(false);
429
+ }
430
+ }
431
+ syncChunkAttr() {
432
+ const n = Math.max(0, Number(this.chunkSize) || 0);
433
+ this.chunkAttr = String(n);
434
+ this.sessionAttr = this._uploadId ? `${this._received.length}/${this._chunkTotal || 0}:${this._uploadId}` : "";
435
+ }
436
+ applyDirectoryAttr() {
437
+ this.directoryAttr = this.directory ? "true" : "false";
438
+ this.selectionMode = this.directory ? "directory" : this.multiple ? "multiple" : "single";
439
+ this.zoneHint = this.directory ? "Choose a folder or drop files here" : this.multiple ? "Choose files or drop them here" : "Choose a file or drop it here";
440
+ // Native directory picker (UA-specific).
441
+ const el = typeof document !== "undefined" ? document.getElementById(this.controlId) : null;
442
+ if (el && "webkitdirectory" in el) {
443
+ try {
444
+ el.webkitdirectory = !!this.directory;
445
+ el.multiple = !!(this.multiple || this.directory);
446
+ } catch {}
447
+ }
448
+ }
449
+ /**
450
+ * @param {FileList | File[] | null} list
451
+ */
452
+ ingestFileList(list) {
453
+ this.syncClearToken();
454
+ this.applyDirectoryAttr();
455
+ this.syncChunkAttr();
456
+ if (this.disabled) return;
457
+ const files = listToFiles(list);
458
+ if (!files.length) {
459
+ this.clearInternal(false);
460
+ this.emitValue([]);
461
+ return;
462
+ }
463
+ const maxCount = Math.max(1, Number(this.maxCount) || 1);
464
+ const capped = this.multiple || this.directory ? files.slice(0, maxCount) : files.slice(0, 1);
465
+ if ((this.multiple || this.directory) && files.length > maxCount) {
466
+ this.failAll(`At most ${maxCount} file(s)`);
467
+ return;
468
+ }
469
+ const maxBytes = Number(this.maxBytes) || 0;
470
+ for (const f of capped) {
471
+ const acceptErr = acceptError(f, this.accept);
472
+ if (acceptErr) {
473
+ this.failAll(acceptErr);
474
+ return;
475
+ }
476
+ if (maxBytes > 0 && Number(f.size) > maxBytes) {
477
+ this.failAll(`File too large (max ${maxBytes} bytes)`);
478
+ return;
479
+ }
480
+ }
481
+ const chunkSize = Math.max(0, Number(this.chunkSize) || 0);
482
+ if (chunkSize > 0 && capped.length !== 1) {
483
+ this.failAll("Resumable chunk upload supports one file per session");
484
+ return;
485
+ }
486
+ this.cancelActive(false);
487
+ this.resetSession();
488
+ this._files = capped;
489
+ this._gen += 1;
490
+ const gen = this._gen;
491
+ this._values = [];
492
+ this.queue = capped.map((f, i) => ({
493
+ key: `f-${gen}-${i}`,
494
+ name: String(f.name || `file-${i}`),
495
+ meta: formatBytes(f.size),
496
+ status: "pending",
497
+ showBar: false,
498
+ barStyle: "width:0%"
499
+ }));
500
+ this.hasItems = this.queue.length > 0;
501
+ this.emptyHint = false;
502
+ this.inputDisabled = true;
503
+ this.canResume = false;
504
+ this.showActions = false;
505
+ this.runDestination(gen);
506
+ }
507
+ /**
508
+ * @param {number} gen
509
+ */
510
+ async runDestination(gen) {
511
+ const dest = String(this.destination || "server").toLowerCase();
512
+ if (dest === "client") {
513
+ await this.runClient(gen);
514
+ return;
515
+ }
516
+ if (dest === "object") {
517
+ await this.runObject(gen);
518
+ return;
519
+ }
520
+ const chunkSize = Math.max(0, Number(this.chunkSize) || 0);
521
+ if (chunkSize > 0) {
522
+ await this.runServerResumable(gen, chunkSize);
523
+ return;
524
+ }
525
+ await this.runServer(gen);
526
+ }
527
+ /**
528
+ * @param {number} gen
529
+ */
530
+ async runClient(gen) {
531
+ if (gen !== this._gen) return;
532
+ this.setOverall("uploading", "Preparing…", true, false);
533
+ const values = [];
534
+ for (let i = 0; i < this._files.length; i++) {
535
+ if (gen !== this._gen) return;
536
+ const file = this._files[i];
537
+ const pct = Math.round((i + 1) / this._files.length * 100);
538
+ this.patchItem(i, {
539
+ status: "done",
540
+ showBar: true,
541
+ barStyle: `width:${pct}%`,
542
+ meta: `${formatBytes(file.size)} · local`
543
+ });
544
+ this.setProgress(pct);
545
+ values.push({
546
+ name: String(file.name || ""),
547
+ size: Number(file.size) || 0,
548
+ type: String(file.type || ""),
549
+ destination: "client",
550
+ id: `client-${gen}-${i}`,
551
+ bytes: Number(file.size) || 0,
552
+ /** Local File handle for in-tab tools (checksum, etc.). Not serialized. */
553
+ file
554
+ });
555
+ }
556
+ if (gen !== this._gen) return;
557
+ this._values = values;
558
+ this.finishOk(values);
559
+ }
560
+ /**
561
+ * @param {number} gen
562
+ */
563
+ async runServer(gen) {
564
+ if (gen !== this._gen) return;
565
+ const action = String(this.action || "").trim();
566
+ if (!action) {
567
+ this.failAll("Upload action URL required");
568
+ return;
569
+ }
570
+ // Sequential single-XHR for thin gate; multi-file appends all parts then one request.
571
+ this.setOverall("uploading", "Uploading…", true, true);
572
+ this.setProgress(0);
573
+ for (let i = 0; i < this.queue.length; i++) {
574
+ this.patchItem(i, {
575
+ status: "uploading",
576
+ showBar: true,
577
+ barStyle: "width:0%"
578
+ });
579
+ }
580
+ const form = new FormData();
581
+ const field = String(this.fieldName || "file");
582
+ if (this._files.length === 1) {
583
+ const file = this._files[0];
584
+ form.append(field, file, file.name);
585
+ form.append("name", file.name);
586
+ } else {
587
+ for (const file of this._files) {
588
+ form.append(field, file, file.name);
589
+ }
590
+ }
591
+ const xhr = new XMLHttpRequest();
592
+ this._xhr = xhr;
593
+ const result = await xhrSend(xhr, "POST", action, form, (pct) => {
594
+ if (gen !== this._gen) return;
595
+ this.setProgress(pct);
596
+ for (let i = 0; i < this.queue.length; i++) {
597
+ this.patchItem(i, { barStyle: `width:${pct}%` });
598
+ }
599
+ });
600
+ if (gen !== this._gen) return;
601
+ this._xhr = null;
602
+ if (result.aborted) {
603
+ this.markCancelled();
604
+ return;
605
+ }
606
+ if (result.error) {
607
+ this.failAll(result.error);
608
+ return;
609
+ }
610
+ const body = result.body;
611
+ if (!(result.status >= 200 && result.status < 300) || !body || !body.ok) {
612
+ this.failAll(body && body.error || `Upload failed (${result.status || "network"})`);
613
+ return;
614
+ }
615
+ // Single-file shape { ok, id, bytes, name } or multi { ok, items: [...] }
616
+ const values = [];
617
+ if (Array.isArray(body.items)) {
618
+ for (let i = 0; i < body.items.length; i++) {
619
+ const it = body.items[i] || {};
620
+ values.push({
621
+ name: String(it.name || this._files[i] && this._files[i].name || ""),
622
+ size: Number(it.bytes || it.size || this._files[i] && this._files[i].size || 0),
623
+ type: String(it.contentType || ""),
624
+ destination: "server",
625
+ id: String(it.id || ""),
626
+ bytes: Number(it.bytes || it.size || 0),
627
+ headHex: it.headHex ? String(it.headHex) : ""
628
+ });
629
+ }
630
+ } else {
631
+ values.push({
632
+ name: String(body.name || this._files[0] && this._files[0].name || ""),
633
+ size: Number(body.bytes || body.reportedSize || this._files[0] && this._files[0].size || 0),
634
+ type: String(body.contentType || ""),
635
+ destination: "server",
636
+ id: String(body.id || ""),
637
+ bytes: Number(body.bytes || 0),
638
+ headHex: body.headHex ? String(body.headHex) : ""
639
+ });
640
+ }
641
+ for (let i = 0; i < this.queue.length; i++) {
642
+ this.patchItem(i, {
643
+ status: "done",
644
+ showBar: true,
645
+ barStyle: "width:100%",
646
+ meta: `${formatBytes(values[i] && values[i].bytes)} · uploaded`
647
+ });
648
+ }
649
+ this._values = values;
650
+ this.finishOk(values);
651
+ }
652
+ /**
653
+ * Chunked server upload: POST init → PUT chunk(s) → POST complete.
654
+ * Cancel → paused (keeps uploadId + received); Resume continues missing indexes.
655
+ * @param {number} gen
656
+ * @param {number} chunkSize
657
+ */
658
+ async runServerResumable(gen, chunkSize) {
659
+ if (gen !== this._gen) return;
660
+ const base = String(this.action || "").trim().replace(/\/+$/, "");
661
+ if (!base) {
662
+ this.failAll("Upload action URL required");
663
+ return;
664
+ }
665
+ const file = this._files[0];
666
+ if (!file) {
667
+ this.failAll("No file for resumable upload");
668
+ return;
669
+ }
670
+ this._activeChunkSize = chunkSize;
671
+ this.setOverall("uploading", this._uploadId ? "Resuming…" : "Uploading…", true, true);
672
+ this.canResume = false;
673
+ this.showActions = true;
674
+ this.patchItem(0, {
675
+ status: "uploading",
676
+ showBar: true,
677
+ barStyle: "width:0%"
678
+ });
679
+ if (!this._uploadId) {
680
+ const initXhr = new XMLHttpRequest();
681
+ this._xhr = initXhr;
682
+ const initResult = await xhrSendJson(initXhr, "POST", `${base}/init`, {
683
+ name: String(file.name || ""),
684
+ size: Number(file.size) || 0,
685
+ type: String(file.type || ""),
686
+ chunkSize
687
+ });
688
+ if (gen !== this._gen) return;
689
+ this._xhr = null;
690
+ if (initResult.aborted) {
691
+ this.markPaused();
692
+ return;
693
+ }
694
+ if (initResult.error || !(initResult.status >= 200 && initResult.status < 300) || !initResult.body || !initResult.body.ok) {
695
+ this.failAll(initResult.body && initResult.body.error || initResult.error || `Init failed (${initResult.status || "network"})`);
696
+ return;
697
+ }
698
+ this._uploadId = String(initResult.body.uploadId || "");
699
+ this._chunkTotal = Number(initResult.body.chunkTotal) || Math.ceil((Number(file.size) || 0) / chunkSize) || 1;
700
+ this._received = [];
701
+ this.syncChunkAttr();
702
+ if (!this._uploadId) {
703
+ this.failAll("Init returned no uploadId");
704
+ return;
705
+ }
706
+ } else {
707
+ const statusXhr = new XMLHttpRequest();
708
+ this._xhr = statusXhr;
709
+ const statusResult = await xhrSendJson(statusXhr, "POST", `${base}/status`, { uploadId: this._uploadId });
710
+ if (gen !== this._gen) return;
711
+ this._xhr = null;
712
+ if (statusResult.aborted) {
713
+ this.markPaused();
714
+ return;
715
+ }
716
+ if (statusResult.error || !(statusResult.status >= 200 && statusResult.status < 300) || !statusResult.body || !statusResult.body.ok) {
717
+ this.failAll(statusResult.body && statusResult.body.error || statusResult.error || `Status failed (${statusResult.status || "network"})`);
718
+ return;
719
+ }
720
+ this._received = Array.isArray(statusResult.body.received) ? statusResult.body.received.map((n) => Number(n)).filter((n) => Number.isFinite(n)) : [];
721
+ this._chunkTotal = Number(statusResult.body.chunkTotal) || this._chunkTotal;
722
+ this.syncChunkAttr();
723
+ }
724
+ const total = Math.max(1, this._chunkTotal || Math.ceil((Number(file.size) || 0) / chunkSize) || 1);
725
+ this._chunkTotal = total;
726
+ const receivedSet = new Set(this._received);
727
+ this.setProgress(Math.round(receivedSet.size / total * 100));
728
+ this.patchItem(0, {
729
+ barStyle: `width:${Math.round(receivedSet.size / total * 100)}%`,
730
+ meta: `${receivedSet.size}/${total} chunks`
731
+ });
732
+ for (let index = 0; index < total; index++) {
733
+ if (gen !== this._gen) return;
734
+ if (receivedSet.has(index)) continue;
735
+ const start = index * chunkSize;
736
+ const end = Math.min(Number(file.size) || 0, start + chunkSize);
737
+ const blob = file.slice(start, end);
738
+ const xhr = new XMLHttpRequest();
739
+ this._xhr = xhr;
740
+ const result = await xhrSend(xhr, "PUT", `${base}/chunk`, blob, (pct) => {
741
+ if (gen !== this._gen) return;
742
+ const overall = Math.round((receivedSet.size + pct / 100) / total * 100);
743
+ this.setProgress(overall);
744
+ this.patchItem(0, { barStyle: `width:${overall}%` });
745
+ }, {
746
+ "content-type": "application/octet-stream",
747
+ "x-vmz-upload-id": this._uploadId,
748
+ "x-vmz-chunk-index": String(index),
749
+ "x-vmz-chunk-total": String(total)
750
+ });
751
+ if (gen !== this._gen) return;
752
+ this._xhr = null;
753
+ if (result.aborted) {
754
+ this.markPaused();
755
+ return;
756
+ }
757
+ if (result.error || !(result.status >= 200 && result.status < 300) || !result.body || !result.body.ok) {
758
+ this.failAll(result.body && result.body.error || result.error || `Chunk ${index} failed (${result.status || "network"})`);
759
+ return;
760
+ }
761
+ this._received = Array.isArray(result.body.received) ? result.body.received.map((n) => Number(n)).filter((n) => Number.isFinite(n)) : this._received.concat([index]);
762
+ receivedSet.add(index);
763
+ for (const n of this._received) receivedSet.add(n);
764
+ this.syncChunkAttr();
765
+ const pct = Math.round(receivedSet.size / total * 100);
766
+ this.setProgress(pct);
767
+ this.patchItem(0, {
768
+ barStyle: `width:${pct}%`,
769
+ meta: `${receivedSet.size}/${total} chunks`
770
+ });
771
+ }
772
+ if (gen !== this._gen) return;
773
+ const completeXhr = new XMLHttpRequest();
774
+ this._xhr = completeXhr;
775
+ const completeResult = await xhrSendJson(completeXhr, "POST", `${base}/complete`, { uploadId: this._uploadId });
776
+ if (gen !== this._gen) return;
777
+ this._xhr = null;
778
+ if (completeResult.aborted) {
779
+ this.markPaused();
780
+ return;
781
+ }
782
+ if (completeResult.error || !(completeResult.status >= 200 && completeResult.status < 300) || !completeResult.body || !completeResult.body.ok) {
783
+ this.failAll(completeResult.body && completeResult.body.error || completeResult.error || `Complete failed (${completeResult.status || "network"})`);
784
+ return;
785
+ }
786
+ const body = completeResult.body;
787
+ const values = [{
788
+ name: String(body.name || file.name || ""),
789
+ size: Number(body.bytes || body.size || file.size || 0),
790
+ type: String(body.contentType || file.type || ""),
791
+ destination: "server",
792
+ id: String(body.id || ""),
793
+ bytes: Number(body.bytes || body.size || 0),
794
+ headHex: body.headHex ? String(body.headHex) : "",
795
+ uploadId: this._uploadId,
796
+ chunks: total
797
+ }];
798
+ this.patchItem(0, {
799
+ status: "done",
800
+ showBar: true,
801
+ barStyle: "width:100%",
802
+ meta: `${formatBytes(values[0].bytes)} · ${total} chunks`
803
+ });
804
+ this.resetSession();
805
+ this._values = values;
806
+ this.finishOk(values);
807
+ }
808
+ /**
809
+ * @param {number} gen
810
+ */
811
+ async runObject(gen) {
812
+ if (gen !== this._gen) return;
813
+ if (typeof this.onPresign !== "function") {
814
+ this.failAll("onPresign callback required for object destination");
815
+ return;
816
+ }
817
+ this.setOverall("uploading", "Uploading…", true, true);
818
+ const values = [];
819
+ for (let i = 0; i < this._files.length; i++) {
820
+ if (gen !== this._gen) return;
821
+ const file = this._files[i];
822
+ this.patchItem(i, {
823
+ status: "uploading",
824
+ showBar: true,
825
+ barStyle: "width:0%"
826
+ });
827
+ let signed;
828
+ try {
829
+ signed = await this.onPresign(file);
830
+ } catch (err) {
831
+ this.failAll(err instanceof Error ? err.message : String(err || "presign failed"));
832
+ return;
833
+ }
834
+ if (gen !== this._gen) return;
835
+ const url = signed && signed.uploadUrl ? String(signed.uploadUrl) : "";
836
+ if (!url) {
837
+ this.failAll("presign returned no uploadUrl");
838
+ return;
839
+ }
840
+ const xhr = new XMLHttpRequest();
841
+ this._xhr = xhr;
842
+ const headers = signed && signed.headers || {};
843
+ const result = await xhrSend(xhr, "PUT", url, file, (pct) => {
844
+ if (gen !== this._gen) return;
845
+ const overall = Math.round((i + pct / 100) / this._files.length * 100);
846
+ this.setProgress(overall);
847
+ this.patchItem(i, { barStyle: `width:${pct}%` });
848
+ }, headers);
849
+ if (gen !== this._gen) return;
850
+ this._xhr = null;
851
+ if (result.aborted) {
852
+ this.markCancelled();
853
+ return;
854
+ }
855
+ if (result.error || !(result.status >= 200 && result.status < 300)) {
856
+ this.failAll(result.error || `Object upload failed (${result.status || "network"})`);
857
+ return;
858
+ }
859
+ const objectKey = String(signed && signed.objectKey || file.name || "");
860
+ values.push({
861
+ name: String(file.name || ""),
862
+ size: Number(file.size) || 0,
863
+ type: String(file.type || ""),
864
+ destination: "object",
865
+ id: objectKey,
866
+ bytes: Number(file.size) || 0,
867
+ objectKey
868
+ });
869
+ this.patchItem(i, {
870
+ status: "done",
871
+ showBar: true,
872
+ barStyle: "width:100%",
873
+ meta: `${formatBytes(file.size)} · object`
874
+ });
875
+ }
876
+ this._values = values;
877
+ this.finishOk(values);
878
+ }
879
+ /**
880
+ * @param {UploadValueItem[]} values
881
+ */
882
+ finishOk(values) {
883
+ this.setProgress(100);
884
+ this.status = "done";
885
+ this.statusText = "Upload complete";
886
+ this.showProgress = true;
887
+ this.canCancel = false;
888
+ this.canResume = false;
889
+ this.showActions = false;
890
+ this.inputDisabled = false;
891
+ const first = values[0];
892
+ if (values.length === 1 && first) {
893
+ const prefix = first.destination === "client" ? "client" : first.destination === "object" ? "object" : "attachment";
894
+ const head = first.headHex ? ` head:${first.headHex}` : "";
895
+ const chunkNote = first.chunks ? ` chunks:${first.chunks}` : "";
896
+ this.resultText = `${prefix}:${first.id} (${first.bytes || 0} bytes)${head}${chunkNote}`;
897
+ } else {
898
+ this.resultText = `${values.length} file(s) ready`;
899
+ }
900
+ this.emitStatus();
901
+ this.emitValue(values);
902
+ }
903
+ /**
904
+ * @param {string} message
905
+ */
906
+ failAll(message) {
907
+ this.cancelActive(false);
908
+ this.resetSession();
909
+ this.status = "error";
910
+ this.statusText = message || "Upload failed";
911
+ this.showProgress = false;
912
+ this.canCancel = false;
913
+ this.canResume = false;
914
+ this.showActions = false;
915
+ this.inputDisabled = false;
916
+ this.resultText = "";
917
+ this._values = [];
918
+ for (let i = 0; i < this.queue.length; i++) {
919
+ this.patchItem(i, {
920
+ status: "error",
921
+ showBar: false
922
+ });
923
+ }
924
+ this.emitStatus();
925
+ this.emitValue([]);
926
+ }
927
+ markCancelled() {
928
+ this.status = "cancelled";
929
+ this.statusText = "Upload cancelled";
930
+ this.showProgress = false;
931
+ this.canCancel = false;
932
+ this.canResume = false;
933
+ this.showActions = false;
934
+ this.inputDisabled = false;
935
+ this.resultText = "";
936
+ this._values = [];
937
+ this.resetSession();
938
+ this.emitStatus();
939
+ this.emitValue([]);
940
+ }
941
+ markPaused() {
942
+ this.status = "paused";
943
+ this.statusText = "Upload paused — resume to continue";
944
+ this.showProgress = true;
945
+ this.canCancel = false;
946
+ this.canResume = !!this._uploadId;
947
+ this.showActions = this.canResume;
948
+ this.inputDisabled = false;
949
+ this.resultText = "";
950
+ this._values = [];
951
+ this.syncChunkAttr();
952
+ this.patchItem(0, {
953
+ status: "paused",
954
+ meta: `${this._received.length}/${this._chunkTotal || 0} chunks`
955
+ });
956
+ this.emitStatus();
957
+ this.emitValue([]);
958
+ }
959
+ resetSession() {
960
+ this._uploadId = "";
961
+ this._chunkTotal = 0;
962
+ this._received = [];
963
+ this._activeChunkSize = 0;
964
+ this.syncChunkAttr();
965
+ }
966
+ /**
967
+ * @param {boolean} mark
968
+ */
969
+ cancelActive(mark) {
970
+ const xhr = this._xhr;
971
+ this._xhr = null;
972
+ this._gen += 1;
973
+ if (xhr) {
974
+ try {
975
+ xhr.abort();
976
+ } catch {}
977
+ }
978
+ if (mark && this.status === "uploading") {
979
+ if (this._uploadId && this._activeChunkSize > 0) {
980
+ this.markPaused();
981
+ } else {
982
+ this.markCancelled();
983
+ }
984
+ }
985
+ }
986
+ /**
987
+ * @param {boolean} emit
988
+ */
989
+ clearInternal(emit) {
990
+ this.cancelActive(false);
991
+ this.resetSession();
992
+ this._files = [];
993
+ this._values = [];
994
+ this.queue = [];
995
+ this.hasItems = false;
996
+ this.emptyHint = true;
997
+ this.status = "idle";
998
+ this.statusText = "";
999
+ this.resultText = "";
1000
+ this.showProgress = false;
1001
+ this.canCancel = false;
1002
+ this.canResume = false;
1003
+ this.showActions = false;
1004
+ this.inputDisabled = !!this.disabled;
1005
+ this.setProgress(0);
1006
+ this.dragOver = false;
1007
+ this.applyDirectoryAttr();
1008
+ this.emitStatus();
1009
+ if (emit) this.emitValue([]);
1010
+ }
1011
+ /**
1012
+ * @param {string} status
1013
+ * @param {string} text
1014
+ * @param {boolean} showProgress
1015
+ * @param {boolean} canCancel
1016
+ */
1017
+ setOverall(status, text, showProgress, canCancel) {
1018
+ this.status = status;
1019
+ this.statusText = text;
1020
+ this.showProgress = showProgress;
1021
+ this.canCancel = canCancel;
1022
+ this.canResume = false;
1023
+ this.showActions = canCancel;
1024
+ this.inputDisabled = status === "uploading" || !!this.disabled;
1025
+ this.emitStatus();
1026
+ }
1027
+ /**
1028
+ * @param {number} pct
1029
+ */
1030
+ setProgress(pct) {
1031
+ const n = Math.max(0, Math.min(100, Number(pct) || 0));
1032
+ this.progress = String(n);
1033
+ this.progressLabel = `${n}%`;
1034
+ this.progressStyle = `width:${n}%`;
1035
+ }
1036
+ /**
1037
+ * @param {number} index
1038
+ * @param {Partial<{ status: string, meta: string, showBar: boolean, barStyle: string }>} patch
1039
+ */
1040
+ patchItem(index, patch) {
1041
+ const cur = this.queue[index];
1042
+ if (!cur) return;
1043
+ const next = Object.assign({}, cur, patch);
1044
+ const copy = this.queue.slice();
1045
+ copy[index] = next;
1046
+ this.queue = copy;
1047
+ }
1048
+ /**
1049
+ * @param {UploadValueItem[]} items
1050
+ */
1051
+ emitValue(items) {
1052
+ if (typeof this.onValue === "function") this.onValue(items);
1053
+ }
1054
+ emitStatus() {
1055
+ if (typeof this.onStatus === "function") this.onStatus(this.status);
1056
+ }
1057
+ }
1058
+ /**
1059
+ * @typedef {{
1060
+ * name: string,
1061
+ * size: number,
1062
+ * type: string,
1063
+ * destination: string,
1064
+ * id: string,
1065
+ * bytes: number,
1066
+ * objectKey?: string,
1067
+ * headHex?: string,
1068
+ * uploadId?: string,
1069
+ * chunks?: number
1070
+ * }} UploadValueItem
1071
+ */
1072
+ /**
1073
+ * @param {FileList | File[] | null | undefined} list
1074
+ * @returns {File[]}
1075
+ */
1076
+ function listToFiles(list) {
1077
+ if (!list) return [];
1078
+ if (Array.isArray(list)) return list.filter(Boolean);
1079
+ const out = [];
1080
+ for (let i = 0; i < list.length; i++) out.push(list[i]);
1081
+ return out;
1082
+ }
1083
+ /**
1084
+ * @param {File} file
1085
+ * @param {string} accept
1086
+ */
1087
+ function acceptError(file, accept) {
1088
+ const raw = String(accept || "").trim();
1089
+ if (!raw) return "";
1090
+ const name = String(file.name || "").toLowerCase();
1091
+ const type = String(file.type || "").toLowerCase();
1092
+ const parts = raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
1093
+ for (const p of parts) {
1094
+ if (p.startsWith(".")) {
1095
+ if (name.endsWith(p)) return "";
1096
+ } else if (p.endsWith("/*")) {
1097
+ const prefix = p.slice(0, -1);
1098
+ if (type.startsWith(prefix)) return "";
1099
+ } else if (p === type) {
1100
+ return "";
1101
+ }
1102
+ }
1103
+ return `File type not allowed (${raw})`;
1104
+ }
1105
+ /**
1106
+ * @param {number} n
1107
+ */
1108
+ function formatBytes(n) {
1109
+ const v = Number(n) || 0;
1110
+ if (v < 1024) return `${v} B`;
1111
+ if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
1112
+ return `${(v / (1024 * 1024)).toFixed(1)} MB`;
1113
+ }
1114
+ /**
1115
+ * @param {XMLHttpRequest} xhr
1116
+ * @param {string} method
1117
+ * @param {string} url
1118
+ * @param {FormData | Blob | File} body
1119
+ * @param {(pct: number) => void} onProgress
1120
+ * @param {Record<string, string>} [headers]
1121
+ */
1122
+ function xhrSend(xhr, method, url, body, onProgress, headers) {
1123
+ return new Promise((resolve) => {
1124
+ xhr.open(method, url);
1125
+ if (headers) {
1126
+ for (const k of Object.keys(headers)) {
1127
+ try {
1128
+ xhr.setRequestHeader(k, headers[k]);
1129
+ } catch {}
1130
+ }
1131
+ }
1132
+ xhr.upload.onprogress = (ev) => {
1133
+ if (!ev || !ev.lengthComputable || !ev.total) return;
1134
+ onProgress(Math.round(100 * ev.loaded / ev.total));
1135
+ };
1136
+ xhr.onabort = () => resolve({
1137
+ aborted: true,
1138
+ status: 0,
1139
+ body: null,
1140
+ error: ""
1141
+ });
1142
+ xhr.onerror = () => resolve({
1143
+ aborted: false,
1144
+ status: 0,
1145
+ body: null,
1146
+ error: "Network upload failed"
1147
+ });
1148
+ xhr.onload = () => {
1149
+ let parsed = null;
1150
+ const text = String(xhr.responseText || "");
1151
+ if (text) {
1152
+ try {
1153
+ parsed = JSON.parse(text);
1154
+ } catch {
1155
+ parsed = null;
1156
+ }
1157
+ }
1158
+ resolve({
1159
+ aborted: false,
1160
+ status: xhr.status,
1161
+ body: parsed,
1162
+ error: ""
1163
+ });
1164
+ };
1165
+ xhr.send(body);
1166
+ });
1167
+ }
1168
+ /**
1169
+ * @param {XMLHttpRequest} xhr
1170
+ * @param {string} method
1171
+ * @param {string} url
1172
+ * @param {Record<string, unknown>} payload
1173
+ */
1174
+ function xhrSendJson(xhr, method, url, payload) {
1175
+ return new Promise((resolve) => {
1176
+ xhr.open(method, url);
1177
+ try {
1178
+ xhr.setRequestHeader("content-type", "application/json");
1179
+ } catch {}
1180
+ xhr.onabort = () => resolve({
1181
+ aborted: true,
1182
+ status: 0,
1183
+ body: null,
1184
+ error: ""
1185
+ });
1186
+ xhr.onerror = () => resolve({
1187
+ aborted: false,
1188
+ status: 0,
1189
+ body: null,
1190
+ error: "Network upload failed"
1191
+ });
1192
+ xhr.onload = () => {
1193
+ let parsed = null;
1194
+ const text = String(xhr.responseText || "");
1195
+ if (text) {
1196
+ try {
1197
+ parsed = JSON.parse(text);
1198
+ } catch {
1199
+ parsed = null;
1200
+ }
1201
+ }
1202
+ resolve({
1203
+ aborted: false,
1204
+ status: xhr.status,
1205
+ body: parsed,
1206
+ error: ""
1207
+ });
1208
+ };
1209
+ xhr.send(JSON.stringify(payload || {}));
1210
+ });
101
1211
  }
102
1212
  </script>