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
@@ -0,0 +1,86 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['iframe']
5
+
6
+ connect () {
7
+ this.handleComponentChange = this.handleComponentChange.bind(this)
8
+ this.handleIframeLoad = this.handleIframeLoad.bind(this)
9
+
10
+ document.addEventListener('lato-cms:component-change', this.handleComponentChange)
11
+
12
+ if (this.hasIframeTarget) {
13
+ this.iframeTarget.addEventListener('load', this.handleIframeLoad)
14
+ }
15
+
16
+ window.requestAnimationFrame(() => {
17
+ this.syncInitiallyOpenComponent()
18
+ })
19
+ }
20
+
21
+ disconnect () {
22
+ document.removeEventListener('lato-cms:component-change', this.handleComponentChange)
23
+
24
+ if (this.hasIframeTarget) {
25
+ this.iframeTarget.removeEventListener('load', this.handleIframeLoad)
26
+ }
27
+ }
28
+
29
+ refresh () {
30
+ if (!this.hasIframeTarget) return
31
+
32
+ const iframe = this.iframeTarget
33
+ if (iframe.src) {
34
+ const url = new URL(iframe.src, window.location.href)
35
+ url.searchParams.set('_t', Date.now())
36
+ iframe.src = url.toString()
37
+ }
38
+ }
39
+
40
+ handleComponentChange (event) {
41
+ this.activeComponent = event.detail
42
+ this.postActiveComponent()
43
+ }
44
+
45
+ handleIframeLoad () {
46
+ this.postActiveComponent()
47
+ }
48
+
49
+ syncInitiallyOpenComponent () {
50
+ const openCollapse = document.querySelector('#components-accordion .accordion-collapse.show')
51
+ if (!openCollapse) return
52
+
53
+ const templateComponentId = openCollapse.dataset.templateComponentId
54
+ if (!templateComponentId) return
55
+
56
+ this.activeComponent = {
57
+ id: templateComponentId,
58
+ templateComponentId,
59
+ componentId: openCollapse.dataset.componentId
60
+ }
61
+
62
+ this.postActiveComponent()
63
+ }
64
+
65
+ postActiveComponent () {
66
+ if (!this.hasIframeTarget || !this.activeComponent) return
67
+
68
+ const iframeWindow = this.iframeTarget.contentWindow
69
+ if (!iframeWindow) return
70
+
71
+ iframeWindow.postMessage({
72
+ type: 'lato-cms:component-change',
73
+ ...this.activeComponent
74
+ }, this.targetOrigin())
75
+ }
76
+
77
+ targetOrigin () {
78
+ if (!this.iframeTarget.src) return window.location.origin
79
+
80
+ try {
81
+ return new URL(this.iframeTarget.src, window.location.href).origin
82
+ } catch (_error) {
83
+ return window.location.origin
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,74 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["editor", "source", "input", "toolbar", "sourceBtn"]
5
+
6
+ #sourceMode = false
7
+
8
+ connect() {
9
+ this.editorTarget.addEventListener("input", () => this.sync())
10
+ this.sourceTarget.addEventListener("input", () => this.syncFromSource())
11
+
12
+ // Prevent toolbar buttons from stealing focus (preserves text selection)
13
+ this.toolbarTarget.querySelectorAll("button").forEach(btn => {
14
+ btn.addEventListener("mousedown", (e) => e.preventDefault())
15
+ })
16
+
17
+ // Sync to hidden input just before form submission
18
+ const form = this.element.closest("form")
19
+ if (form) {
20
+ form.addEventListener("submit", () => this.sync(), { capture: true })
21
+ }
22
+ }
23
+
24
+ // ── Sync helpers ────────────────────────────────────────────────────────────
25
+
26
+ sync() {
27
+ this.inputTarget.value = this.editorTarget.innerHTML
28
+ }
29
+
30
+ syncFromSource() {
31
+ this.inputTarget.value = this.sourceTarget.value
32
+ }
33
+
34
+ // ── Toggle source view ──────────────────────────────────────────────────────
35
+
36
+ toggleSource() {
37
+ this.#sourceMode = !this.#sourceMode
38
+
39
+ if (this.#sourceMode) {
40
+ // Copy current HTML into the textarea (pretty-printed)
41
+ this.sourceTarget.value = this.editorTarget.innerHTML
42
+ this.editorTarget.classList.add("d-none")
43
+ this.sourceTarget.classList.remove("d-none")
44
+ this.sourceTarget.style.height = this.editorTarget.offsetHeight + "px"
45
+ this.sourceBtnTarget.classList.replace("btn-outline-secondary", "btn-secondary")
46
+ } else {
47
+ // Apply edited source back to the editor
48
+ this.editorTarget.innerHTML = this.sourceTarget.value
49
+ this.sync()
50
+ this.sourceTarget.classList.add("d-none")
51
+ this.editorTarget.classList.remove("d-none")
52
+ this.sourceBtnTarget.classList.replace("btn-secondary", "btn-outline-secondary")
53
+ }
54
+ }
55
+
56
+ // ── execCommand wrappers ────────────────────────────────────────────────────
57
+
58
+ exec(command, value = null) {
59
+ if (this.#sourceMode) return
60
+ this.editorTarget.focus()
61
+ document.execCommand(command, false, value)
62
+ this.sync()
63
+ }
64
+
65
+ bold() { this.exec("bold") }
66
+ italic() { this.exec("italic") }
67
+ underline() { this.exec("underline") }
68
+ strikethrough() { this.exec("strikeThrough") }
69
+ alignLeft() { this.exec("justifyLeft") }
70
+ alignCenter() { this.exec("justifyCenter") }
71
+ alignRight() { this.exec("justifyRight") }
72
+ insertUnorderedList() { this.exec("insertUnorderedList") }
73
+ insertOrderedList() { this.exec("insertOrderedList") }
74
+ }
@@ -0,0 +1,252 @@
1
+ // ── Advanced editor mode ───────────────────────────────────────────────────────
2
+
3
+ body.lato-cms-advanced-editor--open {
4
+ overflow: hidden;
5
+ }
6
+
7
+ .lato-cms-advanced-editor__row--active {
8
+ position: fixed;
9
+ inset: 0;
10
+ z-index: 1040;
11
+ margin: 0 !important;
12
+ display: flex !important;
13
+ align-items: stretch !important;
14
+ gap: 0 !important;
15
+
16
+ > .lato-cms-advanced-editor__preview-col {
17
+ flex: 1 1 auto;
18
+ min-width: 0;
19
+ padding: 0;
20
+ margin: 0;
21
+
22
+ .card {
23
+ height: 100dvh !important;
24
+ border: none !important;
25
+ border-radius: 0 !important;
26
+ }
27
+
28
+ iframe {
29
+ height: 100dvh !important;
30
+ }
31
+
32
+ // No-preview placeholder fills full screen
33
+ .card-body {
34
+ height: 100dvh;
35
+ }
36
+ }
37
+
38
+ > .lato-cms-advanced-editor__editor-col {
39
+ flex: 0 0 500px;
40
+ width: 500px;
41
+ padding: 0;
42
+ margin: 0;
43
+ overflow: hidden;
44
+ transition: flex-basis 0.25s ease, width 0.25s ease;
45
+
46
+ .card {
47
+ height: 100dvh !important;
48
+ border-radius: 0 !important;
49
+ border-top: none !important;
50
+ border-bottom: none !important;
51
+ border-right: none !important;
52
+ }
53
+
54
+ .card-body {
55
+ max-height: calc(100dvh - 57px) !important;
56
+ }
57
+
58
+ &.lato-cms-advanced-editor__editor-col--collapsed {
59
+ flex-basis: 0 !important;
60
+ width: 0 !important;
61
+ }
62
+ }
63
+ }
64
+
65
+ // Tab to re-open the sidebar when collapsed
66
+ .lato-cms-advanced-editor__reopen-btn {
67
+ position: fixed;
68
+ right: 0;
69
+ top: 50%;
70
+ transform: translateY(-50%);
71
+ z-index: 1041;
72
+ width: 24px;
73
+ height: 52px;
74
+ padding: 0;
75
+ border: 1px solid var(--bs-border-color);
76
+ border-right: none;
77
+ border-radius: 6px 0 0 6px;
78
+ background: #fff;
79
+ cursor: pointer;
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ box-shadow: -2px 0 6px rgba(0, 0, 0, 0.08);
84
+ transition: background 0.15s;
85
+
86
+ &:hover {
87
+ background: var(--bs-light, #f8f9fa);
88
+ }
89
+
90
+ i {
91
+ font-size: 11px;
92
+ color: var(--bs-secondary);
93
+ }
94
+ }
95
+
96
+ // ── Component form status ──────────────────────────────────────────────────────
97
+
98
+ .lato-cms-component-form__status {
99
+ transition: opacity 0.4s ease;
100
+ }
101
+
102
+ .lato-cms-component-form__status--fade {
103
+ opacity: 0;
104
+ }
105
+
106
+ .controller-pages {
107
+ .lato-index-desk-col-label-actions {
108
+ width: 1px;
109
+
110
+ > div {
111
+ display: none !important;
112
+ }
113
+ }
114
+
115
+ .lato-index-desk-col-value-actions {
116
+ width: 1px;
117
+ }
118
+ }
119
+
120
+ // ── WYSIWYG text field ─────────────────────────────────────────────────────────
121
+
122
+ .lato-cms-wysiwyg-editor {
123
+ height: auto;
124
+ cursor: text;
125
+
126
+ &:focus {
127
+ outline: none;
128
+ box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
129
+ border-color: #86b7fe;
130
+ }
131
+
132
+ // Basic prose spacing inside the editor
133
+ p, div { margin-bottom: 0.5em; }
134
+ ul, ol { padding-left: 1.5em; margin-bottom: 0.5em; }
135
+ li { margin-bottom: 0.1em; }
136
+
137
+ // Avoid empty-editor collapse
138
+ &:empty::before {
139
+ content: attr(data-placeholder);
140
+ color: var(--bs-secondary);
141
+ pointer-events: none;
142
+ }
143
+ }
144
+
145
+ // ── Image field ────────────────────────────────────────────────────────────────
146
+ .lato-cms-image-field__thumb {
147
+ max-width: 200px;
148
+ max-height: 200px;
149
+ object-fit: cover;
150
+ display: block;
151
+ transition: opacity 0.15s, outline 0.15s;
152
+ }
153
+
154
+ .lato-cms-image-field__remove-btn {
155
+ position: absolute;
156
+ top: 4px;
157
+ right: 4px;
158
+ width: 22px;
159
+ height: 22px;
160
+ border-radius: 50%;
161
+ background: rgba(220, 53, 69, 0.85);
162
+ color: #fff;
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: center;
166
+ font-size: 10px;
167
+ cursor: pointer;
168
+ transition: background 0.15s;
169
+
170
+ &:hover {
171
+ background: rgb(220, 53, 69);
172
+ }
173
+ }
174
+
175
+ .lato-cms-image-field__new-badge {
176
+ position: absolute;
177
+ bottom: 4px;
178
+ left: 4px;
179
+ font-size: 10px;
180
+ }
181
+
182
+ // ── Gallery field ──────────────────────────────────────────────────────────────
183
+ .lato-cms-gallery-field__grid {
184
+ display: flex;
185
+ flex-wrap: wrap;
186
+ gap: 8px;
187
+ min-height: 24px;
188
+ }
189
+
190
+ .lato-cms-gallery-field__item {
191
+ position: relative;
192
+ border-radius: 6px;
193
+ overflow: hidden;
194
+ cursor: grab;
195
+ user-select: none;
196
+ transition: opacity 0.15s, box-shadow 0.15s;
197
+
198
+ &:active {
199
+ cursor: grabbing;
200
+ }
201
+
202
+ &--dragging {
203
+ opacity: 0.35;
204
+ box-shadow: 0 0 0 2px var(--bs-primary);
205
+ }
206
+ }
207
+
208
+ .lato-cms-gallery-field__thumb {
209
+ width: 90px;
210
+ height: 90px;
211
+ object-fit: cover;
212
+ display: block;
213
+ }
214
+
215
+ .lato-cms-gallery-field__remove {
216
+ position: absolute;
217
+ top: 3px;
218
+ right: 3px;
219
+ width: 20px;
220
+ height: 20px;
221
+ border-radius: 50%;
222
+ border: none;
223
+ background: rgba(220, 53, 69, 0.85);
224
+ color: #fff;
225
+ display: flex;
226
+ align-items: center;
227
+ justify-content: center;
228
+ font-size: 9px;
229
+ padding: 0;
230
+ cursor: pointer;
231
+ line-height: 1;
232
+ transition: background 0.15s;
233
+
234
+ &:hover {
235
+ background: rgb(220, 53, 69);
236
+ }
237
+ }
238
+
239
+ .controller-pages-show {
240
+ .accordion-button {
241
+ font-weight: 500;
242
+
243
+ &:not(.collapsed) {
244
+ background-color: rgba(var(--bs-primary-rgb), 0.05);
245
+ }
246
+ }
247
+
248
+ .accordion-body .form-label {
249
+ font-weight: 500;
250
+ font-size: 0.875rem;
251
+ }
252
+ }
@@ -0,0 +1,38 @@
1
+ module LatoCms
2
+ module Api
3
+ class PagesController < ActionController::API
4
+ before_action :authenticate_lato_spaces_group
5
+ before_action :set_page, only: [:show]
6
+
7
+ def index
8
+ pages = LatoCms::Page.for_lato_spaces_group(@lato_spaces_group_id).order(title: :asc)
9
+ pages = pages.where(locale: params[:locale]) if params[:locale].present?
10
+ render json: pages.map(&:as_json)
11
+ end
12
+
13
+ def show
14
+ @page.fields.load
15
+ render json: @page.as_json(include_fields: true)
16
+ end
17
+
18
+ private
19
+
20
+ def authenticate_lato_spaces_group
21
+ @lato_spaces_group_id = params[:group_id]
22
+ if @lato_spaces_group_id.blank?
23
+ render json: { error: 'group_id parameter is required' }, status: :bad_request
24
+ return
25
+ end
26
+
27
+ true
28
+ end
29
+
30
+ def set_page
31
+ id = params[:id]
32
+ @page = id.match?(/\A\d+\z/) ? LatoCms::Page.for_lato_spaces_group(@lato_spaces_group_id).find(id) : LatoCms::Page.for_lato_spaces_group(@lato_spaces_group_id).find_by!(permalink: "/#{id.delete_prefix('/')}")
33
+ rescue ActiveRecord::RecordNotFound
34
+ render json: { error: I18n.t('lato_cms.api_page_not_found') }, status: :not_found
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ module LatoCms
2
+ class ApplicationController < Lato::ApplicationController
3
+ include LatoSpaces::Groupable
4
+ layout 'lato/application'
5
+ before_action :authenticate_session
6
+ before_action :authenticate_group
7
+ before_action :authenticate_lato_cms_admin
8
+ before_action { active_sidebar(:lato_cms); active_navbar(:lato_cms) }
9
+
10
+ def index
11
+ redirect_to lato_cms.pages_path
12
+ end
13
+
14
+ protected
15
+
16
+ def query_pages
17
+ @query_pages ||= LatoCms::Page.for_lato_spaces_group(@session.get(:spaces_group_id))
18
+ end
19
+
20
+ def authenticate_lato_cms_admin
21
+ return true if @session.user&.lato_cms_admin
22
+
23
+ redirect_to lato.root_path, alert: t('lato_cms.unauthorized_section')
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,194 @@
1
+ module LatoCms
2
+ class PagesController < ApplicationController
3
+ before_action { active_sidebar(:lato_cms_pages) }
4
+
5
+ def index
6
+ pages = query_pages
7
+ pages = pages.where(locale: params[:locale]) if params[:locale].present?
8
+
9
+ @pages = lato_index_collection(
10
+ pages,
11
+ columns: %i[title permalink locale actions],
12
+ sortable_columns: %i[title permalink locale],
13
+ searchable_columns: %i[title permalink],
14
+ default_sort_by: 'title|ASC',
15
+ pagination: 20
16
+ )
17
+ end
18
+
19
+ def show
20
+ @page = query_pages.find(params[:id])
21
+ @page.fields.load
22
+ @template = @page.template
23
+ @template_components = @page.template_components
24
+ end
25
+
26
+ def create
27
+ @page = LatoCms::Page.new(locale: LatoCms.config.locales.first.to_s)
28
+ end
29
+
30
+ def create_action
31
+ @page = LatoCms::Page.new(create_params.merge(lato_spaces_group_id: @session.get(:spaces_group_id)))
32
+
33
+ respond_to do |format|
34
+ if @page.save
35
+ format.html { redirect_to lato_cms.pages_path, notice: t('lato_cms.page_created') }
36
+ format.json { render json: @page }
37
+ else
38
+ format.html { render :create, status: :unprocessable_entity }
39
+ format.json { render json: @page.errors, status: :unprocessable_entity }
40
+ end
41
+ end
42
+ end
43
+
44
+ def update
45
+ @page = query_pages.find(params[:id])
46
+ end
47
+
48
+ def update_action
49
+ @page = query_pages.find(params[:id])
50
+
51
+ respond_to do |format|
52
+ if @page.update(update_params)
53
+ format.html { redirect_to lato_cms.pages_show_path(@page), notice: t('lato_cms.page_updated') }
54
+ format.json { render json: @page }
55
+ else
56
+ format.html { render :update, status: :unprocessable_entity }
57
+ format.json { render json: @page.errors, status: :unprocessable_entity }
58
+ end
59
+ end
60
+ end
61
+
62
+ def save_fields_action
63
+ @page = query_pages.find(params[:id])
64
+
65
+ template_component_id = params[:template_component_id]
66
+ component_id = params[:component_id]
67
+ fields_data = params[:fields] || {}
68
+
69
+ component = LatoCms::TemplateManager.find_component(component_id)
70
+ errors = []
71
+
72
+ unless @page.component_effectively_enabled?(template_component_id)
73
+ respond_to do |format|
74
+ format.html { redirect_to lato_cms.pages_show_path(@page), alert: t('lato_cms.component_disabled_cannot_save') }
75
+ format.json { render json: { error: t('lato_cms.component_disabled_cannot_save') }, status: :unprocessable_entity }
76
+ end
77
+ return
78
+ end
79
+
80
+ fields_data.each do |field_id, field_data|
81
+ field = @page.fields.find_or_initialize_by(
82
+ template_id: @page.template_id,
83
+ template_component_id: template_component_id,
84
+ component_id: component_id,
85
+ field_id: field_id
86
+ )
87
+
88
+ field_config = component&.dig('fields', field_id)
89
+ field_type = field_config&.dig('type') || 'string'
90
+
91
+ case field_type
92
+ when 'file', 'image'
93
+ field.save if field.new_record?
94
+ if field_data[:files].present?
95
+ Array(field_data[:files]).compact.each { |f| field.files.attach(f) }
96
+ end
97
+ if field_data[:remove_file_ids].present?
98
+ Array(field_data[:remove_file_ids]).reject(&:blank?).each do |file_id_to_remove|
99
+ field.files.find { |f| f.id == file_id_to_remove.to_i }&.purge
100
+ end
101
+ end
102
+ when 'gallery'
103
+ field.save if field.new_record?
104
+ if field_data[:files].present?
105
+ Array(field_data[:files]).compact.each { |f| field.files.attach(f) }
106
+ end
107
+ if field_data[:remove_file_ids].present?
108
+ Array(field_data[:remove_file_ids]).reject(&:blank?).each do |file_id_to_remove|
109
+ field.files.find { |f| f.id == file_id_to_remove.to_i }&.purge
110
+ end
111
+ end
112
+ # Persist order: existing IDs in dragged order + any new file IDs appended at end
113
+ order = Array(field_data[:order]).reject(&:blank?).map(&:to_s)
114
+ all_ids = field.files.reload.map { |f| f.id.to_s }
115
+ sorted = order.select { |id| all_ids.include?(id) }
116
+ new_ids = all_ids - sorted
117
+ field.value = (sorted + new_ids).to_json
118
+ when 'multiselect'
119
+ value = field_data[:value]
120
+ value = Array(value).reject(&:blank?)
121
+ field.value = value.to_json
122
+ else
123
+ raw_value = field_data.is_a?(ActionController::Parameters) ? field_data[:value] : field_data
124
+ field.value = raw_value.to_s.presence
125
+ end
126
+
127
+ unless field.save
128
+ errors << { field_id: field_id, errors: field.errors.full_messages }
129
+ end
130
+ end
131
+
132
+ respond_to do |format|
133
+ if errors.empty?
134
+ format.html { redirect_to lato_cms.pages_show_path(@page), notice: t('lato_cms.fields_saved') }
135
+ format.json { render json: { message: t('lato_cms.fields_saved') } }
136
+ else
137
+ error_messages = errors.map { |e| "#{e[:field_id]}: #{e[:errors].join(', ')}" }.join('; ')
138
+ format.html { redirect_to lato_cms.pages_show_path(@page), alert: error_messages }
139
+ format.json { render json: { errors: errors }, status: :unprocessable_entity }
140
+ end
141
+ end
142
+ end
143
+
144
+ def toggle_component_action
145
+ @page = query_pages.find(params[:id])
146
+ template_component_id = params[:template_component_id].to_s
147
+ enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
148
+
149
+ if @page.component_required?(template_component_id)
150
+ respond_to do |format|
151
+ format.html { redirect_to lato_cms.pages_show_path(@page), alert: t('lato_cms.component_required_cannot_disable') }
152
+ format.json { render json: { error: t('lato_cms.component_required_cannot_disable') }, status: :unprocessable_entity }
153
+ end
154
+ return
155
+ end
156
+
157
+ @page.set_component_enabled(template_component_id, enabled)
158
+
159
+ respond_to do |format|
160
+ if @page.save
161
+ format.html { redirect_to lato_cms.pages_show_path(@page), notice: t('lato_cms.component_state_updated') }
162
+ format.json { render json: { message: t('lato_cms.component_state_updated') } }
163
+ else
164
+ format.html { redirect_to lato_cms.pages_show_path(@page), alert: @page.errors.full_messages.to_sentence }
165
+ format.json { render json: { errors: @page.errors.full_messages }, status: :unprocessable_entity }
166
+ end
167
+ end
168
+ end
169
+
170
+ def destroy_action
171
+ @page = query_pages.find(params[:id])
172
+
173
+ respond_to do |format|
174
+ if @page.destroy
175
+ format.html { redirect_to lato_cms.pages_path, notice: t('lato_cms.page_deleted') }
176
+ format.json { render json: { message: t('lato_cms.page_deleted') } }
177
+ else
178
+ format.html { redirect_to lato_cms.pages_path, alert: t('lato_cms.page_delete_failed') }
179
+ format.json { render json: { error: t('lato_cms.page_delete_failed') }, status: :unprocessable_entity }
180
+ end
181
+ end
182
+ end
183
+
184
+ private
185
+
186
+ def create_params
187
+ params.require(:page).permit(:title, :locale)
188
+ end
189
+
190
+ def update_params
191
+ params.require(:page).permit(:title, :permalink, :frontend_url, :template_id)
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,4 @@
1
+ module LatoCms
2
+ module ApplicationHelper
3
+ end
4
+ end