@vmz/ui 0.1.0 → 0.1.2

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 +8 -0
  2. package/contracts/token-requirements.v0.json +2 -6
  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 +90 -75
  14. package/src/components/Callout.vmz +61 -61
  15. package/src/components/Card.vmz +58 -51
  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 +613 -536
  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 -290
  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 +1130 -1151
@@ -1,1233 +1,1212 @@
1
1
  <template>
2
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>
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>
56
72
  </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>
73
73
  </template>
74
74
 
75
75
  <style>
76
- .vmz-ui-upload {
77
- display: flex;
78
- flex-direction: column;
79
- gap: 0.35rem;
80
- max-width: 28rem;
81
- }
82
-
83
- .vmz-ui-upload__label {
84
- font: inherit;
85
- font-weight: 600;
86
- }
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
-
101
- .vmz-ui-upload__control {
102
- font: inherit;
103
- width: 100%;
104
- padding: 0.15rem 0;
105
- color: inherit;
106
- outline: none;
107
- }
108
-
109
- .vmz-ui-upload__control:focus-visible {
110
- box-shadow: 0 0 0 2px var(--vmz-focus-ring);
111
- }
112
-
113
- .vmz-ui-upload__control:disabled {
114
- opacity: 0.55;
115
- cursor: not-allowed;
116
- }
117
-
118
- .vmz-ui-upload__hint {
119
- margin: 0.35rem 0 0;
120
- font-size: 0.88rem;
121
- color: var(--vmz-text-steel);
122
- }
123
-
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
- }
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 {
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: 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);
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: 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
-
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
- }
267
- </style>
268
-
269
- <script client>
270
- /**
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
- */
280
- export default class Upload {
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<{ 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
- }
76
+ .vmz-ui-upload {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 0.35rem;
80
+ max-width: 28rem;
365
81
  }
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
82
 
380
- onMount() {
381
- this.applyDirectoryAttr();
382
- this.syncChunkAttr();
83
+ .vmz-ui-upload__label {
84
+ font: inherit;
85
+ font-weight: 600;
383
86
  }
384
87
 
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 = '';
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);
396
94
  }
397
95
 
398
- onDragEnter(ev) {
399
- if (this.inputDisabled || this.disabled) return;
400
- if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
401
- this.dragOver = true;
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));
402
99
  }
403
100
 
404
- onDragOver(ev) {
405
- if (this.inputDisabled || this.disabled) return;
406
- if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
407
- this.dragOver = true;
101
+ .vmz-ui-upload__control {
102
+ font: inherit;
103
+ width: 100%;
104
+ padding: 0.15rem 0;
105
+ color: inherit;
106
+ outline: none;
408
107
  }
409
108
 
410
- onDragLeave() {
411
- this.dragOver = false;
109
+ .vmz-ui-upload__control:focus-visible {
110
+ box-shadow: 0 0 0 2px var(--vmz-focus-ring);
412
111
  }
413
112
 
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);
113
+ .vmz-ui-upload__control:disabled {
114
+ opacity: 0.55;
115
+ cursor: not-allowed;
420
116
  }
421
117
 
422
- onCancelClick() {
423
- this.cancelActive(true);
118
+ .vmz-ui-upload__hint {
119
+ margin: 0.35rem 0 0;
120
+ font-size: 0.88rem;
121
+ color: var(--vmz-text-steel);
424
122
  }
425
123
 
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);
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;
431
131
  }
432
132
 
433
- syncClearToken() {
434
- const token = Number(this.clearToken) || 0;
435
- if (token !== this._seenClearToken) {
436
- this._seenClearToken = token;
437
- this.clearInternal(false);
438
- }
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);
439
141
  }
440
142
 
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
- : '';
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;
447
150
  }
448
151
 
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
- }
152
+ .vmz-ui-upload__item-meta {
153
+ font-size: 0.8rem;
154
+ color: var(--vmz-text-steel);
155
+ white-space: nowrap;
467
156
  }
468
157
 
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);
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);
530
165
  }
531
166
 
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);
167
+ .vmz-ui-upload[data-invalid='true'] .vmz-ui-upload__label {
168
+ color: var(--vmz-status-danger-foreground);
551
169
  }
552
170
 
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);
171
+ .vmz-ui-upload__empty {
172
+ margin: 0;
173
+ color: var(--vmz-text-steel);
174
+ font-size: 0.9em;
578
175
  }
579
176
 
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);
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;
667
183
  }
668
184
 
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);
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);
845
191
  }
846
192
 
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);
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;
908
198
  }
909
199
 
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);
200
+ .vmz-ui-upload__status {
201
+ margin: 0;
202
+ font-size: 0.9em;
203
+ color: var(--vmz-text-steel);
933
204
  }
934
205
 
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([]);
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);
955
209
  }
956
210
 
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([]);
211
+ .vmz-ui-upload[data-vmz-upload-status='error'] .vmz-ui-upload__status {
212
+ color: var(--vmz-status-danger-foreground);
970
213
  }
971
214
 
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([]);
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);
989
220
  }
990
221
 
991
- resetSession() {
992
- this._uploadId = '';
993
- this._chunkTotal = 0;
994
- this._received = [];
995
- this._activeChunkSize = 0;
996
- this.syncChunkAttr();
222
+ .vmz-ui-upload__actions {
223
+ display: flex;
224
+ gap: 0.5rem;
997
225
  }
998
226
 
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
- }
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;
1020
237
  }
1021
238
 
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([]);
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);
1046
242
  }
1047
243
 
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();
244
+ .vmz-ui-upload__resume {
245
+ border-color: var(--vmz-action-primary-background);
246
+ color: var(--vmz-action-primary-background);
1063
247
  }
1064
248
 
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}%`;
249
+ .vmz-ui-upload__description {
250
+ margin: 0;
251
+ opacity: 0.75;
252
+ font-size: 0.9em;
1073
253
  }
1074
254
 
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;
255
+ .vmz-ui-upload__error {
256
+ margin: 0;
257
+ color: var(--vmz-status-danger-foreground);
258
+ font-size: 0.9em;
1086
259
  }
1087
260
 
1088
- /**
1089
- * @param {UploadValueItem[]} items
1090
- */
1091
- emitValue(items) {
1092
- if (typeof this.onValue === 'function') this.onValue(items);
261
+ @media (prefers-reduced-motion: reduce) {
262
+ .vmz-ui-upload__bar,
263
+ .vmz-ui-upload__item-bar {
264
+ transition: none;
265
+ }
1093
266
  }
267
+ </style>
1094
268
 
1095
- emitStatus() {
1096
- if (typeof this.onStatus === 'function') this.onStatus(this.status);
1097
- }
269
+ <script client>
270
+ /**
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
+ */
280
+ export default class Upload {
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
+ }
1098
1057
  }
1099
-
1100
1058
  /**
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
-
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
+ */
1115
1072
  /**
1116
- * @param {FileList | File[] | null | undefined} list
1117
- * @returns {File[]}
1118
- */
1073
+ * @param {FileList | File[] | null | undefined} list
1074
+ * @returns {File[]}
1075
+ */
1119
1076
  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;
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;
1125
1082
  }
1126
-
1127
1083
  /**
1128
- * @param {File} file
1129
- * @param {string} accept
1130
- */
1084
+ * @param {File} file
1085
+ * @param {string} accept
1086
+ */
1131
1087
  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})`;
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})`;
1148
1104
  }
1149
-
1150
1105
  /**
1151
- * @param {number} n
1152
- */
1106
+ * @param {number} n
1107
+ */
1153
1108
  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`;
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`;
1158
1113
  }
1159
-
1160
1114
  /**
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
- */
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
+ */
1168
1122
  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
- });
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
+ });
1200
1167
  }
1201
-
1202
1168
  /**
1203
- * @param {XMLHttpRequest} xhr
1204
- * @param {string} method
1205
- * @param {string} url
1206
- * @param {Record<string, unknown>} payload
1207
- */
1169
+ * @param {XMLHttpRequest} xhr
1170
+ * @param {string} method
1171
+ * @param {string} url
1172
+ * @param {Record<string, unknown>} payload
1173
+ */
1208
1174
  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
- });
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
+ });
1232
1211
  }
1233
1212
  </script>