lato_cms 3.0.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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +67 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/config/lato_cms_manifest.js +2 -0
  6. data/app/assets/javascripts/lato_cms/application.js +1 -0
  7. data/app/assets/javascripts/lato_cms/controllers/lato_cms_advanced_editor_controller.js +57 -0
  8. data/app/assets/javascripts/lato_cms/controllers/lato_cms_color_field_controller.js +9 -0
  9. data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_accordion_controller.js +60 -0
  10. data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_form_controller.js +96 -0
  11. data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_toggle_controller.js +89 -0
  12. data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +136 -0
  13. data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +24 -0
  14. data/app/assets/javascripts/lato_cms/controllers/lato_cms_json_field_controller.js +27 -0
  15. data/app/assets/javascripts/lato_cms/controllers/lato_cms_page_preview_controller.js +86 -0
  16. data/app/assets/javascripts/lato_cms/controllers/lato_cms_text_field_controller.js +74 -0
  17. data/app/assets/stylesheets/lato_cms/application.scss +252 -0
  18. data/app/controllers/lato_cms/api/pages_controller.rb +38 -0
  19. data/app/controllers/lato_cms/application_controller.rb +26 -0
  20. data/app/controllers/lato_cms/pages_controller.rb +194 -0
  21. data/app/helpers/lato_cms/application_helper.rb +4 -0
  22. data/app/helpers/lato_cms/pages_helper.rb +69 -0
  23. data/app/jobs/lato_cms/application_job.rb +4 -0
  24. data/app/mailers/lato_cms/application_mailer.rb +6 -0
  25. data/app/models/lato_cms/page.rb +133 -0
  26. data/app/models/lato_cms/page_field.rb +113 -0
  27. data/app/models/lato_cms/template_manager.rb +64 -0
  28. data/app/views/lato_cms/pages/_component_accordion.html.erb +97 -0
  29. data/app/views/lato_cms/pages/_fields_editor.html.erb +12 -0
  30. data/app/views/lato_cms/pages/_form_create.html.erb +26 -0
  31. data/app/views/lato_cms/pages/_form_update.html.erb +39 -0
  32. data/app/views/lato_cms/pages/create.html.erb +13 -0
  33. data/app/views/lato_cms/pages/fields/_boolean.html.erb +16 -0
  34. data/app/views/lato_cms/pages/fields/_color.html.erb +18 -0
  35. data/app/views/lato_cms/pages/fields/_date.html.erb +16 -0
  36. data/app/views/lato_cms/pages/fields/_datetime.html.erb +16 -0
  37. data/app/views/lato_cms/pages/fields/_file.html.erb +29 -0
  38. data/app/views/lato_cms/pages/fields/_gallery.html.erb +55 -0
  39. data/app/views/lato_cms/pages/fields/_image.html.erb +39 -0
  40. data/app/views/lato_cms/pages/fields/_json.html.erb +23 -0
  41. data/app/views/lato_cms/pages/fields/_multiselect.html.erb +25 -0
  42. data/app/views/lato_cms/pages/fields/_number.html.erb +18 -0
  43. data/app/views/lato_cms/pages/fields/_select.html.erb +23 -0
  44. data/app/views/lato_cms/pages/fields/_string.html.erb +18 -0
  45. data/app/views/lato_cms/pages/fields/_text.html.erb +80 -0
  46. data/app/views/lato_cms/pages/fields/_textarea.html.erb +16 -0
  47. data/app/views/lato_cms/pages/index.html.erb +32 -0
  48. data/app/views/lato_cms/pages/show.html.erb +105 -0
  49. data/app/views/lato_cms/pages/update.html.erb +13 -0
  50. data/config/importmap.rb +2 -0
  51. data/config/locales/en.yml +70 -0
  52. data/config/locales/it.yml +70 -0
  53. data/config/routes.rb +22 -0
  54. data/db/migrate/20250328072343_add_lato_cms_admin_to_lato_user.rb +5 -0
  55. data/db/migrate/20260320070819_create_lato_cms_pages.rb +13 -0
  56. data/db/migrate/20260323171241_create_lato_cms_page_fields.rb +13 -0
  57. data/lib/lato_cms/config.rb +13 -0
  58. data/lib/lato_cms/engine.rb +13 -0
  59. data/lib/lato_cms/version.rb +3 -0
  60. data/lib/lato_cms.rb +15 -0
  61. data/lib/tasks/lato_cms_tasks.rake +183 -0
  62. metadata +158 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 508aae768bf2679d04559b46cf39e353f3279e959e527045586f042c80dfb0ed
4
+ data.tar.gz: 8e1e8b0e8a45f226ba09d5c32bc183ace111b5e2029c7c62ea5562fd4ac43688
5
+ SHA512:
6
+ metadata.gz: e7d17de34c1292366c754ff952e4197f40a4107aa7137cae4ae6f587a3411c78be834bba87133102872955539036e2cefd90261728fcbc874755395abe9d6a27
7
+ data.tar.gz: 8cc1b2d80143483f2ef2f2204b18d63f0f96434a4fc24a0f762aa1e8adc7693137346273aac1b27e6c3306f790a51da90803c437a55cc6c4961457c20bf1ab36
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Gregorio Galante
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # Lato CMS
2
+
3
+ Manage application content on Lato projects.
4
+
5
+ ## Installation
6
+ Add required dependencies to your application's Gemfile:
7
+
8
+ ```ruby
9
+ # Use lato as application panel
10
+ gem "lato"
11
+ gem "lato_cms"
12
+ ```
13
+
14
+ Install gem and run required tasks:
15
+
16
+ ```bash
17
+ $ bundle
18
+ $ rails lato_cms:install:application
19
+ $ rails lato_cms:install:migrations
20
+ $ rails db:migrate
21
+ ```
22
+
23
+ Mount lato users routes on the **config/routes.rb** file:
24
+
25
+ ```ruby
26
+ Rails.application.routes.draw do
27
+ mount LatoCms::Engine => "/lato-users"
28
+ # ....
29
+ end
30
+ ```
31
+
32
+ Import Lato Scss on **app/assets/stylesheets/application.scss** file:
33
+ ```scss
34
+ @import 'lato_cms/application';
35
+
36
+ // ....
37
+ ```
38
+
39
+ Import Lato Users Js on **app/javascript/application.js** file:
40
+ ```js
41
+ import "lato_cms/application";
42
+
43
+ // ....
44
+ ```
45
+
46
+ ## Development
47
+
48
+ Clone repository, install dependencies, run migrations and start:
49
+
50
+ ```shell
51
+ $ git clone https://github.com/Lato-GAM/lato_cms
52
+ $ cd lato_cms
53
+ $ bundle
54
+ $ rails db:migrate
55
+ $ rails db:seed
56
+ $ foreman start -f Procfile.dev
57
+ ```
58
+
59
+ ## Publish
60
+
61
+ ```shell
62
+ $ ruby ./bin/publish.rb
63
+ ```
64
+
65
+ ## License
66
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
67
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,2 @@
1
+ //= link_directory ../stylesheets/lato_cms .css
2
+ //= link_tree ../javascripts/lato_cms .js
@@ -0,0 +1 @@
1
+ console.log("Lato Users JS loaded");
@@ -0,0 +1,57 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["row", "previewCol", "editorCol", "toggleBtn", "collapseBtn", "reopenBtn"]
5
+
6
+ isActive = false
7
+ isSidebarOpen = true
8
+
9
+ // ─── Toggle advanced mode ────────────────────────────────────────────────────
10
+
11
+ toggle() {
12
+ this.isActive ? this.deactivate() : this.activate()
13
+ }
14
+
15
+ activate() {
16
+ this.isActive = true
17
+ this.isSidebarOpen = true
18
+
19
+ this.rowTarget.classList.add('lato-cms-advanced-editor__row--active')
20
+ document.body.classList.add('lato-cms-advanced-editor--open')
21
+
22
+ this.toggleBtnTarget.title = 'Exit advanced editor'
23
+ this.toggleBtnTarget.querySelector('i').className = 'bi bi-fullscreen-exit'
24
+ this.collapseBtnTarget.classList.remove('d-none')
25
+ this.editorColTarget.classList.remove('lato-cms-advanced-editor__editor-col--collapsed')
26
+ this.reopenBtnTarget.hidden = true
27
+
28
+ this._escHandler = (e) => { if (e.key === 'Escape') this.deactivate() }
29
+ document.addEventListener('keydown', this._escHandler)
30
+ }
31
+
32
+ deactivate() {
33
+ this.isActive = false
34
+ this.isSidebarOpen = true
35
+
36
+ this.rowTarget.classList.remove('lato-cms-advanced-editor__row--active')
37
+ document.body.classList.remove('lato-cms-advanced-editor--open')
38
+
39
+ this.toggleBtnTarget.title = 'Advanced editor'
40
+ this.toggleBtnTarget.querySelector('i').className = 'bi bi-fullscreen'
41
+ this.collapseBtnTarget.classList.add('d-none')
42
+ this.editorColTarget.classList.remove('lato-cms-advanced-editor__editor-col--collapsed')
43
+ this.reopenBtnTarget.hidden = true
44
+
45
+ document.removeEventListener('keydown', this._escHandler)
46
+ }
47
+
48
+ // ─── Toggle sidebar within advanced mode ─────────────────────────────────────
49
+
50
+ toggleSidebar() {
51
+ if (!this.isActive) return
52
+
53
+ this.isSidebarOpen = !this.isSidebarOpen
54
+ this.editorColTarget.classList.toggle('lato-cms-advanced-editor__editor-col--collapsed', !this.isSidebarOpen)
55
+ this.reopenBtnTarget.hidden = this.isSidebarOpen
56
+ }
57
+ }
@@ -0,0 +1,9 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "hex"]
5
+
6
+ update() {
7
+ this.hexTarget.textContent = this.inputTarget.value.toUpperCase()
8
+ }
9
+ }
@@ -0,0 +1,60 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ connect () {
5
+ this.handleShown = this.handleShown.bind(this)
6
+ this.handleHidden = this.handleHidden.bind(this)
7
+
8
+ this.element.addEventListener('shown.bs.collapse', this.handleShown)
9
+ this.element.addEventListener('hidden.bs.collapse', this.handleHidden)
10
+
11
+ window.requestAnimationFrame(() => {
12
+ this.broadcastInitiallyOpenComponent()
13
+ })
14
+ }
15
+
16
+ disconnect () {
17
+ this.element.removeEventListener('shown.bs.collapse', this.handleShown)
18
+ this.element.removeEventListener('hidden.bs.collapse', this.handleHidden)
19
+ }
20
+
21
+ handleShown (event) {
22
+ this.broadcastForCollapse(event.target)
23
+ }
24
+
25
+ handleHidden () {
26
+ this.broadcastClosedComponent()
27
+ }
28
+
29
+ broadcastInitiallyOpenComponent () {
30
+ const openCollapse = this.element.querySelector('.accordion-collapse.show')
31
+ if (!openCollapse) return
32
+
33
+ this.broadcastForCollapse(openCollapse)
34
+ }
35
+
36
+ broadcastForCollapse (collapseElement) {
37
+ const templateComponentId = collapseElement.dataset.templateComponentId
38
+ const componentId = collapseElement.dataset.componentId
39
+
40
+ if (!templateComponentId) return
41
+
42
+ document.dispatchEvent(new CustomEvent('lato-cms:component-change', {
43
+ detail: {
44
+ id: templateComponentId,
45
+ templateComponentId,
46
+ componentId
47
+ }
48
+ }))
49
+ }
50
+
51
+ broadcastClosedComponent () {
52
+ document.dispatchEvent(new CustomEvent('lato-cms:component-change', {
53
+ detail: {
54
+ id: null,
55
+ templateComponentId: null,
56
+ componentId: null
57
+ }
58
+ }))
59
+ }
60
+ }
@@ -0,0 +1,96 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["submitBtn", "status"]
5
+
6
+ connect() {
7
+ this.element.addEventListener("submit", this.handleSubmit.bind(this))
8
+ }
9
+
10
+ async handleSubmit(event) {
11
+ event.preventDefault()
12
+
13
+ const form = this.element
14
+ const submitBtn = this.submitBtnTarget
15
+ const originalHTML = submitBtn.innerHTML
16
+
17
+ submitBtn.disabled = true
18
+ submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...'
19
+
20
+ try {
21
+ const formData = new FormData(form)
22
+ const response = await fetch(form.action, {
23
+ method: 'POST',
24
+ body: formData,
25
+ headers: {
26
+ 'Accept': 'application/json',
27
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
28
+ }
29
+ })
30
+
31
+ const data = await response.json()
32
+
33
+ if (response.ok) {
34
+ this.showStatus('Fields saved successfully', 'success')
35
+ this.refreshPreview()
36
+ } else {
37
+ const errorMsg = data.errors?.map(e => `${e.field_id}: ${e.errors.join(', ')}`).join('; ') || 'Failed to save fields'
38
+ this.showStatus(errorMsg, 'danger')
39
+ }
40
+ } catch (error) {
41
+ this.showStatus('An error occurred while saving', 'danger')
42
+ } finally {
43
+ submitBtn.disabled = false
44
+ submitBtn.innerHTML = originalHTML
45
+ }
46
+ }
47
+
48
+ refreshPreview() {
49
+ const iframe = document.querySelector('[data-lato-cms-page-preview-target="iframe"]')
50
+ if (iframe && iframe.src) {
51
+ const url = new URL(iframe.src)
52
+ url.searchParams.set('_t', Date.now())
53
+ iframe.src = url.toString()
54
+ this.broadcastOpenComponent()
55
+ }
56
+ }
57
+
58
+ broadcastOpenComponent() {
59
+ const openCollapse = document.querySelector('#components-accordion .accordion-collapse.show')
60
+ if (!openCollapse) return
61
+
62
+ const templateComponentId = openCollapse.dataset.templateComponentId
63
+ if (!templateComponentId) return
64
+
65
+ document.dispatchEvent(new CustomEvent('lato-cms:component-change', {
66
+ detail: {
67
+ id: templateComponentId,
68
+ templateComponentId,
69
+ componentId: openCollapse.dataset.componentId
70
+ }
71
+ }))
72
+ }
73
+
74
+ showStatus(message, type) {
75
+ if (!this.hasStatusTarget) return
76
+
77
+ clearTimeout(this._statusTimeout)
78
+
79
+ const isSuccess = type === 'success'
80
+ const icon = isSuccess ? 'bi-check-circle-fill' : 'bi-exclamation-circle-fill'
81
+ const color = isSuccess ? 'text-success' : 'text-danger'
82
+
83
+ this.statusTarget.innerHTML = `
84
+ <span class="d-inline-flex align-items-center gap-1 small ${color} lato-cms-component-form__status">
85
+ <i class="bi ${icon}"></i>${message}
86
+ </span>`
87
+
88
+ if (isSuccess) {
89
+ this._statusTimeout = setTimeout(() => {
90
+ const msg = this.statusTarget.querySelector('.lato-cms-component-form__status')
91
+ if (msg) msg.classList.add('lato-cms-component-form__status--fade')
92
+ setTimeout(() => { this.statusTarget.innerHTML = '' }, 400)
93
+ }, 3000)
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,89 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ['checkbox', 'stateLabel', 'stateIcon', 'stateWrapper', 'fieldsContainer', 'status']
5
+ static values = {
6
+ activeLabel: String,
7
+ inactiveLabel: String,
8
+ savingLabel: String,
9
+ savedLabel: String,
10
+ errorLabel: String
11
+ }
12
+
13
+ async toggle (event) {
14
+ event.preventDefault()
15
+
16
+ const form = event.currentTarget
17
+ const formData = new FormData(form)
18
+ const checkbox = this.checkboxTarget
19
+ const enabled = checkbox.checked
20
+
21
+ checkbox.disabled = true
22
+ if (this.hasStatusTarget) {
23
+ this.statusTarget.innerHTML = ''
24
+ }
25
+
26
+ try {
27
+ const response = await fetch(form.action, {
28
+ method: form.method.toUpperCase(),
29
+ body: formData,
30
+ headers: {
31
+ Accept: 'application/json',
32
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
33
+ }
34
+ })
35
+
36
+ if (!response.ok) {
37
+ throw new Error('toggle failed')
38
+ }
39
+
40
+ this.updateVisualState(enabled)
41
+ if (this.hasStatusTarget) {
42
+ this.statusTarget.innerHTML = ''
43
+ }
44
+ } catch (_error) {
45
+ checkbox.checked = !enabled
46
+ this.updateVisualState(!enabled)
47
+ this.showStatus(this.errorLabelValue, 'danger')
48
+ } finally {
49
+ checkbox.disabled = false
50
+ }
51
+ }
52
+
53
+ updateVisualState (enabled) {
54
+ if (this.hasStateLabelTarget) {
55
+ this.stateLabelTarget.textContent = enabled ? this.activeLabelValue : this.inactiveLabelValue
56
+ }
57
+
58
+ if (this.hasStateIconTarget) {
59
+ this.stateIconTarget.className = enabled ? 'bi bi-eye' : 'bi bi-eye-slash'
60
+ }
61
+
62
+ if (this.hasStateWrapperTarget) {
63
+ this.stateWrapperTarget.classList.toggle('text-success', enabled)
64
+ this.stateWrapperTarget.classList.toggle('text-muted', !enabled)
65
+ }
66
+
67
+ const fieldsContainer = this.hasFieldsContainerTarget
68
+ ? this.fieldsContainerTarget
69
+ : this.element.querySelector('[data-lato-cms-component-toggle-target="fieldsContainer"]')
70
+
71
+ if (fieldsContainer) {
72
+ fieldsContainer.classList.toggle('d-none', !enabled)
73
+ }
74
+ }
75
+
76
+ showStatus (message, type) {
77
+ if (!this.hasStatusTarget) return
78
+
79
+ const colorClass = type === 'success' ? 'text-success' : (type === 'danger' ? 'text-danger' : 'text-muted')
80
+ this.statusTarget.innerHTML = `<span class="small ${colorClass}">${message}</span>`
81
+
82
+ if (type === 'success') {
83
+ clearTimeout(this._statusTimeout)
84
+ this._statusTimeout = setTimeout(() => {
85
+ this.statusTarget.innerHTML = ''
86
+ }, 2000)
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,136 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["grid", "fileInput", "emptyMsg"]
5
+ static values = { fieldId: String }
6
+
7
+ dragging = null
8
+ newFileMap = new Map()
9
+ nextUid = 0
10
+
11
+ // ─── File selection ─────────────────────────────────────────────────────────
12
+
13
+ addFiles() {
14
+ Array.from(this.fileInputTarget.files).forEach(file => {
15
+ if (!file.type.startsWith('image/')) return
16
+ const uid = `new_${this.nextUid++}`
17
+ this.newFileMap.set(uid, file)
18
+
19
+ const reader = new FileReader()
20
+ reader.onload = ({ target }) => {
21
+ const item = this.buildItem(target.result, null, uid)
22
+ this.gridTarget.appendChild(item)
23
+ this.updateEmpty()
24
+ this.updateOrderInputs()
25
+ }
26
+ reader.readAsDataURL(file)
27
+ })
28
+
29
+ // Reset input then sync with DataTransfer
30
+ this.fileInputTarget.value = ''
31
+ this.syncFileInput()
32
+ }
33
+
34
+ // ─── Remove ─────────────────────────────────────────────────────────────────
35
+
36
+ remove(event) {
37
+ const item = event.currentTarget.closest('[data-gallery-item]')
38
+ if (!item) return
39
+
40
+ const { attachmentId, fileUid } = item.dataset
41
+
42
+ if (attachmentId) {
43
+ const input = document.createElement('input')
44
+ input.type = 'hidden'
45
+ input.name = `fields[${this.fieldIdValue}][remove_file_ids][]`
46
+ input.value = attachmentId
47
+ this.element.appendChild(input)
48
+ } else if (fileUid) {
49
+ this.newFileMap.delete(fileUid)
50
+ this.syncFileInput()
51
+ }
52
+
53
+ item.remove()
54
+ this.updateEmpty()
55
+ this.updateOrderInputs()
56
+ }
57
+
58
+ // ─── Drag & drop ─────────────────────────────────────────────────────────────
59
+
60
+ onDragStart(event) {
61
+ this.dragging = event.currentTarget
62
+ event.dataTransfer.effectAllowed = 'move'
63
+ // Defer class so browser can snapshot the element first
64
+ requestAnimationFrame(() => event.currentTarget.classList.add('lato-cms-gallery-field__item--dragging'))
65
+ }
66
+
67
+ onDragEnd(event) {
68
+ event.currentTarget.classList.remove('lato-cms-gallery-field__item--dragging')
69
+ this.dragging = null
70
+ this.updateOrderInputs()
71
+ }
72
+
73
+ onGridDragOver(event) {
74
+ event.preventDefault()
75
+ if (!this.dragging) return
76
+
77
+ const target = event.target.closest('[data-gallery-item]')
78
+ if (!target || target === this.dragging) return
79
+
80
+ const { left, width } = target.getBoundingClientRect()
81
+ const insertBefore = event.clientX < left + width / 2
82
+ this.gridTarget.insertBefore(this.dragging, insertBefore ? target : target.nextSibling)
83
+ }
84
+
85
+ onGridDrop(event) {
86
+ event.preventDefault()
87
+ }
88
+
89
+ // ─── Order inputs ────────────────────────────────────────────────────────────
90
+
91
+ updateOrderInputs() {
92
+ this.element.querySelectorAll('[data-gallery-order-input]').forEach(el => el.remove())
93
+
94
+ this.gridTarget.querySelectorAll('[data-gallery-item][data-attachment-id]').forEach(item => {
95
+ const id = item.dataset.attachmentId
96
+ if (!id) return
97
+ const input = document.createElement('input')
98
+ input.type = 'hidden'
99
+ input.name = `fields[${this.fieldIdValue}][order][]`
100
+ input.value = id
101
+ input.setAttribute('data-gallery-order-input', '')
102
+ this.element.appendChild(input)
103
+ })
104
+ }
105
+
106
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
107
+
108
+ syncFileInput() {
109
+ const dt = new DataTransfer()
110
+ this.newFileMap.forEach(file => dt.items.add(file))
111
+ this.fileInputTarget.files = dt.files
112
+ }
113
+
114
+ updateEmpty() {
115
+ if (!this.hasEmptyMsgTarget) return
116
+ const hasItems = this.gridTarget.querySelector('[data-gallery-item]')
117
+ this.emptyMsgTarget.classList.toggle('d-none', !!hasItems)
118
+ }
119
+
120
+ buildItem(src, attachmentId, fileUid) {
121
+ const div = document.createElement('div')
122
+ div.setAttribute('data-gallery-item', '')
123
+ div.dataset.attachmentId = attachmentId || ''
124
+ div.dataset.fileUid = fileUid || ''
125
+ div.draggable = true
126
+ div.className = 'lato-cms-gallery-field__item'
127
+ div.setAttribute('data-action', 'dragstart->gallery-field#onDragStart dragend->gallery-field#onDragEnd')
128
+ div.innerHTML = `
129
+ <img src="${src}" class="lato-cms-gallery-field__thumb" alt="">
130
+ <button type="button" class="lato-cms-gallery-field__remove" data-action="click->lato-cms-gallery-field#remove">
131
+ <i class="bi bi-x-lg"></i>
132
+ </button>
133
+ `
134
+ return div
135
+ }
136
+ }
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "newPreview", "newPreviewContainer", "currentContainer", "currentImage", "removeCheck"]
5
+
6
+ preview() {
7
+ const file = this.inputTarget.files[0]
8
+ if (!file || !file.type.startsWith('image/')) return
9
+
10
+ const reader = new FileReader()
11
+ reader.onload = ({ target }) => {
12
+ this.newPreviewTarget.src = target.result
13
+ this.newPreviewContainerTarget.classList.remove('d-none')
14
+ }
15
+ reader.readAsDataURL(file)
16
+ }
17
+
18
+ toggleRemove() {
19
+ if (!this.hasCurrentImageTarget) return
20
+ const removing = this.removeCheckTarget.checked
21
+ this.currentImageTarget.style.opacity = removing ? '0.3' : ''
22
+ this.currentImageTarget.style.outline = removing ? '2px solid var(--bs-danger)' : ''
23
+ }
24
+ }
@@ -0,0 +1,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "status"]
5
+
6
+ connect() {
7
+ this.validate()
8
+ }
9
+
10
+ validate() {
11
+ const value = this.inputTarget.value.trim()
12
+ if (!value) {
13
+ this.statusTarget.textContent = ""
14
+ this.statusTarget.className = "form-text"
15
+ return
16
+ }
17
+
18
+ try {
19
+ JSON.parse(value)
20
+ this.statusTarget.textContent = "Valid JSON"
21
+ this.statusTarget.className = "form-text text-success"
22
+ } catch (e) {
23
+ this.statusTarget.textContent = "Invalid JSON: " + e.message
24
+ this.statusTarget.className = "form-text text-danger"
25
+ }
26
+ }
27
+ }