alchemy_cms 8.3.11 → 8.4.1

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 (368) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +58 -10
  4. data/app/assets/builds/alchemy/admin/page-select.css +1 -1
  5. data/app/assets/builds/alchemy/admin/print.css +1 -1
  6. data/app/assets/builds/alchemy/admin/remote-select.css +1 -0
  7. data/app/assets/builds/alchemy/admin/tags-autocomplete.css +1 -0
  8. data/app/assets/builds/alchemy/admin.css +4 -4
  9. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  10. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  11. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  12. data/app/assets/builds/alchemy/light-theme.css +1 -1
  13. data/app/assets/builds/alchemy/preview.min.js +1 -1
  14. data/app/assets/builds/alchemy/theme.css +1 -1
  15. data/app/assets/builds/alchemy/welcome.css +1 -1
  16. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  17. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  18. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  19. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  20. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +1 -1
  22. data/app/components/alchemy/admin/dashboard/widgets/news_reader.html.erb +38 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/news_reader.rb +54 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +1 -1
  25. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +1 -1
  26. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +11 -3
  27. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +6 -1
  28. data/app/components/alchemy/admin/element_editor.html.erb +1 -1
  29. data/app/components/alchemy/admin/element_select.rb +30 -20
  30. data/app/components/alchemy/admin/element_status_indicators.html.erb +16 -0
  31. data/app/components/alchemy/admin/element_status_indicators.rb +31 -0
  32. data/app/components/alchemy/admin/link_dialog/external_tab.rb +2 -1
  33. data/app/components/alchemy/admin/main_navigation/entry.rb +61 -0
  34. data/app/components/alchemy/admin/main_navigation/menu.rb +21 -0
  35. data/app/components/alchemy/admin/main_navigation/sub_navigation.rb +31 -0
  36. data/app/components/alchemy/admin/page_node.html.erb +1 -1
  37. data/app/components/alchemy/admin/page_permitted_roles_select.html.erb +11 -0
  38. data/app/components/alchemy/admin/page_permitted_roles_select.rb +35 -0
  39. data/app/components/alchemy/admin/page_select.rb +18 -8
  40. data/app/components/alchemy/admin/preview_time_select.rb +1 -1
  41. data/app/components/alchemy/admin/toolbar_button.rb +43 -20
  42. data/app/components/alchemy/admin/uploader_button.rb +66 -0
  43. data/app/components/alchemy/ingredients/base_editor.rb +10 -1
  44. data/app/components/alchemy/ingredients/boolean_editor.rb +1 -0
  45. data/app/components/alchemy/ingredients/color_editor.rb +6 -3
  46. data/app/components/alchemy/ingredients/datetime_view.rb +1 -1
  47. data/app/components/alchemy/ingredients/picture_view.rb +2 -4
  48. data/app/components/alchemy/menubar.html.erb +157 -0
  49. data/app/components/alchemy/menubar.rb +15 -0
  50. data/app/controllers/alchemy/admin/attachments_controller.rb +12 -9
  51. data/app/controllers/alchemy/admin/base_controller.rb +34 -21
  52. data/app/controllers/alchemy/admin/dashboard_controller.rb +4 -0
  53. data/app/controllers/alchemy/admin/elements_controller.rb +4 -23
  54. data/app/controllers/alchemy/admin/languages_controller.rb +1 -1
  55. data/app/controllers/alchemy/admin/nodes_controller.rb +1 -1
  56. data/app/controllers/alchemy/admin/pages_controller.rb +1 -1
  57. data/app/controllers/alchemy/admin/pictures_controller.rb +6 -3
  58. data/app/controllers/alchemy/admin/resources_controller.rb +5 -5
  59. data/app/controllers/alchemy/admin/sites_controller.rb +1 -1
  60. data/app/controllers/alchemy/base_controller.rb +24 -16
  61. data/app/controllers/alchemy/pages_controller.rb +29 -8
  62. data/app/controllers/alchemy/sitemap_controller.rb +58 -0
  63. data/app/controllers/concerns/alchemy/admin/csp_protection.rb +43 -0
  64. data/app/controllers/concerns/alchemy/admin/current_language.rb +1 -1
  65. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +3 -1
  66. data/{lib → app/controllers/concerns}/alchemy/controller_actions.rb +13 -0
  67. data/app/controllers/concerns/alchemy/legacy_page_redirects.rb +4 -4
  68. data/app/helpers/alchemy/admin/base_helper.rb +4 -1
  69. data/app/helpers/alchemy/admin/form_helper.rb +0 -2
  70. data/app/helpers/alchemy/admin/navigation_helper.rb +10 -181
  71. data/app/helpers/alchemy/elements_block_helper.rb +14 -0
  72. data/app/helpers/alchemy/resources_helper.rb +15 -1
  73. data/app/javascript/alchemy_admin/components/attachment_select.js +11 -20
  74. data/app/javascript/alchemy_admin/components/auto_submit.js +2 -4
  75. data/app/javascript/alchemy_admin/components/button.js +0 -6
  76. data/app/javascript/alchemy_admin/components/clear_schedule_button.js +21 -0
  77. data/app/javascript/alchemy_admin/components/color_select.js +50 -45
  78. data/app/javascript/alchemy_admin/components/conditional_field.js +50 -0
  79. data/app/javascript/alchemy_admin/components/dirty_guard.js +77 -0
  80. data/app/javascript/alchemy_admin/components/dom_id_select.js +17 -10
  81. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +7 -2
  82. data/app/javascript/alchemy_admin/components/element_editor.js +29 -56
  83. data/app/javascript/alchemy_admin/components/element_select.js +26 -66
  84. data/app/javascript/alchemy_admin/components/icon.js +9 -4
  85. data/app/javascript/alchemy_admin/{image_cropper.js → components/image_cropper.js} +39 -53
  86. data/app/javascript/alchemy_admin/components/image_overlay_link.js +14 -0
  87. data/app/javascript/alchemy_admin/components/index.js +12 -1
  88. data/app/javascript/alchemy_admin/components/main_navi_entry.js +75 -0
  89. data/app/javascript/alchemy_admin/components/menu_node.js +73 -0
  90. data/app/javascript/alchemy_admin/components/news_reader.js +109 -0
  91. data/app/javascript/alchemy_admin/components/node_folder.js +36 -0
  92. data/app/javascript/alchemy_admin/components/node_form.js +34 -0
  93. data/app/javascript/alchemy_admin/components/node_select.js +25 -30
  94. data/app/javascript/alchemy_admin/components/node_tree.js +56 -0
  95. data/app/javascript/alchemy_admin/components/page_node.js +2 -1
  96. data/app/javascript/alchemy_admin/components/page_select.js +16 -34
  97. data/app/javascript/alchemy_admin/components/picture_archive.js +92 -0
  98. data/app/javascript/alchemy_admin/components/picture_editor.js +2 -1
  99. data/app/javascript/alchemy_admin/components/preview_window.js +11 -3
  100. data/app/javascript/alchemy_admin/components/remote_select.js +461 -81
  101. data/app/javascript/alchemy_admin/components/select.js +26 -88
  102. data/app/javascript/alchemy_admin/components/sitemap.js +2 -1
  103. data/app/javascript/alchemy_admin/components/sortable_elements.js +2 -1
  104. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +63 -48
  105. data/app/javascript/alchemy_admin/components/tinymce.js +2 -1
  106. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +3 -2
  107. data/app/javascript/alchemy_admin/components/uploader.js +19 -3
  108. data/app/javascript/alchemy_admin/confirm_dialog.js +7 -1
  109. data/app/javascript/alchemy_admin/dialog.js +182 -110
  110. data/app/javascript/alchemy_admin/hotkeys.js +0 -5
  111. data/app/javascript/alchemy_admin/i18n.js +3 -9
  112. data/app/javascript/alchemy_admin/image_overlay.js +58 -42
  113. data/app/javascript/alchemy_admin/initializer.js +2 -2
  114. data/app/javascript/alchemy_admin/link_dialog.js +15 -2
  115. data/app/javascript/alchemy_admin/routes.js +18 -0
  116. data/app/javascript/alchemy_admin/turbo_stream_actions.js +88 -0
  117. data/app/javascript/alchemy_admin/utils/ajax.js +14 -3
  118. data/app/javascript/alchemy_admin/utils/events.js +5 -0
  119. data/app/javascript/alchemy_admin/utils/json_script.js +12 -0
  120. data/app/javascript/alchemy_admin/utils/tom_select.js +171 -0
  121. data/app/javascript/alchemy_admin.js +2 -19
  122. data/app/javascript/preview.js +131 -113
  123. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +1 -1
  124. data/app/models/alchemy/admin/content_security_policy.rb +143 -0
  125. data/app/models/alchemy/admin/filters/base.rb +7 -0
  126. data/app/models/alchemy/admin/filters/datepicker.rb +1 -1
  127. data/app/models/alchemy/admin/filters/select.rb +20 -7
  128. data/app/models/alchemy/attachment.rb +14 -7
  129. data/app/models/alchemy/cache_control.rb +104 -0
  130. data/app/models/alchemy/current.rb +1 -1
  131. data/app/models/alchemy/eager_loading.rb +0 -2
  132. data/app/models/alchemy/element.rb +23 -8
  133. data/app/models/alchemy/element_definition.rb +1 -0
  134. data/app/models/alchemy/elements_repository.rb +11 -0
  135. data/app/models/alchemy/folded_page.rb +5 -1
  136. data/app/models/alchemy/ingredient.rb +26 -0
  137. data/app/models/alchemy/ingredients/datetime.rb +1 -1
  138. data/app/models/alchemy/ingredients/link.rb +2 -0
  139. data/app/models/alchemy/ingredients/picture.rb +3 -0
  140. data/app/models/alchemy/ingredients/text.rb +2 -0
  141. data/app/models/alchemy/language.rb +19 -7
  142. data/app/models/alchemy/legacy_page_url.rb +6 -1
  143. data/app/models/alchemy/node.rb +51 -0
  144. data/app/models/alchemy/page/page_natures.rb +41 -8
  145. data/app/models/alchemy/page/page_scopes.rb +23 -0
  146. data/app/models/alchemy/page/sitemap_cache_key.rb +56 -0
  147. data/app/models/alchemy/page.rb +88 -32
  148. data/app/models/alchemy/page_definition.rb +1 -1
  149. data/app/models/alchemy/page_mutex.rb +16 -0
  150. data/app/models/alchemy/page_version.rb +24 -1
  151. data/app/models/alchemy/permissions.rb +6 -5
  152. data/app/models/alchemy/picture.rb +15 -7
  153. data/app/models/alchemy/picture_description.rb +22 -0
  154. data/app/models/alchemy/picture_thumb.rb +18 -0
  155. data/app/models/alchemy/publishable/timestamp_resolver.rb +70 -0
  156. data/app/models/alchemy/resource.rb +1 -1
  157. data/app/models/alchemy/safe_url_validator.rb +34 -0
  158. data/app/models/alchemy/site.rb +8 -3
  159. data/app/models/alchemy/storage_adapter/active_storage/preprocessor.rb +7 -3
  160. data/app/models/alchemy/storage_adapter/active_storage.rb +4 -1
  161. data/app/models/alchemy/tag.rb +10 -4
  162. data/{lib → app/models/concerns}/alchemy/filetypes.rb +5 -0
  163. data/app/models/concerns/alchemy/picture_thumbnails.rb +61 -6
  164. data/app/models/concerns/alchemy/publishable.rb +14 -27
  165. data/app/models/concerns/alchemy/taggable.rb +92 -0
  166. data/app/models/concerns/alchemy/user_methods.rb +95 -0
  167. data/app/services/alchemy/duplicate_element.rb +5 -2
  168. data/app/services/alchemy/element_preloader.rb +1 -2
  169. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  170. data/app/stylesheets/alchemy/_extends.scss +0 -8
  171. data/app/stylesheets/alchemy/_themes.scss +10 -9
  172. data/app/stylesheets/alchemy/admin/_empty-state.scss +29 -0
  173. data/app/stylesheets/alchemy/admin/_help.scss +10 -0
  174. data/app/stylesheets/alchemy/admin/_tom-select.scss +104 -15
  175. data/app/stylesheets/alchemy/admin/archive.scss +56 -11
  176. data/app/stylesheets/alchemy/admin/base.scss +4 -5
  177. data/app/stylesheets/alchemy/admin/buttons.scss +12 -1
  178. data/app/stylesheets/alchemy/admin/dashboard.scss +150 -20
  179. data/app/stylesheets/alchemy/admin/dialogs.scss +27 -36
  180. data/app/stylesheets/alchemy/admin/element-select.scss +1 -1
  181. data/app/stylesheets/alchemy/admin/elements.scss +8 -24
  182. data/app/stylesheets/alchemy/admin/flash.scss +2 -1
  183. data/app/stylesheets/alchemy/admin/forms.scss +60 -24
  184. data/app/stylesheets/alchemy/admin/frame.scss +40 -4
  185. data/app/stylesheets/alchemy/admin/image_library.scss +9 -12
  186. data/app/stylesheets/alchemy/admin/navigation.scss +17 -14
  187. data/app/stylesheets/alchemy/admin/node-select.scss +13 -37
  188. data/app/stylesheets/alchemy/admin/nodes.scss +1 -1
  189. data/app/stylesheets/alchemy/admin/page-select.scss +6 -97
  190. data/app/stylesheets/alchemy/admin/print.scss +0 -4
  191. data/app/stylesheets/alchemy/admin/remote-select.scss +170 -0
  192. data/app/stylesheets/alchemy/admin/search.scss +2 -3
  193. data/app/stylesheets/alchemy/admin/selects.scss +1 -294
  194. data/app/stylesheets/alchemy/admin/sitemap.scss +2 -1
  195. data/app/stylesheets/alchemy/admin/tables.scss +7 -1
  196. data/app/stylesheets/alchemy/admin/tags-autocomplete.scss +17 -0
  197. data/app/stylesheets/alchemy/admin/tags.scss +0 -13
  198. data/app/stylesheets/alchemy/admin/toolbar.scss +9 -6
  199. data/app/stylesheets/alchemy/admin/upload.scss +61 -1
  200. data/app/stylesheets/alchemy/admin.scss +4 -1
  201. data/app/types/alchemy/cache_control_type.rb +84 -0
  202. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  203. data/app/views/alchemy/_menubar.html.erb +2 -154
  204. data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
  205. data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
  206. data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
  207. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
  208. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  209. data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
  210. data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
  211. data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
  212. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  213. data/app/views/alchemy/admin/crop.html.erb +20 -20
  214. data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
  215. data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
  216. data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
  217. data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
  218. data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
  219. data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
  220. data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
  221. data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
  222. data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
  223. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
  224. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
  225. data/app/views/alchemy/admin/elements/index.html.erb +1 -1
  226. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
  227. data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
  228. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
  229. data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
  230. data/app/views/alchemy/admin/languages/index.html.erb +25 -29
  231. data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
  232. data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
  233. data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
  234. data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
  235. data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
  236. data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
  237. data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
  238. data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
  239. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
  240. data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
  241. data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
  242. data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
  243. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
  244. data/app/views/alchemy/admin/pages/index.html.erb +26 -31
  245. data/app/views/alchemy/admin/pages/info.html.erb +2 -2
  246. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
  247. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
  248. data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
  249. data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
  250. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  251. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
  252. data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
  253. data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
  254. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  255. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
  256. data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
  257. data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
  258. data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
  259. data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
  260. data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
  261. data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
  262. data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
  263. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
  264. data/app/views/alchemy/admin/resources/index.html.erb +32 -13
  265. data/app/views/alchemy/admin/sites/index.html.erb +24 -22
  266. data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
  267. data/app/views/alchemy/admin/tags/index.html.erb +27 -25
  268. data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
  269. data/app/views/alchemy/admin/translations/_en.js +3 -0
  270. data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
  271. data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
  272. data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
  273. data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
  274. data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
  275. data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
  276. data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
  277. data/app/views/kaminari/alchemy/_page.html.erb +1 -2
  278. data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
  279. data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
  280. data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
  281. data/app/views/layouts/alchemy/admin.html.erb +5 -6
  282. data/config/brakeman.ignore +23 -1
  283. data/config/importmap.rb +10 -8
  284. data/config/initializers/dragonfly.rb +21 -17
  285. data/config/locales/alchemy.en.yml +52 -11
  286. data/config/routes.rb +3 -3
  287. data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
  288. data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
  289. data/lib/alchemy/configuration/number_option.rb +13 -0
  290. data/lib/alchemy/configuration/proc_option.rb +13 -0
  291. data/lib/alchemy/configuration.rb +2 -0
  292. data/lib/alchemy/configurations/admin_components.rb +30 -0
  293. data/lib/alchemy/configurations/format_matchers.rb +7 -1
  294. data/lib/alchemy/configurations/main.rb +103 -4
  295. data/lib/alchemy/configurations/menu.rb +91 -0
  296. data/lib/alchemy/configurations/menu_item.rb +123 -0
  297. data/lib/alchemy/configurations/sitemap.rb +1 -0
  298. data/lib/alchemy/configurations/uploader.rb +15 -1
  299. data/lib/alchemy/engine.rb +0 -6
  300. data/lib/alchemy/i18n.rb +26 -0
  301. data/lib/alchemy/modules.rb +52 -50
  302. data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
  303. data/lib/alchemy/tasks/tidy.rb +1 -1
  304. data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
  305. data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
  306. data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
  307. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
  308. data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
  309. data/lib/alchemy/test_support/shared_publishable_examples.rb +29 -4
  310. data/lib/alchemy/upgrader/eight_four.rb +237 -0
  311. data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
  312. data/lib/alchemy/upgrader.rb +2 -1
  313. data/lib/alchemy/version.rb +1 -1
  314. data/lib/alchemy.rb +0 -1
  315. data/lib/alchemy_cms.rb +0 -13
  316. data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
  317. data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
  318. data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
  319. data/lib/generators/alchemy/install/install_generator.rb +2 -13
  320. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
  321. data/lib/generators/alchemy/module/module_generator.rb +1 -0
  322. data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
  323. data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
  324. data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
  325. data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
  326. data/lib/tasks/alchemy/thumbnails.rake +16 -29
  327. data/lib/tasks/alchemy/upgrade.rake +45 -3
  328. data/vendor/javascript/cropperjs.min.js +3 -3
  329. data/vendor/javascript/floating-ui.min.js +1 -1
  330. data/vendor/javascript/tinymce.min.js +5 -5
  331. data/vendor/javascript/tom-select.min.js +1 -1
  332. metadata +85 -65
  333. data/app/javascript/alchemy_admin/components/action.js +0 -50
  334. data/app/javascript/alchemy_admin/dirty.js +0 -55
  335. data/app/javascript/alchemy_admin/node_tree.js +0 -82
  336. data/app/javascript/alchemy_admin/picture_selector.js +0 -67
  337. data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
  338. data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
  339. data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
  340. data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
  341. data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
  342. data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
  343. data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
  344. data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
  345. data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
  346. data/app/views/alchemy/base/error_notice.js.erb +0 -1
  347. data/app/views/alchemy/base/permission_denied.js.erb +0 -3
  348. data/app/views/alchemy/base/redirect.js.erb +0 -11
  349. data/config/alchemy/config.yml +0 -217
  350. data/config/alchemy/modules.yml +0 -82
  351. data/config/initializers/rails_live_reload.rb +0 -13
  352. data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
  353. data/lib/alchemy/taggable.rb +0 -49
  354. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
  355. data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
  356. data/vendor/javascript/handlebars.min.js +0 -29
  357. data/vendor/javascript/jquery.min.js +0 -2
  358. data/vendor/javascript/rails-ujs.min.js +0 -1
  359. data/vendor/javascript/select2.min.js +0 -23
  360. /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
  361. /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
  362. /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
  363. /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
  364. /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
  365. /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
  366. /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
  367. /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
  368. /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
@@ -0,0 +1,88 @@
1
+ import { Turbo } from "@hotwired/turbo-rails"
2
+ import { closeCurrentDialog } from "alchemy_admin/dialog"
3
+ import { reloadPreview } from "alchemy_admin/components/preview_window"
4
+ import { removeTab } from "alchemy_admin/fixed_elements"
5
+ import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
6
+ import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
7
+
8
+ // A dialog form submits into the dialog frame, so a redirect would be followed
9
+ // inside the dialog and render the target page in it. The server answers a
10
+ // successful submission with this action instead: close the dialog, then visit
11
+ // the destination the action would have redirected to.
12
+ Turbo.StreamActions.dialog_visit = function () {
13
+ const url = this.getAttribute("url")
14
+
15
+ if (!closeCurrentDialog(() => Turbo.visit(url))) {
16
+ Turbo.visit(url)
17
+ }
18
+ }
19
+
20
+ // The editors observe their form field for mutations, so it must not be replaced.
21
+
22
+ Turbo.StreamActions.assign_picture = function () {
23
+ const [formField] = this.targetElements
24
+
25
+ if (!formField) return
26
+
27
+ formField.value = this.getAttribute("picture-id")
28
+ formField.setAttribute(
29
+ "data-image-file-width",
30
+ this.getAttribute("image-file-width")
31
+ )
32
+ formField.setAttribute(
33
+ "data-image-file-height",
34
+ this.getAttribute("image-file-height")
35
+ )
36
+
37
+ closeCurrentDialog(() => {
38
+ formField.closest("alchemy-element-editor")?.setDirty()
39
+ })
40
+ }
41
+
42
+ Turbo.StreamActions.assign_attachment = function () {
43
+ const [formField] = this.targetElements
44
+
45
+ if (!formField) return
46
+
47
+ const fileEditor = formField.parentElement
48
+
49
+ formField.value = this.getAttribute("attachment-id")
50
+ fileEditor.querySelector(":scope > .file_name").textContent =
51
+ this.getAttribute("attachment-name")
52
+ fileEditor
53
+ .querySelector(":scope > .file_icon")
54
+ .replaceChildren(this.templateContent)
55
+ fileEditor
56
+ .querySelector(":scope > .remove_file_link")
57
+ .classList.remove("hidden")
58
+
59
+ closeCurrentDialog(() => {
60
+ formField.closest("alchemy-element-editor")?.setDirty(formField)
61
+ })
62
+ }
63
+
64
+ // An intermediate action that closes the current dialog. This will be gone once
65
+ // all dialogs resolve through a promise and no longer have to be closed
66
+ // implicitly.
67
+ Turbo.StreamActions.close_current_dialog = function () {
68
+ closeCurrentDialog()
69
+ }
70
+
71
+ Turbo.StreamActions.reload_preview = function () {
72
+ reloadPreview()
73
+ }
74
+
75
+ Turbo.StreamActions.remove_fixed_element = function () {
76
+ removeTab(this.getAttribute("element-id"))
77
+ }
78
+
79
+ Turbo.StreamActions.update_anchor_icon = function () {
80
+ IngredientAnchorLink.updateIcon(
81
+ this.getAttribute("ingredient-id"),
82
+ this.getAttribute("active") === "true"
83
+ )
84
+ }
85
+
86
+ Turbo.StreamActions.hide_please_wait_overlay = function () {
87
+ pleaseWaitOverlay(false)
88
+ }
@@ -9,7 +9,9 @@ function prepareURL(path, data, method) {
9
9
  const url = new URL(window.location.origin + path)
10
10
 
11
11
  if (data && isGetRequest(method)) {
12
- url.search = new URLSearchParams(data).toString()
12
+ new URLSearchParams(data).forEach((value, key) => {
13
+ url.searchParams.set(key, value)
14
+ })
13
15
  }
14
16
 
15
17
  return url.toString()
@@ -79,7 +81,16 @@ export default async function ajax(
79
81
 
80
82
  if (response.ok) {
81
83
  return { data: responseData, status: response.status }
82
- } else {
83
- throw responseData || new Error("An error occurred during the transaction")
84
84
  }
85
+
86
+ // The session expired while the tab was open. Send the user to the login
87
+ // page instead of leaving them with an error they cannot act on. Never
88
+ // settle, so callers skip their error handling: the login page is what the
89
+ // user needs to see, not a growl about a request they cannot retry.
90
+ if (response.status === 401 && responseData?.redirect_url) {
91
+ Turbo.visit(responseData.redirect_url)
92
+ return new Promise(() => {})
93
+ }
94
+
95
+ throw responseData || new Error("An error occurred during the transaction")
85
96
  }
@@ -1,3 +1,8 @@
1
+ // Dispatches a bubbling CustomEvent from the given element.
2
+ export function dispatchCustomEvent(element, name, detail = {}) {
3
+ element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }))
4
+ }
5
+
1
6
  export function on(eventName, baseSelector, targetSelector, callback) {
2
7
  document.querySelectorAll(baseSelector).forEach((baseNode) => {
3
8
  baseNode.addEventListener(eventName, (evt) => {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Reads the data the server rendered into a JSON script tag
3
+ * @param {string} id
4
+ * @returns {any|undefined} undefined if no such script tag is present
5
+ */
6
+ export function readJSONScript(id) {
7
+ const script = document.getElementById(id)
8
+
9
+ if (!script) return undefined
10
+
11
+ return JSON.parse(script.textContent)
12
+ }
@@ -0,0 +1,171 @@
1
+ import { translate } from "alchemy_admin/i18n"
2
+ import {
3
+ autoUpdate,
4
+ computePosition,
5
+ flip,
6
+ offset,
7
+ shift,
8
+ size
9
+ } from "@floating-ui/dom"
10
+
11
+ const DROPDOWN_WINDOW_MARGIN = 16
12
+ const DROPDOWN_MIN_HEIGHT = 120
13
+ const DROPDOWN_MAX_WIDTH = 480
14
+
15
+ // Tom Select hides the native element it enhances, so the element can not take
16
+ // focus itself. Focus the Tom Select control instead, falling back to the
17
+ // native focus while Tom Select has not initialized yet. Tom Select based custom
18
+ // elements use this for their focus() override, so acting on an autofocus
19
+ // attribute (e.g. from a dialog) lands on the visible control.
20
+ export function focusTomSelect(tomSelect, focusFallback) {
21
+ if (tomSelect) {
22
+ tomSelect.focus()
23
+ } else {
24
+ focusFallback()
25
+ }
26
+ }
27
+
28
+ // Tom Select's `destroy()` removes the dropdown, but it never closes it, so the
29
+ // `dropdown_close` handler below would not run. Close first, so a select that is
30
+ // torn down while its dropdown is open (e.g. because a Turbo render replaces the
31
+ // form) does not leave the full window dropdown mask behind, swallowing every
32
+ // click.
33
+ export function teardownTomSelect(tomSelect) {
34
+ tomSelect?.close(false)
35
+ tomSelect?.destroy()
36
+ }
37
+
38
+ // Dropdown positioning shared by the Tom Select based components. It appends the
39
+ // dropdown to the body and keeps it positioned with Floating UI so it is not
40
+ // clipped inside dialogs or scrollable panels. The returned handlers are used as
41
+ // Tom Select `onDropdownOpen`/`onDropdownClose` callbacks and are therefore
42
+ // invoked with the Tom Select instance as `this`.
43
+ export function createDropdownPositioning() {
44
+ const dropdownMask = document.createElement("div")
45
+ dropdownMask.className = "ts-dropdown-mask"
46
+
47
+ let removeAutoUpdater = () => {}
48
+
49
+ return {
50
+ async onDropdownOpen() {
51
+ // Make the dropdown at least as wide as the control.
52
+ const styles = {
53
+ minWidth: `${this.control.offsetWidth}px`
54
+ }
55
+ // If the select is inside a dialog, we need to ensure the dropdown appears above it.
56
+ if (this.control.closest(".alchemy-dialog-body, .alchemy-popover")) {
57
+ styles.zIndex = "101"
58
+ }
59
+ Object.assign(this.dropdown.style, styles)
60
+ // Append the dropdown to the body to avoid overflow issues. Inside a
61
+ // native modal dialog it has to go into the dialog element instead,
62
+ // because content outside of the top layer would be inert and
63
+ // unreachable.
64
+ const dropdownRoot = this.control.closest("dialog") || document.body
65
+ dropdownRoot.append(dropdownMask)
66
+ dropdownRoot.append(this.dropdown)
67
+ // Use Floating UI to position the dropdown relative to the control.
68
+ const updatePosition = async () => {
69
+ // Use Floating UI to calculate the dropdown position
70
+ const { x, y } = await computePosition(this.control, this.dropdown, {
71
+ // Line the dropdown up with the left edge of the control. Without this
72
+ // it is centered on the control and a dropdown grown wide by long
73
+ // entries hangs over into the page left of the field.
74
+ placement: "bottom-start",
75
+ middleware: [
76
+ // Make some space between the control and the dropdown to prevent overlap
77
+ offset(2),
78
+ // Flip to the opposite side if there’s not enough space
79
+ flip(),
80
+ // Keep the dropdown inside the window
81
+ shift({ padding: DROPDOWN_WINDOW_MARGIN }),
82
+ // Ensure the dropdown fits within the viewport
83
+ size({
84
+ padding: DROPDOWN_WINDOW_MARGIN,
85
+ apply({ availableWidth, availableHeight, elements }) {
86
+ // Keep long entries from stretching the dropdown across the
87
+ // window, but never make it narrower than the control.
88
+ Object.assign(elements.floating.style, {
89
+ maxWidth: `max(${elements.reference.offsetWidth}px, min(${availableWidth}px, var(--select-dropdown-max-width, ${DROPDOWN_MAX_WIDTH}px)))`
90
+ })
91
+ Object.assign(
92
+ elements.floating.querySelector(".ts-dropdown-content").style,
93
+ {
94
+ maxHeight: `${Math.max(DROPDOWN_MIN_HEIGHT, availableHeight - DROPDOWN_WINDOW_MARGIN)}px`
95
+ }
96
+ )
97
+ }
98
+ })
99
+ ]
100
+ })
101
+ // Position the dropdown
102
+ Object.assign(this.dropdown.style, {
103
+ left: `${x}px`,
104
+ top: `${y}px`
105
+ })
106
+ }
107
+ // Update the dropdown position whenever the window resizes or scrolls.
108
+ removeAutoUpdater = autoUpdate(
109
+ this.control,
110
+ this.dropdown,
111
+ updatePosition
112
+ )
113
+ },
114
+ onDropdownClose() {
115
+ this.control_input.classList.remove("has-value")
116
+ this.control_input.blur()
117
+ // Remove the dropdown from DOM when closed.
118
+ this.dropdown.remove()
119
+ dropdownMask.remove()
120
+ // Cleanup the position auto-update when the dropdown is closed.
121
+ removeAutoUpdater()
122
+ }
123
+ }
124
+ }
125
+
126
+ // Configuration for Tom Select's `remove_button` plugin. The plugin renders its
127
+ // control as a non-semantic element (a `<div role="button">` by default); render
128
+ // a real `<button>` instead so assistive tech and the keyboard treat it as one.
129
+ // The translated title also names the icon-only button via `aria-label`, like the
130
+ // clear button does.
131
+ export function removeButton() {
132
+ return {
133
+ title: translate("Remove"),
134
+ html(data) {
135
+ const button = document.createElement("button")
136
+ button.type = "button"
137
+ button.className = data.className
138
+ button.title = data.title
139
+ button.setAttribute("aria-label", data.title)
140
+ button.tabIndex = -1
141
+ button.innerHTML = `<alchemy-icon name="close" size="sm"></alchemy-icon>`
142
+ return button
143
+ }
144
+ }
145
+ }
146
+
147
+ // Customize the "create" and "no results" dropdown messages with i18n. Shared by
148
+ // the Tom Select based components as `render` functions.
149
+ export const dropdownMessages = {
150
+ option_create(data, escape) {
151
+ return `<div class="create">
152
+ ${translate("Add")} <strong>${escape(data.input)}</strong>&hellip;
153
+ </div>`
154
+ },
155
+ // Tom Select prepends this above the results it already has, which would
156
+ // shift them.
157
+ loading() {
158
+ if (Object.keys(this.options).length > 0) return
159
+ return `<div class="loading-results">${translate("Please wait, loading")}&hellip;</div>`
160
+ },
161
+ no_results() {
162
+ return `<div class="no-results">${translate("No results found")}</div>`
163
+ },
164
+ // Shown at the bottom of the dropdown while the virtual scroll plugin appends
165
+ // the next page of results.
166
+ loading_more: () =>
167
+ `<div class="loading-more">${translate("Loading more results")}&hellip;</div>`,
168
+ // Shown at the bottom of the dropdown once every page has been loaded.
169
+ no_more_results: () =>
170
+ `<div class="no-more-results">${translate("No more results")}</div>`
171
+ }
@@ -1,15 +1,8 @@
1
- // We still use jQuery in some places (ie. select2)
2
- import "handlebars"
3
- import "jquery"
4
1
  import "@ungap/custom-elements"
5
2
  import { Turbo } from "@hotwired/turbo-rails"
6
- import "select2"
7
-
8
- import Rails from "@rails/ujs"
9
3
 
10
4
  import { translate } from "alchemy_admin/i18n"
11
5
  import { currentDialog, closeCurrentDialog } from "alchemy_admin/dialog"
12
- import Dirty from "alchemy_admin/dirty"
13
6
  import * as FixedElements from "alchemy_admin/fixed_elements"
14
7
  import { growl } from "alchemy_admin/growler"
15
8
  import Initializer from "alchemy_admin/initializer"
@@ -19,12 +12,11 @@ import Spinner from "alchemy_admin/spinner"
19
12
  import { reloadPreview } from "alchemy_admin/components/preview_window"
20
13
  import { openConfirmDialog } from "alchemy_admin/confirm_dialog"
21
14
 
15
+ import "alchemy_admin/turbo_stream_actions"
16
+
22
17
  // Web Components
23
18
  import "alchemy_admin/components"
24
19
 
25
- // Handlebars Templates
26
- import "alchemy_admin/templates/compiled"
27
-
28
20
  // Shoelace Setup
29
21
  import "alchemy_admin/shoelace_theme"
30
22
 
@@ -37,7 +29,6 @@ if (typeof window.Alchemy === "undefined") {
37
29
  Object.assign(Alchemy, {
38
30
  closeCurrentDialog,
39
31
  currentDialog,
40
- ...Dirty,
41
32
  t: translate, // Global utility method for translating a given string
42
33
  FixedElements,
43
34
  growl,
@@ -46,8 +37,6 @@ Object.assign(Alchemy, {
46
37
  Spinner,
47
38
  reloadPreview
48
39
  })
49
-
50
- Rails.start()
51
40
  Turbo.config.forms.confirm = openConfirmDialog
52
41
  document.addEventListener("turbo:load", Initializer)
53
42
 
@@ -59,9 +48,3 @@ export { ElementSelect } from "alchemy_admin/components/element_select"
59
48
  export { TagsAutocomplete } from "alchemy_admin/components/tags_autocomplete"
60
49
  export { Tinymce } from "alchemy_admin/components/tinymce"
61
50
  export { on } from "alchemy_admin/utils/events"
62
-
63
- // Page-specific modules - bundled to avoid dual-loading
64
- export { default as ImageCropper } from "alchemy_admin/image_cropper"
65
- export { default as ImageOverlay } from "alchemy_admin/image_overlay"
66
- export { default as pictureSelector } from "alchemy_admin/picture_selector"
67
- export { default as NodeTree } from "alchemy_admin/node_tree"
@@ -1,125 +1,143 @@
1
- window.Alchemy = Alchemy || {}
2
-
3
- Object.assign(Alchemy, {
4
- ElementSelector: {
5
- styles: {
6
- reset: {
7
- outline: "",
8
- "outline-offset": "",
9
- cursor: ""
10
- },
11
- hover: {
12
- outline: "2px dashed #f0b437",
13
- "outline-offset": "4px",
14
- cursor: "pointer"
15
- },
16
- selected: {
17
- outline: "2px dashed #90b9d0",
18
- "outline-offset": "4px"
19
- }
20
- },
1
+ const STYLES = {
2
+ reset: {
3
+ outline: "",
4
+ "outline-offset": "",
5
+ cursor: ""
6
+ },
7
+ hover: {
8
+ outline: "2px dashed #f0b437",
9
+ "outline-offset": "4px",
10
+ cursor: "pointer"
11
+ },
12
+ selected: {
13
+ outline: "2px dashed #90b9d0",
14
+ "outline-offset": "4px"
15
+ }
16
+ }
21
17
 
22
- init() {
23
- window.addEventListener("message", (event) => {
24
- switch (event.data.message) {
25
- case "Alchemy.blurElements":
26
- this.blurElements()
27
- break
28
- case "Alchemy.focusElement":
29
- this.focusElement(event.data)
30
- break
31
- default:
32
- console.info("Received unknown message!", event.data)
33
- }
34
- })
35
- this.elements = Array.from(
36
- document.querySelectorAll("[data-alchemy-element]")
37
- )
38
- this.elements.forEach((element) => {
39
- element.addEventListener("mouseover", () => {
40
- if (!element.classList.contains("selected")) {
41
- Object.assign(element.style, this.getStyle("hover"))
42
- }
43
- })
44
- element.addEventListener("mouseout", () => {
45
- if (!element.classList.contains("selected")) {
46
- Object.assign(element.style, this.getStyle("reset"))
47
- }
48
- })
49
- element.addEventListener("click", (e) => {
50
- e.stopPropagation()
51
- e.preventDefault()
52
- this.selectElement(element)
53
- this.focusElementEditor(element)
54
- })
55
- })
56
- },
18
+ let elements = []
19
+ let selection = null
57
20
 
58
- // Mark element in preview frame as selected and scrolls to it.
59
- selectElement(element) {
60
- this.blurElements(element)
61
- element.classList.add("selected")
62
- Object.assign(element.style, this.getStyle("selected"))
63
- element.scrollIntoView({
64
- behavior: "smooth",
65
- block: "start"
66
- })
67
- },
21
+ // Mark element in preview frame as selected and scrolls to it.
22
+ function selectElement(element) {
23
+ blurElements(element)
24
+ selection = element
25
+ Object.assign(element.style, STYLES.selected)
26
+ element.scrollIntoView({
27
+ behavior: "smooth",
28
+ block: "start"
29
+ })
30
+ }
68
31
 
69
- // Blur all elements in preview frame.
70
- blurElements(selectedElement) {
71
- this.elements.forEach((element) => {
72
- if (element !== selectedElement) {
73
- element.classList.remove("selected")
74
- Object.assign(element.style, this.getStyle("reset"))
75
- }
76
- })
77
- },
32
+ // Blur all elements in preview frame.
33
+ function blurElements(selectedElement) {
34
+ elements.forEach((element) => {
35
+ if (element !== selectedElement) {
36
+ Object.assign(element.style, STYLES.reset)
37
+ }
38
+ })
39
+ selection = null
40
+ }
78
41
 
79
- // Focus the element in the Alchemy preview window.
80
- focusElement(data) {
81
- const element = this.getElement(data.element_id)
82
- if (element) {
83
- return this.selectElement(element)
84
- } else {
85
- return console.warn("Could not focus element with id", data.element_id)
86
- }
87
- },
42
+ function getElement(elementId) {
43
+ return elements.find(
44
+ (element) => element.dataset.alchemyElement === elementId.toString()
45
+ )
46
+ }
88
47
 
89
- getElement(element_id) {
90
- return this.elements.find(
91
- (element) => element.dataset.alchemyElement === element_id.toString()
92
- )
93
- },
48
+ // Focus the element in the Alchemy preview window.
49
+ function focusElement(data) {
50
+ const element = getElement(data.element_id)
94
51
 
95
- // Focus the element editor in the Alchemy element window.
96
- focusElementEditor(element) {
97
- const element_id = element.dataset.alchemyElement
98
- window.parent.postMessage(
99
- {
100
- message: "Alchemy.focusElementEditor",
101
- element_id
102
- },
103
- window.location.origin
104
- )
52
+ if (element) {
53
+ selectElement(element)
54
+ } else {
55
+ console.warn("Could not focus element with id", data.element_id)
56
+ }
57
+ }
58
+
59
+ // Focus the element editor in the Alchemy element window.
60
+ function focusElementEditor(element) {
61
+ window.parent.postMessage(
62
+ {
63
+ message: "Alchemy.focusElementEditor",
64
+ element_id: element.dataset.alchemyElement
105
65
  },
66
+ window.location.origin
67
+ )
68
+ }
106
69
 
107
- getStyle(state) {
108
- if (state === "reset") {
109
- return this.styles["reset"]
110
- } else {
111
- return this.styles[state]
112
- }
70
+ function observeElement(element) {
71
+ element.addEventListener("mouseover", () => {
72
+ if (element !== selection) {
73
+ Object.assign(element.style, STYLES.hover)
74
+ }
75
+ })
76
+ element.addEventListener("mouseout", () => {
77
+ if (element !== selection) {
78
+ Object.assign(element.style, STYLES.reset)
113
79
  }
80
+ })
81
+ }
82
+
83
+ function selectClickedElement(event) {
84
+ const element = event.target.closest("[data-alchemy-element]")
85
+
86
+ if (element) {
87
+ selectElement(element)
88
+ focusElementEditor(element)
114
89
  }
115
- })
116
90
 
117
- Alchemy.ElementSelector.init()
91
+ return element
92
+ }
118
93
 
119
- // Notify parent window that preview is ready
120
- window.parent.postMessage(
121
- {
122
- message: "Alchemy.previewReady"
123
- },
124
- window.location.origin
125
- )
94
+ function navigates(target) {
95
+ if (target.closest("a")) return true
96
+
97
+ const control = target.closest("button, input")
98
+ return Boolean(control?.form) && ["submit", "image"].includes(control.type)
99
+ }
100
+
101
+ // Client side routers navigate from click handlers on the link itself, so
102
+ // navigating clicks have to be taken before the event reaches the page.
103
+ function onNavigatingClick(event) {
104
+ if (navigates(event.target) && selectClickedElement(event)) {
105
+ event.stopPropagation()
106
+ event.preventDefault()
107
+ }
108
+ }
109
+
110
+ function init() {
111
+ window.addEventListener("message", (event) => {
112
+ switch (event.data.message) {
113
+ case "Alchemy.blurElements":
114
+ blurElements()
115
+ break
116
+ case "Alchemy.focusElement":
117
+ focusElement(event.data)
118
+ break
119
+ default:
120
+ console.info("Received unknown message!", event.data)
121
+ }
122
+ })
123
+
124
+ window.addEventListener("click", onNavigatingClick, { capture: true })
125
+ window.addEventListener("click", selectClickedElement)
126
+
127
+ elements = Array.from(document.querySelectorAll("[data-alchemy-element]"))
128
+ elements.forEach(observeElement)
129
+
130
+ // Notify parent window that preview is ready
131
+ window.parent.postMessage(
132
+ {
133
+ message: "Alchemy.previewReady"
134
+ },
135
+ window.location.origin
136
+ )
137
+ }
138
+
139
+ if (document.readyState === "loading") {
140
+ document.addEventListener("DOMContentLoaded", init, { once: true })
141
+ } else {
142
+ init()
143
+ }
@@ -12,7 +12,7 @@ module Alchemy
12
12
  all_element_ids = get_all_element_ids(elements, element_ids)
13
13
  Element.where(id: all_element_ids).touch_all
14
14
 
15
- page_ids = elements.joins(page_version: :page).select("DISTINCT alchemy_pages.id")
15
+ page_ids = elements.joins(page_version: :page).distinct.pluck("alchemy_pages.id")
16
16
  Page.where(id: page_ids).touch_all
17
17
  end
18
18