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.
Files changed (47) 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/stylesheets/lato_cms/application.scss +131 -158
  7. data/app/controllers/lato_cms/application_controller.rb +4 -0
  8. data/app/controllers/lato_cms/media_controller.rb +121 -0
  9. data/app/controllers/lato_cms/pages_controller.rb +75 -91
  10. data/app/helpers/lato_cms/media_helper.rb +50 -0
  11. data/app/jobs/lato_cms/export_sitemap_csv_job.rb +33 -0
  12. data/app/jobs/lato_cms/generate_alt_text_job.rb +21 -0
  13. data/app/jobs/lato_cms/generate_video_poster_job.rb +7 -5
  14. data/app/jobs/lato_cms/translate_component_fields_job.rb +16 -0
  15. data/app/models/lato_cms/llm_client.rb +31 -0
  16. data/app/models/lato_cms/media.rb +270 -0
  17. data/app/models/lato_cms/page.rb +40 -0
  18. data/app/models/lato_cms/page_field.rb +52 -80
  19. data/app/models/lato_cms/page_field_media.rb +8 -0
  20. data/app/views/lato_cms/media/_form_create.html.erb +20 -0
  21. data/app/views/lato_cms/media/_form_update.html.erb +60 -0
  22. data/app/views/lato_cms/media/create.html.erb +13 -0
  23. data/app/views/lato_cms/media/index.html.erb +22 -0
  24. data/app/views/lato_cms/media/picker_action.html.erb +98 -0
  25. data/app/views/lato_cms/media/update.html.erb +13 -0
  26. data/app/views/lato_cms/pages/_component_accordion.html.erb +15 -0
  27. data/app/views/lato_cms/pages/fields/_file.html.erb +4 -55
  28. data/app/views/lato_cms/pages/fields/_gallery.html.erb +4 -60
  29. data/app/views/lato_cms/pages/fields/_image.html.erb +4 -68
  30. data/app/views/lato_cms/pages/fields/_media_field.html.erb +62 -0
  31. data/app/views/lato_cms/pages/fields/_media_item.html.erb +27 -0
  32. data/app/views/lato_cms/pages/fields/_video.html.erb +4 -69
  33. data/app/views/lato_cms/pages/index.html.erb +17 -0
  34. data/app/views/lato_cms/pages/show.html.erb +2 -1
  35. data/config/locales/en.yml +36 -12
  36. data/config/locales/it.yml +36 -12
  37. data/config/routes.rb +12 -0
  38. data/db/migrate/20260728175413_create_lato_cms_media.rb +11 -0
  39. data/db/migrate/20260728175414_create_lato_cms_page_field_media.rb +12 -0
  40. data/db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb +25 -0
  41. data/lib/lato_cms/config.rb +12 -1
  42. data/lib/lato_cms/version.rb +1 -1
  43. metadata +38 -5
  44. data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +0 -225
  45. data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +0 -190
  46. data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +0 -169
  47. 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: d1b3de7e40dab4ee5574179d52307473705c2b084aa7a2a76d03dcd0fb0f3cef
4
- data.tar.gz: 70cfbb74343f62265a02f93d8998d92995b8332f8bc16e9671b3ab4af63ef70b
3
+ metadata.gz: fe4976c31eee3bc5137206d66037755baf19825e152a1c37a4be32e5b715dd6d
4
+ data.tar.gz: a209a52cb918989ce0179da4c902e3f4f71f0bb0843b6c4e7fac83c8b901424c
5
5
  SHA512:
6
- metadata.gz: '054983638f6a1454267a5d7eed3574152eac784a03ff1905c357a42bcd3fa5a3093b4591a8308008925f2b1612e7eb2eeccf2b58916a6080550156bc1414540d'
7
- data.tar.gz: 1767c5b3550717035094aa78c755bc8b0531cbec89d430421334c91e9fe2b96b9d0c9b9c5735e52fdd582906b82846d55cce21002b7ec32ad8de6aaac034a415
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
+ }