@hakumi-dev/hakumi-components 0.1.17-pre → 0.1.19-pre

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 (56) hide show
  1. package/README.md +218 -369
  2. package/app/javascript/hakumi_components/controllers/hakumi/admin_panel_controller.js +5 -7
  3. package/app/javascript/hakumi_components/controllers/hakumi/back_top_controller.js +1 -1
  4. package/app/javascript/hakumi_components/controllers/hakumi/button_controller.js +108 -2
  5. package/app/javascript/hakumi_components/controllers/hakumi/calendar_controller.js +183 -95
  6. package/app/javascript/hakumi_components/controllers/hakumi/color_picker_controller.js +23 -285
  7. package/app/javascript/hakumi_components/controllers/hakumi/date_picker_controller.js +274 -262
  8. package/app/javascript/hakumi_components/controllers/hakumi/float_button_group_controller.js +2 -2
  9. package/app/javascript/hakumi_components/controllers/hakumi/message_controller.js +4 -2
  10. package/app/javascript/hakumi_components/controllers/hakumi/modal_controller.js +119 -125
  11. package/app/javascript/hakumi_components/controllers/hakumi/table/editable.js +291 -0
  12. package/app/javascript/hakumi_components/controllers/hakumi/table_controller.js +166 -366
  13. package/app/javascript/hakumi_components/controllers/hakumi/tabs_controller.js +8 -2
  14. package/app/javascript/hakumi_components/controllers/hakumi/tag_controller.js +27 -25
  15. package/app/javascript/hakumi_components/controllers/hakumi/tag_group_controller.js +19 -18
  16. package/app/javascript/hakumi_components/controllers/hakumi/theme_controller.js +116 -117
  17. package/app/javascript/hakumi_components/controllers/hakumi/transfer_controller.js +17 -1
  18. package/app/javascript/hakumi_components/controllers/hakumi/tree_controller.js +363 -78
  19. package/app/javascript/hakumi_components/controllers/hakumi/typography_controller.js +3 -3
  20. package/app/javascript/hakumi_components/controllers/hakumi/upload_controller.js +320 -204
  21. package/app/javascript/hakumi_components/core/render_component.js +37 -11
  22. package/app/javascript/hakumi_components/utils/color_helper.js +262 -0
  23. package/app/javascript/stylesheets/_base.scss +9 -0
  24. package/app/javascript/stylesheets/_hakumi_components.scss +1 -0
  25. package/app/javascript/stylesheets/components/_breadcrumb.scss +2 -2
  26. package/app/javascript/stylesheets/components/_calendar.scss +13 -13
  27. package/app/javascript/stylesheets/components/_cascader.scss +5 -5
  28. package/app/javascript/stylesheets/components/_checkbox.scss +9 -11
  29. package/app/javascript/stylesheets/components/_color_picker.scss +11 -11
  30. package/app/javascript/stylesheets/components/_date_picker.scss +4 -4
  31. package/app/javascript/stylesheets/components/_descriptions.scss +2 -2
  32. package/app/javascript/stylesheets/components/_drawer.scss +3 -3
  33. package/app/javascript/stylesheets/components/_dropdown.scss +2 -2
  34. package/app/javascript/stylesheets/components/_flex.scss +1 -1
  35. package/app/javascript/stylesheets/components/_float_button.scss +5 -5
  36. package/app/javascript/stylesheets/components/_form_item.scss +92 -0
  37. package/app/javascript/stylesheets/components/_image.scss +15 -15
  38. package/app/javascript/stylesheets/components/_input.scss +23 -113
  39. package/app/javascript/stylesheets/components/_layout.scss +27 -26
  40. package/app/javascript/stylesheets/components/_menu.scss +15 -15
  41. package/app/javascript/stylesheets/components/_modal.scss +13 -13
  42. package/app/javascript/stylesheets/components/_notification.scss +3 -3
  43. package/app/javascript/stylesheets/components/_popover.scss +1 -1
  44. package/app/javascript/stylesheets/components/_segmented.scss +3 -3
  45. package/app/javascript/stylesheets/components/_select.scss +6 -6
  46. package/app/javascript/stylesheets/components/_slider.scss +1 -1
  47. package/app/javascript/stylesheets/components/_spin.scss +2 -2
  48. package/app/javascript/stylesheets/components/_steps.scss +10 -10
  49. package/app/javascript/stylesheets/components/_switch.scss +11 -10
  50. package/app/javascript/stylesheets/components/_table.scss +6 -6
  51. package/app/javascript/stylesheets/components/_tag.scss +2 -2
  52. package/app/javascript/stylesheets/components/_tooltip.scss +4 -4
  53. package/app/javascript/stylesheets/components/_tree_select.scss +3 -3
  54. package/app/javascript/stylesheets/components/_typography.scss +3 -3
  55. package/app/javascript/stylesheets/components/_upload.scss +4 -4
  56. package/package.json +2 -2
@@ -5,6 +5,78 @@ const ALLOWED_URL_PROTOCOLS = ["http:", "https:", "data:"]
5
5
 
6
6
 
7
7
  const VALID_STATUSES = ["ready", "uploading", "done", "error", "aborted"]
8
+ const BLOCKED_UPLOAD_HEADERS = ["cookie", "host", "origin", "referer"]
9
+ const IMAGE_DATA_URL_PREFIX = "data:image/"
10
+ const MAX_IMAGE_DATA_URL_LENGTH = 1024 * 100
11
+
12
+ const generateUploadUid = () => `upload_${Date.now()}_${Math.random().toString(16).slice(2)}`
13
+
14
+ const sanitizeUploadFileName = (name) => {
15
+ if (typeof name !== "string") return "file"
16
+
17
+ return name
18
+ .replace(/[<>:"\/\\|?*\x00-\x1f]/g, "_")
19
+ .slice(0, 255)
20
+ }
21
+
22
+ const sanitizeUploadUrl = (url) => {
23
+ if (!url || typeof url !== "string") return null
24
+
25
+ try {
26
+ if (url.startsWith("data:")) {
27
+ if (url.startsWith(IMAGE_DATA_URL_PREFIX)) {
28
+ return url.slice(0, MAX_IMAGE_DATA_URL_LENGTH)
29
+ }
30
+ return null
31
+ }
32
+
33
+ const parsed = new URL(url)
34
+ if (ALLOWED_URL_PROTOCOLS.includes(parsed.protocol)) {
35
+ return parsed.href
36
+ }
37
+ } catch {
38
+ }
39
+
40
+ return null
41
+ }
42
+
43
+ const normalizeUploadStatus = (status) => {
44
+ if (typeof status !== "string") return null
45
+ const normalized = status.toLowerCase()
46
+ return VALID_STATUSES.includes(normalized) ? normalized : null
47
+ }
48
+
49
+ const clampUploadPercent = (percent) => {
50
+ const num = Number(percent)
51
+ if (isNaN(num)) return 0
52
+ return Math.max(0, Math.min(100, Math.round(num)))
53
+ }
54
+
55
+ const isBlockedUploadHeader = (header) => BLOCKED_UPLOAD_HEADERS.includes(header.toLowerCase())
56
+
57
+ const normalizeAcceptTypes = (accept) => accept.split(",").map((type) => type.trim().toLowerCase())
58
+
59
+ const normalizeFileTypeMatchInput = (file) => ({
60
+ name: file.name.toLowerCase(),
61
+ type: file.type.toLowerCase()
62
+ })
63
+
64
+ const isPreviewRecord = (record) => record.status === "done" && record.url
65
+
66
+ const clearUploadXhr = (record, xhr) => {
67
+ if (record.xhr === xhr) delete record.xhr
68
+ }
69
+
70
+ const releaseUploadRecordResources = (record) => {
71
+ if (record.xhr) record.xhr.abort()
72
+ if (record.blobUrl && record.url) URL.revokeObjectURL(record.url)
73
+ }
74
+
75
+ const normalizePreviewItem = (record) => ({
76
+ src: record.url,
77
+ alt: record.name,
78
+ uid: record.uid
79
+ })
8
80
 
9
81
  export default class extends RegistryController {
10
82
  static targets = ["input", "list", "dropzone", "inlineTrigger", "avatar", "avatarWrapper"]
@@ -28,11 +100,18 @@ export default class extends RegistryController {
28
100
  inlineTrigger: { type: Boolean, default: false },
29
101
  avatarShape: String,
30
102
  avatarSize: Number,
31
- avatarIcon: String
103
+ avatarIcon: String,
104
+ // i18n
105
+ previewLabel: { type: String, default: "Preview" },
106
+ removeLabel: { type: String, default: "Remove" },
107
+ closeLabel: { type: String, default: "Close" },
108
+ previousLabel: { type: String, default: "Previous" },
109
+ nextLabel: { type: String, default: "Next" }
32
110
  }
33
111
 
34
112
 
35
113
  setup() {
114
+ this.activeAvatarUploadRecord = null
36
115
  this.fileList = this.normalizeFileList(this.defaultFileListValue || [])
37
116
  this.renderFileList()
38
117
  }
@@ -40,14 +119,10 @@ export default class extends RegistryController {
40
119
 
41
120
  teardown() {
42
121
 
43
- this.fileList.forEach((record) => {
44
- if (record.xhr) {
45
- record.xhr.abort()
46
- }
47
- if (record.blobUrl && record.url) {
48
- URL.revokeObjectURL(record.url)
49
- }
50
- })
122
+ this.#closePreviewModal({ animate: false })
123
+
124
+ this.releaseActiveAvatarUploadRecord()
125
+ this.fileList.forEach(releaseUploadRecordResources)
51
126
  }
52
127
 
53
128
 
@@ -91,15 +166,7 @@ export default class extends RegistryController {
91
166
  addFile(fileData) {
92
167
  if (!fileData || typeof fileData !== "object") return null
93
168
 
94
- const record = {
95
- uid: `upload_${Date.now()}_${Math.random().toString(16).slice(2)}`,
96
- name: this.sanitizeFileName(fileData.name || "file"),
97
- status: this.validateStatus(fileData.status) || "ready",
98
- url: this.sanitizeUrl(fileData.url),
99
- percent: this.clampPercent(fileData.percent || 0),
100
- size: typeof fileData.size === "number" ? fileData.size : null,
101
- error: typeof fileData.error === "string" ? fileData.error : null
102
- }
169
+ const record = this.normalizeApiFileData(fileData)
103
170
 
104
171
 
105
172
  if (this.hasMaxCountValue && this.fileList.length >= this.maxCountValue) {
@@ -205,54 +272,40 @@ export default class extends RegistryController {
205
272
 
206
273
 
207
274
 
208
- sanitizeFileName(name) {
209
- if (typeof name !== "string") return "file"
210
-
211
- return name
212
- .replace(/[<>:"\/\\|?*\x00-\x1f]/g, "_")
213
- .slice(0, 255)
275
+ normalizeApiFileData(fileData) {
276
+ return {
277
+ uid: generateUploadUid(),
278
+ name: this.sanitizeFileName(fileData.name || "file"),
279
+ status: this.validateStatus(fileData.status) || "ready",
280
+ url: this.sanitizeUrl(fileData.url),
281
+ percent: this.clampPercent(fileData.percent || 0),
282
+ size: typeof fileData.size === "number" ? fileData.size : null,
283
+ error: typeof fileData.error === "string" ? fileData.error : null
284
+ }
214
285
  }
215
286
 
216
287
 
217
288
 
218
- sanitizeUrl(url) {
219
- if (!url || typeof url !== "string") return null
220
-
221
- try {
289
+ sanitizeFileName(name) {
290
+ return sanitizeUploadFileName(name)
291
+ }
222
292
 
223
- if (url.startsWith("data:")) {
224
293
 
225
- if (url.startsWith("data:image/")) {
226
- return url.slice(0, 1024 * 100)
227
- }
228
- return null
229
- }
230
294
 
231
- const parsed = new URL(url)
232
- if (ALLOWED_URL_PROTOCOLS.includes(parsed.protocol)) {
233
- return parsed.href
234
- }
235
- } catch {
236
-
237
- }
238
-
239
- return null
295
+ sanitizeUrl(url) {
296
+ return sanitizeUploadUrl(url)
240
297
  }
241
298
 
242
299
 
243
300
 
244
301
  validateStatus(status) {
245
- if (typeof status !== "string") return null
246
- const normalized = status.toLowerCase()
247
- return VALID_STATUSES.includes(normalized) ? normalized : null
302
+ return normalizeUploadStatus(status)
248
303
  }
249
304
 
250
305
 
251
306
 
252
307
  clampPercent(percent) {
253
- const num = Number(percent)
254
- if (isNaN(num)) return 0
255
- return Math.max(0, Math.min(100, Math.round(num)))
308
+ return clampUploadPercent(percent)
256
309
  }
257
310
 
258
311
 
@@ -260,21 +313,32 @@ export default class extends RegistryController {
260
313
  validateFileType(file) {
261
314
  if (!this.acceptValue) return true
262
315
 
263
- const acceptTypes = this.acceptValue.split(",").map((t) => t.trim().toLowerCase())
264
- const fileName = file.name.toLowerCase()
265
- const fileType = file.type.toLowerCase()
316
+ const acceptTypes = normalizeAcceptTypes(this.acceptValue)
317
+ const fileMatch = normalizeFileTypeMatchInput(file)
266
318
 
267
319
  return acceptTypes.some((accept) => {
268
320
  if (accept.startsWith(".")) {
269
- return fileName.endsWith(accept)
321
+ return fileMatch.name.endsWith(accept)
270
322
  } else if (accept.endsWith("/*")) {
271
- return fileType.startsWith(accept.slice(0, -2))
323
+ return fileMatch.type.startsWith(accept.slice(0, -2))
272
324
  } else {
273
- return fileType === accept
325
+ return fileMatch.type === accept
274
326
  }
275
327
  })
276
328
  }
277
329
 
330
+ validationErrorForFile(file, { avatar = false } = {}) {
331
+ if (!this.validateFileType(file)) {
332
+ return avatar ? "Please upload an image file" : "File type not accepted"
333
+ }
334
+
335
+ if (this.hasMaxSizeValue && file.size > this.maxSizeValue) {
336
+ return "File exceeds max size"
337
+ }
338
+
339
+ return null
340
+ }
341
+
278
342
  getCsrfToken() {
279
343
  const meta = document.querySelector('meta[name="csrf-token"]')
280
344
  return meta ? meta.getAttribute("content") : null
@@ -335,20 +399,11 @@ export default class extends RegistryController {
335
399
 
336
400
  acceptedFiles.forEach((file) => {
337
401
  const record = this.buildFileRecord(file)
402
+ const validationError = this.validationErrorForFile(file)
338
403
 
339
-
340
- if (!this.validateFileType(file)) {
341
- record.status = "error"
342
- record.error = "File type not accepted"
343
- this.fileList.push(record)
344
- this.dispatchError(record)
345
- return
346
- }
347
-
348
-
349
- if (this.hasMaxSizeValue && file.size > this.maxSizeValue) {
404
+ if (validationError) {
350
405
  record.status = "error"
351
- record.error = "File exceeds max size"
406
+ record.error = validationError
352
407
  this.fileList.push(record)
353
408
  this.dispatchError(record)
354
409
  } else {
@@ -388,14 +443,7 @@ export default class extends RegistryController {
388
443
  if (!record) return
389
444
 
390
445
 
391
- if (record.xhr) {
392
- record.xhr.abort()
393
- }
394
-
395
-
396
- if (record.blobUrl && record.url) {
397
- URL.revokeObjectURL(record.url)
398
- }
446
+ releaseUploadRecordResources(record)
399
447
 
400
448
  this.fileList = this.fileList.filter((item) => item.uid !== record.uid)
401
449
  this.renderFileList()
@@ -414,10 +462,7 @@ export default class extends RegistryController {
414
462
 
415
463
  removeAll() {
416
464
 
417
- this.fileList.forEach((record) => {
418
- if (record.xhr) record.xhr.abort()
419
- if (record.blobUrl && record.url) URL.revokeObjectURL(record.url)
420
- })
465
+ this.fileList.forEach(releaseUploadRecordResources)
421
466
 
422
467
  this.fileList = []
423
468
  this.renderFileList()
@@ -431,10 +476,7 @@ export default class extends RegistryController {
431
476
 
432
477
  setFileList(list) {
433
478
 
434
- this.fileList.forEach((record) => {
435
- if (record.xhr) record.xhr.abort()
436
- if (record.blobUrl && record.url) URL.revokeObjectURL(record.url)
437
- })
479
+ this.fileList.forEach(releaseUploadRecordResources)
438
480
 
439
481
  this.fileList = this.normalizeFileList(list || [])
440
482
  this.renderFileList()
@@ -444,7 +486,9 @@ export default class extends RegistryController {
444
486
  abort(file) {
445
487
  const record = this.resolveRecord(file)
446
488
  if (!record || !record.xhr) return
447
- record.xhr.abort()
489
+ const { xhr } = record
490
+ xhr.abort()
491
+ clearUploadXhr(record, xhr)
448
492
  record.status = "aborted"
449
493
  this.renderFileList()
450
494
  this.dispatchError(record, "aborted")
@@ -453,7 +497,11 @@ export default class extends RegistryController {
453
497
 
454
498
  abortAll() {
455
499
  this.fileList.forEach((record) => {
456
- if (record.xhr) record.xhr.abort()
500
+ if (record.xhr) {
501
+ const { xhr } = record
502
+ xhr.abort()
503
+ clearUploadXhr(record, xhr)
504
+ }
457
505
  if (record.status === "uploading") record.status = "aborted"
458
506
  })
459
507
  this.renderFileList()
@@ -480,9 +528,7 @@ export default class extends RegistryController {
480
528
  record.xhr = xhr
481
529
 
482
530
 
483
- if (this.withCredentialsValue) {
484
- xhr.withCredentials = true
485
- }
531
+ this.applyUploadRequestOptions(xhr)
486
532
 
487
533
  xhr.upload.addEventListener("progress", (event) => {
488
534
  if (!event.lengthComputable) return
@@ -492,6 +538,8 @@ export default class extends RegistryController {
492
538
  })
493
539
 
494
540
  xhr.addEventListener("load", () => {
541
+ clearUploadXhr(record, xhr)
542
+
495
543
  if (xhr.status >= 200 && xhr.status < 300) {
496
544
  record.status = "done"
497
545
  record.percent = 100
@@ -513,6 +561,7 @@ export default class extends RegistryController {
513
561
  })
514
562
 
515
563
  xhr.addEventListener("error", () => {
564
+ clearUploadXhr(record, xhr)
516
565
  record.status = "error"
517
566
  record.error = "Network error"
518
567
  this.renderFileList()
@@ -521,32 +570,43 @@ export default class extends RegistryController {
521
570
  })
522
571
 
523
572
  xhr.addEventListener("abort", () => {
573
+ clearUploadXhr(record, xhr)
524
574
  record.status = "aborted"
525
575
  this.renderFileList()
526
576
  this.dispatchError(record, "aborted")
527
577
  this.dispatchChange(record)
528
578
  })
529
579
 
530
- xhr.open("POST", this.actionValue)
580
+ this.sendUploadRequest(xhr, record.raw)
581
+ }
531
582
 
583
+ applyUploadRequestOptions(xhr) {
584
+ if (this.withCredentialsValue) {
585
+ xhr.withCredentials = true
586
+ }
587
+ }
588
+
589
+ openUploadRequest(xhr) {
590
+ xhr.open("POST", this.actionValue)
532
591
 
533
592
  const csrfToken = this.getCsrfToken()
534
593
  if (csrfToken) {
535
594
  xhr.setRequestHeader("X-CSRF-Token", csrfToken)
536
595
  }
537
596
 
538
-
539
597
  const headers = this.ensureObject(this.headersValue)
540
598
  if (headers) {
541
- Object.entries(headers).forEach(([key, value]) => {
542
-
543
- const lowerKey = key.toLowerCase()
544
- if (!["cookie", "host", "origin", "referer"].includes(lowerKey)) {
545
- xhr.setRequestHeader(key, value)
546
- }
599
+ this.getSafeUploadHeaders(headers).forEach(([key, value]) => {
600
+ xhr.setRequestHeader(key, value)
547
601
  })
548
602
  }
603
+ }
604
+
605
+ getSafeUploadHeaders(headers) {
606
+ return Object.entries(headers).filter(([key]) => !isBlockedUploadHeader(key))
607
+ }
549
608
 
609
+ buildUploadFormData(file) {
550
610
  const formData = new FormData()
551
611
  const extraData = this.ensureObject(this.dataValue)
552
612
  if (extraData) {
@@ -555,33 +615,38 @@ export default class extends RegistryController {
555
615
  })
556
616
  }
557
617
 
558
- formData.append(this.nameValue || "file", record.raw)
559
- xhr.send(formData)
618
+ formData.append(this.nameValue || "file", file)
619
+ return formData
620
+ }
621
+
622
+ sendUploadRequest(xhr, file) {
623
+ this.openUploadRequest(xhr)
624
+ xhr.send(this.buildUploadFormData(file))
560
625
  }
561
626
 
562
627
  normalizeFileList(list) {
563
- return (list || []).map((item, index) => {
564
- if (item instanceof File) return this.buildFileRecord(item)
565
-
566
- const record = {
567
- uid: item.uid || `upload_${Date.now()}_${index}`,
568
- name: this.sanitizeFileName(item.name || item.filename || "file"),
569
- status: this.validateStatus(item.status) || "ready",
570
- url: this.sanitizeUrl(item.url),
571
- percent: this.clampPercent(item.percent || 0),
572
- size: item.size,
573
- response: item.response,
574
- error: typeof item.error === "string" ? item.error : null,
575
- raw: item.raw || item.file
576
- }
628
+ return (list || []).map((item, index) => this.normalizeFileListItem(item, index))
629
+ }
577
630
 
578
- return record
579
- })
631
+ normalizeFileListItem(item, index) {
632
+ if (item instanceof File) return this.buildFileRecord(item)
633
+
634
+ return {
635
+ uid: item.uid || `upload_${Date.now()}_${index}`,
636
+ name: this.sanitizeFileName(item.name || item.filename || "file"),
637
+ status: this.validateStatus(item.status) || "ready",
638
+ url: this.sanitizeUrl(item.url),
639
+ percent: this.clampPercent(item.percent || 0),
640
+ size: item.size,
641
+ response: item.response,
642
+ error: typeof item.error === "string" ? item.error : null,
643
+ raw: item.raw || item.file
644
+ }
580
645
  }
581
646
 
582
647
  buildFileRecord(file) {
583
648
  const record = {
584
- uid: `upload_${Date.now()}_${Math.random().toString(16).slice(2)}`,
649
+ uid: generateUploadUid(),
585
650
  name: this.sanitizeFileName(file.name),
586
651
  status: "ready",
587
652
  percent: 0,
@@ -675,7 +740,7 @@ export default class extends RegistryController {
675
740
  viewButton.type = "button"
676
741
  viewButton.className = "hakumi-upload-list-item-action hakumi-upload-list-item-view"
677
742
  viewButton.innerHTML = `<svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor"><path d="M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z"/></svg>`
678
- viewButton.title = "Preview"
743
+ viewButton.title = this.previewLabelValue
679
744
  viewButton.addEventListener("click", (e) => {
680
745
  e.stopPropagation()
681
746
  this.openPreview(record)
@@ -687,7 +752,7 @@ export default class extends RegistryController {
687
752
  removeButton.type = "button"
688
753
  removeButton.className = "hakumi-upload-list-item-action hakumi-upload-list-item-remove"
689
754
  removeButton.innerHTML = `<svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor"><path d="M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z"/></svg>`
690
- removeButton.title = "Remove"
755
+ removeButton.title = this.removeLabelValue
691
756
  removeButton.addEventListener("click", (e) => {
692
757
  e.stopPropagation()
693
758
  this.remove(record.uid)
@@ -702,7 +767,7 @@ export default class extends RegistryController {
702
767
  removeButton.type = "button"
703
768
  removeButton.className = "hakumi-upload-list-item-action hakumi-upload-list-item-remove"
704
769
  removeButton.textContent = "×"
705
- removeButton.title = "Remove"
770
+ removeButton.title = this.removeLabelValue
706
771
  removeButton.addEventListener("click", (e) => {
707
772
  e.stopPropagation()
708
773
  this.remove(record.uid)
@@ -808,42 +873,21 @@ export default class extends RegistryController {
808
873
  handleAvatarUpload(file) {
809
874
  if (!this.hasAvatarTarget || !this.hasAvatarWrapperTarget) return
810
875
 
811
-
812
- if (!this.validateFileType(file)) {
813
- if (window.HakumiComponents?.renderComponent) {
814
- window.HakumiComponents.renderComponent("message", {
815
- params: {
816
- id: "hakumi-upload-avatar-message",
817
- type: "error",
818
- message: "Please upload an image file",
819
- duration: 3
820
- }
821
- })
822
- }
876
+ const validationError = this.validationErrorForFile(file, { avatar: true })
877
+ if (validationError) {
878
+ const record = this.buildAvatarErrorRecord(file, validationError)
879
+ this.dispatchError(record, record.error)
823
880
  return
824
881
  }
825
882
 
826
-
827
- if (this.hasMaxSizeValue && file.size > this.maxSizeValue) {
828
- if (window.HakumiComponents?.renderComponent) {
829
- window.HakumiComponents.renderComponent("message", {
830
- params: {
831
- id: "hakumi-upload-avatar-message",
832
- type: "error",
833
- message: "File exceeds max size",
834
- duration: 3
835
- }
836
- })
837
- }
838
- return
883
+ const previousAvatarState = this.captureAvatarState()
884
+ const previousAvatarRecord = this.activeAvatarUploadRecord
885
+ const releasePreviousAvatarOnSuccess = previousAvatarRecord?.status === "done"
886
+ if (!releasePreviousAvatarOnSuccess) {
887
+ this.releaseActiveAvatarUploadRecord(previousAvatarRecord)
839
888
  }
840
889
 
841
-
842
- this.avatarWrapperTarget.classList.add("hakumi-upload-avatar-wrapper-loading")
843
-
844
-
845
- const loadingIcon = `<span class="anticon anticon-loading" style="animation: spin 1s linear infinite;"><svg viewBox="0 0 1024 1024" width="1em" height="1em" fill="currentColor"><path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"></path></svg></span>`
846
- this.avatarTarget.innerHTML = loadingIcon
890
+ this.showAvatarLoading()
847
891
 
848
892
 
849
893
  const previewUrl = URL.createObjectURL(file)
@@ -852,11 +896,19 @@ export default class extends RegistryController {
852
896
  const record = this.buildFileRecord(file)
853
897
  record.url = previewUrl
854
898
  record.blobUrl = true
899
+ this.activeAvatarUploadRecord = record
855
900
 
856
901
 
857
902
  const isDemoMode = !this.actionValue || this.actionValue === "#"
858
903
 
859
904
  const updateAvatar = () => {
905
+ if (this.activeAvatarUploadRecord !== record) return
906
+
907
+ if (releasePreviousAvatarOnSuccess) {
908
+ releaseUploadRecordResources(previousAvatarRecord)
909
+ }
910
+ record.status = "done"
911
+ record.percent = 100
860
912
  this.avatarWrapperTarget.classList.remove("hakumi-upload-avatar-wrapper-loading")
861
913
 
862
914
 
@@ -877,20 +929,28 @@ export default class extends RegistryController {
877
929
  setTimeout(updateAvatar, 1500)
878
930
  } else {
879
931
 
880
- this.performAvatarUpload(record, updateAvatar)
932
+ this.performAvatarUpload(record, updateAvatar, () => {
933
+ this.restoreFailedAvatarUpload(record, previousAvatarState, previousAvatarRecord)
934
+ })
881
935
  }
882
936
  }
883
937
 
938
+ showAvatarLoading() {
939
+ this.avatarWrapperTarget.classList.add("hakumi-upload-avatar-wrapper-loading")
940
+ const loadingIcon = `<span class="anticon anticon-loading" style="animation: spin 1s linear infinite;"><svg viewBox="0 0 1024 1024" width="1em" height="1em" fill="currentColor"><path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"></path></svg></span>`
941
+ this.avatarTarget.innerHTML = loadingIcon
942
+ }
943
+
884
944
 
885
945
 
886
- performAvatarUpload(record, onSuccess) {
946
+ performAvatarUpload(record, onSuccess, onError = () => this.clearAvatarErrorState()) {
887
947
  const xhr = new XMLHttpRequest()
948
+ record.xhr = xhr
888
949
 
889
- if (this.withCredentialsValue) {
890
- xhr.withCredentials = true
891
- }
950
+ this.applyUploadRequestOptions(xhr)
892
951
 
893
952
  xhr.addEventListener("load", () => {
953
+ clearUploadXhr(record, xhr)
894
954
  if (xhr.status >= 200 && xhr.status < 300) {
895
955
  try {
896
956
  record.response = JSON.parse(xhr.response)
@@ -899,43 +959,67 @@ export default class extends RegistryController {
899
959
  }
900
960
  onSuccess()
901
961
  } else {
902
- this.avatarWrapperTarget?.classList.remove("hakumi-upload-avatar-wrapper-loading")
962
+ onError()
963
+ record.status = "error"
964
+ record.error = xhr.statusText || "Upload failed"
903
965
  this.dispatchError(record, xhr.statusText || "Upload failed")
904
966
  }
905
967
  })
906
968
 
907
969
  xhr.addEventListener("error", () => {
908
- this.avatarWrapperTarget?.classList.remove("hakumi-upload-avatar-wrapper-loading")
970
+ clearUploadXhr(record, xhr)
971
+ onError()
972
+ record.status = "error"
973
+ record.error = "Network error"
909
974
  this.dispatchError(record, "Network error")
910
975
  })
911
976
 
912
- xhr.open("POST", this.actionValue)
977
+ this.sendUploadRequest(xhr, record.raw)
978
+ }
913
979
 
914
- const csrfToken = this.getCsrfToken()
915
- if (csrfToken) {
916
- xhr.setRequestHeader("X-CSRF-Token", csrfToken)
917
- }
980
+ buildAvatarErrorRecord(file, error) {
981
+ const record = this.buildFileRecord(file)
982
+ record.status = "error"
983
+ record.error = error
984
+ return record
985
+ }
918
986
 
919
- const headers = this.ensureObject(this.headersValue)
920
- if (headers) {
921
- Object.entries(headers).forEach(([key, value]) => {
922
- const lowerKey = key.toLowerCase()
923
- if (!["cookie", "host", "origin", "referer"].includes(lowerKey)) {
924
- xhr.setRequestHeader(key, value)
925
- }
926
- })
987
+ captureAvatarState() {
988
+ if (!this.hasAvatarTarget) return { html: "", image: false }
989
+
990
+ return {
991
+ html: this.avatarTarget.innerHTML,
992
+ image: this.avatarTarget.classList.contains("hakumi-avatar-image")
927
993
  }
994
+ }
928
995
 
929
- const formData = new FormData()
930
- const extraData = this.ensureObject(this.dataValue)
931
- if (extraData) {
932
- Object.entries(extraData).forEach(([key, value]) => {
933
- formData.append(key, value)
934
- })
996
+ restoreAvatarState(state) {
997
+ this.avatarWrapperTarget?.classList.remove("hakumi-upload-avatar-wrapper-loading")
998
+ if (!this.hasAvatarTarget) return
999
+
1000
+ this.avatarTarget.innerHTML = state?.html || ""
1001
+ this.avatarTarget.classList.toggle("hakumi-avatar-image", Boolean(state?.image))
1002
+ }
1003
+
1004
+ restoreFailedAvatarUpload(record, previousAvatarState, previousAvatarRecord = null) {
1005
+ this.restoreAvatarState(previousAvatarState)
1006
+ this.releaseActiveAvatarUploadRecord(record)
1007
+ if (previousAvatarRecord?.status === "done") {
1008
+ this.activeAvatarUploadRecord = previousAvatarRecord
935
1009
  }
1010
+ }
936
1011
 
937
- formData.append(this.nameValue || "file", record.raw)
938
- xhr.send(formData)
1012
+ clearAvatarErrorState() {
1013
+ this.restoreAvatarState({ html: "", image: false })
1014
+ }
1015
+
1016
+ releaseActiveAvatarUploadRecord(record = this.activeAvatarUploadRecord) {
1017
+ if (!record) return
1018
+
1019
+ releaseUploadRecordResources(record)
1020
+ if (this.activeAvatarUploadRecord === record) {
1021
+ this.activeAvatarUploadRecord = null
1022
+ }
939
1023
  }
940
1024
 
941
1025
 
@@ -946,12 +1030,8 @@ export default class extends RegistryController {
946
1030
 
947
1031
  getPreviewItems() {
948
1032
  return this.fileList
949
- .filter((record) => record.status === "done" && record.url)
950
- .map((record) => ({
951
- src: record.url,
952
- alt: record.name,
953
- uid: record.uid
954
- }))
1033
+ .filter(isPreviewRecord)
1034
+ .map(normalizePreviewItem)
955
1035
  }
956
1036
 
957
1037
 
@@ -971,17 +1051,30 @@ export default class extends RegistryController {
971
1051
  #previewState = {
972
1052
  modal: null,
973
1053
  items: [],
974
- currentIndex: 0
1054
+ currentIndex: 0,
1055
+ closeTimer: null
975
1056
  }
976
1057
 
977
1058
  #createPreviewModal(items, startIndex) {
978
1059
 
979
- this.#closePreviewModal()
1060
+ this.#closePreviewModal({ animate: false })
1061
+ this.#setPreviewItems(items, startIndex)
1062
+
1063
+ const modal = this.#buildPreviewModal()
980
1064
 
1065
+ this.#mountPreviewModal(modal)
1066
+ this.#bindPreviewModalControls(modal)
1067
+ this.#bindPreviewKeyboardNavigation()
1068
+ this.#activatePreviewModal(modal)
1069
+ }
1070
+
1071
+ #setPreviewItems(items, startIndex) {
981
1072
  this.#previewState.items = items
982
1073
  this.#previewState.currentIndex = startIndex
1074
+ }
983
1075
 
984
-
1076
+ #buildPreviewModal() {
1077
+ const { items, currentIndex } = this.#previewState
985
1078
  const modal = document.createElement("div")
986
1079
  modal.className = "hakumi-upload-preview-root"
987
1080
  modal.innerHTML = `
@@ -991,19 +1084,19 @@ export default class extends RegistryController {
991
1084
  <img class="hakumi-upload-preview-img" src="" alt="" />
992
1085
  </div>
993
1086
  <div class="hakumi-upload-preview-operations">
994
- <button type="button" class="hakumi-upload-preview-close" title="Close">
1087
+ <button type="button" class="hakumi-upload-preview-close" title="${this.closeLabelValue}">
995
1088
  <svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor"><path d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 00203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"/></svg>
996
1089
  </button>
997
1090
  </div>
998
1091
  ${items.length > 1 ? `
999
- <button type="button" class="hakumi-upload-preview-switch hakumi-upload-preview-switch-left" title="Previous">
1092
+ <button type="button" class="hakumi-upload-preview-switch hakumi-upload-preview-switch-left" title="${this.previousLabelValue}">
1000
1093
  <svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor"><path d="M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"/></svg>
1001
1094
  </button>
1002
- <button type="button" class="hakumi-upload-preview-switch hakumi-upload-preview-switch-right" title="Next">
1095
+ <button type="button" class="hakumi-upload-preview-switch hakumi-upload-preview-switch-right" title="${this.nextLabelValue}">
1003
1096
  <svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor"><path d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V183c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"/></svg>
1004
1097
  </button>
1005
1098
  <div class="hakumi-upload-preview-counter">
1006
- <span class="hakumi-upload-preview-counter-current">${startIndex + 1}</span>
1099
+ <span class="hakumi-upload-preview-counter-current">${currentIndex + 1}</span>
1007
1100
  <span>/</span>
1008
1101
  <span class="hakumi-upload-preview-counter-total">${items.length}</span>
1009
1102
  </div>
@@ -1011,28 +1104,33 @@ export default class extends RegistryController {
1011
1104
  </div>
1012
1105
  `
1013
1106
 
1107
+ return modal
1108
+ }
1109
+
1110
+ #mountPreviewModal(modal) {
1014
1111
  this.#previewState.modal = modal
1015
1112
  document.body.appendChild(modal)
1016
1113
  document.body.style.overflow = "hidden"
1017
-
1018
-
1019
1114
  this.#updatePreviewImage()
1115
+ }
1020
1116
 
1021
-
1117
+ #bindPreviewModalControls(modal) {
1022
1118
  modal.querySelector(".hakumi-upload-preview-mask").addEventListener("click", () => this.#closePreviewModal())
1023
1119
  modal.querySelector(".hakumi-upload-preview-close").addEventListener("click", () => this.#closePreviewModal())
1024
1120
  modal.querySelector(".hakumi-upload-preview-switch-left")?.addEventListener("click", () => this.#prevPreviewImage())
1025
1121
  modal.querySelector(".hakumi-upload-preview-switch-right")?.addEventListener("click", () => this.#nextPreviewImage())
1122
+ }
1026
1123
 
1027
-
1124
+ #bindPreviewKeyboardNavigation() {
1028
1125
  this._previewKeyHandler = (e) => {
1029
1126
  if (e.key === "Escape") this.#closePreviewModal()
1030
1127
  else if (e.key === "ArrowLeft") this.#prevPreviewImage()
1031
1128
  else if (e.key === "ArrowRight") this.#nextPreviewImage()
1032
1129
  }
1033
1130
  document.addEventListener("keydown", this._previewKeyHandler)
1131
+ }
1034
1132
 
1035
-
1133
+ #activatePreviewModal(modal) {
1036
1134
  requestAnimationFrame(() => {
1037
1135
  modal.classList.add("hakumi-upload-preview-active")
1038
1136
  })
@@ -1067,21 +1165,39 @@ export default class extends RegistryController {
1067
1165
  this.#updatePreviewImage()
1068
1166
  }
1069
1167
 
1070
- #closePreviewModal() {
1168
+ #closePreviewModal({ animate = true } = {}) {
1071
1169
  const { modal } = this.#previewState
1072
1170
  if (!modal) return
1073
1171
 
1074
1172
  modal.classList.remove("hakumi-upload-preview-active")
1173
+ this.#unbindPreviewKeyboardNavigation()
1075
1174
 
1076
- if (this._previewKeyHandler) {
1077
- document.removeEventListener("keydown", this._previewKeyHandler)
1078
- this._previewKeyHandler = null
1079
- }
1175
+ if (this.#previewState.closeTimer) clearTimeout(this.#previewState.closeTimer)
1080
1176
 
1081
- setTimeout(() => {
1177
+ const removeModal = () => {
1082
1178
  modal.remove()
1083
- this.#previewState.modal = null
1084
- document.body.style.overflow = ""
1085
- }, 300)
1179
+ if (this.#previewState.modal === modal) {
1180
+ this.#resetPreviewModalState()
1181
+ }
1182
+ }
1183
+
1184
+ if (animate) {
1185
+ this.#previewState.closeTimer = setTimeout(removeModal, 300)
1186
+ } else {
1187
+ removeModal()
1188
+ }
1189
+ }
1190
+
1191
+ #unbindPreviewKeyboardNavigation() {
1192
+ if (!this._previewKeyHandler) return
1193
+
1194
+ document.removeEventListener("keydown", this._previewKeyHandler)
1195
+ this._previewKeyHandler = null
1196
+ }
1197
+
1198
+ #resetPreviewModalState() {
1199
+ this.#previewState.modal = null
1200
+ this.#previewState.closeTimer = null
1201
+ document.body.style.overflow = ""
1086
1202
  }
1087
1203
  }