alchemy_cms 8.0.0.a → 8.0.0.b

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.

Potentially problematic release.


This version of alchemy_cms might be problematic. Click here for more details.

Files changed (182) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -0
  3. data/app/assets/builds/alchemy/admin/page-select.css +1 -1
  4. data/app/assets/builds/alchemy/admin.css +1 -1
  5. data/app/assets/builds/alchemy/dark-theme.css +1 -0
  6. data/app/assets/builds/alchemy/light-theme.css +1 -0
  7. data/app/assets/builds/alchemy/theme.css +1 -0
  8. data/app/assets/builds/alchemy/welcome.css +1 -1
  9. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  10. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -0
  11. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  12. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/content.min.css +1 -0
  13. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -0
  14. data/app/assets/images/alchemy/element_icons/default.svg +1 -0
  15. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  16. data/app/components/alchemy/admin/element_select.rb +39 -0
  17. data/app/components/alchemy/ingredients/datetime_view.rb +4 -2
  18. data/app/controllers/alchemy/admin/attachments_controller.rb +2 -0
  19. data/app/controllers/alchemy/admin/elements_controller.rb +2 -0
  20. data/app/controllers/alchemy/admin/pages_controller.rb +2 -0
  21. data/app/controllers/alchemy/admin/pictures_controller.rb +19 -33
  22. data/app/controllers/alchemy/pages_controller.rb +19 -2
  23. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +1 -0
  24. data/app/helpers/alchemy/admin/attachments_helper.rb +5 -5
  25. data/app/helpers/alchemy/pages_helper.rb +1 -1
  26. data/app/javascript/alchemy_admin/components/auto_submit.js +20 -0
  27. data/app/javascript/alchemy_admin/components/datepicker.js +8 -5
  28. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +3 -2
  29. data/app/javascript/alchemy_admin/components/element_editor.js +25 -15
  30. data/app/javascript/alchemy_admin/components/element_select.js +43 -0
  31. data/app/javascript/alchemy_admin/components/index.js +5 -0
  32. data/app/javascript/alchemy_admin/components/link_buttons.js +6 -2
  33. data/app/javascript/alchemy_admin/components/remote_select.js +5 -1
  34. data/app/javascript/alchemy_admin/components/tinymce.js +93 -16
  35. data/app/javascript/alchemy_admin/dialog.js +1 -1
  36. data/app/javascript/alchemy_admin/file_editors.js +1 -1
  37. data/app/javascript/alchemy_admin/image_loader.js +4 -2
  38. data/app/javascript/alchemy_admin/picture_editors.js +7 -4
  39. data/app/javascript/alchemy_admin/picture_selector.js +4 -4
  40. data/app/jobs/alchemy/delete_picture_job.rb +12 -0
  41. data/app/models/alchemy/attachment.rb +2 -9
  42. data/app/models/alchemy/element.rb +1 -0
  43. data/app/models/alchemy/element_definition.rb +30 -0
  44. data/app/models/alchemy/ingredient.rb +1 -1
  45. data/app/models/alchemy/language.rb +2 -7
  46. data/app/models/alchemy/page/page_naming.rb +4 -11
  47. data/app/models/alchemy/page/page_natures.rb +16 -11
  48. data/app/models/alchemy/page.rb +1 -6
  49. data/app/models/alchemy/page_definition.rb +1 -1
  50. data/app/models/alchemy/picture.rb +6 -17
  51. data/app/models/alchemy/site/layout.rb +1 -0
  52. data/app/models/alchemy/site.rb +1 -6
  53. data/app/models/alchemy/storage_adapter/dragonfly/picture_url.rb +7 -2
  54. data/app/models/alchemy/storage_adapter/dragonfly.rb +24 -2
  55. data/app/models/concerns/alchemy/relatable_resource.rb +28 -0
  56. data/app/stylesheets/alchemy/_custom-properties.scss +162 -0
  57. data/app/stylesheets/alchemy/_mixins.scss +12 -24
  58. data/app/stylesheets/alchemy/_themes.scss +540 -0
  59. data/app/stylesheets/alchemy/admin/archive.scss +28 -8
  60. data/app/stylesheets/alchemy/admin/attachments.scss +10 -33
  61. data/app/stylesheets/alchemy/admin/base.scss +4 -1
  62. data/app/stylesheets/alchemy/admin/buttons.scss +7 -32
  63. data/app/stylesheets/alchemy/admin/dialogs.scss +17 -7
  64. data/app/stylesheets/alchemy/admin/element-select.scss +11 -0
  65. data/app/stylesheets/alchemy/admin/elements.scss +94 -33
  66. data/app/stylesheets/alchemy/admin/filters.scss +8 -9
  67. data/app/stylesheets/alchemy/admin/flatpickr.scss +12 -27
  68. data/app/stylesheets/alchemy/admin/form_fields.scss +0 -15
  69. data/app/stylesheets/alchemy/admin/forms.scss +3 -8
  70. data/app/stylesheets/alchemy/admin/frame.scss +5 -7
  71. data/app/stylesheets/alchemy/admin/icons.scss +0 -9
  72. data/app/stylesheets/alchemy/admin/image_library.scss +13 -55
  73. data/app/stylesheets/alchemy/admin/navigation.scss +1 -11
  74. data/app/stylesheets/alchemy/admin/node-select.scss +1 -10
  75. data/app/stylesheets/alchemy/admin/notices.scss +5 -4
  76. data/app/stylesheets/alchemy/admin/page-select.scss +16 -0
  77. data/app/stylesheets/alchemy/admin/pagination.scss +1 -8
  78. data/app/stylesheets/alchemy/admin/preview_window.scss +12 -1
  79. data/app/stylesheets/alchemy/admin/resource_info.scss +106 -3
  80. data/app/stylesheets/alchemy/admin/search.scss +1 -1
  81. data/app/stylesheets/alchemy/admin/selects.scss +58 -31
  82. data/app/stylesheets/alchemy/admin/shoelace.scss +32 -62
  83. data/app/stylesheets/alchemy/admin/sitemap.scss +1 -1
  84. data/app/stylesheets/alchemy/admin/tables.scss +3 -3
  85. data/app/stylesheets/alchemy/admin/tags.scss +18 -35
  86. data/app/stylesheets/alchemy/admin/toolbar.scss +0 -6
  87. data/app/stylesheets/alchemy/admin/typography.scss +2 -5
  88. data/app/stylesheets/alchemy/admin.scss +1 -1
  89. data/app/stylesheets/alchemy/dark-theme.scss +5 -0
  90. data/app/stylesheets/alchemy/light-theme.scss +6 -0
  91. data/app/stylesheets/alchemy/theme.scss +13 -0
  92. data/app/stylesheets/tinymce/skins/content/alchemy/content.scss +8 -8
  93. data/app/stylesheets/tinymce/skins/content/alchemy-dark/content.scss +70 -0
  94. data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +28 -43
  95. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/content.scss +1 -0
  96. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +3784 -0
  97. data/app/views/alchemy/admin/attachments/_files_list.html.erb +20 -10
  98. data/app/views/alchemy/admin/attachments/assign.js.erb +4 -3
  99. data/app/views/alchemy/admin/attachments/show.html.erb +55 -43
  100. data/app/views/alchemy/admin/crop.html.erb +1 -1
  101. data/app/views/alchemy/admin/elements/_form.html.erb +9 -9
  102. data/app/views/alchemy/admin/elements/_header.html.erb +4 -1
  103. data/app/views/alchemy/admin/ingredients/_picture_fields.html.erb +1 -1
  104. data/app/views/alchemy/admin/pages/info.html.erb +1 -1
  105. data/app/views/alchemy/admin/partials/_search_form.html.erb +1 -0
  106. data/app/views/alchemy/admin/pictures/_archive.html.erb +12 -22
  107. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -6
  108. data/app/views/alchemy/admin/pictures/_form.html.erb +1 -1
  109. data/app/views/alchemy/admin/pictures/_infos.html.erb +21 -52
  110. data/app/views/alchemy/admin/pictures/_library_sidebar.html.erb +7 -0
  111. data/app/views/alchemy/admin/pictures/_picture.html.erb +14 -20
  112. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +20 -16
  113. data/app/views/alchemy/admin/pictures/_sorting_select.html.erb +13 -0
  114. data/app/views/alchemy/admin/pictures/_tag_list.html.erb +1 -1
  115. data/app/views/alchemy/admin/pictures/edit_multiple.html.erb +1 -6
  116. data/app/views/alchemy/admin/pictures/index.html.erb +3 -12
  117. data/app/views/alchemy/admin/pictures/show.html.erb +10 -5
  118. data/app/views/alchemy/admin/resources/_filter_bar.html.erb +5 -15
  119. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +36 -0
  120. data/app/views/alchemy/admin/styleguide/index.html.erb +118 -66
  121. data/app/views/alchemy/base/error_notice.html.erb +1 -1
  122. data/app/views/alchemy/ingredients/_page_editor.html.erb +0 -1
  123. data/app/views/alchemy/ingredients/_richtext_editor.html.erb +0 -1
  124. data/app/views/alchemy/ingredients/_select_editor.html.erb +1 -2
  125. data/app/views/layouts/alchemy/admin.html.erb +22 -17
  126. data/config/locales/alchemy.en.yml +26 -8
  127. data/db/migrate/20250905140323_add_created_at_index_to_pictures_and_attachments.rb +14 -0
  128. data/lib/alchemy/configurations/format_matchers.rb +1 -1
  129. data/lib/alchemy/configurations/main.rb +7 -0
  130. data/lib/alchemy/configurations/page_cache.rb +19 -0
  131. data/lib/alchemy/engine.rb +16 -7
  132. data/lib/alchemy/install/tasks.rb +0 -12
  133. data/lib/alchemy/name_conversions.rb +6 -0
  134. data/lib/alchemy/tasks/tidy.rb +18 -0
  135. data/lib/alchemy/test_support/factories/picture_factory.rb +1 -0
  136. data/lib/alchemy/test_support/relatable_resource_examples.rb +58 -0
  137. data/lib/alchemy/tinymce.rb +0 -1
  138. data/lib/alchemy/version.rb +1 -1
  139. data/lib/alchemy.rb +2 -11
  140. data/lib/generators/alchemy/install/install_generator.rb +21 -10
  141. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +10 -6
  142. data/lib/tasks/alchemy/tidy.rake +6 -0
  143. data/lib/tasks/alchemy/usage.rake +2 -0
  144. data/vendor/assets/stylesheets/tinymce/skins/content/dark/content.min.css +1 -0
  145. data/vendor/assets/stylesheets/tinymce/skins/content/default/content.min.css +1 -0
  146. data/vendor/assets/stylesheets/tinymce/skins/ui/oxide/skin.min.css +1 -0
  147. data/vendor/assets/stylesheets/tinymce/skins/ui/oxide-dark/content.min.css +1 -0
  148. data/vendor/assets/stylesheets/tinymce/skins/ui/oxide-dark/skin.min.css +1 -0
  149. data/vendor/javascript/clipboard.min.js +1 -1
  150. data/vendor/javascript/cropperjs.min.js +1 -1
  151. data/vendor/javascript/handlebars.min.js +3 -3
  152. data/vendor/javascript/jquery.min.js +1 -1
  153. data/vendor/javascript/select2.min.js +3 -3
  154. data/vendor/javascript/shoelace.min.js +92 -76
  155. data/vendor/javascript/sortable.min.js +2 -2
  156. data/vendor/javascript/tinymce.min.js +1 -1
  157. data/vendor/javascript/ungap-custom-elements.min.js +2 -2
  158. metadata +46 -32
  159. data/CHANGELOG.md +0 -2100
  160. data/CODE_OF_CONDUCT.md +0 -13
  161. data/CONTRIBUTING.md +0 -73
  162. data/Gemfile +0 -78
  163. data/Rakefile +0 -102
  164. data/SECURITY.md +0 -13
  165. data/alchemy_cms.gemspec +0 -97
  166. data/app/assets/builds/alchemy/custom-properties.css +0 -1
  167. data/app/helpers/alchemy/admin/elements_helper.rb +0 -25
  168. data/app/stylesheets/alchemy/custom-properties.css +0 -244
  169. data/bin/importmap +0 -4
  170. data/bin/rails +0 -9
  171. data/bin/rspec +0 -3
  172. data/bin/setup +0 -30
  173. data/bin/start +0 -17
  174. data/bun.lockb +0 -0
  175. data/bundles/remixicon.mjs +0 -153
  176. data/bundles/shoelace.js +0 -12
  177. data/bundles/tinymce.js +0 -22
  178. data/eslint.config.js +0 -18
  179. data/lib/alchemy/upgrader/.keep +0 -0
  180. data/lib/alchemy/upgrader/tasks/.keep +0 -0
  181. data/rollup.config.mjs +0 -108
  182. data/vitest.config.js +0 -21
@@ -0,0 +1,43 @@
1
+ import { hightlightTerm } from "alchemy_admin/components/remote_select"
2
+
3
+ const formatItem = (icon, text) => {
4
+ return `<div class="element-select-item">${icon} ${text}</div>`
5
+ }
6
+
7
+ class ElementSelect extends HTMLInputElement {
8
+ constructor() {
9
+ super()
10
+ this.classList.add("alchemy_selectbox")
11
+ }
12
+
13
+ connectedCallback() {
14
+ const el = this
15
+ const options = {
16
+ minimumResultsForSearch: 3,
17
+ dropdownAutoWidth: true,
18
+ data() {
19
+ return { results: JSON.parse(el.dataset.options) }
20
+ },
21
+ formatResult: (option, _el, search) => {
22
+ let text
23
+
24
+ if (option.id === "") return option.text
25
+ if (search.term !== "") {
26
+ text = hightlightTerm(option.text, search.term)
27
+ } else {
28
+ text = option.text
29
+ }
30
+
31
+ return formatItem(option.icon, text)
32
+ },
33
+ formatSelection: (option) => {
34
+ return formatItem(option.icon, option.text)
35
+ }
36
+ }
37
+ $(this).select2(options)
38
+ }
39
+ }
40
+
41
+ customElements.define("alchemy-element-select", ElementSelect, {
42
+ extends: "input"
43
+ })
@@ -1,5 +1,9 @@
1
+ "use strict"
2
+ // ^ add support for top-level await in Terser
3
+
1
4
  import "alchemy_admin/components/action"
2
5
  import "alchemy_admin/components/attachment_select"
6
+ import "alchemy_admin/components/auto_submit"
3
7
  import "alchemy_admin/components/button"
4
8
  import "alchemy_admin/components/char_counter"
5
9
  import "alchemy_admin/components/clipboard_button"
@@ -7,6 +11,7 @@ import "alchemy_admin/components/datepicker"
7
11
  import "alchemy_admin/components/dialog_link"
8
12
  import "alchemy_admin/components/dom_id_select"
9
13
  import "alchemy_admin/components/element_editor"
14
+ import "alchemy_admin/components/element_select"
10
15
  import "alchemy_admin/components/elements_window"
11
16
  import "alchemy_admin/components/elements_window_handle"
12
17
  import "alchemy_admin/components/list_filter"
@@ -27,7 +27,7 @@ class LinkButtons extends HTMLElement {
27
27
  this.linkTargetField.value = data.target
28
28
 
29
29
  this.unlinkButton.linked = true
30
- this.elementEditor.setDirty()
30
+ this.setElementDirty()
31
31
  }
32
32
 
33
33
  removeLink() {
@@ -40,7 +40,11 @@ class LinkButtons extends HTMLElement {
40
40
  this.linkButton.classList.remove("linked")
41
41
  this.unlinkButton.linked = false
42
42
 
43
- this.elementEditor.setDirty()
43
+ this.setElementDirty()
44
+ }
45
+
46
+ setElementDirty() {
47
+ this.elementEditor.setDirty(this)
44
48
  }
45
49
 
46
50
  get linkButton() {
@@ -1,6 +1,10 @@
1
1
  import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
2
2
  import { setupSelectLocale } from "alchemy_admin/i18n"
3
3
 
4
+ export function hightlightTerm(name, term) {
5
+ return name.replace(new RegExp(term, "gi"), (match) => `<em>${match}</em>`)
6
+ }
7
+
4
8
  export class RemoteSelect extends AlchemyHTMLElement {
5
9
  static properties = {
6
10
  allowClear: { default: false },
@@ -148,6 +152,6 @@ export class RemoteSelect extends AlchemyHTMLElement {
148
152
  * @private
149
153
  */
150
154
  _hightlightTerm(name, term) {
151
- return name.replace(new RegExp(term, "gi"), (match) => `<em>${match}</em>`)
155
+ return hightlightTerm(name, term)
152
156
  }
153
157
  }
@@ -2,6 +2,9 @@ import "tinymce"
2
2
  import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
3
3
  import { currentLocale } from "alchemy_admin/i18n"
4
4
 
5
+ const DARK_THEME = "alchemy-dark"
6
+ const LIGHT_THEME = "alchemy"
7
+
5
8
  class Tinymce extends AlchemyHTMLElement {
6
9
  #min_height = null
7
10
 
@@ -32,6 +35,9 @@ class Tinymce extends AlchemyHTMLElement {
32
35
  options
33
36
  )
34
37
  this.tinymceIntersectionObserver.observe(this)
38
+
39
+ // Set up theme change listener
40
+ this._setupThemeChangeListener()
35
41
  }
36
42
 
37
43
  /**
@@ -42,6 +48,9 @@ class Tinymce extends AlchemyHTMLElement {
42
48
  this.tinymceIntersectionObserver.disconnect()
43
49
  }
44
50
 
51
+ // Remove theme change listener
52
+ this._removeThemeChangeListener()
53
+
45
54
  tinymce.get(this.editorId)?.remove(this.editorId)
46
55
  }
47
56
 
@@ -66,24 +75,84 @@ class Tinymce extends AlchemyHTMLElement {
66
75
  */
67
76
  _initTinymceEditor() {
68
77
  tinymce.init(this.configuration).then((editors) => {
69
- editors.forEach((editor) => {
70
- // mark the editor container as visible
71
- // without these correction the editor remains hidden
72
- // after a drag and drop action
73
- editor.show()
74
-
75
- // remove the spinner after the Tinymce initialized
76
- this.getElementsByTagName("alchemy-spinner")[0].remove()
77
-
78
- // event listener to mark the editor as dirty
79
- if (this.elementEditor) {
80
- editor.on("dirty", () => this.elementEditor.setDirty())
81
- editor.on("click", () => this.elementEditor.onClickElement(false))
82
- }
83
- })
78
+ editors.forEach((editor) => this._setupEditor(editor))
84
79
  })
85
80
  }
86
81
 
82
+ /**
83
+ * Setup editor after initialization
84
+ * @param {Object} editor - The TinyMCE editor instance
85
+ * @private
86
+ */
87
+ _setupEditor(editor) {
88
+ // mark the editor container as visible
89
+ // without these correction the editor remains hidden
90
+ // after a drag and drop action
91
+ editor.show()
92
+
93
+ // remove the spinner after the Tinymce initialized (only on first init)
94
+ const spinner = this.getElementsByTagName("alchemy-spinner")[0]
95
+ if (spinner) {
96
+ spinner.remove()
97
+ }
98
+
99
+ // event listener to mark the editor as dirty
100
+ if (this.elementEditor) {
101
+ editor.on("dirty", (evt) => {
102
+ this.elementEditor.setDirty(evt.target.editorContainer)
103
+ })
104
+ editor.on("click", () => this.elementEditor.onClickElement(false))
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Set up listener for OS theme changes
110
+ * @private
111
+ */
112
+ _setupThemeChangeListener() {
113
+ this.darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
114
+ this.themeChangeHandler = (event) => this._handleThemeChange(event)
115
+ this.darkModeMediaQuery.addEventListener("change", this.themeChangeHandler)
116
+ }
117
+
118
+ /**
119
+ * Remove theme change listener
120
+ * @private
121
+ */
122
+ _removeThemeChangeListener() {
123
+ if (this.darkModeMediaQuery && this.themeChangeHandler) {
124
+ this.darkModeMediaQuery.removeEventListener(
125
+ "change",
126
+ this.themeChangeHandler
127
+ )
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Handle OS theme change and update TinyMCE skin
133
+ * @param {MediaQueryListEvent} event - The media query change event
134
+ * @private
135
+ */
136
+ _handleThemeChange(event) {
137
+ const editor = tinymce.get(this.editorId)
138
+ if (editor) {
139
+ const skin = event.matches ? DARK_THEME : LIGHT_THEME
140
+ const content_css = event.matches ? DARK_THEME : LIGHT_THEME
141
+
142
+ // Update the skin by reinitializing the editor with new configuration
143
+ editor.remove()
144
+ tinymce
145
+ .init({
146
+ content_css,
147
+ ...this.configuration,
148
+ skin
149
+ })
150
+ .then((editors) => {
151
+ editors.forEach((editor) => this._setupEditor(editor))
152
+ })
153
+ }
154
+ }
155
+
87
156
  get configuration() {
88
157
  const customConfig = {}
89
158
 
@@ -103,10 +172,12 @@ class Tinymce extends AlchemyHTMLElement {
103
172
  })
104
173
 
105
174
  const config = {
175
+ content_css: this.preferredTheme,
106
176
  ...Alchemy.TinymceDefaults,
107
177
  ...customConfig,
108
178
  language: currentLocale(),
109
- selector: `#${this.editorId}`
179
+ selector: `#${this.editorId}`,
180
+ skin: this.preferredTheme
110
181
  }
111
182
 
112
183
  // Tinymce has a height of 400px by default
@@ -117,6 +188,12 @@ class Tinymce extends AlchemyHTMLElement {
117
188
  return config
118
189
  }
119
190
 
191
+ get preferredTheme() {
192
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
193
+ ? DARK_THEME
194
+ : LIGHT_THEME
195
+ }
196
+
120
197
  get editorId() {
121
198
  return this.editor.id
122
199
  }
@@ -233,7 +233,7 @@ export class Dialog {
233
233
  this.dialog_header = $('<div class="alchemy-dialog-header" />')
234
234
  this.dialog_title = $('<div class="alchemy-dialog-title" />')
235
235
  this.close_button = $(
236
- '<a class="alchemy-dialog-close"><alchemy-icon name="close"></alchemy-icon></a>'
236
+ '<button class="alchemy-dialog-close"><alchemy-icon name="close"></alchemy-icon></button>'
237
237
  )
238
238
  this.dialog_title.text(this.options.title)
239
239
  this.dialog_header.append(this.dialog_title)
@@ -16,7 +16,7 @@ class FileEditor {
16
16
  this.fileIcon.innerHTML = ""
17
17
  this.fileName.innerHTML = ""
18
18
  this.deleteLink.classList.add("hidden")
19
- this.container.closest("alchemy-element-editor").setDirty()
19
+ this.container.closest("alchemy-element-editor").setDirty(this.formField)
20
20
  return false
21
21
  }
22
22
  }
@@ -1,6 +1,8 @@
1
1
  // Shows spinner while loading images and
2
2
  // fades the image after its been loaded
3
3
 
4
+ import Spinner from "alchemy_admin/spinner"
5
+
4
6
  export default class ImageLoader {
5
7
  static init(scope = document) {
6
8
  if (typeof scope === "string") {
@@ -15,7 +17,7 @@ export default class ImageLoader {
15
17
  constructor(image) {
16
18
  this.image = image
17
19
  this.parent = image.parentNode
18
- this.spinner = new Alchemy.Spinner("small")
20
+ this.spinner = new Spinner("small")
19
21
  this.bind()
20
22
  }
21
23
 
@@ -28,7 +30,7 @@ export default class ImageLoader {
28
30
  if (!force && this.image.complete) return
29
31
 
30
32
  this.image.classList.add("loading")
31
- this.spinner.spin(this.image.parentElement)
33
+ this.spinner.spin(this.parent)
32
34
  }
33
35
 
34
36
  onLoaded() {
@@ -5,7 +5,7 @@ import { growl } from "alchemy_admin/growler"
5
5
  import ImageLoader from "alchemy_admin/image_loader"
6
6
 
7
7
  const UPDATE_DELAY = 125
8
- const IMAGE_PLACEHOLDER = '<alchemy-icon name="image"></alchemy-icon>'
8
+ const IMAGE_PLACEHOLDER = '<alchemy-icon name="image" size="xl"></alchemy-icon>'
9
9
  const THUMBNAIL_SIZE = "160x120"
10
10
 
11
11
  export class PictureEditor {
@@ -75,6 +75,7 @@ export class PictureEditor {
75
75
  this.image.src = data.url
76
76
  this.image.alt = data.alt
77
77
  this.image.title = data.title
78
+ this.setElementDirty()
78
79
  })
79
80
  .catch((error) => {
80
81
  console.error(error.message || error)
@@ -83,8 +84,6 @@ export class PictureEditor {
83
84
  }
84
85
 
85
86
  ensureImage() {
86
- if (this.image) return
87
-
88
87
  const img = new Image()
89
88
  this.thumbnailBackground.replaceChildren(img)
90
89
  this.image = img
@@ -96,7 +95,11 @@ export class PictureEditor {
96
95
  this.pictureIdField.value = ""
97
96
  this.image = null
98
97
  this.cropLink.classList.add("disabled")
99
- this.container.closest(".element-editor").setDirty()
98
+ this.setElementDirty()
99
+ }
100
+
101
+ setElementDirty() {
102
+ this.container.closest(".element-editor").setDirty(this.container)
100
103
  }
101
104
 
102
105
  updateCropLink() {
@@ -15,13 +15,13 @@ function checkedInputs() {
15
15
  }
16
16
 
17
17
  function editMultiplePicturesUrl(href) {
18
- const searchParameters = new URLSearchParams()
18
+ const url = new URL(href)
19
+
19
20
  checkedInputs().forEach((entry) =>
20
- searchParameters.append(entry.name, entry.value)
21
+ url.searchParams.append(entry.name, entry.value)
21
22
  )
22
- const url = href + "?" + searchParameters.toString()
23
23
 
24
- return url
24
+ return url.toString()
25
25
  }
26
26
 
27
27
  /**
@@ -0,0 +1,12 @@
1
+ module Alchemy
2
+ class DeletePictureJob < BaseJob
3
+ queue_as :default
4
+
5
+ def perform(picture_id)
6
+ picture = Alchemy::Picture.find_by(id: picture_id)
7
+ return if picture.nil? || !picture.deletable?
8
+
9
+ picture.destroy
10
+ end
11
+ end
12
+ end
@@ -23,19 +23,12 @@ module Alchemy
23
23
  include Alchemy::NameConversions
24
24
  include Alchemy::Taggable
25
25
  include Alchemy::TouchElements
26
+ include Alchemy::RelatableResource
26
27
 
27
28
  include Alchemy.storage_adapter.attachment_class_methods
28
29
 
29
30
  stampable stamper_class_name: Alchemy.user_class_name
30
31
 
31
- has_many :file_ingredients,
32
- class_name: "Alchemy::Ingredients::File",
33
- foreign_key: "related_object_id",
34
- inverse_of: :related_object
35
-
36
- has_many :elements, through: :file_ingredients
37
- has_many :pages, through: :elements
38
-
39
32
  scope :by_file_type, ->(file_type) do
40
33
  Alchemy.storage_adapter.by_file_type_scope(file_type)
41
34
  end
@@ -86,7 +79,7 @@ module Alchemy
86
79
  end
87
80
 
88
81
  def ransackable_scopes(_auth_object = nil)
89
- %i[by_file_type recent last_upload without_tag]
82
+ %i[by_file_type recent last_upload without_tag deletable]
90
83
  end
91
84
  end
92
85
 
@@ -37,6 +37,7 @@ module Alchemy
37
37
  "compact",
38
38
  "deprecated",
39
39
  "hint",
40
+ "icon",
40
41
  "ingredients",
41
42
  "message",
42
43
  "nestable_elements",
@@ -21,6 +21,7 @@ module Alchemy
21
21
  attribute :message
22
22
  attribute :warning
23
23
  attribute :hint
24
+ attribute :icon
24
25
 
25
26
  validates :name,
26
27
  presence: true,
@@ -28,6 +29,10 @@ module Alchemy
28
29
  with: /\A[a-z_-]+\z/
29
30
  }
30
31
 
32
+ validates :icon,
33
+ format: {with: /\A[\w-]+\z/i},
34
+ if: -> { icon.is_a?(String) }
35
+
31
36
  delegate :blank?, to: :name
32
37
 
33
38
  class << self
@@ -151,8 +156,33 @@ module Alchemy
151
156
  end
152
157
  end
153
158
 
159
+ def icon_file
160
+ @_icon_file ||= File.read(icon_file_path).html_safe
161
+ end
162
+
163
+ def icon_file_name
164
+ "#{icon_name}.svg"
165
+ end
166
+
167
+ def icon_name
168
+ case icon
169
+ when TrueClass then name
170
+ when String then icon
171
+ else
172
+ "default"
173
+ end
174
+ end
175
+
154
176
  private
155
177
 
178
+ def icon_file_path
179
+ icons_root_path.join("app/assets/images/alchemy/element_icons", icon_file_name)
180
+ end
181
+
182
+ def icons_root_path
183
+ icon.nil? ? Alchemy::Engine.root : Rails.root
184
+ end
185
+
156
186
  def hint_translation_scope
157
187
  :element_hints
158
188
  end
@@ -54,7 +54,7 @@ module Alchemy
54
54
 
55
55
  define_method :"#{name}_id=" do |id|
56
56
  self.related_object_id = id
57
- self.related_object_type = class_name
57
+ self.related_object_type = id.nil? ? nil : class_name
58
58
  end
59
59
  end
60
60
 
@@ -25,8 +25,8 @@ require_dependency "alchemy/site"
25
25
  module Alchemy
26
26
  class Language < BaseRecord
27
27
  belongs_to :site
28
- has_many :pages, inverse_of: :language
29
- has_many :nodes, inverse_of: :language
28
+ has_many :pages, inverse_of: :language, dependent: :restrict_with_error
29
+ has_many :nodes, inverse_of: :language, dependent: :restrict_with_error
30
30
 
31
31
  before_validation :set_locale, if: -> { locale.blank? }
32
32
 
@@ -54,11 +54,6 @@ module Alchemy
54
54
  after_update :set_pages_language,
55
55
  if: :should_set_pages_language?
56
56
 
57
- before_destroy if: -> { pages.any? } do
58
- errors.add(:pages, :still_present)
59
- throw(:abort)
60
- end
61
-
62
57
  scope :published, -> { where(public: true) }
63
58
  scope :with_root_page, -> { joins(:pages).where(Page.table_name => {language_root: true}) }
64
59
 
@@ -5,6 +5,7 @@ module Alchemy
5
5
  module PageNaming
6
6
  extend ActiveSupport::Concern
7
7
  include NameConversions
8
+
8
9
  RESERVED_URLNAMES = %w[admin messages new]
9
10
 
10
11
  included do
@@ -16,8 +17,7 @@ module Alchemy
16
17
  presence: true, uniqueness: {scope: [:parent_id], case_sensitive: false, unless: -> { parent_id.nil? }}
17
18
  validates :urlname,
18
19
  uniqueness: {scope: [:language_id, :layoutpage], if: -> { urlname.present? }, case_sensitive: false},
19
- exclusion: {in: RESERVED_URLNAMES},
20
- length: {minimum: 3, if: -> { urlname.present? }}
20
+ exclusion: {in: RESERVED_URLNAMES}
21
21
 
22
22
  before_save :set_title,
23
23
  if: -> { title.blank? }
@@ -73,17 +73,10 @@ module Alchemy
73
73
  self[:title] = name
74
74
  end
75
75
 
76
- # Converts the given name into an url friendly string.
77
- #
78
- # Names shorter than 3 will be filled up with dashes,
79
- # so it does not collidate with the language code.
76
+ # Returns the full nested urlname.
80
77
  #
81
- def converted_url_name
82
- url_name = convert_to_urlname(slug.blank? ? name : slug)
83
- url_name.rjust(3, "-")
84
- end
85
-
86
78
  def nested_url_name
79
+ converted_url_name = convert_to_urlname(slug.blank? ? name : slug)
87
80
  if parent&.language_root?
88
81
  converted_url_name
89
82
  else
@@ -13,8 +13,21 @@ module Alchemy
13
13
  language.public? && !!public_version&.public?
14
14
  end
15
15
 
16
+ # Cache-Control max-age duration in seconds.
17
+ #
18
+ # You can set this via the `ALCHEMY_PAGE_CACHE_MAX_AGE` environment variable,
19
+ # in the `Alchemy.config.page_cache_max_age` configuration option,
20
+ # or in the pages definition in `config/alchemy/page_layouts.yml` file.
21
+ #
22
+ # Defaults to 600 seconds.
16
23
  def expiration_time
17
- public_until ? public_until - Time.current : nil
24
+ return 0 unless cache_page?
25
+
26
+ if definition.cache.to_s.match?(/\d+/)
27
+ definition.cache.to_i
28
+ else
29
+ Alchemy.config.page_cache.max_age
30
+ end
18
31
  end
19
32
 
20
33
  def rootpage?
@@ -144,17 +157,9 @@ module Alchemy
144
157
  # @returns Boolean
145
158
  #
146
159
  def cache_page?
147
- return false unless caching_enabled?
148
-
149
- page_layout = PageDefinition.get(self.page_layout)
150
- page_layout.cache != false && page_layout.searchresults != true
151
- end
152
-
153
- private
160
+ return false if !public? || restricted?
154
161
 
155
- def caching_enabled?
156
- Alchemy.config.cache_pages &&
157
- Rails.application.config.action_controller.perform_caching
162
+ definition.cache != false && definition.searchresults != true
158
163
  end
159
164
  end
160
165
  end
@@ -115,7 +115,7 @@ module Alchemy
115
115
  has_many :site_languages, through: :site, source: :languages
116
116
  has_many :folded_pages, dependent: :destroy
117
117
  has_many :legacy_urls, class_name: "Alchemy::LegacyPageUrl", dependent: :destroy
118
- has_many :nodes, class_name: "Alchemy::Node", inverse_of: :page
118
+ has_many :nodes, class_name: "Alchemy::Node", inverse_of: :page, dependent: :restrict_with_error
119
119
  has_many :versions, class_name: "Alchemy::PageVersion", inverse_of: :page, dependent: :destroy
120
120
  has_one :draft_version, -> { drafts }, class_name: "Alchemy::PageVersion"
121
121
  has_one :public_version, -> { published }, class_name: "Alchemy::PageVersion", autosave: -> { persisted? }
@@ -132,11 +132,6 @@ module Alchemy
132
132
  before_create -> { versions.build },
133
133
  if: -> { versions.none? }
134
134
 
135
- before_destroy if: -> { nodes.any? } do
136
- errors.add(:nodes, :still_present)
137
- throw(:abort)
138
- end
139
-
140
135
  before_save :set_language_code,
141
136
  if: -> { language.present? }
142
137
 
@@ -13,7 +13,7 @@ module Alchemy
13
13
  attribute :autogenerate, default: []
14
14
  attribute :layoutpage, :boolean, default: false
15
15
  attribute :unique, :boolean, default: false
16
- attribute :cache, :boolean, default: true
16
+ attribute :cache, default: true
17
17
  attribute :insert_elements_at, :string, default: "bottom"
18
18
  attribute :fixed_attributes, default: {}
19
19
  attribute :searchable, :boolean, default: true
@@ -32,14 +32,8 @@ module Alchemy
32
32
  include Alchemy::NameConversions
33
33
  include Alchemy::Taggable
34
34
  include Alchemy::TouchElements
35
+ include Alchemy::RelatableResource
35
36
 
36
- has_many :picture_ingredients,
37
- class_name: "Alchemy::Ingredients::Picture",
38
- foreign_key: "related_object_id",
39
- inverse_of: :related_object
40
-
41
- has_many :elements, through: :picture_ingredients
42
- has_many :pages, through: :elements
43
37
  has_many :descriptions, class_name: "Alchemy::PictureDescription", dependent: :destroy
44
38
 
45
39
  accepts_nested_attributes_for :descriptions, allow_destroy: true, reject_if: ->(attr) { attr[:text].blank? }
@@ -86,15 +80,16 @@ module Alchemy
86
80
 
87
81
  scope :named, ->(name) { where("#{table_name}.name LIKE ?", "%#{name}%") }
88
82
  scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
89
- scope :deletable,
90
- -> {
91
- where("#{table_name}.id NOT IN (SELECT related_object_id FROM alchemy_ingredients WHERE related_object_type = 'Alchemy::Picture')")
92
- }
93
83
  scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) }
94
84
  scope :by_file_format, ->(file_format) do
95
85
  Alchemy.storage_adapter.by_file_format_scope(file_format)
96
86
  end
97
87
 
88
+ # Case insensitive Ransack searching and sorting for name attribute
89
+ ransacker :name, type: :string do
90
+ arel_table[:name].lower
91
+ end
92
+
98
93
  # Class methods
99
94
 
100
95
  class << self
@@ -251,12 +246,6 @@ module Alchemy
251
246
  pages.any? && pages.not_restricted.blank?
252
247
  end
253
248
 
254
- # Returns true if picture is not assigned to any Picture ingredient.
255
- #
256
- def deletable?
257
- picture_ingredients.empty?
258
- end
259
-
260
249
  def image_file_name
261
250
  Alchemy.storage_adapter.image_file_name(self)
262
251
  end
@@ -3,6 +3,7 @@
3
3
  module Alchemy
4
4
  module Site::Layout
5
5
  extend ActiveSupport::Concern
6
+
6
7
  SITE_DEFINITIONS_FILE = Rails.root.join("config/alchemy/site_layouts.yml")
7
8
 
8
9
  module ClassMethods