alchemy_cms 8.3.10 → 8.4.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 (370) 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 -29
  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/stylesheets/tinymce/skins/ui/alchemy/skin.scss +19 -0
  202. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +19 -0
  203. data/app/types/alchemy/cache_control_type.rb +84 -0
  204. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  205. data/app/views/alchemy/_menubar.html.erb +2 -154
  206. data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
  207. data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
  208. data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
  209. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
  210. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  211. data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
  212. data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
  213. data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
  214. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  215. data/app/views/alchemy/admin/crop.html.erb +20 -20
  216. data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
  217. data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
  218. data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
  219. data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
  220. data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
  221. data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
  222. data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
  223. data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
  224. data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
  225. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
  226. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
  227. data/app/views/alchemy/admin/elements/index.html.erb +1 -1
  228. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
  229. data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
  230. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
  231. data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
  232. data/app/views/alchemy/admin/languages/index.html.erb +25 -29
  233. data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
  234. data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
  235. data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
  236. data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
  237. data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
  238. data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
  239. data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
  240. data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
  241. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
  242. data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
  243. data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
  244. data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
  245. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
  246. data/app/views/alchemy/admin/pages/index.html.erb +26 -31
  247. data/app/views/alchemy/admin/pages/info.html.erb +2 -2
  248. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
  249. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
  250. data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
  251. data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
  252. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  253. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
  254. data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
  255. data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
  256. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  257. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
  258. data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
  259. data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
  260. data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
  261. data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
  262. data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
  263. data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
  264. data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
  265. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
  266. data/app/views/alchemy/admin/resources/index.html.erb +32 -13
  267. data/app/views/alchemy/admin/sites/index.html.erb +24 -22
  268. data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
  269. data/app/views/alchemy/admin/tags/index.html.erb +27 -25
  270. data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
  271. data/app/views/alchemy/admin/translations/_en.js +3 -0
  272. data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
  273. data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
  274. data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
  275. data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
  276. data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
  277. data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
  278. data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
  279. data/app/views/kaminari/alchemy/_page.html.erb +1 -2
  280. data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
  281. data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
  282. data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
  283. data/app/views/layouts/alchemy/admin.html.erb +5 -6
  284. data/config/brakeman.ignore +23 -1
  285. data/config/importmap.rb +10 -8
  286. data/config/initializers/dragonfly.rb +21 -17
  287. data/config/locales/alchemy.en.yml +53 -12
  288. data/config/routes.rb +3 -3
  289. data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
  290. data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
  291. data/lib/alchemy/configuration/number_option.rb +13 -0
  292. data/lib/alchemy/configuration/proc_option.rb +13 -0
  293. data/lib/alchemy/configuration.rb +2 -0
  294. data/lib/alchemy/configurations/admin_components.rb +30 -0
  295. data/lib/alchemy/configurations/format_matchers.rb +7 -1
  296. data/lib/alchemy/configurations/main.rb +103 -4
  297. data/lib/alchemy/configurations/menu.rb +91 -0
  298. data/lib/alchemy/configurations/menu_item.rb +123 -0
  299. data/lib/alchemy/configurations/sitemap.rb +1 -0
  300. data/lib/alchemy/configurations/uploader.rb +15 -1
  301. data/lib/alchemy/engine.rb +0 -6
  302. data/lib/alchemy/i18n.rb +26 -0
  303. data/lib/alchemy/modules.rb +52 -50
  304. data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
  305. data/lib/alchemy/tasks/tidy.rb +1 -1
  306. data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
  307. data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
  308. data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
  309. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
  310. data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
  311. data/lib/alchemy/test_support/shared_publishable_examples.rb +39 -4
  312. data/lib/alchemy/upgrader/eight_four.rb +237 -0
  313. data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
  314. data/lib/alchemy/upgrader.rb +2 -1
  315. data/lib/alchemy/version.rb +1 -1
  316. data/lib/alchemy.rb +0 -1
  317. data/lib/alchemy_cms.rb +0 -13
  318. data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
  319. data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
  320. data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
  321. data/lib/generators/alchemy/install/install_generator.rb +2 -13
  322. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
  323. data/lib/generators/alchemy/module/module_generator.rb +1 -0
  324. data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
  325. data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
  326. data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
  327. data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
  328. data/lib/tasks/alchemy/thumbnails.rake +16 -29
  329. data/lib/tasks/alchemy/upgrade.rake +45 -3
  330. data/vendor/javascript/cropperjs.min.js +3 -3
  331. data/vendor/javascript/floating-ui.min.js +1 -1
  332. data/vendor/javascript/tinymce.min.js +5 -5
  333. data/vendor/javascript/tom-select.min.js +1 -1
  334. metadata +85 -65
  335. data/app/javascript/alchemy_admin/components/action.js +0 -50
  336. data/app/javascript/alchemy_admin/dirty.js +0 -55
  337. data/app/javascript/alchemy_admin/node_tree.js +0 -82
  338. data/app/javascript/alchemy_admin/picture_selector.js +0 -67
  339. data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
  340. data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
  341. data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
  342. data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
  343. data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
  344. data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
  345. data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
  346. data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
  347. data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
  348. data/app/views/alchemy/base/error_notice.js.erb +0 -1
  349. data/app/views/alchemy/base/permission_denied.js.erb +0 -3
  350. data/app/views/alchemy/base/redirect.js.erb +0 -11
  351. data/config/alchemy/config.yml +0 -217
  352. data/config/alchemy/modules.yml +0 -82
  353. data/config/initializers/rails_live_reload.rb +0 -13
  354. data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
  355. data/lib/alchemy/taggable.rb +0 -49
  356. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
  357. data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
  358. data/vendor/javascript/handlebars.min.js +0 -29
  359. data/vendor/javascript/jquery.min.js +0 -2
  360. data/vendor/javascript/rails-ujs.min.js +0 -1
  361. data/vendor/javascript/select2.min.js +0 -23
  362. /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
  363. /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
  364. /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
  365. /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
  366. /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
  367. /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
  368. /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
  369. /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
  370. /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
@@ -1,9 +1,18 @@
1
+ import { Turbo } from "@hotwired/turbo-rails"
1
2
  import Hotkeys from "alchemy_admin/hotkeys"
2
3
  import Spinner from "alchemy_admin/spinner"
4
+ import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
5
+ import { dispatchCustomEvent } from "alchemy_admin/utils/events"
3
6
 
4
7
  // Collection of all current dialog instances
5
8
  const currentDialogs = []
6
9
 
10
+ // Id of the Turbo Frame wrapping the dialog content.
11
+ // Keep in sync with Alchemy::Admin::DIALOG_FRAME_ID.
12
+ export const DIALOG_FRAME_ID = "alchemy_dialog_frame"
13
+
14
+ const TURBO_STREAM_CONTENT_TYPE = "text/vnd.turbo-stream.html"
15
+
7
16
  const DEFAULTS = {
8
17
  header_height: 36,
9
18
  size: "400x300",
@@ -16,6 +25,9 @@ const DEFAULTS = {
16
25
  }
17
26
 
18
27
  export class Dialog {
28
+ #previousFlashParent = null
29
+ #closing = false
30
+
19
31
  // Arguments:
20
32
  // - url: The url to load the content from via ajax
21
33
  // - options: A object holding options
@@ -24,9 +36,6 @@ export class Dialog {
24
36
  constructor(url, options = {}) {
25
37
  this.url = url
26
38
  this.options = { ...DEFAULTS, ...options }
27
- this.$document = $(document)
28
- this.$window = $(window)
29
- this.$body = $("body")
30
39
  const size = this.options.size.split("x")
31
40
  this.width = parseInt(size[0], 10)
32
41
  this.height = parseInt(size[1], 10)
@@ -36,85 +45,153 @@ export class Dialog {
36
45
 
37
46
  // Opens the Dialog and loads the content via ajax.
38
47
  open() {
39
- this.dialog.trigger("Alchemy.DialogOpen")
48
+ dispatchCustomEvent(this.dialog, "Alchemy.DialogOpen")
40
49
  this.bind_close_events()
50
+ if (this.options.modal) {
51
+ this.dialog_container.showModal()
52
+ this.#adoptFlashNotices()
53
+ } else {
54
+ this.dialog_container.show()
55
+ }
41
56
  window.requestAnimationFrame(() => {
42
- this.dialog_container.addClass("open")
43
- if (this.overlay != null) {
44
- return this.overlay.addClass("open")
45
- }
57
+ this.dialog_container.classList.add("open")
46
58
  })
47
- this.$body.addClass("prevent-scrolling")
59
+ document.body.classList.add("prevent-scrolling")
48
60
  currentDialogs.push(this)
49
61
  this.load()
50
62
  }
51
63
 
52
64
  // Closes the Dialog and removes it from the DOM
53
65
  close() {
54
- this.dialog.trigger("DialogClose.Alchemy")
55
- this.$document.off("keydown")
56
- this.dialog_container.removeClass("open")
57
- if (this.overlay != null) {
58
- this.overlay.removeClass("open")
66
+ // The container stays open during the closing transition, so close() can be
67
+ // called again mid-fade (mashing Esc, a double click on the close button or
68
+ // backdrop). Guard against re-entry, otherwise a second transitionend
69
+ // listener is registered and the teardown runs twice.
70
+ if (this.#closing) {
71
+ return true
59
72
  }
60
- this.$document.on(
61
- "webkitTransitionEnd transitionend oTransitionEnd",
73
+ this.#closing = true
74
+ dispatchCustomEvent(this.dialog, "DialogClose.Alchemy")
75
+ this.dialog_container.classList.remove("open")
76
+ this.dialog_container.addEventListener(
77
+ "transitionend",
62
78
  () => {
63
- this.$document.off("webkitTransitionEnd transitionend oTransitionEnd")
79
+ // Move the flash notices back out before removing the container, so
80
+ // they are not destroyed together with the dialog.
81
+ this.#releaseFlashNotices()
82
+ this.dialog_container.close()
64
83
  this.dialog_container.remove()
65
- if (this.overlay != null) {
66
- this.overlay.remove()
84
+ currentDialogs.pop()
85
+ if (currentDialogs.length === 0) {
86
+ document.body.classList.remove("prevent-scrolling")
67
87
  }
68
- this.$body.removeClass("prevent-scrolling")
69
- currentDialogs.pop(this)
70
88
  if (this.options.closed != null) {
71
- return this.options.closed()
89
+ this.options.closed()
72
90
  }
73
- }
91
+ },
92
+ { once: true }
74
93
  )
75
94
  return true
76
95
  }
77
96
 
97
+ // A modal <dialog> makes everything outside its subtree inert, so flash
98
+ // notices rendered on the body would be visible but not interactable while
99
+ // the dialog is open. Move them into the dialog, which is in the top layer
100
+ // and not inert, for the dialog's lifetime and restore them on close.
101
+ #adoptFlashNotices() {
102
+ const flashNotices = document.getElementById("flash_notices")
103
+ if (flashNotices) {
104
+ this.#previousFlashParent = flashNotices.parentElement
105
+ this.dialog_container.append(flashNotices)
106
+ this.#clearTransientNotices(flashNotices)
107
+ }
108
+ }
109
+
110
+ // The notices are not cleared on the way back. Anything transient left at
111
+ // this point was raised by the dialog itself, typically the growl of the
112
+ // response that closes it, and has to outlive the dialog. Moving it re-arms
113
+ // its dismiss timer, which is what a just-raised notice wants anyway.
114
+ #releaseFlashNotices() {
115
+ const flashNotices = document.getElementById("flash_notices")
116
+ if (flashNotices && this.#previousFlashParent) {
117
+ this.#previousFlashParent.append(flashNotices)
118
+ }
119
+ }
120
+
121
+ // Only the persistent error notices need to travel with the dialog to stay
122
+ // interactable. Moving the auto-dismissing ones would restart their dismiss
123
+ // timers (their connectedCallback re-arms the timer), making them linger, so
124
+ // remove them instead. A fade-out is skipped on purpose: the just-moved
125
+ // element has no transition baseline, so dismiss()'s transitionend would never
126
+ // fire and the node would be stranded invisible-but-present.
127
+ #clearTransientNotices(flashNotices) {
128
+ flashNotices
129
+ .querySelectorAll('alchemy-message[dismissable]:not([type="error"])')
130
+ .forEach((message) => message.remove())
131
+ }
132
+
78
133
  // Loads the content via ajax and replaces the Dialog body with server response.
134
+ //
135
+ // The Turbo-Frame header tells the server this content is rendered for a
136
+ // dialog, so it wraps the response in the dialog frame (see set_layout). The
137
+ // same action requested as a full page is not wrapped.
79
138
  load() {
80
139
  this.show_spinner()
81
- $.get(this.url, (data) => {
82
- this.replace(data)
83
- }).fail((xhr) => {
84
- this.show_error(xhr)
140
+ fetch(this.url, {
141
+ headers: { "Turbo-Frame": DIALOG_FRAME_ID }
85
142
  })
143
+ .then(async (response) => {
144
+ const responseText = await response.text()
145
+ // A stream answers a request the frame must not render, whatever the
146
+ // status: an expired session sends a `dialog_visit` to the login page.
147
+ // Without this the stream is never run and the dialog shows a 401 the
148
+ // user cannot act on.
149
+ if (
150
+ response.headers
151
+ .get("content-type")
152
+ ?.includes(TURBO_STREAM_CONTENT_TYPE)
153
+ ) {
154
+ Turbo.renderStreamMessage(responseText)
155
+ } else if (response.ok) {
156
+ this.replace(responseText)
157
+ } else {
158
+ this.show_error({
159
+ status: response.status,
160
+ statusText: response.statusText,
161
+ responseText
162
+ })
163
+ }
164
+ })
165
+ .catch(() => {
166
+ this.show_error({ status: 0 })
167
+ })
86
168
  }
87
169
 
88
170
  // Reloads the Dialog content
89
171
  reload() {
90
- this.dialog_body.empty()
172
+ this.dialog_body.innerHTML = ""
91
173
  this.load()
92
174
  }
93
175
 
94
176
  // Replaces the dialog body with given content and initializes it.
95
177
  replace(data) {
96
178
  this.remove_spinner()
97
- this.dialog_body.hide()
98
- this.dialog_body.html(data)
179
+ this.dialog_body.style.display = "none"
180
+ this.dialog_body.innerHTML = data
99
181
  this.init()
100
- this.dialog[0].dispatchEvent(
101
- new CustomEvent("DialogReady.Alchemy", {
102
- bubbles: true,
103
- detail: {
104
- body: this.dialog_body[0]
105
- }
106
- })
107
- )
182
+ dispatchCustomEvent(this.dialog, "DialogReady.Alchemy", {
183
+ body: this.dialog_body
184
+ })
108
185
  if (this.options.ready != null) {
109
186
  this.options.ready(this.dialog_body)
110
187
  }
111
- this.dialog_body.show()
188
+ this.dialog_body.style.display = ""
112
189
  }
113
190
 
114
191
  // Adds a spinner into Dialog body
115
192
  show_spinner() {
116
193
  this.spinner = new Spinner("medium")
117
- this.spinner.spin(this.dialog_body[0])
194
+ this.spinner.spin(this.dialog_body)
118
195
  }
119
196
 
120
197
  // Removes the spinner from Dialog body
@@ -125,38 +202,27 @@ export class Dialog {
125
202
  // Initializes the Dialog body
126
203
  init() {
127
204
  Hotkeys(this.dialog_body)
128
- this.watch_remote_forms()
129
- window.requestAnimationFrame(() => {
130
- this.dialog_body.find("[autofocus]").focus()
131
- })
205
+ window.requestAnimationFrame(() => this.#focusInitialElement())
132
206
  }
133
207
 
134
- // Watches ajax requests inside of dialog body and replaces the content accordingly
135
- watch_remote_forms() {
136
- const $form = $('[data-remote="true"]', this.dialog_body)
137
-
138
- $form.on("ajax:success", (event) => {
139
- const xhr = event.detail[2]
140
- const content_type = xhr.getResponseHeader("Content-Type")
141
- if (content_type.match(/javascript/)) {
142
- return
143
- } else {
144
- this.dialog_body.html(xhr.responseText)
145
- this.init()
146
- }
147
- })
148
-
149
- $form.on("ajax:error", (event) => {
150
- const statusText = event.detail[1]
151
- const xhr = event.detail[2]
152
- this.show_error(xhr, statusText)
153
- })
208
+ // Shoelace tab panels (link, page configure) only render a few frames after
209
+ // the content loaded, so their fields can not take focus yet. Hence the retry.
210
+ #focusInitialElement(attempts = 0) {
211
+ const target =
212
+ this.dialog_body.querySelector("[autofocus]") ??
213
+ this.dialog_body.querySelector("form [type='submit']")
214
+ target?.focus()
215
+ if (document.activeElement === this.close_button && attempts < 20) {
216
+ window.requestAnimationFrame(() =>
217
+ this.#focusInitialElement(attempts + 1)
218
+ )
219
+ }
154
220
  }
155
221
 
156
222
  // Displays an error message
157
223
  show_error(xhr, statusText) {
158
224
  if (xhr.status === 422) {
159
- this.dialog_body.html(xhr.responseText)
225
+ this.dialog_body.innerHTML = xhr.responseText
160
226
  this.init()
161
227
  return
162
228
  }
@@ -166,12 +232,10 @@ export class Dialog {
166
232
  statusText
167
233
  )
168
234
 
169
- const $errorDiv = $(`<alchemy-message type="${error_type}">
235
+ this.dialog_body.innerHTML = `<alchemy-message type="${error_type}">
170
236
  <h1>${error_header}</h1>
171
237
  <p>${error_body}</p>
172
- </alchemy-message>`)
173
-
174
- this.dialog_body.html($errorDiv)
238
+ </alchemy-message>`
175
239
  }
176
240
 
177
241
  // Returns error message based on xhr status
@@ -206,55 +270,69 @@ export class Dialog {
206
270
  // Binds close events on:
207
271
  // - Close button
208
272
  // - Overlay (if the Dialog is a modal)
209
- // - ESC Key
273
+ // - ESC Key (the dialog element's cancel event)
210
274
  bind_close_events() {
211
- this.close_button.on("click", () => {
275
+ this.close_button.addEventListener("click", (e) => {
276
+ e.preventDefault()
212
277
  this.close()
213
278
  })
214
- this.dialog_container.addClass("closable").on("click", (e) => {
215
- if (e.target !== this.dialog_container.get(0)) {
216
- return true
217
- }
279
+ this.dialog_container.addEventListener("close", (e) => {
280
+ e.preventDefault()
218
281
  this.close()
219
- return false
220
282
  })
221
- this.$document.keydown((e) => {
222
- if (e.which === 27) {
283
+ this.dialog_container.classList.add("closable")
284
+ // Use pointerdown, not click: a click whose mousedown and mouseup land on
285
+ // different nodes is dispatched to their common ancestor — this element for
286
+ // anything inside the dialog — closing it although the backdrop was never
287
+ // hit, e.g. when selecting text and releasing outside.
288
+ this.dialog_container.addEventListener("pointerdown", (e) => {
289
+ if (e.target === this.dialog_container) {
223
290
  this.close()
224
- return false
225
- } else {
226
- return true
227
291
  }
228
292
  })
293
+ this.dialog_container.addEventListener("cancel", (e) => {
294
+ // The <dialog>'s own cancel event (Esc) targets the container, but a file
295
+ // input's native cancel event (dismissing the OS file picker) bubbles up
296
+ // to the same node. Only the former should close the dialog.
297
+ if (e.target !== this.dialog_container) {
298
+ return
299
+ }
300
+ e.preventDefault()
301
+ this.close()
302
+ })
229
303
  }
230
304
 
231
305
  // Builds the html structure of the Dialog
232
306
  build() {
233
- this.dialog_container = $('<div class="alchemy-dialog-container" />')
234
- this.dialog = $('<div class="alchemy-dialog" />')
235
- this.dialog_body = $('<div class="alchemy-dialog-body" />')
236
- this.dialog_header = $('<div class="alchemy-dialog-header" />')
237
- this.dialog_title = $('<div class="alchemy-dialog-title" />')
238
- this.close_button = $(
307
+ this.dialog_container = createHtmlElement(
308
+ '<dialog class="alchemy-dialog-container"></dialog>'
309
+ )
310
+ this.dialog = createHtmlElement('<div class="alchemy-dialog"></div>')
311
+ this.dialog_body = createHtmlElement(
312
+ '<div class="alchemy-dialog-body"></div>'
313
+ )
314
+ this.dialog_header = createHtmlElement(
315
+ '<div class="alchemy-dialog-header"></div>'
316
+ )
317
+ this.dialog_title = createHtmlElement(
318
+ '<div class="alchemy-dialog-title"></div>'
319
+ )
320
+ this.close_button = createHtmlElement(
239
321
  '<button class="alchemy-dialog-close"><alchemy-icon name="close"></alchemy-icon></button>'
240
322
  )
241
- this.dialog_title.text(this.options.title)
323
+ this.dialog_title.textContent = this.options.title
242
324
  this.dialog_header.append(this.dialog_title)
243
325
  this.dialog_header.append(this.close_button)
244
326
  this.dialog.append(this.dialog_header)
245
327
  this.dialog.append(this.dialog_body)
246
328
  this.dialog_container.append(this.dialog)
247
329
  if (this.options.modal) {
248
- this.dialog.addClass("modal")
330
+ this.dialog.classList.add("modal")
249
331
  }
250
332
  if (this.options.padding) {
251
- this.dialog_body.addClass("padded")
252
- }
253
- if (this.options.modal) {
254
- this.overlay = $('<div class="alchemy-dialog-overlay" />')
255
- this.$body.append(this.overlay)
333
+ this.dialog_body.classList.add("padded")
256
334
  }
257
- this.$body.append(this.dialog_container)
335
+ document.body.append(this.dialog_container)
258
336
  }
259
337
 
260
338
  // Sets the correct size of the dialog
@@ -262,29 +340,23 @@ export class Dialog {
262
340
  resize() {
263
341
  const { width, height } = this.getSize()
264
342
 
265
- this.dialog.css({
266
- width: width,
267
- "min-height": height,
268
- overflow: this.options.overflow
269
- })
343
+ this.dialog.style.width = `${width}px`
344
+ this.dialog.style.minHeight = `${height}px`
345
+ this.dialog.style.overflow = this.options.overflow
270
346
 
271
347
  if (this.options.overflow === "hidden") {
272
- this.dialog_body.css({
273
- height: height,
274
- overflow: "auto"
275
- })
348
+ this.dialog_body.style.height = `${height}px`
349
+ this.dialog_body.style.overflow = "auto"
276
350
  } else {
277
- this.dialog_body.css({
278
- "min-height": height,
279
- overflow: "visible"
280
- })
351
+ this.dialog_body.style.minHeight = `${height}px`
352
+ this.dialog_body.style.overflow = "visible"
281
353
  }
282
354
  }
283
355
 
284
356
  getSize() {
285
357
  const padding = this.options.padding ? 16 : 0
286
- const doc_width = this.$window.width()
287
- const doc_height = this.$window.height()
358
+ const doc_width = window.innerWidth
359
+ const doc_height = window.innerHeight
288
360
 
289
361
  let width = this.width
290
362
  let height = this.height
@@ -3,11 +3,6 @@ import "keymaster"
3
3
  const bindedHotkeys = []
4
4
 
5
5
  export default function (scope = document) {
6
- // The scope can be a jQuery object because we still use jQuery in alchemy_admin/dialog.js.
7
- if (scope instanceof jQuery) {
8
- scope = scope[0]
9
- }
10
-
11
6
  // Unbind all previously registered hotkeys if we are not inside a dialog.
12
7
  if (scope === document) {
13
8
  bindedHotkeys.forEach((hotkey) => key.unbind(hotkey))
@@ -11,7 +11,9 @@ function nestedTranslation(translations, key) {
11
11
 
12
12
  function getTranslation(key) {
13
13
  const locale = currentLocale()
14
- const translations = Alchemy.translations
14
+ // Host engines import components standalone, outside of the admin layout
15
+ // that defines the global.
16
+ const translations = window.Alchemy?.translations
15
17
 
16
18
  if (!translations) {
17
19
  console.warn(`Translations for locale ${locale} not found!`)
@@ -39,11 +41,3 @@ export function translate(key, replacement = undefined) {
39
41
  }
40
42
  return translation
41
43
  }
42
-
43
- export async function setupSelectLocale() {
44
- const locale = currentLocale()
45
- if (locale === "en") return
46
-
47
- await import(`select2/${locale}.js`)
48
- $.extend($.fn.select2.defaults, $.fn.select2.locales[locale])
49
- }
@@ -1,71 +1,87 @@
1
1
  import { Dialog } from "alchemy_admin/dialog"
2
+ import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
2
3
 
3
4
  export default class ImageOverlay extends Dialog {
4
- constructor(url, options = {}) {
5
- super(url, options)
6
- }
7
-
8
- init() {
9
- $(".zoomed-picture-background").on("click", (e) => {
5
+ // The picture is rendered in a Turbo frame that replaces its content on every
6
+ // navigation, so the picture elements are looked up on demand instead of being
7
+ // bound once.
8
+ #clickHandler = (e) => {
9
+ if (e.target.closest(".picture-overlay-handle")) {
10
+ e.preventDefault()
11
+ this.dialog.classList.toggle("hide-form")
12
+ return
13
+ }
14
+ if (e.target.closest(".zoomed-picture-background")) {
10
15
  e.stopPropagation()
11
16
  if (e.target.nodeName === "IMG") {
12
17
  return
13
18
  }
14
19
  this.close()
15
- return false
16
- })
17
- $(".picture-overlay-handle").on("click", (e) => {
18
- this.dialog.toggleClass("hide-form")
19
- return false
20
- })
21
- this.$previous = $(".previous-picture")
22
- this.$next = $(".next-picture")
20
+ }
21
+ }
22
+
23
+ #keydownHandler = (e) => {
24
+ if (e.target.nodeName === "INPUT" || e.target.nodeName === "TEXTAREA") {
25
+ return
26
+ }
27
+ switch (e.key) {
28
+ case "ArrowLeft":
29
+ this.previous()
30
+ e.preventDefault()
31
+ break
32
+ case "ArrowRight":
33
+ this.next()
34
+ e.preventDefault()
35
+ break
36
+ }
37
+ }
38
+
39
+ // The overlay always fills the viewport via its stylesheet, so the inline
40
+ // dimensions the generic dialog applies would only cap it.
41
+ resize() {}
42
+
43
+ init() {
44
+ this.dialog_body.removeEventListener("click", this.#clickHandler)
45
+ this.dialog_body.addEventListener("click", this.#clickHandler)
23
46
  this.#initKeyboardNavigation()
24
47
  super.init()
25
48
  }
26
49
 
27
50
  previous() {
28
- if (this.$previous[0] != null) {
29
- this.$previous[0].click()
30
- }
51
+ this.dialog_body.querySelector(".previous-picture")?.click()
31
52
  }
32
53
 
33
54
  next() {
34
- if (this.$next[0] != null) {
35
- this.$next[0].click()
36
- }
55
+ this.dialog_body.querySelector(".next-picture")?.click()
56
+ }
57
+
58
+ close() {
59
+ document.removeEventListener("keydown", this.#keydownHandler)
60
+ return super.close()
37
61
  }
38
62
 
39
63
  build() {
40
- this.dialog_container = $('<div class="alchemy-image-overlay-container" />')
41
- this.dialog = $('<div class="alchemy-image-overlay-dialog" />')
42
- this.dialog_body = $('<div class="alchemy-image-overlay-body" />')
43
- this.close_button = $(`<a class="alchemy-image-overlay-close">
64
+ this.dialog_container = createHtmlElement(
65
+ '<dialog class="alchemy-image-overlay-container"></dialog>'
66
+ )
67
+ this.dialog = createHtmlElement(
68
+ '<div class="alchemy-image-overlay-dialog"></div>'
69
+ )
70
+ this.dialog_body = createHtmlElement(
71
+ '<div class="alchemy-image-overlay-body"></div>'
72
+ )
73
+ this.close_button =
74
+ createHtmlElement(`<a class="alchemy-image-overlay-close">
44
75
  <alchemy-icon name="close" size="xl"></alchemy-icon>
45
76
  </a>`)
46
77
  this.dialog.append(this.close_button)
47
78
  this.dialog.append(this.dialog_body)
48
79
  this.dialog_container.append(this.dialog)
49
- this.overlay = $('<div class="alchemy-image-overlay" />')
50
- this.$body.append(this.overlay)
51
- this.$body.append(this.dialog_container)
80
+ document.body.append(this.dialog_container)
52
81
  }
53
82
 
54
83
  #initKeyboardNavigation() {
55
- this.$document.keydown((e) => {
56
- if (e.target.nodeName === "INPUT" || e.target.nodeName === "TEXTAREA") {
57
- return true
58
- }
59
- switch (e.which) {
60
- case 37:
61
- this.previous()
62
- return false
63
- case 39:
64
- this.next()
65
- return false
66
- default:
67
- return true
68
- }
69
- })
84
+ document.removeEventListener("keydown", this.#keydownHandler)
85
+ document.addEventListener("keydown", this.#keydownHandler)
70
86
  }
71
87
  }
@@ -5,12 +5,12 @@ import { openDialog } from "alchemy_admin/dialog"
5
5
  // Opens the help dialog when the user presses the "?" key outside of a field.
6
6
  function showHelp(evt) {
7
7
  if (
8
- !$(evt.target).is("input, textarea") &&
8
+ !evt.target.matches("input, textarea") &&
9
9
  String.fromCharCode(evt.which) === "?"
10
10
  ) {
11
11
  openDialog("/admin/help", {
12
12
  title: Alchemy.t("help"),
13
- size: "400x492"
13
+ size: "500x400"
14
14
  })
15
15
  return false
16
16
  } else {
@@ -1,11 +1,24 @@
1
1
  import { translate } from "alchemy_admin/i18n"
2
2
  import { Dialog } from "alchemy_admin/dialog"
3
+ import { routes } from "alchemy_admin/routes"
3
4
 
4
5
  // Matches a URL fragment (#anchor) at the end of a string.
5
6
  // Covers RFC 3986 unreserved characters (ALPHA, DIGIT, "-", ".", "_", "~")
6
7
  // which are the characters valid in URL fragments and common in DOM element IDs.
7
8
  const ANCHOR_REGEX = /#[\w.~-]+$/
8
9
 
10
+ // The server renders the expression as a JavaScript literal, so that whatever
11
+ // a host application configures keeps the meaning it had as an inline script.
12
+ // A replaced external tab may not render it, the server validates urls anyway.
13
+ function isLinkUrl(url) {
14
+ const literal =
15
+ document.getElementById("external_link")?.dataset.linkUrlRegexp
16
+ if (!literal) return true
17
+
18
+ const end = literal.lastIndexOf("/")
19
+ return new RegExp(literal.slice(1, end), literal.slice(end + 1)).test(url)
20
+ }
21
+
9
22
  // Represents the link Dialog that appears, if a user clicks the link buttons
10
23
  // in TinyMCE or on an Ingredient that has links enabled (e.g. Picture)
11
24
  //
@@ -13,7 +26,7 @@ export class LinkDialog extends Dialog {
13
26
  #onCreateLink
14
27
 
15
28
  constructor(link) {
16
- const url = new URL(Alchemy.routes.link_admin_pages_path, window.location)
29
+ const url = new URL(routes.link_admin_pages_path, window.location)
17
30
  const parameterMapping = {
18
31
  url: link.url,
19
32
  selected_tab: link.type,
@@ -109,7 +122,7 @@ export class LinkDialog extends Dialog {
109
122
  if (linkType === "internal" && elementAnchor.value !== "") {
110
123
  // remove possible fragments on the url and attach the fragment (which contains the #)
111
124
  url = url.replace(ANCHOR_REGEX, "") + elementAnchor.value
112
- } else if (linkType === "external" && !url.match(Alchemy.link_url_regexp)) {
125
+ } else if (linkType === "external" && !isLinkUrl(url)) {
113
126
  // show validation error and prevent link creation
114
127
  this.#showValidationError()
115
128
  return
@@ -0,0 +1,18 @@
1
+ import { readJSONScript } from "alchemy_admin/utils/json_script"
2
+
3
+ // Member routes are rendered with 1 as the record id and become functions
4
+ // that put the given id in its place.
5
+ function buildRoutes({ collection = {}, member = {} } = {}) {
6
+ const routes = { ...collection }
7
+
8
+ Object.entries(member).forEach(([name, path]) => {
9
+ routes[name] =
10
+ typeof path === "string"
11
+ ? (id) => path.replace(/1/, id)
12
+ : buildRoutes({ member: path })
13
+ })
14
+
15
+ return routes
16
+ }
17
+
18
+ export const routes = buildRoutes(readJSONScript("alchemy_routes"))