lato_cms 3.0.10 → 3.0.12

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67cc56a05385cb8ed79d06de5407bae73f23444f0872e02021696ebc1dd4000b
4
- data.tar.gz: 794483334a4c7906ad00a370448ce488650bc6a94b6755a5f84bd2c909fd3676
3
+ metadata.gz: 6fd8416ab840a11e364752d13f21440d8282862df78e4b4b67ef8f69432aa56b
4
+ data.tar.gz: 544411ddd79120e75f9f3df5cf7136d9d0d940cd3ba15abb0720fbf95ec368f7
5
5
  SHA512:
6
- metadata.gz: d020a8b135ea587b61396ed1d8bc8314a9066a165d576e3a31aeb4c127894d0e0652729adf4c3c634a70ef07b075f54de1010d70dcff0ffbdb3f6218e3641fc7
7
- data.tar.gz: 20a54bfd7811d1de1acfe2b44af5012fb5d1f810ab9fc2c84b037431e98b9f28541ae9c2a0dc17061db40670c72ce213670404e158ad40b991a35e2f6a534aec
6
+ metadata.gz: b0d6f6b71abf13818513d8921e6daa52254ea32209d9b0a44ff3b25f3d3d1e83e4dc90e262eeefba8f9ac08a7fe5bf65d518f6a3c638dfabcdfb072b238ba28d
7
+ data.tar.gz: f899bc4fbe56254e67359839b79a29598ba0507d05704d7e8cb8cded33f0675f3499bb6aac33666b0b4f2b5eae8f18614e3b3b0f2f0c68fb00c4b22f136af7ba
@@ -27,6 +27,7 @@ export default class extends Controller {
27
27
 
28
28
  this.inputTarget.value = ''
29
29
  this.syncFileInput()
30
+ this.syncRequired()
30
31
  }
31
32
 
32
33
  remove(event) {
@@ -38,12 +39,14 @@ export default class extends Controller {
38
39
  item.remove()
39
40
  this.syncFileInput()
40
41
  if (!this.multipleValue) this.restoreReplacedItems()
42
+ this.syncRequired()
41
43
  return
42
44
  }
43
45
 
44
46
  item.classList.add('lato-cms-file-field__item--removing')
45
47
  item.querySelector('[data-lato-cms-file-field-target="removedNotice"]')?.classList.remove('d-none')
46
48
  this.ensureRemoveInput(item)
49
+ this.syncRequired()
47
50
  }
48
51
 
49
52
  undo(event) {
@@ -53,6 +56,19 @@ export default class extends Controller {
53
56
  item.classList.remove('lato-cms-file-field__item--removing')
54
57
  item.querySelector('[data-lato-cms-file-field-target="removedNotice"]')?.classList.add('d-none')
55
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
56
72
  }
57
73
 
58
74
  ensureRemoveInput(item) {
@@ -124,6 +140,7 @@ export default class extends Controller {
124
140
  this.itemTargets
125
141
  .filter(item => item.querySelector('[data-remove-file-input]'))
126
142
  .forEach(item => item.remove())
143
+ this.syncRequired()
127
144
  return
128
145
  }
129
146
 
@@ -131,6 +148,7 @@ export default class extends Controller {
131
148
  field.attachments.forEach(attachment => {
132
149
  this.listTarget.appendChild(this.buildExistingItem(attachment))
133
150
  })
151
+ this.syncRequired()
134
152
  }
135
153
 
136
154
  findSavedField(fields) {
@@ -200,4 +218,8 @@ export default class extends Controller {
200
218
  get fieldId() {
201
219
  return this.element.dataset.fieldId
202
220
  }
221
+
222
+ get fieldRequired() {
223
+ return this.element.dataset.fieldRequired === 'true'
224
+ }
203
225
  }
@@ -38,6 +38,7 @@ export default class extends Controller {
38
38
  // Reset input then sync with DataTransfer
39
39
  this.fileInputTarget.value = ''
40
40
  this.syncFileInput()
41
+ this.syncRequired()
41
42
  }
42
43
 
43
44
  // ─── Remove ─────────────────────────────────────────────────────────────────
@@ -62,6 +63,7 @@ export default class extends Controller {
62
63
  item.remove()
63
64
  this.updateEmpty()
64
65
  this.updateOrderInputs()
66
+ this.syncRequired()
65
67
  }
66
68
 
67
69
  // ─── Drag & drop ─────────────────────────────────────────────────────────────
@@ -126,6 +128,17 @@ export default class extends Controller {
126
128
  this.emptyMsgTarget.classList.toggle('d-none', !!hasItems)
127
129
  }
128
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
+
129
142
  buildItem(src, attachmentId, fileUid) {
130
143
  const div = document.createElement('div')
131
144
  div.setAttribute('data-gallery-item', '')
@@ -158,6 +171,7 @@ export default class extends Controller {
158
171
  })
159
172
  this.updateEmpty()
160
173
  this.updateOrderInputs()
174
+ this.syncRequired()
161
175
  }
162
176
 
163
177
  findSavedField(fields) {
@@ -169,4 +183,8 @@ export default class extends Controller {
169
183
  get removeLabel() {
170
184
  return this.element.dataset.removeLabel || 'Remove'
171
185
  }
186
+
187
+ get fieldRequired() {
188
+ return this.element.dataset.fieldRequired === 'true'
189
+ }
172
190
  }
@@ -45,6 +45,7 @@ export default class extends Controller {
45
45
  this.currentContainerTarget.classList.add('lato-cms-file-field__item--removing')
46
46
  this.removedNoticeTarget.classList.remove('d-none')
47
47
  this.ensureRemoveInput()
48
+ this.syncRequired()
48
49
  }
49
50
 
50
51
  undo() {
@@ -54,6 +55,16 @@ export default class extends Controller {
54
55
  this.removedNoticeTarget.classList.add('d-none')
55
56
  this.removeInput?.remove()
56
57
  this.removeInput = null
58
+ this.syncRequired()
59
+ }
60
+
61
+ // The file input only has to be required while no attachment survives the next
62
+ // save: a persisted attachment (not marked for removal) already satisfies the
63
+ // field, so keeping `required` on the emptied input would block every re-save.
64
+ syncRequired() {
65
+ const hasActiveAttachment = this.hasCurrentContainerTarget &&
66
+ !this.currentContainerTarget.classList.contains('lato-cms-file-field__item--removing')
67
+ this.inputTarget.required = this.fieldRequired && !hasActiveAttachment
57
68
  }
58
69
 
59
70
  ensureRemoveInput() {
@@ -82,6 +93,7 @@ export default class extends Controller {
82
93
  if (attachment) {
83
94
  this.element.insertBefore(this.buildCurrentContainer(attachment), this.newPreviewContainerTarget)
84
95
  }
96
+ this.syncRequired()
85
97
  }
86
98
 
87
99
  findSavedField(fields) {
@@ -139,6 +151,10 @@ export default class extends Controller {
139
151
  return this.element.dataset.fieldId
140
152
  }
141
153
 
154
+ get fieldRequired() {
155
+ return this.element.dataset.fieldRequired === 'true'
156
+ }
157
+
142
158
  get removeLabel() {
143
159
  return this.element.dataset.removeLabel || 'Remove'
144
160
  }
@@ -0,0 +1,180 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "newPreview", "newPreviewContainer", "newPreviewName", "newPreviewSize", "currentContainer", "currentVideo", "removedNotice"]
5
+
6
+ connect() {
7
+ this.afterSave = this.afterSave.bind(this)
8
+ this.element.closest('form')?.addEventListener('lato-cms:fields-save-success', this.afterSave)
9
+ }
10
+
11
+ disconnect() {
12
+ this.element.closest('form')?.removeEventListener('lato-cms:fields-save-success', this.afterSave)
13
+ this.revokeObjectUrl()
14
+ }
15
+
16
+ preview() {
17
+ const file = this.inputTarget.files[0]
18
+ if (!file || !file.type.startsWith('video/')) return
19
+
20
+ // Object URL instead of FileReader: videos can be large and the browser
21
+ // streams from the original file without loading it in memory.
22
+ this.revokeObjectUrl()
23
+ this.objectUrl = URL.createObjectURL(file)
24
+ this.newPreviewTarget.src = this.objectUrl
25
+ this.newPreviewNameTarget.textContent = file.name
26
+ this.newPreviewSizeTarget.textContent = `(${this.humanSize(file.size)})`
27
+ this.newPreviewContainerTarget.classList.remove('d-none')
28
+ this.undo()
29
+ if (this.hasCurrentContainerTarget) this.currentContainerTarget.classList.add('d-none')
30
+ }
31
+
32
+ removeNew() {
33
+ this.inputTarget.value = ''
34
+ this.revokeObjectUrl()
35
+ this.newPreviewTarget.removeAttribute('src')
36
+ this.newPreviewNameTarget.textContent = ''
37
+ this.newPreviewSizeTarget.textContent = ''
38
+ this.newPreviewContainerTarget.classList.add('d-none')
39
+ if (this.hasCurrentContainerTarget) this.currentContainerTarget.classList.remove('d-none')
40
+ }
41
+
42
+ remove() {
43
+ if (!this.hasCurrentContainerTarget) return
44
+
45
+ this.currentContainerTarget.classList.add('lato-cms-file-field__item--removing')
46
+ this.removedNoticeTarget.classList.remove('d-none')
47
+ this.ensureRemoveInput()
48
+ this.syncRequired()
49
+ }
50
+
51
+ undo() {
52
+ if (!this.hasCurrentContainerTarget) return
53
+
54
+ this.currentContainerTarget.classList.remove('lato-cms-file-field__item--removing')
55
+ this.removedNoticeTarget.classList.add('d-none')
56
+ this.removeInput?.remove()
57
+ this.removeInput = null
58
+ this.syncRequired()
59
+ }
60
+
61
+ // The file input only has to be required while no attachment survives the next
62
+ // save: a persisted attachment (not marked for removal) already satisfies the
63
+ // field, so keeping `required` on the emptied input would block every re-save.
64
+ syncRequired() {
65
+ const hasActiveAttachment = this.hasCurrentContainerTarget &&
66
+ !this.currentContainerTarget.classList.contains('lato-cms-file-field__item--removing')
67
+ this.inputTarget.required = this.fieldRequired && !hasActiveAttachment
68
+ }
69
+
70
+ ensureRemoveInput() {
71
+ if (this.removeInput) return
72
+
73
+ const input = document.createElement('input')
74
+ input.type = 'hidden'
75
+ input.name = this.inputTarget.name.replace(/\[files\]\[\]$/, '[remove_file_ids][]')
76
+ input.value = this.currentContainerTarget.dataset.attachmentId
77
+ this.element.appendChild(input)
78
+ this.removeInput = input
79
+ }
80
+
81
+ afterSave(event) {
82
+ if (!event.detail.success) return
83
+
84
+ this.inputTarget.value = ''
85
+ this.removeInput = null
86
+ this.revokeObjectUrl()
87
+ const field = this.findSavedField(event.detail.data?.fields || [])
88
+ if (!field) return
89
+
90
+ const attachment = field.attachments[0]
91
+ this.element.querySelector('[data-lato-cms-video-field-target="currentContainer"]')?.remove()
92
+ this.newPreviewContainerTarget.classList.add('d-none')
93
+
94
+ if (attachment) {
95
+ this.element.insertBefore(this.buildCurrentContainer(attachment), this.newPreviewContainerTarget)
96
+ }
97
+ this.syncRequired()
98
+ }
99
+
100
+ findSavedField(fields) {
101
+ return fields.find(field => {
102
+ return field.persisted_field_id === this.fieldId || field.field_id === this.fieldId
103
+ })
104
+ }
105
+
106
+ buildCurrentContainer(attachment) {
107
+ const container = document.createElement('div')
108
+ container.className = 'lato-cms-file-field__item lato-cms-video-field__item'
109
+ container.dataset.latoCmsVideoFieldTarget = 'currentContainer'
110
+ container.dataset.attachmentId = attachment.id
111
+ // poster_url is usually still null right after save: the poster is
112
+ // generated by a background job and shows up on the next page load.
113
+ const poster = attachment.poster_url ? ` poster="${attachment.poster_url}"` : ''
114
+ container.innerHTML = `
115
+ <div class="lato-cms-video-field__preview">
116
+ <video controls preload="metadata" class="lato-cms-video-field__player" src="${attachment.url}"${poster} data-lato-cms-video-field-target="currentVideo"></video>
117
+ </div>
118
+ <div class="lato-cms-file-field__info">
119
+ <i class="bi bi-film"></i>
120
+ <span>${this.escapeHtml(attachment.filename)}</span>
121
+ <span class="text-muted small">(${this.humanSize(attachment.byte_size || 0)})</span>
122
+ </div>
123
+ <button type="button" class="lato-cms-attachment-field__remove" title="${this.removeLabel}" aria-label="${this.removeLabel}" data-action="lato-cms-video-field#remove">
124
+ <i class="bi bi-trash"></i>
125
+ </button>
126
+ <div class="lato-cms-file-field__removed d-none" data-lato-cms-video-field-target="removedNotice">
127
+ <span class="text-danger small">
128
+ <i class="bi bi-trash me-1"></i>${this.removePendingLabel}
129
+ </span>
130
+ <button type="button" class="btn btn-link btn-sm p-0" data-action="lato-cms-video-field#undo">
131
+ ${this.undoLabel}
132
+ </button>
133
+ </div>
134
+ `
135
+ return container
136
+ }
137
+
138
+ revokeObjectUrl() {
139
+ if (!this.objectUrl) return
140
+
141
+ URL.revokeObjectURL(this.objectUrl)
142
+ this.objectUrl = null
143
+ }
144
+
145
+ humanSize(size) {
146
+ if (size < 1024) return `${size} B`
147
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
148
+ return `${(size / 1024 / 1024).toFixed(1)} MB`
149
+ }
150
+
151
+ escapeHtml(value) {
152
+ return value.replace(/[&<>"']/g, char => ({
153
+ '&': '&amp;',
154
+ '<': '&lt;',
155
+ '>': '&gt;',
156
+ '"': '&quot;',
157
+ "'": '&#39;'
158
+ }[char]))
159
+ }
160
+
161
+ get fieldId() {
162
+ return this.element.dataset.fieldId
163
+ }
164
+
165
+ get fieldRequired() {
166
+ return this.element.dataset.fieldRequired === 'true'
167
+ }
168
+
169
+ get removeLabel() {
170
+ return this.element.dataset.removeLabel || 'Remove'
171
+ }
172
+
173
+ get removePendingLabel() {
174
+ return this.element.dataset.removePendingLabel || 'Video will be removed on save'
175
+ }
176
+
177
+ get undoLabel() {
178
+ return this.element.dataset.undoLabel || 'Undo'
179
+ }
180
+ }
@@ -268,6 +268,7 @@ body.controller-pages.action-show {
268
268
 
269
269
  .lato-cms-file-field__info,
270
270
  .lato-cms-image-field__preview,
271
+ .lato-cms-video-field__preview,
271
272
  .lato-cms-attachment-field__remove,
272
273
  > .btn {
273
274
  display: none;
@@ -339,6 +340,27 @@ body.controller-pages.action-show {
339
340
  font-size: 10px;
340
341
  }
341
342
 
343
+ // ── Video field ────────────────────────────────────────────────────────────────
344
+ .lato-cms-video-field__item {
345
+ align-items: stretch;
346
+ }
347
+
348
+ .lato-cms-video-field__preview {
349
+ align-self: center;
350
+ background: #000;
351
+ border: 1px solid rgba(33, 37, 41, 0.08);
352
+ border-radius: 6px;
353
+ flex: 0 0 auto;
354
+ overflow: hidden;
355
+ width: 180px;
356
+ }
357
+
358
+ .lato-cms-video-field__player {
359
+ display: block;
360
+ max-height: 110px;
361
+ width: 100%;
362
+ }
363
+
342
364
  // ── Gallery field ──────────────────────────────────────────────────────────────
343
365
  .lato-cms-gallery-field__grid {
344
366
  display: flex;
@@ -9,6 +9,10 @@ module LatoCms
9
9
  translations link_translation_action unlink_translation_action
10
10
  ].freeze
11
11
 
12
+ # Field types whose value lives in Active Storage attachments instead of
13
+ # `value`, so `required` must be checked against the attached files.
14
+ ATTACHMENT_FIELD_TYPES = %w[file image video gallery].freeze
15
+
12
16
  before_action { active_sidebar(:lato_cms_pages) }
13
17
  before_action :authenticate_lato_cms_admin, only: ADMIN_ONLY_ACTIONS
14
18
 
@@ -288,6 +292,15 @@ module LatoCms
288
292
 
289
293
  unless field.save
290
294
  errors << { field_id: persisted_field_id, errors: field.errors.full_messages }
295
+ return
296
+ end
297
+
298
+ # Attachments are attached/purged here in the controller, so the model
299
+ # cannot validate `required` for attachment fields. Backstop the
300
+ # client-side enforcement: a required field must end the save with at
301
+ # least one file, otherwise required would be bypassable.
302
+ if ATTACHMENT_FIELD_TYPES.include?(field_type) && field_config&.dig("required") == true && field.files.reload.empty?
303
+ errors << { field_id: persisted_field_id, errors: [t("lato_cms.field_required_attachment_error")] }
291
304
  end
292
305
  end
293
306
 
@@ -299,6 +312,9 @@ module LatoCms
299
312
  when 'image'
300
313
  field.save if field.new_record?
301
314
  replace_field_image(field, field_data)
315
+ when 'video'
316
+ field.save if field.new_record?
317
+ replace_field_video(field, field_data)
302
318
  when 'gallery'
303
319
  field.save if field.new_record?
304
320
  attach_field_files(field, field_data)
@@ -315,17 +331,20 @@ module LatoCms
315
331
  end
316
332
  end
317
333
 
318
- def attach_field_files(field, field_data)
319
- return unless field_data[:files].present?
334
+ # Browsers submit empty entries for file inputs with no selection: strip
335
+ # them so `attach` never receives a blank value and the required check
336
+ # sees the field as actually empty.
337
+ def submitted_files(field_data)
338
+ Array(field_data[:files]).reject(&:blank?)
339
+ end
320
340
 
321
- Array(field_data[:files]).compact.each { |file| field.files.attach(file) }
341
+ def attach_field_files(field, field_data)
342
+ submitted_files(field_data).each { |file| field.files.attach(file) }
322
343
  end
323
344
 
324
345
  def assign_file_field_files(field, field_data)
325
- if field.field_settings['multiple'] != true && field_data[:files].present?
326
- new_file = Array(field_data[:files]).compact.first
327
- return unless new_file
328
-
346
+ new_file = submitted_files(field_data).first
347
+ if field.field_settings['multiple'] != true && new_file
329
348
  field.files.each(&:purge)
330
349
  field.files.attach(new_file)
331
350
  else
@@ -343,10 +362,8 @@ module LatoCms
343
362
  end
344
363
 
345
364
  def replace_field_image(field, field_data)
346
- if field_data[:files].present?
347
- new_file = Array(field_data[:files]).compact.first
348
- return unless new_file
349
-
365
+ new_file = submitted_files(field_data).first
366
+ if new_file
350
367
  field.files.each(&:purge)
351
368
  field.files.attach(new_file)
352
369
  else
@@ -354,6 +371,20 @@ module LatoCms
354
371
  end
355
372
  end
356
373
 
374
+ # Single-video semantics: uploading a new video replaces both the previous
375
+ # video and its auto-generated poster; removing the video drops the poster
376
+ # too. Poster generation runs in a background job to keep the save fast.
377
+ def replace_field_video(field, field_data)
378
+ new_file = submitted_files(field_data).first
379
+ if new_file
380
+ field.files.each(&:purge)
381
+ field.files.attach(new_file)
382
+ LatoCms::GenerateVideoPosterJob.perform_later(field.id)
383
+ elsif field_data[:remove_file_ids].present?
384
+ field.files.each(&:purge)
385
+ end
386
+ end
387
+
357
388
  def create_params
358
389
  params.require(:page).permit(:title, :locale)
359
390
  end
@@ -1,6 +1,6 @@
1
1
  module LatoCms
2
2
  module PagesHelper
3
- BUILTIN_FIELD_TYPES = %w[string textarea text number date datetime boolean select multiselect color json file image gallery].freeze
3
+ BUILTIN_FIELD_TYPES = %w[string textarea text number date datetime boolean select multiselect color json file image video gallery].freeze
4
4
 
5
5
  # Returns the path for an Active Storage attachment.
6
6
  # Using main_app avoids the missing `attachment_path` error inside the engine.
@@ -0,0 +1,12 @@
1
+ module LatoCms
2
+ # Generates the poster image for a video field in the background so the save
3
+ # request is not blocked by ffmpeg processing. Failures are swallowed by the
4
+ # model (logged as warnings): a missing poster must never break the field.
5
+ class GenerateVideoPosterJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform(page_field_id)
9
+ LatoCms::PageField.find_by(id: page_field_id)&.generate_video_poster!
10
+ end
11
+ end
12
+ end
@@ -75,6 +75,38 @@ module LatoCms
75
75
  field_config&.dig('settings') || {}
76
76
  end
77
77
 
78
+ # A video field stores two attachments in `files`: the video itself and an
79
+ # optional auto-generated poster image. Content type is the discriminator,
80
+ # so no extra column or naming convention is needed.
81
+ def video_attachment
82
+ files.find { |file| file.content_type.to_s.start_with?("video/") }
83
+ end
84
+
85
+ def poster_attachment
86
+ files.find { |file| file.content_type.to_s.start_with?("image/") }
87
+ end
88
+
89
+ # Generates a poster image from the video via Active Storage previews
90
+ # (ffmpeg) and attaches it alongside the video. Best effort: any failure is
91
+ # logged and the video keeps working without a poster.
92
+ def generate_video_poster!
93
+ video = video_attachment
94
+ return unless video
95
+ return if poster_attachment
96
+
97
+ unless video.previewable?
98
+ Rails.logger.warn("LatoCms: video preview unavailable (ffmpeg missing?) for attachment #{video.id}, skipping poster generation")
99
+ return
100
+ end
101
+
102
+ preview = video.preview(resize_to_limit: [1280, 720]).processed
103
+ preview.image.blob.open do |file|
104
+ files.attach(io: file, filename: "#{video.filename.base}_poster.jpg", content_type: preview.image.blob.content_type)
105
+ end
106
+ rescue StandardError => e
107
+ Rails.logger.warn("LatoCms: failed to generate video poster for field #{id}: #{e.message}")
108
+ end
109
+
78
110
  def as_json(_options = {})
79
111
  result = {
80
112
  id: id,
@@ -93,6 +125,9 @@ module LatoCms
93
125
  when 'image'
94
126
  attached = files.first
95
127
  result[:attachments] = attached ? [attachment_as_json(attached, with_variants: true)] : []
128
+ when 'video'
129
+ attached = video_attachment
130
+ result[:attachments] = attached ? [video_attachment_as_json(attached)] : []
96
131
  when 'gallery'
97
132
  order = value ? (JSON.parse(value) rescue []) : []
98
133
  all_files = files.to_a
@@ -119,6 +154,15 @@ module LatoCms
119
154
  json
120
155
  end
121
156
 
157
+ # Video attachment json gains a `poster_url` key (nil until the poster job
158
+ # runs) so API consumers get video and poster URLs in a single object.
159
+ def video_attachment_as_json(attachment)
160
+ poster = poster_attachment
161
+ attachment_as_json(attachment).merge(
162
+ poster_url: poster && Rails.application.routes.url_helpers.rails_blob_path(poster, only_path: true)
163
+ )
164
+ end
165
+
122
166
  # Builds a map of { size_name => variant_url } from the field's `settings.sizes`.
123
167
  # Variants are processed lazily by Active Storage on first request to their URL.
124
168
  def attachment_variant_urls(attachment)
@@ -13,6 +13,7 @@
13
13
 
14
14
  <div data-controller="lato-cms-file-field"
15
15
  data-field-id="<%= field_id %>"
16
+ data-field-required="<%= required %>"
16
17
  data-lato-cms-file-field-multiple-value="<%= multiple %>"
17
18
  data-new-badge-label="<%= t('lato_cms.field_file_new_badge') %>"
18
19
  data-remove-label="<%= t('lato_cms.field_file_remove') %>"
@@ -49,6 +50,7 @@
49
50
  id="<%= input_id %>"
50
51
  data-lato-cms-file-field-target="input"
51
52
  data-action="change->lato-cms-file-field#addFiles"
53
+ <%= 'required' if required && !page_field&.files&.attached? %>
52
54
  <%= 'multiple' if multiple %>
53
55
  <% if accept %>accept="<%= accept %>"<% end %>>
54
56
  </div>
@@ -20,7 +20,7 @@ end %>
20
20
  <%= label %><%= ' *' if required %>
21
21
  </label>
22
22
 
23
- <div data-controller="lato-cms-gallery-field" data-lato-cms-gallery-field-field-id-value="<%= field_id %>" data-lato-cms-gallery-field-input-name-prefix-value="<%= local_assigns[:input_name_prefix].presence || "fields[#{field_id}]" %>" data-remove-label="<%= t('lato_cms.field_file_remove') %>">
23
+ <div data-controller="lato-cms-gallery-field" data-lato-cms-gallery-field-field-id-value="<%= field_id %>" data-lato-cms-gallery-field-input-name-prefix-value="<%= local_assigns[:input_name_prefix].presence || "fields[#{field_id}]" %>" data-field-required="<%= required %>" data-remove-label="<%= t('lato_cms.field_file_remove') %>">
24
24
  <div class="lato-cms-gallery-field__grid mb-2"
25
25
  data-lato-cms-gallery-field-target="grid"
26
26
  data-action="dragover->lato-cms-gallery-field#onGridDragOver drop->lato-cms-gallery-field#onGridDrop">
@@ -44,13 +44,17 @@ end %>
44
44
 
45
45
  <label class="btn btn-sm btn-outline-secondary">
46
46
  <i class="bi bi-plus-lg me-1"></i><%= t('lato_cms.field_gallery_add_images') %>
47
+ <%# visually-hidden (not d-none): a required display:none input makes native
48
+ validation fail silently with a "not focusable" error, while a clipped
49
+ 1px input stays focusable so the browser can anchor its message here. %>
47
50
  <input type="file"
48
- class="d-none"
51
+ class="visually-hidden"
49
52
  name="<%= files_input_name %>"
50
53
  accept="<%= accept %>"
51
54
  multiple
52
55
  data-lato-cms-gallery-field-target="fileInput"
53
- data-action="change->lato-cms-gallery-field#addFiles">
56
+ data-action="change->lato-cms-gallery-field#addFiles"
57
+ <%= 'required' if required && ordered_files.empty? %>>
54
58
  </label>
55
59
  <small class="text-muted ms-2"><%= t('lato_cms.field_gallery_drag_to_reorder') %></small>
56
60
  </div>
@@ -13,6 +13,7 @@
13
13
 
14
14
  <div data-controller="lato-cms-image-field"
15
15
  data-field-id="<%= field_id %>"
16
+ data-field-required="<%= required %>"
16
17
  data-new-badge-label="<%= t('lato_cms.field_image_new_badge') %>"
17
18
  data-remove-label="<%= t('lato_cms.field_image_remove') %>"
18
19
  data-remove-pending-label="<%= t('lato_cms.field_image_remove_pending') %>"
@@ -0,0 +1,69 @@
1
+ <% label = field_config['name'] || field_id.humanize %>
2
+ <% required = field_config['required'] == true %>
3
+ <% settings = field_config['settings'] || {} %>
4
+ <% accept = settings['accept'] || 'video/*' %>
5
+ <% current_file = page_field&.video_attachment %>
6
+ <% poster_file = page_field&.poster_attachment %>
7
+ <% files_input_name = lato_cms_field_input_name(field_id, 'files', input_name_prefix: local_assigns[:input_name_prefix], multiple: true) %>
8
+ <% remove_input_name = lato_cms_field_input_name(field_id, 'remove_file_ids', input_name_prefix: local_assigns[:input_name_prefix], multiple: true) %>
9
+ <% input_id = lato_cms_field_dom_id(field_id, 'files', dom_id_prefix: local_assigns[:dom_id_prefix]) %>
10
+
11
+ <label class="form-label">
12
+ <%= label %><%= ' *' if required %>
13
+ </label>
14
+
15
+ <div data-controller="lato-cms-video-field"
16
+ data-field-id="<%= field_id %>"
17
+ data-field-required="<%= required %>"
18
+ data-new-badge-label="<%= t('lato_cms.field_video_new_badge') %>"
19
+ data-remove-label="<%= t('lato_cms.field_video_remove') %>"
20
+ data-remove-pending-label="<%= t('lato_cms.field_video_remove_pending') %>"
21
+ data-undo-label="<%= t('lato_cms.field_file_remove_undo') %>">
22
+ <% if current_file %>
23
+ <div class="lato-cms-file-field__item lato-cms-video-field__item" data-lato-cms-video-field-target="currentContainer" data-attachment-id="<%= current_file.id %>">
24
+ <div class="lato-cms-video-field__preview">
25
+ <video controls preload="metadata" class="lato-cms-video-field__player" src="<%= lato_cms_attachment_path(current_file) %>" <% if poster_file %>poster="<%= lato_cms_attachment_path(poster_file) %>"<% end %> data-lato-cms-video-field-target="currentVideo"></video>
26
+ </div>
27
+ <div class="lato-cms-file-field__info">
28
+ <i class="bi bi-film"></i>
29
+ <span><%= current_file.blob.filename %></span>
30
+ <span class="text-muted small">(<%= number_to_human_size(current_file.blob.byte_size) %>)</span>
31
+ </div>
32
+ <button type="button" class="lato-cms-attachment-field__remove" title="<%= t('lato_cms.field_video_remove') %>" aria-label="<%= t('lato_cms.field_video_remove') %>" data-action="lato-cms-video-field#remove">
33
+ <i class="bi bi-trash"></i>
34
+ </button>
35
+ <div class="lato-cms-file-field__removed d-none" data-lato-cms-video-field-target="removedNotice">
36
+ <span class="text-danger small">
37
+ <i class="bi bi-trash me-1"></i><%= t('lato_cms.field_video_remove_pending') %>
38
+ </span>
39
+ <button type="button" class="btn btn-link btn-sm p-0" data-action="lato-cms-video-field#undo">
40
+ <%= t('lato_cms.field_file_remove_undo') %>
41
+ </button>
42
+ </div>
43
+ </div>
44
+ <% end %>
45
+
46
+ <div class="lato-cms-file-field__item lato-cms-file-field__item--new lato-cms-video-field__item d-none" data-lato-cms-video-field-target="newPreviewContainer">
47
+ <div class="lato-cms-video-field__preview">
48
+ <video controls preload="metadata" class="lato-cms-video-field__player" data-lato-cms-video-field-target="newPreview"></video>
49
+ </div>
50
+ <div class="lato-cms-file-field__info">
51
+ <i class="bi bi-film"></i>
52
+ <span data-lato-cms-video-field-target="newPreviewName"></span>
53
+ <span class="text-muted small" data-lato-cms-video-field-target="newPreviewSize"></span>
54
+ <span class="badge bg-success"><%= t('lato_cms.field_video_new_badge') %></span>
55
+ </div>
56
+ <button type="button" class="lato-cms-attachment-field__remove" title="<%= t('lato_cms.field_video_remove') %>" aria-label="<%= t('lato_cms.field_video_remove') %>" data-action="lato-cms-video-field#removeNew">
57
+ <i class="bi bi-trash"></i>
58
+ </button>
59
+ </div>
60
+
61
+ <input type="file"
62
+ class="form-control"
63
+ name="<%= files_input_name %>"
64
+ id="<%= input_id %>"
65
+ accept="<%= accept %>"
66
+ data-lato-cms-video-field-target="input"
67
+ data-action="change->lato-cms-video-field#preview"
68
+ <%= 'required' if required && !current_file %>>
69
+ </div>
@@ -82,6 +82,7 @@ en:
82
82
  repeater_remove_item: Remove
83
83
  repeater_min_error: "At least %{count} items are required"
84
84
  repeater_max_error: "At most %{count} items are allowed"
85
+ field_required_attachment_error: "At least one file is required"
85
86
  fields_component_missing_alert_html: "Component <strong>%{component_id}</strong> not found. The component configuration may have been removed."
86
87
  fields_previously_saved_data: "Previously saved data:"
87
88
  field_file_remove: Remove
@@ -94,6 +95,9 @@ en:
94
95
  field_image_remove: Remove
95
96
  field_image_remove_pending: Image will be removed on save
96
97
  field_image_new_badge: New
98
+ field_video_remove: Remove
99
+ field_video_remove_pending: Video will be removed on save
100
+ field_video_new_badge: New
97
101
  field_multiselect_hint: Hold Ctrl/Cmd to select multiple
98
102
  field_select_placeholder: "-- Select --"
99
103
  field_text_toolbar_bold: Bold
@@ -82,6 +82,7 @@ it:
82
82
  repeater_remove_item: Rimuovi
83
83
  repeater_min_error: "Sono richiesti almeno %{count} elementi"
84
84
  repeater_max_error: "Sono ammessi al massimo %{count} elementi"
85
+ field_required_attachment_error: "È richiesto almeno un file"
85
86
  fields_component_missing_alert_html: "Componente <strong>%{component_id}</strong> non trovato. La configurazione del componente potrebbe essere stata rimossa."
86
87
  fields_previously_saved_data: "Dati salvati in precedenza:"
87
88
  field_file_remove: Rimuovi
@@ -94,6 +95,9 @@ it:
94
95
  field_image_remove: Rimuovi
95
96
  field_image_remove_pending: L'immagine verra rimossa al salvataggio
96
97
  field_image_new_badge: Nuovo
98
+ field_video_remove: Rimuovi
99
+ field_video_remove_pending: Il video verra rimosso al salvataggio
100
+ field_video_new_badge: Nuovo
97
101
  field_multiselect_hint: Tieni premuto Ctrl/Cmd per selezionare piu opzioni
98
102
  field_select_placeholder: "-- Seleziona --"
99
103
  field_text_toolbar_bold: Grassetto
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.0.10"
2
+ VERSION = "3.0.12"
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.10
4
+ version: 3.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante
@@ -103,6 +103,7 @@ files:
103
103
  - app/assets/javascripts/lato_cms/controllers/lato_cms_page_preview_controller.js
104
104
  - app/assets/javascripts/lato_cms/controllers/lato_cms_repeater_controller.js
105
105
  - 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
106
107
  - app/assets/stylesheets/lato_cms/application.scss
107
108
  - app/controllers/lato_cms/api/pages_controller.rb
108
109
  - app/controllers/lato_cms/application_controller.rb
@@ -110,6 +111,7 @@ files:
110
111
  - app/helpers/lato_cms/application_helper.rb
111
112
  - app/helpers/lato_cms/pages_helper.rb
112
113
  - app/jobs/lato_cms/application_job.rb
114
+ - app/jobs/lato_cms/generate_video_poster_job.rb
113
115
  - app/mailers/lato_cms/application_mailer.rb
114
116
  - app/models/lato_cms/page.rb
115
117
  - app/models/lato_cms/page_field.rb
@@ -136,6 +138,7 @@ files:
136
138
  - app/views/lato_cms/pages/fields/_string.html.erb
137
139
  - app/views/lato_cms/pages/fields/_text.html.erb
138
140
  - app/views/lato_cms/pages/fields/_textarea.html.erb
141
+ - app/views/lato_cms/pages/fields/_video.html.erb
139
142
  - app/views/lato_cms/pages/index.html.erb
140
143
  - app/views/lato_cms/pages/show.html.erb
141
144
  - app/views/lato_cms/pages/translations.html.erb