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 +4 -4
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +22 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +18 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +16 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_video_field_controller.js +180 -0
- data/app/assets/stylesheets/lato_cms/application.scss +22 -0
- data/app/controllers/lato_cms/pages_controller.rb +42 -11
- data/app/helpers/lato_cms/pages_helper.rb +1 -1
- data/app/jobs/lato_cms/generate_video_poster_job.rb +12 -0
- data/app/models/lato_cms/page_field.rb +44 -0
- data/app/views/lato_cms/pages/fields/_file.html.erb +2 -0
- data/app/views/lato_cms/pages/fields/_gallery.html.erb +7 -3
- data/app/views/lato_cms/pages/fields/_image.html.erb +1 -0
- data/app/views/lato_cms/pages/fields/_video.html.erb +69 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/it.yml +4 -0
- data/lib/lato_cms/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fd8416ab840a11e364752d13f21440d8282862df78e4b4b67ef8f69432aa56b
|
|
4
|
+
data.tar.gz: 544411ddd79120e75f9f3df5cf7136d9d0d940cd3ba15abb0720fbf95ec368f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
'&': '&',
|
|
154
|
+
'<': '<',
|
|
155
|
+
'>': '>',
|
|
156
|
+
'"': '"',
|
|
157
|
+
"'": '''
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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="
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
data/config/locales/it.yml
CHANGED
|
@@ -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
|
data/lib/lato_cms/version.rb
CHANGED
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.
|
|
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
|