lato_cms 3.0.12 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +143 -0
  3. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +110 -0
  4. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +37 -0
  5. data/app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js +37 -0
  6. data/app/assets/javascripts/lato_cms/controllers/lato_cms_repeater_controller.js +40 -0
  7. data/app/assets/stylesheets/lato_cms/application.scss +141 -155
  8. data/app/controllers/lato_cms/application_controller.rb +4 -0
  9. data/app/controllers/lato_cms/media_controller.rb +121 -0
  10. data/app/controllers/lato_cms/pages_controller.rb +75 -91
  11. data/app/helpers/lato_cms/media_helper.rb +50 -0
  12. data/app/jobs/lato_cms/export_sitemap_csv_job.rb +33 -0
  13. data/app/jobs/lato_cms/generate_alt_text_job.rb +21 -0
  14. data/app/jobs/lato_cms/generate_video_poster_job.rb +7 -5
  15. data/app/jobs/lato_cms/translate_component_fields_job.rb +16 -0
  16. data/app/models/lato_cms/llm_client.rb +31 -0
  17. data/app/models/lato_cms/media.rb +270 -0
  18. data/app/models/lato_cms/page.rb +40 -0
  19. data/app/models/lato_cms/page_field.rb +52 -80
  20. data/app/models/lato_cms/page_field_media.rb +8 -0
  21. data/app/views/lato_cms/media/_form_create.html.erb +20 -0
  22. data/app/views/lato_cms/media/_form_update.html.erb +60 -0
  23. data/app/views/lato_cms/media/create.html.erb +13 -0
  24. data/app/views/lato_cms/media/index.html.erb +22 -0
  25. data/app/views/lato_cms/media/picker_action.html.erb +98 -0
  26. data/app/views/lato_cms/media/update.html.erb +13 -0
  27. data/app/views/lato_cms/pages/_component_accordion.html.erb +15 -0
  28. data/app/views/lato_cms/pages/_repeater.html.erb +10 -3
  29. data/app/views/lato_cms/pages/_repeater_item.html.erb +26 -18
  30. data/app/views/lato_cms/pages/fields/_file.html.erb +4 -55
  31. data/app/views/lato_cms/pages/fields/_gallery.html.erb +4 -60
  32. data/app/views/lato_cms/pages/fields/_image.html.erb +4 -68
  33. data/app/views/lato_cms/pages/fields/_media_field.html.erb +62 -0
  34. data/app/views/lato_cms/pages/fields/_media_item.html.erb +27 -0
  35. data/app/views/lato_cms/pages/fields/_video.html.erb +4 -69
  36. data/app/views/lato_cms/pages/index.html.erb +17 -0
  37. data/app/views/lato_cms/pages/show.html.erb +2 -1
  38. data/config/locales/en.yml +36 -12
  39. data/config/locales/it.yml +36 -12
  40. data/config/routes.rb +12 -0
  41. data/db/migrate/20260728175413_create_lato_cms_media.rb +11 -0
  42. data/db/migrate/20260728175414_create_lato_cms_page_field_media.rb +12 -0
  43. data/db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb +25 -0
  44. data/lib/lato_cms/config.rb +12 -1
  45. data/lib/lato_cms/version.rb +1 -1
  46. metadata +38 -5
  47. data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +0 -225
  48. data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +0 -190
  49. data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +0 -169
  50. data/app/assets/javascripts/lato_cms/controllers/lato_cms_video_field_controller.js +0 -180
@@ -3,7 +3,7 @@ module LatoCms
3
3
  # This class contains the default configuration of the engine.
4
4
  ##
5
5
  class Config
6
- attr_accessor :locales, :templates_path, :admin_roles
6
+ attr_accessor :locales, :templates_path, :admin_roles, :llm_api_url, :llm_model, :llm_api_key
7
7
 
8
8
  def initialize
9
9
  @locales = [:en]
@@ -15,6 +15,17 @@ module LatoCms
15
15
  # `operator` has read/edit access; `admin` also manages pages
16
16
  # (create, update, delete) and translation links.
17
17
  @admin_roles = { none: 0, operator: 1, admin: 2 }
18
+
19
+ # Optional OpenAI-compatible endpoint used to auto-generate alt text
20
+ # for uploaded images (see LatoCms::Media#generate_alt_text!). All
21
+ # three must be set for the feature to activate; unset by default.
22
+ @llm_api_url = nil
23
+ @llm_model = nil
24
+ @llm_api_key = nil
25
+ end
26
+
27
+ def llm_configured?
28
+ llm_api_url.present? && llm_model.present? && llm_api_key.present?
18
29
  end
19
30
  end
20
31
  end
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.0.12"
2
+ VERSION = "3.1.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lato_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.12
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '1.2'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ruby-vips
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.0'
82
96
  description: A Rails engine to manage application users on Lato projects!
83
97
  email:
84
98
  - me@gregoriogalante.com
@@ -96,26 +110,40 @@ files:
96
110
  - app/assets/javascripts/lato_cms/controllers/lato_cms_component_accordion_controller.js
97
111
  - app/assets/javascripts/lato_cms/controllers/lato_cms_component_form_controller.js
98
112
  - app/assets/javascripts/lato_cms/controllers/lato_cms_component_toggle_controller.js
99
- - app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js
100
- - app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js
101
- - app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js
102
113
  - app/assets/javascripts/lato_cms/controllers/lato_cms_json_field_controller.js
114
+ - app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js
115
+ - app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js
116
+ - app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js
103
117
  - app/assets/javascripts/lato_cms/controllers/lato_cms_page_preview_controller.js
118
+ - app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js
104
119
  - app/assets/javascripts/lato_cms/controllers/lato_cms_repeater_controller.js
105
120
  - app/assets/javascripts/lato_cms/controllers/lato_cms_text_field_controller.js
106
- - app/assets/javascripts/lato_cms/controllers/lato_cms_video_field_controller.js
107
121
  - app/assets/stylesheets/lato_cms/application.scss
108
122
  - app/controllers/lato_cms/api/pages_controller.rb
109
123
  - app/controllers/lato_cms/application_controller.rb
124
+ - app/controllers/lato_cms/media_controller.rb
110
125
  - app/controllers/lato_cms/pages_controller.rb
111
126
  - app/helpers/lato_cms/application_helper.rb
127
+ - app/helpers/lato_cms/media_helper.rb
112
128
  - app/helpers/lato_cms/pages_helper.rb
113
129
  - app/jobs/lato_cms/application_job.rb
130
+ - app/jobs/lato_cms/export_sitemap_csv_job.rb
131
+ - app/jobs/lato_cms/generate_alt_text_job.rb
114
132
  - app/jobs/lato_cms/generate_video_poster_job.rb
133
+ - app/jobs/lato_cms/translate_component_fields_job.rb
115
134
  - app/mailers/lato_cms/application_mailer.rb
135
+ - app/models/lato_cms/llm_client.rb
136
+ - app/models/lato_cms/media.rb
116
137
  - app/models/lato_cms/page.rb
117
138
  - app/models/lato_cms/page_field.rb
139
+ - app/models/lato_cms/page_field_media.rb
118
140
  - app/models/lato_cms/template_manager.rb
141
+ - app/views/lato_cms/media/_form_create.html.erb
142
+ - app/views/lato_cms/media/_form_update.html.erb
143
+ - app/views/lato_cms/media/create.html.erb
144
+ - app/views/lato_cms/media/index.html.erb
145
+ - app/views/lato_cms/media/picker_action.html.erb
146
+ - app/views/lato_cms/media/update.html.erb
119
147
  - app/views/lato_cms/pages/_component_accordion.html.erb
120
148
  - app/views/lato_cms/pages/_fields_editor.html.erb
121
149
  - app/views/lato_cms/pages/_form_create.html.erb
@@ -132,6 +160,8 @@ files:
132
160
  - app/views/lato_cms/pages/fields/_gallery.html.erb
133
161
  - app/views/lato_cms/pages/fields/_image.html.erb
134
162
  - app/views/lato_cms/pages/fields/_json.html.erb
163
+ - app/views/lato_cms/pages/fields/_media_field.html.erb
164
+ - app/views/lato_cms/pages/fields/_media_item.html.erb
135
165
  - app/views/lato_cms/pages/fields/_multiselect.html.erb
136
166
  - app/views/lato_cms/pages/fields/_number.html.erb
137
167
  - app/views/lato_cms/pages/fields/_select.html.erb
@@ -152,6 +182,9 @@ files:
152
182
  - db/migrate/20260323171241_create_lato_cms_page_fields.rb
153
183
  - db/migrate/20260705000000_add_translation_group_id_to_lato_cms_pages.rb
154
184
  - db/migrate/20260712092241_change_lato_cms_admin_to_lato_cms_admin_role_on_lato_user.rb
185
+ - db/migrate/20260728175413_create_lato_cms_media.rb
186
+ - db/migrate/20260728175414_create_lato_cms_page_field_media.rb
187
+ - db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb
155
188
  - lib/lato_cms.rb
156
189
  - lib/lato_cms/config.rb
157
190
  - lib/lato_cms/engine.rb
@@ -1,225 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus"
2
-
3
- export default class extends Controller {
4
- static targets = ["input", "item", "list", "removedNotice"]
5
- static values = { multiple: Boolean }
6
-
7
- newFileMap = new Map()
8
- nextUid = 0
9
-
10
- connect() {
11
- this.afterSave = this.afterSave.bind(this)
12
- this.element.closest('form')?.addEventListener('lato-cms:fields-save-success', this.afterSave)
13
- }
14
-
15
- disconnect() {
16
- this.element.closest('form')?.removeEventListener('lato-cms:fields-save-success', this.afterSave)
17
- }
18
-
19
- addFiles() {
20
- if (!this.multipleValue) this.prepareSingleReplacement()
21
-
22
- Array.from(this.inputTarget.files).forEach(file => {
23
- const uid = `new_${this.nextUid++}`
24
- this.newFileMap.set(uid, file)
25
- this.listTarget.appendChild(this.buildNewItem(file, uid))
26
- })
27
-
28
- this.inputTarget.value = ''
29
- this.syncFileInput()
30
- this.syncRequired()
31
- }
32
-
33
- remove(event) {
34
- const item = event.currentTarget.closest('[data-lato-cms-file-field-target="item"]')
35
- if (!item) return
36
-
37
- if (item.dataset.fileUid) {
38
- this.newFileMap.delete(item.dataset.fileUid)
39
- item.remove()
40
- this.syncFileInput()
41
- if (!this.multipleValue) this.restoreReplacedItems()
42
- this.syncRequired()
43
- return
44
- }
45
-
46
- item.classList.add('lato-cms-file-field__item--removing')
47
- item.querySelector('[data-lato-cms-file-field-target="removedNotice"]')?.classList.remove('d-none')
48
- this.ensureRemoveInput(item)
49
- this.syncRequired()
50
- }
51
-
52
- undo(event) {
53
- const item = event.currentTarget.closest('[data-lato-cms-file-field-target="item"]')
54
- if (!item) return
55
-
56
- item.classList.remove('lato-cms-file-field__item--removing')
57
- item.querySelector('[data-lato-cms-file-field-target="removedNotice"]')?.classList.add('d-none')
58
- item.querySelector('[data-remove-file-input]')?.remove()
59
- this.syncRequired()
60
- }
61
-
62
- // The file input only has to be required while nothing survives the next save:
63
- // a pending new file or a persisted attachment not marked for removal already
64
- // satisfies the field, so keeping `required` would block every re-save (the
65
- // form saves via fetch without reload, so the attribute must track state).
66
- syncRequired() {
67
- const hasActiveAttachment = this.newFileMap.size > 0 ||
68
- this.itemTargets.some(item => {
69
- return item.dataset.attachmentId && !item.classList.contains('lato-cms-file-field__item--removing')
70
- })
71
- this.inputTarget.required = this.fieldRequired && !hasActiveAttachment
72
- }
73
-
74
- ensureRemoveInput(item) {
75
- if (item.querySelector('[data-remove-file-input]')) return
76
-
77
- const input = document.createElement('input')
78
- input.type = 'hidden'
79
- input.name = this.inputTarget.name.replace(/\[files\]\[\]$/, '[remove_file_ids][]')
80
- input.value = item.dataset.attachmentId
81
- input.setAttribute('data-remove-file-input', '')
82
- item.appendChild(input)
83
- }
84
-
85
- syncFileInput() {
86
- const dt = new DataTransfer()
87
- this.newFileMap.forEach(file => dt.items.add(file))
88
- this.inputTarget.files = dt.files
89
- }
90
-
91
- prepareSingleReplacement() {
92
- this.newFileMap.clear()
93
- this.listTarget.querySelectorAll('[data-file-uid]').forEach(item => item.remove())
94
- this.itemTargets
95
- .filter(item => item.dataset.attachmentId && !item.querySelector('[data-remove-file-input]'))
96
- .forEach(item => {
97
- item.classList.add('d-none')
98
- item.dataset.replacedByNewFile = 'true'
99
- })
100
- }
101
-
102
- restoreReplacedItems() {
103
- if (this.newFileMap.size > 0) return
104
-
105
- this.itemTargets
106
- .filter(item => item.dataset.replacedByNewFile === 'true')
107
- .forEach(item => {
108
- item.classList.remove('d-none')
109
- delete item.dataset.replacedByNewFile
110
- })
111
- }
112
-
113
- buildNewItem(file, uid) {
114
- const item = document.createElement('div')
115
- item.className = 'lato-cms-file-field__item lato-cms-file-field__item--new'
116
- item.dataset.latoCmsFileFieldTarget = 'item'
117
- item.dataset.fileUid = uid
118
- item.innerHTML = `
119
- <div class="lato-cms-file-field__info">
120
- <i class="bi bi-paperclip"></i>
121
- <span>${this.escapeHtml(file.name)}</span>
122
- <span class="text-muted small">(${this.humanSize(file.size)})</span>
123
- <span class="badge bg-success">${this.newBadgeLabel}</span>
124
- </div>
125
- <button type="button" class="lato-cms-attachment-field__remove" title="${this.removeLabel}" aria-label="${this.removeLabel}" data-action="lato-cms-file-field#remove">
126
- <i class="bi bi-trash"></i>
127
- </button>
128
- `
129
- return item
130
- }
131
-
132
- afterSave(event) {
133
- if (!event.detail.success) return
134
-
135
- this.newFileMap.clear()
136
- this.inputTarget.value = ''
137
-
138
- const field = this.findSavedField(event.detail.data?.fields || [])
139
- if (!field) {
140
- this.itemTargets
141
- .filter(item => item.querySelector('[data-remove-file-input]'))
142
- .forEach(item => item.remove())
143
- this.syncRequired()
144
- return
145
- }
146
-
147
- this.listTarget.innerHTML = ''
148
- field.attachments.forEach(attachment => {
149
- this.listTarget.appendChild(this.buildExistingItem(attachment))
150
- })
151
- this.syncRequired()
152
- }
153
-
154
- findSavedField(fields) {
155
- return fields.find(field => {
156
- return field.persisted_field_id === this.fieldId || field.field_id === this.fieldId
157
- })
158
- }
159
-
160
- buildExistingItem(attachment) {
161
- const item = document.createElement('div')
162
- item.className = 'lato-cms-file-field__item'
163
- item.dataset.latoCmsFileFieldTarget = 'item'
164
- item.dataset.attachmentId = attachment.id
165
- item.innerHTML = `
166
- <div class="lato-cms-file-field__info">
167
- <i class="bi bi-paperclip"></i>
168
- <span>${this.escapeHtml(attachment.filename)}</span>
169
- <span class="text-muted small">(${this.humanSize(attachment.byte_size || 0)})</span>
170
- </div>
171
- <button type="button" class="lato-cms-attachment-field__remove" title="${this.removeLabel}" aria-label="${this.removeLabel}" data-action="lato-cms-file-field#remove">
172
- <i class="bi bi-trash"></i>
173
- </button>
174
- <div class="lato-cms-file-field__removed d-none" data-lato-cms-file-field-target="removedNotice">
175
- <span class="text-danger small">
176
- <i class="bi bi-trash me-1"></i>${this.removePendingLabel}
177
- </span>
178
- <button type="button" class="btn btn-link btn-sm p-0" data-action="lato-cms-file-field#undo">
179
- ${this.undoLabel}
180
- </button>
181
- </div>
182
- `
183
- return item
184
- }
185
-
186
- humanSize(size) {
187
- if (size < 1024) return `${size} B`
188
- if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
189
- return `${(size / 1024 / 1024).toFixed(1)} MB`
190
- }
191
-
192
- escapeHtml(value) {
193
- return value.replace(/[&<>"']/g, char => ({
194
- '&': '&amp;',
195
- '<': '&lt;',
196
- '>': '&gt;',
197
- '"': '&quot;',
198
- "'": '&#39;'
199
- }[char]))
200
- }
201
-
202
- get newBadgeLabel() {
203
- return this.element.dataset.newBadgeLabel || 'New'
204
- }
205
-
206
- get removeLabel() {
207
- return this.element.dataset.removeLabel || 'Remove'
208
- }
209
-
210
- get removePendingLabel() {
211
- return this.element.dataset.removePendingLabel || 'File will be removed on save'
212
- }
213
-
214
- get undoLabel() {
215
- return this.element.dataset.undoLabel || 'Undo'
216
- }
217
-
218
- get fieldId() {
219
- return this.element.dataset.fieldId
220
- }
221
-
222
- get fieldRequired() {
223
- return this.element.dataset.fieldRequired === 'true'
224
- }
225
- }
@@ -1,190 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus"
2
-
3
- export default class extends Controller {
4
- static targets = ["grid", "fileInput", "emptyMsg"]
5
- static values = { fieldId: String, inputNamePrefix: String }
6
-
7
- dragging = null
8
- newFileMap = new Map()
9
- nextUid = 0
10
-
11
- connect() {
12
- this.afterSave = this.afterSave.bind(this)
13
- this.element.closest('form')?.addEventListener('lato-cms:fields-save-success', this.afterSave)
14
- }
15
-
16
- disconnect() {
17
- this.element.closest('form')?.removeEventListener('lato-cms:fields-save-success', this.afterSave)
18
- }
19
-
20
- // ─── File selection ─────────────────────────────────────────────────────────
21
-
22
- addFiles() {
23
- Array.from(this.fileInputTarget.files).forEach(file => {
24
- if (!file.type.startsWith('image/')) return
25
- const uid = `new_${this.nextUid++}`
26
- this.newFileMap.set(uid, file)
27
-
28
- const reader = new FileReader()
29
- reader.onload = ({ target }) => {
30
- const item = this.buildItem(target.result, null, uid)
31
- this.gridTarget.appendChild(item)
32
- this.updateEmpty()
33
- this.updateOrderInputs()
34
- }
35
- reader.readAsDataURL(file)
36
- })
37
-
38
- // Reset input then sync with DataTransfer
39
- this.fileInputTarget.value = ''
40
- this.syncFileInput()
41
- this.syncRequired()
42
- }
43
-
44
- // ─── Remove ─────────────────────────────────────────────────────────────────
45
-
46
- remove(event) {
47
- const item = event.currentTarget.closest('[data-gallery-item]')
48
- if (!item) return
49
-
50
- const { attachmentId, fileUid } = item.dataset
51
-
52
- if (attachmentId) {
53
- const input = document.createElement('input')
54
- input.type = 'hidden'
55
- input.name = `${this.inputNamePrefixValue}[remove_file_ids][]`
56
- input.value = attachmentId
57
- this.element.appendChild(input)
58
- } else if (fileUid) {
59
- this.newFileMap.delete(fileUid)
60
- this.syncFileInput()
61
- }
62
-
63
- item.remove()
64
- this.updateEmpty()
65
- this.updateOrderInputs()
66
- this.syncRequired()
67
- }
68
-
69
- // ─── Drag & drop ─────────────────────────────────────────────────────────────
70
-
71
- onDragStart(event) {
72
- this.dragging = event.currentTarget
73
- event.dataTransfer.effectAllowed = 'move'
74
- // Defer class so browser can snapshot the element first
75
- requestAnimationFrame(() => event.currentTarget.classList.add('lato-cms-gallery-field__item--dragging'))
76
- }
77
-
78
- onDragEnd(event) {
79
- event.currentTarget.classList.remove('lato-cms-gallery-field__item--dragging')
80
- this.dragging = null
81
- this.updateOrderInputs()
82
- }
83
-
84
- onGridDragOver(event) {
85
- event.preventDefault()
86
- if (!this.dragging) return
87
-
88
- const target = event.target.closest('[data-gallery-item]')
89
- if (!target || target === this.dragging) return
90
-
91
- const { left, width } = target.getBoundingClientRect()
92
- const insertBefore = event.clientX < left + width / 2
93
- this.gridTarget.insertBefore(this.dragging, insertBefore ? target : target.nextSibling)
94
- }
95
-
96
- onGridDrop(event) {
97
- event.preventDefault()
98
- }
99
-
100
- // ─── Order inputs ────────────────────────────────────────────────────────────
101
-
102
- updateOrderInputs() {
103
- this.element.querySelectorAll('[data-gallery-order-input]').forEach(el => el.remove())
104
-
105
- this.gridTarget.querySelectorAll('[data-gallery-item][data-attachment-id]').forEach(item => {
106
- const id = item.dataset.attachmentId
107
- if (!id) return
108
- const input = document.createElement('input')
109
- input.type = 'hidden'
110
- input.name = `${this.inputNamePrefixValue}[order][]`
111
- input.value = id
112
- input.setAttribute('data-gallery-order-input', '')
113
- this.element.appendChild(input)
114
- })
115
- }
116
-
117
- // ─── Helpers ─────────────────────────────────────────────────────────────────
118
-
119
- syncFileInput() {
120
- const dt = new DataTransfer()
121
- this.newFileMap.forEach(file => dt.items.add(file))
122
- this.fileInputTarget.files = dt.files
123
- }
124
-
125
- updateEmpty() {
126
- if (!this.hasEmptyMsgTarget) return
127
- const hasItems = this.gridTarget.querySelector('[data-gallery-item]')
128
- this.emptyMsgTarget.classList.toggle('d-none', !!hasItems)
129
- }
130
-
131
- // Required enforcement rides on native form validation: the file input is
132
- // `visually-hidden` (not display:none) so the browser can focus it and anchor
133
- // the validation bubble on the "Add images" button. `required` must be set
134
- // only while no image survives the next save — pending files kept in the
135
- // input via DataTransfer or surviving grid items satisfy the field.
136
- syncRequired() {
137
- const hasActiveItem = this.newFileMap.size > 0 ||
138
- !!this.gridTarget.querySelector('[data-gallery-item]')
139
- this.fileInputTarget.required = this.fieldRequired && !hasActiveItem
140
- }
141
-
142
- buildItem(src, attachmentId, fileUid) {
143
- const div = document.createElement('div')
144
- div.setAttribute('data-gallery-item', '')
145
- div.dataset.attachmentId = attachmentId || ''
146
- div.dataset.fileUid = fileUid || ''
147
- div.draggable = true
148
- div.className = 'lato-cms-gallery-field__item'
149
- div.setAttribute('data-action', 'dragstart->lato-cms-gallery-field#onDragStart dragend->lato-cms-gallery-field#onDragEnd')
150
- div.innerHTML = `
151
- <img src="${src}" class="lato-cms-gallery-field__thumb" alt="">
152
- <button type="button" class="lato-cms-attachment-field__remove lato-cms-gallery-field__remove" title="${this.removeLabel}" aria-label="${this.removeLabel}" data-action="click->lato-cms-gallery-field#remove">
153
- <i class="bi bi-trash"></i>
154
- </button>
155
- `
156
- return div
157
- }
158
-
159
- afterSave(event) {
160
- if (!event.detail.success) return
161
-
162
- const field = this.findSavedField(event.detail.data?.fields || [])
163
- if (!field) return
164
-
165
- this.newFileMap.clear()
166
- this.fileInputTarget.value = ''
167
- this.element.querySelectorAll('[name$="[remove_file_ids][]"]').forEach(input => input.remove())
168
- this.gridTarget.innerHTML = ''
169
- field.attachments.forEach(attachment => {
170
- this.gridTarget.appendChild(this.buildItem(attachment.url, attachment.id, null))
171
- })
172
- this.updateEmpty()
173
- this.updateOrderInputs()
174
- this.syncRequired()
175
- }
176
-
177
- findSavedField(fields) {
178
- return fields.find(field => {
179
- return field.persisted_field_id === this.fieldIdValue || field.field_id === this.fieldIdValue
180
- })
181
- }
182
-
183
- get removeLabel() {
184
- return this.element.dataset.removeLabel || 'Remove'
185
- }
186
-
187
- get fieldRequired() {
188
- return this.element.dataset.fieldRequired === 'true'
189
- }
190
- }