alchemy_cms 6.0.0.pre.rc5 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -8
  3. data/.github/workflows/stale.yml +21 -7
  4. data/.gitignore +0 -1
  5. data/.rspec +1 -0
  6. data/CHANGELOG.md +89 -0
  7. data/Gemfile +10 -7
  8. data/Rakefile +5 -1
  9. data/alchemy_cms.gemspec +3 -3
  10. data/app/assets/javascripts/alchemy/admin.js +0 -2
  11. data/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee +6 -1
  12. data/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee +2 -0
  13. data/app/assets/javascripts/alchemy/page_select.js +13 -8
  14. data/app/assets/javascripts/alchemy/templates/index.js +1 -0
  15. data/app/assets/javascripts/alchemy/templates/page.hbs +17 -7
  16. data/app/assets/javascripts/alchemy/templates/page_folder.hbs +3 -0
  17. data/app/assets/stylesheets/alchemy/archive.scss +9 -0
  18. data/app/assets/stylesheets/alchemy/elements.scss +4 -0
  19. data/app/assets/stylesheets/alchemy/page-select.scss +29 -4
  20. data/app/assets/stylesheets/alchemy/sitemap.scss +9 -7
  21. data/app/controllers/alchemy/admin/elements_controller.rb +2 -6
  22. data/app/controllers/alchemy/admin/pages_controller.rb +16 -18
  23. data/app/controllers/alchemy/api/contents_controller.rb +1 -5
  24. data/app/controllers/alchemy/api/elements_controller.rb +2 -6
  25. data/app/controllers/alchemy/api/pages_controller.rb +16 -10
  26. data/app/helpers/alchemy/elements_helper.rb +2 -2
  27. data/app/models/alchemy/ingredient.rb +6 -1
  28. data/app/models/alchemy/page.rb +3 -2
  29. data/app/models/alchemy/picture.rb +1 -1
  30. data/app/serializers/alchemy/page_serializer.rb +7 -1
  31. data/app/serializers/alchemy/page_tree_serializer.rb +3 -3
  32. data/app/views/alchemy/admin/pages/_form.html.erb +19 -0
  33. data/app/views/alchemy/admin/pages/_new_page_form.html.erb +16 -5
  34. data/app/views/alchemy/admin/pages/_page.html.erb +111 -133
  35. data/app/views/alchemy/admin/pages/_sitemap.html.erb +10 -16
  36. data/app/views/alchemy/admin/pages/_toolbar.html.erb +0 -12
  37. data/app/views/alchemy/admin/pages/edit.html.erb +1 -1
  38. data/app/views/alchemy/admin/pages/index.html.erb +1 -1
  39. data/app/views/alchemy/admin/pages/update.js.erb +7 -5
  40. data/app/views/alchemy/admin/partials/_routes.html.erb +12 -1
  41. data/app/views/alchemy/admin/resources/_form.html.erb +5 -0
  42. data/app/views/alchemy/essences/_essence_page_editor.html.erb +1 -1
  43. data/config/alchemy/config.yml +1 -0
  44. data/config/locales/alchemy.en.yml +0 -4
  45. data/config/routes.rb +4 -2
  46. data/lib/alchemy/engine.rb +12 -1
  47. data/lib/alchemy/essence.rb +0 -26
  48. data/lib/alchemy/permissions.rb +0 -1
  49. data/lib/alchemy/resource.rb +16 -1
  50. data/lib/alchemy/test_support/essence_shared_examples.rb +0 -12
  51. data/lib/alchemy/test_support/shared_ingredient_examples.rb +4 -2
  52. data/lib/alchemy/upgrader/tasks/ingredients_migrator.rb +1 -1
  53. data/lib/alchemy/version.rb +1 -1
  54. data/lib/generators/alchemy/install/install_generator.rb +6 -1
  55. data/package/admin.js +5 -1
  56. data/package/src/image_loader.js +4 -2
  57. data/package/src/node_tree.js +13 -6
  58. data/package/src/page_publication_fields.js +28 -0
  59. data/package/src/page_sorter.js +62 -0
  60. data/package/src/picture_editors.js +8 -8
  61. data/package/src/sitemap.js +148 -0
  62. data/package/src/utils/__tests__/ajax.spec.js +52 -16
  63. data/package/src/utils/ajax.js +12 -0
  64. data/package.json +1 -1
  65. metadata +43 -43
  66. data/app/assets/javascripts/alchemy/alchemy.page_sorter.js +0 -24
  67. data/app/assets/javascripts/alchemy/alchemy.sitemap.js.coffee +0 -119
  68. data/app/views/alchemy/admin/pages/fold.js.erb +0 -2
  69. data/app/views/alchemy/admin/pages/sort.html.erb +0 -19
  70. data/vendor/assets/javascripts/jquery_plugins/jquery.ui.nestedSortable.js +0 -434
@@ -5,6 +5,10 @@
5
5
  return '<%= alchemy.admin_picture_path(id: 1) %>'.replace(/1/, id);
6
6
  },
7
7
 
8
+ url_admin_picture_path: function(id) {
9
+ return '<%= alchemy.url_admin_picture_path(id: 1) %>'.replace(/1/, id);
10
+ },
11
+
8
12
  fold_admin_element_path: function(id) {
9
13
  return '<%= alchemy.fold_admin_element_path(id: 1) %>'.replace(/1/, id);
10
14
  },
@@ -19,8 +23,15 @@
19
23
  },
20
24
  },
21
25
 
26
+ move_admin_page_path: function(id) {
27
+ return '<%= alchemy.move_api_page_path(id: 1) %>'.replace(/1/, id);
28
+ },
29
+
30
+ fold_admin_page_path: function(id) {
31
+ return '<%= alchemy.fold_admin_page_path(id: 1) %>'.replace(/1/, id);
32
+ },
33
+
22
34
  order_admin_elements_path: '<%= alchemy.order_admin_elements_path %>',
23
- order_admin_pages_path: '<%= alchemy.order_admin_pages_path %>',
24
35
  link_admin_pages_path: '<%= alchemy.link_admin_pages_path %>',
25
36
  api_pages_path: '<%= alchemy.api_pages_path %>',
26
37
  api_elements_path: '<%= alchemy.api_elements_path %>'
@@ -8,6 +8,11 @@
8
8
  input_html: {class: 'alchemy_selectbox'} %>
9
9
  <% elsif attribute[:type].in? %i[date time datetime] %>
10
10
  <%= f.datepicker attribute[:name], resource_attribute_field_options(attribute) %>
11
+ <% elsif attribute[:enum].present? %>
12
+ <%= f.input attribute[:name],
13
+ collection: attribute[:enum],
14
+ include_blank: Alchemy.t(:blank, scope: 'resources.relation_select'),
15
+ input_html: {class: 'alchemy_selectbox'} %>
11
16
  <% else %>
12
17
  <%= f.input attribute[:name], resource_attribute_field_options(attribute) %>
13
18
  <% end %>
@@ -14,7 +14,7 @@
14
14
  <script>
15
15
  $('#<%= essence_page_editor.form_field_id %>').alchemyPageSelect({
16
16
  placeholder: "<%= Alchemy.t(:search_page) %>",
17
- url: "<%= alchemy.api_pages_path %>",
17
+ url: "<%= alchemy.api_pages_path language_id: essence_page_editor.page&.language_id %>",
18
18
  query_params: <%== essence_page_editor.settings[:query_params].to_json %>,
19
19
  <% if essence_page_editor.essence.page %>
20
20
  initialSelection: {
@@ -173,6 +173,7 @@ uploader:
173
173
  - gif
174
174
  - png
175
175
  - svg
176
+ - webp
176
177
 
177
178
  # === Link Target Options
178
179
  #
@@ -334,7 +334,6 @@ en:
334
334
  "Site successfully removed": "Website successfully removed."
335
335
  "Site successfully updated": "Website successfully updated."
336
336
  "Size": "Size"
337
- "Sort pages": "Reorder pages"
338
337
  "Successfully added content": "Successfully added %{content}"
339
338
  "Successfully deleted content": "Successfully deleted %{content}"
340
339
  "Successfully deleted element": "Successfully deleted %{element}"
@@ -437,7 +436,6 @@ en:
437
436
  enter_external_link: "Please enter the URL you want to link with"
438
437
  explain_cropping: "<p>Move the frame and change its size with the mouse or arrow keys to adjust the image mask. Click on \"apply\" when you are satisfied with your selection.</p><p>If you want to return to the original centered image mask like it was defined in the layout, click \"reset\" and \"apply\" afterwards.</p>"
439
438
  explain_publishing: "Publish current page content"
440
- explain_sitemap_dragndrop_sorting: "Tip: Drag the pages at the icon in order to sort them."
441
439
  explain_unlocking: "Leave page and unlock it for other users."
442
440
  external_link_notice_1: "Please enter the complete url with http:// or a similar protocol."
443
441
  external_link_notice_2: "To refer a path from your website url, start with a /."
@@ -534,7 +532,6 @@ en:
534
532
  or_replace_it_with_an_existing_tag: 'Or replace it with an existing tag'
535
533
  "Page created": "Page: '%{name}' created."
536
534
  page_infos: 'Page info'
537
- page_layout_changed_notice: "Page type was changed. Elements not usable anymore have been hided."
538
535
  page_properties: "Page properties"
539
536
  page_public: "published"
540
537
  page_published: "Published page"
@@ -593,7 +590,6 @@ en:
593
590
  robot_follow: "robot may follow links."
594
591
  robot_index: "allow robot to index."
595
592
  save: "Save"
596
- "save order": "Save order"
597
593
  saved_link: "Link saved."
598
594
  search: "search"
599
595
  search_engines: "Search engines"
data/config/routes.rb CHANGED
@@ -28,13 +28,12 @@ Alchemy::Engine.routes.draw do
28
28
  post :copy_language_tree
29
29
  get :create_language
30
30
  get :link
31
- get :sort
32
31
  get :tree
33
32
  end
34
33
  member do
35
34
  post :unlock
36
35
  post :publish
37
- post :fold
36
+ patch :fold
38
37
  get :configure
39
38
  get :preview
40
39
  get :info
@@ -139,6 +138,9 @@ Alchemy::Engine.routes.draw do
139
138
  collection do
140
139
  get :nested
141
140
  end
141
+ member do
142
+ patch :move
143
+ end
142
144
  end
143
145
 
144
146
  get "/pages/*urlname(.:format)" => "pages#show", as: "page"
@@ -37,7 +37,7 @@ module Alchemy
37
37
  end
38
38
  end
39
39
 
40
- initializer "alchemy.userstamp" do
40
+ config.after_initialize do
41
41
  if Alchemy.user_class
42
42
  ActiveSupport.on_load(:active_record) do
43
43
  Alchemy.user_class.model_stamper
@@ -45,5 +45,16 @@ module Alchemy
45
45
  end
46
46
  end
47
47
  end
48
+
49
+ initializer "alchemy.webp-mime_type" do
50
+ # Rails does not know anything about webp even in 2022
51
+ unless Mime::Type.lookup_by_extension(:webp)
52
+ Mime::Type.register("image/webp", :webp)
53
+ end
54
+ # Dragonfly uses Rack to read the mime type and guess what
55
+ unless Rack::Mime::MIME_TYPES[".webp"]
56
+ Rack::Mime::MIME_TYPES[".webp"] = "image/webp"
57
+ end
58
+ end
48
59
  end
49
60
  end
@@ -3,18 +3,6 @@
3
3
  require "active_record"
4
4
 
5
5
  module Alchemy #:nodoc:
6
- # A bogus association that skips eager loading for essences not having an ingredient association
7
- class IngredientAssociation < ActiveRecord::Associations::BelongsToAssociation
8
- # Skip eager loading if called by Rails' preloader
9
- def klass
10
- if caller.any? { |line| line =~ /preloader\.rb/ }
11
- nil
12
- else
13
- super
14
- end
15
- end
16
- end
17
-
18
6
  module Essence #:nodoc:
19
7
  def self.included(base)
20
8
  base.extend(ClassMethods)
@@ -43,8 +31,6 @@ module Alchemy #:nodoc:
43
31
  ingredient_column: "body",
44
32
  }.update(options)
45
33
 
46
- @_classes_with_ingredient_association ||= []
47
-
48
34
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
49
35
  attr_writer :validation_errors
50
36
  include Alchemy::Essence::InstanceMethods
@@ -87,18 +73,6 @@ module Alchemy #:nodoc:
87
73
  alias_method :#{configuration[:ingredient_column]}, :ingredient_association
88
74
  alias_method :#{configuration[:ingredient_column]}=, :ingredient_association=
89
75
  RUBY
90
-
91
- @_classes_with_ingredient_association << self
92
- end
93
- end
94
-
95
- # Overwrite ActiveRecords method to return a bogus association class that skips eager loading
96
- # for essence classes that do not have an ingredient association
97
- def _reflect_on_association(name)
98
- if name == :ingredient_association && !in?(@_classes_with_ingredient_association)
99
- OpenStruct.new(association_class: Alchemy::IngredientAssociation)
100
- else
101
- super
102
76
  end
103
77
  end
104
78
 
@@ -147,7 +147,6 @@ module Alchemy
147
147
  :copy_language_tree,
148
148
  :flush,
149
149
  :order,
150
- :sort,
151
150
  :switch_language,
152
151
  ], Alchemy::Page
153
152
 
@@ -169,10 +169,25 @@ module Alchemy
169
169
  name: col.name,
170
170
  type: resource_column_type(col),
171
171
  relation: resource_relation(col.name),
172
- }.delete_if { |_k, v| v.nil? }
172
+ enum: enum_values_collection_for_select(col.name),
173
+ }.delete_if { |_k, v| v.blank? }
173
174
  end.compact
174
175
  end
175
176
 
177
+ def enum_values_collection_for_select(column_name)
178
+ enum = model.defined_enums[column_name]
179
+ return if enum.blank?
180
+
181
+ enum.keys.map do |key|
182
+ [
183
+ ::I18n.t(key, scope: [
184
+ :activerecord, :attributes, model.model_name.i18n_key, "#{column_name}_values"
185
+ ], default: key.humanize),
186
+ key,
187
+ ]
188
+ end
189
+ end
190
+
176
191
  def sorted_attributes
177
192
  @_sorted_attributes ||= attributes.
178
193
  sort_by { |attr| attr[:name] == "name" ? 0 : 1 }.
@@ -7,18 +7,6 @@ RSpec.shared_examples_for "an essence" do
7
7
  let(:content) { Alchemy::Content.new(name: "foo") }
8
8
  let(:content_definition) { { "name" => "foo" } }
9
9
 
10
- describe "eager loading" do
11
- before do
12
- 2.times { described_class.create! }
13
- end
14
-
15
- it "does not throw error if eager loaded" do
16
- expect {
17
- described_class.all.includes(:ingredient_association).to_a
18
- }.to_not raise_error
19
- end
20
- end
21
-
22
10
  it "touches the element after save" do
23
11
  element = FactoryBot.create(:alchemy_element)
24
12
  content = FactoryBot.create(:alchemy_content, element: element, essence: essence, essence_type: essence.class.name)
@@ -28,7 +28,7 @@ RSpec.shared_examples_for "an alchemy ingredient" do
28
28
 
29
29
  context "with element" do
30
30
  before do
31
- expect(element).to receive(:ingredient_definition_for) do
31
+ expect(element).to receive(:ingredient_definition_for).at_least(:once) do
32
32
  {
33
33
  settings: {
34
34
  linkable: true,
@@ -63,7 +63,9 @@ RSpec.shared_examples_for "an alchemy ingredient" do
63
63
  end
64
64
 
65
65
  before do
66
- expect(element).to receive(:ingredient_definition_for) { definition }
66
+ expect(element).to receive(:ingredient_definition_for).at_least(:once) do
67
+ definition
68
+ end
67
69
  end
68
70
 
69
71
  it "returns ingredient definition" do
@@ -16,7 +16,7 @@ module Alchemy::Upgrader::Tasks
16
16
  # eager load all elements that have ingredients defined but no ingredient records yet.
17
17
  all_elements = Alchemy::Element
18
18
  .named(elements_with_ingredients.map { |d| d[:name] })
19
- .includes(contents: { essence: :ingredient_association })
19
+ .includes(contents: :essence)
20
20
  .left_outer_joins(:ingredients).where(alchemy_ingredients: { id: nil })
21
21
  .to_a
22
22
  elements_with_ingredients.map do |element_definition|
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Alchemy
4
- VERSION = "6.0.0-rc5"
4
+ VERSION = "6.0.0"
5
5
 
6
6
  def self.version
7
7
  VERSION
@@ -23,6 +23,11 @@ module Alchemy
23
23
  default: false,
24
24
  desc: "Skip running the webpacker installer."
25
25
 
26
+ class_option :skip_db_create,
27
+ type: :boolean,
28
+ default: false,
29
+ desc: "Skip creting the database during install."
30
+
26
31
  source_root File.expand_path("files", __dir__)
27
32
 
28
33
  def setup
@@ -104,7 +109,7 @@ module Alchemy
104
109
  end
105
110
 
106
111
  def setup_database
107
- rake("db:create", abort_on_failure: true)
112
+ rake("db:create", abort_on_failure: true) unless options[:skip_db_create]
108
113
  # We can't invoke this rake task, because Rails will use wrong engine names otherwise
109
114
  rake("railties:install:migrations", abort_on_failure: true)
110
115
  rake("db:migrate", abort_on_failure: true)
data/package/admin.js CHANGED
@@ -6,6 +6,8 @@ import pictureEditors from "./src/picture_editors"
6
6
  import ImageLoader from "./src/image_loader"
7
7
  import ImageCropper from "./src/image_cropper"
8
8
  import Datepicker from "./src/datepicker"
9
+ import Sitemap from "./src/sitemap"
10
+ import PagePublicationFields from "./src/page_publication_fields.js"
9
11
 
10
12
  // Global Alchemy object
11
13
  if (typeof window.Alchemy === "undefined") {
@@ -22,5 +24,7 @@ Object.assign(Alchemy, {
22
24
  pictureEditors,
23
25
  ImageLoader: ImageLoader.init,
24
26
  ImageCropper,
25
- Datepicker
27
+ Datepicker,
28
+ Sitemap,
29
+ PagePublicationFields
26
30
  })
@@ -39,9 +39,11 @@ export default class ImageLoader {
39
39
  this.unbind()
40
40
  }
41
41
 
42
- onError() {
42
+ onError(evt) {
43
+ const message = `Could not load "${this.image.src}"`
43
44
  this.removeSpinner()
44
- this.parent.innerHtml = '<span class="icon warn"></span>'
45
+ this.parent.innerHTML = `<span class="icon error fas fa-exclamation-triangle" title="${message}" />`
46
+ console.error(message, evt)
45
47
  this.unbind()
46
48
  }
47
49
 
@@ -1,12 +1,16 @@
1
1
  import Sortable from "sortablejs"
2
- import ajax from "./utils/ajax"
2
+ import { patch } from "./utils/ajax"
3
3
  import { on } from "./utils/events"
4
4
 
5
5
  function displayNodeFolders() {
6
6
  document.querySelectorAll("li.menu-item").forEach((el) => {
7
7
  const leftIconArea = el.querySelector(".nodes_tree-left_images")
8
8
  const list = el.querySelector(".children")
9
- const node = { folded: el.dataset.folded === "true", id: el.dataset.id, type: el.dataset.type }
9
+ const node = {
10
+ folded: el.dataset.folded === "true",
11
+ id: el.dataset.id,
12
+ type: el.dataset.type
13
+ }
10
14
 
11
15
  if (list.children.length > 0 || node.folded) {
12
16
  leftIconArea.innerHTML = HandlebarsTemplates.node_folder({ node: node })
@@ -17,13 +21,15 @@ function displayNodeFolders() {
17
21
  }
18
22
 
19
23
  function onFinishDragging(evt) {
20
- const url = Alchemy.routes[evt.item.dataset.type].move_api_path(evt.item.dataset.id)
24
+ const url = Alchemy.routes[evt.item.dataset.type].move_api_path(
25
+ evt.item.dataset.id
26
+ )
21
27
  const data = {
22
28
  target_parent_id: evt.to.dataset.recordId,
23
29
  new_position: evt.newIndex
24
30
  }
25
31
 
26
- ajax("PATCH", url, data)
32
+ patch(url, data)
27
33
  .then(() => {
28
34
  const message = Alchemy.t("Successfully moved menu item")
29
35
  Alchemy.growl(message)
@@ -38,10 +44,11 @@ function handleNodeFolders() {
38
44
  on("click", ".nodes_tree", ".node_folder", function () {
39
45
  const nodeId = this.dataset.recordId
40
46
  const menu_item = this.closest("li.menu-item")
41
- const url = Alchemy.routes[this.dataset.recordType].toggle_folded_api_path(nodeId)
47
+ const url =
48
+ Alchemy.routes[this.dataset.recordType].toggle_folded_api_path(nodeId)
42
49
  const list = menu_item.querySelector(".children")
43
50
 
44
- ajax("PATCH", url)
51
+ patch(url)
45
52
  .then(() => {
46
53
  list.classList.toggle("folded")
47
54
  menu_item.dataset.folded =
@@ -0,0 +1,28 @@
1
+ // Handles the page publication date fields
2
+ export default function () {
3
+ document.addEventListener("DialogReady.Alchemy", function (evt) {
4
+ const dialog = evt.detail.body
5
+ const public_on_field = dialog.querySelector("#page_public_on")
6
+ const public_until_field = dialog.querySelector("#page_public_until")
7
+ const publication_date_fields = dialog.querySelector(
8
+ ".page-publication-date-fields"
9
+ )
10
+ const public_field = dialog.querySelector("#page_public")
11
+
12
+ if(!public_field) return
13
+
14
+ public_field.addEventListener("click", function (evt) {
15
+ const checkbox = evt.target
16
+ const now = new Date()
17
+
18
+ if (checkbox.checked) {
19
+ publication_date_fields.classList.remove("hidden")
20
+ public_on_field._flatpickr.setDate(now)
21
+ } else {
22
+ publication_date_fields.classList.add("hidden")
23
+ public_on_field.value = ""
24
+ }
25
+ public_until_field.value = ""
26
+ })
27
+ })
28
+ }
@@ -0,0 +1,62 @@
1
+ import Sortable from "sortablejs"
2
+ import { patch } from "./utils/ajax"
3
+
4
+ function onFinishDragging(evt) {
5
+ const pageId = evt.item.dataset.pageId
6
+ const url = Alchemy.routes.move_admin_page_path(pageId)
7
+ const data = {
8
+ target_parent_id: evt.to.dataset.parentId,
9
+ new_position: evt.newIndex
10
+ }
11
+
12
+ patch(url, data)
13
+ .then(async (response) => {
14
+ const pageData = await response.data
15
+ const pageEl = document.getElementById(`page_${pageId}`)
16
+ const urlPathEl = pageEl.querySelector(".sitemap_url")
17
+
18
+ Alchemy.growl(Alchemy.t("Successfully moved page"))
19
+ urlPathEl.textContent = pageData.url_path
20
+ displayPageFolders()
21
+ })
22
+ .catch((error) => {
23
+ Alchemy.growl(error.message || error, "error")
24
+ })
25
+ }
26
+
27
+ export function displayPageFolders() {
28
+ document.querySelectorAll("li.sitemap-item").forEach((el) => {
29
+ const pageFolderEl = el.querySelector(".page_folder")
30
+ const list = el.querySelector(".children")
31
+ const page = {
32
+ folded: el.dataset.folded === "true",
33
+ id: el.dataset.pageId,
34
+ type: el.dataset.type
35
+ }
36
+
37
+ if (list.children.length > 0 || page.folded) {
38
+ pageFolderEl.outerHTML = HandlebarsTemplates.page_folder({ page })
39
+ } else {
40
+ pageFolderEl.innerHTML = ""
41
+ }
42
+ })
43
+ }
44
+
45
+ export function createSortables(sortables) {
46
+ sortables.forEach((el) => {
47
+ new Sortable(el, {
48
+ group: "pages",
49
+ animation: 150,
50
+ fallbackOnBody: true,
51
+ swapThreshold: 0.65,
52
+ handle: ".handle",
53
+ onEnd: onFinishDragging
54
+ })
55
+ })
56
+ }
57
+
58
+ export default function () {
59
+ const sortables = document.querySelectorAll("ul.children")
60
+ displayPageFolders()
61
+ createSortables(sortables)
62
+ }
@@ -1,11 +1,10 @@
1
- import debounce from "lodash/debounce"
2
- import max from "lodash/max"
3
- import ajax from "./utils/ajax"
1
+ import debounce from "lodash-es/debounce"
2
+ import max from "lodash-es/max"
3
+ import { get } from "./utils/ajax"
4
4
  import ImageLoader from "./image_loader"
5
5
 
6
6
  const UPDATE_DELAY = 125
7
7
  const IMAGE_PLACEHOLDER = '<i class="icon far fa-image fa-fw"></i>'
8
- const EMPTY_IMAGE = '<img src="" class="img_paddingtop" />'
9
8
  const THUMBNAIL_SIZE = "160x120"
10
9
 
11
10
  class PictureEditor {
@@ -62,7 +61,7 @@ class PictureEditor {
62
61
  this.image.removeAttribute("alt")
63
62
  this.image.removeAttribute("src")
64
63
  this.imageLoader.load(true)
65
- ajax("GET", `/admin/pictures/${this.pictureId}/url`, {
64
+ get(Alchemy.routes.url_admin_picture_path(this.pictureId), {
66
65
  crop: this.imageCropperEnabled,
67
66
  crop_from: this.cropFrom,
68
67
  crop_size: this.cropSize,
@@ -83,9 +82,10 @@ class PictureEditor {
83
82
  ensureImage() {
84
83
  if (this.image) return
85
84
 
86
- this.thumbnailBackground.innerHTML = EMPTY_IMAGE
87
- this.image = this.container.querySelector("img")
88
- this.imageLoader = new ImageLoader(this.image)
85
+ const img = new Image()
86
+ this.thumbnailBackground.replaceChildren(img)
87
+ this.image = img
88
+ this.imageLoader = new ImageLoader(img)
89
89
  }
90
90
 
91
91
  removeImage() {
@@ -0,0 +1,148 @@
1
+ // The admin sitemap Alchemy class
2
+ import PageSorter from "./page_sorter"
3
+ import { on } from "./utils/events"
4
+ import { get, patch } from "./utils/ajax"
5
+ import { createSortables, displayPageFolders } from "./page_sorter"
6
+
7
+ export default class Sitemap {
8
+ // Storing some objects.
9
+ constructor(options) {
10
+ const list_template_html = document
11
+ .getElementById("sitemap-list")
12
+ .innerHTML.replace(/__ID__/g, "{{id}}")
13
+ this.search_field = document.querySelector(".search_input_field")
14
+ this.filter_field_clear = document.querySelector(".search_field_clear")
15
+ this.filter_field_clear.removeAttribute("href")
16
+ this.display = document.getElementById("page_filter_result")
17
+ this.sitemap_wrapper = document.getElementById("sitemap-wrapper")
18
+ this.template = Handlebars.compile(
19
+ document.getElementById("sitemap-template").innerHTML
20
+ )
21
+ this.list_template = Handlebars.compile(list_template_html)
22
+ this.items = null
23
+ this.options = options
24
+ Handlebars.registerPartial("list", list_template_html)
25
+ this.load(options.page_root_id)
26
+ }
27
+
28
+ // Loads the sitemap
29
+ load(pageId) {
30
+ const spinner = new Alchemy.Spinner("medium")
31
+ const spinTarget = this.sitemap_wrapper
32
+ spinTarget.innerHTML = ""
33
+ spinner.spin(spinTarget)
34
+ get(this.options.url, { id: pageId })
35
+ .then(async (response) => {
36
+ this.render(await response.data)
37
+ this.handlePageFolders()
38
+ spinner.stop()
39
+ })
40
+ .catch(this.errorHandler)
41
+ }
42
+
43
+ // Watch page folder clicks and re-render the page branch
44
+ handlePageFolders() {
45
+ on(
46
+ "click",
47
+ "#sitemap",
48
+ ".page_folder",
49
+ function (evt) {
50
+ const spinner = new Alchemy.Spinner("small")
51
+ const pageFolder = evt.target.closest(".page_folder")
52
+ const pageId = pageFolder.dataset.pageId
53
+ pageFolder.innerHTML = ""
54
+ spinner.spin(pageFolder)
55
+
56
+ patch(Alchemy.routes.fold_admin_page_path(pageId))
57
+ .then(async (response) => {
58
+ this.reRender(pageId, await response.data)
59
+ spinner.stop()
60
+ })
61
+ .catch(this.errorHandler)
62
+ }.bind(this)
63
+ )
64
+ }
65
+
66
+ // Renders the sitemap
67
+ render(data) {
68
+ const renderTarget = this.sitemap_wrapper
69
+ const renderTemplate = this.template
70
+
71
+ renderTarget.innerHTML = renderTemplate({ children: data.pages })
72
+ this.items = document
73
+ .getElementById("sitemap")
74
+ .querySelectorAll(".sitemap_page")
75
+ this.sitemap_wrapper = document.getElementById("sitemap-wrapper")
76
+ this._observe()
77
+ PageSorter()
78
+ }
79
+
80
+ reRender(pageId, data) {
81
+ let pageEl = document.getElementById(`page_${pageId}`)
82
+ pageEl.outerHTML = this.list_template({ children: data.pages })
83
+ pageEl = document.getElementById(`page_${pageId}`)
84
+ const sortables = pageEl.querySelectorAll("ul.children")
85
+ createSortables(sortables)
86
+ displayPageFolders()
87
+ }
88
+
89
+ // Filters the sitemap
90
+ filter(term) {
91
+ const results = []
92
+
93
+ this.items.forEach(function (item) {
94
+ if (
95
+ term !== "" &&
96
+ item.getAttribute("name").toLowerCase().indexOf(term) !== -1
97
+ ) {
98
+ item.classList.add("highlight")
99
+ item.classList.remove("no-match")
100
+ results.push(item)
101
+ } else {
102
+ item.classList.add("no-match")
103
+ item.classList.remove("highlight")
104
+ }
105
+ })
106
+ this.filter_field_clear.style.display = "inline-block"
107
+ const { length } = results
108
+
109
+ if (length === 1) {
110
+ this.display.style.display = "block"
111
+ this.display.innerText = `1 ${Alchemy.t("page_found")}`
112
+ results[0].scrollIntoView({ behavior: "smooth", block: "center" })
113
+ } else if (length > 1) {
114
+ this.display.style.display = "block"
115
+ this.display.innerText = `${length} ${Alchemy.t("pages_found")}`
116
+ } else {
117
+ this.items.forEach((item) =>
118
+ item.classList.remove("no-match", "highlight")
119
+ )
120
+ this.display.style.display = "none"
121
+ window.scrollTo({
122
+ top: 0,
123
+ left: 0,
124
+ behavior: "smooth"
125
+ })
126
+ this.filter_field_clear.style.display = "none"
127
+ }
128
+ }
129
+
130
+ // Adds onkey up observer to search field
131
+ _observe() {
132
+ this.search_field.addEventListener("keyup", (evt) => {
133
+ const term = evt.target.value
134
+ this.filter(term.toLowerCase())
135
+ })
136
+ this.search_field.addEventListener("focus", () => key.setScope("search"))
137
+ this.filter_field_clear.addEventListener("click", () => {
138
+ this.search_field.value = ""
139
+ this.filter("")
140
+ return false
141
+ })
142
+ }
143
+
144
+ errorHandler(error) {
145
+ Alchemy.growl(error.message || error, "error")
146
+ console.error(error)
147
+ }
148
+ }