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,31 +1,30 @@
1
1
  import Cropper from "cropperjs"
2
+ import { currentDialog } from "alchemy_admin/dialog"
2
3
 
3
- export default class ImageCropper {
4
- #initialized = false
4
+ export class ImageCropper extends HTMLElement {
5
5
  #cropper = null
6
6
  #cropFromField = null
7
7
  #cropSizeField = null
8
8
 
9
- constructor(image, settings) {
10
- this.image = image
11
- this.defaultBox = settings.default_box
12
- this.aspectRatio = settings.ratio
9
+ connectedCallback() {
10
+ this.image = this.querySelector("img")
11
+ this.form = this.querySelector("form")
13
12
  this.#cropFromField = document.getElementById(
14
- settings.crop_from_form_field_id
13
+ this.getAttribute("crop-from-field-id")
15
14
  )
16
15
  this.#cropSizeField = document.getElementById(
17
- settings.crop_size_form_field_id
16
+ this.getAttribute("crop-size-field-id")
18
17
  )
19
- this.elementId = settings.element_id
20
18
  this.elementEditor = document.querySelector(
21
- `[data-element-id='${this.elementId}']`
19
+ `[data-element-id='${this.getAttribute("element-id")}']`
22
20
  )
23
- this.dialog = Alchemy.currentDialog()
24
- if (this.dialog) {
25
- this.dialog.options.closed = () => this.destroy()
26
- this.bind()
27
- }
28
- this.init()
21
+ this.form.addEventListener("submit", this.#onSubmit)
22
+ this.form.addEventListener("reset", this.#onReset)
23
+ this.#cropper = new Cropper(this.image, this.cropperOptions)
24
+ }
25
+
26
+ disconnectedCallback() {
27
+ this.#cropper?.destroy()
29
28
  }
30
29
 
31
30
  get cropperOptions() {
@@ -39,6 +38,12 @@ export default class ImageCropper {
39
38
  }
40
39
  }
41
40
 
41
+ get aspectRatio() {
42
+ const ratio = this.getAttribute("ratio")
43
+ // NaN lets cropperjs use a free aspect ratio.
44
+ return ratio ? parseFloat(ratio) : NaN
45
+ }
46
+
42
47
  get cropFrom() {
43
48
  if (this.#cropFromField?.value) {
44
49
  return this.#cropFromField.value.split("x").map((v) => parseInt(v))
@@ -60,24 +65,13 @@ export default class ImageCropper {
60
65
  height: this.cropSize[1]
61
66
  }
62
67
  } else {
63
- return this.defaultBoxSize
64
- }
65
- }
66
-
67
- get defaultBoxSize() {
68
- return {
69
- x: this.defaultBox[0],
70
- y: this.defaultBox[1],
71
- width: this.defaultBox[2],
72
- height: this.defaultBox[3]
68
+ return this.defaultBox
73
69
  }
74
70
  }
75
71
 
76
- init() {
77
- if (!this.#initialized) {
78
- this.#cropper = new Cropper(this.image, this.cropperOptions)
79
- this.#initialized = true
80
- }
72
+ get defaultBox() {
73
+ const box = JSON.parse(this.getAttribute("default-box"))
74
+ return { x: box[0], y: box[1], width: box[2], height: box[3] }
81
75
  }
82
76
 
83
77
  update(coords) {
@@ -90,7 +84,7 @@ export default class ImageCropper {
90
84
  reset() {
91
85
  const cropper = this.#cropper
92
86
  // Apply the default size. cropperjs clamps the crop box to its maximum size.
93
- cropper.setData(this.defaultBoxSize)
87
+ cropper.setData(this.defaultBox)
94
88
  // When the default box sits at the maximum crop box size, sub-pixel rounding
95
89
  // makes cropperjs treat setData's box as oversized and revert its position
96
90
  // (renderCropBox resets top/left to their old values). Re-apply the position
@@ -99,31 +93,23 @@ export default class ImageCropper {
99
93
  const canvas = cropper.getCanvasData()
100
94
  const scale = canvas.width / canvas.naturalWidth
101
95
  cropper.setCropBoxData({
102
- left: canvas.left + this.defaultBoxSize.x * scale,
103
- top: canvas.top + this.defaultBoxSize.y * scale
96
+ left: canvas.left + this.defaultBox.x * scale,
97
+ top: canvas.top + this.defaultBox.y * scale
104
98
  })
105
- this.update(this.defaultBoxSize)
99
+ this.update(this.defaultBox)
106
100
  }
107
101
 
108
- destroy() {
109
- if (this.#cropper) {
110
- this.#cropper.destroy()
111
- }
112
- this.#initialized = false
113
- return true
102
+ #onSubmit = (event) => {
103
+ event.preventDefault()
104
+ this.update(this.#cropper.getData(true))
105
+ this.elementEditor?.setDirty()
106
+ currentDialog()?.close()
114
107
  }
115
108
 
116
- bind() {
117
- this.dialog.dialog_body.find('button[type="submit"]').on("click", () => {
118
- const data = this.#cropper.getData(true)
119
- this.update(data)
120
- this.elementEditor.setDirty()
121
- this.dialog.close()
122
- return false
123
- })
124
- this.dialog.dialog_body.find('button[type="reset"]').on("click", () => {
125
- this.reset()
126
- return false
127
- })
109
+ #onReset = (event) => {
110
+ event.preventDefault()
111
+ this.reset()
128
112
  }
129
113
  }
114
+
115
+ customElements.define("alchemy-image-cropper", ImageCropper)
@@ -0,0 +1,14 @@
1
+ import { DialogLink } from "alchemy_admin/components/dialog_link"
2
+ import ImageOverlay from "alchemy_admin/image_overlay"
3
+
4
+ // Opens the picture in a full screen image overlay instead of navigating to it
5
+ export class ImageOverlayLink extends DialogLink {
6
+ openDialog() {
7
+ this.dialog = new ImageOverlay(this.href, { padding: false })
8
+ this.dialog.open()
9
+ }
10
+ }
11
+
12
+ customElements.define("alchemy-image-overlay-link", ImageOverlayLink, {
13
+ extends: "a"
14
+ })
@@ -1,15 +1,17 @@
1
1
  "use strict"
2
2
  // ^ add support for top-level await in Terser
3
3
 
4
- import "alchemy_admin/components/action"
5
4
  import "alchemy_admin/components/attachment_select"
6
5
  import "alchemy_admin/components/auto_submit"
7
6
  import "alchemy_admin/components/button"
8
7
  import "alchemy_admin/components/char_counter"
8
+ import "alchemy_admin/components/clear_schedule_button"
9
9
  import "alchemy_admin/components/clipboard_button"
10
10
  import "alchemy_admin/components/color_select"
11
+ import "alchemy_admin/components/conditional_field"
11
12
  import "alchemy_admin/components/datepicker"
12
13
  import "alchemy_admin/components/dialog_link"
14
+ import "alchemy_admin/components/dirty_guard"
13
15
  import "alchemy_admin/components/dom_id_select"
14
16
  import "alchemy_admin/components/element_editor"
15
17
  import "alchemy_admin/components/element_select"
@@ -17,17 +19,26 @@ import "alchemy_admin/components/elements_window"
17
19
  import "alchemy_admin/components/elements_window_handle"
18
20
  import "alchemy_admin/components/file_editor"
19
21
  import "alchemy_admin/components/list_filter"
22
+ import "alchemy_admin/components/main_navi_entry"
23
+ import "alchemy_admin/components/menu_node"
20
24
  import "alchemy_admin/components/message"
21
25
  import "alchemy_admin/components/growl"
22
26
  import "alchemy_admin/components/icon"
27
+ import "alchemy_admin/components/image_cropper"
28
+ import "alchemy_admin/components/image_overlay_link"
23
29
  import "alchemy_admin/components/ingredient_group"
24
30
  import "alchemy_admin/components/link_buttons"
31
+ import "alchemy_admin/components/news_reader"
32
+ import "alchemy_admin/components/node_folder"
33
+ import "alchemy_admin/components/node_form"
25
34
  import "alchemy_admin/components/node_select"
35
+ import "alchemy_admin/components/node_tree"
26
36
  import "alchemy_admin/components/uploader"
27
37
  import "alchemy_admin/components/overlay"
28
38
  import "alchemy_admin/components/page_node"
29
39
  import "alchemy_admin/components/page_publication_fields"
30
40
  import "alchemy_admin/components/page_select"
41
+ import "alchemy_admin/components/picture_archive"
31
42
  import "alchemy_admin/components/picture_description_select"
32
43
  import "alchemy_admin/components/picture_editor"
33
44
  import "alchemy_admin/components/picture_thumbnail"
@@ -0,0 +1,75 @@
1
+ import { autoUpdate, computePosition, flip } from "@floating-ui/dom"
2
+
3
+ const SCREEN_PADDING = 8
4
+
5
+ // A main navigation entry lives inside the fixed, vertically scrollable menu
6
+ // column. A scroll container clips its content in both axes, so the hover
7
+ // flyout (this entry's label or its sub navigation) that reaches out to the
8
+ // right of the column would be cut off. While the flyout is open we lift it out
9
+ // of the clipping context by fixing it to the viewport and let Floating UI keep
10
+ // it anchored to the entry.
11
+ class MainNaviEntry extends HTMLElement {
12
+ #flyout = null
13
+ #stopAutoUpdate = null
14
+
15
+ connectedCallback() {
16
+ this.addEventListener("mouseenter", this.#open)
17
+ this.addEventListener("mouseleave", this.#close)
18
+ }
19
+
20
+ disconnectedCallback() {
21
+ this.removeEventListener("mouseenter", this.#open)
22
+ this.removeEventListener("mouseleave", this.#close)
23
+ this.#close()
24
+ }
25
+
26
+ #open = () => {
27
+ const flyout = this.#flyoutElement()
28
+ if (!flyout) return
29
+
30
+ this.#flyout = flyout
31
+ this.#stopAutoUpdate = autoUpdate(this, flyout, () => {
32
+ computePosition(this, flyout, {
33
+ strategy: "fixed",
34
+ placement: "right-start",
35
+ // Keep the flyout anchored to the entry: when there is no room below,
36
+ // flip to bottom alignment instead of drifting away from the entry.
37
+ middleware: [
38
+ flip({ fallbackPlacements: ["right-end"], padding: SCREEN_PADDING })
39
+ ]
40
+ }).then(({ x, y }) => {
41
+ Object.assign(flyout.style, {
42
+ position: "fixed",
43
+ left: `${Math.round(x)}px`,
44
+ top: `${Math.round(y)}px`
45
+ })
46
+ })
47
+ })
48
+ }
49
+
50
+ #close = () => {
51
+ this.#stopAutoUpdate?.()
52
+ this.#stopAutoUpdate = null
53
+
54
+ if (this.#flyout) {
55
+ this.#flyout.style.position = ""
56
+ this.#flyout.style.left = ""
57
+ this.#flyout.style.top = ""
58
+ this.#flyout = null
59
+ }
60
+ }
61
+
62
+ // The entry's flyout element, but only when it is actually rendered as a
63
+ // flyout. In the expanded menu the label and sub navigation sit in the normal
64
+ // document flow (position: static) and must be left untouched.
65
+ #flyoutElement() {
66
+ const flyout =
67
+ this.querySelector(".sub_navigation") || this.querySelector("label")
68
+ if (!flyout || getComputedStyle(flyout).position === "static") return null
69
+ return flyout
70
+ }
71
+ }
72
+
73
+ customElements.define("alchemy-main-navi-entry", MainNaviEntry)
74
+
75
+ export default MainNaviEntry
@@ -0,0 +1,73 @@
1
+ import { patch } from "alchemy_admin/utils/ajax"
2
+ import { growl } from "alchemy_admin/growler"
3
+ import { routes } from "alchemy_admin/routes"
4
+
5
+ /**
6
+ * Custom element for menu nodes in the nodes tree
7
+ * Handles folding and unfolding of its children
8
+ */
9
+ export class AlchemyMenuNode extends HTMLElement {
10
+ connectedCallback() {
11
+ this.addEventListener("click", this)
12
+ }
13
+
14
+ disconnectedCallback() {
15
+ this.removeEventListener("click", this)
16
+ }
17
+
18
+ handleEvent(event) {
19
+ // Clicks of nested menu nodes bubble up here as well
20
+ if (event.target.closest("alchemy-node-folder") === this.folder) {
21
+ this.toggleFolded(event)
22
+ }
23
+ }
24
+
25
+ async toggleFolded(event) {
26
+ event.preventDefault()
27
+
28
+ try {
29
+ await patch(routes.node.toggle_folded_api_path(this.nodeId))
30
+ this.folded = !this.folded
31
+ } catch (error) {
32
+ growl(error.message || error, "error")
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Shows the fold toggle only for nodes that have something to fold
38
+ */
39
+ updateFolder() {
40
+ this.folder.hidden = !(this.hasChildren || this.folded)
41
+ }
42
+
43
+ get folded() {
44
+ return this.hasAttribute("folded")
45
+ }
46
+
47
+ set folded(value) {
48
+ this.toggleAttribute("folded", value)
49
+ this.folder.toggleAttribute("folded", value)
50
+ this.childrenList.classList.toggle("folded", value)
51
+ }
52
+
53
+ get hasChildren() {
54
+ return (
55
+ this.childrenList.querySelectorAll(":scope > alchemy-menu-node").length >
56
+ 0
57
+ )
58
+ }
59
+
60
+ get nodeId() {
61
+ return this.getAttribute("node-id")
62
+ }
63
+
64
+ get folder() {
65
+ return this.querySelector(":scope > li > .sitemap_node alchemy-node-folder")
66
+ }
67
+
68
+ get childrenList() {
69
+ return this.querySelector(":scope > li > ul.children")
70
+ }
71
+ }
72
+
73
+ customElements.define("alchemy-menu-node", AlchemyMenuNode)
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Auto-rotating carousel for the dashboard news widget.
3
+ *
4
+ * Paging is handled by CSS scroll snap on `.news-reader--track`; this component
5
+ * scrolls to the next slide on a timer (pausing while hovered), lets the user
6
+ * swipe/scroll manually, and keeps the dot indicators in sync with the scroll
7
+ * position.
8
+ */
9
+ class NewsReader extends HTMLElement {
10
+ connectedCallback() {
11
+ this.track = this.querySelector(".news-reader--track")
12
+ this.slides = Array.from(this.querySelectorAll(".news-reader--item"))
13
+ if (!this.track || this.slides.length < 2) return
14
+
15
+ this.currentIndex = 0
16
+ this.createDots()
17
+ this.setActive(0)
18
+ this.startRotation()
19
+
20
+ this.addEventListener("mouseenter", this)
21
+ this.addEventListener("mouseleave", this)
22
+ this.track.addEventListener("scroll", this, { passive: true })
23
+ }
24
+
25
+ disconnectedCallback() {
26
+ this.stopRotation()
27
+ }
28
+
29
+ handleEvent(event) {
30
+ switch (event.type) {
31
+ case "mouseenter":
32
+ this.stopRotation()
33
+ break
34
+ case "mouseleave":
35
+ this.startRotation()
36
+ break
37
+ case "scroll":
38
+ this.syncActiveToScroll()
39
+ break
40
+ }
41
+ }
42
+
43
+ createDots() {
44
+ this.dotList = document.createElement("div")
45
+ this.dotList.className = "news-reader--dots"
46
+ this.dots = this.slides.map((_, index) => {
47
+ const dot = document.createElement("button")
48
+ dot.type = "button"
49
+ dot.className = "news-reader--dot"
50
+ dot.setAttribute("aria-label", `${index + 1}`)
51
+ dot.addEventListener("click", () => {
52
+ this.goTo(index)
53
+ this.startRotation()
54
+ })
55
+ this.dotList.append(dot)
56
+ return dot
57
+ })
58
+ this.append(this.dotList)
59
+ }
60
+
61
+ goTo(index) {
62
+ this.currentIndex = index
63
+ this.slides[index].scrollIntoView({
64
+ behavior: "smooth",
65
+ inline: "start",
66
+ block: "nearest"
67
+ })
68
+ this.setActive(index)
69
+ }
70
+
71
+ next() {
72
+ this.goTo((this.currentIndex + 1) % this.slides.length)
73
+ }
74
+
75
+ // Keep the active dot in sync when the user scrolls the track manually.
76
+ syncActiveToScroll() {
77
+ const width = this.track.clientWidth
78
+ if (!width) return
79
+ const index = Math.round(this.track.scrollLeft / width)
80
+ if (index !== this.currentIndex && this.slides[index]) {
81
+ this.currentIndex = index
82
+ this.setActive(index)
83
+ }
84
+ }
85
+
86
+ setActive(index) {
87
+ this.dots.forEach((dot, i) => {
88
+ dot.classList.toggle("is-active", i === index)
89
+ })
90
+ }
91
+
92
+ startRotation() {
93
+ this.stopRotation()
94
+ this.timer = window.setInterval(() => this.next(), this.interval)
95
+ }
96
+
97
+ stopRotation() {
98
+ if (this.timer) window.clearInterval(this.timer)
99
+ this.timer = null
100
+ }
101
+
102
+ get interval() {
103
+ return parseInt(this.getAttribute("interval"), 10) || 8000
104
+ }
105
+ }
106
+
107
+ customElements.define("alchemy-news-reader", NewsReader)
108
+
109
+ export default NewsReader
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Custom element for the fold toggle of a menu node
3
+ * Renders the fold arrow and keeps it in sync with the folded attribute
4
+ */
5
+ export class AlchemyNodeFolder extends HTMLElement {
6
+ static get observedAttributes() {
7
+ return ["folded"]
8
+ }
9
+
10
+ connectedCallback() {
11
+ this.innerHTML = `
12
+ <button class="node_folder icon_button">
13
+ <alchemy-icon name="${this.iconName}"></alchemy-icon>
14
+ </button>
15
+ `
16
+ }
17
+
18
+ attributeChangedCallback() {
19
+ // Fires before connectedCallback for attributes present in the markup
20
+ this.icon?.setAttribute("name", this.iconName)
21
+ }
22
+
23
+ get icon() {
24
+ return this.querySelector("alchemy-icon")
25
+ }
26
+
27
+ get iconName() {
28
+ return this.folded ? "arrow-right-s" : "arrow-down-s"
29
+ }
30
+
31
+ get folded() {
32
+ return this.hasAttribute("folded")
33
+ }
34
+ }
35
+
36
+ customElements.define("alchemy-node-folder", AlchemyNodeFolder)
@@ -0,0 +1,34 @@
1
+ // Syncs the node name and url fields with the selected page
2
+ export class NodeForm extends HTMLElement {
3
+ connectedCallback() {
4
+ this.addEventListener("Alchemy.RemoteSelect.Change", this.#onPageSelect)
5
+ }
6
+
7
+ disconnectedCallback() {
8
+ this.removeEventListener("Alchemy.RemoteSelect.Change", this.#onPageSelect)
9
+ }
10
+
11
+ #onPageSelect = (event) => {
12
+ const page = event.detail.added
13
+
14
+ if (page) {
15
+ this.nameField.setAttribute("placeholder", page.name)
16
+ this.urlField.value = page.url_path
17
+ this.urlField.setAttribute("disabled", "disabled")
18
+ } else {
19
+ this.nameField.removeAttribute("placeholder")
20
+ this.urlField.value = ""
21
+ this.urlField.removeAttribute("disabled")
22
+ }
23
+ }
24
+
25
+ get nameField() {
26
+ return this.querySelector("#node_name")
27
+ }
28
+
29
+ get urlField() {
30
+ return this.querySelector("#node_url")
31
+ }
32
+ }
33
+
34
+ customElements.define("alchemy-node-form", NodeForm)
@@ -1,50 +1,45 @@
1
- import {
2
- RemoteSelect,
3
- escapeHtml
4
- } from "alchemy_admin/components/remote_select"
1
+ import { RemoteSelect } from "alchemy_admin/components/remote_select"
2
+ import { escape_html } from "tom-select/utils"
5
3
 
6
4
  class NodeSelect extends RemoteSelect {
7
5
  _searchQuery(term, page) {
8
6
  return {
9
7
  filter: {
10
- name_or_page_name_cont: term,
8
+ name_or_page_name_i_cont: term,
11
9
  ...JSON.parse(this.queryParams)
12
10
  },
13
11
  page: page
14
12
  }
15
13
  }
16
14
 
17
- _renderResult(item) {
18
- return this._renderListEntry(item)
15
+ _entry(node, term) {
16
+ return {
17
+ icon: "menu-2",
18
+ primary: this.#breadcrumb(node) + this._hightlightTerm(node.name, term),
19
+ secondary: { text: node.url, truncate: "head" }
20
+ }
21
+ }
22
+
23
+ _selectedEntry(node) {
24
+ return {
25
+ icon: "menu-2",
26
+ primary: this.#breadcrumb(node) + escape_html(node.name)
27
+ }
19
28
  }
20
29
 
21
30
  /**
22
- * html template for each list entry
31
+ * Renders the node's ancestors as a breadcrumb that precedes its name. The
32
+ * ancestors place the node in the tree, so a deeply nested node reads clearly.
23
33
  * @param {object} node
24
- * @param {string} term
25
34
  * @returns {string}
26
- * @private
27
35
  */
28
- _renderListEntry(node, term) {
29
- const ancestors = node.ancestors.map((a) => escapeHtml(a.name))
30
- const seperator = `<alchemy-icon name="arrow-right-s"></alchemy-icon>`
31
-
32
- return `
33
- <div class="node-select--node">
34
- <alchemy-icon name="menu-2"></alchemy-icon>
35
- <div class="node-select--node-display_name">
36
- <span class="node-select--node-ancestors">
37
- ${ancestors.length > 0 ? ancestors.join(seperator) + seperator : ""}
38
- </span>
39
- <span class="node-select--node-name">
40
- ${this._hightlightTerm(node.name, term)}
41
- </span>
42
- </div>
43
- <div class="node-select--node-url">
44
- ${escapeHtml(node.url || "")}
45
- </div>
46
- </div>
47
- `
36
+ #breadcrumb(node) {
37
+ const ancestors = node.ancestors.map((ancestor) => ancestor.name)
38
+ if (ancestors.length === 0) return ""
39
+ const separator = `<alchemy-icon name="arrow-right-s" class="node-select--separator"></alchemy-icon>`
40
+ return `<span class="node-select--ancestors">${ancestors
41
+ .map(escape_html)
42
+ .join(separator)}</span>${separator}`
48
43
  }
49
44
  }
50
45
 
@@ -0,0 +1,56 @@
1
+ import Sortable from "sortablejs"
2
+ import { patch } from "alchemy_admin/utils/ajax"
3
+ import { growl } from "alchemy_admin/growler"
4
+ import { translate } from "alchemy_admin/i18n"
5
+ import { routes } from "alchemy_admin/routes"
6
+
7
+ /**
8
+ * Custom element for the nodes tree container
9
+ * Handles drag and drop sorting of its menu nodes
10
+ */
11
+ export class AlchemyNodeTree extends HTMLElement {
12
+ connectedCallback() {
13
+ this.querySelectorAll("ul.children").forEach((list) => {
14
+ new Sortable(list, {
15
+ group: "nodes",
16
+ animation: 150,
17
+ fallbackOnBody: true,
18
+ swapThreshold: 0.65,
19
+ handle: ".node_name",
20
+ invertSwap: true,
21
+ draggable: "alchemy-menu-node",
22
+ onEnd: (event) => this.handleSort(event)
23
+ })
24
+ })
25
+ }
26
+
27
+ async handleSort(event) {
28
+ if (event.from === event.to && event.oldIndex === event.newIndex) {
29
+ return
30
+ }
31
+
32
+ const url = routes.node.move_api_path(event.item.nodeId)
33
+ const data = {
34
+ target_parent_id: event.to.dataset.recordId,
35
+ new_position: event.newIndex
36
+ }
37
+
38
+ try {
39
+ await patch(url, data)
40
+ this.updateFolders(event.from, event.to)
41
+ growl(translate("Successfully moved menu item"))
42
+ } catch (error) {
43
+ growl(error.message || error, "error")
44
+ }
45
+ }
46
+
47
+ updateFolders(from, to) {
48
+ from.closest("alchemy-menu-node")?.updateFolder()
49
+
50
+ if (from !== to) {
51
+ to.closest("alchemy-menu-node")?.updateFolder()
52
+ }
53
+ }
54
+ }
55
+
56
+ customElements.define("alchemy-node-tree", AlchemyNodeTree)
@@ -1,6 +1,7 @@
1
1
  import { patch } from "alchemy_admin/utils/ajax"
2
2
  import { growl } from "alchemy_admin/growler"
3
3
  import Spinner from "alchemy_admin/spinner"
4
+ import { routes } from "alchemy_admin/routes"
4
5
 
5
6
  const BUTTON = "BUTTON"
6
7
  const SPAN = "SPAN"
@@ -38,7 +39,7 @@ export class AlchemyPageNode extends HTMLElement {
38
39
 
39
40
  try {
40
41
  await patch(
41
- Alchemy.routes.fold_admin_page_path(this.pageId),
42
+ routes.fold_admin_page_path(this.pageId),
42
43
  null,
43
44
  "text/vnd.turbo-stream.html"
44
45
  )