alchemy_cms 8.0.11 → 8.1.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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -10
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  5. data/app/assets/builds/alchemy/light-theme.css +1 -1
  6. data/app/assets/builds/alchemy/preview.min.js +1 -1
  7. data/app/assets/builds/alchemy/theme.css +1 -1
  8. data/app/{views/alchemy/admin/elements/_element.html.erb → components/alchemy/admin/element_editor.html.erb} +34 -29
  9. data/app/components/alchemy/admin/element_editor.rb +115 -0
  10. data/app/components/alchemy/admin/element_select.rb +12 -9
  11. data/app/components/alchemy/admin/ingredient_editor.rb +54 -0
  12. data/app/components/alchemy/admin/list_filter.rb +16 -5
  13. data/app/components/alchemy/admin/page_node.html.erb +214 -0
  14. data/app/components/alchemy/admin/page_node.rb +70 -0
  15. data/app/components/alchemy/admin/picture_thumbnail.rb +36 -0
  16. data/app/components/alchemy/admin/publish_page_button.html.erb +15 -0
  17. data/app/components/alchemy/admin/publish_page_button.rb +54 -0
  18. data/app/{helpers/alchemy/admin/tags_helper.rb → components/alchemy/admin/tags_list.rb} +19 -11
  19. data/app/components/alchemy/admin/toolbar_button.rb +17 -13
  20. data/app/components/alchemy/ingredients/audio_editor.rb +8 -0
  21. data/app/components/alchemy/ingredients/base_editor.rb +222 -0
  22. data/app/components/alchemy/ingredients/boolean_editor.rb +21 -0
  23. data/app/components/alchemy/ingredients/color_editor.rb +80 -0
  24. data/app/components/alchemy/ingredients/color_view.rb +13 -0
  25. data/app/components/alchemy/ingredients/datetime_editor.rb +28 -0
  26. data/app/components/alchemy/ingredients/file_editor.rb +69 -0
  27. data/app/components/alchemy/ingredients/headline_editor.rb +88 -0
  28. data/app/components/alchemy/ingredients/html_editor.rb +11 -0
  29. data/app/components/alchemy/ingredients/link_editor.rb +29 -0
  30. data/app/components/alchemy/ingredients/node_editor.rb +23 -0
  31. data/app/components/alchemy/ingredients/number_editor.rb +28 -0
  32. data/app/components/alchemy/ingredients/page_editor.rb +19 -0
  33. data/app/components/alchemy/ingredients/picture_editor.rb +81 -0
  34. data/app/components/alchemy/ingredients/richtext_editor.rb +31 -0
  35. data/app/components/alchemy/ingredients/select_editor.rb +37 -0
  36. data/app/components/alchemy/ingredients/select_view.rb +7 -0
  37. data/app/components/alchemy/ingredients/text_editor.rb +41 -0
  38. data/app/components/alchemy/ingredients/video_editor.rb +8 -0
  39. data/app/controllers/alchemy/admin/attachments_controller.rb +8 -6
  40. data/app/controllers/alchemy/admin/base_controller.rb +7 -18
  41. data/app/controllers/alchemy/admin/clipboard_controller.rb +15 -11
  42. data/app/controllers/alchemy/admin/dashboard_controller.rb +2 -2
  43. data/app/controllers/alchemy/admin/elements_controller.rb +34 -32
  44. data/app/controllers/alchemy/admin/ingredients_controller.rb +1 -0
  45. data/app/controllers/alchemy/admin/languages_controller.rb +0 -3
  46. data/app/controllers/alchemy/admin/layoutpages_controller.rb +2 -1
  47. data/app/controllers/alchemy/admin/legacy_page_urls_controller.rb +1 -1
  48. data/app/controllers/alchemy/admin/nodes_controller.rb +24 -1
  49. data/app/controllers/alchemy/admin/pages_controller.rb +36 -42
  50. data/app/controllers/alchemy/admin/pictures_controller.rb +2 -5
  51. data/app/controllers/alchemy/admin/resources_controller.rb +1 -1
  52. data/app/controllers/alchemy/api/ingredients_controller.rb +1 -1
  53. data/app/controllers/alchemy/api/pages_controller.rb +5 -3
  54. data/app/controllers/alchemy/base_controller.rb +6 -6
  55. data/app/controllers/alchemy/pages_controller.rb +12 -6
  56. data/app/controllers/concerns/alchemy/admin/archive_overlay.rb +0 -1
  57. data/app/controllers/concerns/alchemy/admin/clipboard.rb +57 -0
  58. data/app/controllers/concerns/alchemy/admin/uploader_responses.rb +2 -2
  59. data/app/controllers/concerns/alchemy/site_redirects.rb +1 -1
  60. data/app/decorators/alchemy/ingredient_editor.rb +37 -4
  61. data/app/helpers/alchemy/admin/base_helper.rb +10 -6
  62. data/app/helpers/alchemy/admin/ingredients_helper.rb +6 -3
  63. data/app/helpers/alchemy/base_helper.rb +1 -1
  64. data/app/helpers/alchemy/pages_helper.rb +1 -1
  65. data/app/javascript/alchemy_admin/components/action.js +5 -1
  66. data/app/javascript/alchemy_admin/components/color_select.js +73 -0
  67. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +11 -3
  68. data/app/javascript/alchemy_admin/components/element_editor/publish_element_button.js +7 -2
  69. data/app/javascript/alchemy_admin/components/element_editor.js +11 -12
  70. data/app/javascript/alchemy_admin/components/element_select.js +39 -17
  71. data/app/javascript/alchemy_admin/components/elements_window.js +0 -2
  72. data/app/javascript/alchemy_admin/components/file_editor.js +26 -0
  73. data/app/javascript/alchemy_admin/components/index.js +9 -0
  74. data/app/javascript/alchemy_admin/components/list_filter.js +57 -8
  75. data/app/javascript/alchemy_admin/components/message.js +9 -3
  76. data/app/javascript/alchemy_admin/components/page_node.js +119 -0
  77. data/app/javascript/alchemy_admin/{page_publication_fields.js → components/page_publication_fields.js} +9 -8
  78. data/app/javascript/alchemy_admin/{picture_editors.js → components/picture_editor.js} +30 -45
  79. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +107 -0
  80. data/app/javascript/alchemy_admin/components/publish_page_button.js +41 -0
  81. data/app/javascript/alchemy_admin/components/select.js +3 -1
  82. data/app/javascript/alchemy_admin/components/sitemap.js +210 -0
  83. data/app/javascript/alchemy_admin/{sortable_elements.js → components/sortable_elements.js} +22 -25
  84. data/app/javascript/alchemy_admin/components/tinymce.js +10 -5
  85. data/app/javascript/alchemy_admin/components/uploader.js +30 -0
  86. data/app/javascript/alchemy_admin/image_overlay.js +0 -2
  87. data/app/javascript/alchemy_admin/initializer.js +0 -3
  88. data/app/javascript/alchemy_admin/link_dialog.js +1 -6
  89. data/app/javascript/alchemy_admin/templates/compiled.js +1 -1
  90. data/app/javascript/alchemy_admin/utils/ajax.js +15 -3
  91. data/app/javascript/alchemy_admin.js +0 -6
  92. data/app/models/alchemy/attachment.rb +4 -4
  93. data/app/models/alchemy/element/definitions.rb +1 -2
  94. data/app/models/alchemy/element/element_ingredients.rb +6 -2
  95. data/app/models/alchemy/element.rb +54 -13
  96. data/app/models/alchemy/element_definition.rb +4 -1
  97. data/app/models/alchemy/elements_repository.rb +6 -0
  98. data/app/models/alchemy/folded_page.rb +2 -2
  99. data/app/models/alchemy/ingredient.rb +38 -1
  100. data/app/models/alchemy/ingredient_definition.rb +4 -1
  101. data/app/models/alchemy/ingredient_validator.rb +6 -2
  102. data/app/models/alchemy/ingredients/color.rb +10 -0
  103. data/app/models/alchemy/ingredients/headline.rb +2 -17
  104. data/app/models/alchemy/ingredients/picture.rb +4 -4
  105. data/app/models/alchemy/ingredients/select.rb +19 -0
  106. data/app/models/alchemy/language/code.rb +0 -1
  107. data/app/models/alchemy/node.rb +28 -1
  108. data/app/models/alchemy/page/page_naming.rb +0 -7
  109. data/app/models/alchemy/page/page_natures.rb +7 -3
  110. data/app/models/alchemy/page/page_scopes.rb +13 -1
  111. data/app/models/alchemy/page/publisher.rb +14 -2
  112. data/app/models/alchemy/page.rb +102 -23
  113. data/app/models/alchemy/page_definition.rb +4 -1
  114. data/app/models/alchemy/page_version.rb +22 -6
  115. data/app/models/alchemy/picture.rb +10 -11
  116. data/app/models/alchemy/picture_variant.rb +1 -3
  117. data/app/models/alchemy/resource.rb +1 -1
  118. data/app/models/alchemy/storage_adapter/active_storage.rb +14 -2
  119. data/app/models/alchemy/storage_adapter/dragonfly.rb +12 -0
  120. data/app/models/alchemy/storage_adapter.rb +2 -0
  121. data/app/models/concerns/alchemy/picture_thumbnails.rb +4 -4
  122. data/app/models/concerns/alchemy/publishable.rb +54 -0
  123. data/app/serializers/alchemy/page_tree_serializer.rb +11 -31
  124. data/app/services/alchemy/copy_page.rb +17 -0
  125. data/app/services/alchemy/duplicate_element.rb +1 -1
  126. data/app/services/alchemy/page_tree_preloader.rb +105 -0
  127. data/app/stylesheets/alchemy/_extends.scss +3 -9
  128. data/app/stylesheets/alchemy/_mixins.scss +3 -1
  129. data/app/stylesheets/alchemy/_themes.scss +19 -10
  130. data/app/stylesheets/alchemy/admin/archive.scss +1 -0
  131. data/app/stylesheets/alchemy/admin/base.scss +5 -2
  132. data/app/stylesheets/alchemy/admin/buttons.scss +3 -3
  133. data/app/stylesheets/alchemy/admin/element-select.scss +18 -0
  134. data/app/stylesheets/alchemy/admin/elements.scss +123 -23
  135. data/app/stylesheets/alchemy/admin/errors.scss +1 -1
  136. data/app/stylesheets/alchemy/admin/flash.scss +6 -4
  137. data/app/stylesheets/alchemy/admin/images.scss +9 -5
  138. data/app/stylesheets/alchemy/admin/list_filter.scss +4 -4
  139. data/app/stylesheets/alchemy/admin/navigation.scss +1 -1
  140. data/app/stylesheets/alchemy/admin/notices.scss +1 -2
  141. data/app/stylesheets/alchemy/admin/selects.scss +36 -21
  142. data/app/stylesheets/alchemy/admin/shoelace.scss +14 -1
  143. data/app/stylesheets/alchemy/admin/sitemap.scss +11 -3
  144. data/app/stylesheets/alchemy/admin/tags.scss +3 -1
  145. data/app/stylesheets/alchemy/admin/toolbar.scss +1 -1
  146. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  147. data/app/views/alchemy/_menubar.html.erb +1 -1
  148. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +35 -31
  149. data/app/views/alchemy/admin/attachments/_library_sidebar.html.erb +6 -0
  150. data/app/views/alchemy/admin/attachments/_overlay_file_list.html.erb +1 -1
  151. data/app/views/alchemy/admin/attachments/_replace_button.html.erb +1 -8
  152. data/app/views/alchemy/admin/attachments/_sorting_select.html.erb +13 -0
  153. data/app/views/alchemy/admin/attachments/_tag_list.html.erb +2 -3
  154. data/app/views/alchemy/admin/attachments/index.html.erb +5 -11
  155. data/app/views/alchemy/admin/attachments/show.html.erb +1 -1
  156. data/app/views/alchemy/admin/clipboard/_button.html.erb +1 -0
  157. data/app/views/alchemy/admin/clipboard/index.html.erb +4 -5
  158. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  159. data/app/views/alchemy/admin/crop.html.erb +5 -7
  160. data/app/views/alchemy/admin/dashboard/widgets/_locked_pages.html.erb +1 -1
  161. data/app/views/alchemy/admin/elements/_add_nested_element_form.html.erb +6 -6
  162. data/app/views/alchemy/admin/elements/_fixed_element.html.erb +1 -1
  163. data/app/views/alchemy/admin/elements/_footer.html.erb +7 -1
  164. data/app/views/alchemy/admin/elements/_header.html.erb +5 -5
  165. data/app/views/alchemy/admin/elements/_toolbar.html.erb +33 -8
  166. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +10 -10
  167. data/app/views/alchemy/admin/elements/index.html.erb +29 -16
  168. data/app/views/alchemy/admin/elements/new.html.erb +2 -2
  169. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +3 -5
  170. data/app/views/alchemy/admin/leave.html.erb +1 -1
  171. data/app/views/alchemy/admin/nodes/_node.html.erb +19 -0
  172. data/app/views/alchemy/admin/nodes/edit.html.erb +1 -1
  173. data/app/views/alchemy/admin/nodes/index.html.erb +3 -1
  174. data/app/views/alchemy/admin/nodes/new.html.erb +14 -1
  175. data/app/views/alchemy/admin/pages/_current_page.html.erb +3 -1
  176. data/app/views/alchemy/admin/pages/_form.html.erb +21 -9
  177. data/app/views/alchemy/admin/pages/_page_status.html.erb +1 -1
  178. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +28 -26
  179. data/app/views/alchemy/admin/pages/_table.html.erb +0 -7
  180. data/app/views/alchemy/admin/pages/_toolbar.html.erb +3 -6
  181. data/app/views/alchemy/admin/pages/edit.html.erb +5 -11
  182. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +2 -0
  183. data/app/views/alchemy/admin/pages/fold.turbo_stream.erb +5 -0
  184. data/app/views/alchemy/admin/pages/index.html.erb +5 -3
  185. data/app/views/alchemy/admin/pages/new.html.erb +2 -12
  186. data/app/views/alchemy/admin/pages/publish.turbo_stream.erb +12 -0
  187. data/app/views/alchemy/admin/pages/tree.html.erb +13 -0
  188. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +5 -16
  189. data/app/views/alchemy/admin/partials/_flash_notices.html.erb +1 -1
  190. data/app/views/alchemy/admin/partials/{_remote_search_form.html.erb → _overlay_search_form.html.erb} +1 -2
  191. data/app/views/alchemy/admin/partials/_paste_from_clipboard_form.html.erb +12 -0
  192. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +24 -21
  193. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +18 -26
  194. data/app/views/alchemy/admin/pictures/_picture.html.erb +11 -15
  195. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -6
  196. data/app/views/alchemy/admin/pictures/_tag_list.html.erb +2 -3
  197. data/app/views/alchemy/admin/pictures/index.html.erb +0 -1
  198. data/app/views/alchemy/admin/pictures/update.turbo_stream.erb +1 -1
  199. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +1 -1
  200. data/app/views/alchemy/admin/resources/_tag_list.html.erb +2 -3
  201. data/app/views/alchemy/admin/styleguide/index.html.erb +25 -20
  202. data/app/views/alchemy/admin/tags/edit.html.erb +1 -1
  203. data/app/views/alchemy/admin/tinymce/_setup.html.erb +2 -2
  204. data/app/views/alchemy/admin/uploader/_button.html.erb +1 -15
  205. data/app/views/alchemy/attachments/show.html.erb +1 -1
  206. data/app/views/alchemy/base/permission_denied.js.erb +1 -1
  207. data/app/views/alchemy/ingredients/shared/_anchor.html.erb +9 -7
  208. data/app/views/alchemy/ingredients/shared/_link_tools.html.erb +12 -5
  209. data/app/views/alchemy/ingredients/shared/_picture_tools.html.erb +10 -11
  210. data/app/views/alchemy/language_links/_spacer.html.erb +1 -1
  211. data/app/views/alchemy/messages_mailer/new.html.erb +1 -1
  212. data/app/views/alchemy/welcome.html.erb +1 -1
  213. data/config/locales/alchemy.en.yml +12 -3
  214. data/config/routes.rb +2 -2
  215. data/db/migrate/20230123112425_add_searchable_to_alchemy_pages.rb +1 -1
  216. data/db/migrate/20230505132743_add_indexes_to_alchemy_pictures.rb +1 -1
  217. data/db/migrate/20231113104432_create_page_mutexes.rb +1 -1
  218. data/db/migrate/20240314105244_create_alchemy_picture_descriptions.rb +1 -1
  219. data/db/migrate/20250626160259_add_unique_index_to_picture_descriptions.rb +1 -1
  220. data/db/migrate/20250905140323_add_created_at_index_to_pictures_and_attachments.rb +1 -1
  221. data/db/migrate/20251106150010_convert_select_value_for_multiple.rb +11 -0
  222. data/db/migrate/20260102121232_add_metadata_to_page_versions.rb +9 -0
  223. data/db/migrate/20260115164704_add_publication_timestamps_to_alchemy_elements.rb +30 -0
  224. data/db/migrate/20260115164705_add_index_to_element_publication_timestamps.rb +13 -0
  225. data/lib/alchemy/ability_helper.rb +1 -3
  226. data/lib/alchemy/auth_accessors.rb +51 -117
  227. data/lib/alchemy/configuration.rb +1 -0
  228. data/lib/alchemy/configurations/main.rb +63 -0
  229. data/lib/alchemy/controller_actions.rb +2 -3
  230. data/lib/alchemy/engine.rb +9 -12
  231. data/lib/alchemy/error_tracking/error_logger.rb +1 -1
  232. data/lib/alchemy/errors.rb +1 -1
  233. data/lib/alchemy/logger.rb +34 -4
  234. data/lib/alchemy/name_conversions.rb +0 -6
  235. data/lib/alchemy/seeder.rb +2 -2
  236. data/lib/alchemy/tasks/usage.rb +4 -4
  237. data/lib/alchemy/test_support/factories/page_version_factory.rb +3 -0
  238. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +30 -0
  239. data/lib/alchemy/test_support/shared_ingredient_editor_examples.rb +26 -6
  240. data/lib/alchemy/test_support/shared_publishable_examples.rb +114 -0
  241. data/lib/alchemy/upgrader/eight_one.rb +56 -0
  242. data/lib/alchemy/upgrader.rb +9 -1
  243. data/lib/alchemy/version.rb +1 -1
  244. data/lib/alchemy.rb +1 -4
  245. data/lib/alchemy_cms.rb +0 -1
  246. data/lib/generators/alchemy/elements/templates/view.html.erb +3 -3
  247. data/lib/generators/alchemy/ingredient/ingredient_generator.rb +6 -8
  248. data/lib/generators/alchemy/ingredient/templates/editor_component.rb.tt +22 -0
  249. data/lib/generators/alchemy/page_layouts/templates/layout.html.erb +1 -1
  250. data/lib/generators/alchemy/site_layouts/templates/layout.html.erb +1 -1
  251. data/lib/tasks/alchemy/upgrade.rake +21 -7
  252. data/vendor/javascript/shoelace.min.js +713 -31
  253. data/vendor/javascript/tinymce.min.js +1 -1
  254. metadata +104 -84
  255. data/app/decorators/alchemy/element_editor.rb +0 -90
  256. data/app/helpers/alchemy/admin/pictures_helper.rb +0 -14
  257. data/app/javascript/alchemy_admin/file_editors.js +0 -28
  258. data/app/javascript/alchemy_admin/image_loader.js +0 -54
  259. data/app/javascript/alchemy_admin/page_sorter.js +0 -71
  260. data/app/javascript/alchemy_admin/sitemap.js +0 -154
  261. data/app/javascript/alchemy_admin/templates/page_folder.hbs +0 -3
  262. data/app/views/alchemy/admin/attachments/archive_overlay.js.erb +0 -4
  263. data/app/views/alchemy/admin/pages/_page.html.erb +0 -163
  264. data/app/views/alchemy/admin/pages/_sitemap.html.erb +0 -30
  265. data/app/views/alchemy/admin/pages/flush.js.erb +0 -2
  266. data/app/views/alchemy/admin/pictures/archive_overlay.js.erb +0 -5
  267. data/app/views/alchemy/admin/pictures/index.js.erb +0 -2
  268. data/app/views/alchemy/ingredients/_audio_editor.html.erb +0 -5
  269. data/app/views/alchemy/ingredients/_boolean_editor.html.erb +0 -11
  270. data/app/views/alchemy/ingredients/_datetime_editor.html.erb +0 -20
  271. data/app/views/alchemy/ingredients/_file_editor.html.erb +0 -52
  272. data/app/views/alchemy/ingredients/_headline_editor.html.erb +0 -44
  273. data/app/views/alchemy/ingredients/_html_editor.html.erb +0 -8
  274. data/app/views/alchemy/ingredients/_link_editor.html.erb +0 -30
  275. data/app/views/alchemy/ingredients/_node_editor.html.erb +0 -13
  276. data/app/views/alchemy/ingredients/_number_editor.html.erb +0 -24
  277. data/app/views/alchemy/ingredients/_page_editor.html.erb +0 -13
  278. data/app/views/alchemy/ingredients/_picture_editor.html.erb +0 -59
  279. data/app/views/alchemy/ingredients/_richtext_editor.html.erb +0 -15
  280. data/app/views/alchemy/ingredients/_select_editor.html.erb +0 -31
  281. data/app/views/alchemy/ingredients/_text_editor.html.erb +0 -29
  282. data/app/views/alchemy/ingredients/_video_editor.html.erb +0 -5
  283. data/lib/generators/alchemy/ingredient/templates/editor.html.erb +0 -14
  284. /data/{lib → app/models}/alchemy/permissions.rb +0 -0
@@ -2,32 +2,28 @@ import debounce from "alchemy_admin/utils/debounce"
2
2
  import max from "alchemy_admin/utils/max"
3
3
  import { get } from "alchemy_admin/utils/ajax"
4
4
  import { growl } from "alchemy_admin/growler"
5
- import ImageLoader from "alchemy_admin/image_loader"
6
5
 
7
6
  const UPDATE_DELAY = 125
8
7
  const IMAGE_PLACEHOLDER = '<alchemy-icon name="image" size="xl"></alchemy-icon>'
9
8
  const THUMBNAIL_SIZE = "160x120"
10
9
 
11
- export class PictureEditor {
12
- constructor(container) {
13
- this.container = container
14
- this.cropFromField = container.querySelector("[data-crop-from]")
15
- this.cropSizeField = container.querySelector("[data-crop-size]")
16
- this.pictureIdField = container.querySelector("[data-picture-id]")
17
- this.targetSizeField = container.querySelector("[data-target-size]")
18
- this.imageCropperField = container.querySelector("[data-image-cropper]")
19
- this.image = container.querySelector("img")
20
- this.thumbnailBackground = container.querySelector(".thumbnail_background")
21
- this.deleteButton = container.querySelector(".picture_tool.delete")
22
- this.cropLink = container.querySelector(".crop_link")
10
+ export class PictureEditor extends HTMLElement {
11
+ constructor() {
12
+ super()
13
+
14
+ this.cropFromField = this.querySelector("[data-crop-from]")
15
+ this.cropSizeField = this.querySelector("[data-crop-size]")
16
+ this.pictureIdField = this.querySelector("[data-picture-id]")
17
+ this.targetSizeField = this.querySelector("[data-target-size]")
18
+ this.imageCropperField = this.querySelector("[data-image-cropper]")
19
+ this.image = this.querySelector("img")
20
+ this.pictureThumbnail = this.querySelector("alchemy-picture-thumbnail")
21
+ this.deleteButton = this.querySelector(".picture_tool.delete")
22
+ this.cropLink = this.querySelector(".crop_link")
23
23
 
24
24
  this.targetSize = this.targetSizeField.dataset.targetSize
25
25
  this.pictureId = this.pictureIdField.value
26
26
 
27
- if (this.image) {
28
- this.imageLoader = new ImageLoader(this.image)
29
- }
30
-
31
27
  // The mutation observer is observing multiple fields that all get updated
32
28
  // simultaneously. We only want to update the image once, so we debounce.
33
29
  this.update = debounce(() => {
@@ -35,15 +31,19 @@ export class PictureEditor {
35
31
  this.updateCropLink()
36
32
  }, UPDATE_DELAY)
37
33
 
38
- this.deleteButton.addEventListener("click", this.removeImage.bind(this))
34
+ this.deleteButton?.addEventListener("click", this.removeImage.bind(this))
39
35
  }
40
36
 
41
- observe() {
42
- const observer = new MutationObserver(this.mutationCallback.bind(this))
37
+ connectedCallback() {
38
+ this.observer = new MutationObserver(this.mutationCallback.bind(this))
39
+
40
+ this.observer.observe(this.cropFromField, { attributes: true })
41
+ this.observer.observe(this.cropSizeField, { attributes: true })
42
+ this.observer.observe(this.pictureIdField, { attributes: true })
43
+ }
43
44
 
44
- observer.observe(this.cropFromField, { attributes: true })
45
- observer.observe(this.cropSizeField, { attributes: true })
46
- observer.observe(this.pictureIdField, { attributes: true })
45
+ disconnectedCallback() {
46
+ this.observer.disconnect()
47
47
  }
48
48
 
49
49
  mutationCallback(mutationsList) {
@@ -60,10 +60,7 @@ export class PictureEditor {
60
60
  updateImage() {
61
61
  if (!this.pictureId) return
62
62
 
63
- this.ensureImage()
64
- this.image.removeAttribute("alt")
65
- this.image.removeAttribute("src")
66
- this.imageLoader.load(true)
63
+ this.pictureThumbnail.loading = true
67
64
  get(Alchemy.routes.url_admin_picture_path(this.pictureId), {
68
65
  crop: this.imageCropperEnabled,
69
66
  crop_from: this.cropFrom,
@@ -72,9 +69,9 @@ export class PictureEditor {
72
69
  size: THUMBNAIL_SIZE
73
70
  })
74
71
  .then(({ data }) => {
75
- this.image.src = data.url
76
- this.image.alt = data.alt
77
- this.image.title = data.title
72
+ this.pictureThumbnail.src = data.url
73
+ this.pictureThumbnail.image.alt = data.alt
74
+ this.pictureThumbnail.image.title = data.title
78
75
  this.setElementDirty()
79
76
  })
80
77
  .catch((error) => {
@@ -83,15 +80,8 @@ export class PictureEditor {
83
80
  })
84
81
  }
85
82
 
86
- ensureImage() {
87
- const img = new Image()
88
- this.thumbnailBackground.replaceChildren(img)
89
- this.image = img
90
- this.imageLoader = new ImageLoader(img)
91
- }
92
-
93
83
  removeImage() {
94
- this.thumbnailBackground.innerHTML = IMAGE_PLACEHOLDER
84
+ this.pictureThumbnail.innerHTML = IMAGE_PLACEHOLDER
95
85
  this.pictureIdField.value = ""
96
86
  this.image = null
97
87
  this.cropLink.classList.add("disabled")
@@ -99,7 +89,7 @@ export class PictureEditor {
99
89
  }
100
90
 
101
91
  setElementDirty() {
102
- this.container.closest(".element-editor").setDirty(this.container)
92
+ this.closest(".element-editor").setDirty(this)
103
93
  }
104
94
 
105
95
  updateCropLink() {
@@ -167,9 +157,4 @@ export class PictureEditor {
167
157
  }
168
158
  }
169
159
 
170
- export default function init(selector) {
171
- document.querySelectorAll(selector).forEach((node) => {
172
- const thumbnail = new PictureEditor(node)
173
- thumbnail.observe()
174
- })
175
- }
160
+ customElements.define("alchemy-picture-editor", PictureEditor)
@@ -0,0 +1,107 @@
1
+ // Shows spinner while loading images and
2
+ // fades the image after its been loaded
3
+
4
+ import Spinner from "alchemy_admin/spinner"
5
+
6
+ export default class PictureThumbnail extends HTMLElement {
7
+ constructor() {
8
+ super()
9
+
10
+ this.classList.add("thumbnail_background")
11
+ this.spinner = new Spinner("small")
12
+
13
+ if (this.src) {
14
+ this.start()
15
+ }
16
+ }
17
+
18
+ handleEvent(evt) {
19
+ switch (evt.type) {
20
+ case "load":
21
+ this.#onLoaded()
22
+ break
23
+ case "error":
24
+ this.#onError(evt)
25
+ break
26
+ default:
27
+ break
28
+ }
29
+ }
30
+
31
+ connectedCallback() {
32
+ if (this.image) {
33
+ this.replaceChildren(this.image)
34
+ }
35
+ }
36
+
37
+ disconnectedCallback() {
38
+ this.image?.removeEventListener("load", this)
39
+ this.image?.removeEventListener("error", this)
40
+ this.stop()
41
+ }
42
+
43
+ createImage(src = this.src, alt = this.name) {
44
+ this.image = new Image()
45
+ this.image.src = src
46
+ if (alt) {
47
+ this.image.alt = alt
48
+ }
49
+ this.image.loading = "lazy"
50
+ }
51
+
52
+ start(src) {
53
+ this.createImage(src)
54
+ this.image.addEventListener("load", this)
55
+ this.image.addEventListener("error", this)
56
+ this.load()
57
+ }
58
+
59
+ load() {
60
+ if (this.image?.complete) {
61
+ return
62
+ }
63
+ this.setAttribute("loading", "loading")
64
+ this.innerHTML = ""
65
+ this.spinner.spin(this)
66
+ }
67
+
68
+ stop() {
69
+ this.classList.remove("loading")
70
+ this.spinner.stop()
71
+ }
72
+
73
+ #onLoaded() {
74
+ this.spinner.stop()
75
+ this.removeAttribute("loading")
76
+ }
77
+
78
+ #onError(evt) {
79
+ const message = `Could not load ${this.image.src}`
80
+ this.spinner.stop()
81
+ this.innerHTML = `
82
+ <sl-tooltip content="${message}">
83
+ <alchemy-icon name="alert" class="error"></alchemy-icon>
84
+ </sl-tooltip>
85
+ `
86
+ console.error(message, evt)
87
+ }
88
+
89
+ set loading(value) {
90
+ value ? this.load() : this.stop()
91
+ }
92
+
93
+ set src(src) {
94
+ this.start(src)
95
+ this.replaceChildren(this.image)
96
+ }
97
+
98
+ get name() {
99
+ return this.getAttribute("name")
100
+ }
101
+
102
+ get src() {
103
+ return this.getAttribute("src")
104
+ }
105
+ }
106
+
107
+ customElements.define("alchemy-picture-thumbnail", PictureThumbnail)
@@ -0,0 +1,41 @@
1
+ class PublishPageButton extends HTMLElement {
2
+ constructor() {
3
+ super()
4
+ this.addEventListener("submit", this)
5
+ }
6
+
7
+ connectedCallback() {
8
+ document.addEventListener("alchemy:page-dirty", this)
9
+ }
10
+
11
+ disconnectedCallback() {
12
+ document.removeEventListener("alchemy:page-dirty", this)
13
+ }
14
+
15
+ handleEvent(event) {
16
+ switch (event.type) {
17
+ case "alchemy:page-dirty":
18
+ this.markDirty(event.detail)
19
+ break
20
+ case "submit":
21
+ this.button.loading = true
22
+ break
23
+ }
24
+ }
25
+
26
+ markDirty(detail) {
27
+ this.button.variant = "primary"
28
+ this.button.disabled = false
29
+ this.tooltip.content = detail.tooltip
30
+ }
31
+
32
+ get button() {
33
+ return this.querySelector("sl-button")
34
+ }
35
+
36
+ get tooltip() {
37
+ return this.querySelector("sl-tooltip")
38
+ }
39
+ }
40
+
41
+ customElements.define("alchemy-publish-page-button", PublishPageButton)
@@ -10,7 +10,9 @@ class Select extends HTMLSelectElement {
10
10
  allowClear: !!this.allowClear
11
11
  })
12
12
 
13
- if (!this.allowClear) {
13
+ // For single selects, remove the close button if allowClear is not set
14
+ // For multiple selects, always keep the close buttons
15
+ if (!this.allowClear && !this.multiple) {
14
16
  this.#select2Element
15
17
  .prev(".select2-container")
16
18
  .find(".select2-search-choice-close")
@@ -0,0 +1,210 @@
1
+ import Sortable from "sortablejs"
2
+ import { growl } from "alchemy_admin/growler"
3
+ import { patch } from "alchemy_admin/utils/ajax"
4
+ import { translate } from "alchemy_admin/i18n"
5
+ import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
6
+
7
+ /**
8
+ * Custom element for the sitemap container
9
+ * Handles search/filter functionality and drag-and-drop sorting
10
+ */
11
+ export class AlchemySitemap extends HTMLElement {
12
+ connectedCallback() {
13
+ this.searchInput = document.querySelector(".search_input_field")
14
+ this.clearButton = document.querySelector("#search_field_clear")
15
+ this.resultCounter = document.querySelector("#page_filter_result")
16
+
17
+ this.setupSearch()
18
+
19
+ // Wait for child custom elements to be defined before setting up sortables
20
+ requestAnimationFrame(() => {
21
+ this.setupSortables()
22
+ })
23
+
24
+ // Set up MutationObserver to re-initialize sortables when children containers are added
25
+ this.observer = new MutationObserver((mutations) => {
26
+ mutations.forEach((mutation) => {
27
+ mutation.addedNodes.forEach((node) => {
28
+ if (node.nodeType !== Node.ELEMENT_NODE) return
29
+
30
+ // If the added node itself is a children container, initialize it
31
+ if (node.classList?.contains("children")) {
32
+ this.setupSortable(node)
33
+ }
34
+
35
+ // Also check for children containers nested within the added node
36
+ // This handles cases where a parent element with nested children is added at once
37
+ node
38
+ .querySelectorAll(".children")
39
+ .forEach((el) => this.setupSortable(el))
40
+ })
41
+ })
42
+ })
43
+
44
+ // Observe the sitemap for added nodes
45
+ this.observer.observe(this, {
46
+ childList: true,
47
+ subtree: true
48
+ })
49
+ }
50
+
51
+ disconnectedCallback() {
52
+ this.teardownSearch()
53
+ this.observer?.disconnect()
54
+ }
55
+
56
+ setupSearch() {
57
+ this.searchInput?.addEventListener("input", this)
58
+ this.clearButton?.addEventListener("click", this)
59
+ }
60
+
61
+ teardownSearch() {
62
+ this.searchInput?.removeEventListener("input", this)
63
+ this.clearButton?.removeEventListener("click", this)
64
+ }
65
+
66
+ handleEvent(event) {
67
+ if (event.type === "input" && event.target === this.searchInput) {
68
+ this.handleSearch(event)
69
+ } else if (event.type === "click" && event.target === this.clearButton) {
70
+ this.handleClearSearch(event)
71
+ }
72
+ }
73
+
74
+ handleSearch(event) {
75
+ const term = event.target.value.toLowerCase().trim()
76
+
77
+ if (term === "") {
78
+ this.clearFilter()
79
+ return
80
+ }
81
+
82
+ this.filterPages(term)
83
+ }
84
+
85
+ filterPages(term) {
86
+ const allPages = this.querySelectorAll(".sitemap_page")
87
+ let matchCount = 0
88
+ let firstMatch = null
89
+
90
+ allPages.forEach((pageElement) => {
91
+ const pageName = pageElement.getAttribute("name") || ""
92
+
93
+ if (pageName.toLowerCase().includes(term)) {
94
+ pageElement.classList.add("highlight")
95
+ pageElement.classList.remove("no-match")
96
+ matchCount++
97
+ if (!firstMatch) firstMatch = pageElement
98
+ } else {
99
+ pageElement.classList.remove("highlight")
100
+ pageElement.classList.add("no-match")
101
+ }
102
+ })
103
+
104
+ // Update result counter
105
+
106
+ if (matchCount === 1) {
107
+ this.resultCounter.textContent = `1 ${translate("page_found")}`
108
+ this.resultCounter.style.display = "block"
109
+ } else if (matchCount > 1) {
110
+ this.resultCounter.textContent = `${matchCount} ${translate("pages_found")}`
111
+ this.resultCounter.style.display = "block"
112
+ } else {
113
+ this.resultCounter.style.display = "none"
114
+ }
115
+
116
+ // Scroll first match into view
117
+ if (firstMatch) {
118
+ firstMatch.scrollIntoView({ behavior: "smooth", block: "center" })
119
+ }
120
+ }
121
+
122
+ clearFilter() {
123
+ const allPages = this.querySelectorAll(".sitemap_page")
124
+ allPages.forEach((pageElement) => {
125
+ pageElement.classList.remove("highlight", "no-match")
126
+ })
127
+
128
+ this.resultCounter.style.display = "none"
129
+ }
130
+
131
+ handleClearSearch(event) {
132
+ event.preventDefault()
133
+ this.searchInput.value = ""
134
+ this.clearFilter()
135
+ }
136
+
137
+ setupSortable(container) {
138
+ new Sortable(container, {
139
+ group: "pages",
140
+ animation: 150,
141
+ fallbackOnBody: true,
142
+ swapThreshold: 0.65,
143
+ handle: ".page-icon.handle",
144
+ draggable: "alchemy-page-node",
145
+ onEnd: (evt) => this.handleSort(evt)
146
+ })
147
+ }
148
+
149
+ setupSortables() {
150
+ const sortables = this.querySelectorAll(".children")
151
+ sortables.forEach((el) => this.setupSortable(el))
152
+ }
153
+
154
+ async handleSort(evt) {
155
+ // Only proceed if actually moved to different position/container
156
+ if (evt.from === evt.to && evt.oldIndex === evt.newIndex) {
157
+ return
158
+ }
159
+
160
+ // evt.item is the <alchemy-page-node> element being dragged
161
+ const pageNode = evt.item
162
+ const pageId = pageNode.pageId
163
+ const url = Alchemy.routes.move_admin_page_path(pageId)
164
+ const data = {
165
+ target_parent_id: evt.to.dataset.parentId,
166
+ new_position: evt.newIndex
167
+ }
168
+
169
+ pleaseWaitOverlay(true)
170
+
171
+ try {
172
+ const response = await patch(url, data)
173
+ const pageData = await response.data
174
+
175
+ // Update the URL path of the moved page
176
+ const pageEl = pageNode.querySelector(`#page_${pageId}`)
177
+ if (pageEl) {
178
+ const urlPathEl = pageEl.querySelector(".sitemap_url")
179
+ if (urlPathEl && pageData.url_path) {
180
+ urlPathEl.textContent = pageData.url_path
181
+ }
182
+ }
183
+
184
+ // Update folder icons for affected parent pages
185
+ this.updateFolderIcons(evt.from, evt.to)
186
+
187
+ growl(translate("Successfully moved page"))
188
+ } catch (error) {
189
+ growl(error.message || error, "error")
190
+ // Revert the DOM change by reloading on error
191
+ window.location.reload()
192
+ } finally {
193
+ pleaseWaitOverlay(false)
194
+ }
195
+ }
196
+
197
+ updateFolderIcons(fromContainer, toContainer) {
198
+ // Update folder icon for source parent (might now have no children)
199
+ const fromParent = fromContainer.closest("alchemy-page-node")
200
+ fromParent?.updateFolderButton()
201
+
202
+ // Update folder icon for destination parent (now definitely has children)
203
+ if (fromContainer !== toContainer) {
204
+ const toParent = toContainer.closest("alchemy-page-node")
205
+ toParent?.updateFolderButton()
206
+ }
207
+ }
208
+ }
209
+
210
+ customElements.define("alchemy-sitemap", AlchemySitemap)
@@ -2,10 +2,11 @@ import Sortable from "sortablejs"
2
2
  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
+ import { dispatchPageDirtyEvent } from "alchemy_admin/components/element_editor"
5
6
 
6
7
  const SORTABLE_OPTIONS = {
7
8
  draggable: ".element-editor",
8
- handle: ".element-handle",
9
+ handle: ".element-handle.draggable",
9
10
  ghostClass: "dragged",
10
11
  animation: 150,
11
12
  swapThreshold: 0.65,
@@ -38,6 +39,9 @@ function onSort(event) {
38
39
  post(Alchemy.routes.order_admin_elements_path, params).then((response) => {
39
40
  const data = response.data
40
41
  growl(data.message)
42
+ if (data.pageHasUnpublishedChanges) {
43
+ dispatchPageDirtyEvent(data)
44
+ }
41
45
  reloadPreview()
42
46
  item.updateTitle(data.preview_text)
43
47
  })
@@ -51,31 +55,24 @@ function onEnd() {
51
55
  )
52
56
  }
53
57
 
54
- function createSortable(element, options = {}) {
55
- const group = {
56
- name: element.dataset.elementName,
57
- put(to, _from, item) {
58
- return to.el.dataset.droppableElements
59
- .split(" ")
60
- .includes(item.dataset.elementName)
58
+ class SortableElements extends HTMLElement {
59
+ connectedCallback() {
60
+ const group = {
61
+ name: this.dataset.elementName,
62
+ put(to, _from, item) {
63
+ return to.el.dataset.droppableElements
64
+ .split(" ")
65
+ .includes(item.dataset.elementName)
66
+ }
61
67
  }
68
+ new Sortable(this, {
69
+ ...SORTABLE_OPTIONS,
70
+ onStart,
71
+ onSort,
72
+ onEnd,
73
+ group
74
+ })
62
75
  }
63
- new Sortable(element, {
64
- ...SORTABLE_OPTIONS,
65
- ...options,
66
- onStart,
67
- onSort,
68
- onEnd,
69
- group
70
- })
71
76
  }
72
77
 
73
- export default function SortableElements(selector) {
74
- if (selector == null) selector = ".sortable-elements"
75
-
76
- const sortable_areas = document.querySelectorAll(selector, {
77
- direction: "vertical"
78
- })
79
-
80
- sortable_areas.forEach((element) => createSortable(element))
81
- }
78
+ customElements.define("alchemy-sortable-elements", SortableElements)
@@ -162,11 +162,16 @@ class Tinymce extends AlchemyHTMLElement {
162
162
  const config = this.getAttribute(attributeName)
163
163
  const key = attributeName.replaceAll("-", "_")
164
164
 
165
- try {
166
- customConfig[key] = JSON.parse(config)
167
- } catch (e) {
168
- // also string values as parameter
169
- customConfig[key] = config
165
+ // Handle boolean HTML attributes (e.g., readonly="readonly" or readonly="")
166
+ if (config === attributeName || config === "") {
167
+ customConfig[key] = true
168
+ } else {
169
+ try {
170
+ customConfig[key] = JSON.parse(config)
171
+ } catch (e) {
172
+ // also string values as parameter
173
+ customConfig[key] = config
174
+ }
170
175
  }
171
176
  }
172
177
  })
@@ -21,6 +21,32 @@ export class Uploader extends AlchemyHTMLElement {
21
21
  if (this.dropzone) {
22
22
  this._dragAndDropBehavior()
23
23
  }
24
+ this.addEventListener("Alchemy.upload.successful", this)
25
+ }
26
+
27
+ handleEvent(evt) {
28
+ switch (evt.type) {
29
+ case "Alchemy.upload.successful":
30
+ this._handleUploadComplete()
31
+ break
32
+ }
33
+ }
34
+
35
+ _handleUploadComplete() {
36
+ setTimeout(() => {
37
+ const url = this.redirectUrl
38
+ const turboFrame = this.closest("turbo-frame")
39
+ this.uploadProgress.visible = false
40
+
41
+ if (!url) return
42
+
43
+ if (turboFrame) {
44
+ turboFrame.setAttribute("src", url)
45
+ turboFrame.reload()
46
+ } else {
47
+ Turbo.visit(url)
48
+ }
49
+ }, 750)
24
50
  }
25
51
 
26
52
  /**
@@ -126,6 +152,10 @@ export class Uploader extends AlchemyHTMLElement {
126
152
  get fileInput() {
127
153
  return this.querySelector("input[type='file']")
128
154
  }
155
+
156
+ get redirectUrl() {
157
+ return this.getAttribute("redirect-url")
158
+ }
129
159
  }
130
160
 
131
161
  customElements.define("alchemy-uploader", Uploader)
@@ -1,4 +1,3 @@
1
- import ImageLoader from "alchemy_admin/image_loader"
2
1
  import { Dialog } from "alchemy_admin/dialog"
3
2
 
4
3
  export default class ImageOverlay extends Dialog {
@@ -7,7 +6,6 @@ export default class ImageOverlay extends Dialog {
7
6
  }
8
7
 
9
8
  init() {
10
- ImageLoader.init(this.dialog_body[0])
11
9
  $(".zoomed-picture-background").on("click", (e) => {
12
10
  e.stopPropagation()
13
11
  if (e.target.nodeName === "IMG") {
@@ -45,9 +45,6 @@ export default function Initializer() {
45
45
  $(this.form).submit()
46
46
  })
47
47
 
48
- // Attaches the image loader on all images
49
- Alchemy.ImageLoader("#main_content")
50
-
51
48
  // Override the filter of keymaster.js so we can blur the fields on esc key.
52
49
  key.filter = function (event) {
53
50
  let tagName = (event.target || event.srcElement).tagName
@@ -1,11 +1,6 @@
1
1
  import { translate } from "alchemy_admin/i18n"
2
2
  import { Dialog } from "alchemy_admin/dialog"
3
3
 
4
- // Matches a URL fragment (#anchor) at the end of a string.
5
- // Covers RFC 3986 unreserved characters (ALPHA, DIGIT, "-", ".", "_", "~")
6
- // which are the characters valid in URL fragments and common in DOM element IDs.
7
- const ANCHOR_REGEX = /#[\w.~-]+$/
8
-
9
4
  // Represents the link Dialog that appears, if a user clicks the link buttons
10
5
  // in TinyMCE or on an Ingredient that has links enabled (e.g. Picture)
11
6
  //
@@ -108,7 +103,7 @@ export class LinkDialog extends Dialog {
108
103
 
109
104
  if (linkType === "internal" && elementAnchor.value !== "") {
110
105
  // remove possible fragments on the url and attach the fragment (which contains the #)
111
- url = url.replace(ANCHOR_REGEX, "") + elementAnchor.value
106
+ url = url.replace(/#\w+$/, "") + elementAnchor.value
112
107
  } else if (linkType === "external" && !url.match(Alchemy.link_url_regexp)) {
113
108
  // show validation error and prevent link creation
114
109
  this.#showValidationError()