lato_cms 3.0.13 → 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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +143 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +110 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +37 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js +37 -0
- data/app/assets/stylesheets/lato_cms/application.scss +131 -158
- data/app/controllers/lato_cms/application_controller.rb +4 -0
- data/app/controllers/lato_cms/media_controller.rb +121 -0
- data/app/controllers/lato_cms/pages_controller.rb +75 -91
- data/app/helpers/lato_cms/media_helper.rb +50 -0
- data/app/jobs/lato_cms/export_sitemap_csv_job.rb +33 -0
- data/app/jobs/lato_cms/generate_alt_text_job.rb +21 -0
- data/app/jobs/lato_cms/generate_video_poster_job.rb +7 -5
- data/app/jobs/lato_cms/translate_component_fields_job.rb +16 -0
- data/app/models/lato_cms/llm_client.rb +31 -0
- data/app/models/lato_cms/media.rb +270 -0
- data/app/models/lato_cms/page.rb +40 -0
- data/app/models/lato_cms/page_field.rb +52 -80
- data/app/models/lato_cms/page_field_media.rb +8 -0
- data/app/views/lato_cms/media/_form_create.html.erb +20 -0
- data/app/views/lato_cms/media/_form_update.html.erb +60 -0
- data/app/views/lato_cms/media/create.html.erb +13 -0
- data/app/views/lato_cms/media/index.html.erb +22 -0
- data/app/views/lato_cms/media/picker_action.html.erb +98 -0
- data/app/views/lato_cms/media/update.html.erb +13 -0
- data/app/views/lato_cms/pages/_component_accordion.html.erb +15 -0
- data/app/views/lato_cms/pages/fields/_file.html.erb +4 -55
- data/app/views/lato_cms/pages/fields/_gallery.html.erb +4 -60
- data/app/views/lato_cms/pages/fields/_image.html.erb +4 -68
- data/app/views/lato_cms/pages/fields/_media_field.html.erb +62 -0
- data/app/views/lato_cms/pages/fields/_media_item.html.erb +27 -0
- data/app/views/lato_cms/pages/fields/_video.html.erb +4 -69
- data/app/views/lato_cms/pages/index.html.erb +17 -0
- data/app/views/lato_cms/pages/show.html.erb +2 -1
- data/config/locales/en.yml +36 -12
- data/config/locales/it.yml +36 -12
- data/config/routes.rb +12 -0
- data/db/migrate/20260728175413_create_lato_cms_media.rb +11 -0
- data/db/migrate/20260728175414_create_lato_cms_page_field_media.rb +12 -0
- data/db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb +25 -0
- data/lib/lato_cms/config.rb +12 -1
- data/lib/lato_cms/version.rb +1 -1
- metadata +38 -5
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +0 -225
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +0 -190
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +0 -169
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_video_field_controller.js +0 -180
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe4976c31eee3bc5137206d66037755baf19825e152a1c37a4be32e5b715dd6d
|
|
4
|
+
data.tar.gz: a209a52cb918989ce0179da4c902e3f4f71f0bb0843b6c4e7fac83c8b901424c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 469fa040c5c8ea3b04d630c1d664e0ff2db92a418089852eb2252c57899da646325c76969b4389dd219f20ff96c3f679b0581548107e19a354e9f47977bde3bd
|
|
7
|
+
data.tar.gz: 7638ec4ca940ab161bb361ec69fff832fb3950ab814dd656d3f4dabe422cafdf6b91ebff1962949dfcd89e86fd269d2bde9d95d17fff3f1a25eb16a36dc9413b
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// Generic controller for image/video/file/gallery fields backed by the Media
|
|
4
|
+
// library (replaces the old per-type native-upload controllers). Holds the
|
|
5
|
+
// selected media id(s) in hidden input(s) and renders a preview; the actual
|
|
6
|
+
// picking happens in a modal driven by lato_cms_media_picker_controller.js,
|
|
7
|
+
// which writes the selection back here via a `lato-cms:media-selected`
|
|
8
|
+
// event correlated by this field's own turbo-frame id.
|
|
9
|
+
export default class extends Controller {
|
|
10
|
+
static targets = ['grid', 'addTile', 'validationAnchor']
|
|
11
|
+
static values = { kind: String, multiple: Boolean, frameId: String, hiddenName: String, fieldId: String }
|
|
12
|
+
|
|
13
|
+
connect () {
|
|
14
|
+
this.onMediaSelected = this.onMediaSelected.bind(this)
|
|
15
|
+
this.afterSave = this.afterSave.bind(this)
|
|
16
|
+
|
|
17
|
+
document.addEventListener('lato-cms:media-selected', this.onMediaSelected)
|
|
18
|
+
this.form = this.element.closest('form')
|
|
19
|
+
this.form?.addEventListener('lato-cms:fields-save-success', this.afterSave)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnect () {
|
|
23
|
+
document.removeEventListener('lato-cms:media-selected', this.onMediaSelected)
|
|
24
|
+
this.form?.removeEventListener('lato-cms:fields-save-success', this.afterSave)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Opens the picker in an overlay. Reuses the same pool of pre-rendered
|
|
28
|
+
// Bootstrap modals the shared `lato-action` mechanism uses, but drives it
|
|
29
|
+
// directly instead of going through that controller's trigger convention:
|
|
30
|
+
// that convention opens a frame by hoping Turbo notices it was just
|
|
31
|
+
// inserted before driving a full-page visit off the link's plain href,
|
|
32
|
+
// and loses that race for this link (verified: it was navigating to a
|
|
33
|
+
// separate page instead of showing an overlay). Setting the frame's `src`
|
|
34
|
+
// explicitly is Turbo's documented, non-racy way to trigger a frame fetch.
|
|
35
|
+
openPicker (event) {
|
|
36
|
+
event.preventDefault()
|
|
37
|
+
|
|
38
|
+
const link = event.currentTarget
|
|
39
|
+
const modal = Array.from(document.querySelectorAll('[data-lato-action-target="modal"]'))
|
|
40
|
+
.find(el => !el.classList.contains('show'))
|
|
41
|
+
if (!modal) return
|
|
42
|
+
|
|
43
|
+
const body = modal.querySelector('[data-lato-action-target="modalBody"]')
|
|
44
|
+
const title = modal.querySelector('[data-lato-action-target="modalTitle"]')
|
|
45
|
+
const dialog = modal.querySelector('[data-lato-action-target="modalDialog"]')
|
|
46
|
+
|
|
47
|
+
body.innerHTML = `
|
|
48
|
+
<turbo-frame id="${this.frameIdValue}">
|
|
49
|
+
<div class="placeholder-glow"><span class="placeholder placeholder-lg col-12"></span></div>
|
|
50
|
+
</turbo-frame>`
|
|
51
|
+
title.textContent = link.dataset.actionTitle || ''
|
|
52
|
+
dialog.classList.remove('modal-sm', 'modal-md', 'modal-lg', 'modal-xl')
|
|
53
|
+
dialog.classList.add(`modal-${link.dataset.actionSize || 'md'}`)
|
|
54
|
+
|
|
55
|
+
window.bootstrap.Modal.getOrCreateInstance(modal).show()
|
|
56
|
+
body.querySelector('turbo-frame').src = link.href
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onMediaSelected (event) {
|
|
60
|
+
if (event.detail.frameId !== this.frameIdValue) return
|
|
61
|
+
|
|
62
|
+
if (!this.multipleValue) {
|
|
63
|
+
this.itemTiles().forEach(tile => tile.remove())
|
|
64
|
+
}
|
|
65
|
+
event.detail.items.forEach(item => this.appendItem(item))
|
|
66
|
+
|
|
67
|
+
this.syncEmptyAndValidation()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
remove (event) {
|
|
71
|
+
event.currentTarget.closest('.lato-cms-media-field__item')?.remove()
|
|
72
|
+
this.syncEmptyAndValidation()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
afterSave (event) {
|
|
76
|
+
if (!event.detail.success) return
|
|
77
|
+
|
|
78
|
+
const field = (event.detail.data?.fields || []).find(
|
|
79
|
+
f => f.persisted_field_id === this.fieldIdValue || f.field_id === this.fieldIdValue
|
|
80
|
+
)
|
|
81
|
+
if (!field) return
|
|
82
|
+
|
|
83
|
+
this.itemTiles().forEach(tile => tile.remove())
|
|
84
|
+
field.attachments.forEach(attachment => this.appendItem({
|
|
85
|
+
id: attachment.media_id,
|
|
86
|
+
name: attachment.name,
|
|
87
|
+
thumbnailUrl: attachment.url,
|
|
88
|
+
url: attachment.url,
|
|
89
|
+
posterUrl: attachment.poster_url
|
|
90
|
+
}))
|
|
91
|
+
this.syncEmptyAndValidation()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
appendItem (item) {
|
|
95
|
+
if (!item || this.gridTarget.querySelector(`[data-media-id="${item.id}"]`)) return
|
|
96
|
+
|
|
97
|
+
const wrapper = document.createElement('div')
|
|
98
|
+
wrapper.className = 'lato-cms-media-field__tile lato-cms-media-field__item'
|
|
99
|
+
wrapper.dataset.mediaId = item.id
|
|
100
|
+
if (this.multipleValue) {
|
|
101
|
+
wrapper.draggable = true
|
|
102
|
+
wrapper.dataset.action = 'dragstart->lato-cms-media-reorder#onDragStart dragend->lato-cms-media-reorder#onDragEnd'
|
|
103
|
+
wrapper.setAttribute('data-lato-cms-media-reorder-target', 'item')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
wrapper.innerHTML = `
|
|
107
|
+
${this.previewMarkup(item)}
|
|
108
|
+
<div class="lato-cms-media-field__overlay">
|
|
109
|
+
<span class="lato-cms-media-field__name">${item.name}</span>
|
|
110
|
+
</div>
|
|
111
|
+
<button type="button" class="lato-cms-media-field__remove" data-action="lato-cms-media-field#remove">
|
|
112
|
+
<i class="bi bi-x-lg"></i>
|
|
113
|
+
</button>
|
|
114
|
+
<input type="hidden" name="${this.hiddenNameValue}" value="${item.id}">`
|
|
115
|
+
|
|
116
|
+
this.gridTarget.insertBefore(wrapper, this.addTileTarget)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
previewMarkup (item) {
|
|
120
|
+
if (this.kindValue === 'video') {
|
|
121
|
+
return `<video muted preload="metadata" class="lato-cms-media-field__player" src="${item.url}"></video>
|
|
122
|
+
<i class="bi bi-play-circle-fill lato-cms-media-field__badge"></i>`
|
|
123
|
+
}
|
|
124
|
+
if (item.thumbnailUrl || item.url) {
|
|
125
|
+
return `<img src="${item.thumbnailUrl || item.url}" class="lato-cms-media-field__thumb">`
|
|
126
|
+
}
|
|
127
|
+
return '<div class="lato-cms-media-field__icon"><i class="bi bi-paperclip"></i></div>'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
itemTiles () {
|
|
131
|
+
return Array.from(this.gridTarget.querySelectorAll('.lato-cms-media-field__item'))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
syncEmptyAndValidation () {
|
|
135
|
+
const hasItems = this.itemTiles().length > 0
|
|
136
|
+
|
|
137
|
+
if (!this.multipleValue) this.addTileTarget.classList.toggle('d-none', hasItems)
|
|
138
|
+
if (this.hasValidationAnchorTarget) {
|
|
139
|
+
this.validationAnchorTarget.value = hasItems ? '1' : ''
|
|
140
|
+
this.validationAnchorTarget.required = this.element.dataset.fieldRequired === 'true' && !hasItems
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// Modal content loaded via the `lato-action` mechanism (see lato_action_controller.js).
|
|
4
|
+
// Lets the admin browse the Media library or upload a new file, then writes
|
|
5
|
+
// the selection back to whichever field opened this picker by dispatching a
|
|
6
|
+
// `lato-cms:media-selected` event on `document`, correlated by the picker's
|
|
7
|
+
// own turbo-frame id (unique per field instance, even inside a repeater).
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ['grid', 'item', 'selectedCount', 'confirmButton', 'uploadForm', 'uploadNotice']
|
|
10
|
+
static values = { multiple: Boolean, selectedLabel: String }
|
|
11
|
+
|
|
12
|
+
connect () {
|
|
13
|
+
this.frameId = this.element.closest('turbo-frame')?.id
|
|
14
|
+
this.pending = new Map()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
select (event) {
|
|
18
|
+
const item = this.buildItemFromElement(event.currentTarget)
|
|
19
|
+
|
|
20
|
+
if (!this.multipleValue) {
|
|
21
|
+
this.dispatchSelection([item])
|
|
22
|
+
this.closeModal()
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (this.pending.has(item.id)) {
|
|
27
|
+
this.pending.delete(item.id)
|
|
28
|
+
event.currentTarget.classList.remove('lato-cms-media-picker__item--selected')
|
|
29
|
+
} else {
|
|
30
|
+
this.pending.set(item.id, item)
|
|
31
|
+
event.currentTarget.classList.add('lato-cms-media-picker__item--selected')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.updateSelectionUi()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
confirm () {
|
|
38
|
+
if (this.pending.size === 0) return
|
|
39
|
+
|
|
40
|
+
this.dispatchSelection(Array.from(this.pending.values()))
|
|
41
|
+
this.closeModal()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async upload (event) {
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
|
|
47
|
+
const form = event.currentTarget
|
|
48
|
+
const response = await fetch(form.action, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { Accept: 'application/json' },
|
|
51
|
+
body: new FormData(form)
|
|
52
|
+
})
|
|
53
|
+
const data = await response.json()
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
this.uploadNoticeTarget.innerHTML = `<div class="alert alert-danger">${Object.values(data).flat().join(', ')}</div>`
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const item = {
|
|
61
|
+
id: data.id,
|
|
62
|
+
name: data.name,
|
|
63
|
+
thumbnailUrl: data.thumbnail_url,
|
|
64
|
+
mediaType: data.media_type,
|
|
65
|
+
url: data.url,
|
|
66
|
+
posterUrl: data.poster_url
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!this.multipleValue) {
|
|
70
|
+
this.dispatchSelection([item])
|
|
71
|
+
this.closeModal()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.pending.set(item.id, item)
|
|
76
|
+
this.updateSelectionUi()
|
|
77
|
+
form.reset()
|
|
78
|
+
this.uploadNoticeTarget.innerHTML = `<div class="alert alert-success">${item.name}</div>`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
buildItemFromElement (element) {
|
|
82
|
+
return {
|
|
83
|
+
id: element.dataset.mediaId,
|
|
84
|
+
name: element.dataset.mediaName,
|
|
85
|
+
thumbnailUrl: element.dataset.mediaThumb,
|
|
86
|
+
mediaType: element.dataset.mediaType,
|
|
87
|
+
url: element.dataset.mediaUrl,
|
|
88
|
+
posterUrl: element.dataset.mediaPosterUrl
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
dispatchSelection (items) {
|
|
93
|
+
document.dispatchEvent(new CustomEvent('lato-cms:media-selected', {
|
|
94
|
+
bubbles: true,
|
|
95
|
+
detail: { frameId: this.frameId, items }
|
|
96
|
+
}))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
updateSelectionUi () {
|
|
100
|
+
if (this.hasSelectedCountTarget) this.selectedCountTarget.textContent = `${this.pending.size} ${this.selectedLabelValue}`
|
|
101
|
+
if (this.hasConfirmButtonTarget) this.confirmButtonTarget.disabled = this.pending.size === 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
closeModal () {
|
|
105
|
+
const modalElement = this.element.closest('.modal')
|
|
106
|
+
if (!modalElement) return
|
|
107
|
+
|
|
108
|
+
window.bootstrap.Modal.getInstance(modalElement)?.hide()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// Drag-and-drop reordering for multi-value media fields (gallery, file
|
|
4
|
+
// multiple). Mounted alongside lato_cms_media_field_controller on the same
|
|
5
|
+
// element. Submitted order comes from plain DOM order of the hidden inputs
|
|
6
|
+
// inside the grid, so reordering nodes here is the entire contract — no
|
|
7
|
+
// separate "order" param to keep in sync.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ['grid', 'item']
|
|
10
|
+
|
|
11
|
+
onDragStart (event) {
|
|
12
|
+
this.dragging = event.currentTarget
|
|
13
|
+
this.dragging.classList.add('lato-cms-media-field__item--dragging')
|
|
14
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
onDragEnd (event) {
|
|
18
|
+
event.currentTarget.classList.remove('lato-cms-media-field__item--dragging')
|
|
19
|
+
this.dragging = null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
onGridDragOver (event) {
|
|
23
|
+
event.preventDefault()
|
|
24
|
+
if (!this.dragging) return
|
|
25
|
+
|
|
26
|
+
const target = event.target.closest('.lato-cms-media-field__item')
|
|
27
|
+
if (!target || target === this.dragging) return
|
|
28
|
+
|
|
29
|
+
const rect = target.getBoundingClientRect()
|
|
30
|
+
const before = (event.clientX - rect.left) < rect.width / 2
|
|
31
|
+
this.gridTarget.insertBefore(this.dragging, before ? target : target.nextSibling)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onGridDrop (event) {
|
|
35
|
+
event.preventDefault()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// Refreshes the enclosing turbo-frame (or the whole page) whenever a
|
|
4
|
+
// Bootstrap modal on the page closes. Used after triggering a
|
|
5
|
+
// Lato::Operation (e.g. "Regenerate with AI", "Clone & translate") from a
|
|
6
|
+
// link inside it: the underlying page/frame has no way to know when that
|
|
7
|
+
// background job finishes, so once the admin dismisses the operation's
|
|
8
|
+
// progress modal, refresh so the result is shown without a manual reload.
|
|
9
|
+
//
|
|
10
|
+
// Mounted on an element INSIDE the turbo-frame's content, not on the frame
|
|
11
|
+
// tag itself: when this frame is opened inside a modal, `lato-action`
|
|
12
|
+
// pre-creates a bare `<turbo-frame>` via innerHTML before Turbo fetches
|
|
13
|
+
// into it, and Turbo's frame navigation only replaces that element's
|
|
14
|
+
// children, not its own attributes — a `data-controller` set directly on
|
|
15
|
+
// the frame tag in the fetched content would never actually attach.
|
|
16
|
+
// `closest('turbo-frame')` finds the frame at the time of reload, whether
|
|
17
|
+
// this connected inside a modal-loaded frame or a plain, directly-loaded
|
|
18
|
+
// page (no frame at all, so it falls back to reloading the whole page).
|
|
19
|
+
export default class extends Controller {
|
|
20
|
+
connect () {
|
|
21
|
+
this.reload = this.reload.bind(this)
|
|
22
|
+
document.addEventListener('hidden.bs.modal', this.reload)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect () {
|
|
26
|
+
document.removeEventListener('hidden.bs.modal', this.reload)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
reload () {
|
|
30
|
+
const frame = this.element.closest('turbo-frame')
|
|
31
|
+
if (frame && frame.getAttribute('src')) {
|
|
32
|
+
frame.reload()
|
|
33
|
+
} else {
|
|
34
|
+
window.Turbo.visit(window.location.href)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|