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
@@ -1,29 +1,61 @@
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>`
1
+ import { Select } from "alchemy_admin/components/select"
2
+
3
+ const renderColorOption = (data, escape) => {
4
+ const swatch = data.swatch || data.value
5
+ const colorIndicator =
6
+ data.value === "custom_color"
7
+ ? `<alchemy-icon name="palette"></alchemy-icon>`
8
+ : `<span class="color-indicator" style="--color: ${escape(swatch)}"></span>`
8
9
 
9
10
  return `
10
11
  <div class="select-color-option">
11
12
  ${colorIndicator}
12
- <span>${object.text}</span>
13
+ <span>${escape(data.text)}</span>
13
14
  </div>`
14
15
  }
15
16
 
16
- class ColorSelect extends HTMLElement {
17
- #select2 = null
17
+ // A color options select enhanced by Tom Select. It inherits from the shared
18
+ // alchemy-select component to reuse the Tom Select setup, renders each option
19
+ // and the selected item with a color swatch (or the custom-color icon), and
20
+ // enables the adjacent color picker while a custom color is selected.
21
+ export class ColorSelect extends Select {
22
+ connectedCallback() {
23
+ super.connectedCallback()
24
+ this.addEventListener("change", this.#onChange)
25
+ }
26
+
27
+ disconnectedCallback() {
28
+ super.disconnectedCallback()
29
+ this.removeEventListener("change", this.#onChange)
30
+ }
31
+
32
+ get renderers() {
33
+ return {
34
+ item: renderColorOption,
35
+ option: renderColorOption
36
+ }
37
+ }
38
+
39
+ #onChange = () => {
40
+ const colorPicker = this.parentElement?.querySelector("input[type='color']")
41
+ if (colorPicker) {
42
+ colorPicker.disabled = this.value !== "custom_color"
43
+ }
44
+ }
45
+ }
46
+
47
+ customElements.define("alchemy-color-select", ColorSelect, {
48
+ extends: "select"
49
+ })
18
50
 
51
+ // Free-form color input used when no color options are configured. Keeps the
52
+ // text field and the native color picker in sync and enables the picker.
53
+ export class ColorInput extends HTMLElement {
19
54
  connectedCallback() {
20
- if (this.select) {
21
- this.#initializeSelect2()
22
- this.#select2.on("change", this.#onSelectChange)
23
- } else {
24
- this.colorInput?.addEventListener("input", this)
25
- this.textInput?.addEventListener("input", this)
26
- this.#toggleColorPicker(true)
55
+ this.colorInput?.addEventListener("input", this)
56
+ this.textInput?.addEventListener("input", this)
57
+ if (this.colorInput) {
58
+ this.colorInput.disabled = false
27
59
  }
28
60
  }
29
61
 
@@ -41,29 +73,6 @@ class ColorSelect extends HTMLElement {
41
73
  disconnectedCallback() {
42
74
  this.colorInput?.removeEventListener("input", this)
43
75
  this.textInput?.removeEventListener("input", this)
44
- if (this.#select2) {
45
- this.#select2.off("change", this.#onSelectChange)
46
- this.#select2.select2("destroy")
47
- this.#select2 = null
48
- }
49
- }
50
-
51
- #onSelectChange = (event) => {
52
- this.#toggleColorPicker(event.val === "custom_color")
53
- }
54
-
55
- #initializeSelect2() {
56
- this.select.classList.add("alchemy_selectbox")
57
- const options = {
58
- minimumResultsForSearch: 10,
59
- formatResult: formatItem,
60
- formatSelection: formatItem
61
- }
62
- this.#select2 = $(this.select).select2(options)
63
- }
64
-
65
- #toggleColorPicker(enabled = true) {
66
- this.colorInput.disabled = !enabled
67
76
  }
68
77
 
69
78
  get colorInput() {
@@ -73,10 +82,6 @@ class ColorSelect extends HTMLElement {
73
82
  get textInput() {
74
83
  return this.querySelector("input[type='text']")
75
84
  }
76
-
77
- get select() {
78
- return this.querySelector("select")
79
- }
80
85
  }
81
86
 
82
- customElements.define("alchemy-color-select", ColorSelect)
87
+ customElements.define("alchemy-color-input", ColorInput)
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shows its fields only while the controlling checkbox is checked.
3
+ *
4
+ * Hidden fields are disabled as well, so they are not submitted and the stored
5
+ * value is left untouched while the condition does not apply. A disabled
6
+ * control (a fixed page attribute for example) keeps the fields disabled.
7
+ *
8
+ * <alchemy-conditional-field control="page_restricted">
9
+ * <input type="text" name="page[foo]">
10
+ * </alchemy-conditional-field>
11
+ */
12
+ class ConditionalField extends HTMLElement {
13
+ connectedCallback() {
14
+ this.control?.addEventListener("change", this)
15
+ this.toggle()
16
+ }
17
+
18
+ disconnectedCallback() {
19
+ this.control?.removeEventListener("change", this)
20
+ }
21
+
22
+ handleEvent(event) {
23
+ if (event.type === "change") this.toggle()
24
+ }
25
+
26
+ toggle() {
27
+ const active = !!this.control?.checked && !this.control.disabled
28
+
29
+ this.hidden = !active
30
+ this.fields.forEach((field) => {
31
+ // Custom elements like alchemy-select need their own API to keep their
32
+ // rendered control in sync, but might not be upgraded yet.
33
+ if (typeof field.disable === "function") {
34
+ active ? field.enable() : field.disable()
35
+ } else {
36
+ field.disabled = !active
37
+ }
38
+ })
39
+ }
40
+
41
+ get control() {
42
+ return document.getElementById(this.getAttribute("control"))
43
+ }
44
+
45
+ get fields() {
46
+ return this.querySelectorAll("input, select, textarea")
47
+ }
48
+ }
49
+
50
+ customElements.define("alchemy-conditional-field", ConditionalField)
@@ -0,0 +1,77 @@
1
+ import { Turbo } from "@hotwired/turbo-rails"
2
+ import { openConfirmDialog } from "alchemy_admin/confirm_dialog"
3
+ import { translate } from "alchemy_admin/i18n"
4
+ import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
5
+
6
+ // Set once the user agreed to leave, so that the navigation they agreed to is
7
+ // not questioned again: a guarded form's redirect comes back through
8
+ // turbo:before-visit.
9
+ let leaveConfirmed = false
10
+
11
+ // Warns before a wrapped form takes the user away from a page that has unsaved
12
+ // element changes. Links need no wrapping, turbo:before-visit covers them, but
13
+ // Turbo dispatches that for a form only once its request has returned, which is
14
+ // too late for a form that changes something on the server.
15
+ //
16
+ // Guards in the capture phase, so that a cancelled submit never reaches
17
+ // Turbo's document level handlers, nor any component the guard sits in.
18
+ //
19
+ // Wrap an sl-tooltip, never the element inside it: Shoelace anchors to its
20
+ // first slotted child, and this element has no box of its own to anchor to.
21
+ class DirtyGuard extends HTMLElement {
22
+ connectedCallback() {
23
+ this.addEventListener("submit", this.#guard, true)
24
+ }
25
+
26
+ disconnectedCallback() {
27
+ this.removeEventListener("submit", this.#guard, true)
28
+ }
29
+
30
+ #guard = (event) => {
31
+ if (leaveConfirmed || !isPageDirty()) return
32
+
33
+ event.preventDefault()
34
+ event.stopPropagation()
35
+
36
+ const form = event.target
37
+ confirmLeave().then((confirmed) => {
38
+ if (confirmed) {
39
+ pleaseWaitOverlay()
40
+ form.requestSubmit()
41
+ }
42
+ })
43
+ }
44
+ }
45
+
46
+ function isPageDirty() {
47
+ return document.querySelectorAll("alchemy-element-editor.dirty").length > 0
48
+ }
49
+
50
+ async function confirmLeave() {
51
+ const confirmed = await openConfirmDialog(translate("page_dirty_notice"), {
52
+ title: translate("warning"),
53
+ ok_label: translate("ok"),
54
+ cancel_label: translate("cancel")
55
+ })
56
+ if (confirmed) {
57
+ leaveConfirmed = true
58
+ window.onbeforeunload = void 0
59
+ }
60
+ return confirmed
61
+ }
62
+
63
+ document.addEventListener("turbo:before-visit", (event) => {
64
+ if (leaveConfirmed || !isPageDirty()) return
65
+
66
+ event.preventDefault()
67
+
68
+ confirmLeave().then((confirmed) => {
69
+ if (confirmed) Turbo.visit(event.detail.url)
70
+ })
71
+ })
72
+
73
+ document.addEventListener("turbo:load", () => {
74
+ leaveConfirmed = false
75
+ })
76
+
77
+ customElements.define("alchemy-dirty-guard", DirtyGuard)
@@ -1,5 +1,7 @@
1
1
  import { get } from "alchemy_admin/utils/ajax"
2
2
  import { translate } from "alchemy_admin/i18n"
3
+ import { growl } from "alchemy_admin/growler"
4
+ import { routes } from "alchemy_admin/routes"
3
5
 
4
6
  class DomIdSelect extends HTMLElement {
5
7
  dataItem(hash) {
@@ -22,17 +24,22 @@ class DomIdApiSelect extends DomIdSelect {
22
24
  }
23
25
 
24
26
  async #fetchDomIds() {
25
- const result = await get(Alchemy.routes.api_ingredients_path, {
26
- page_id: this.#pageId
27
- })
28
- const options = result.data.ingredients
29
- .filter((ingredient) => ingredient.data?.dom_id)
30
- .map((ingredient) => this.dataItem(ingredient.data.dom_id))
31
- const prompt =
32
- options.length > 0 ? translate("None") : translate("No anchors found")
27
+ try {
28
+ const result = await get(routes.api_ingredients_path, {
29
+ page_id: this.#pageId
30
+ })
31
+ const options = result.data.ingredients
32
+ .filter((ingredient) => ingredient.data?.dom_id)
33
+ .map((ingredient) => this.dataItem(ingredient.data.dom_id))
34
+ const prompt =
35
+ options.length > 0 ? translate("None") : translate("No anchors found")
33
36
 
34
- this.selectElement.setOptions(options, prompt)
35
- this.selectElement.enable()
37
+ this.selectElement.setOptions(options, prompt)
38
+ this.selectElement.enable()
39
+ } catch (error) {
40
+ growl(error.message || error, "error")
41
+ console.error(error)
42
+ }
36
43
  }
37
44
 
38
45
  #reset() {
@@ -17,8 +17,13 @@ export class DeleteElementButton extends HTMLElement {
17
17
  async handleEvent() {
18
18
  const confirmed = await openConfirmDialog(this.message)
19
19
  if (confirmed) {
20
- const response = await ajax("DELETE", this.url)
21
- this.#removeElement(response.data)
20
+ try {
21
+ const response = await ajax("DELETE", this.url)
22
+ this.#removeElement(response.data)
23
+ } catch (error) {
24
+ growl(error.message || error, "error")
25
+ console.error(error)
26
+ }
22
27
  }
23
28
  }
24
29
 
@@ -1,11 +1,9 @@
1
- import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
2
1
  import { post } from "alchemy_admin/utils/ajax"
3
- import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
4
- import { dispatchPageDirtyEvent } from "alchemy_admin/utils/dispatch_page_dirty_event"
5
2
  import { growl } from "alchemy_admin/growler"
6
3
 
7
4
  import "alchemy_admin/components/element_editor/publish_element_button"
8
5
  import "alchemy_admin/components/element_editor/delete_element_button"
6
+ import { routes } from "alchemy_admin/routes"
9
7
 
10
8
  export class ElementEditor extends HTMLElement {
11
9
  #form = null
@@ -22,14 +20,12 @@ export class ElementEditor extends HTMLElement {
22
20
  this.addEventListener("click", this)
23
21
  // Triggered by child elements
24
22
  this.addEventListener("alchemy:element-update-title", this)
25
- // We use of @rails/ujs for Rails remote forms
26
- this.addEventListener("ajax:complete", this)
27
23
 
28
- // Dirty observer still needs to be jQuery
29
- // in order to support select2.
30
24
  this.#form = this.form
31
25
  if (this.#form) {
32
- $(this.#form).on("change", this.onChange)
26
+ this.#form.addEventListener("change", this.onChange)
27
+ this.#form.addEventListener("turbo:submit-start", this)
28
+ this.#form.addEventListener("turbo:submit-end", this)
33
29
  }
34
30
 
35
31
  this.#header = this.header
@@ -51,9 +47,10 @@ export class ElementEditor extends HTMLElement {
51
47
  disconnectedCallback() {
52
48
  this.removeEventListener("click", this)
53
49
  this.removeEventListener("alchemy:element-update-title", this)
54
- this.removeEventListener("ajax:complete", this)
55
50
  if (this.#form) {
56
- $(this.#form).off("change", this.onChange)
51
+ this.#form.removeEventListener("change", this.onChange)
52
+ this.#form.removeEventListener("turbo:submit-start", this)
53
+ this.#form.removeEventListener("turbo:submit-end", this)
57
54
  this.#form = null
58
55
  }
59
56
  this.#header?.removeEventListener("dblclick", this.#onHeaderDblclick)
@@ -70,12 +67,11 @@ export class ElementEditor extends HTMLElement {
70
67
  this.onClickElement()
71
68
  }
72
69
  break
73
- case "ajax:complete":
74
- if (event.target === this.body) {
75
- const xhr = event.detail[0]
76
- event.stopPropagation()
77
- this.onSaveElement(xhr)
78
- }
70
+ case "turbo:submit-start":
71
+ this.setClean()
72
+ break
73
+ case "turbo:submit-end":
74
+ this.onSaveElement(event.detail.success)
79
75
  break
80
76
  case "alchemy:element-update-title":
81
77
  if (!this.hasEditors && event.target == this.firstChild) {
@@ -128,46 +124,20 @@ export class ElementEditor extends HTMLElement {
128
124
  }
129
125
 
130
126
  /**
131
- * Sets the element to saved state
132
- * Updates title
133
- * JS event bubbling will also update the parents element quote.
134
- * Shows error messages if ingredient validations fail
135
- * @argument {XMLHttpRequest} xhr
136
- */
137
- onSaveElement(xhr) {
138
- const data = JSON.parse(xhr.responseText)
139
- // Reset errors that might be visible from last save attempt
140
- this.setClean()
141
- // If validation failed
142
- if (xhr.status === 422) {
143
- const warning = data.warning
144
- // Create error messages
145
- // Mark ingredients as failed
146
- data.ingredientsWithErrors.forEach((ingredient) => {
147
- const ingredientEditor = this.querySelector(
148
- `[data-ingredient-id="${ingredient.id}"]`
149
- )
150
- const errorDisplay = createHtmlElement(
151
- `<small class="error">${ingredient.errorMessage}</small>`
152
- )
153
- ingredientEditor?.appendChild(errorDisplay)
154
- ingredientEditor?.classList.add("validation_failed")
155
- })
156
- // Show message
157
- growl(warning, "warn")
158
- this.elementErrors.classList.remove("hidden")
159
- } else {
160
- growl(data.notice)
127
+ * Applies the client-side effects of a save once the turbo stream rendered.
128
+ *
129
+ * The notice, error markup, header title, anchor icons and publish button are
130
+ * server-rendered turbo streams. Only the preview refresh (which must focus
131
+ * this element afterwards) and the error box toggle remain client-side.
132
+ * @argument {boolean} success
133
+ */
134
+ onSaveElement(success) {
135
+ if (success) {
161
136
  this.previewWindow?.refresh().then(() => {
162
137
  this.focusElementPreview()
163
138
  })
164
- this.updateTitle(data.previewText)
165
- data.ingredientAnchors.forEach((anchor) => {
166
- IngredientAnchorLink.updateIcon(anchor.ingredientId, anchor.active)
167
- })
168
- if (data.pageHasUnpublishedChanges) {
169
- dispatchPageDirtyEvent(data)
170
- }
139
+ } else {
140
+ this.elementErrors.classList.remove("hidden")
171
141
  }
172
142
  }
173
143
 
@@ -286,7 +256,7 @@ export class ElementEditor extends HTMLElement {
286
256
  const spinner = new Alchemy.Spinner("small")
287
257
  spinner.spin(this.toggleButton)
288
258
  this.toggleIcon?.classList?.add("hidden")
289
- return post(Alchemy.routes.collapse_admin_element_path(this.elementId))
259
+ return post(routes.collapse_admin_element_path(this.elementId))
290
260
  .then((response) => {
291
261
  const data = response.data
292
262
 
@@ -331,7 +301,7 @@ export class ElementEditor extends HTMLElement {
331
301
  this.toggleIcon?.classList.add("hidden")
332
302
 
333
303
  return new Promise((resolve, reject) => {
334
- post(Alchemy.routes.expand_admin_element_path(this.elementId))
304
+ post(routes.expand_admin_element_path(this.elementId))
335
305
  .then((response) => {
336
306
  const data = response.data
337
307
 
@@ -567,10 +537,13 @@ export class ElementEditor extends HTMLElement {
567
537
  /**
568
538
  * The form element if present
569
539
  *
540
+ * Scoped to this element's own form so a wrapper element without ingredients
541
+ * does not return a nested child's form.
542
+ *
570
543
  * @returns {HTMLFormElement|undefined}
571
544
  */
572
545
  get form() {
573
- return this.querySelector("form.element-body")
546
+ return this.querySelector(`#${this.id} > form.element-body`)
574
547
  }
575
548
 
576
549
  /**
@@ -1,70 +1,30 @@
1
- import { hightlightTerm } from "alchemy_admin/components/remote_select"
2
-
3
- const formatSelection = (option) => {
4
- return `
5
- <div class="element-select-name">
6
- ${option.icon}<span>${option.text}</span>
7
- </div>
8
- `
9
- }
10
-
11
- const formatItem = (icon, text, hint) => {
12
- const description = hint
13
- ? `<div class="element-select-description">${hint}</div>`
14
- : ""
15
- return `
16
- <div class="element-select-item">
17
- ${formatSelection({ icon, text })}
18
- ${description}
19
- </div>
20
- `
21
- }
22
-
23
- export class ElementSelect extends HTMLElement {
24
- #select2 = null
25
-
26
- connectedCallback() {
27
- const results = this.options
28
- const options = {
29
- minimumResultsForSearch: 3,
30
- dropdownAutoWidth: true,
31
- data() {
32
- return { results }
33
- },
34
- formatResult: (option, _el, search) => {
35
- let text
36
-
37
- if (option.id === "") return option.text
38
- if (search.term !== "") {
39
- text = hightlightTerm(option.text, search.term)
40
- } else {
41
- text = option.text
42
- }
43
-
44
- return formatItem(option.icon, text, option.hint)
45
- },
46
- formatSelection,
47
- placeholder: this.placeholder
1
+ import { Select } from "alchemy_admin/components/select"
2
+
3
+ const renderName = (icon, text) => `
4
+ <div class="element-select-name">
5
+ ${icon}<span>${text}</span>
6
+ </div>
7
+ `
8
+
9
+ export class ElementSelect extends Select {
10
+ get renderers() {
11
+ return {
12
+ item: (data, escape) => renderName(data.icon, escape(data.text)),
13
+ option: (data, escape) => {
14
+ const description = data.hint
15
+ ? `<div class="element-select-description">${escape(data.hint)}</div>`
16
+ : ""
17
+ return `
18
+ <div class="element-select-item">
19
+ ${renderName(data.icon, escape(data.text))}
20
+ ${description}
21
+ </div>
22
+ `
23
+ }
48
24
  }
49
- this.#select2 = $(this.inputField).select2(options)
50
- }
51
-
52
- disconnectedCallback() {
53
- this.#select2?.select2("destroy")
54
- this.#select2 = null
55
- }
56
-
57
- get options() {
58
- return JSON.parse(this.getAttribute("options"))
59
- }
60
-
61
- get placeholder() {
62
- return this.getAttribute("placeholder")
63
- }
64
-
65
- get inputField() {
66
- return this.querySelector("input")
67
25
  }
68
26
  }
69
27
 
70
- customElements.define("alchemy-element-select", ElementSelect)
28
+ customElements.define("alchemy-element-select", ElementSelect, {
29
+ extends: "select"
30
+ })
@@ -5,9 +5,10 @@ class Icon extends HTMLElement {
5
5
 
6
6
  constructor() {
7
7
  super()
8
- this.spriteUrl = document
9
- .querySelector('link[rel="preload"][as="image"]')
10
- .getAttribute("href")
8
+ this.spriteUrl =
9
+ document
10
+ .querySelector('link[rel="preload"][as="image"]')
11
+ ?.getAttribute("href") ?? ""
11
12
  }
12
13
 
13
14
  connectedCallback() {
@@ -48,4 +49,8 @@ class Icon extends HTMLElement {
48
49
  }
49
50
  }
50
51
 
51
- customElements.define("alchemy-icon", Icon)
52
+ // Standalone components import this module next to the admin bundle, which
53
+ // already defined the element from its own copy.
54
+ if (!customElements.get("alchemy-icon")) {
55
+ customElements.define("alchemy-icon", Icon)
56
+ }