alchemy_cms 8.2.7 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  5. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  6. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  7. data/app/assets/builds/alchemy/light-theme.css +1 -1
  8. data/app/assets/builds/alchemy/preview.min.js +1 -1
  9. data/app/assets/builds/alchemy/theme.css +1 -1
  10. data/app/assets/builds/alchemy/welcome.css +1 -1
  11. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  12. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  13. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  14. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  15. data/app/assets/images/alchemy/admin/logo.svg +27 -0
  16. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  17. data/app/components/alchemy/admin/dashboard/widget.rb +40 -0
  18. data/app/components/alchemy/admin/dashboard/widgets/attachment_counts.rb +17 -0
  19. data/app/components/alchemy/admin/dashboard/widgets/element_usage.rb +37 -0
  20. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +13 -0
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.rb +21 -0
  22. data/app/components/alchemy/admin/dashboard/widgets/locked_pages.html.erb +54 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/locked_pages.rb +20 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +22 -0
  25. data/app/components/alchemy/admin/dashboard/widgets/online_users.rb +19 -0
  26. data/app/components/alchemy/admin/dashboard/widgets/page_counts.rb +23 -0
  27. data/app/components/alchemy/admin/dashboard/widgets/page_usage.rb +46 -0
  28. data/app/components/alchemy/admin/dashboard/widgets/picture_counts.rb +17 -0
  29. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +41 -0
  30. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.rb +16 -0
  31. data/app/components/alchemy/admin/dashboard/widgets/sites.html.erb +29 -0
  32. data/app/components/alchemy/admin/dashboard/widgets/sites.rb +15 -0
  33. data/app/components/alchemy/admin/dashboard/widgets/stat_widget.html.erb +23 -0
  34. data/app/components/alchemy/admin/dashboard/widgets/stat_widget.rb +19 -0
  35. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +32 -0
  36. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +37 -0
  37. data/app/components/alchemy/admin/dashboard/widgets/usage_widget.html.erb +42 -0
  38. data/app/components/alchemy/admin/dashboard/widgets/usage_widget.rb +66 -0
  39. data/app/components/alchemy/admin/dashboard/widgets/user_counts.rb +25 -0
  40. data/app/components/alchemy/admin/element_editor.html.erb +27 -20
  41. data/app/components/alchemy/admin/element_schedule_timestamps.rb +33 -0
  42. data/app/components/alchemy/admin/element_select.rb +4 -3
  43. data/app/components/alchemy/admin/page_node.html.erb +1 -20
  44. data/app/components/alchemy/admin/page_publication_fields.html.erb +30 -0
  45. data/app/components/alchemy/admin/page_publication_fields.rb +18 -0
  46. data/app/components/alchemy/admin/page_status_indicators.html.erb +29 -0
  47. data/app/components/alchemy/admin/page_status_indicators.rb +9 -0
  48. data/app/components/alchemy/admin/publish_element_button.html.erb +12 -4
  49. data/app/components/alchemy/ingredients/headline_editor.rb +1 -1
  50. data/app/controllers/alchemy/admin/dashboard/widgets_controller.rb +21 -0
  51. data/app/controllers/alchemy/admin/dashboard_controller.rb +3 -12
  52. data/app/controllers/alchemy/pages_controller.rb +5 -4
  53. data/app/helpers/alchemy/elements_block_helper.rb +1 -0
  54. data/app/javascript/alchemy_admin/components/auto_submit.js +15 -9
  55. data/app/javascript/alchemy_admin/components/char_counter.js +17 -7
  56. data/app/javascript/alchemy_admin/components/clipboard_button.js +2 -6
  57. data/app/javascript/alchemy_admin/components/color_select.js +13 -4
  58. data/app/javascript/alchemy_admin/components/datepicker.js +11 -14
  59. data/app/javascript/alchemy_admin/components/dialog_link.js +5 -2
  60. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +6 -3
  61. data/app/javascript/alchemy_admin/components/element_editor.js +45 -28
  62. data/app/javascript/alchemy_admin/components/element_select.js +7 -4
  63. data/app/javascript/alchemy_admin/components/elements_window.js +38 -31
  64. data/app/javascript/alchemy_admin/components/elements_window_handle.js +7 -3
  65. data/app/javascript/alchemy_admin/components/file_editor.js +5 -2
  66. data/app/javascript/alchemy_admin/components/ingredient_group.js +6 -4
  67. data/app/javascript/alchemy_admin/components/link_buttons/link_button.js +1 -2
  68. data/app/javascript/alchemy_admin/components/link_buttons/unlink_button.js +1 -2
  69. data/app/javascript/alchemy_admin/components/link_buttons.js +6 -2
  70. data/app/javascript/alchemy_admin/components/list_filter.js +44 -29
  71. data/app/javascript/alchemy_admin/components/message.js +22 -15
  72. data/app/javascript/alchemy_admin/components/overlay.js +5 -7
  73. data/app/javascript/alchemy_admin/components/page_publication_fields.js +38 -25
  74. data/app/javascript/alchemy_admin/components/picture_description_select.js +5 -2
  75. data/app/javascript/alchemy_admin/components/picture_editor.js +5 -10
  76. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +4 -5
  77. data/app/javascript/alchemy_admin/components/preview_window.js +5 -10
  78. data/app/javascript/alchemy_admin/components/publish_page_button.js +2 -5
  79. data/app/javascript/alchemy_admin/components/remote_select.js +53 -23
  80. data/app/javascript/alchemy_admin/components/select.js +169 -26
  81. data/app/javascript/alchemy_admin/components/sortable_elements.js +1 -1
  82. data/app/javascript/alchemy_admin/components/spinner.js +11 -11
  83. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +9 -1
  84. data/app/javascript/alchemy_admin/components/tinymce.js +16 -22
  85. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +48 -45
  86. data/app/javascript/alchemy_admin/components/uploader/progress.js +70 -84
  87. data/app/javascript/alchemy_admin/components/uploader.js +71 -46
  88. data/app/javascript/alchemy_admin/dialog.js +3 -0
  89. data/app/javascript/alchemy_admin/hotkeys.js +0 -18
  90. data/app/javascript/alchemy_admin/image_cropper.js +7 -9
  91. data/app/javascript/alchemy_admin/initializer.js +21 -0
  92. data/app/javascript/alchemy_admin/utils/dispatch_page_dirty_event.js +7 -0
  93. data/app/javascript/tinymce/plugins/alchemy_link/index.js +9 -0
  94. data/app/jobs/alchemy/base_job.rb +2 -2
  95. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +33 -0
  96. data/app/models/alchemy/page/page_naming.rb +28 -5
  97. data/app/models/alchemy/page/page_natures.rb +7 -2
  98. data/app/models/alchemy/page/page_scopes.rb +2 -2
  99. data/app/models/alchemy/page/url_path.rb +7 -2
  100. data/app/models/alchemy/page.rb +2 -2
  101. data/app/models/alchemy/page_definition.rb +1 -0
  102. data/app/models/alchemy/permissions.rb +1 -1
  103. data/app/models/concerns/alchemy/relatable_resource.rb +8 -0
  104. data/app/services/alchemy/page_finder.rb +88 -0
  105. data/app/stylesheets/alchemy/_custom-properties.scss +6 -4
  106. data/app/stylesheets/alchemy/_mixins.scss +1 -7
  107. data/app/stylesheets/alchemy/_themes.scss +13 -1
  108. data/app/stylesheets/alchemy/admin/_tom-select.scss +240 -0
  109. data/app/stylesheets/alchemy/admin/archive.scss +0 -1
  110. data/app/stylesheets/alchemy/admin/base.scss +0 -19
  111. data/app/stylesheets/alchemy/admin/dashboard.scss +395 -28
  112. data/app/stylesheets/alchemy/admin/elements.scss +14 -17
  113. data/app/stylesheets/alchemy/admin/form_fields.scss +3 -3
  114. data/app/stylesheets/alchemy/admin/forms.scss +107 -93
  115. data/app/stylesheets/alchemy/admin/icons.scss +28 -0
  116. data/app/stylesheets/alchemy/admin/image_library.scss +20 -10
  117. data/app/stylesheets/alchemy/admin/navigation.scss +4 -1
  118. data/app/stylesheets/alchemy/admin/popover.scss +3 -5
  119. data/app/stylesheets/alchemy/admin/resource_info.scss +11 -17
  120. data/app/stylesheets/alchemy/admin/shoelace.scss +8 -0
  121. data/app/stylesheets/alchemy/admin/sitemap.scss +5 -0
  122. data/app/stylesheets/alchemy/admin/tables.scss +32 -3
  123. data/app/stylesheets/alchemy/admin/toolbar.scss +0 -1
  124. data/app/stylesheets/alchemy/admin.scss +1 -0
  125. data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +0 -4
  126. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +0 -4
  127. data/app/types/alchemy/wildcard_url_type.rb +48 -0
  128. data/app/views/alchemy/_menubar.html.erb +1 -5
  129. data/app/views/alchemy/admin/attachments/edit.html.erb +6 -3
  130. data/app/views/alchemy/admin/dashboard/_dashboard.html.erb +3 -2
  131. data/app/views/alchemy/admin/dashboard/_footer.html.erb +22 -0
  132. data/app/views/alchemy/admin/dashboard/_stats.html.erb +7 -0
  133. data/app/views/alchemy/admin/dashboard/_top.html.erb +4 -12
  134. data/app/views/alchemy/admin/dashboard/_widgets.html.erb +7 -0
  135. data/app/views/alchemy/admin/dashboard/index.html.erb +0 -17
  136. data/app/views/alchemy/admin/dashboard/info.html.erb +1 -62
  137. data/app/views/alchemy/admin/dashboard/widgets/show.html.erb +3 -0
  138. data/app/views/alchemy/admin/elements/_form.html.erb +2 -1
  139. data/app/views/alchemy/admin/elements/_schedule.html.erb +2 -15
  140. data/app/views/alchemy/admin/elements/_schedule_fields.html.erb +2 -0
  141. data/app/views/alchemy/admin/layoutpages/edit.html.erb +6 -3
  142. data/app/views/alchemy/admin/nodes/_page_nodes.html.erb +10 -8
  143. data/app/views/alchemy/admin/pages/_form.html.erb +25 -19
  144. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +2 -32
  145. data/app/views/alchemy/admin/pages/_table.html.erb +1 -18
  146. data/app/views/alchemy/admin/pages/configure.html.erb +2 -2
  147. data/app/views/alchemy/admin/pages/info.html.erb +6 -0
  148. data/app/views/alchemy/admin/resources/_form.html.erb +7 -4
  149. data/app/views/alchemy/admin/resources/edit.html.erb +3 -1
  150. data/app/views/alchemy/admin/resources/new.html.erb +3 -1
  151. data/app/views/alchemy/admin/styleguide/index.html.erb +52 -30
  152. data/app/views/alchemy/admin/translations/_en.js +4 -0
  153. data/app/views/layouts/alchemy/admin.html.erb +3 -3
  154. data/config/importmap.rb +2 -0
  155. data/config/locales/alchemy.en.yml +15 -0
  156. data/config/routes.rb +1 -0
  157. data/lib/alchemy/configuration/class_option.rb +46 -3
  158. data/lib/alchemy/configuration/collection_option.rb +4 -0
  159. data/lib/alchemy/configurations/dashboard.rb +79 -0
  160. data/lib/alchemy/configurations/main.rb +15 -0
  161. data/lib/alchemy/engine.rb +9 -3
  162. data/lib/alchemy/sprockets/skip_builds_compression.rb +33 -0
  163. data/lib/alchemy/test_support/capybara_helpers.rb +17 -0
  164. data/lib/alchemy/test_support/relatable_resource_examples.rb +20 -0
  165. data/lib/alchemy/test_support/rspec_matchers.rb +8 -0
  166. data/lib/alchemy/test_support/shared_publishable_examples.rb +38 -31
  167. data/lib/alchemy/tinymce.rb +1 -1
  168. data/lib/alchemy/version.rb +17 -3
  169. data/vendor/javascript/cropperjs.min.js +1 -1
  170. data/vendor/javascript/flatpickr.min.js +1 -1
  171. data/vendor/javascript/floating-ui.min.js +1 -0
  172. data/vendor/javascript/keymaster.min.js +1 -1
  173. data/vendor/javascript/rails-ujs.min.js +1 -1
  174. data/vendor/javascript/shoelace.min.js +93 -93
  175. data/vendor/javascript/sortable.min.js +1 -1
  176. data/vendor/javascript/tinymce.min.js +5 -1
  177. data/vendor/javascript/tom-select.min.js +1 -0
  178. metadata +57 -18
  179. data/app/javascript/alchemy_admin/components/alchemy_html_element.js +0 -129
  180. data/app/views/alchemy/admin/dashboard/_left_column.html.erb +0 -4
  181. data/app/views/alchemy/admin/dashboard/_right_column.html.erb +0 -9
  182. data/app/views/alchemy/admin/dashboard/widgets/_locked_pages.html.erb +0 -52
  183. data/app/views/alchemy/admin/dashboard/widgets/_recent_pages.html.erb +0 -34
  184. data/app/views/alchemy/admin/dashboard/widgets/_sites.html.erb +0 -25
  185. data/app/views/alchemy/admin/dashboard/widgets/_users.html.erb +0 -21
  186. data/app/views/alchemy/admin/languages/edit.html.erb +0 -1
  187. data/app/views/alchemy/admin/languages/new.html.erb +0 -1
  188. data/app/views/alchemy/admin/sites/edit.html.erb +0 -1
  189. data/app/views/alchemy/admin/sites/new.html.erb +0 -1
@@ -1,38 +1,22 @@
1
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
2
1
  import { formatFileSize } from "alchemy_admin/utils/format"
3
2
  import { translate } from "alchemy_admin/i18n"
4
3
  import { growl } from "alchemy_admin/growler"
5
4
 
6
- export class FileUpload extends AlchemyHTMLElement {
7
- constructor() {
8
- super()
9
-
10
- this.file = null
11
- this.request = null
12
-
13
- this.progressEventLoaded = 0
14
- this.progressEventTotal = 0
15
- this.className = "in-progress"
16
- this.valid = true
17
- this.value = 0
18
- }
19
-
20
- /**
21
- * Initialize the component with file and request
22
- * @param {File} file
23
- * @param {XMLHttpRequest} request
24
- */
25
- initialize(file, request) {
26
- this.file = file
27
- this.request = request
28
- this.progressEventTotal = file ? file.size : 0
29
-
30
- this._validateFile()
31
- this._addRequestEventListener()
32
- }
33
-
34
- render() {
35
- return `
5
+ export class FileUpload extends HTMLElement {
6
+ // public — used by callers (Uploader, Progress, tests)
7
+ file = null
8
+ request = null
9
+ progressEventLoaded = 0
10
+ progressEventTotal = 0
11
+
12
+ // private — backing state for getters/setters
13
+ #valid = true
14
+ #value = 0
15
+ #status = undefined
16
+ #errorMessage = ""
17
+
18
+ connectedCallback() {
19
+ this.innerHTML = `
36
20
  <sl-progress-bar value="${this.value}"></sl-progress-bar>
37
21
  <div class="description">
38
22
  <span class="file-name">${this.file?.name}</span>
@@ -45,9 +29,7 @@ export class FileUpload extends AlchemyHTMLElement {
45
29
  </button>
46
30
  </sl-tooltip>
47
31
  `
48
- }
49
32
 
50
- afterRender() {
51
33
  this.querySelector("button").addEventListener("click", () => this.cancel())
52
34
 
53
35
  if (this.file?.type.includes("image")) {
@@ -61,6 +43,21 @@ export class FileUpload extends AlchemyHTMLElement {
61
43
  }
62
44
  }
63
45
 
46
+ /**
47
+ * Initialize the component with file and request
48
+ * @param {File} file
49
+ * @param {XMLHttpRequest} request
50
+ */
51
+ initialize(file, request) {
52
+ this.file = file
53
+ this.request = request
54
+ this.progressEventTotal = file ? file.size : 0
55
+ this.status = "in-progress"
56
+
57
+ this.#validateFile()
58
+ this.#addRequestEventListener()
59
+ }
60
+
64
61
  /**
65
62
  * cancel the upload
66
63
  */
@@ -72,11 +69,18 @@ export class FileUpload extends AlchemyHTMLElement {
72
69
  }
73
70
  }
74
71
 
72
+ /**
73
+ * Dispatches a custom event with given name, namespaced under `Alchemy.`.
74
+ * @param {string} name The name of the custom event
75
+ */
76
+ dispatchCustomEvent(name) {
77
+ this.dispatchEvent(new CustomEvent(`Alchemy.${name}`, { bubbles: true }))
78
+ }
79
+
75
80
  /**
76
81
  * validate given file with the `Alchemy.uploader_defaults` - configuration
77
- * @private
78
82
  */
79
- _validateFile() {
83
+ #validateFile() {
80
84
  const config = Alchemy.uploader_defaults
81
85
  const maxFileSize = config.file_size_limit * Math.pow(1024, 2) // in Byte
82
86
  let errorMessage = undefined
@@ -107,9 +111,8 @@ export class FileUpload extends AlchemyHTMLElement {
107
111
 
108
112
  /**
109
113
  * register event listeners to react on request changes
110
- * @private
111
114
  */
112
- _addRequestEventListener() {
115
+ #addRequestEventListener() {
113
116
  // prevent errors if the component will be called without a request - object
114
117
  if (!this.request) {
115
118
  return
@@ -149,14 +152,14 @@ export class FileUpload extends AlchemyHTMLElement {
149
152
  * @returns {string}
150
153
  */
151
154
  get errorMessage() {
152
- return this._errorMessage || ""
155
+ return this.#errorMessage || ""
153
156
  }
154
157
 
155
158
  /**
156
159
  * @param {string} message
157
160
  */
158
161
  set errorMessage(message) {
159
- this._errorMessage = message
162
+ this.#errorMessage = message
160
163
  const errorMessageContainer = this.querySelector(".error-message")
161
164
  if (errorMessageContainer) {
162
165
  errorMessageContainer.textContent = message
@@ -215,14 +218,14 @@ export class FileUpload extends AlchemyHTMLElement {
215
218
  * @returns {string}
216
219
  */
217
220
  get status() {
218
- return this._status
221
+ return this.#status
219
222
  }
220
223
 
221
224
  /**
222
225
  * @param {string} status
223
226
  */
224
227
  set status(status) {
225
- this._status = status
228
+ this.#status = status
226
229
  this.className = status
227
230
 
228
231
  this.progressElement?.toggleAttribute(
@@ -235,14 +238,14 @@ export class FileUpload extends AlchemyHTMLElement {
235
238
  * @returns {boolean}
236
239
  */
237
240
  get valid() {
238
- return this._valid
241
+ return this.#valid
239
242
  }
240
243
 
241
244
  /**
242
245
  * @param {boolean} isValid
243
246
  */
244
247
  set valid(isValid) {
245
- this._valid = isValid
248
+ this.#valid = isValid
246
249
  this.classList.toggle("invalid", !isValid)
247
250
  }
248
251
 
@@ -251,14 +254,14 @@ export class FileUpload extends AlchemyHTMLElement {
251
254
  * @returns {number}
252
255
  */
253
256
  get value() {
254
- return this._value
257
+ return this.#value
255
258
  }
256
259
 
257
260
  /**
258
261
  * @param {number} value
259
262
  */
260
263
  set value(value) {
261
- this._value = value
264
+ this.#value = value
262
265
  if (this.progressElement) {
263
266
  this.progressElement.value = value
264
267
  }
@@ -1,36 +1,41 @@
1
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
2
1
  import { FileUpload } from "alchemy_admin/components/uploader/file_upload"
3
2
  import { formatFileSize } from "alchemy_admin/utils/format"
4
3
  import { translate } from "alchemy_admin/i18n"
5
4
 
6
- export class Progress extends AlchemyHTMLElement {
5
+ const template = (buttonLabel, fileCount) => `
6
+ <sl-progress-bar value="0"></sl-progress-bar>
7
+ <div class="overall-progress-value">
8
+ <span class="value-text"></span>
9
+
10
+ <sl-tooltip content="${buttonLabel}">
11
+ <button class="icon_button" aria-label="${buttonLabel}">
12
+ <alchemy-icon name="close"></alchemy-icon>
13
+ </button>
14
+ </sl-tooltip>
15
+ </div>
16
+ <div class="single-uploads" style="--progress-columns: ${
17
+ fileCount > 3 ? 3 : fileCount
18
+ }"></div>
19
+ <div class="overall-upload-value value-text"></div>
20
+ `
21
+
22
+ export class Progress extends HTMLElement {
23
+ // public — accessed by Uploader and tests
24
+ fileCount = 0
25
+
26
+ // private — backing state and internals
27
+ #fileUploads = []
28
+ #buttonLabel = translate("Cancel all uploads")
29
+ #actionButton = null
7
30
  #visible = false
31
+ #handleFileChange = () => this.#updateView()
8
32
 
9
- constructor() {
10
- super()
11
- this.buttonLabel = translate("Cancel all uploads")
12
- this.fileUploads = []
13
- this.fileCount = 0
14
- this.className = "in-progress"
33
+ connectedCallback() {
34
+ this.innerHTML = template(this.#buttonLabel, this.fileCount)
15
35
  this.visible = true
16
- this.handleFileChange = () => this._updateView()
17
- }
18
36
 
19
- /**
20
- * Initialize the component with file uploads
21
- * @param {FileUpload[]} fileUploads
22
- */
23
- initialize(fileUploads = []) {
24
- this.fileUploads = fileUploads
25
- this.fileCount = fileUploads.length
26
- }
27
-
28
- /**
29
- * append file progress - components for each file
30
- */
31
- afterRender() {
32
- this.actionButton = this.querySelector("button")
33
- this.actionButton.addEventListener("click", () => {
37
+ this.#actionButton = this.querySelector("button")
38
+ this.#actionButton.addEventListener("click", () => {
34
39
  if (this.finished) {
35
40
  this.onComplete(this.status)
36
41
  } else {
@@ -38,34 +43,38 @@ export class Progress extends AlchemyHTMLElement {
38
43
  }
39
44
  })
40
45
 
41
- this.fileUploads.forEach((fileUpload) => {
46
+ this.#fileUploads.forEach((fileUpload) => {
42
47
  this.querySelector(".single-uploads").append(fileUpload)
43
48
  })
49
+
50
+ this.#updateView()
51
+ this.addEventListener("Alchemy.FileUpload.Change", this.#handleFileChange)
44
52
  }
45
53
 
46
- /**
47
- * cancel requests in all remaining uploads
48
- */
49
- cancel() {
50
- this._activeUploads().forEach((upload) => {
51
- upload.cancel()
52
- })
53
- this._setupCloseButton()
54
+ disconnectedCallback() {
55
+ this.removeEventListener(
56
+ "Alchemy.FileUpload.Change",
57
+ this.#handleFileChange
58
+ )
54
59
  }
55
60
 
56
61
  /**
57
- * update view and register change event
62
+ * Initialize the component with file uploads
63
+ * @param {FileUpload[]} fileUploads
58
64
  */
59
- connected() {
60
- this._updateView()
61
- this.addEventListener("Alchemy.FileUpload.Change", this.handleFileChange)
65
+ initialize(fileUploads = []) {
66
+ this.#fileUploads = fileUploads
67
+ this.fileCount = fileUploads.length
62
68
  }
63
69
 
64
70
  /**
65
- * deregister file upload change - event
71
+ * cancel requests in all remaining uploads
66
72
  */
67
- disconnected() {
68
- this.removeEventListener("Alchemy.FileUpload.Change", this.handleFileChange)
73
+ cancel() {
74
+ this.#activeUploads().forEach((upload) => {
75
+ upload.cancel()
76
+ })
77
+ this.#setupCloseButton()
69
78
  }
70
79
 
71
80
  /**
@@ -75,51 +84,29 @@ export class Progress extends AlchemyHTMLElement {
75
84
  */
76
85
  onComplete(_status) {}
77
86
 
78
- render() {
79
- return `
80
- <sl-progress-bar value="0"></sl-progress-bar>
81
- <div class="overall-progress-value">
82
- <span class="value-text"></span>
83
-
84
- <sl-tooltip content="${this.buttonLabel}">
85
- <button class="icon_button" aria-label="${this.buttonLabel}">
86
- <alchemy-icon name="close"></alchemy-icon>
87
- </button>
88
- </sl-tooltip>
89
- </div>
90
- <div class="single-uploads" style="--progress-columns: ${
91
- this.fileCount > 3 ? 3 : this.fileCount
92
- }"></div>
93
- <div class="overall-upload-value value-text"></div>
94
- `
95
- }
96
-
97
87
  /**
98
88
  * get all active upload components
99
89
  * @returns {FileUpload[]}
100
- * @private
101
90
  */
102
- _activeUploads() {
103
- return this.fileUploads.filter((upload) => upload.active)
91
+ #activeUploads() {
92
+ return this.#fileUploads.filter((upload) => upload.active)
104
93
  }
105
94
 
106
95
  /**
107
96
  * replace cancel button to be the close button
108
- * @private
109
97
  */
110
- _setupCloseButton() {
111
- this.buttonLabel = translate("Close")
112
- this.actionButton.ariaLabel = this.buttonLabel
113
- this.actionButton.parentElement.content = this.buttonLabel // update tooltip content
98
+ #setupCloseButton() {
99
+ this.#buttonLabel = translate("Close")
100
+ this.#actionButton.ariaLabel = this.#buttonLabel
101
+ this.#actionButton.parentElement.content = this.#buttonLabel // update tooltip content
114
102
  }
115
103
 
116
104
  /**
117
105
  * @param {string} field
118
106
  * @returns {number}
119
- * @private
120
107
  */
121
- _sumFileProgresses(field) {
122
- return this._activeUploads().reduce(
108
+ #sumFileProgresses(field) {
109
+ return this.#activeUploads().reduce(
123
110
  (accumulator, upload) => upload[field] + accumulator,
124
111
  0
125
112
  )
@@ -127,9 +114,8 @@ export class Progress extends AlchemyHTMLElement {
127
114
 
128
115
  /**
129
116
  * don't render the whole element new, because it would prevent selecting buttons
130
- * @private
131
117
  */
132
- _updateView() {
118
+ #updateView() {
133
119
  const status = this.status
134
120
  this.className = status
135
121
 
@@ -147,7 +133,7 @@ export class Progress extends AlchemyHTMLElement {
147
133
  this.overallUploadSize
148
134
 
149
135
  if (this.finished) {
150
- this._setupCloseButton()
136
+ this.#setupCloseButton()
151
137
  this.onComplete(status)
152
138
  } else {
153
139
  this.visible = true
@@ -158,34 +144,34 @@ export class Progress extends AlchemyHTMLElement {
158
144
  * @returns {boolean}
159
145
  */
160
146
  get finished() {
161
- return this._activeUploads().every((entry) => entry.finished)
147
+ return this.#activeUploads().every((entry) => entry.finished)
162
148
  }
163
149
 
164
150
  /**
165
151
  * @returns {string}
166
152
  */
167
153
  get overallUploadSize() {
168
- const uploadedFileCount = this._activeUploads().filter(
154
+ const uploadedFileCount = this.#activeUploads().filter(
169
155
  (fileProgress) => fileProgress.value >= 100
170
156
  ).length
171
157
  const overallProgressValue = `${
172
158
  this.totalProgress
173
- }% (${uploadedFileCount} / ${this._activeUploads().length})`
159
+ }% (${uploadedFileCount} / ${this.#activeUploads().length})`
174
160
 
175
161
  return `${formatFileSize(
176
- this._sumFileProgresses("progressEventLoaded")
177
- )} / ${formatFileSize(this._sumFileProgresses("progressEventTotal"))}`
162
+ this.#sumFileProgresses("progressEventLoaded")
163
+ )} / ${formatFileSize(this.#sumFileProgresses("progressEventTotal"))}`
178
164
  }
179
165
 
180
166
  /**
181
167
  * @returns {string}
182
168
  */
183
169
  get overallProgressValue() {
184
- const uploadedFileCount = this._activeUploads().filter(
170
+ const uploadedFileCount = this.#activeUploads().filter(
185
171
  (fileProgress) => fileProgress.value >= 100
186
172
  ).length
187
173
  return `${this.totalProgress}% (${uploadedFileCount} / ${
188
- this._activeUploads().length
174
+ this.#activeUploads().length
189
175
  })`
190
176
  }
191
177
 
@@ -201,7 +187,7 @@ export class Progress extends AlchemyHTMLElement {
201
187
  * @returns {string}
202
188
  */
203
189
  get status() {
204
- const uploadsStatuses = this._activeUploads().map(
190
+ const uploadsStatuses = this.#activeUploads().map(
205
191
  (upload) => upload.className
206
192
  )
207
193
 
@@ -227,12 +213,12 @@ export class Progress extends AlchemyHTMLElement {
227
213
  * @returns {number}
228
214
  */
229
215
  get totalProgress() {
230
- const totalSize = this._activeUploads().reduce(
216
+ const totalSize = this.#activeUploads().reduce(
231
217
  (accumulator, upload) => accumulator + upload.file.size,
232
218
  0
233
219
  )
234
220
  let totalProgress = Math.ceil(
235
- this._activeUploads().reduce((accumulator, upload) => {
221
+ this.#activeUploads().reduce((accumulator, upload) => {
236
222
  const weight = upload.file.size / totalSize
237
223
  return upload.value * weight + accumulator
238
224
  }, 0)
@@ -3,36 +3,75 @@
3
3
  * @property {string} name
4
4
  * @property {number} size
5
5
  */
6
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
7
6
  import { Progress } from "alchemy_admin/components/uploader/progress"
8
7
  import { FileUpload } from "alchemy_admin/components/uploader/file_upload"
9
8
  import { translate } from "alchemy_admin/i18n"
10
9
  import { getToken } from "alchemy_admin/utils/ajax"
11
10
 
12
- export class Uploader extends AlchemyHTMLElement {
13
- static properties = {
14
- dropzone: { default: false }
15
- }
11
+ export class Uploader extends HTMLElement {
12
+ #dropzoneElement = null
13
+ #isDraggedOver = false
16
14
 
17
- connected() {
18
- this.fileInput.addEventListener("change", (event) => {
19
- this._uploadFiles(Array.from(event.target.files))
20
- })
15
+ connectedCallback() {
16
+ this.fileInput.addEventListener("change", this.#onFileInputChange)
21
17
  if (this.dropzone) {
22
- this._dragAndDropBehavior()
18
+ this.#setupDropZone()
23
19
  }
24
20
  this.addEventListener("Alchemy.upload.successful", this)
25
21
  }
26
22
 
23
+ disconnectedCallback() {
24
+ this.fileInput?.removeEventListener("change", this.#onFileInputChange)
25
+ if (this.#dropzoneElement) {
26
+ this.#dropzoneElement.removeEventListener(
27
+ "dragleave",
28
+ this.#onDropzoneDragleave
29
+ )
30
+ this.#dropzoneElement.removeEventListener("drop", this.#onDropzoneDrop)
31
+ this.#dropzoneElement.removeEventListener(
32
+ "dragover",
33
+ this.#onDropzoneDragover
34
+ )
35
+ this.#dropzoneElement = null
36
+ }
37
+ }
38
+
27
39
  handleEvent(evt) {
28
40
  switch (evt.type) {
29
41
  case "Alchemy.upload.successful":
30
- this._handleUploadComplete()
42
+ this.#handleUploadComplete()
31
43
  break
32
44
  }
33
45
  }
34
46
 
35
- _handleUploadComplete() {
47
+ #onFileInputChange = (event) => {
48
+ this.uploadFiles(Array.from(event.target.files))
49
+ }
50
+
51
+ #toggleDropzoneClass = (enabled) => {
52
+ if (this.#isDraggedOver !== enabled) {
53
+ this.#isDraggedOver = enabled
54
+ this.#dropzoneElement.classList.toggle("dragover")
55
+ }
56
+ }
57
+
58
+ #onDropzoneDragleave = () => this.#toggleDropzoneClass(false)
59
+
60
+ #onDropzoneDrop = async (event) => {
61
+ event.preventDefault()
62
+ this.#toggleDropzoneClass(false)
63
+
64
+ const files = [...event.dataTransfer.items].map((item) => item.getAsFile())
65
+
66
+ this.uploadFiles(files)
67
+ }
68
+
69
+ #onDropzoneDragover = (event) => {
70
+ event.preventDefault() // dragover has to be disabled to use the custom drop event
71
+ this.#toggleDropzoneClass(true)
72
+ }
73
+
74
+ #handleUploadComplete() {
36
75
  setTimeout(() => {
37
76
  const url = this.redirectUrl
38
77
  const turboFrame = this.closest("turbo-frame")
@@ -53,42 +92,22 @@ export class Uploader extends AlchemyHTMLElement {
53
92
  * add dragover class to indicate, if the file is draggable
54
93
  * @private
55
94
  */
56
- _dragAndDropBehavior() {
57
- const dropzoneElement = document.querySelector(this.dropzone)
58
- let isDraggedOver = false
59
-
60
- const toggleDropzoneClass = (enabled) => {
61
- if (isDraggedOver !== enabled) {
62
- isDraggedOver = enabled
63
- dropzoneElement.classList.toggle("dragover")
64
- }
65
- }
95
+ #setupDropZone() {
96
+ this.#dropzoneElement = document.querySelector(this.dropzone)
97
+ if (!this.#dropzoneElement) return
66
98
 
67
- dropzoneElement.addEventListener("dragleave", () =>
68
- toggleDropzoneClass(false)
99
+ this.#dropzoneElement.addEventListener(
100
+ "dragleave",
101
+ this.#onDropzoneDragleave
69
102
  )
70
- dropzoneElement.addEventListener("drop", async (event) => {
71
- event.preventDefault()
72
- toggleDropzoneClass(false)
73
-
74
- const files = [...event.dataTransfer.items].map((item) =>
75
- item.getAsFile()
76
- )
77
-
78
- this._uploadFiles(files)
79
- })
80
-
81
- dropzoneElement.addEventListener("dragover", (event) => {
82
- event.preventDefault() // dragover has to be disabled to use the custom drop event
83
- toggleDropzoneClass(true)
84
- })
103
+ this.#dropzoneElement.addEventListener("drop", this.#onDropzoneDrop)
104
+ this.#dropzoneElement.addEventListener("dragover", this.#onDropzoneDragover)
85
105
  }
86
106
 
87
107
  /**
88
108
  * @param {File[]} files
89
- * @private
90
109
  */
91
- _uploadFiles(files) {
110
+ uploadFiles(files) {
92
111
  // prepare file progress bars and server request
93
112
  let fileUploadCount = 0
94
113
 
@@ -102,13 +121,13 @@ export class Uploader extends AlchemyHTMLElement {
102
121
  fileUpload.errorMessage = translate("Maximum number of files exceeded")
103
122
  } else if (fileUpload.valid) {
104
123
  fileUploadCount++
105
- this._submitFile(request, file)
124
+ this.#submitFile(request, file)
106
125
  }
107
126
 
108
127
  return fileUpload
109
128
  })
110
129
 
111
- this._createProgress(fileUploads)
130
+ this.#createProgress(fileUploads)
112
131
  }
113
132
 
114
133
  /**
@@ -116,7 +135,7 @@ export class Uploader extends AlchemyHTMLElement {
116
135
  * @param {File} file
117
136
  * @private
118
137
  */
119
- _submitFile(request, file) {
138
+ #submitFile(request, file) {
120
139
  const form = this.querySelector("form")
121
140
  const formData = new FormData(form)
122
141
  formData.set(this.fileInput.name, file)
@@ -132,7 +151,7 @@ export class Uploader extends AlchemyHTMLElement {
132
151
  * @param {FileUpload[]} fileUploads
133
152
  * @private
134
153
  */
135
- _createProgress(fileUploads) {
154
+ #createProgress(fileUploads) {
136
155
  if (this.uploadProgress) {
137
156
  this.uploadProgress.cancel()
138
157
  document.body.removeChild(this.uploadProgress)
@@ -140,12 +159,18 @@ export class Uploader extends AlchemyHTMLElement {
140
159
  this.uploadProgress = new Progress()
141
160
  this.uploadProgress.initialize(fileUploads)
142
161
  this.uploadProgress.onComplete = (status) => {
143
- this.dispatchCustomEvent(`upload.${status}`)
162
+ this.dispatchEvent(
163
+ new CustomEvent(`Alchemy.upload.${status}`, { bubbles: true })
164
+ )
144
165
  }
145
166
 
146
167
  document.body.append(this.uploadProgress)
147
168
  }
148
169
 
170
+ get dropzone() {
171
+ return this.getAttribute("dropzone")
172
+ }
173
+
149
174
  /**
150
175
  * @returns {HTMLInputElement}
151
176
  */
@@ -126,6 +126,9 @@ export class Dialog {
126
126
  init() {
127
127
  Hotkeys(this.dialog_body)
128
128
  this.watch_remote_forms()
129
+ window.requestAnimationFrame(() => {
130
+ this.dialog_body.find("[autofocus]").focus()
131
+ })
129
132
  }
130
133
 
131
134
  // Watches ajax requests inside of dialog body and replaces the content accordingly
@@ -1,23 +1,7 @@
1
1
  import "keymaster"
2
- import { openDialog } from "alchemy_admin/dialog"
3
2
 
4
3
  const bindedHotkeys = []
5
4
 
6
- function showHelp(evt) {
7
- if (
8
- !$(evt.target).is("input, textarea") &&
9
- String.fromCharCode(evt.which) === "?"
10
- ) {
11
- openDialog("/admin/help", {
12
- title: Alchemy.t("help"),
13
- size: "400x492"
14
- })
15
- return false
16
- } else {
17
- return true
18
- }
19
- }
20
-
21
5
  export default function (scope = document) {
22
6
  // The scope can be a jQuery object because we still use jQuery in alchemy_admin/dialog.js.
23
7
  if (scope instanceof jQuery) {
@@ -26,8 +10,6 @@ export default function (scope = document) {
26
10
 
27
11
  // Unbind all previously registered hotkeys if we are not inside a dialog.
28
12
  if (scope === document) {
29
- document.removeEventListener("keypress", showHelp)
30
- document.addEventListener("keypress", showHelp)
31
13
  bindedHotkeys.forEach((hotkey) => key.unbind(hotkey))
32
14
  }
33
15