@vmz/ui 0.2.3 → 0.2.5

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