alchemy_cms 8.3.11 → 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 (368) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +58 -10
  4. data/app/assets/builds/alchemy/admin/page-select.css +1 -1
  5. data/app/assets/builds/alchemy/admin/print.css +1 -1
  6. data/app/assets/builds/alchemy/admin/remote-select.css +1 -0
  7. data/app/assets/builds/alchemy/admin/tags-autocomplete.css +1 -0
  8. data/app/assets/builds/alchemy/admin.css +4 -4
  9. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  10. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  11. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  12. data/app/assets/builds/alchemy/light-theme.css +1 -1
  13. data/app/assets/builds/alchemy/preview.min.js +1 -1
  14. data/app/assets/builds/alchemy/theme.css +1 -1
  15. data/app/assets/builds/alchemy/welcome.css +1 -1
  16. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  17. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  18. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  19. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  20. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +1 -1
  22. data/app/components/alchemy/admin/dashboard/widgets/news_reader.html.erb +38 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/news_reader.rb +54 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +1 -1
  25. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +1 -1
  26. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +11 -3
  27. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +6 -1
  28. data/app/components/alchemy/admin/element_editor.html.erb +1 -1
  29. data/app/components/alchemy/admin/element_select.rb +30 -20
  30. data/app/components/alchemy/admin/element_status_indicators.html.erb +16 -0
  31. data/app/components/alchemy/admin/element_status_indicators.rb +31 -0
  32. data/app/components/alchemy/admin/link_dialog/external_tab.rb +2 -1
  33. data/app/components/alchemy/admin/main_navigation/entry.rb +61 -0
  34. data/app/components/alchemy/admin/main_navigation/menu.rb +21 -0
  35. data/app/components/alchemy/admin/main_navigation/sub_navigation.rb +31 -0
  36. data/app/components/alchemy/admin/page_node.html.erb +1 -1
  37. data/app/components/alchemy/admin/page_permitted_roles_select.html.erb +11 -0
  38. data/app/components/alchemy/admin/page_permitted_roles_select.rb +35 -0
  39. data/app/components/alchemy/admin/page_select.rb +18 -8
  40. data/app/components/alchemy/admin/preview_time_select.rb +1 -1
  41. data/app/components/alchemy/admin/toolbar_button.rb +43 -20
  42. data/app/components/alchemy/admin/uploader_button.rb +66 -0
  43. data/app/components/alchemy/ingredients/base_editor.rb +10 -1
  44. data/app/components/alchemy/ingredients/boolean_editor.rb +1 -0
  45. data/app/components/alchemy/ingredients/color_editor.rb +6 -3
  46. data/app/components/alchemy/ingredients/datetime_view.rb +1 -1
  47. data/app/components/alchemy/ingredients/picture_view.rb +2 -4
  48. data/app/components/alchemy/menubar.html.erb +157 -0
  49. data/app/components/alchemy/menubar.rb +15 -0
  50. data/app/controllers/alchemy/admin/attachments_controller.rb +12 -9
  51. data/app/controllers/alchemy/admin/base_controller.rb +34 -21
  52. data/app/controllers/alchemy/admin/dashboard_controller.rb +4 -0
  53. data/app/controllers/alchemy/admin/elements_controller.rb +4 -23
  54. data/app/controllers/alchemy/admin/languages_controller.rb +1 -1
  55. data/app/controllers/alchemy/admin/nodes_controller.rb +1 -1
  56. data/app/controllers/alchemy/admin/pages_controller.rb +1 -1
  57. data/app/controllers/alchemy/admin/pictures_controller.rb +6 -3
  58. data/app/controllers/alchemy/admin/resources_controller.rb +5 -5
  59. data/app/controllers/alchemy/admin/sites_controller.rb +1 -1
  60. data/app/controllers/alchemy/base_controller.rb +24 -16
  61. data/app/controllers/alchemy/pages_controller.rb +29 -8
  62. data/app/controllers/alchemy/sitemap_controller.rb +58 -0
  63. data/app/controllers/concerns/alchemy/admin/csp_protection.rb +43 -0
  64. data/app/controllers/concerns/alchemy/admin/current_language.rb +1 -1
  65. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +3 -1
  66. data/{lib → app/controllers/concerns}/alchemy/controller_actions.rb +13 -0
  67. data/app/controllers/concerns/alchemy/legacy_page_redirects.rb +4 -4
  68. data/app/helpers/alchemy/admin/base_helper.rb +4 -1
  69. data/app/helpers/alchemy/admin/form_helper.rb +0 -2
  70. data/app/helpers/alchemy/admin/navigation_helper.rb +10 -181
  71. data/app/helpers/alchemy/elements_block_helper.rb +14 -0
  72. data/app/helpers/alchemy/resources_helper.rb +15 -1
  73. data/app/javascript/alchemy_admin/components/attachment_select.js +11 -20
  74. data/app/javascript/alchemy_admin/components/auto_submit.js +2 -4
  75. data/app/javascript/alchemy_admin/components/button.js +0 -6
  76. data/app/javascript/alchemy_admin/components/clear_schedule_button.js +21 -0
  77. data/app/javascript/alchemy_admin/components/color_select.js +50 -45
  78. data/app/javascript/alchemy_admin/components/conditional_field.js +50 -0
  79. data/app/javascript/alchemy_admin/components/dirty_guard.js +77 -0
  80. data/app/javascript/alchemy_admin/components/dom_id_select.js +17 -10
  81. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +7 -2
  82. data/app/javascript/alchemy_admin/components/element_editor.js +29 -56
  83. data/app/javascript/alchemy_admin/components/element_select.js +26 -66
  84. data/app/javascript/alchemy_admin/components/icon.js +9 -4
  85. data/app/javascript/alchemy_admin/{image_cropper.js → components/image_cropper.js} +39 -53
  86. data/app/javascript/alchemy_admin/components/image_overlay_link.js +14 -0
  87. data/app/javascript/alchemy_admin/components/index.js +12 -1
  88. data/app/javascript/alchemy_admin/components/main_navi_entry.js +75 -0
  89. data/app/javascript/alchemy_admin/components/menu_node.js +73 -0
  90. data/app/javascript/alchemy_admin/components/news_reader.js +109 -0
  91. data/app/javascript/alchemy_admin/components/node_folder.js +36 -0
  92. data/app/javascript/alchemy_admin/components/node_form.js +34 -0
  93. data/app/javascript/alchemy_admin/components/node_select.js +25 -30
  94. data/app/javascript/alchemy_admin/components/node_tree.js +56 -0
  95. data/app/javascript/alchemy_admin/components/page_node.js +2 -1
  96. data/app/javascript/alchemy_admin/components/page_select.js +16 -34
  97. data/app/javascript/alchemy_admin/components/picture_archive.js +92 -0
  98. data/app/javascript/alchemy_admin/components/picture_editor.js +2 -1
  99. data/app/javascript/alchemy_admin/components/preview_window.js +11 -3
  100. data/app/javascript/alchemy_admin/components/remote_select.js +461 -81
  101. data/app/javascript/alchemy_admin/components/select.js +26 -88
  102. data/app/javascript/alchemy_admin/components/sitemap.js +2 -1
  103. data/app/javascript/alchemy_admin/components/sortable_elements.js +2 -1
  104. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +63 -48
  105. data/app/javascript/alchemy_admin/components/tinymce.js +2 -1
  106. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +3 -2
  107. data/app/javascript/alchemy_admin/components/uploader.js +19 -3
  108. data/app/javascript/alchemy_admin/confirm_dialog.js +7 -1
  109. data/app/javascript/alchemy_admin/dialog.js +182 -110
  110. data/app/javascript/alchemy_admin/hotkeys.js +0 -5
  111. data/app/javascript/alchemy_admin/i18n.js +3 -9
  112. data/app/javascript/alchemy_admin/image_overlay.js +58 -42
  113. data/app/javascript/alchemy_admin/initializer.js +2 -2
  114. data/app/javascript/alchemy_admin/link_dialog.js +15 -2
  115. data/app/javascript/alchemy_admin/routes.js +18 -0
  116. data/app/javascript/alchemy_admin/turbo_stream_actions.js +88 -0
  117. data/app/javascript/alchemy_admin/utils/ajax.js +14 -3
  118. data/app/javascript/alchemy_admin/utils/events.js +5 -0
  119. data/app/javascript/alchemy_admin/utils/json_script.js +12 -0
  120. data/app/javascript/alchemy_admin/utils/tom_select.js +171 -0
  121. data/app/javascript/alchemy_admin.js +2 -19
  122. data/app/javascript/preview.js +131 -113
  123. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +1 -1
  124. data/app/models/alchemy/admin/content_security_policy.rb +143 -0
  125. data/app/models/alchemy/admin/filters/base.rb +7 -0
  126. data/app/models/alchemy/admin/filters/datepicker.rb +1 -1
  127. data/app/models/alchemy/admin/filters/select.rb +20 -7
  128. data/app/models/alchemy/attachment.rb +14 -7
  129. data/app/models/alchemy/cache_control.rb +104 -0
  130. data/app/models/alchemy/current.rb +1 -1
  131. data/app/models/alchemy/eager_loading.rb +0 -2
  132. data/app/models/alchemy/element.rb +23 -8
  133. data/app/models/alchemy/element_definition.rb +1 -0
  134. data/app/models/alchemy/elements_repository.rb +11 -0
  135. data/app/models/alchemy/folded_page.rb +5 -1
  136. data/app/models/alchemy/ingredient.rb +26 -0
  137. data/app/models/alchemy/ingredients/datetime.rb +1 -1
  138. data/app/models/alchemy/ingredients/link.rb +2 -0
  139. data/app/models/alchemy/ingredients/picture.rb +3 -0
  140. data/app/models/alchemy/ingredients/text.rb +2 -0
  141. data/app/models/alchemy/language.rb +19 -7
  142. data/app/models/alchemy/legacy_page_url.rb +6 -1
  143. data/app/models/alchemy/node.rb +51 -0
  144. data/app/models/alchemy/page/page_natures.rb +41 -8
  145. data/app/models/alchemy/page/page_scopes.rb +23 -0
  146. data/app/models/alchemy/page/sitemap_cache_key.rb +56 -0
  147. data/app/models/alchemy/page.rb +88 -32
  148. data/app/models/alchemy/page_definition.rb +1 -1
  149. data/app/models/alchemy/page_mutex.rb +16 -0
  150. data/app/models/alchemy/page_version.rb +24 -1
  151. data/app/models/alchemy/permissions.rb +6 -5
  152. data/app/models/alchemy/picture.rb +15 -7
  153. data/app/models/alchemy/picture_description.rb +22 -0
  154. data/app/models/alchemy/picture_thumb.rb +18 -0
  155. data/app/models/alchemy/publishable/timestamp_resolver.rb +70 -0
  156. data/app/models/alchemy/resource.rb +1 -1
  157. data/app/models/alchemy/safe_url_validator.rb +34 -0
  158. data/app/models/alchemy/site.rb +8 -3
  159. data/app/models/alchemy/storage_adapter/active_storage/preprocessor.rb +7 -3
  160. data/app/models/alchemy/storage_adapter/active_storage.rb +4 -1
  161. data/app/models/alchemy/tag.rb +10 -4
  162. data/{lib → app/models/concerns}/alchemy/filetypes.rb +5 -0
  163. data/app/models/concerns/alchemy/picture_thumbnails.rb +61 -6
  164. data/app/models/concerns/alchemy/publishable.rb +14 -27
  165. data/app/models/concerns/alchemy/taggable.rb +92 -0
  166. data/app/models/concerns/alchemy/user_methods.rb +95 -0
  167. data/app/services/alchemy/duplicate_element.rb +5 -2
  168. data/app/services/alchemy/element_preloader.rb +1 -2
  169. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  170. data/app/stylesheets/alchemy/_extends.scss +0 -8
  171. data/app/stylesheets/alchemy/_themes.scss +10 -9
  172. data/app/stylesheets/alchemy/admin/_empty-state.scss +29 -0
  173. data/app/stylesheets/alchemy/admin/_help.scss +10 -0
  174. data/app/stylesheets/alchemy/admin/_tom-select.scss +104 -15
  175. data/app/stylesheets/alchemy/admin/archive.scss +56 -11
  176. data/app/stylesheets/alchemy/admin/base.scss +4 -5
  177. data/app/stylesheets/alchemy/admin/buttons.scss +12 -1
  178. data/app/stylesheets/alchemy/admin/dashboard.scss +150 -20
  179. data/app/stylesheets/alchemy/admin/dialogs.scss +27 -36
  180. data/app/stylesheets/alchemy/admin/element-select.scss +1 -1
  181. data/app/stylesheets/alchemy/admin/elements.scss +8 -24
  182. data/app/stylesheets/alchemy/admin/flash.scss +2 -1
  183. data/app/stylesheets/alchemy/admin/forms.scss +60 -24
  184. data/app/stylesheets/alchemy/admin/frame.scss +40 -4
  185. data/app/stylesheets/alchemy/admin/image_library.scss +9 -12
  186. data/app/stylesheets/alchemy/admin/navigation.scss +17 -14
  187. data/app/stylesheets/alchemy/admin/node-select.scss +13 -37
  188. data/app/stylesheets/alchemy/admin/nodes.scss +1 -1
  189. data/app/stylesheets/alchemy/admin/page-select.scss +6 -97
  190. data/app/stylesheets/alchemy/admin/print.scss +0 -4
  191. data/app/stylesheets/alchemy/admin/remote-select.scss +170 -0
  192. data/app/stylesheets/alchemy/admin/search.scss +2 -3
  193. data/app/stylesheets/alchemy/admin/selects.scss +1 -294
  194. data/app/stylesheets/alchemy/admin/sitemap.scss +2 -1
  195. data/app/stylesheets/alchemy/admin/tables.scss +7 -1
  196. data/app/stylesheets/alchemy/admin/tags-autocomplete.scss +17 -0
  197. data/app/stylesheets/alchemy/admin/tags.scss +0 -13
  198. data/app/stylesheets/alchemy/admin/toolbar.scss +9 -6
  199. data/app/stylesheets/alchemy/admin/upload.scss +61 -1
  200. data/app/stylesheets/alchemy/admin.scss +4 -1
  201. data/app/types/alchemy/cache_control_type.rb +84 -0
  202. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  203. data/app/views/alchemy/_menubar.html.erb +2 -154
  204. data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
  205. data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
  206. data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
  207. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
  208. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  209. data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
  210. data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
  211. data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
  212. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  213. data/app/views/alchemy/admin/crop.html.erb +20 -20
  214. data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
  215. data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
  216. data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
  217. data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
  218. data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
  219. data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
  220. data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
  221. data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
  222. data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
  223. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
  224. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
  225. data/app/views/alchemy/admin/elements/index.html.erb +1 -1
  226. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
  227. data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
  228. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
  229. data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
  230. data/app/views/alchemy/admin/languages/index.html.erb +25 -29
  231. data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
  232. data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
  233. data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
  234. data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
  235. data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
  236. data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
  237. data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
  238. data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
  239. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
  240. data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
  241. data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
  242. data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
  243. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
  244. data/app/views/alchemy/admin/pages/index.html.erb +26 -31
  245. data/app/views/alchemy/admin/pages/info.html.erb +2 -2
  246. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
  247. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
  248. data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
  249. data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
  250. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  251. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
  252. data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
  253. data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
  254. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  255. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
  256. data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
  257. data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
  258. data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
  259. data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
  260. data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
  261. data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
  262. data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
  263. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
  264. data/app/views/alchemy/admin/resources/index.html.erb +32 -13
  265. data/app/views/alchemy/admin/sites/index.html.erb +24 -22
  266. data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
  267. data/app/views/alchemy/admin/tags/index.html.erb +27 -25
  268. data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
  269. data/app/views/alchemy/admin/translations/_en.js +3 -0
  270. data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
  271. data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
  272. data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
  273. data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
  274. data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
  275. data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
  276. data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
  277. data/app/views/kaminari/alchemy/_page.html.erb +1 -2
  278. data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
  279. data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
  280. data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
  281. data/app/views/layouts/alchemy/admin.html.erb +5 -6
  282. data/config/brakeman.ignore +23 -1
  283. data/config/importmap.rb +10 -8
  284. data/config/initializers/dragonfly.rb +21 -17
  285. data/config/locales/alchemy.en.yml +52 -11
  286. data/config/routes.rb +3 -3
  287. data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
  288. data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
  289. data/lib/alchemy/configuration/number_option.rb +13 -0
  290. data/lib/alchemy/configuration/proc_option.rb +13 -0
  291. data/lib/alchemy/configuration.rb +2 -0
  292. data/lib/alchemy/configurations/admin_components.rb +30 -0
  293. data/lib/alchemy/configurations/format_matchers.rb +7 -1
  294. data/lib/alchemy/configurations/main.rb +103 -4
  295. data/lib/alchemy/configurations/menu.rb +91 -0
  296. data/lib/alchemy/configurations/menu_item.rb +123 -0
  297. data/lib/alchemy/configurations/sitemap.rb +1 -0
  298. data/lib/alchemy/configurations/uploader.rb +15 -1
  299. data/lib/alchemy/engine.rb +0 -6
  300. data/lib/alchemy/i18n.rb +26 -0
  301. data/lib/alchemy/modules.rb +52 -50
  302. data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
  303. data/lib/alchemy/tasks/tidy.rb +1 -1
  304. data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
  305. data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
  306. data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
  307. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
  308. data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
  309. data/lib/alchemy/test_support/shared_publishable_examples.rb +29 -4
  310. data/lib/alchemy/upgrader/eight_four.rb +237 -0
  311. data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
  312. data/lib/alchemy/upgrader.rb +2 -1
  313. data/lib/alchemy/version.rb +1 -1
  314. data/lib/alchemy.rb +0 -1
  315. data/lib/alchemy_cms.rb +0 -13
  316. data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
  317. data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
  318. data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
  319. data/lib/generators/alchemy/install/install_generator.rb +2 -13
  320. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
  321. data/lib/generators/alchemy/module/module_generator.rb +1 -0
  322. data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
  323. data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
  324. data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
  325. data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
  326. data/lib/tasks/alchemy/thumbnails.rake +16 -29
  327. data/lib/tasks/alchemy/upgrade.rake +45 -3
  328. data/vendor/javascript/cropperjs.min.js +3 -3
  329. data/vendor/javascript/floating-ui.min.js +1 -1
  330. data/vendor/javascript/tinymce.min.js +5 -5
  331. data/vendor/javascript/tom-select.min.js +1 -1
  332. metadata +85 -65
  333. data/app/javascript/alchemy_admin/components/action.js +0 -50
  334. data/app/javascript/alchemy_admin/dirty.js +0 -55
  335. data/app/javascript/alchemy_admin/node_tree.js +0 -82
  336. data/app/javascript/alchemy_admin/picture_selector.js +0 -67
  337. data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
  338. data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
  339. data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
  340. data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
  341. data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
  342. data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
  343. data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
  344. data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
  345. data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
  346. data/app/views/alchemy/base/error_notice.js.erb +0 -1
  347. data/app/views/alchemy/base/permission_denied.js.erb +0 -3
  348. data/app/views/alchemy/base/redirect.js.erb +0 -11
  349. data/config/alchemy/config.yml +0 -217
  350. data/config/alchemy/modules.yml +0 -82
  351. data/config/initializers/rails_live_reload.rb +0 -13
  352. data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
  353. data/lib/alchemy/taggable.rb +0 -49
  354. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
  355. data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
  356. data/vendor/javascript/handlebars.min.js +0 -29
  357. data/vendor/javascript/jquery.min.js +0 -2
  358. data/vendor/javascript/rails-ujs.min.js +0 -1
  359. data/vendor/javascript/select2.min.js +0 -23
  360. /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
  361. /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
  362. /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
  363. /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
  364. /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
  365. /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
  366. /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
  367. /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
  368. /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
@@ -0,0 +1,54 @@
1
+ module Alchemy
2
+ module Admin
3
+ module Dashboard
4
+ module Widgets
5
+ class NewsReader < ViewComponent::Base
6
+ MAX_ENTRIES = 5
7
+ CACHE_KEY = "alchemy/dashboard/news_reader"
8
+ CACHE_EXPIRY = 1.hour
9
+
10
+ FEED_URL = "https://www.alchemy-cms.com/news"
11
+
12
+ def entries
13
+ # Failed fetches return nil and are skipped (skip_nil), so a transient
14
+ # outage does not blank the widget for the whole cache duration.
15
+ @entries ||= Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_EXPIRY, skip_nil: true) do
16
+ fetch_entries
17
+ end || []
18
+ end
19
+
20
+ private
21
+
22
+ def fetch_entries
23
+ response = Net::HTTP.get(URI(FEED_URL), {"Accept" => "application/atom+xml"})
24
+ doc = Nokogiri.parse(response)
25
+ doc.remove_namespaces!
26
+ doc.xpath("/feed/entry").first(MAX_ENTRIES).map { |entry| parse_entry(entry) }
27
+ rescue => error
28
+ Rails.logger.warn("Alchemy::Admin::Dashboard::Widgets::NewsReader could not load feed: #{error.message}")
29
+ nil
30
+ end
31
+
32
+ def parse_entry(entry)
33
+ content = Nokogiri::HTML.fragment(entry.xpath("content").text)
34
+ {
35
+ title: entry.xpath("title").text,
36
+ url: entry.at_xpath("link[@rel='alternate']")&.attr("href") || entry.at_xpath("link")&.attr("href"),
37
+ author: entry.xpath("author/name").text.presence,
38
+ image_url: content.at_css("img")&.attr("src"),
39
+ summary: content.text.squish.presence,
40
+ published_at: published_at(entry)
41
+ }
42
+ end
43
+
44
+ def published_at(entry)
45
+ date = entry.xpath("published").text
46
+ Alchemy.l(Date.parse(date))
47
+ rescue ArgumentError
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -13,7 +13,7 @@
13
13
  <tr>
14
14
  <td class="name"><%= user.name %></td>
15
15
  <td class="right">
16
- <small><%= user.human_roles_string %></small>
16
+ <small><%= user.human_alchemy_roles %></small>
17
17
  </td>
18
18
  </tr>
19
19
  <% end %>
@@ -31,7 +31,7 @@
31
31
  </td>
32
32
  <% end %>
33
33
  <td class="right">
34
- <small><%= l(page.updated_at, format: :'alchemy.short_datetime') %></small>
34
+ <small><%= Alchemy.l(page.updated_at, format: :'alchemy.short_datetime') %></small>
35
35
  </td>
36
36
  </tr>
37
37
  <% end %>
@@ -25,8 +25,16 @@
25
25
  </span>
26
26
  </span>
27
27
 
28
- <a href="#legal_info" data-turbo="false">
29
- <small><%= Alchemy.t(:legal_info) %></small>
30
- </a>
28
+ <span class="label-with-icon license">
29
+ <%= render_icon("certificate-2") %>
30
+ <%= link_to_dialog(
31
+ license,
32
+ alchemy.dashboard_license_path,
33
+ {
34
+ title: Alchemy.t(:license),
35
+ size: "620x470"
36
+ }
37
+ ) %>
38
+ </span>
31
39
  </div>
32
40
  </div>
@@ -5,7 +5,7 @@ module Alchemy
5
5
  module Dashboard
6
6
  module Widgets
7
7
  class SystemInfo < ViewComponent::Base
8
- delegate :alchemy, :render_message, to: :helpers
8
+ delegate :alchemy, :render_message, :render_icon, :link_to_dialog, to: :helpers
9
9
 
10
10
  def initialize
11
11
  @alchemy_version = Alchemy.version
@@ -13,6 +13,11 @@ module Alchemy
13
13
 
14
14
  private
15
15
 
16
+ def license
17
+ license_id = Gem.loaded_specs["alchemy_cms"]&.license
18
+ Alchemy.t(license_id, scope: :licenses) if license_id
19
+ end
20
+
16
21
  def logo
17
22
  @_logo_file ||= File.read(logo_file_path).html_safe
18
23
  end
@@ -29,7 +29,7 @@
29
29
  <% end %>
30
30
 
31
31
  <% if editable? %>
32
- <%= form_for [alchemy, :admin, element], remote: true,
32
+ <%= form_for [alchemy, :admin, element],
33
33
  html: {id: "element_#{id}_form".html_safe, class: 'element-body'} do |f| %>
34
34
 
35
35
  <div id="element_<%= id %>_errors" class="element_errors hidden">
@@ -12,30 +12,40 @@ module Alchemy
12
12
  end
13
13
 
14
14
  def call
15
- content_tag "alchemy-element-select",
16
- options: elements_options.to_json,
17
- placeholder: Alchemy.t(:select_element) do
18
- text_field_tag(field_name, nil, {
19
- autofocus:,
20
- value: elements.many? ? nil : elements.first&.name,
21
- class: "alchemy_selectbox full_width"
22
- })
23
- end
15
+ content_tag "select", element_options,
16
+ is: "alchemy-element-select",
17
+ id: field_id,
18
+ name: field_name,
19
+ required: true,
20
+ autofocus:,
21
+ placeholder: Alchemy.t(:select_element)
24
22
  end
25
23
 
26
24
  private
27
25
 
28
- def elements_options
29
- return [] if elements.nil?
30
-
31
- elements.sort_by(&:name).map do |element|
32
- {
33
- text: Element.display_name_for(element.name),
34
- hint: element.hint,
35
- icon: element.icon_file,
36
- id: element.name
37
- }
38
- end
26
+ # Mirror Rails' input id sanitization (as the previous text_field_tag did)
27
+ # so the form label's `for` attribute keeps targeting the select.
28
+ def field_id
29
+ field_name.to_s.delete("]").tr("^-a-zA-Z0-9:.", "_")
30
+ end
31
+
32
+ def element_options
33
+ return "".html_safe if elements.nil?
34
+
35
+ # Preselect the only option, so adding a single available element type
36
+ # does not require an explicit selection.
37
+ preselect = !elements.many?
38
+
39
+ safe_join(
40
+ elements.sort_by(&:name).map do |element|
41
+ tag.option(
42
+ Element.display_name_for(element.name),
43
+ value: element.name,
44
+ selected: preselect,
45
+ data: {icon: element.icon_file, hint: element.hint}
46
+ )
47
+ end
48
+ )
39
49
  end
40
50
  end
41
51
  end
@@ -0,0 +1,16 @@
1
+ <span class="element-status-icons">
2
+ <% unless element.public? || element.scheduled? %>
3
+ <alchemy-icon name="cloud-off" size="1x"></alchemy-icon>
4
+ <span class="element-hidden-label">
5
+ <%= Alchemy.t(:element_hidden) %>
6
+ </span>
7
+ <% end %>
8
+ <sl-tooltip
9
+ class="element-scheduled-icon"
10
+ content="<%= scheduled_tooltip %>"
11
+ <%= "hidden" unless element.scheduled? %>
12
+ >
13
+ <alchemy-icon name="calendar-schedule" size="1x"></alchemy-icon>
14
+ <%= scheduled_label %>
15
+ </sl-tooltip>
16
+ </span>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Admin
5
+ # Renders the visibility state badges (hidden / scheduled) shown for an
6
+ # element in the element header.
7
+ class ElementStatusIndicators < ViewComponent::Base
8
+ def initialize(element:)
9
+ @element = element
10
+ end
11
+
12
+ private
13
+
14
+ attr_reader :element
15
+
16
+ def scheduled_tooltip
17
+ Alchemy.t(
18
+ element.public_on&.future? ? :public_on : :public_until,
19
+ scope: :element_scheduled,
20
+ public_on: Alchemy.l(element.public_on),
21
+ public_until: Alchemy.l(element.public_until)
22
+ )
23
+ end
24
+
25
+ def scheduled_label
26
+ date = element.public_on&.future? ? element.public_on : element.public_until
27
+ Alchemy.l(date, format: :"alchemy.short_datetime")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -33,7 +33,8 @@ module Alchemy
33
33
 
34
34
  def url_input
35
35
  label = label_tag("external_link", "URL", class: "control-label")
36
- input = text_field_tag "external_link", is_selected? ? @url : ""
36
+ input = text_field_tag "external_link", is_selected? ? @url : "",
37
+ data: {link_url_regexp: link_url_regexp.inspect}
37
38
  content_tag("div", label + input, class: "input text")
38
39
  end
39
40
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Admin
5
+ module MainNavigation
6
+ class Entry < ViewComponent::Base
7
+ erb_template <<~ERB
8
+ <%= content_tag :"alchemy-main-navi-entry", class: css_classes, data: data_attributes do %>
9
+ <%= link_to item.path(helpers) do %>
10
+ <% if item.image %>
11
+ <%= image_tag(item.image) %>
12
+ <% elsif item.inline_image %>
13
+ <%== item.inline_image %>
14
+ <% elsif item.icon %>
15
+ <%= content_tag :"alchemy-icon", nil,
16
+ name: item.icon,
17
+ "icon-style": item.icon_style %>
18
+ <% else %>
19
+ <%= render_icon :table %>
20
+ <% end %>
21
+ <label><%= Alchemy.t(item.title) %></label>
22
+ <% end %>
23
+ <%= render Alchemy::Admin::MainNavigation::SubNavigation.new(item) %>
24
+ <% end %>
25
+ ERB
26
+
27
+ with_collection_parameter :item
28
+
29
+ delegate :render_icon, to: :helpers
30
+
31
+ attr_reader :item
32
+
33
+ def initialize(item:)
34
+ @item = item
35
+ end
36
+
37
+ def before_render
38
+ item.validate_controller_existence!
39
+ end
40
+
41
+ def render?
42
+ item.visible?(helpers)
43
+ end
44
+
45
+ private
46
+
47
+ def css_classes
48
+ [
49
+ "main_navi_entry",
50
+ ("active" if item.active?(helpers)),
51
+ ("has_sub_navigation" if item.children.any?)
52
+ ].compact
53
+ end
54
+
55
+ def data_attributes
56
+ {turbo: false} unless item.turbo
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Admin
5
+ module MainNavigation
6
+ class Menu < ViewComponent::Base
7
+ erb_template <<~ERB
8
+ <div id="main_navi">
9
+ <%= render Alchemy::Admin::MainNavigation::Entry.with_collection(items) %>
10
+ <%= helpers.content_for(:alchemy_main_navigation) %>
11
+ </div>
12
+ ERB
13
+
14
+ def items
15
+ positioned, unpositioned = Alchemy.config.menu.partition(&:position)
16
+ positioned.sort_by(&:position) + unpositioned
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Admin
5
+ module MainNavigation
6
+ class SubNavigation < ViewComponent::Base
7
+ erb_template <<~ERB
8
+ <div class="sub_navigation">
9
+ <% item.children.each do |child| %>
10
+ <% if child.visible?(helpers) %>
11
+ <div class="subnavi_tab<%= " active" if child.active?(helpers) %>">
12
+ <%= link_to Alchemy.t(child.title), child.path(helpers) %>
13
+ </div>
14
+ <% end %>
15
+ <% end %>
16
+ </div>
17
+ ERB
18
+
19
+ attr_reader :item
20
+
21
+ def initialize(item)
22
+ @item = item
23
+ end
24
+
25
+ def render?
26
+ item.children.any?
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -65,7 +65,7 @@
65
65
  </div>
66
66
 
67
67
  <div class="page_infos">
68
- <%= render Alchemy::Admin::PageStatusIndicators.new(page: @page) %>
68
+ <%= render Alchemy.config.admin_components.page_status_indicators.new(page: @page) %>
69
69
  </div>
70
70
 
71
71
  <div class="sitemap_right_tools">
@@ -0,0 +1,11 @@
1
+ <alchemy-conditional-field control="page_restricted">
2
+ <%= form.input :permitted_roles,
3
+ collection: role_options,
4
+ include_blank: false,
5
+ wrapper_html: {class: "input-column"},
6
+ input_html: {
7
+ multiple: true,
8
+ is: "alchemy-select",
9
+ disabled: disabled?
10
+ } %>
11
+ </alchemy-conditional-field>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Admin
5
+ # Renders the multi select of roles that are permitted to read a restricted
6
+ # page.
7
+ #
8
+ # Only visible while the +restricted+ checkbox is checked. While hidden the
9
+ # select is disabled, so it does not submit and clear the stored roles.
10
+ #
11
+ class PagePermittedRolesSelect < ViewComponent::Base
12
+ def initialize(page:, form:)
13
+ @page = page
14
+ @form = form
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :form
20
+
21
+ def disabled?
22
+ !@page.restricted? || @page.attribute_fixed?(:restricted)
23
+ end
24
+
25
+ # The configured user roles. Only configured roles are selectable, and a
26
+ # validation rejects any unknown role, so the select offers these only.
27
+ #
28
+ def role_options
29
+ @_role_options ||= Alchemy.config.user_roles.map do |role|
30
+ [Alchemy.t(role, scope: :user_roles, default: role.humanize), role]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,12 +3,13 @@ module Alchemy
3
3
  class PageSelect < ViewComponent::Base
4
4
  delegate :alchemy, to: :helpers
5
5
 
6
- def initialize(page = nil, url: nil, allow_clear: false, placeholder: Alchemy.t(:search_page), query_params: nil)
6
+ def initialize(page = nil, url: nil, allow_clear: false, placeholder: Alchemy.t(:search_page), query_params: nil, multiple: false)
7
7
  @page = page
8
8
  @url = url
9
9
  @allow_clear = allow_clear
10
10
  @placeholder = placeholder
11
11
  @query_params = query_params
12
+ @multiple = multiple
12
13
  end
13
14
 
14
15
  def call
@@ -27,18 +28,27 @@ module Alchemy
27
28
 
28
29
  options = options.merge({"allow-clear": @allow_clear}) if @allow_clear
29
30
  options = options.merge({"query-params": @query_params.to_json}) if @query_params
31
+ options = options.merge({multiple: true}) if @multiple
30
32
 
31
- if @page
32
- selection = {
33
- id: @page.id,
34
- name: @page.name,
35
- url_path: @page.url_path
36
- }
37
- options = options.merge({selection: selection.to_json})
33
+ if @multiple
34
+ if @page.present?
35
+ selection = Array(@page).map { |page| page_selection(page) }
36
+ options = options.merge({selection: selection.to_json})
37
+ end
38
+ elsif @page
39
+ options = options.merge({selection: page_selection(@page).to_json})
38
40
  end
39
41
 
40
42
  options
41
43
  end
44
+
45
+ def page_selection(page)
46
+ {
47
+ id: page.id,
48
+ name: page.name,
49
+ url_path: page.url_path
50
+ }
51
+ end
42
52
  end
43
53
  end
44
54
  end
@@ -39,7 +39,7 @@ module Alchemy
39
39
  future_public_until = elements.where("public_until > ?", now).pluck(:public_until)
40
40
  times = (future_public_on | future_public_until)
41
41
  times.sort!
42
- times.map { |time| [l(time, format: :"alchemy.element_date"), time.iso8601] }
42
+ times.map { |time| [Alchemy.l(time, format: :"alchemy.element_date"), time.iso8601] }
43
43
  end
44
44
  end
45
45
 
@@ -32,23 +32,6 @@ module Alchemy
32
32
  # Skip the permission check. NOT RECOMMENDED!
33
33
  #
34
34
  class ToolbarButton < ViewComponent::Base
35
- erb_template <<-ERB
36
- <div class="toolbar_button" id="<%= id %>">
37
- <sl-tooltip content="<%= label %>" placement="<%= tooltip_placement %>" <%= "disabled" if disabled? %>>
38
- <% if disabled? %>
39
- <%= tag.a(render_icon(icon, style: icon_style), class: css_classes + ["disabled"], tabindex: "-1") %>
40
- <% else %>
41
- <%= link_to(render_icon(icon, style: icon_style), url, {
42
- class: css_classes,
43
- "data-dialog-options" => dialog ? dialog_options.to_json : nil,
44
- "data-alchemy-hotkey" => hotkey,
45
- :is => dialog ? "alchemy-dialog-link" : nil
46
- }.merge(link_options)) %>
47
- <% end %>
48
- </sl-tooltip>
49
- </div>
50
- ERB
51
-
52
35
  delegate :cannot?, :link_to, :link_to_dialog, :render_icon, to: :helpers
53
36
 
54
37
  attr_reader :url,
@@ -63,7 +46,8 @@ module Alchemy
63
46
  :link_options,
64
47
  :id,
65
48
  :icon_style,
66
- :tooltip_placement
49
+ :tooltip_placement,
50
+ :primary
67
51
 
68
52
  def initialize(
69
53
  url:,
@@ -79,7 +63,8 @@ module Alchemy
79
63
  link_options: {},
80
64
  id: nil,
81
65
  icon_style: "line",
82
- tooltip_placement: "top-start"
66
+ tooltip_placement: "top-start",
67
+ primary: false
83
68
  )
84
69
  @url = url
85
70
  @icon = icon
@@ -94,15 +79,53 @@ module Alchemy
94
79
  @id = id
95
80
  @icon_style = icon_style
96
81
  @tooltip_placement = tooltip_placement
82
+ @primary = primary
83
+ end
84
+
85
+ def call
86
+ tag.div class: "toolbar_button", id: do
87
+ if primary
88
+ link_tag
89
+ else
90
+ content_tag "sl-tooltip", content: label, placement: tooltip_placement, disabled: disabled? do
91
+ link_tag
92
+ end
93
+ end
94
+ end
97
95
  end
98
96
 
99
97
  private
100
98
 
99
+ def link_tag
100
+ if disabled?
101
+ tag.a(link_text, class: css_classes, tabindex: "-1")
102
+ else
103
+ link_to link_text, url, {
104
+ :class => css_classes,
105
+ "data-dialog-options" => dialog ? dialog_options.to_json : nil,
106
+ "data-alchemy-hotkey" => hotkey,
107
+ :is => dialog ? "alchemy-dialog-link" : nil
108
+ }.merge(link_options)
109
+ end
110
+ end
111
+
112
+ def link_text
113
+ primary ? icon_tag + label : icon_tag
114
+ end
115
+
116
+ def icon_tag = render_icon(icon, style: icon_style)
117
+
101
118
  def disabled?
102
119
  @_disabled ||= !skip_permission_check && cannot?(*permission_options)
103
120
  end
104
121
 
105
- def css_classes = ["icon_button", active && "active"].compact
122
+ def css_classes
123
+ [
124
+ primary ? "button with_icon" : "icon_button",
125
+ disabled? ? "disabled" : nil,
126
+ active ? "active" : nil
127
+ ].compact
128
+ end
106
129
 
107
130
  def permission_options = if_permitted_to.presence || permissions_from_url
108
131
 
@@ -0,0 +1,66 @@
1
+ module Alchemy
2
+ module Admin
3
+ class UploaderButton < ViewComponent::Base
4
+ delegate :alchemy, to: :helpers
5
+
6
+ attr_reader :object, :file_attribute, :accept, :dropzone, :redirect_url, :layout, :inline_label
7
+
8
+ def initialize(object:, file_attribute:, redirect_url:, accept: nil, dropzone: nil, label: nil, layout: nil, inline_label: false)
9
+ @object = object
10
+ @file_attribute = file_attribute
11
+ @redirect_url = redirect_url
12
+ @dropzone = dropzone || "#main_content"
13
+ @label = label
14
+ @layout = layout
15
+ @accept = accept || ((file_types.to_a == ["*"]) ? nil : file_types.map { |type| ".#{type}" }.join(", "))
16
+ @inline_label = inline_label
17
+ end
18
+
19
+ def call
20
+ content_tag "alchemy-uploader", "redirect-url": redirect_url, dropzone:, layout: do
21
+ form_for [alchemy, :admin, object], html: {multipart: true, class: "upload-button"} do |f|
22
+ safe_join([upload_hash_field(f), file_input(f), upload_label(f)].compact)
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def upload_hash_field(f)
30
+ return unless object.respond_to?(:upload_hash)
31
+
32
+ f.hidden_field(:upload_hash, name: "#{f.object_name}[upload_hash]", value: Time.current.hash)
33
+ end
34
+
35
+ def file_input(f)
36
+ f.file_field file_attribute,
37
+ class: "fileupload fileupload--field", multiple: true, accept: accept,
38
+ name: "#{f.object_name}[#{file_attribute}]", tabindex: "-1"
39
+ end
40
+
41
+ def upload_label(f)
42
+ f.label file_attribute, data: {alchemy_hotkey: "alt+n"} do
43
+ if inline_label
44
+ tag.span class: "button with_icon", tabindex: "0" do
45
+ render(Alchemy::Admin::Icon.new("upload-2")) + tooltip
46
+ end
47
+ else
48
+ content_tag "sl-tooltip", content: tooltip, placement: "top-start" do
49
+ tag.span class: "icon_button", tabindex: "0" do
50
+ render Alchemy::Admin::Icon.new("upload-2")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def tooltip
58
+ @label.presence || Alchemy.t(:button_label, scope: [:uploader, object.class.model_name.i18n_key])
59
+ end
60
+
61
+ def file_types
62
+ Alchemy.config.uploader.allowed_filetypes[object.class.model_name.collection] || ["*"]
63
+ end
64
+ end
65
+ end
66
+ end
@@ -37,6 +37,7 @@ module Alchemy
37
37
  concat ingredient_id_field
38
38
  concat ingredient_label
39
39
  concat input_field
40
+ concat validation_error
40
41
  end
41
42
  end
42
43
 
@@ -101,10 +102,18 @@ module Alchemy
101
102
  partial_name,
102
103
  ingredient.deprecated? ? "deprecated" : nil,
103
104
  settings[:linkable] ? "linkable" : nil,
104
- settings[:anchor] ? "with-anchor" : nil
105
+ settings[:anchor] ? "with-anchor" : nil,
106
+ ingredient.errors[:value].any? ? "validation_failed" : nil
105
107
  ].compact
106
108
  end
107
109
 
110
+ # Renders the inline validation error shown below a failing ingredient input.
111
+ def validation_error
112
+ return if ingredient.errors[:value].empty?
113
+
114
+ tag.small(ingredient.errors[:value].to_sentence, class: "error")
115
+ end
116
+
108
117
  def data_attributes
109
118
  {
110
119
  ingredient_id: ingredient.id,