alchemy_cms 8.0.11 → 8.1.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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -10
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  5. data/app/assets/builds/alchemy/light-theme.css +1 -1
  6. data/app/assets/builds/alchemy/preview.min.js +1 -1
  7. data/app/assets/builds/alchemy/theme.css +1 -1
  8. data/app/{views/alchemy/admin/elements/_element.html.erb → components/alchemy/admin/element_editor.html.erb} +34 -29
  9. data/app/components/alchemy/admin/element_editor.rb +115 -0
  10. data/app/components/alchemy/admin/element_select.rb +12 -9
  11. data/app/components/alchemy/admin/ingredient_editor.rb +54 -0
  12. data/app/components/alchemy/admin/list_filter.rb +16 -5
  13. data/app/components/alchemy/admin/page_node.html.erb +214 -0
  14. data/app/components/alchemy/admin/page_node.rb +70 -0
  15. data/app/components/alchemy/admin/picture_thumbnail.rb +36 -0
  16. data/app/components/alchemy/admin/publish_page_button.html.erb +15 -0
  17. data/app/components/alchemy/admin/publish_page_button.rb +54 -0
  18. data/app/{helpers/alchemy/admin/tags_helper.rb → components/alchemy/admin/tags_list.rb} +19 -11
  19. data/app/components/alchemy/admin/toolbar_button.rb +17 -13
  20. data/app/components/alchemy/ingredients/audio_editor.rb +8 -0
  21. data/app/components/alchemy/ingredients/base_editor.rb +222 -0
  22. data/app/components/alchemy/ingredients/boolean_editor.rb +21 -0
  23. data/app/components/alchemy/ingredients/color_editor.rb +80 -0
  24. data/app/components/alchemy/ingredients/color_view.rb +13 -0
  25. data/app/components/alchemy/ingredients/datetime_editor.rb +28 -0
  26. data/app/components/alchemy/ingredients/file_editor.rb +69 -0
  27. data/app/components/alchemy/ingredients/headline_editor.rb +88 -0
  28. data/app/components/alchemy/ingredients/html_editor.rb +11 -0
  29. data/app/components/alchemy/ingredients/link_editor.rb +29 -0
  30. data/app/components/alchemy/ingredients/node_editor.rb +23 -0
  31. data/app/components/alchemy/ingredients/number_editor.rb +28 -0
  32. data/app/components/alchemy/ingredients/page_editor.rb +19 -0
  33. data/app/components/alchemy/ingredients/picture_editor.rb +81 -0
  34. data/app/components/alchemy/ingredients/richtext_editor.rb +31 -0
  35. data/app/components/alchemy/ingredients/select_editor.rb +37 -0
  36. data/app/components/alchemy/ingredients/select_view.rb +7 -0
  37. data/app/components/alchemy/ingredients/text_editor.rb +41 -0
  38. data/app/components/alchemy/ingredients/video_editor.rb +8 -0
  39. data/app/controllers/alchemy/admin/attachments_controller.rb +8 -6
  40. data/app/controllers/alchemy/admin/base_controller.rb +7 -18
  41. data/app/controllers/alchemy/admin/clipboard_controller.rb +15 -11
  42. data/app/controllers/alchemy/admin/dashboard_controller.rb +2 -2
  43. data/app/controllers/alchemy/admin/elements_controller.rb +34 -32
  44. data/app/controllers/alchemy/admin/ingredients_controller.rb +1 -0
  45. data/app/controllers/alchemy/admin/languages_controller.rb +0 -3
  46. data/app/controllers/alchemy/admin/layoutpages_controller.rb +2 -1
  47. data/app/controllers/alchemy/admin/legacy_page_urls_controller.rb +1 -1
  48. data/app/controllers/alchemy/admin/nodes_controller.rb +24 -1
  49. data/app/controllers/alchemy/admin/pages_controller.rb +36 -42
  50. data/app/controllers/alchemy/admin/pictures_controller.rb +2 -5
  51. data/app/controllers/alchemy/admin/resources_controller.rb +1 -1
  52. data/app/controllers/alchemy/api/ingredients_controller.rb +1 -1
  53. data/app/controllers/alchemy/api/pages_controller.rb +5 -3
  54. data/app/controllers/alchemy/base_controller.rb +6 -6
  55. data/app/controllers/alchemy/pages_controller.rb +12 -6
  56. data/app/controllers/concerns/alchemy/admin/archive_overlay.rb +0 -1
  57. data/app/controllers/concerns/alchemy/admin/clipboard.rb +57 -0
  58. data/app/controllers/concerns/alchemy/admin/uploader_responses.rb +2 -2
  59. data/app/controllers/concerns/alchemy/site_redirects.rb +1 -1
  60. data/app/decorators/alchemy/ingredient_editor.rb +37 -4
  61. data/app/helpers/alchemy/admin/base_helper.rb +10 -6
  62. data/app/helpers/alchemy/admin/ingredients_helper.rb +6 -3
  63. data/app/helpers/alchemy/base_helper.rb +1 -1
  64. data/app/helpers/alchemy/pages_helper.rb +1 -1
  65. data/app/javascript/alchemy_admin/components/action.js +5 -1
  66. data/app/javascript/alchemy_admin/components/color_select.js +73 -0
  67. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +11 -3
  68. data/app/javascript/alchemy_admin/components/element_editor/publish_element_button.js +7 -2
  69. data/app/javascript/alchemy_admin/components/element_editor.js +11 -12
  70. data/app/javascript/alchemy_admin/components/element_select.js +39 -17
  71. data/app/javascript/alchemy_admin/components/elements_window.js +0 -2
  72. data/app/javascript/alchemy_admin/components/file_editor.js +26 -0
  73. data/app/javascript/alchemy_admin/components/index.js +9 -0
  74. data/app/javascript/alchemy_admin/components/list_filter.js +57 -8
  75. data/app/javascript/alchemy_admin/components/message.js +9 -3
  76. data/app/javascript/alchemy_admin/components/page_node.js +119 -0
  77. data/app/javascript/alchemy_admin/{page_publication_fields.js → components/page_publication_fields.js} +9 -8
  78. data/app/javascript/alchemy_admin/{picture_editors.js → components/picture_editor.js} +30 -45
  79. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +107 -0
  80. data/app/javascript/alchemy_admin/components/publish_page_button.js +41 -0
  81. data/app/javascript/alchemy_admin/components/select.js +3 -1
  82. data/app/javascript/alchemy_admin/components/sitemap.js +210 -0
  83. data/app/javascript/alchemy_admin/{sortable_elements.js → components/sortable_elements.js} +22 -25
  84. data/app/javascript/alchemy_admin/components/tinymce.js +10 -5
  85. data/app/javascript/alchemy_admin/components/uploader.js +30 -0
  86. data/app/javascript/alchemy_admin/image_overlay.js +0 -2
  87. data/app/javascript/alchemy_admin/initializer.js +0 -3
  88. data/app/javascript/alchemy_admin/link_dialog.js +1 -6
  89. data/app/javascript/alchemy_admin/templates/compiled.js +1 -1
  90. data/app/javascript/alchemy_admin/utils/ajax.js +15 -3
  91. data/app/javascript/alchemy_admin.js +0 -6
  92. data/app/models/alchemy/attachment.rb +4 -4
  93. data/app/models/alchemy/element/definitions.rb +1 -2
  94. data/app/models/alchemy/element/element_ingredients.rb +6 -2
  95. data/app/models/alchemy/element.rb +54 -13
  96. data/app/models/alchemy/element_definition.rb +4 -1
  97. data/app/models/alchemy/elements_repository.rb +6 -0
  98. data/app/models/alchemy/folded_page.rb +2 -2
  99. data/app/models/alchemy/ingredient.rb +38 -1
  100. data/app/models/alchemy/ingredient_definition.rb +4 -1
  101. data/app/models/alchemy/ingredient_validator.rb +6 -2
  102. data/app/models/alchemy/ingredients/color.rb +10 -0
  103. data/app/models/alchemy/ingredients/headline.rb +2 -17
  104. data/app/models/alchemy/ingredients/picture.rb +4 -4
  105. data/app/models/alchemy/ingredients/select.rb +19 -0
  106. data/app/models/alchemy/language/code.rb +0 -1
  107. data/app/models/alchemy/node.rb +28 -1
  108. data/app/models/alchemy/page/page_naming.rb +0 -7
  109. data/app/models/alchemy/page/page_natures.rb +7 -3
  110. data/app/models/alchemy/page/page_scopes.rb +13 -1
  111. data/app/models/alchemy/page/publisher.rb +14 -2
  112. data/app/models/alchemy/page.rb +102 -23
  113. data/app/models/alchemy/page_definition.rb +4 -1
  114. data/app/models/alchemy/page_version.rb +22 -6
  115. data/app/models/alchemy/picture.rb +10 -11
  116. data/app/models/alchemy/picture_variant.rb +1 -3
  117. data/app/models/alchemy/resource.rb +1 -1
  118. data/app/models/alchemy/storage_adapter/active_storage.rb +14 -2
  119. data/app/models/alchemy/storage_adapter/dragonfly.rb +12 -0
  120. data/app/models/alchemy/storage_adapter.rb +2 -0
  121. data/app/models/concerns/alchemy/picture_thumbnails.rb +4 -4
  122. data/app/models/concerns/alchemy/publishable.rb +54 -0
  123. data/app/serializers/alchemy/page_tree_serializer.rb +11 -31
  124. data/app/services/alchemy/copy_page.rb +17 -0
  125. data/app/services/alchemy/duplicate_element.rb +1 -1
  126. data/app/services/alchemy/page_tree_preloader.rb +105 -0
  127. data/app/stylesheets/alchemy/_extends.scss +3 -9
  128. data/app/stylesheets/alchemy/_mixins.scss +3 -1
  129. data/app/stylesheets/alchemy/_themes.scss +19 -10
  130. data/app/stylesheets/alchemy/admin/archive.scss +1 -0
  131. data/app/stylesheets/alchemy/admin/base.scss +5 -2
  132. data/app/stylesheets/alchemy/admin/buttons.scss +3 -3
  133. data/app/stylesheets/alchemy/admin/element-select.scss +18 -0
  134. data/app/stylesheets/alchemy/admin/elements.scss +123 -23
  135. data/app/stylesheets/alchemy/admin/errors.scss +1 -1
  136. data/app/stylesheets/alchemy/admin/flash.scss +6 -4
  137. data/app/stylesheets/alchemy/admin/images.scss +9 -5
  138. data/app/stylesheets/alchemy/admin/list_filter.scss +4 -4
  139. data/app/stylesheets/alchemy/admin/navigation.scss +1 -1
  140. data/app/stylesheets/alchemy/admin/notices.scss +1 -2
  141. data/app/stylesheets/alchemy/admin/selects.scss +36 -21
  142. data/app/stylesheets/alchemy/admin/shoelace.scss +14 -1
  143. data/app/stylesheets/alchemy/admin/sitemap.scss +11 -3
  144. data/app/stylesheets/alchemy/admin/tags.scss +3 -1
  145. data/app/stylesheets/alchemy/admin/toolbar.scss +1 -1
  146. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  147. data/app/views/alchemy/_menubar.html.erb +1 -1
  148. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +35 -31
  149. data/app/views/alchemy/admin/attachments/_library_sidebar.html.erb +6 -0
  150. data/app/views/alchemy/admin/attachments/_overlay_file_list.html.erb +1 -1
  151. data/app/views/alchemy/admin/attachments/_replace_button.html.erb +1 -8
  152. data/app/views/alchemy/admin/attachments/_sorting_select.html.erb +13 -0
  153. data/app/views/alchemy/admin/attachments/_tag_list.html.erb +2 -3
  154. data/app/views/alchemy/admin/attachments/index.html.erb +5 -11
  155. data/app/views/alchemy/admin/attachments/show.html.erb +1 -1
  156. data/app/views/alchemy/admin/clipboard/_button.html.erb +1 -0
  157. data/app/views/alchemy/admin/clipboard/index.html.erb +4 -5
  158. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  159. data/app/views/alchemy/admin/crop.html.erb +5 -7
  160. data/app/views/alchemy/admin/dashboard/widgets/_locked_pages.html.erb +1 -1
  161. data/app/views/alchemy/admin/elements/_add_nested_element_form.html.erb +6 -6
  162. data/app/views/alchemy/admin/elements/_fixed_element.html.erb +1 -1
  163. data/app/views/alchemy/admin/elements/_footer.html.erb +7 -1
  164. data/app/views/alchemy/admin/elements/_header.html.erb +5 -5
  165. data/app/views/alchemy/admin/elements/_toolbar.html.erb +33 -8
  166. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +10 -10
  167. data/app/views/alchemy/admin/elements/index.html.erb +29 -16
  168. data/app/views/alchemy/admin/elements/new.html.erb +2 -2
  169. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +3 -5
  170. data/app/views/alchemy/admin/leave.html.erb +1 -1
  171. data/app/views/alchemy/admin/nodes/_node.html.erb +19 -0
  172. data/app/views/alchemy/admin/nodes/edit.html.erb +1 -1
  173. data/app/views/alchemy/admin/nodes/index.html.erb +3 -1
  174. data/app/views/alchemy/admin/nodes/new.html.erb +14 -1
  175. data/app/views/alchemy/admin/pages/_current_page.html.erb +3 -1
  176. data/app/views/alchemy/admin/pages/_form.html.erb +21 -9
  177. data/app/views/alchemy/admin/pages/_page_status.html.erb +1 -1
  178. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +28 -26
  179. data/app/views/alchemy/admin/pages/_table.html.erb +0 -7
  180. data/app/views/alchemy/admin/pages/_toolbar.html.erb +3 -6
  181. data/app/views/alchemy/admin/pages/edit.html.erb +5 -11
  182. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +2 -0
  183. data/app/views/alchemy/admin/pages/fold.turbo_stream.erb +5 -0
  184. data/app/views/alchemy/admin/pages/index.html.erb +5 -3
  185. data/app/views/alchemy/admin/pages/new.html.erb +2 -12
  186. data/app/views/alchemy/admin/pages/publish.turbo_stream.erb +12 -0
  187. data/app/views/alchemy/admin/pages/tree.html.erb +13 -0
  188. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +5 -16
  189. data/app/views/alchemy/admin/partials/_flash_notices.html.erb +1 -1
  190. data/app/views/alchemy/admin/partials/{_remote_search_form.html.erb → _overlay_search_form.html.erb} +1 -2
  191. data/app/views/alchemy/admin/partials/_paste_from_clipboard_form.html.erb +12 -0
  192. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +24 -21
  193. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +18 -26
  194. data/app/views/alchemy/admin/pictures/_picture.html.erb +11 -15
  195. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -6
  196. data/app/views/alchemy/admin/pictures/_tag_list.html.erb +2 -3
  197. data/app/views/alchemy/admin/pictures/index.html.erb +0 -1
  198. data/app/views/alchemy/admin/pictures/update.turbo_stream.erb +1 -1
  199. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +1 -1
  200. data/app/views/alchemy/admin/resources/_tag_list.html.erb +2 -3
  201. data/app/views/alchemy/admin/styleguide/index.html.erb +25 -20
  202. data/app/views/alchemy/admin/tags/edit.html.erb +1 -1
  203. data/app/views/alchemy/admin/tinymce/_setup.html.erb +2 -2
  204. data/app/views/alchemy/admin/uploader/_button.html.erb +1 -15
  205. data/app/views/alchemy/attachments/show.html.erb +1 -1
  206. data/app/views/alchemy/base/permission_denied.js.erb +1 -1
  207. data/app/views/alchemy/ingredients/shared/_anchor.html.erb +9 -7
  208. data/app/views/alchemy/ingredients/shared/_link_tools.html.erb +12 -5
  209. data/app/views/alchemy/ingredients/shared/_picture_tools.html.erb +10 -11
  210. data/app/views/alchemy/language_links/_spacer.html.erb +1 -1
  211. data/app/views/alchemy/messages_mailer/new.html.erb +1 -1
  212. data/app/views/alchemy/welcome.html.erb +1 -1
  213. data/config/locales/alchemy.en.yml +12 -3
  214. data/config/routes.rb +2 -2
  215. data/db/migrate/20230123112425_add_searchable_to_alchemy_pages.rb +1 -1
  216. data/db/migrate/20230505132743_add_indexes_to_alchemy_pictures.rb +1 -1
  217. data/db/migrate/20231113104432_create_page_mutexes.rb +1 -1
  218. data/db/migrate/20240314105244_create_alchemy_picture_descriptions.rb +1 -1
  219. data/db/migrate/20250626160259_add_unique_index_to_picture_descriptions.rb +1 -1
  220. data/db/migrate/20250905140323_add_created_at_index_to_pictures_and_attachments.rb +1 -1
  221. data/db/migrate/20251106150010_convert_select_value_for_multiple.rb +11 -0
  222. data/db/migrate/20260102121232_add_metadata_to_page_versions.rb +9 -0
  223. data/db/migrate/20260115164704_add_publication_timestamps_to_alchemy_elements.rb +30 -0
  224. data/db/migrate/20260115164705_add_index_to_element_publication_timestamps.rb +13 -0
  225. data/lib/alchemy/ability_helper.rb +1 -3
  226. data/lib/alchemy/auth_accessors.rb +51 -117
  227. data/lib/alchemy/configuration.rb +1 -0
  228. data/lib/alchemy/configurations/main.rb +63 -0
  229. data/lib/alchemy/controller_actions.rb +2 -3
  230. data/lib/alchemy/engine.rb +9 -12
  231. data/lib/alchemy/error_tracking/error_logger.rb +1 -1
  232. data/lib/alchemy/errors.rb +1 -1
  233. data/lib/alchemy/logger.rb +34 -4
  234. data/lib/alchemy/name_conversions.rb +0 -6
  235. data/lib/alchemy/seeder.rb +2 -2
  236. data/lib/alchemy/tasks/usage.rb +4 -4
  237. data/lib/alchemy/test_support/factories/page_version_factory.rb +3 -0
  238. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +30 -0
  239. data/lib/alchemy/test_support/shared_ingredient_editor_examples.rb +26 -6
  240. data/lib/alchemy/test_support/shared_publishable_examples.rb +114 -0
  241. data/lib/alchemy/upgrader/eight_one.rb +56 -0
  242. data/lib/alchemy/upgrader.rb +9 -1
  243. data/lib/alchemy/version.rb +1 -1
  244. data/lib/alchemy.rb +1 -4
  245. data/lib/alchemy_cms.rb +0 -1
  246. data/lib/generators/alchemy/elements/templates/view.html.erb +3 -3
  247. data/lib/generators/alchemy/ingredient/ingredient_generator.rb +6 -8
  248. data/lib/generators/alchemy/ingredient/templates/editor_component.rb.tt +22 -0
  249. data/lib/generators/alchemy/page_layouts/templates/layout.html.erb +1 -1
  250. data/lib/generators/alchemy/site_layouts/templates/layout.html.erb +1 -1
  251. data/lib/tasks/alchemy/upgrade.rake +21 -7
  252. data/vendor/javascript/shoelace.min.js +713 -31
  253. data/vendor/javascript/tinymce.min.js +1 -1
  254. metadata +104 -84
  255. data/app/decorators/alchemy/element_editor.rb +0 -90
  256. data/app/helpers/alchemy/admin/pictures_helper.rb +0 -14
  257. data/app/javascript/alchemy_admin/file_editors.js +0 -28
  258. data/app/javascript/alchemy_admin/image_loader.js +0 -54
  259. data/app/javascript/alchemy_admin/page_sorter.js +0 -71
  260. data/app/javascript/alchemy_admin/sitemap.js +0 -154
  261. data/app/javascript/alchemy_admin/templates/page_folder.hbs +0 -3
  262. data/app/views/alchemy/admin/attachments/archive_overlay.js.erb +0 -4
  263. data/app/views/alchemy/admin/pages/_page.html.erb +0 -163
  264. data/app/views/alchemy/admin/pages/_sitemap.html.erb +0 -30
  265. data/app/views/alchemy/admin/pages/flush.js.erb +0 -2
  266. data/app/views/alchemy/admin/pictures/archive_overlay.js.erb +0 -5
  267. data/app/views/alchemy/admin/pictures/index.js.erb +0 -2
  268. data/app/views/alchemy/ingredients/_audio_editor.html.erb +0 -5
  269. data/app/views/alchemy/ingredients/_boolean_editor.html.erb +0 -11
  270. data/app/views/alchemy/ingredients/_datetime_editor.html.erb +0 -20
  271. data/app/views/alchemy/ingredients/_file_editor.html.erb +0 -52
  272. data/app/views/alchemy/ingredients/_headline_editor.html.erb +0 -44
  273. data/app/views/alchemy/ingredients/_html_editor.html.erb +0 -8
  274. data/app/views/alchemy/ingredients/_link_editor.html.erb +0 -30
  275. data/app/views/alchemy/ingredients/_node_editor.html.erb +0 -13
  276. data/app/views/alchemy/ingredients/_number_editor.html.erb +0 -24
  277. data/app/views/alchemy/ingredients/_page_editor.html.erb +0 -13
  278. data/app/views/alchemy/ingredients/_picture_editor.html.erb +0 -59
  279. data/app/views/alchemy/ingredients/_richtext_editor.html.erb +0 -15
  280. data/app/views/alchemy/ingredients/_select_editor.html.erb +0 -31
  281. data/app/views/alchemy/ingredients/_text_editor.html.erb +0 -29
  282. data/app/views/alchemy/ingredients/_video_editor.html.erb +0 -5
  283. data/lib/generators/alchemy/ingredient/templates/editor.html.erb +0 -14
  284. /data/{lib → app/models}/alchemy/permissions.rb +0 -0
@@ -0,0 +1,73 @@
1
+ const formatItem = (object) => {
2
+ const optionEl = object.element[0]
3
+ const swatch = optionEl.dataset.swatch || optionEl.value
4
+ const customColor = optionEl.value === "custom_color"
5
+ const colorIndicator = customColor
6
+ ? `<alchemy-icon name="palette"></alchemy-icon>`
7
+ : `<span class="color-indicator" style="--color: ${swatch}"></span>`
8
+
9
+ return `
10
+ <div class="select-color-option">
11
+ ${colorIndicator}
12
+ <span>${object.text}</span>
13
+ </div>`
14
+ }
15
+
16
+ class ColorSelect extends HTMLElement {
17
+ connectedCallback() {
18
+ if (this.select) {
19
+ this.#initializeSelect2()
20
+ $(this.select).on("change", (event) =>
21
+ this.#toggleColorPicker(event.val === "custom_color")
22
+ )
23
+ } else {
24
+ this.colorInput?.addEventListener("input", this)
25
+ this.textInput?.addEventListener("input", this)
26
+ this.#toggleColorPicker(true)
27
+ }
28
+ }
29
+
30
+ handleEvent(event) {
31
+ switch (event.target) {
32
+ case this.colorInput:
33
+ this.textInput.value = this.colorInput.value
34
+ break
35
+ case this.textInput:
36
+ this.colorInput.value = this.textInput.value
37
+ break
38
+ }
39
+ }
40
+
41
+ disconnectedCallback() {
42
+ this.colorInput?.removeEventListener("input", this)
43
+ this.textInput?.removeEventListener("input", this)
44
+ }
45
+
46
+ #initializeSelect2() {
47
+ this.select.classList.add("alchemy_selectbox")
48
+ const options = {
49
+ minimumResultsForSearch: 10,
50
+ formatResult: formatItem,
51
+ formatSelection: formatItem
52
+ }
53
+ $(this.select).select2(options)
54
+ }
55
+
56
+ #toggleColorPicker(enabled = true) {
57
+ this.colorInput.disabled = !enabled
58
+ }
59
+
60
+ get colorInput() {
61
+ return this.querySelector("input[type='color']")
62
+ }
63
+
64
+ get textInput() {
65
+ return this.querySelector("input[type='text']")
66
+ }
67
+
68
+ get select() {
69
+ return this.querySelector("select")
70
+ }
71
+ }
72
+
73
+ customElements.define("alchemy-color-select", ColorSelect)
@@ -3,11 +3,12 @@ import { removeTab } from "alchemy_admin/fixed_elements"
3
3
  import { growl } from "alchemy_admin/growler"
4
4
  import { reloadPreview } from "alchemy_admin/components/preview_window"
5
5
  import { openConfirmDialog } from "alchemy_admin/confirm_dialog"
6
+ import { dispatchPageDirtyEvent } from "alchemy_admin/components/element_editor"
6
7
 
7
8
  export class DeleteElementButton extends HTMLElement {
8
9
  constructor() {
9
10
  super()
10
- this.addEventListener("click", this)
11
+ this.button?.addEventListener("click", this)
11
12
  }
12
13
 
13
14
  async handleEvent() {
@@ -18,7 +19,7 @@ export class DeleteElementButton extends HTMLElement {
18
19
  }
19
20
  }
20
21
 
21
- #removeElement(response) {
22
+ #removeElement(data) {
22
23
  const elementEditor = this.closest("alchemy-element-editor")
23
24
  elementEditor.addEventListener("transitionend", () => {
24
25
  if (elementEditor.fixed) {
@@ -27,7 +28,10 @@ export class DeleteElementButton extends HTMLElement {
27
28
  elementEditor.remove()
28
29
  })
29
30
  elementEditor.classList.add("dismiss")
30
- growl(response.message)
31
+ growl(data.message)
32
+ if (data.pageHasUnpublishedChanges) {
33
+ dispatchPageDirtyEvent(data)
34
+ }
31
35
  reloadPreview()
32
36
  }
33
37
 
@@ -38,6 +42,10 @@ export class DeleteElementButton extends HTMLElement {
38
42
  get message() {
39
43
  return this.getAttribute("message")
40
44
  }
45
+
46
+ get button() {
47
+ return this.querySelector("button")
48
+ }
41
49
  }
42
50
 
43
51
  customElements.define("alchemy-delete-element-button", DeleteElementButton)
@@ -1,6 +1,7 @@
1
1
  import { patch } from "alchemy_admin/utils/ajax"
2
2
  import { reloadPreview } from "alchemy_admin/components/preview_window"
3
3
  import { growl } from "alchemy_admin/growler"
4
+ import { dispatchPageDirtyEvent } from "alchemy_admin/components/element_editor"
4
5
 
5
6
  export class PublishElementButton extends HTMLElement {
6
7
  constructor() {
@@ -14,9 +15,13 @@ export class PublishElementButton extends HTMLElement {
14
15
  if (elementEditor === this.elementEditor) {
15
16
  patch(Alchemy.routes.publish_admin_element_path(this.elementId))
16
17
  .then((response) => {
17
- this.elementEditor.published = response.data.public
18
- this.tooltip.setAttribute("content", response.data.label)
18
+ const data = response.data
19
+ this.elementEditor.published = data.public
20
+ this.tooltip.setAttribute("content", data.label)
19
21
  reloadPreview()
22
+ if (data.pageHasUnpublishedChanges) {
23
+ dispatchPageDirtyEvent(data)
24
+ }
20
25
  })
21
26
  .catch((error) => growl(error.message, "error"))
22
27
  }
@@ -1,7 +1,3 @@
1
- import ImageLoader from "alchemy_admin/image_loader"
2
- import fileEditors from "alchemy_admin/file_editors"
3
- import pictureEditors from "alchemy_admin/picture_editors"
4
- import SortableElements from "alchemy_admin/sortable_elements"
5
1
  import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
6
2
  import { post } from "alchemy_admin/utils/ajax"
7
3
  import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
@@ -10,6 +6,14 @@ import { growl } from "alchemy_admin/growler"
10
6
  import "alchemy_admin/components/element_editor/publish_element_button"
11
7
  import "alchemy_admin/components/element_editor/delete_element_button"
12
8
 
9
+ export function dispatchPageDirtyEvent(data) {
10
+ document.dispatchEvent(
11
+ new CustomEvent("alchemy:page-dirty", {
12
+ detail: { tooltip: data.publishButtonTooltip }
13
+ })
14
+ )
15
+ }
16
+
13
17
  export class ElementEditor extends HTMLElement {
14
18
  constructor() {
15
19
  super()
@@ -50,14 +54,6 @@ export class ElementEditor extends HTMLElement {
50
54
  })
51
55
  this.removeAttribute("created")
52
56
  }
53
-
54
- // Init GUI elements
55
- ImageLoader.init(this)
56
- fileEditors(
57
- `#${this.id} .ingredient-editor.file, #${this.id} .ingredient-editor.audio, #${this.id} .ingredient-editor.video`
58
- )
59
- pictureEditors(`#${this.id} .ingredient-editor.picture`)
60
- SortableElements(`#${this.id} .nested-elements`)
61
57
  }
62
58
 
63
59
  handleEvent(event) {
@@ -163,6 +159,9 @@ export class ElementEditor extends HTMLElement {
163
159
  data.ingredientAnchors.forEach((anchor) => {
164
160
  IngredientAnchorLink.updateIcon(anchor.ingredientId, anchor.active)
165
161
  })
162
+ if (data.pageHasUnpublishedChanges) {
163
+ dispatchPageDirtyEvent(data)
164
+ }
166
165
  }
167
166
  }
168
167
 
@@ -1,43 +1,65 @@
1
1
  import { hightlightTerm } from "alchemy_admin/components/remote_select"
2
2
 
3
- const formatItem = (icon, text) => {
4
- return `<div class="element-select-item">${icon} ${text}</div>`
3
+ const formatSelection = (option) => {
4
+ return `
5
+ <div class="element-select-name">${option.icon} ${option.name}</div>
6
+ `
5
7
  }
6
8
 
7
- class ElementSelect extends HTMLInputElement {
9
+ const formatItem = (icon, name, hint) => {
10
+ const description = hint
11
+ ? `<div class="element-select-description">${hint}</div>`
12
+ : ""
13
+ return `
14
+ <div class="element-select-item">
15
+ ${formatSelection({ icon, name })}
16
+ ${description}
17
+ </div>
18
+ `
19
+ }
20
+
21
+ class ElementSelect extends HTMLElement {
8
22
  constructor() {
9
23
  super()
10
- this.classList.add("alchemy_selectbox")
11
24
  }
12
25
 
13
26
  connectedCallback() {
14
- const el = this
27
+ const results = this.options
15
28
  const options = {
16
29
  minimumResultsForSearch: 3,
17
30
  dropdownAutoWidth: true,
18
31
  data() {
19
- return { results: JSON.parse(el.dataset.options) }
32
+ return { results }
20
33
  },
21
34
  formatResult: (option, _el, search) => {
22
35
  let text
23
36
 
24
- if (option.id === "") return option.text
37
+ if (option.id === "") return option.name
25
38
  if (search.term !== "") {
26
- text = hightlightTerm(option.text, search.term)
39
+ text = hightlightTerm(option.name, search.term)
27
40
  } else {
28
- text = option.text
41
+ text = option.name
29
42
  }
30
43
 
31
- return formatItem(option.icon, text)
44
+ return formatItem(option.icon, text, option.hint)
32
45
  },
33
- formatSelection: (option) => {
34
- return formatItem(option.icon, option.text)
35
- }
46
+ formatSelection,
47
+ placeholder: this.placeholder
36
48
  }
37
- $(this).select2(options)
49
+ $(this.inputField).select2(options)
50
+ }
51
+
52
+ get options() {
53
+ return JSON.parse(this.getAttribute("options"))
54
+ }
55
+
56
+ get placeholder() {
57
+ return this.getAttribute("placeholder")
58
+ }
59
+
60
+ get inputField() {
61
+ return this.querySelector("input")
38
62
  }
39
63
  }
40
64
 
41
- customElements.define("alchemy-element-select", ElementSelect, {
42
- extends: "input"
43
- })
65
+ customElements.define("alchemy-element-select", ElementSelect)
@@ -1,4 +1,3 @@
1
- import SortableElements from "alchemy_admin/sortable_elements"
2
1
  import { ElementEditor } from "alchemy_admin/components/element_editor"
3
2
 
4
3
  class ElementsWindow extends HTMLElement {
@@ -18,7 +17,6 @@ class ElementsWindow extends HTMLElement {
18
17
  if (window.location.hash) {
19
18
  this.focusElementEditor(window.location.hash)
20
19
  }
21
- SortableElements()
22
20
  this.resize()
23
21
  }
24
22
 
@@ -0,0 +1,26 @@
1
+ class FileEditor extends HTMLElement {
2
+ constructor() {
3
+ super()
4
+ this.deleteLink = this.querySelector(".remove_file_link")
5
+ this.fileIcon = this.querySelector(".file_icon")
6
+ this.fileName = this.querySelector(".file_name")
7
+ this.formFieldId = this.deleteLink?.dataset.formFieldId
8
+ this.formField = this.querySelector(`#${this.formFieldId}`)
9
+ this.deleteLink?.addEventListener("click", this)
10
+ }
11
+
12
+ handleEvent(event) {
13
+ if (event.type === "click") this.removeFile()
14
+ event.stopPropagation()
15
+ }
16
+
17
+ removeFile() {
18
+ this.formField.value = ""
19
+ this.fileIcon.innerHTML = ""
20
+ this.fileName.innerHTML = ""
21
+ this.deleteLink?.classList.add("hidden")
22
+ this.closest("alchemy-element-editor").setDirty(this.formField)
23
+ }
24
+ }
25
+
26
+ customElements.define("alchemy-file-editor", FileEditor)
@@ -7,6 +7,7 @@ import "alchemy_admin/components/auto_submit"
7
7
  import "alchemy_admin/components/button"
8
8
  import "alchemy_admin/components/char_counter"
9
9
  import "alchemy_admin/components/clipboard_button"
10
+ import "alchemy_admin/components/color_select"
10
11
  import "alchemy_admin/components/datepicker"
11
12
  import "alchemy_admin/components/dialog_link"
12
13
  import "alchemy_admin/components/dom_id_select"
@@ -14,6 +15,7 @@ import "alchemy_admin/components/element_editor"
14
15
  import "alchemy_admin/components/element_select"
15
16
  import "alchemy_admin/components/elements_window"
16
17
  import "alchemy_admin/components/elements_window_handle"
18
+ import "alchemy_admin/components/file_editor"
17
19
  import "alchemy_admin/components/list_filter"
18
20
  import "alchemy_admin/components/message"
19
21
  import "alchemy_admin/components/growl"
@@ -23,10 +25,17 @@ import "alchemy_admin/components/link_buttons"
23
25
  import "alchemy_admin/components/node_select"
24
26
  import "alchemy_admin/components/uploader"
25
27
  import "alchemy_admin/components/overlay"
28
+ import "alchemy_admin/components/page_node"
29
+ import "alchemy_admin/components/page_publication_fields"
26
30
  import "alchemy_admin/components/page_select"
27
31
  import "alchemy_admin/components/picture_description_select"
32
+ import "alchemy_admin/components/picture_editor"
33
+ import "alchemy_admin/components/picture_thumbnail"
28
34
  import "alchemy_admin/components/preview_window"
35
+ import "alchemy_admin/components/publish_page_button"
29
36
  import "alchemy_admin/components/select"
37
+ import "alchemy_admin/components/sitemap"
38
+ import "alchemy_admin/components/sortable_elements"
30
39
  import "alchemy_admin/components/spinner"
31
40
  import "alchemy_admin/components/tags_autocomplete"
32
41
  import "alchemy_admin/components/tinymce"
@@ -1,14 +1,27 @@
1
+ const DEFAULT_DEBOUNCE_TIME = 150
2
+
1
3
  class ListFilter extends HTMLElement {
4
+ #debounceTimer
5
+
2
6
  constructor() {
3
7
  super()
4
8
  this.#attachEvents()
5
9
  }
6
10
 
7
11
  #attachEvents() {
12
+ if (this.hotkey) {
13
+ key(this.hotkey, () => {
14
+ this.filterField.focus()
15
+ return false
16
+ })
17
+ }
8
18
  this.filterField.addEventListener("keyup", () => {
9
- const term = this.filterField.value
10
- this.clearButton.style.visibility = "visible"
11
- this.filter(term)
19
+ clearTimeout(this.#debounceTimer)
20
+ this.#debounceTimer = setTimeout(() => {
21
+ const term = this.filterField.value
22
+ this.clearButton.style.visibility = term ? "visible" : "hidden"
23
+ this.filter(term)
24
+ }, this.debounceTime)
12
25
  })
13
26
  this.clearButton.addEventListener("click", (e) => {
14
27
  e.preventDefault()
@@ -23,25 +36,53 @@ class ListFilter extends HTMLElement {
23
36
  })
24
37
  }
25
38
 
39
+ disconnectedCallback() {
40
+ if (this.hotkey) {
41
+ key.unbind(this.hotkey)
42
+ }
43
+ key.unbind("esc", "list_filter")
44
+ }
45
+
26
46
  filter(term) {
27
47
  if (term === "") {
28
48
  this.clearButton.style.visibility = "hidden"
29
49
  }
30
50
 
51
+ const matchedItems = []
52
+ const itemsToShow = new Set()
53
+ const lowerTerm = term.toLowerCase()
54
+
55
+ // First pass: find matching items and mark their ancestors as visible too
31
56
  this.items.forEach((item) => {
32
57
  const name = item.getAttribute(this.nameAttribute)?.toLowerCase()
33
58
  // indexOf is much faster then match()
34
- if (name.indexOf(term.toLowerCase()) !== -1) {
35
- item.classList.remove("hidden")
36
- } else {
37
- item.classList.add("hidden")
59
+ if (name.indexOf(lowerTerm) !== -1) {
60
+ matchedItems.push(item)
61
+ itemsToShow.add(item)
62
+ // Mark ancestor items as visible so nested matches stay visible
63
+ let ancestor = item.parentElement?.closest(this.itemsSelector)
64
+ while (ancestor) {
65
+ itemsToShow.add(ancestor)
66
+ ancestor = ancestor.parentElement?.closest(this.itemsSelector)
67
+ }
38
68
  }
39
69
  })
70
+
71
+ // Second pass: apply visibility
72
+ this.items.forEach((item) => {
73
+ item.classList.toggle("hidden", !itemsToShow.has(item))
74
+ })
75
+
76
+ // Scroll into view if only one match
77
+ if (matchedItems.length === 1) {
78
+ matchedItems[0].scrollIntoView({ behavior: "smooth", block: "nearest" })
79
+ }
40
80
  }
41
81
 
42
82
  clear() {
43
83
  this.filterField.value = ""
44
- this.filter("")
84
+ this.clearButton.style.visibility = "hidden"
85
+ this.items.forEach((item) => item.classList.remove("hidden"))
45
86
  }
46
87
 
47
88
  get nameAttribute() {
@@ -63,6 +104,14 @@ class ListFilter extends HTMLElement {
63
104
  get itemsSelector() {
64
105
  return this.getAttribute("items-selector")
65
106
  }
107
+
108
+ get debounceTime() {
109
+ return parseInt(this.getAttribute("debounce-time")) || DEFAULT_DEBOUNCE_TIME
110
+ }
111
+
112
+ get hotkey() {
113
+ return this.getAttribute("hotkey")
114
+ }
66
115
  }
67
116
 
68
117
  customElements.define("alchemy-list-filter", ListFilter)
@@ -26,7 +26,7 @@ class Message extends HTMLElement {
26
26
  if (this.dismissable && this.type !== "error") {
27
27
  setTimeout(() => {
28
28
  this.dismiss()
29
- }, this.delay)
29
+ }, this.dismissDelay)
30
30
  }
31
31
  }
32
32
 
@@ -43,8 +43,10 @@ class Message extends HTMLElement {
43
43
  return this.getAttribute("type") || "notice"
44
44
  }
45
45
 
46
- get delay() {
47
- return parseInt(this.getAttribute("delay") || DISMISS_DELAY)
46
+ get dismissDelay() {
47
+ return parseInt(
48
+ this.noticesWrapper?.dataset.autoDismissDelay || DISMISS_DELAY
49
+ )
48
50
  }
49
51
 
50
52
  get iconName() {
@@ -64,6 +66,10 @@ class Message extends HTMLElement {
64
66
  return this.type
65
67
  }
66
68
  }
69
+
70
+ get noticesWrapper() {
71
+ return this.closest("#flash_notices")
72
+ }
67
73
  }
68
74
 
69
75
  customElements.define("alchemy-message", Message)
@@ -0,0 +1,119 @@
1
+ import { patch } from "alchemy_admin/utils/ajax"
2
+ import { growl } from "alchemy_admin/growler"
3
+ import Spinner from "alchemy_admin/spinner"
4
+
5
+ const BUTTON = "BUTTON"
6
+ const SPAN = "SPAN"
7
+
8
+ /**
9
+ * Custom element for page nodes in the sitemap tree
10
+ * Handles folding/unfolding of page children
11
+ */
12
+ export class AlchemyPageNode extends HTMLElement {
13
+ connectedCallback() {
14
+ this.pageId = this.getAttribute("page-id")
15
+ this.folded = this.hasAttribute("folded")
16
+
17
+ this.folderButton?.addEventListener("click", this)
18
+ }
19
+
20
+ disconnectedCallback() {
21
+ this.folderButton?.removeEventListener("click", this)
22
+ }
23
+
24
+ async handleEvent(event) {
25
+ if (event.type === "click") {
26
+ await this.handleFolderClick(event)
27
+ }
28
+ }
29
+
30
+ async handleFolderClick(event) {
31
+ event.preventDefault()
32
+ event.stopPropagation()
33
+
34
+ const folderButton = event.currentTarget
35
+ folderButton.innerHTML = ""
36
+ const spinner = new Spinner("small")
37
+ spinner.spin(folderButton)
38
+
39
+ try {
40
+ await patch(
41
+ Alchemy.routes.fold_admin_page_path(this.pageId),
42
+ null,
43
+ "text/vnd.turbo-stream.html"
44
+ )
45
+
46
+ this.folded = !this.folded
47
+ this.toggleAttribute("folded", this.folded)
48
+ this.toggleChildren()
49
+ this.updateFolderIcon()
50
+ } catch (error) {
51
+ growl(error.message || error, "error")
52
+ this.updateFolderIcon()
53
+ } finally {
54
+ spinner.stop()
55
+ }
56
+ }
57
+
58
+ toggleChildren() {
59
+ const childrenContainer = this.querySelector(
60
+ `#page_${this.pageId}_children`
61
+ )
62
+ if (childrenContainer) {
63
+ childrenContainer.classList.toggle("hidden", this.folded)
64
+ }
65
+ }
66
+
67
+ updateFolderIcon() {
68
+ if (this.folderButton) {
69
+ const iconName = this.folded ? "arrow-right-s" : "arrow-down-s"
70
+ this.folderButton.innerHTML = `<alchemy-icon name="${iconName}"></alchemy-icon>`
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Updates the folder button state based on whether the node has children
76
+ * Converts between button and span as needed
77
+ */
78
+ updateFolderButton() {
79
+ const folderElement = this.querySelector(".page_folder")
80
+ if (!folderElement) return
81
+
82
+ const shouldShowButton = this.hasChildren || this.folded
83
+
84
+ if (shouldShowButton && folderElement.tagName === SPAN) {
85
+ // Convert span to button with icon
86
+ const iconName = this.folded ? "arrow-right-s" : "arrow-down-s"
87
+ folderElement.outerHTML = `<button class="page_folder icon_button">
88
+ <alchemy-icon name="${iconName}"></alchemy-icon>
89
+ </button>`
90
+
91
+ // Re-attach event listener to the new button element
92
+ this.folderButton?.addEventListener("click", this)
93
+ } else if (!shouldShowButton && folderElement.tagName === BUTTON) {
94
+ // Convert button to empty span (no children and not folded)
95
+ folderElement.outerHTML = '<span class="page_folder"></span>'
96
+ } else if (shouldShowButton && folderElement.tagName === BUTTON) {
97
+ // Button exists, just update the icon direction
98
+ this.updateFolderIcon()
99
+ }
100
+ }
101
+
102
+ get hasChildren() {
103
+ const childrenContainer = this.querySelector(
104
+ `#page_${this.pageId}_children`
105
+ )
106
+ if (!childrenContainer) return false
107
+
108
+ return (
109
+ childrenContainer.querySelectorAll(":scope > alchemy-page-node").length >
110
+ 0
111
+ )
112
+ }
113
+
114
+ get folderButton() {
115
+ return this.querySelector("button.page_folder")
116
+ }
117
+ }
118
+
119
+ customElements.define("alchemy-page-node", AlchemyPageNode)
@@ -1,13 +1,12 @@
1
1
  // Handles the page publication date fields
2
- export default function () {
3
- document.addEventListener("DialogReady.Alchemy", function (evt) {
4
- const dialog = evt.detail.body
5
- const public_on_field = dialog.querySelector("#page_public_on")
6
- const public_until_field = dialog.querySelector("#page_public_until")
7
- const publication_date_fields = dialog.querySelector(
2
+ export class PagePublicationFields extends HTMLElement {
3
+ connectedCallback() {
4
+ const public_on_field = this.querySelector("#page_public_on")
5
+ const public_until_field = this.querySelector("#page_public_until")
6
+ const publication_date_fields = this.querySelector(
8
7
  ".page-publication-date-fields"
9
8
  )
10
- const public_field = dialog.querySelector("#page_public")
9
+ const public_field = this.querySelector("#page_public")
11
10
 
12
11
  if (!public_field) return
13
12
 
@@ -24,5 +23,7 @@ export default function () {
24
23
  }
25
24
  public_until_field.value = ""
26
25
  })
27
- })
26
+ }
28
27
  }
28
+
29
+ customElements.define("alchemy-page-publication-fields", PagePublicationFields)