alchemy_cms 8.0.0.a → 8.0.0.c

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 (216) 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/layout-bottom-2-line.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/admin/link_dialog/tabs.rb +1 -1
  18. data/app/components/alchemy/admin/locale_select.rb +38 -0
  19. data/app/components/alchemy/ingredients/datetime_view.rb +4 -2
  20. data/app/controllers/alchemy/admin/attachments_controller.rb +2 -0
  21. data/app/controllers/alchemy/admin/elements_controller.rb +2 -0
  22. data/app/controllers/alchemy/admin/pages_controller.rb +3 -1
  23. data/app/controllers/alchemy/admin/pictures_controller.rb +26 -34
  24. data/app/controllers/alchemy/admin/resources_controller.rb +1 -1
  25. data/app/controllers/alchemy/pages_controller.rb +19 -2
  26. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +1 -0
  27. data/app/decorators/alchemy/ingredient_editor.rb +9 -1
  28. data/app/helpers/alchemy/admin/attachments_helper.rb +5 -5
  29. data/app/helpers/alchemy/admin/base_helper.rb +0 -7
  30. data/app/helpers/alchemy/admin/form_helper.rb +2 -1
  31. data/app/helpers/alchemy/pages_helper.rb +1 -1
  32. data/app/javascript/alchemy_admin/components/auto_submit.js +20 -0
  33. data/app/javascript/alchemy_admin/components/datepicker.js +8 -5
  34. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +3 -2
  35. data/app/javascript/alchemy_admin/components/element_editor.js +25 -15
  36. data/app/javascript/alchemy_admin/components/element_select.js +43 -0
  37. data/app/javascript/alchemy_admin/components/index.js +5 -0
  38. data/app/javascript/alchemy_admin/components/link_buttons.js +6 -2
  39. data/app/javascript/alchemy_admin/components/remote_select.js +5 -1
  40. data/app/javascript/alchemy_admin/components/tinymce.js +93 -16
  41. data/app/javascript/alchemy_admin/dialog.js +1 -1
  42. data/app/javascript/alchemy_admin/file_editors.js +1 -1
  43. data/app/javascript/alchemy_admin/image_loader.js +4 -2
  44. data/app/javascript/alchemy_admin/picture_editors.js +7 -4
  45. data/app/javascript/alchemy_admin/picture_selector.js +4 -4
  46. data/app/jobs/alchemy/delete_picture_job.rb +12 -0
  47. data/app/models/alchemy/attachment.rb +2 -9
  48. data/app/models/alchemy/element.rb +1 -0
  49. data/app/models/alchemy/element_definition.rb +31 -0
  50. data/app/models/alchemy/ingredient.rb +1 -1
  51. data/app/models/alchemy/ingredients/boolean.rb +2 -1
  52. data/app/models/alchemy/language.rb +2 -7
  53. data/app/models/alchemy/page/page_naming.rb +4 -11
  54. data/app/models/alchemy/page/page_natures.rb +16 -11
  55. data/app/models/alchemy/page/publisher.rb +1 -1
  56. data/app/models/alchemy/page.rb +1 -6
  57. data/app/models/alchemy/page_definition.rb +1 -1
  58. data/app/models/alchemy/picture.rb +6 -17
  59. data/app/models/alchemy/resource.rb +15 -2
  60. data/app/models/alchemy/site/layout.rb +1 -0
  61. data/app/models/alchemy/site.rb +1 -6
  62. data/app/models/alchemy/storage_adapter/dragonfly/picture_url.rb +7 -2
  63. data/app/models/alchemy/storage_adapter/dragonfly.rb +24 -2
  64. data/app/models/concerns/alchemy/relatable_resource.rb +28 -0
  65. data/app/stylesheets/alchemy/_custom-properties.scss +162 -0
  66. data/app/stylesheets/alchemy/_mixins.scss +12 -24
  67. data/app/stylesheets/alchemy/_themes.scss +540 -0
  68. data/app/stylesheets/alchemy/admin/archive.scss +28 -8
  69. data/app/stylesheets/alchemy/admin/attachments.scss +10 -33
  70. data/app/stylesheets/alchemy/admin/base.scss +4 -1
  71. data/app/stylesheets/alchemy/admin/buttons.scss +7 -32
  72. data/app/stylesheets/alchemy/admin/dashboard.scss +13 -0
  73. data/app/stylesheets/alchemy/admin/dialogs.scss +17 -7
  74. data/app/stylesheets/alchemy/admin/element-select.scss +11 -0
  75. data/app/stylesheets/alchemy/admin/elements.scss +95 -34
  76. data/app/stylesheets/alchemy/admin/filters.scss +8 -9
  77. data/app/stylesheets/alchemy/admin/flatpickr.scss +12 -27
  78. data/app/stylesheets/alchemy/admin/form_fields.scss +0 -15
  79. data/app/stylesheets/alchemy/admin/forms.scss +3 -8
  80. data/app/stylesheets/alchemy/admin/frame.scss +5 -7
  81. data/app/stylesheets/alchemy/admin/icons.scss +0 -9
  82. data/app/stylesheets/alchemy/admin/image_library.scss +13 -55
  83. data/app/stylesheets/alchemy/admin/navigation.scss +1 -11
  84. data/app/stylesheets/alchemy/admin/node-select.scss +1 -10
  85. data/app/stylesheets/alchemy/admin/nodes.scss +6 -2
  86. data/app/stylesheets/alchemy/admin/notices.scss +5 -4
  87. data/app/stylesheets/alchemy/admin/page-select.scss +16 -0
  88. data/app/stylesheets/alchemy/admin/pagination.scss +1 -8
  89. data/app/stylesheets/alchemy/admin/preview_window.scss +12 -1
  90. data/app/stylesheets/alchemy/admin/resource_info.scss +106 -3
  91. data/app/stylesheets/alchemy/admin/search.scss +1 -1
  92. data/app/stylesheets/alchemy/admin/selects.scss +58 -31
  93. data/app/stylesheets/alchemy/admin/shoelace.scss +32 -62
  94. data/app/stylesheets/alchemy/admin/sitemap.scss +7 -18
  95. data/app/stylesheets/alchemy/admin/tables.scss +3 -3
  96. data/app/stylesheets/alchemy/admin/tags.scss +18 -35
  97. data/app/stylesheets/alchemy/admin/toolbar.scss +0 -6
  98. data/app/stylesheets/alchemy/admin/typography.scss +2 -5
  99. data/app/stylesheets/alchemy/admin.scss +1 -1
  100. data/app/stylesheets/alchemy/dark-theme.scss +5 -0
  101. data/app/stylesheets/alchemy/light-theme.scss +6 -0
  102. data/app/stylesheets/alchemy/theme.scss +13 -0
  103. data/app/stylesheets/tinymce/skins/content/alchemy/content.scss +8 -8
  104. data/app/stylesheets/tinymce/skins/content/alchemy-dark/content.scss +70 -0
  105. data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +28 -43
  106. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/content.scss +1 -0
  107. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +3784 -0
  108. data/app/views/alchemy/admin/attachments/_files_list.html.erb +20 -10
  109. data/app/views/alchemy/admin/attachments/assign.js.erb +4 -3
  110. data/app/views/alchemy/admin/attachments/show.html.erb +55 -43
  111. data/app/views/alchemy/admin/crop.html.erb +1 -1
  112. data/app/views/alchemy/admin/dashboard/index.html.erb +1 -1
  113. data/app/views/alchemy/admin/dashboard/info.html.erb +36 -6
  114. data/app/views/alchemy/admin/elements/_form.html.erb +9 -9
  115. data/app/views/alchemy/admin/elements/_header.html.erb +12 -10
  116. data/app/views/alchemy/admin/ingredients/_picture_fields.html.erb +1 -1
  117. data/app/views/alchemy/admin/nodes/_form.html.erb +5 -1
  118. data/app/views/alchemy/admin/pages/info.html.erb +1 -1
  119. data/app/views/alchemy/admin/partials/_search_form.html.erb +1 -0
  120. data/app/views/alchemy/admin/pictures/_archive.html.erb +13 -23
  121. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -6
  122. data/app/views/alchemy/admin/pictures/_form.html.erb +10 -5
  123. data/app/views/alchemy/admin/pictures/_infos.html.erb +21 -52
  124. data/app/views/alchemy/admin/pictures/_library_sidebar.html.erb +7 -0
  125. data/app/views/alchemy/admin/pictures/_picture.html.erb +15 -16
  126. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +20 -16
  127. data/app/views/alchemy/admin/pictures/_sorting_select.html.erb +13 -0
  128. data/app/views/alchemy/admin/pictures/_tag_list.html.erb +1 -1
  129. data/app/views/alchemy/admin/pictures/edit_multiple.html.erb +1 -6
  130. data/app/views/alchemy/admin/pictures/index.html.erb +3 -12
  131. data/app/views/alchemy/admin/pictures/show.html.erb +17 -14
  132. data/app/views/alchemy/admin/pictures/update.turbo_stream.erb +1 -1
  133. data/app/views/alchemy/admin/resources/_filter_bar.html.erb +5 -15
  134. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +36 -0
  135. data/app/views/alchemy/admin/styleguide/index.html.erb +118 -66
  136. data/app/views/alchemy/admin/uploader/_button.html.erb +1 -1
  137. data/app/views/alchemy/base/error_notice.html.erb +1 -1
  138. data/app/views/alchemy/ingredients/_page_editor.html.erb +0 -1
  139. data/app/views/alchemy/ingredients/_richtext_editor.html.erb +0 -1
  140. data/app/views/alchemy/ingredients/_select_editor.html.erb +1 -2
  141. data/app/views/layouts/alchemy/admin.html.erb +25 -23
  142. data/config/locales/alchemy.en.yml +26 -8
  143. data/db/migrate/20250905140323_add_created_at_index_to_pictures_and_attachments.rb +14 -0
  144. data/lib/alchemy/configuration/base_option.rb +18 -5
  145. data/lib/alchemy/configuration/boolean_option.rb +2 -5
  146. data/lib/alchemy/configuration/collection_option.rb +69 -0
  147. data/lib/alchemy/configuration/configuration_option.rb +35 -0
  148. data/lib/alchemy/configuration/pathname_option.rb +12 -0
  149. data/lib/alchemy/configuration.rb +44 -6
  150. data/lib/alchemy/configurations/format_matchers.rb +1 -1
  151. data/lib/alchemy/configurations/importmap.rb +11 -0
  152. data/lib/alchemy/configurations/mailer.rb +2 -2
  153. data/lib/alchemy/configurations/main.rb +148 -3
  154. data/lib/alchemy/configurations/page_cache.rb +19 -0
  155. data/lib/alchemy/configurations/uploader.rb +2 -2
  156. data/lib/alchemy/deprecation.rb +1 -1
  157. data/lib/alchemy/engine.rb +43 -21
  158. data/lib/alchemy/install/tasks.rb +0 -12
  159. data/lib/alchemy/name_conversions.rb +6 -0
  160. data/lib/alchemy/tasks/tidy.rb +18 -0
  161. data/lib/alchemy/test_support/config_stubbing.rb +13 -4
  162. data/lib/alchemy/test_support/factories/language_factory.rb +8 -4
  163. data/lib/alchemy/test_support/factories/page_factory.rb +1 -0
  164. data/lib/alchemy/test_support/factories/picture_factory.rb +1 -0
  165. data/lib/alchemy/test_support/relatable_resource_examples.rb +58 -0
  166. data/lib/alchemy/tinymce.rb +0 -1
  167. data/lib/alchemy/version.rb +1 -1
  168. data/lib/alchemy.rb +18 -171
  169. data/lib/generators/alchemy/install/install_generator.rb +21 -10
  170. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +88 -13
  171. data/lib/tasks/alchemy/assets.rake +1 -1
  172. data/lib/tasks/alchemy/tidy.rake +6 -0
  173. data/lib/tasks/alchemy/usage.rake +2 -0
  174. data/vendor/assets/stylesheets/tinymce/skins/content/dark/content.min.css +1 -0
  175. data/vendor/assets/stylesheets/tinymce/skins/content/default/content.min.css +1 -0
  176. data/vendor/assets/stylesheets/tinymce/skins/ui/oxide/skin.min.css +1 -0
  177. data/vendor/assets/stylesheets/tinymce/skins/ui/oxide-dark/content.min.css +1 -0
  178. data/vendor/assets/stylesheets/tinymce/skins/ui/oxide-dark/skin.min.css +1 -0
  179. data/vendor/javascript/clipboard.min.js +1 -1
  180. data/vendor/javascript/cropperjs.min.js +1 -1
  181. data/vendor/javascript/handlebars.min.js +3 -3
  182. data/vendor/javascript/jquery.min.js +1 -1
  183. data/vendor/javascript/select2.min.js +3 -3
  184. data/vendor/javascript/shoelace.min.js +92 -76
  185. data/vendor/javascript/sortable.min.js +2 -2
  186. data/vendor/javascript/tinymce.min.js +1 -1
  187. data/vendor/javascript/ungap-custom-elements.min.js +2 -2
  188. metadata +51 -36
  189. data/CHANGELOG.md +0 -2100
  190. data/CODE_OF_CONDUCT.md +0 -13
  191. data/CONTRIBUTING.md +0 -73
  192. data/Gemfile +0 -78
  193. data/Rakefile +0 -102
  194. data/SECURITY.md +0 -13
  195. data/alchemy_cms.gemspec +0 -97
  196. data/app/assets/builds/alchemy/custom-properties.css +0 -1
  197. data/app/helpers/alchemy/admin/elements_helper.rb +0 -25
  198. data/app/stylesheets/alchemy/custom-properties.css +0 -244
  199. data/bin/importmap +0 -4
  200. data/bin/rails +0 -9
  201. data/bin/rspec +0 -3
  202. data/bin/setup +0 -30
  203. data/bin/start +0 -17
  204. data/bun.lockb +0 -0
  205. data/bundles/remixicon.mjs +0 -153
  206. data/bundles/shoelace.js +0 -12
  207. data/bundles/tinymce.js +0 -22
  208. data/eslint.config.js +0 -18
  209. data/lib/alchemy/configuration/class_set_option.rb +0 -46
  210. data/lib/alchemy/configuration/integer_list_option.rb +0 -13
  211. data/lib/alchemy/configuration/list_option.rb +0 -22
  212. data/lib/alchemy/configuration/string_list_option.rb +0 -13
  213. data/lib/alchemy/upgrader/.keep +0 -0
  214. data/lib/alchemy/upgrader/tasks/.keep +0 -0
  215. data/rollup.config.mjs +0 -108
  216. data/vitest.config.js +0 -21
@@ -21,10 +21,7 @@ class Datepicker extends AlchemyHTMLElement {
21
21
  await import(`flatpickr/${locale}.js`)
22
22
  }
23
23
 
24
- this.flatpickr = flatpickr(
25
- this.getElementsByTagName("input")[0],
26
- this.flatpickrOptions
27
- )
24
+ this.flatpickr = flatpickr(this.inputField, this.flatpickrOptions)
28
25
  }
29
26
 
30
27
  disconnected() {
@@ -43,7 +40,9 @@ class Datepicker extends AlchemyHTMLElement {
43
40
  noCalendar: this.inputType === "time",
44
41
  time_24hr: translate("formats.time_24hr"),
45
42
  onValueUpdate(_selectedDates, _dateStr, instance) {
46
- instance.element.closest("alchemy-element-editor")?.setDirty()
43
+ instance.element
44
+ .closest("alchemy-element-editor")
45
+ ?.setDirty(this.inputField)
47
46
  }
48
47
  }
49
48
 
@@ -53,6 +52,10 @@ class Datepicker extends AlchemyHTMLElement {
53
52
 
54
53
  return options
55
54
  }
55
+
56
+ get inputField() {
57
+ return this.querySelector("input")
58
+ }
56
59
  }
57
60
 
58
61
  customElements.define("alchemy-datepicker", Datepicker)
@@ -1,3 +1,4 @@
1
+ import ajax from "alchemy_admin/utils/ajax"
1
2
  import { removeTab } from "alchemy_admin/fixed_elements"
2
3
  import { growl } from "alchemy_admin/growler"
3
4
  import { reloadPreview } from "alchemy_admin/components/preview_window"
@@ -12,8 +13,8 @@ export class DeleteElementButton extends HTMLElement {
12
13
  async handleEvent() {
13
14
  const confirmed = await openConfirmDialog(this.message)
14
15
  if (confirmed) {
15
- const response = await fetch(this.url, { method: "DELETE" })
16
- this.#removeElement(await response.json())
16
+ const response = await ajax("DELETE", this.url)
17
+ this.#removeElement(response.data)
17
18
  }
18
19
  }
19
20
 
@@ -20,8 +20,10 @@ export class ElementEditor extends HTMLElement {
20
20
  this.addEventListener("alchemy:element-update-title", this)
21
21
  // We use of @rails/ujs for Rails remote forms
22
22
  this.addEventListener("ajax:complete", this)
23
- // Dirty observer
24
- this.addEventListener("change", this)
23
+
24
+ // Dirty observer still needs to be jQuery
25
+ // in order to support select2.
26
+ $(this).on("change", this.onChange)
25
27
 
26
28
  this.header?.addEventListener("dblclick", () => {
27
29
  this.toggle()
@@ -78,20 +80,22 @@ export class ElementEditor extends HTMLElement {
78
80
  this.setTitle(event.detail.title)
79
81
  }
80
82
  break
81
- case "change":
82
- // SortableJS fires a native change event :/
83
- // and we do not want to set the element editor dirty
84
- // when this happens
85
- if (event.target.classList.contains("nested-elements")) {
86
- return
87
- }
88
- event.stopPropagation()
89
- event.target.classList.add("dirty")
90
- this.setDirty()
91
- break
92
83
  }
93
84
  }
94
85
 
86
+ onChange(event) {
87
+ const target = event.target
88
+ // SortableJS fires a native change event :/
89
+ // and we do not want to set the element editor dirty
90
+ // when this happens
91
+ if (target.classList.contains("nested-elements")) {
92
+ return
93
+ }
94
+ this.setDirty(target)
95
+ event.stopPropagation()
96
+ return false
97
+ }
98
+
95
99
  /**
96
100
  * Scrolls to and highlights element
97
101
  * Expands if collapsed
@@ -227,11 +231,17 @@ export class ElementEditor extends HTMLElement {
227
231
 
228
232
  /**
229
233
  * Sets the element into dirty (unsafed) state
234
+ * @param {HTMLElement} editor
230
235
  */
231
- setDirty() {
236
+ setDirty(editor) {
232
237
  if (this.hasEditors) {
233
238
  this.dirty = true
234
- window.onbeforeunload = (event) => event.preventDefault()
239
+
240
+ if (!window.onbeforeunload) {
241
+ window.onbeforeunload = (event) => event.preventDefault()
242
+ }
243
+
244
+ editor?.closest(".ingredient-editor")?.classList.add("dirty")
235
245
  }
236
246
  }
237
247
 
@@ -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,8 +29,14 @@ 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
 
38
+ DEFAULT_ICON_NAME = "layout-bottom-2-line"
39
+
33
40
  class << self
34
41
  # Returns the definitions from elements.yml file.
35
42
  #
@@ -151,8 +158,32 @@ module Alchemy
151
158
  end
152
159
  end
153
160
 
161
+ def icon_file
162
+ @_icon_file ||= File.read(icon_file_path).html_safe
163
+ end
164
+
165
+ def icon_file_name
166
+ "#{icon_name}.svg"
167
+ end
168
+
169
+ def icon_name
170
+ case icon
171
+ when TrueClass then name
172
+ when String then icon
173
+ else DEFAULT_ICON_NAME
174
+ end
175
+ end
176
+
154
177
  private
155
178
 
179
+ def icon_file_path
180
+ icons_root_path.join("app/assets/images/alchemy/element_icons", icon_file_name)
181
+ end
182
+
183
+ def icons_root_path
184
+ icon.nil? ? Alchemy::Engine.root : Rails.root
185
+ end
186
+
156
187
  def hint_translation_scope
157
188
  :element_hints
158
189
  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
 
@@ -6,7 +6,8 @@ module Alchemy
6
6
  #
7
7
  class Boolean < Alchemy::Ingredient
8
8
  def value
9
- ActiveRecord::Type::Boolean.new.cast(self[:value])
9
+ val = self[:value].nil? ? definition.default : self[:value]
10
+ ActiveRecord::Type::Boolean.new.cast(val)
10
11
  end
11
12
 
12
13
  # The localized value
@@ -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