alchemy_cms 8.1.12 → 8.2.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -1
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  5. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  6. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  7. data/app/assets/builds/alchemy/light-theme.css +1 -1
  8. data/app/assets/builds/alchemy/theme.css +1 -1
  9. data/app/assets/builds/alchemy/welcome.css +1 -1
  10. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  11. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  12. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  13. data/app/components/alchemy/admin/current_user_name.rb +34 -0
  14. data/app/components/alchemy/admin/locale_select.rb +12 -8
  15. data/app/components/alchemy/admin/page_node.html.erb +3 -2
  16. data/app/components/alchemy/admin/picture_thumbnail.rb +1 -1
  17. data/app/components/alchemy/admin/preview_time_select.rb +55 -0
  18. data/app/components/alchemy/admin/publish_element_button.html.erb +41 -0
  19. data/app/components/alchemy/admin/publish_element_button.rb +13 -0
  20. data/app/components/alchemy/admin/timezone_select.rb +47 -0
  21. data/app/components/alchemy/ingredients/select_editor.rb +6 -1
  22. data/app/controllers/alchemy/admin/attachments_controller.rb +0 -9
  23. data/app/controllers/alchemy/admin/base_controller.rb +1 -0
  24. data/app/controllers/alchemy/admin/elements_controller.rb +54 -34
  25. data/app/controllers/alchemy/admin/pages_controller.rb +1 -0
  26. data/app/controllers/alchemy/admin/pictures_controller.rb +2 -23
  27. data/app/controllers/alchemy/admin/resources_controller.rb +11 -6
  28. data/app/controllers/alchemy/pages_controller.rb +1 -2
  29. data/app/helpers/alchemy/admin/base_helper.rb +4 -7
  30. data/app/helpers/alchemy/url_helper.rb +2 -10
  31. data/app/javascript/alchemy_admin/components/element_editor/publish_element_button.js +28 -27
  32. data/app/javascript/alchemy_admin/components/element_editor.js +11 -2
  33. data/app/javascript/alchemy_admin/components/message.js +5 -1
  34. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +1 -0
  35. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +5 -5
  36. data/app/javascript/alchemy_admin/image_cropper.js +10 -6
  37. data/app/javascript/alchemy_admin/initializer.js +6 -33
  38. data/app/javascript/alchemy_admin/shoelace_theme.js +6 -2
  39. data/app/javascript/alchemy_admin/templates/compiled.js +1 -1
  40. data/app/javascript/alchemy_admin.js +12 -2
  41. data/app/models/alchemy/attachment.rb +1 -33
  42. data/app/models/alchemy/current.rb +5 -1
  43. data/app/models/alchemy/element/element_ingredients.rb +11 -3
  44. data/app/models/alchemy/element.rb +10 -0
  45. data/app/models/alchemy/ingredient.rb +2 -0
  46. data/app/models/alchemy/ingredients/select.rb +1 -2
  47. data/app/models/alchemy/page/etag_generator.rb +21 -0
  48. data/app/models/alchemy/page/url_path.rb +11 -2
  49. data/app/models/alchemy/page.rb +12 -2
  50. data/app/models/alchemy/page_version.rb +5 -5
  51. data/app/models/alchemy/picture.rb +19 -2
  52. data/app/models/alchemy/storage_adapter/active_storage.rb +9 -0
  53. data/app/models/alchemy/storage_adapter/dragonfly.rb +9 -0
  54. data/app/models/alchemy/storage_adapter.rb +1 -0
  55. data/app/models/concerns/alchemy/publishable.rb +20 -12
  56. data/app/models/concerns/alchemy/relatable_resource.rb +19 -15
  57. data/app/models/concerns/alchemy/touch_elements.rb +3 -3
  58. data/app/services/alchemy/element_preloader.rb +107 -0
  59. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  60. data/app/stylesheets/alchemy/_mixins.scss +1 -1
  61. data/app/stylesheets/alchemy/_themes.scss +2 -0
  62. data/app/stylesheets/alchemy/admin/archive.scss +2 -2
  63. data/app/stylesheets/alchemy/admin/base.scss +2 -1
  64. data/app/stylesheets/alchemy/admin/elements.scss +22 -19
  65. data/app/stylesheets/alchemy/admin/form_fields.scss +3 -0
  66. data/app/stylesheets/alchemy/admin/forms.scss +14 -1
  67. data/app/stylesheets/alchemy/admin/frame.scss +9 -8
  68. data/app/stylesheets/alchemy/admin/images.scss +2 -2
  69. data/app/stylesheets/alchemy/admin/notices.scss +1 -10
  70. data/app/stylesheets/alchemy/admin/popover.scss +37 -0
  71. data/app/stylesheets/alchemy/admin/selects.scss +4 -0
  72. data/app/stylesheets/alchemy/admin/shoelace.scss +16 -4
  73. data/app/stylesheets/alchemy/admin/toolbar.scss +8 -0
  74. data/app/stylesheets/alchemy/admin.scss +1 -0
  75. data/app/views/alchemy/admin/_header.html.erb +4 -0
  76. data/app/views/alchemy/admin/_left_menu.html.erb +24 -0
  77. data/app/views/alchemy/admin/_main_navi.html.erb +6 -0
  78. data/app/views/alchemy/admin/_top_menu.html.erb +6 -0
  79. data/app/views/alchemy/admin/_user_info.html.erb +5 -0
  80. data/app/views/alchemy/admin/attachments/_files_list.html.erb +1 -1
  81. data/app/views/alchemy/admin/crop.html.erb +6 -11
  82. data/app/views/alchemy/admin/elements/_header.html.erb +16 -6
  83. data/app/views/alchemy/admin/elements/_schedule.html.erb +62 -0
  84. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -15
  85. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +28 -0
  86. data/app/views/alchemy/admin/nodes/index.html.erb +1 -1
  87. data/app/views/alchemy/admin/pages/_locked_pages.html.erb +5 -0
  88. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +4 -4
  89. data/app/views/alchemy/admin/pages/_table.html.erb +2 -2
  90. data/app/views/alchemy/admin/pages/edit.html.erb +6 -2
  91. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +10 -10
  92. data/app/views/alchemy/admin/partials/_site_select.html.erb +6 -3
  93. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +3 -3
  94. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  95. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +1 -1
  96. data/app/views/alchemy/admin/pictures/index.html.erb +2 -2
  97. data/app/views/alchemy/admin/tinymce/_setup.html.erb +9 -16
  98. data/app/views/alchemy/admin/uploader/_setup.html.erb +1 -6
  99. data/app/views/alchemy/language_links/_language.html.erb +1 -2
  100. data/app/views/layouts/alchemy/admin.html.erb +2 -45
  101. data/config/importmap.rb +7 -2
  102. data/config/locales/alchemy.en.yml +35 -5
  103. data/lib/alchemy/admin/preview_time.rb +23 -0
  104. data/lib/alchemy/admin/preview_url.rb +13 -2
  105. data/lib/alchemy/admin/timezone.rb +56 -0
  106. data/lib/alchemy/configurations/main.rb +13 -1
  107. data/lib/alchemy/test_support/factories/element_factory.rb +2 -2
  108. data/lib/alchemy/test_support/relatable_resource_examples.rb +2 -2
  109. data/lib/alchemy/test_support/shared_publishable_examples.rb +44 -2
  110. data/lib/alchemy/upgrader.rb +3 -1
  111. data/lib/alchemy/version.rb +1 -1
  112. data/lib/alchemy_cms.rb +2 -0
  113. data/lib/generators/alchemy/install/install_generator.rb +2 -1
  114. data/vendor/javascript/handlebars.min.js +4 -4
  115. data/vendor/javascript/shoelace.min.js +1419 -1323
  116. data/vendor/javascript/sortable.min.js +2 -2
  117. data/vendor/javascript/tinymce.min.js +1 -1
  118. metadata +33 -1
@@ -7,14 +7,18 @@ module Alchemy
7
7
 
8
8
  before_action :load_page_and_version, only: [:index, :new]
9
9
  include Alchemy::Admin::Clipboard
10
+ include Alchemy::Admin::PreviewTime
10
11
 
11
12
  before_action :load_element, only: [:update, :destroy, :collapse, :expand, :publish]
12
13
  authorize_resource class: Alchemy::Element
13
14
 
14
15
  def index
15
- elements = @page_version.elements.order(:position).includes(*element_includes)
16
- @elements = elements.not_nested.unfixed
17
- @fixed_elements = elements.not_nested.fixed
16
+ preloaded = Alchemy::ElementPreloader
17
+ .new(page_version: @page_version)
18
+ .call
19
+
20
+ @elements = preloaded.reject(&:fixed?)
21
+ @fixed_elements = preloaded.select(&:fixed?)
18
22
  end
19
23
 
20
24
  def new
@@ -89,12 +93,23 @@ module Alchemy
89
93
  end
90
94
 
91
95
  def publish
92
- @element.public = !@element.public?
93
- @element.save(validate: false)
94
- render json: {
95
- public: @element.public?,
96
- label: @element.public? ? Alchemy.t(:hide_element) : Alchemy.t(:show_element)
97
- }.merge(pagePublicationData(@element.page))
96
+ if schedule_element_params.present?
97
+ @element.assign_attributes(schedule_element_params)
98
+ @element.skip_ingredient_validations = true
99
+ @element.save
100
+ status = @element.valid? ? 200 : 422
101
+ else
102
+ @element.public = !@element.public?
103
+ @element.save(validate: false)
104
+ status = 200
105
+ end
106
+
107
+ respond_to do |format|
108
+ format.turbo_stream do
109
+ @page = @element.page
110
+ render status:
111
+ end
112
+ end
98
113
  end
99
114
 
100
115
  def order
@@ -119,12 +134,12 @@ module Alchemy
119
134
  def collapse
120
135
  # We do not want to trigger the touch callback or any validations
121
136
  @element.update_columns(folded: true)
122
- # Collapse all nested elements
123
- nested_elements_ids = collapse_nested_elements_ids(@element)
124
- Alchemy::Element.where(id: nested_elements_ids).update_all(folded: true)
137
+ # Collapse all nested elements (except compact ones which stay as-is)
138
+ nested_element_ids = nested_element_ids_to_collapse(@element)
139
+ Alchemy::Element.where(id: nested_element_ids).update_all(folded: true)
125
140
 
126
141
  render json: {
127
- nestedElementIds: nested_elements_ids,
142
+ nestedElementIds: nested_element_ids,
128
143
  title: Alchemy.t(@element.folded? ? :show_element_content : :hide_element_content)
129
144
  }
130
145
  end
@@ -152,30 +167,31 @@ module Alchemy
152
167
  @page = @page_version.page
153
168
  end
154
169
 
155
- def collapse_nested_elements_ids(element)
170
+ # Collects IDs of nested elements that should be collapsed.
171
+ # Skips compact elements (they retain their fold state).
172
+ # Optimized to use a single query instead of N queries for N levels.
173
+ def nested_element_ids_to_collapse(element)
174
+ all_elements = Element
175
+ .where(page_version_id: element.page_version_id)
176
+ .select(:id, :parent_element_id, :folded, :name)
177
+ .to_a
178
+
179
+ children_by_parent = all_elements.group_by(&:parent_element_id)
180
+
156
181
  ids = []
157
- element.all_nested_elements.includes(:all_nested_elements).reject(&:compact?).each do |nested_element|
158
- ids.push nested_element.id if nested_element.expanded?
159
- ids.concat collapse_nested_elements_ids(nested_element) if nested_element.all_nested_elements.reject(&:compact?).any?
182
+ stack = [element.id]
183
+
184
+ while stack.any?
185
+ current_id = stack.pop
186
+ children = children_by_parent[current_id] || []
187
+
188
+ children.reject(&:compact?).each do |child|
189
+ ids << child.id if child.expanded?
190
+ stack << child.id
191
+ end
160
192
  end
161
- ids
162
- end
163
193
 
164
- def element_includes
165
- [
166
- {
167
- ingredients: :related_object
168
- },
169
- :tags,
170
- {
171
- all_nested_elements: [
172
- {
173
- ingredients: :related_object
174
- },
175
- :tags
176
- ]
177
- }
178
- ]
194
+ ids
179
195
  end
180
196
 
181
197
  def load_element
@@ -216,6 +232,10 @@ module Alchemy
216
232
  def create_element_params
217
233
  params.require(:element).permit(:name, :page_version_id, :parent_element_id)
218
234
  end
235
+
236
+ def schedule_element_params
237
+ params[:element]&.permit(:public_on, :public_until)
238
+ end
219
239
  end
220
240
  end
221
241
  end
@@ -5,6 +5,7 @@ module Alchemy
5
5
  class PagesController < ResourcesController
6
6
  include OnPageLayout::CallbacksRunner
7
7
  include Alchemy::Admin::Clipboard
8
+ include Alchemy::Admin::PreviewTime
8
9
 
9
10
  helper "alchemy/pages"
10
11
 
@@ -8,8 +8,6 @@ module Alchemy
8
8
  include CurrentLanguage
9
9
  include PictureDescriptionsFormHelper
10
10
 
11
- before_action :load_pictures, only: :index
12
-
13
11
  before_action :load_resource,
14
12
  only: [:edit, :update, :url, :destroy]
15
13
 
@@ -21,12 +19,6 @@ module Alchemy
21
19
  @picture = Picture.find(params[:id])
22
20
  end
23
21
 
24
- # Preload deletable ids for the current page (index) or the current
25
- # resource (update, which re-renders the +_picture+ partial via a
26
- # turbo stream). One query replaces the per-row +deletable?+ check
27
- # that would otherwise fire for every picture the view renders.
28
- before_action :load_deletable_picture_ids, only: [:index, :update]
29
-
30
22
  add_alchemy_filter :by_file_format, type: :select, options: ->(query) do
31
23
  Alchemy::Picture.file_formats(query.result)
32
24
  end
@@ -38,6 +30,8 @@ module Alchemy
38
30
  helper_method :picture_offset
39
31
 
40
32
  def index
33
+ @pictures = filtered_pictures(page: params[:page])
34
+
41
35
  if in_overlay?
42
36
  archive_overlay
43
37
  end
@@ -165,21 +159,6 @@ module Alchemy
165
159
 
166
160
  private
167
161
 
168
- def load_pictures
169
- @pictures = filtered_pictures(page: params[:page])
170
- end
171
-
172
- # Preload deletable ids in a single query so the view can decide which
173
- # delete buttons to enable without calling +deletable?+ (a two-query
174
- # check) per row. We pass already-loaded ids (via +map(&:id)+) rather
175
- # than the relation itself, because passing a paginated relation
176
- # produces +IN (SELECT ... LIMIT n)+, which older MariaDB versions
177
- # reject.
178
- def load_deletable_picture_ids
179
- ids = @pictures ? @pictures.map(&:id) : [@picture.id]
180
- @deletable_picture_ids = Picture.where(id: ids).deletable.pluck(:id).to_set
181
- end
182
-
183
162
  def picture_offset
184
163
  ((params[:page] || 1).to_i - 1) * items_per_page
185
164
  end
@@ -18,12 +18,7 @@ module Alchemy
18
18
  before_action :authorize_resource
19
19
 
20
20
  def index
21
- @query = resource_handler.model.ransack(search_filter_params[:q])
22
- @query.sorts = default_sort_order if @query.sorts.empty?
23
- items = @query.result
24
-
25
- items = items.includes(*resource_relations_names) if contains_relations?
26
- items = items.tagged_with(search_filter_params[:tagged_with]) if search_filter_params[:tagged_with].present?
21
+ items = collection
27
22
 
28
23
  respond_to do |format|
29
24
  format.html do
@@ -121,6 +116,16 @@ module Alchemy
121
116
  authorize!(action_name.to_sym, resource_instance_variable || resource_handler.model)
122
117
  end
123
118
 
119
+ def collection
120
+ @query = resource_handler.model.ransack(search_filter_params[:q])
121
+ @query.sorts = default_sort_order if @query.sorts.empty?
122
+ items = @query.result(distinct: true)
123
+
124
+ items = items.includes(*resource_relations_names) if contains_relations?
125
+ items = items.tagged_with(search_filter_params[:tagged_with]) if search_filter_params[:tagged_with].present?
126
+ items
127
+ end
128
+
124
129
  # Permits all editable resource attributes as default.
125
130
  #
126
131
  # Define this method in your inheriting controller if you want to permit additional attributes.
@@ -230,8 +230,7 @@ module Alchemy
230
230
  # Otherwise all users will see the same cached page, regardless of user's state.
231
231
  #
232
232
  def page_etag
233
- elements_cache_key = @page.public_version&.elements&.published&.order(:id)&.pluck(:id)
234
- [@page, elements_cache_key, current_alchemy_user]
233
+ Alchemy::Page::EtagGenerator.new(@page).call(current_alchemy_user)
235
234
  end
236
235
 
237
236
  # We only render the page if either the cache is disabled for this page
@@ -17,15 +17,12 @@ module Alchemy
17
17
  #
18
18
  # In order to represent your own +User+'s class instance,
19
19
  # you should add a +alchemy_display_name+ method to your +User+ class
20
- #
20
+ # @deprecated Use the +CurrentAlchemyUserName+ component instead.
21
21
  def current_alchemy_user_name
22
- name = current_alchemy_user.try(:alchemy_display_name)
23
- if name.present?
24
- content_tag :span, class: "current-user-name" do
25
- "#{render_icon(:user, size: "1x")} #{name}".html_safe
26
- end
27
- end
22
+ render Alchemy::Admin::CurrentUserName.new(user: current_alchemy_user)
28
23
  end
24
+ deprecate current_alchemy_user_name: "Use the Alchemy::Admin::CurrentUserName component instead.",
25
+ deprecator: Alchemy::Deprecation
29
26
 
30
27
  # This helper renders the link to an dialog.
31
28
  #
@@ -8,20 +8,12 @@ module Alchemy
8
8
  module UrlHelper
9
9
  # Returns the path for rendering an alchemy page
10
10
  def show_alchemy_page_path(page, optional_params = {})
11
- alchemy.show_page_path(show_page_path_params(page, optional_params))
11
+ page.url_path(optional_params)
12
12
  end
13
13
 
14
14
  # Returns the url for rendering an alchemy page
15
15
  def show_alchemy_page_url(page, optional_params = {})
16
- alchemy.show_page_url(show_page_path_params(page, optional_params))
17
- end
18
-
19
- # Returns the correct params-hash for passing to show_page_path
20
- def show_page_path_params(page, optional_params = {})
21
- raise ArgumentError, "Page is nil" if page.nil?
22
-
23
- url_params = {urlname: page.urlname}.update(optional_params)
24
- prefix_locale?(page.language_code) ? url_params.update(locale: page.language_code) : url_params
16
+ "#{request.base_url}#{page.url_path(optional_params)}"
25
17
  end
26
18
 
27
19
  # Returns the path for downloading an alchemy attachment
@@ -1,42 +1,43 @@
1
- import { patch } from "alchemy_admin/utils/ajax"
2
- import { reloadPreview } from "alchemy_admin/components/preview_window"
3
- import { growl } from "alchemy_admin/growler"
4
- import { dispatchPageDirtyEvent } from "alchemy_admin/components/element_editor"
5
-
6
1
  export class PublishElementButton extends HTMLElement {
7
- constructor() {
8
- super()
2
+ #scheduleButtonVariant
3
+
4
+ connectedCallback() {
5
+ this.#scheduleButtonVariant = this.scheduleButton.getAttribute("variant")
6
+ this.publishButton.addEventListener("click", this)
7
+ this.dropdown.addEventListener("sl-show", this)
8
+ this.dropdown.addEventListener("sl-hide", this)
9
+ }
9
10
 
10
- this.addEventListener("sl-change", this)
11
+ disconnectedCallback() {
12
+ this.publishButton.removeEventListener("click", this)
13
+ this.dropdown.removeEventListener("sl-show", this)
14
+ this.dropdown.removeEventListener("sl-hide", this)
11
15
  }
12
16
 
13
17
  handleEvent(event) {
14
- const elementEditor = event.target.closest("alchemy-element-editor")
15
- if (elementEditor === this.elementEditor) {
16
- patch(Alchemy.routes.publish_admin_element_path(this.elementId))
17
- .then((response) => {
18
- const data = response.data
19
- this.elementEditor.published = data.public
20
- this.tooltip.setAttribute("content", data.label)
21
- reloadPreview()
22
- if (data.pageHasUnpublishedChanges) {
23
- dispatchPageDirtyEvent(data)
24
- }
25
- })
26
- .catch((error) => growl(error.message, "error"))
18
+ switch (event.type) {
19
+ case "click":
20
+ this.publishButton.loading = true
21
+ break
22
+ case "sl-show":
23
+ this.scheduleButton.setAttribute("variant", "primary")
24
+ break
25
+ case "sl-hide":
26
+ this.scheduleButton.setAttribute("variant", this.#scheduleButtonVariant)
27
+ break
27
28
  }
28
29
  }
29
30
 
30
- get elementEditor() {
31
- return this.closest("alchemy-element-editor")
31
+ get publishButton() {
32
+ return this.querySelector("sl-button[type='submit']")
32
33
  }
33
34
 
34
- get tooltip() {
35
- return this.closest("sl-tooltip")
35
+ get dropdown() {
36
+ return this.querySelector("sl-dropdown")
36
37
  }
37
38
 
38
- get elementId() {
39
- return this.elementEditor.elementId
39
+ get scheduleButton() {
40
+ return this.querySelector("sl-button[slot='trigger']")
40
41
  }
41
42
  }
42
43
 
@@ -27,7 +27,7 @@ export class ElementEditor extends HTMLElement {
27
27
 
28
28
  // Dirty observer still needs to be jQuery
29
29
  // in order to support select2.
30
- $(this).on("change", this.onChange)
30
+ $(this.form).on("change", this.onChange)
31
31
 
32
32
  this.header?.addEventListener("dblclick", () => {
33
33
  this.toggle()
@@ -87,7 +87,7 @@ export class ElementEditor extends HTMLElement {
87
87
  if (target.classList.contains("nested-elements")) {
88
88
  return
89
89
  }
90
- this.setDirty(target)
90
+ this.closest("alchemy-element-editor").setDirty(target)
91
91
  event.stopPropagation()
92
92
  return false
93
93
  }
@@ -555,6 +555,15 @@ export class ElementEditor extends HTMLElement {
555
555
  return this.querySelector("alchemy-element-editor")
556
556
  }
557
557
 
558
+ /**
559
+ * The form element if present
560
+ *
561
+ * @returns {HTMLFormElement|undefined}
562
+ */
563
+ get form() {
564
+ return this.querySelector("form.element-body")
565
+ }
566
+
558
567
  /**
559
568
  * The parent element editor if present
560
569
  *
@@ -39,6 +39,10 @@ class Message extends HTMLElement {
39
39
  return this.hasAttribute("dismissable")
40
40
  }
41
41
 
42
+ get icon() {
43
+ return this.getAttribute("icon")
44
+ }
45
+
42
46
  get type() {
43
47
  return this.getAttribute("type") || "notice"
44
48
  }
@@ -50,7 +54,7 @@ class Message extends HTMLElement {
50
54
  }
51
55
 
52
56
  get iconName() {
53
- switch (this.type) {
57
+ switch (this.icon || this.type) {
54
58
  case "warning":
55
59
  case "warn":
56
60
  case "alert":
@@ -44,6 +44,7 @@ export default class PictureThumbnail extends HTMLElement {
44
44
  if (alt) {
45
45
  this.image.alt = alt
46
46
  }
47
+ this.image.loading = "lazy"
47
48
  }
48
49
 
49
50
  start(src) {
@@ -85,13 +85,13 @@ export class FileUpload extends AlchemyHTMLElement {
85
85
  errorMessage = translate("Uploaded bytes exceed file size")
86
86
  }
87
87
 
88
- const fileConfiguration = this.file?.type.includes("image")
89
- ? "allowed_filetype_pictures"
90
- : "allowed_filetype_attachments"
88
+ const allowedFiletypes = this.file?.type.includes("image")
89
+ ? config.allowed_filetypes.alchemy_pictures
90
+ : config.allowed_filetypes.alchemy_attachments
91
91
 
92
92
  const isFileFormatSupported =
93
- config[fileConfiguration] === "*" ||
94
- config[fileConfiguration].includes(
93
+ allowedFiletypes.includes("*") ||
94
+ allowedFiletypes.includes(
95
95
  this.file?.type.replace(/^\w+\/(\w+)(\+\w+)?/i, "$1")
96
96
  )
97
97
 
@@ -6,13 +6,17 @@ export default class ImageCropper {
6
6
  #cropFromField = null
7
7
  #cropSizeField = null
8
8
 
9
- constructor(image, defaultBox, aspectRatio, formFieldIds, elementId) {
9
+ constructor(image, settings) {
10
10
  this.image = image
11
- this.defaultBox = defaultBox
12
- this.aspectRatio = aspectRatio
13
- this.#cropFromField = document.getElementById(formFieldIds[0])
14
- this.#cropSizeField = document.getElementById(formFieldIds[1])
15
- this.elementId = elementId
11
+ this.defaultBox = settings.default_box
12
+ this.aspectRatio = settings.ratio
13
+ this.#cropFromField = document.getElementById(
14
+ settings.crop_from_form_field_id
15
+ )
16
+ this.#cropSizeField = document.getElementById(
17
+ settings.crop_size_form_field_id
18
+ )
19
+ this.elementId = settings.element_id
16
20
  this.dialog = Alchemy.currentDialog()
17
21
  if (this.dialog) {
18
22
  this.dialog.options.closed = () => this.destroy()
@@ -1,48 +1,21 @@
1
1
  import Hotkeys from "alchemy_admin/hotkeys"
2
2
  import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
3
3
 
4
- /**
5
- * add change listener to select to redirect the user after selecting another locale or site
6
- * @param {string} selectId
7
- * @param {string} parameterName
8
- * @param {boolean} forcedReload
9
- */
10
- function selectHandler(selectId, parameterName, forcedReload = false) {
11
- $(`select#${selectId}`).on("change", function (e) {
12
- let url = window.location.pathname
13
- let delimiter = url.match(/\?/) ? "&" : "?"
14
- const location = `${url}${delimiter}${parameterName}=${$(this).val()}`
15
-
16
- if (forcedReload) {
17
- window.location.href = location
18
- } else {
19
- Turbo.visit(location, {})
20
- }
21
- })
22
- }
23
-
24
4
  export default function Initializer() {
25
5
  // We obviously have javascript enabled.
26
- $("html").removeClass("no-js")
6
+ document.documentElement.classList.remove("no-js")
27
7
 
28
8
  // Initialize hotkeys.
29
9
  Hotkeys()
30
10
 
31
11
  // Add observer for please wait overlay.
32
- $(".please_wait").on("click", pleaseWaitOverlay)
12
+ document.querySelectorAll(".please_wait").forEach((element) => {
13
+ element.addEventListener("click", pleaseWaitOverlay)
14
+ })
33
15
 
34
16
  // Hack for enabling tab focus for <a>'s styled as button.
35
- $("a.button").attr({ tabindex: 0 })
36
-
37
- // Locale select handler
38
- selectHandler("change_locale", "admin_locale", true)
39
-
40
- // Site select handler
41
- selectHandler("change_site", "site_id")
42
-
43
- // Submit forms of selects with `data-autosubmit="true"`
44
- $('select[data-auto-submit="true"]').on("change", function () {
45
- $(this.form).submit()
17
+ document.querySelectorAll("a.button").forEach((button) => {
18
+ button.setAttribute("tabindex", 0)
46
19
  })
47
20
 
48
21
  // Override the filter of keymaster.js so we can blur the fields on esc key.
@@ -47,12 +47,16 @@ const spriteUrl = document
47
47
  .getAttribute("href")
48
48
 
49
49
  const iconMap = {
50
- "x-lg": "close"
50
+ "x-lg": "close",
51
+ caret: "arrow-down-s"
51
52
  }
52
53
 
53
54
  const options = {
54
55
  resolver: (name) => `${spriteUrl}#ri-${iconMap[name] || name}-line`,
55
- mutator: (svg) => svg.setAttribute("fill", "currentColor"),
56
+ mutator: (svg) => {
57
+ svg.setAttribute("fill", "currentColor")
58
+ svg.setAttribute("viewBox", "0 0 24 24")
59
+ },
56
60
  spriteSheet: true
57
61
  }
58
62
 
@@ -1 +1 @@
1
- (()=>{var n=Handlebars.template;(Handlebars.templates=Handlebars.templates||{})["node_folder.hbs"]=n({1:function(n,e,l,a,r){return"right"},3:function(n,e,l,a,r){return"down"},compiler:[8,">= 4.3.0"],main:function(n,e,l,a,r){var o,t=n.lambda,d=n.escapeExpression,u=n.lookupProperty||function(n,e){if(Object.prototype.hasOwnProperty.call(n,e))return n[e]};return'<a class="node_folder" data-record-id="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"id"):o,e))+'" data-record-type="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"type"):o,e))+'">\n <alchemy-icon name="arrow-'+(null!=(o=u(l,"if").call(null!=e?e:n.nullContext||{},null!=(o=null!=e?u(e,"node"):e)?u(o,"folded"):o,{name:"if",hash:{},fn:n.program(1,r,0),inverse:n.program(3,r,0),data:r,loc:{start:{line:2,column:28},end:{line:2,column:72}}}))?o:"")+'-s"></alchemy-icon>\n</a>\n'},useData:!0})})();
1
+ (()=>{var n=Handlebars.template;(Handlebars.templates=Handlebars.templates||{})["node_folder.hbs"]=n({0:function(n,e,l,a,r){return"right"},1:function(n,e,l,a,r){return"down"},compiler:[8,">= 4.3.0"],main:function(n,e,l,a,r){var o,t=n.lambda,d=n.escapeExpression,u=n.lookupProperty||function(n,e){if(Object.prototype.hasOwnProperty.call(n,e))return n[e]};return'<a class="node_folder" data-record-id="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"id"):o,e))+'" data-record-type="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"type"):o,e))+'">\n <alchemy-icon name="arrow-'+(null!=(o=u(l,"if").call(null!=e?e:n.nullContext||{},null!=(o=null!=e?u(e,"node"):e)?u(o,"folded"):o,{name:"if",hash:{},fn:n.program(0,r,0),inverse:n.program(1,r,0),data:r,loc:{start:{line:2,column:28},end:{line:2,column:72}}}))?o:"")+'-s"></alchemy-icon>\n</a>\n'},useData:!0})})();
@@ -2,7 +2,7 @@
2
2
  import "handlebars"
3
3
  import "jquery"
4
4
  import "@ungap/custom-elements"
5
- import "@hotwired/turbo-rails"
5
+ import { Turbo } from "@hotwired/turbo-rails"
6
6
  import "select2"
7
7
 
8
8
  import Rails from "@rails/ujs"
@@ -49,4 +49,14 @@ Object.assign(Alchemy, {
49
49
 
50
50
  Rails.start()
51
51
  Turbo.config.forms.confirm = openConfirmDialog
52
- $(document).on("turbo:load", Initializer)
52
+ document.addEventListener("turbo:load", Initializer)
53
+
54
+ // Public API for extensions
55
+ export { RemoteSelect } from "alchemy_admin/components/remote_select"
56
+ export { on } from "alchemy_admin/utils/events"
57
+
58
+ // Page-specific modules - bundled to avoid dual-loading
59
+ export { default as ImageCropper } from "alchemy_admin/image_cropper"
60
+ export { default as ImageOverlay } from "alchemy_admin/image_overlay"
61
+ export { default as pictureSelector } from "alchemy_admin/picture_selector"
62
+ export { default as NodeTree } from "alchemy_admin/node_tree"
@@ -42,25 +42,6 @@ module Alchemy
42
42
  scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
43
43
  scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) }
44
44
 
45
- # Override +Alchemy::RelatableResource#deletable+ to also exclude
46
- # attachments referenced from +/attachment/:id/download+ URLs inside
47
- # ingredient values (e.g. Richtext markup, Link ingredients, raw Html).
48
- # Those URLs are written by the file tab of the link dialog and are
49
- # not tracked via the polymorphic +related_object+ association, so the
50
- # base scope cannot see them.
51
- #
52
- # Extracts referenced attachment IDs from ingredient values via Ruby
53
- # regex to stay database-agnostic.
54
- scope :deletable, -> do
55
- referenced_ids = Alchemy::Ingredient
56
- .where("value LIKE '%/attachment/%/download%'")
57
- .pluck(:value)
58
- .flat_map { |v| v.scan(%r{/attachment/(\d+)/download}).flatten.map(&:to_i) }
59
-
60
- scope = where("#{table_name}.id NOT IN (#{RelatableResource::RELATED_INGREDIENTS_SUBQUERY})", type: name)
61
- referenced_ids.any? ? scope.where.not(id: referenced_ids) : scope
62
- end
63
-
64
45
  # We need to define this method here to have it available in the validations below.
65
46
  class << self
66
47
  # The class used to generate URLs for attachments
@@ -131,16 +112,9 @@ module Alchemy
131
112
  CGI.escape(file_name.gsub(/\.#{extension}$/, "").tr(".", " "))
132
113
  end
133
114
 
134
- # Override +Alchemy::RelatableResource#deletable?+ to also consider
135
- # +/attachment/:id/download+ links inside ingredient values (e.g.
136
- # Richtext markup, Link ingredients, raw Html).
137
- def deletable?
138
- super && !referenced_in_ingredient_value?
139
- end
140
-
141
115
  # Checks if the attachment is restricted, because it is attached on restricted pages only
142
116
  def restricted?
143
- pages.any? && pages.not_restricted.blank?
117
+ related_pages.any? && related_pages.not_restricted.blank?
144
118
  end
145
119
 
146
120
  # File name
@@ -204,12 +178,6 @@ module Alchemy
204
178
  end
205
179
  end
206
180
 
207
- def referenced_in_ingredient_value?
208
- Alchemy::Ingredient
209
- .where("value LIKE ?", "%/attachment/#{id}/download%")
210
- .exists?
211
- end
212
-
213
181
  def set_name
214
182
  self.name ||= Alchemy.storage_adapter.file_basename(self).humanize
215
183
  end
@@ -1,6 +1,6 @@
1
1
  module Alchemy
2
2
  class Current < ActiveSupport::CurrentAttributes
3
- attribute :preview_page, :page, :language, :site
3
+ attribute :preview_page, :preview_time, :page, :language, :site
4
4
 
5
5
  def language
6
6
  super || Language.default
@@ -10,6 +10,10 @@ module Alchemy
10
10
  super || Site.first
11
11
  end
12
12
 
13
+ def preview_time
14
+ super || Time.current
15
+ end
16
+
13
17
  def preview_page=(page)
14
18
  super
15
19
 
@@ -75,11 +75,19 @@ module Alchemy
75
75
  # This is used to re-initialize the TinyMCE editor in the element editor.
76
76
  #
77
77
  def richtext_ingredients_ids
78
- ids = ingredients.select(&:has_tinymce?).collect(&:id)
79
- expanded_nested_elements = nested_elements.expanded
78
+ ids = ingredients.filter_map { |i| i.id if i.has_tinymce? }
79
+
80
+ # Use preloaded association if available, otherwise query
81
+ expanded_nested_elements = if association(:all_nested_elements).loaded?
82
+ all_nested_elements.select(&:expanded?)
83
+ else
84
+ nested_elements.expanded
85
+ end
86
+
80
87
  if expanded_nested_elements.present?
81
- ids += expanded_nested_elements.collect(&:richtext_ingredients_ids)
88
+ ids += expanded_nested_elements.map(&:richtext_ingredients_ids)
82
89
  end
90
+
83
91
  ids.flatten
84
92
  end
85
93