alchemy_cms 8.3.11 → 8.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +58 -10
  4. data/app/assets/builds/alchemy/admin/page-select.css +1 -1
  5. data/app/assets/builds/alchemy/admin/print.css +1 -1
  6. data/app/assets/builds/alchemy/admin/remote-select.css +1 -0
  7. data/app/assets/builds/alchemy/admin/tags-autocomplete.css +1 -0
  8. data/app/assets/builds/alchemy/admin.css +4 -4
  9. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  10. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  11. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  12. data/app/assets/builds/alchemy/light-theme.css +1 -1
  13. data/app/assets/builds/alchemy/preview.min.js +1 -1
  14. data/app/assets/builds/alchemy/theme.css +1 -1
  15. data/app/assets/builds/alchemy/welcome.css +1 -1
  16. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  17. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  18. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  19. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  20. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +1 -1
  22. data/app/components/alchemy/admin/dashboard/widgets/news_reader.html.erb +38 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/news_reader.rb +54 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +1 -1
  25. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +1 -1
  26. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +11 -3
  27. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +6 -1
  28. data/app/components/alchemy/admin/element_editor.html.erb +1 -1
  29. data/app/components/alchemy/admin/element_select.rb +30 -20
  30. data/app/components/alchemy/admin/element_status_indicators.html.erb +16 -0
  31. data/app/components/alchemy/admin/element_status_indicators.rb +31 -0
  32. data/app/components/alchemy/admin/link_dialog/external_tab.rb +2 -1
  33. data/app/components/alchemy/admin/main_navigation/entry.rb +61 -0
  34. data/app/components/alchemy/admin/main_navigation/menu.rb +21 -0
  35. data/app/components/alchemy/admin/main_navigation/sub_navigation.rb +31 -0
  36. data/app/components/alchemy/admin/page_node.html.erb +1 -1
  37. data/app/components/alchemy/admin/page_permitted_roles_select.html.erb +11 -0
  38. data/app/components/alchemy/admin/page_permitted_roles_select.rb +35 -0
  39. data/app/components/alchemy/admin/page_select.rb +18 -8
  40. data/app/components/alchemy/admin/preview_time_select.rb +1 -1
  41. data/app/components/alchemy/admin/toolbar_button.rb +43 -20
  42. data/app/components/alchemy/admin/uploader_button.rb +66 -0
  43. data/app/components/alchemy/ingredients/base_editor.rb +10 -1
  44. data/app/components/alchemy/ingredients/boolean_editor.rb +1 -0
  45. data/app/components/alchemy/ingredients/color_editor.rb +6 -3
  46. data/app/components/alchemy/ingredients/datetime_view.rb +1 -1
  47. data/app/components/alchemy/ingredients/picture_view.rb +2 -4
  48. data/app/components/alchemy/menubar.html.erb +157 -0
  49. data/app/components/alchemy/menubar.rb +15 -0
  50. data/app/controllers/alchemy/admin/attachments_controller.rb +12 -9
  51. data/app/controllers/alchemy/admin/base_controller.rb +34 -21
  52. data/app/controllers/alchemy/admin/dashboard_controller.rb +4 -0
  53. data/app/controllers/alchemy/admin/elements_controller.rb +4 -23
  54. data/app/controllers/alchemy/admin/languages_controller.rb +1 -1
  55. data/app/controllers/alchemy/admin/nodes_controller.rb +1 -1
  56. data/app/controllers/alchemy/admin/pages_controller.rb +1 -1
  57. data/app/controllers/alchemy/admin/pictures_controller.rb +6 -3
  58. data/app/controllers/alchemy/admin/resources_controller.rb +5 -5
  59. data/app/controllers/alchemy/admin/sites_controller.rb +1 -1
  60. data/app/controllers/alchemy/base_controller.rb +24 -16
  61. data/app/controllers/alchemy/pages_controller.rb +29 -8
  62. data/app/controllers/alchemy/sitemap_controller.rb +58 -0
  63. data/app/controllers/concerns/alchemy/admin/csp_protection.rb +43 -0
  64. data/app/controllers/concerns/alchemy/admin/current_language.rb +1 -1
  65. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +3 -1
  66. data/{lib → app/controllers/concerns}/alchemy/controller_actions.rb +13 -0
  67. data/app/controllers/concerns/alchemy/legacy_page_redirects.rb +4 -4
  68. data/app/helpers/alchemy/admin/base_helper.rb +4 -1
  69. data/app/helpers/alchemy/admin/form_helper.rb +0 -2
  70. data/app/helpers/alchemy/admin/navigation_helper.rb +10 -181
  71. data/app/helpers/alchemy/elements_block_helper.rb +14 -0
  72. data/app/helpers/alchemy/resources_helper.rb +15 -1
  73. data/app/javascript/alchemy_admin/components/attachment_select.js +11 -20
  74. data/app/javascript/alchemy_admin/components/auto_submit.js +2 -4
  75. data/app/javascript/alchemy_admin/components/button.js +0 -6
  76. data/app/javascript/alchemy_admin/components/clear_schedule_button.js +21 -0
  77. data/app/javascript/alchemy_admin/components/color_select.js +50 -45
  78. data/app/javascript/alchemy_admin/components/conditional_field.js +50 -0
  79. data/app/javascript/alchemy_admin/components/dirty_guard.js +77 -0
  80. data/app/javascript/alchemy_admin/components/dom_id_select.js +17 -10
  81. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +7 -2
  82. data/app/javascript/alchemy_admin/components/element_editor.js +29 -56
  83. data/app/javascript/alchemy_admin/components/element_select.js +26 -66
  84. data/app/javascript/alchemy_admin/components/icon.js +9 -4
  85. data/app/javascript/alchemy_admin/{image_cropper.js → components/image_cropper.js} +39 -53
  86. data/app/javascript/alchemy_admin/components/image_overlay_link.js +14 -0
  87. data/app/javascript/alchemy_admin/components/index.js +12 -1
  88. data/app/javascript/alchemy_admin/components/main_navi_entry.js +75 -0
  89. data/app/javascript/alchemy_admin/components/menu_node.js +73 -0
  90. data/app/javascript/alchemy_admin/components/news_reader.js +109 -0
  91. data/app/javascript/alchemy_admin/components/node_folder.js +36 -0
  92. data/app/javascript/alchemy_admin/components/node_form.js +34 -0
  93. data/app/javascript/alchemy_admin/components/node_select.js +25 -30
  94. data/app/javascript/alchemy_admin/components/node_tree.js +56 -0
  95. data/app/javascript/alchemy_admin/components/page_node.js +2 -1
  96. data/app/javascript/alchemy_admin/components/page_select.js +16 -34
  97. data/app/javascript/alchemy_admin/components/picture_archive.js +92 -0
  98. data/app/javascript/alchemy_admin/components/picture_editor.js +2 -1
  99. data/app/javascript/alchemy_admin/components/preview_window.js +11 -3
  100. data/app/javascript/alchemy_admin/components/remote_select.js +461 -81
  101. data/app/javascript/alchemy_admin/components/select.js +26 -88
  102. data/app/javascript/alchemy_admin/components/sitemap.js +2 -1
  103. data/app/javascript/alchemy_admin/components/sortable_elements.js +2 -1
  104. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +63 -48
  105. data/app/javascript/alchemy_admin/components/tinymce.js +2 -1
  106. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +3 -2
  107. data/app/javascript/alchemy_admin/components/uploader.js +19 -3
  108. data/app/javascript/alchemy_admin/confirm_dialog.js +7 -1
  109. data/app/javascript/alchemy_admin/dialog.js +182 -110
  110. data/app/javascript/alchemy_admin/hotkeys.js +0 -5
  111. data/app/javascript/alchemy_admin/i18n.js +3 -9
  112. data/app/javascript/alchemy_admin/image_overlay.js +58 -42
  113. data/app/javascript/alchemy_admin/initializer.js +2 -2
  114. data/app/javascript/alchemy_admin/link_dialog.js +15 -2
  115. data/app/javascript/alchemy_admin/routes.js +18 -0
  116. data/app/javascript/alchemy_admin/turbo_stream_actions.js +88 -0
  117. data/app/javascript/alchemy_admin/utils/ajax.js +14 -3
  118. data/app/javascript/alchemy_admin/utils/events.js +5 -0
  119. data/app/javascript/alchemy_admin/utils/json_script.js +12 -0
  120. data/app/javascript/alchemy_admin/utils/tom_select.js +171 -0
  121. data/app/javascript/alchemy_admin.js +2 -19
  122. data/app/javascript/preview.js +131 -113
  123. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +1 -1
  124. data/app/models/alchemy/admin/content_security_policy.rb +143 -0
  125. data/app/models/alchemy/admin/filters/base.rb +7 -0
  126. data/app/models/alchemy/admin/filters/datepicker.rb +1 -1
  127. data/app/models/alchemy/admin/filters/select.rb +20 -7
  128. data/app/models/alchemy/attachment.rb +14 -7
  129. data/app/models/alchemy/cache_control.rb +104 -0
  130. data/app/models/alchemy/current.rb +1 -1
  131. data/app/models/alchemy/eager_loading.rb +0 -2
  132. data/app/models/alchemy/element.rb +23 -8
  133. data/app/models/alchemy/element_definition.rb +1 -0
  134. data/app/models/alchemy/elements_repository.rb +11 -0
  135. data/app/models/alchemy/folded_page.rb +5 -1
  136. data/app/models/alchemy/ingredient.rb +26 -0
  137. data/app/models/alchemy/ingredients/datetime.rb +1 -1
  138. data/app/models/alchemy/ingredients/link.rb +2 -0
  139. data/app/models/alchemy/ingredients/picture.rb +3 -0
  140. data/app/models/alchemy/ingredients/text.rb +2 -0
  141. data/app/models/alchemy/language.rb +19 -7
  142. data/app/models/alchemy/legacy_page_url.rb +6 -1
  143. data/app/models/alchemy/node.rb +51 -0
  144. data/app/models/alchemy/page/page_natures.rb +41 -8
  145. data/app/models/alchemy/page/page_scopes.rb +23 -0
  146. data/app/models/alchemy/page/sitemap_cache_key.rb +56 -0
  147. data/app/models/alchemy/page.rb +88 -32
  148. data/app/models/alchemy/page_definition.rb +1 -1
  149. data/app/models/alchemy/page_mutex.rb +16 -0
  150. data/app/models/alchemy/page_version.rb +24 -1
  151. data/app/models/alchemy/permissions.rb +6 -5
  152. data/app/models/alchemy/picture.rb +15 -7
  153. data/app/models/alchemy/picture_description.rb +22 -0
  154. data/app/models/alchemy/picture_thumb.rb +18 -0
  155. data/app/models/alchemy/publishable/timestamp_resolver.rb +70 -0
  156. data/app/models/alchemy/resource.rb +1 -1
  157. data/app/models/alchemy/safe_url_validator.rb +34 -0
  158. data/app/models/alchemy/site.rb +8 -3
  159. data/app/models/alchemy/storage_adapter/active_storage/preprocessor.rb +7 -3
  160. data/app/models/alchemy/storage_adapter/active_storage.rb +4 -1
  161. data/app/models/alchemy/tag.rb +10 -4
  162. data/{lib → app/models/concerns}/alchemy/filetypes.rb +5 -0
  163. data/app/models/concerns/alchemy/picture_thumbnails.rb +61 -6
  164. data/app/models/concerns/alchemy/publishable.rb +14 -27
  165. data/app/models/concerns/alchemy/taggable.rb +92 -0
  166. data/app/models/concerns/alchemy/user_methods.rb +95 -0
  167. data/app/services/alchemy/duplicate_element.rb +5 -2
  168. data/app/services/alchemy/element_preloader.rb +1 -2
  169. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  170. data/app/stylesheets/alchemy/_extends.scss +0 -8
  171. data/app/stylesheets/alchemy/_themes.scss +10 -9
  172. data/app/stylesheets/alchemy/admin/_empty-state.scss +29 -0
  173. data/app/stylesheets/alchemy/admin/_help.scss +10 -0
  174. data/app/stylesheets/alchemy/admin/_tom-select.scss +104 -15
  175. data/app/stylesheets/alchemy/admin/archive.scss +56 -11
  176. data/app/stylesheets/alchemy/admin/base.scss +4 -5
  177. data/app/stylesheets/alchemy/admin/buttons.scss +12 -1
  178. data/app/stylesheets/alchemy/admin/dashboard.scss +150 -20
  179. data/app/stylesheets/alchemy/admin/dialogs.scss +27 -36
  180. data/app/stylesheets/alchemy/admin/element-select.scss +1 -1
  181. data/app/stylesheets/alchemy/admin/elements.scss +8 -24
  182. data/app/stylesheets/alchemy/admin/flash.scss +2 -1
  183. data/app/stylesheets/alchemy/admin/forms.scss +60 -24
  184. data/app/stylesheets/alchemy/admin/frame.scss +40 -4
  185. data/app/stylesheets/alchemy/admin/image_library.scss +9 -12
  186. data/app/stylesheets/alchemy/admin/navigation.scss +17 -14
  187. data/app/stylesheets/alchemy/admin/node-select.scss +13 -37
  188. data/app/stylesheets/alchemy/admin/nodes.scss +1 -1
  189. data/app/stylesheets/alchemy/admin/page-select.scss +6 -97
  190. data/app/stylesheets/alchemy/admin/print.scss +0 -4
  191. data/app/stylesheets/alchemy/admin/remote-select.scss +170 -0
  192. data/app/stylesheets/alchemy/admin/search.scss +2 -3
  193. data/app/stylesheets/alchemy/admin/selects.scss +1 -294
  194. data/app/stylesheets/alchemy/admin/sitemap.scss +2 -1
  195. data/app/stylesheets/alchemy/admin/tables.scss +7 -1
  196. data/app/stylesheets/alchemy/admin/tags-autocomplete.scss +17 -0
  197. data/app/stylesheets/alchemy/admin/tags.scss +0 -13
  198. data/app/stylesheets/alchemy/admin/toolbar.scss +9 -6
  199. data/app/stylesheets/alchemy/admin/upload.scss +61 -1
  200. data/app/stylesheets/alchemy/admin.scss +4 -1
  201. data/app/types/alchemy/cache_control_type.rb +84 -0
  202. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  203. data/app/views/alchemy/_menubar.html.erb +2 -154
  204. data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
  205. data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
  206. data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
  207. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
  208. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  209. data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
  210. data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
  211. data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
  212. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  213. data/app/views/alchemy/admin/crop.html.erb +20 -20
  214. data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
  215. data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
  216. data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
  217. data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
  218. data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
  219. data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
  220. data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
  221. data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
  222. data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
  223. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
  224. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
  225. data/app/views/alchemy/admin/elements/index.html.erb +1 -1
  226. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
  227. data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
  228. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
  229. data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
  230. data/app/views/alchemy/admin/languages/index.html.erb +25 -29
  231. data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
  232. data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
  233. data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
  234. data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
  235. data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
  236. data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
  237. data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
  238. data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
  239. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
  240. data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
  241. data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
  242. data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
  243. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
  244. data/app/views/alchemy/admin/pages/index.html.erb +26 -31
  245. data/app/views/alchemy/admin/pages/info.html.erb +2 -2
  246. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
  247. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
  248. data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
  249. data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
  250. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  251. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
  252. data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
  253. data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
  254. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  255. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
  256. data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
  257. data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
  258. data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
  259. data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
  260. data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
  261. data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
  262. data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
  263. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
  264. data/app/views/alchemy/admin/resources/index.html.erb +32 -13
  265. data/app/views/alchemy/admin/sites/index.html.erb +24 -22
  266. data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
  267. data/app/views/alchemy/admin/tags/index.html.erb +27 -25
  268. data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
  269. data/app/views/alchemy/admin/translations/_en.js +3 -0
  270. data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
  271. data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
  272. data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
  273. data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
  274. data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
  275. data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
  276. data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
  277. data/app/views/kaminari/alchemy/_page.html.erb +1 -2
  278. data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
  279. data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
  280. data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
  281. data/app/views/layouts/alchemy/admin.html.erb +5 -6
  282. data/config/brakeman.ignore +23 -1
  283. data/config/importmap.rb +10 -8
  284. data/config/initializers/dragonfly.rb +21 -17
  285. data/config/locales/alchemy.en.yml +52 -11
  286. data/config/routes.rb +3 -3
  287. data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
  288. data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
  289. data/lib/alchemy/configuration/number_option.rb +13 -0
  290. data/lib/alchemy/configuration/proc_option.rb +13 -0
  291. data/lib/alchemy/configuration.rb +2 -0
  292. data/lib/alchemy/configurations/admin_components.rb +30 -0
  293. data/lib/alchemy/configurations/format_matchers.rb +7 -1
  294. data/lib/alchemy/configurations/main.rb +103 -4
  295. data/lib/alchemy/configurations/menu.rb +91 -0
  296. data/lib/alchemy/configurations/menu_item.rb +123 -0
  297. data/lib/alchemy/configurations/sitemap.rb +1 -0
  298. data/lib/alchemy/configurations/uploader.rb +15 -1
  299. data/lib/alchemy/engine.rb +0 -6
  300. data/lib/alchemy/i18n.rb +26 -0
  301. data/lib/alchemy/modules.rb +52 -50
  302. data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
  303. data/lib/alchemy/tasks/tidy.rb +1 -1
  304. data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
  305. data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
  306. data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
  307. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
  308. data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
  309. data/lib/alchemy/test_support/shared_publishable_examples.rb +29 -4
  310. data/lib/alchemy/upgrader/eight_four.rb +237 -0
  311. data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
  312. data/lib/alchemy/upgrader.rb +2 -1
  313. data/lib/alchemy/version.rb +1 -1
  314. data/lib/alchemy.rb +0 -1
  315. data/lib/alchemy_cms.rb +0 -13
  316. data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
  317. data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
  318. data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
  319. data/lib/generators/alchemy/install/install_generator.rb +2 -13
  320. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
  321. data/lib/generators/alchemy/module/module_generator.rb +1 -0
  322. data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
  323. data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
  324. data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
  325. data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
  326. data/lib/tasks/alchemy/thumbnails.rake +16 -29
  327. data/lib/tasks/alchemy/upgrade.rake +45 -3
  328. data/vendor/javascript/cropperjs.min.js +3 -3
  329. data/vendor/javascript/floating-ui.min.js +1 -1
  330. data/vendor/javascript/tinymce.min.js +5 -5
  331. data/vendor/javascript/tom-select.min.js +1 -1
  332. metadata +85 -65
  333. data/app/javascript/alchemy_admin/components/action.js +0 -50
  334. data/app/javascript/alchemy_admin/dirty.js +0 -55
  335. data/app/javascript/alchemy_admin/node_tree.js +0 -82
  336. data/app/javascript/alchemy_admin/picture_selector.js +0 -67
  337. data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
  338. data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
  339. data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
  340. data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
  341. data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
  342. data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
  343. data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
  344. data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
  345. data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
  346. data/app/views/alchemy/base/error_notice.js.erb +0 -1
  347. data/app/views/alchemy/base/permission_denied.js.erb +0 -3
  348. data/app/views/alchemy/base/redirect.js.erb +0 -11
  349. data/config/alchemy/config.yml +0 -217
  350. data/config/alchemy/modules.yml +0 -82
  351. data/config/initializers/rails_live_reload.rb +0 -13
  352. data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
  353. data/lib/alchemy/taggable.rb +0 -49
  354. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
  355. data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
  356. data/vendor/javascript/handlebars.min.js +0 -29
  357. data/vendor/javascript/jquery.min.js +0 -2
  358. data/vendor/javascript/rails-ujs.min.js +0 -1
  359. data/vendor/javascript/select2.min.js +0 -23
  360. /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
  361. /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
  362. /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
  363. /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
  364. /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
  365. /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
  366. /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
  367. /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
  368. /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
@@ -1,17 +1,15 @@
1
1
  import TomSelect from "tom-select"
2
+ import "alchemy_admin/components/icon"
2
3
  import { translate } from "alchemy_admin/i18n"
3
4
  import {
4
- autoUpdate,
5
- computePosition,
6
- flip,
7
- offset,
8
- size
9
- } from "@floating-ui/dom"
10
-
11
- const DROPDOWN_WINDOW_MARGIN = 16
12
- const DROPDOWN_MIN_HEIGHT = 120
13
-
14
- class Select extends HTMLSelectElement {
5
+ createDropdownPositioning,
6
+ dropdownMessages,
7
+ focusTomSelect,
8
+ removeButton,
9
+ teardownTomSelect
10
+ } from "alchemy_admin/utils/tom_select"
11
+
12
+ export class Select extends HTMLSelectElement {
15
13
  #tomSelect = null
16
14
 
17
15
  connectedCallback() {
@@ -33,6 +31,10 @@ class Select extends HTMLSelectElement {
33
31
  this.#tomSelect?.disable()
34
32
  }
35
33
 
34
+ focus() {
35
+ focusTomSelect(this.#tomSelect, () => super.focus())
36
+ }
37
+
36
38
  setOptions(data, prompt = undefined) {
37
39
  const selectedValue = this.value
38
40
 
@@ -62,21 +64,22 @@ class Select extends HTMLSelectElement {
62
64
  return this.getAttribute("placeholder")
63
65
  }
64
66
 
67
+ // Subclasses may return extra Tom Select render functions (e.g. custom
68
+ // `option`/`item` templates) to merge on top of the defaults below.
69
+ get renderers() {
70
+ return {}
71
+ }
72
+
65
73
  #initTomSelect() {
66
74
  const plugins = {}
67
75
  const hasPlaceholder = !!this.placeholder
68
76
  // Capture this before Tom Select initializes, since it rewrites the
69
77
  // select's selected option during setup.
70
78
  const hasSelectedOption = !!this.querySelector("option[selected]")
71
- const dropdownMask = document.createElement("div")
72
- dropdownMask.className = "ts-dropdown-mask"
73
-
74
- let removeAutoUpdater = () => {}
79
+ const { onDropdownOpen, onDropdownClose } = createDropdownPositioning()
75
80
 
76
81
  if (this.multiple) {
77
- plugins.remove_button = {
78
- title: translate("Remove")
79
- }
82
+ plugins.remove_button = removeButton()
80
83
  }
81
84
 
82
85
  if (this.allowClear) {
@@ -111,63 +114,8 @@ class Select extends HTMLSelectElement {
111
114
  },
112
115
  // remove the transition after selection of option.
113
116
  refreshThrottle: 0,
114
- onDropdownOpen: async function () {
115
- // Make the dropdown at least as wide as the control.
116
- const styles = {
117
- minWidth: `${this.control.offsetWidth}px`
118
- }
119
- // If the select is inside a dialog, we need to ensure the dropdown appears above it.
120
- if (this.control.closest(".alchemy-dialog-body, .alchemy-popover")) {
121
- styles.zIndex = "101"
122
- }
123
- Object.assign(this.dropdown.style, styles)
124
- // Append the dropdown to the body to avoid overflow issues, especially in dialogs.
125
- document.body.append(dropdownMask)
126
- document.body.append(this.dropdown)
127
- // Use Floating UI to position the dropdown relative to the control.
128
- const updatePosition = async () => {
129
- // Use Floating UI to calculate the dropdown position
130
- const { x, y } = await computePosition(this.control, this.dropdown, {
131
- middleware: [
132
- // Flip to the opposite side if there’s not enough space
133
- flip(),
134
- // Make some space between the control and the dropdown to prevent overlap
135
- offset(2),
136
- // Ensure the dropdown fits within the viewport
137
- size({
138
- apply({ availableHeight, elements }) {
139
- Object.assign(
140
- elements.floating.querySelector(".ts-dropdown-content")
141
- .style,
142
- {
143
- maxHeight: `${Math.max(DROPDOWN_MIN_HEIGHT, availableHeight - DROPDOWN_WINDOW_MARGIN)}px`
144
- }
145
- )
146
- }
147
- })
148
- ]
149
- })
150
- // Position the dropdown
151
- Object.assign(this.dropdown.style, {
152
- left: `${x}px`,
153
- top: `${y}px`
154
- })
155
- }
156
- // Update the dropdown position whenever the window resizes or scrolls.
157
- removeAutoUpdater = autoUpdate(
158
- this.control,
159
- this.dropdown,
160
- updatePosition
161
- )
162
- },
163
- onDropdownClose: function () {
164
- this.control_input.classList.remove("has-value")
165
- // Remove the dropdown from DOM when closed.
166
- this.dropdown.remove()
167
- dropdownMask.remove()
168
- // Cleanup the position auto-update when the dropdown is closed.
169
- removeAutoUpdater()
170
- },
117
+ onDropdownOpen,
118
+ onDropdownClose,
171
119
  allowEmptyOption: true,
172
120
  openOnFocus: false,
173
121
  // Keep options in their original order instead of sorting by value.
@@ -176,14 +124,8 @@ class Select extends HTMLSelectElement {
176
124
  maxOptions: null,
177
125
  // Customize the "create" and "no results" dropdown messages with i18n.
178
126
  render: {
179
- option_create(data, escape) {
180
- return `<div class="create">
181
- ${translate("Add")}<strong>${escape(data.input)}</strong>&hellip;
182
- </div>`
183
- },
184
- no_results() {
185
- return `<div class="no-results">${translate("No results found")}</div>`
186
- }
127
+ ...dropdownMessages,
128
+ ...this.renderers
187
129
  }
188
130
  }
189
131
 
@@ -197,11 +139,7 @@ class Select extends HTMLSelectElement {
197
139
  }
198
140
 
199
141
  #destroyTomSelect() {
200
- // Tom Select's destroy() removes the dropdown, but it never closes it, so
201
- // the onDropdownClose handler does not run and the full window dropdown
202
- // mask would be left behind, swallowing every click.
203
- this.#tomSelect?.close(false)
204
- this.#tomSelect?.destroy()
142
+ teardownTomSelect(this.#tomSelect)
205
143
  this.#tomSelect = null
206
144
  }
207
145
  }
@@ -3,6 +3,7 @@ import { growl } from "alchemy_admin/growler"
3
3
  import { patch } from "alchemy_admin/utils/ajax"
4
4
  import { translate } from "alchemy_admin/i18n"
5
5
  import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
6
+ import { routes } from "alchemy_admin/routes"
6
7
 
7
8
  /**
8
9
  * Custom element for the sitemap container
@@ -160,7 +161,7 @@ export class AlchemySitemap extends HTMLElement {
160
161
  // evt.item is the <alchemy-page-node> element being dragged
161
162
  const pageNode = evt.item
162
163
  const pageId = pageNode.pageId
163
- const url = Alchemy.routes.move_admin_page_path(pageId)
164
+ const url = routes.move_admin_page_path(pageId)
164
165
  const data = {
165
166
  target_parent_id: evt.to.dataset.parentId,
166
167
  new_position: evt.newIndex
@@ -3,6 +3,7 @@ import { growl } from "alchemy_admin/growler"
3
3
  import { post } from "alchemy_admin/utils/ajax"
4
4
  import { reloadPreview } from "alchemy_admin/components/preview_window"
5
5
  import { dispatchPageDirtyEvent } from "alchemy_admin/utils/dispatch_page_dirty_event"
6
+ import { routes } from "alchemy_admin/routes"
6
7
 
7
8
  const SORTABLE_OPTIONS = {
8
9
  draggable: ".element-editor",
@@ -36,7 +37,7 @@ function onSort(event) {
36
37
  // or sorted in the same list. Not on the old list in order to avoid incrementing
37
38
  // the position of the other elements.
38
39
  if (event.target === event.to) {
39
- post(Alchemy.routes.order_admin_elements_path, params).then((response) => {
40
+ post(routes.order_admin_elements_path, params).then((response) => {
40
41
  const data = response.data
41
42
  growl(data.message)
42
43
  if (data.pageHasUnpublishedChanges) {
@@ -1,69 +1,84 @@
1
- import { setupSelectLocale } from "alchemy_admin/i18n"
1
+ import TomSelect from "tom-select"
2
+ import "alchemy_admin/components/icon"
3
+ import { get } from "alchemy_admin/utils/ajax"
4
+ import { growl } from "alchemy_admin/growler"
5
+ import {
6
+ createDropdownPositioning,
7
+ dropdownMessages,
8
+ focusTomSelect,
9
+ removeButton,
10
+ teardownTomSelect
11
+ } from "alchemy_admin/utils/tom_select"
2
12
 
3
13
  export class TagsAutocomplete extends HTMLElement {
4
- #select2 = null
5
-
6
- async connectedCallback() {
7
- await setupSelectLocale()
8
- if (!this.isConnected) return
14
+ #tomSelect = null
9
15
 
16
+ connectedCallback() {
10
17
  this.classList.add("autocomplete_tag_list")
11
- this.#select2 = $(this.input).select2(this.select2Config)
18
+ // Reuse the shared Tom Select styling by tagging the source input, whose
19
+ // class Tom Select copies onto the generated wrapper.
20
+ this.input.classList.add("alchemy_selectbox")
21
+ this.#tomSelect = new TomSelect(this.input, this.settings)
12
22
  }
13
23
 
14
24
  disconnectedCallback() {
15
- this.#select2?.select2("destroy")
16
- this.#select2 = null
25
+ teardownTomSelect(this.#tomSelect)
26
+ this.#tomSelect = null
27
+ }
28
+
29
+ focus() {
30
+ focusTomSelect(this.#tomSelect, () => super.focus())
17
31
  }
18
32
 
19
33
  get input() {
20
34
  return this.getElementsByTagName("input")[0]
21
35
  }
22
36
 
23
- get select2Config() {
37
+ get settings() {
24
38
  return {
25
- tags: true,
26
- tokenSeparators: [","],
27
- openOnEnter: false,
28
- minimumInputLength: 1,
29
- createSearchChoice: this.#createSearchChoice,
30
- ajax: {
31
- url: this.getAttribute("url"),
32
- dataType: "json",
33
- data: (term) => {
34
- return { term }
35
- },
36
- results: (data) => {
37
- return { results: data }
38
- }
39
+ plugins: {
40
+ remove_button: removeButton()
39
41
  },
40
- initSelection: this.#initSelection
41
- }
42
- }
43
-
44
- #createSearchChoice(term, data) {
45
- if (
46
- $(data).filter(function () {
47
- return this.text.localeCompare(term) === 0
48
- }).length === 0
49
- ) {
50
- return {
51
- id: term,
52
- text: term
42
+ // The autocomplete endpoint returns each tag as `{ id, text }`.
43
+ valueField: "id",
44
+ // Tags are entered and stored as a comma separated list.
45
+ delimiter: ",",
46
+ // Allow free tagging.
47
+ create: true,
48
+ // Hide the "Add …" create option while suggestions are still loading, so
49
+ // it doesn't flash and then disappear once a matching tag arrives from
50
+ // the server. When idle, keep Tom Select's default of not offering to
51
+ // create a tag that already exists.
52
+ createFilter(input) {
53
+ if (this.loading) return false
54
+ return this.settings.duplicates || !this.options[input]
55
+ },
56
+ // Do not keep a removed tag around as a lingering suggestion.
57
+ persist: false,
58
+ // Clear the typed text once a tag is added, so it doesn't linger in the
59
+ // input after selecting an existing tag.
60
+ clearAfterSelect: true,
61
+ // Show every matching tag returned by the server.
62
+ maxOptions: null,
63
+ openOnFocus: false,
64
+ // Apply the placeholder set on the wrapper element.
65
+ placeholder: this.getAttribute("placeholder"),
66
+ // Load matching tags from the server as the user types.
67
+ load: (query, callback) => {
68
+ get(this.getAttribute("url"), { term: query })
69
+ .then((response) => callback(response.data))
70
+ .catch((error) => {
71
+ growl(error.message || error, "error")
72
+ // Tom Select stays in loading state until the callback runs.
73
+ callback()
74
+ })
75
+ },
76
+ ...createDropdownPositioning(),
77
+ render: {
78
+ ...dropdownMessages
53
79
  }
54
80
  }
55
81
  }
56
-
57
- #initSelection(element, callback) {
58
- const data = []
59
- $(element.val().split(",")).each(function () {
60
- data.push({
61
- id: this.trim(),
62
- text: this
63
- })
64
- })
65
- callback(data)
66
- }
67
82
  }
68
83
 
69
84
  customElements.define("alchemy-tags-autocomplete", TagsAutocomplete)
@@ -1,5 +1,6 @@
1
1
  import "tinymce"
2
2
  import { currentLocale } from "alchemy_admin/i18n"
3
+ import { readJSONScript } from "alchemy_admin/utils/json_script"
3
4
 
4
5
  const DARK_THEME = "alchemy-dark"
5
6
  const LIGHT_THEME = "alchemy"
@@ -172,7 +173,7 @@ export class Tinymce extends HTMLElement {
172
173
 
173
174
  const config = {
174
175
  content_css: this.preferredTheme,
175
- ...Alchemy.TinymceDefaults,
176
+ ...readJSONScript("alchemy_tinymce_defaults"),
176
177
  ...customConfig,
177
178
  language: currentLocale(),
178
179
  selector: `#${this.editorId}`,
@@ -1,6 +1,7 @@
1
1
  import { formatFileSize } from "alchemy_admin/utils/format"
2
2
  import { translate } from "alchemy_admin/i18n"
3
3
  import { growl } from "alchemy_admin/growler"
4
+ import { readJSONScript } from "alchemy_admin/utils/json_script"
4
5
 
5
6
  export class FileUpload extends HTMLElement {
6
7
  // public — used by callers (Uploader, Progress, tests)
@@ -78,10 +79,10 @@ export class FileUpload extends HTMLElement {
78
79
  }
79
80
 
80
81
  /**
81
- * validate given file with the `Alchemy.uploader_defaults` - configuration
82
+ * validate given file with the uploader configuration
82
83
  */
83
84
  #validateFile() {
84
- const config = Alchemy.uploader_defaults
85
+ const config = readJSONScript("alchemy_uploader_defaults")
85
86
  const maxFileSize = config.file_size_limit * Math.pow(1024, 2) // in Byte
86
87
  let errorMessage = undefined
87
88
 
@@ -6,6 +6,7 @@
6
6
  import { Progress } from "alchemy_admin/components/uploader/progress"
7
7
  import { FileUpload } from "alchemy_admin/components/uploader/file_upload"
8
8
  import { translate } from "alchemy_admin/i18n"
9
+ import { readJSONScript } from "alchemy_admin/utils/json_script"
9
10
  import { getToken } from "alchemy_admin/utils/ajax"
10
11
 
11
12
  export class Uploader extends HTMLElement {
@@ -110,13 +111,14 @@ export class Uploader extends HTMLElement {
110
111
  uploadFiles(files) {
111
112
  // prepare file progress bars and server request
112
113
  let fileUploadCount = 0
114
+ const config = readJSONScript("alchemy_uploader_defaults")
113
115
 
114
116
  const fileUploads = files.map((file) => {
115
117
  const request = new XMLHttpRequest()
116
118
  const fileUpload = new FileUpload()
117
119
  fileUpload.initialize(file, request)
118
120
 
119
- if (Alchemy.uploader_defaults.upload_limit - 1 < fileUploadCount) {
121
+ if (config.upload_limit - 1 < fileUploadCount) {
120
122
  fileUpload.valid = false
121
123
  fileUpload.errorMessage = translate("Maximum number of files exceeded")
122
124
  } else if (fileUpload.valid) {
@@ -154,23 +156,37 @@ export class Uploader extends HTMLElement {
154
156
  #createProgress(fileUploads) {
155
157
  if (this.uploadProgress) {
156
158
  this.uploadProgress.cancel()
157
- document.body.removeChild(this.uploadProgress)
159
+ this.uploadProgress.remove()
158
160
  }
159
161
  this.uploadProgress = new Progress()
160
162
  this.uploadProgress.initialize(fileUploads)
163
+ this.uploadProgress.setAttribute("layout", this.layout)
161
164
  this.uploadProgress.onComplete = (status) => {
162
165
  this.dispatchEvent(
163
166
  new CustomEvent(`Alchemy.upload.${status}`, { bubbles: true })
164
167
  )
165
168
  }
166
169
 
167
- document.body.append(this.uploadProgress)
170
+ this.#progressContainer.append(this.uploadProgress)
171
+ }
172
+
173
+ // The dropzone doubles as the progress container so the upload indicators
174
+ // render inside the archive (and inside dialogs). Falls back to the body for
175
+ // uploaders without a dropzone (e.g. the attachment replace button).
176
+ get #progressContainer() {
177
+ return this.#dropzoneElement ?? document.body
168
178
  }
169
179
 
170
180
  get dropzone() {
171
181
  return this.getAttribute("dropzone")
172
182
  }
173
183
 
184
+ // Shape of the upload indicators. Defaults to "row" so uploads fit naturally
185
+ // into resource table views; set layout="card" for the thumbnail grid.
186
+ get layout() {
187
+ return this.getAttribute("layout") || "row"
188
+ }
189
+
174
190
  /**
175
191
  * @returns {HTMLInputElement}
176
192
  */
@@ -1,4 +1,5 @@
1
1
  import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
2
+ import { currentDialog } from "alchemy_admin/dialog"
2
3
  import { translate } from "alchemy_admin/i18n"
3
4
 
4
5
  const getDefaults = () => ({
@@ -37,7 +38,12 @@ class ConfirmDialog {
37
38
  </button>
38
39
  </sl-dialog>
39
40
  `)
40
- document.body.append(this.dialog)
41
+ // A native <dialog> opened with showModal() sits in the top layer and makes
42
+ // everything outside its subtree inert. Append the confirm into the open
43
+ // dialog (if any) so it stays visible and interactable above it, instead of
44
+ // rendering below and unclickable on the body.
45
+ const container = currentDialog()?.dialog_container ?? document.body
46
+ container.append(this.dialog)
41
47
  }
42
48
 
43
49
  #bindEvents() {