lato_cms 3.0.5 → 3.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 880a42ede332f95018d49081a9d2dc3ac0b510c62e4a218ebf71561eec3c9408
4
- data.tar.gz: 9e7f487b6323f950e3d68bf80acc3bb9770962e72c1f64fc6394ab0adf66e368
3
+ metadata.gz: 73f72c7845f56b80f62423a1709a3ea8d5bd80813d8c61b4eaf6af2b353d660c
4
+ data.tar.gz: f222800a0be54f4e7022ecebfe6259515e56fd82554d1784d7bf55fa1a65074e
5
5
  SHA512:
6
- metadata.gz: 198521b168caa3bfb06b67cb466335da752144c8d33ad8d7850d70b36c4b598864387d1cff4bc9fb0f389fbed304093f49446a944d6e483917abfe98bc59d825
7
- data.tar.gz: 657818b8115590944db38ea1ba4141dd2f720455ed4621a7ff4ad046ac753d0bdf16c01c1a39df79cf251f28c81b515f1316c31dbf0f92c4166725b6fc12fb75
6
+ metadata.gz: 59fa3bb5a49ce31a770192a6708c59f95313d44896bb493bf0d009b523f950d8f3165609b38da4dbfa4c239c9e6f7d44f2f8bcd870c7d01c4a3b147d11877c7b
7
+ data.tar.gz: 2d31910d84e7df094b4c85ae3f790bcd8e47e2c66dd7cfd472f9c2fde5016b85b1fbb9de1c7a12a86e8946ea527ab0fe8d8434d6e3ab1e5683df9d1fb402731d
@@ -4,9 +4,11 @@ module LatoCms
4
4
  layout 'lato/application'
5
5
  before_action :authenticate_session
6
6
  before_action :authenticate_group
7
- before_action :authenticate_lato_cms_admin
7
+ before_action :authenticate_lato_cms_access
8
8
  before_action { active_sidebar(:lato_cms); active_navbar(:lato_cms) }
9
9
 
10
+ helper_method :lato_cms_admin?
11
+
10
12
  def index
11
13
  redirect_to lato_cms.pages_path
12
14
  end
@@ -17,10 +19,23 @@ module LatoCms
17
19
  @query_pages ||= LatoCms::Page.for_lato_spaces_group(@session.get(:spaces_group_id))
18
20
  end
19
21
 
20
- def authenticate_lato_cms_admin
21
- return true if @session.user&.lato_cms_admin
22
+ # Section-level guard: any CMS role (operator or admin) may enter.
23
+ def authenticate_lato_cms_access
24
+ return true if @session.user&.lato_cms_access?
22
25
 
23
26
  redirect_to lato.root_path, alert: t('lato_cms.unauthorized_section')
24
27
  end
28
+
29
+ # Action-level guard for page management and translation links: admin only.
30
+ def authenticate_lato_cms_admin
31
+ return true if lato_cms_admin?
32
+
33
+ redirect_to lato_cms.pages_path, alert: t('lato_cms.unauthorized_action')
34
+ end
35
+
36
+ # Exposed to views to conditionally render admin-only controls.
37
+ def lato_cms_admin?
38
+ @session.user&.lato_cms_admin? || false
39
+ end
25
40
  end
26
41
  end
@@ -1,6 +1,16 @@
1
1
  module LatoCms
2
2
  class PagesController < ApplicationController
3
+ # Page management and translation links are reserved to the admin role.
4
+ # Operators keep read access plus field editing and component toggles.
5
+ ADMIN_ONLY_ACTIONS = %i[
6
+ create create_action
7
+ update update_action
8
+ destroy_action
9
+ translations link_translation_action unlink_translation_action
10
+ ].freeze
11
+
3
12
  before_action { active_sidebar(:lato_cms_pages) }
13
+ before_action :authenticate_lato_cms_admin, only: ADMIN_ONLY_ACTIONS
4
14
 
5
15
  def index
6
16
  pages = query_pages
@@ -125,6 +135,56 @@ module LatoCms
125
135
  end
126
136
  end
127
137
 
138
+ def clone_component_action
139
+ @page = query_pages.find(params[:id])
140
+ source = @page.translations.find_by(id: params[:source_page_id])
141
+ template_component_id = params[:template_component_id].to_s
142
+
143
+ respond_to do |format|
144
+ if source && clone_component_fields(source, @page, template_component_id)
145
+ format.html { redirect_to lato_cms.pages_show_path(@page), notice: t('lato_cms.component_cloned') }
146
+ format.json { render json: { message: t('lato_cms.component_cloned') } }
147
+ else
148
+ format.html { redirect_to lato_cms.pages_show_path(@page), alert: t('lato_cms.component_clone_failed') }
149
+ format.json { render json: { error: t('lato_cms.component_clone_failed') }, status: :unprocessable_entity }
150
+ end
151
+ end
152
+ end
153
+
154
+ def translations
155
+ @page = query_pages.find(params[:id])
156
+ end
157
+
158
+ def link_translation_action
159
+ @page = query_pages.find(params[:id])
160
+ other_page = query_pages.find(params[:translation_page_id])
161
+
162
+ respond_to do |format|
163
+ if @page.link_translation(other_page)
164
+ format.html { redirect_to lato_cms.pages_translations_path(@page), notice: t('lato_cms.translation_linked') }
165
+ format.json { render json: @page }
166
+ else
167
+ format.html { redirect_to lato_cms.pages_translations_path(@page), alert: t('lato_cms.translation_link_failed') }
168
+ format.json { render json: { error: t('lato_cms.translation_link_failed') }, status: :unprocessable_entity }
169
+ end
170
+ end
171
+ end
172
+
173
+ def unlink_translation_action
174
+ @page = query_pages.find(params[:id])
175
+ other_page = query_pages.find(params[:translation_page_id])
176
+
177
+ respond_to do |format|
178
+ if @page.unlink_translation(other_page)
179
+ format.html { redirect_to lato_cms.pages_translations_path(@page), notice: t('lato_cms.translation_unlinked') }
180
+ format.json { render json: @page }
181
+ else
182
+ format.html { redirect_to lato_cms.pages_translations_path(@page), alert: t('lato_cms.translation_unlink_failed') }
183
+ format.json { render json: { error: t('lato_cms.translation_unlink_failed') }, status: :unprocessable_entity }
184
+ end
185
+ end
186
+ end
187
+
128
188
  def destroy_action
129
189
  @page = query_pages.find(params[:id])
130
190
 
@@ -141,6 +201,33 @@ module LatoCms
141
201
 
142
202
  private
143
203
 
204
+ # Replaces the target page's fields for a component with copies of the source
205
+ # page's fields for the same component. Attachments share the source blobs.
206
+ def clone_component_fields(source, target, template_component_id)
207
+ # Components are template-specific: only clone between pages using the same template.
208
+ return false unless source.template_id == target.template_id
209
+
210
+ source_fields = source.fields.select { |f| f.template_component_id == template_component_id }
211
+ return false if source_fields.empty?
212
+
213
+ target.transaction do
214
+ target.fields.where(template_component_id: template_component_id).destroy_all
215
+
216
+ source_fields.each do |src|
217
+ field = target.fields.create!(
218
+ template_id: target.template_id,
219
+ template_component_id: src.template_component_id,
220
+ component_id: src.component_id,
221
+ field_id: src.field_id,
222
+ value: src.value
223
+ )
224
+ src.files.each { |file| field.files.attach(file.blob) }
225
+ end
226
+ end
227
+
228
+ true
229
+ end
230
+
144
231
  def save_repeater_fields(template_component, component, repeater_items, repeater_order, errors)
145
232
  template_component_id = template_component[:template_component_id]
146
233
  component_id = template_component[:component_id]
@@ -10,6 +10,7 @@ module LatoCms
10
10
  '#'
11
11
  end
12
12
 
13
+ # Index locale cell (also the `locale` column viewer). Static badge.
13
14
  def lato_cms_page_locale(page)
14
15
  content_tag(:span, class: 'badge bg-secondary') do
15
16
  concat locale_to_flag(page.locale)
@@ -17,6 +18,7 @@ module LatoCms
17
18
  end
18
19
  end
19
20
 
21
+ # Index actions cell (also the `actions` column viewer). Inline buttons.
20
22
  def lato_cms_page_actions(page, show_edit: false, show_delete: false, hide_show: false)
21
23
  btn_group = capture do
22
24
  content_tag(:div, class: 'btn-group btn-group-sm') do
@@ -42,6 +44,63 @@ module LatoCms
42
44
  end
43
45
  end
44
46
 
47
+ # Show-page locale: dropdown to jump to the same page in another language.
48
+ def lato_cms_page_locale_dropdown(page)
49
+ siblings = page.translations.order(:locale).to_a
50
+ badge = capture do
51
+ concat locale_to_flag(page.locale)
52
+ concat " #{page.locale.upcase}"
53
+ end
54
+
55
+ return content_tag(:span, badge, class: 'badge bg-secondary') if siblings.empty?
56
+
57
+ content_tag(:div, class: 'dropdown') do
58
+ concat button_tag(badge, type: 'button', class: 'btn btn-sm btn-secondary dropdown-toggle', data: { bs_toggle: 'dropdown' })
59
+ concat(content_tag(:ul, class: 'dropdown-menu') do
60
+ siblings.each do |sibling|
61
+ concat(content_tag(:li) do
62
+ link_to lato_cms.pages_show_path(sibling), class: 'dropdown-item' do
63
+ concat locale_to_flag(sibling.locale)
64
+ concat " #{sibling.locale.upcase} — #{sibling.title}"
65
+ end
66
+ end)
67
+ end
68
+ end)
69
+ end
70
+ end
71
+
72
+ # Show-page actions: single dropdown (view frontend, show, edit settings,
73
+ # translations, delete).
74
+ def lato_cms_page_actions_dropdown(page, show_edit: false, show_translations: false, show_delete: false, hide_show: false)
75
+ content_tag(:div, class: 'dropdown', title: t('lato_cms.cta_actions'), data: { controller: 'lato-tooltip' }) do
76
+ concat button_tag(tag.i(class: 'bi bi-three-dots-vertical'), type: 'button',
77
+ class: 'btn btn-sm btn-outline-primary', data: { bs_toggle: 'dropdown' })
78
+ concat(content_tag(:ul, class: 'dropdown-menu dropdown-menu-end') do
79
+ if page.frontend_url.present?
80
+ concat content_tag(:li, link_to(t('lato_cms.action_view_frontend'), page.frontend_url, class: 'dropdown-item', target: '_blank'))
81
+ else
82
+ concat content_tag(:li, content_tag(:span, t('lato_cms.action_view_frontend'), class: 'dropdown-item disabled text-muted'))
83
+ end
84
+ unless hide_show
85
+ concat content_tag(:li, link_to(t('lato_cms.cta_show'), lato_cms.pages_show_path(page), class: 'dropdown-item'))
86
+ end
87
+ if show_edit
88
+ concat content_tag(:li, link_to(t('lato_cms.cta_edit'), lato_cms.pages_update_path(page), class: 'dropdown-item',
89
+ data: { lato_action_target: 'trigger', turbo_frame: dom_id(page, 'form'), action_title: t('lato_cms.page_update_title') }))
90
+ end
91
+ if show_translations
92
+ concat content_tag(:li, link_to(t('lato_cms.translations_title'), lato_cms.pages_translations_path(page), class: 'dropdown-item',
93
+ data: { lato_action_target: 'trigger', turbo_frame: dom_id(page, 'translations'), action_title: t('lato_cms.translations_title') }))
94
+ end
95
+ if show_delete
96
+ concat content_tag(:li, tag.hr(class: 'dropdown-divider'))
97
+ concat content_tag(:li, link_to(t('lato_cms.cta_delete'), lato_cms.pages_destroy_action_path(page), class: 'dropdown-item text-danger',
98
+ data: { turbo_method: 'DELETE', turbo_confirm: t('lato_cms.cta_delete_confirm') }))
99
+ end
100
+ end)
101
+ end
102
+ end
103
+
45
104
  def lato_cms_render_field(field_id:, field_config:, page_field:, input_name_prefix: nil, dom_id_prefix: nil)
46
105
  render resolve_lato_cms_field_partial(field_config),
47
106
  field_id: field_id,
@@ -10,11 +10,73 @@ module LatoCms
10
10
  validates :locale, presence: true, inclusion: { in: ->(page) { LatoCms.config.locales.map(&:to_s) } }
11
11
  validates :permalink, presence: true, format: { with: /\A\/([a-zA-Z0-9\-_]+(\/[a-zA-Z0-9\-_]+)*)?\z/, message: 'must start with / and contain only letters, numbers, hyphens, and underscores' }
12
12
  validate :permalink_unique_within_lato_spaces_group
13
+ validate :locale_unique_within_translation_group
13
14
 
14
15
  before_validation :generate_permalink, on: :create
15
16
 
16
17
  has_many :fields, class_name: 'LatoCms::PageField', dependent: :destroy
17
18
 
19
+ # ── Translations ────────────────────────────────────────────────────────
20
+ # Pages sharing the same translation_group_id (within the same spaces group)
21
+ # are translations of each other, at most one per locale.
22
+
23
+ # Sibling pages linked as translations of this page.
24
+ def translations
25
+ return LatoCms::Page.none if translation_group_id.blank?
26
+
27
+ LatoCms::Page
28
+ .for_lato_spaces_group(lato_spaces_group_id)
29
+ .where(translation_group_id: translation_group_id)
30
+ .where.not(id: id)
31
+ end
32
+
33
+ # The linked translation for a given locale, if any.
34
+ def translation_for(locale)
35
+ translations.find_by(locale: locale.to_s)
36
+ end
37
+
38
+ # Pages of a given locale that can be linked to this page: same spaces group,
39
+ # not this page, and not already part of any translation group.
40
+ def translation_candidates(locale)
41
+ LatoCms::Page
42
+ .for_lato_spaces_group(lato_spaces_group_id)
43
+ .where(locale: locale.to_s, translation_group_id: nil)
44
+ .where.not(id: id)
45
+ end
46
+
47
+ # Links another page as a translation of this one, merging any existing
48
+ # groups so all members share a single translation_group_id.
49
+ def link_translation(other_page)
50
+ return false unless other_page && other_page.id != id
51
+ return false unless other_page.lato_spaces_group_id == lato_spaces_group_id
52
+
53
+ group_id = translation_group_id.presence || other_page.translation_group_id.presence || SecureRandom.uuid
54
+ old_group_id = other_page.translation_group_id.presence
55
+
56
+ transaction do
57
+ update!(translation_group_id: group_id)
58
+ # Re-point every member of the other page's former group to keep groups merged.
59
+ if old_group_id && old_group_id != group_id
60
+ LatoCms::Page
61
+ .for_lato_spaces_group(lato_spaces_group_id)
62
+ .where(translation_group_id: old_group_id)
63
+ .update_all(translation_group_id: group_id)
64
+ else
65
+ other_page.update!(translation_group_id: group_id)
66
+ end
67
+ end
68
+
69
+ true
70
+ end
71
+
72
+ # Removes a page from this page's translation group.
73
+ def unlink_translation(other_page)
74
+ return false unless other_page && other_page.translation_group_id == translation_group_id
75
+
76
+ other_page.update!(translation_group_id: nil)
77
+ true
78
+ end
79
+
18
80
  def template
19
81
  LatoCms::TemplateManager.find_template(template_id)
20
82
  end
@@ -64,6 +126,7 @@ module LatoCms
64
126
  template_id: template_id,
65
127
  template_name: template_name,
66
128
  frontend_url: frontend_url,
129
+ translations: translations_json,
67
130
  created_at: created_at,
68
131
  updated_at: updated_at
69
132
  }
@@ -78,6 +141,13 @@ module LatoCms
78
141
 
79
142
  private
80
143
 
144
+ # Map of locale => permalink/id for linked translations, for API consumers.
145
+ def translations_json
146
+ translations.each_with_object({}) do |page, hash|
147
+ hash[page.locale] = { id: page.id, permalink: page.permalink, frontend_url: page.frontend_url }
148
+ end
149
+ end
150
+
81
151
  def build_fields_json(comps)
82
152
  fields_by_component = fields.group_by(&:template_component_id)
83
153
 
@@ -160,5 +230,17 @@ module LatoCms
160
230
  errors.add(:permalink, :taken)
161
231
  end
162
232
  end
233
+
234
+ def locale_unique_within_translation_group
235
+ return if translation_group_id.blank?
236
+
237
+ conflict = LatoCms::Page
238
+ .for_lato_spaces_group(lato_spaces_group_id)
239
+ .where(translation_group_id: translation_group_id, locale: locale)
240
+ .where.not(id: id)
241
+ .exists?
242
+
243
+ errors.add(:locale, :taken) if conflict
244
+ end
163
245
  end
164
246
  end
@@ -2,6 +2,9 @@
2
2
  <% component_fields = page.fields.select { |f| f.template_component_id == tc[:template_component_id] } %>
3
3
  <% required_component = tc[:required] == true %>
4
4
  <% enabled_component = page.component_effectively_enabled?(tc[:template_component_id]) %>
5
+ <%# Clonable translations: linked pages using the same template (components differ
6
+ across templates, so cross-template clone is not allowed). %>
7
+ <% clonable_translations = tc[:component] ? translations.select { |s| s.template_id == page.template_id } : [] %>
5
8
 
6
9
  <div class="accordion-item">
7
10
  <h2 class="accordion-header" id="heading-<%= accordion_id %>">
@@ -23,25 +26,50 @@
23
26
  data-lato-cms-component-toggle-saving-label-value="<%= t('lato_cms.component_state_saving') %>"
24
27
  data-lato-cms-component-toggle-saved-label-value="<%= t('lato_cms.component_state_saved') %>"
25
28
  data-lato-cms-component-toggle-error-label-value="<%= t('lato_cms.component_state_save_error') %>">
29
+ <% end %>
26
30
 
27
- <%= form_with url: lato_cms.pages_toggle_component_action_path(page, tc[:template_component_id]), method: :patch,
28
- data: { turbo: false, action: 'submit->lato-cms-component-toggle#toggle' } do %>
29
- <div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
30
- <div class="d-flex align-items-center gap-2 small fw-medium <%= enabled_component ? 'text-success' : 'text-muted' %>" data-lato-cms-component-toggle-target="stateWrapper">
31
- <i class="<%= enabled_component ? 'bi bi-eye' : 'bi bi-eye-slash' %>" data-lato-cms-component-toggle-target="stateIcon"></i>
32
- <span data-lato-cms-component-toggle-target="stateLabel"><%= enabled_component ? t('lato_cms.component_active_label') : t('lato_cms.component_inactive_label') %></span>
31
+ <%# Component settings header: enable/disable toggle (left) and clone (right). %>
32
+ <% if !required_component || clonable_translations.any? %>
33
+ <div class="d-flex justify-content-between align-items-center gap-2 mb-3 pb-2 border-bottom">
34
+ <div class="d-flex align-items-center gap-3">
35
+ <% unless required_component %>
36
+ <%= form_with url: lato_cms.pages_toggle_component_action_path(page, tc[:template_component_id]), method: :patch,
37
+ data: { turbo: false, action: 'submit->lato-cms-component-toggle#toggle' } do %>
38
+ <div class="d-flex align-items-center gap-3">
39
+ <div class="form-check form-switch m-0">
40
+ <%= hidden_field_tag :enabled, '0' %>
41
+ <%= check_box_tag :enabled, '1', enabled_component, class: 'form-check-input', id: "toggle-#{accordion_id}", onchange: 'this.form.requestSubmit()', 'aria-label': t('lato_cms.component_enabled_label'), data: { lato_cms_component_toggle_target: 'checkbox' } %>
42
+ </div>
43
+ <div class="d-flex align-items-center gap-2 small fw-medium <%= enabled_component ? 'text-success' : 'text-muted' %>" data-lato-cms-component-toggle-target="stateWrapper">
44
+ <i class="<%= enabled_component ? 'bi bi-eye' : 'bi bi-eye-slash' %>" data-lato-cms-component-toggle-target="stateIcon"></i>
45
+ <span data-lato-cms-component-toggle-target="stateLabel"><%= enabled_component ? t('lato_cms.component_active_label') : t('lato_cms.component_inactive_label') %></span>
46
+ </div>
47
+ <span data-lato-cms-component-toggle-target="status"></span>
48
+ </div>
49
+ <% end %>
50
+ <% end %>
33
51
  </div>
34
- <div class="d-flex align-items-center gap-2">
35
- <span data-lato-cms-component-toggle-target="status"></span>
36
- <div class="form-check form-switch m-0">
37
- <%= hidden_field_tag :enabled, '0' %>
38
- <%= check_box_tag :enabled, '1', enabled_component, class: 'form-check-input', id: "toggle-#{accordion_id}", onchange: 'this.form.requestSubmit()', data: { lato_cms_component_toggle_target: 'checkbox' } %>
39
- <%= label_tag "toggle-#{accordion_id}", t('lato_cms.component_enabled_label'), class: 'form-check-label ms-1 small' %>
52
+
53
+ <% if clonable_translations.any? %>
54
+ <div class="dropdown flex-shrink-0" data-controller="lato-tooltip" title="<%= t('lato_cms.component_clone_cta') %>">
55
+ <button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
56
+ <i class="bi bi-files"></i>
57
+ </button>
58
+ <ul class="dropdown-menu dropdown-menu-end">
59
+ <% clonable_translations.each do |sibling| %>
60
+ <li>
61
+ <%= button_to lato_cms.pages_clone_component_action_path(page, tc[:template_component_id]),
62
+ params: { source_page_id: sibling.id }, method: :post, class: 'dropdown-item',
63
+ form: { data: { turbo_confirm: t('lato_cms.component_clone_confirm', lang: sibling.locale.upcase) } } do %>
64
+ <%= locale_to_flag(sibling.locale) %> <%= t('lato_cms.component_clone_from', lang: sibling.locale.upcase) %>
65
+ <% end %>
66
+ </li>
67
+ <% end %>
68
+ </ul>
40
69
  </div>
41
- </div>
70
+ <% end %>
42
71
  </div>
43
72
  <% end %>
44
- <% end %>
45
73
 
46
74
  <% if tc[:component] %>
47
75
  <% if required_component %>
@@ -1,5 +1,6 @@
1
1
  <% page ||= @page %>
2
2
  <% template_components ||= @template_components %>
3
+ <% translations = page.translations.order(:locale).to_a %>
3
4
 
4
5
  <div class="accordion" id="components-accordion" data-controller="lato-cms-component-accordion">
5
6
  <% template_components.each_with_index do |tc, index| %>
@@ -7,6 +8,7 @@
7
8
  page: page,
8
9
  tc: tc,
9
10
  index: index,
10
- expanded: index == 0 %>
11
+ expanded: index == 0,
12
+ translations: translations %>
11
13
  <% end %>
12
14
  </div>
@@ -0,0 +1,41 @@
1
+ <%= turbo_frame_tag dom_id(page, 'translations') do %>
2
+ <%= lato_form_notices class: %w[mb-3] %>
3
+
4
+ <% other_locales = LatoCms.config.locales.map(&:to_s) - [page.locale.to_s] %>
5
+ <% if other_locales.empty? %>
6
+ <p class="text-muted mb-0"><%= t('lato_cms.translation_no_other_locales') %></p>
7
+ <% else %>
8
+ <% other_locales.each do |locale| %>
9
+ <div class="d-flex align-items-center justify-content-between gap-2 mb-2">
10
+ <span class="badge bg-secondary">
11
+ <%= locale_to_flag(locale) %> <%= locale.upcase %>
12
+ </span>
13
+
14
+ <% sibling = page.translation_for(locale) %>
15
+ <% if sibling %>
16
+ <%# Linked translation: show it with an unlink button. %>
17
+ <div class="d-flex align-items-center gap-2 flex-grow-1 justify-content-end">
18
+ <%= link_to sibling.title, lato_cms.pages_show_path(sibling), class: 'text-truncate' %>
19
+ <%= button_to t('lato_cms.translation_unlink_cta'), lato_cms.pages_unlink_translation_action_path(page),
20
+ method: :delete, params: { translation_page_id: sibling.id },
21
+ class: 'btn btn-sm btn-outline-danger', form: { data: { turbo_confirm: t('lato_cms.translation_unlink_confirm') } } %>
22
+ </div>
23
+ <% else %>
24
+ <%# No translation for this locale: let the user link an existing page. %>
25
+ <% candidates = page.translation_candidates(locale) %>
26
+ <% if candidates.any? %>
27
+ <%= form_with url: lato_cms.pages_link_translation_action_path(page), method: :post,
28
+ class: 'd-flex align-items-center gap-2 flex-grow-1 justify-content-end' do %>
29
+ <%= select_tag :translation_page_id,
30
+ options_from_collection_for_select(candidates, :id, :title),
31
+ class: 'form-select form-select-sm', style: 'max-width: 260px;' %>
32
+ <%= submit_tag t('lato_cms.translation_link_cta'), class: 'btn btn-sm btn-outline-primary' %>
33
+ <% end %>
34
+ <% else %>
35
+ <span class="text-muted small"><%= t('lato_cms.translation_no_candidates') %></span>
36
+ <% end %>
37
+ <% end %>
38
+ </div>
39
+ <% end %>
40
+ <% end %>
41
+ <% end %>
@@ -13,7 +13,7 @@
13
13
  </div>
14
14
  <div class="card-body">
15
15
  <%= lato_index @pages,
16
- custom_actions: {
16
+ custom_actions: lato_cms_admin? ? {
17
17
  create: {
18
18
  path: lato_cms.pages_create_path,
19
19
  icon: 'bi bi-plus',
@@ -25,7 +25,7 @@
25
25
  action_title: t('lato_cms.page_create_title')
26
26
  }
27
27
  }
28
- },
28
+ } : {},
29
29
  pagination_options: [10, 20, 50, 100]
30
30
  %>
31
31
  </div>
@@ -20,8 +20,10 @@
20
20
  <i class="bi bi-globe fs-1 mb-3"></i>
21
21
  <p class="mb-1"><%= t('lato_cms.show_no_preview_title') %></p>
22
22
  <p class="small"><%= t('lato_cms.show_no_preview_description').html_safe %></p>
23
- <%= link_to t('lato_cms.show_edit_settings_cta'), lato_cms.pages_update_path(@page), class: 'btn btn-sm btn-outline-primary mt-2',
24
- data: { lato_action_target: 'trigger', turbo_frame: dom_id(@page, 'form'), action_title: t('lato_cms.page_update_title') } %>
23
+ <% if lato_cms_admin? %>
24
+ <%= link_to t('lato_cms.show_edit_settings_cta'), lato_cms.pages_update_path(@page), class: 'btn btn-sm btn-outline-primary mt-2',
25
+ data: { lato_action_target: 'trigger', turbo_frame: dom_id(@page, 'form'), action_title: t('lato_cms.page_update_title') } %>
26
+ <% end %>
25
27
  </div>
26
28
  </div>
27
29
  <% end %>
@@ -33,24 +35,24 @@
33
35
  <div class="card-header d-flex justify-content-between align-items-center">
34
36
  <h2 class="fs-5 mb-0"><%= t('lato_cms.show_editor_title') %></h2>
35
37
  <div class="d-flex align-items-center gap-2">
36
- <%= lato_cms_page_locale(@page) %>
38
+ <%= lato_cms_page_locale_dropdown(@page) %>
37
39
  <span class="vr"></span>
38
- <%= lato_cms_page_actions(@page, show_edit: true, show_delete: true, hide_show: true) %>
40
+ <%= lato_cms_page_actions_dropdown(@page, show_edit: lato_cms_admin?, show_translations: lato_cms_admin?, show_delete: lato_cms_admin?, hide_show: true) %>
39
41
  <span class="vr"></span>
40
- <button class="btn btn-sm btn-outline-secondary"
41
- title="<%= t('lato_cms.show_advanced_editor_tooltip') %>"
42
- data-controller="lato-tooltip"
43
- data-action="click->lato-cms-advanced-editor#toggle"
44
- data-lato-cms-advanced-editor-target="toggleBtn">
45
- <i class="bi bi-fullscreen"></i>
46
- </button>
47
- <button class="btn btn-sm btn-outline-secondary d-none"
42
+ <button class="btn btn-sm btn-outline-primary d-none"
48
43
  title="<%= t('lato_cms.show_hide_panel_tooltip') %>"
49
44
  data-controller="lato-tooltip"
50
45
  data-action="click->lato-cms-advanced-editor#toggleSidebar"
51
46
  data-lato-cms-advanced-editor-target="collapseBtn">
52
47
  <i class="bi bi-layout-sidebar-reverse"></i>
53
48
  </button>
49
+ <button class="btn btn-sm btn-outline-primary"
50
+ title="<%= t('lato_cms.show_advanced_editor_tooltip') %>"
51
+ data-controller="lato-tooltip"
52
+ data-action="click->lato-cms-advanced-editor#toggle"
53
+ data-lato-cms-advanced-editor-target="toggleBtn">
54
+ <i class="bi bi-fullscreen"></i>
55
+ </button>
54
56
  </div>
55
57
  </div>
56
58
  <div class="card-body" style="overflow-y: auto; max-height: calc(100dvh - 230px);">
@@ -59,15 +61,19 @@
59
61
  <i class="bi bi-file-earmark-code fs-1 mb-3 d-block"></i>
60
62
  <p class="mb-1"><%= t('lato_cms.show_no_template_title') %></p>
61
63
  <p class="small"><%= t('lato_cms.show_no_template_description') %></p>
62
- <%= link_to t('lato_cms.show_edit_settings_cta'), lato_cms.pages_update_path(@page), class: 'btn btn-sm btn-outline-primary mt-2',
63
- data: { lato_action_target: 'trigger', turbo_frame: dom_id(@page, 'form'), action_title: t('lato_cms.page_update_title') } %>
64
+ <% if lato_cms_admin? %>
65
+ <%= link_to t('lato_cms.show_edit_settings_cta'), lato_cms.pages_update_path(@page), class: 'btn btn-sm btn-outline-primary mt-2',
66
+ data: { lato_action_target: 'trigger', turbo_frame: dom_id(@page, 'form'), action_title: t('lato_cms.page_update_title') } %>
67
+ <% end %>
64
68
  </div>
65
69
  <% elsif !@page.template_available? %>
66
70
  <div class="alert alert-warning">
67
71
  <i class="bi bi-exclamation-triangle me-1"></i>
68
72
  <%= t('lato_cms.show_template_unavailable', template_id: @page.template_id) %>
69
- <%= link_to t('lato_cms.show_edit_settings_cta'), lato_cms.pages_update_path(@page), class: 'btn btn-sm btn-outline-warning ms-2',
70
- data: { lato_action_target: 'trigger', turbo_frame: dom_id(@page, 'form'), action_title: t('lato_cms.page_update_title') } %>
73
+ <% if lato_cms_admin? %>
74
+ <%= link_to t('lato_cms.show_edit_settings_cta'), lato_cms.pages_update_path(@page), class: 'btn btn-sm btn-outline-warning ms-2',
75
+ data: { lato_action_target: 'trigger', turbo_frame: dom_id(@page, 'form'), action_title: t('lato_cms.page_update_title') } %>
76
+ <% end %>
71
77
  </div>
72
78
  <% existing_fields = @page.fields.group_by(&:template_component_id) %>
73
79
  <% if existing_fields.any? %>
@@ -0,0 +1,14 @@
1
+ <%= lato_page_head t('lato_cms.translations_title'), [
2
+ { label: t('lato_cms.pages_index_title'), path: lato_cms.pages_path },
3
+ { label: @page.title, path: lato_cms.pages_show_path(@page) },
4
+ { label: t('lato_cms.translations_title') }
5
+ ] %>
6
+
7
+ <div class="card mb-4">
8
+ <div class="card-header">
9
+ <h2 class="fs-4 mb-0"><%= t('lato_cms.translations_title') %></h2>
10
+ </div>
11
+ <div class="card-body">
12
+ <%= render 'lato_cms/pages/translations', page: @page %>
13
+ </div>
14
+ </div>
@@ -1,5 +1,13 @@
1
1
  en:
2
+ activerecord:
3
+ attributes:
4
+ lato/user:
5
+ lato_cms_admin_role: Lato CMS role
2
6
  lato_cms:
7
+ admin_roles:
8
+ none: No access
9
+ operator: Operator
10
+ admin: Administrator
3
11
  cta_show: Show
4
12
  cta_edit: Edit
5
13
  cta_update: Update
@@ -11,6 +19,7 @@ en:
11
19
  fields_saved: Fields saved successfully
12
20
  page_deleted: Page deleted successfully
13
21
  unauthorized_section: You do not have access to this section.
22
+ unauthorized_action: You do not have permission to perform this action.
14
23
  page_delete_failed: Failed to delete page
15
24
  api_page_not_found: Page not found
16
25
  pages_index_title: Pages
@@ -23,6 +32,17 @@ en:
23
32
  form_no_template_option: "-- No template --"
24
33
  form_template_unavailable: "The template \"%{template_id}\" is no longer available. Please select a different template."
25
34
  action_view_frontend: View
35
+ cta_actions: Actions
36
+ translations_title: Translations
37
+ translation_no_other_locales: No other locales are configured.
38
+ translation_link_cta: Link
39
+ translation_unlink_cta: Unlink
40
+ translation_unlink_confirm: Are you sure you want to unlink this translation?
41
+ translation_no_candidates: No pages available for this language
42
+ translation_linked: Translation linked successfully
43
+ translation_unlinked: Translation unlinked successfully
44
+ translation_link_failed: Failed to link translation
45
+ translation_unlink_failed: Failed to unlink translation
26
46
  show_no_preview_title: No preview available
27
47
  show_no_preview_description: Set the <strong>Frontend URL</strong> to see the page preview.
28
48
  show_edit_settings_cta: Edit page settings
@@ -42,6 +62,11 @@ en:
42
62
  component_inactive_label: This component is not active
43
63
  component_disabled_alert: This component is disabled and will not be exposed by API.
44
64
  component_state_updated: Component state updated
65
+ component_clone_cta: Clone
66
+ component_clone_from: "Clone from %{lang}"
67
+ component_clone_confirm: "Replace this component's fields with the ones from the %{lang} page?"
68
+ component_cloned: Component cloned successfully
69
+ component_clone_failed: Failed to clone component
45
70
  component_state_saving: Updating...
46
71
  component_state_saved: State updated
47
72
  component_state_save_error: Failed to update state
@@ -1,5 +1,13 @@
1
1
  it:
2
+ activerecord:
3
+ attributes:
4
+ lato/user:
5
+ lato_cms_admin_role: Ruolo Lato CMS
2
6
  lato_cms:
7
+ admin_roles:
8
+ none: Nessun accesso
9
+ operator: Operatore
10
+ admin: Amministratore
3
11
  cta_show: Visualizza
4
12
  cta_edit: Modifica
5
13
  cta_update: Aggiorna
@@ -11,6 +19,7 @@ it:
11
19
  fields_saved: Campi salvati con successo
12
20
  page_deleted: Pagina eliminata con successo
13
21
  unauthorized_section: Non hai accesso a questa sezione.
22
+ unauthorized_action: Non hai i permessi per eseguire questa azione.
14
23
  page_delete_failed: Impossibile eliminare la pagina
15
24
  api_page_not_found: Pagina non trovata
16
25
  pages_index_title: Pagine
@@ -23,6 +32,17 @@ it:
23
32
  form_no_template_option: "-- Nessun template --"
24
33
  form_template_unavailable: "Il template \"%{template_id}\" non e piu disponibile. Seleziona un template diverso."
25
34
  action_view_frontend: Anteprima
35
+ cta_actions: Azioni
36
+ translations_title: Traduzioni
37
+ translation_no_other_locales: Nessuna altra lingua configurata.
38
+ translation_link_cta: Collega
39
+ translation_unlink_cta: Scollega
40
+ translation_unlink_confirm: Sei sicuro di voler scollegare questa traduzione?
41
+ translation_no_candidates: Nessuna pagina disponibile per questa lingua
42
+ translation_linked: Traduzione collegata con successo
43
+ translation_unlinked: Traduzione scollegata con successo
44
+ translation_link_failed: Impossibile collegare la traduzione
45
+ translation_unlink_failed: Impossibile scollegare la traduzione
26
46
  show_no_preview_title: Anteprima non disponibile
27
47
  show_no_preview_description: Imposta il <strong>URL Frontend</strong> per visualizzare l'anteprima della pagina.
28
48
  show_edit_settings_cta: Modifica impostazioni pagina
@@ -42,6 +62,11 @@ it:
42
62
  component_inactive_label: Questa componente non e attiva
43
63
  component_disabled_alert: Questa componente e disattivata e non verra esposta dalle API.
44
64
  component_state_updated: Stato componente aggiornato
65
+ component_clone_cta: Clona
66
+ component_clone_from: "Clona da %{lang}"
67
+ component_clone_confirm: "Sostituire i campi di questa componente con quelli della pagina in %{lang}?"
68
+ component_cloned: Componente clonata con successo
69
+ component_clone_failed: Impossibile clonare la componente
45
70
  component_state_saving: Aggiornamento in corso...
46
71
  component_state_saved: Stato aggiornato
47
72
  component_state_save_error: Errore durante aggiornamento stato
data/config/routes.rb CHANGED
@@ -17,6 +17,10 @@ LatoCms::Engine.routes.draw do
17
17
  patch ':id/update', to: 'pages#update_action', as: :pages_update_action
18
18
  post ':id/fields', to: 'pages#save_fields_action', as: :pages_save_fields_action
19
19
  patch ':id/components/:template_component_id/toggle', to: 'pages#toggle_component_action', as: :pages_toggle_component_action
20
+ post ':id/components/:template_component_id/clone', to: 'pages#clone_component_action', as: :pages_clone_component_action
21
+ get ':id/translations', to: 'pages#translations', as: :pages_translations
22
+ post ':id/translations', to: 'pages#link_translation_action', as: :pages_link_translation_action
23
+ delete ':id/translations', to: 'pages#unlink_translation_action', as: :pages_unlink_translation_action
20
24
  delete ':id', to: 'pages#destroy_action', as: :pages_destroy_action
21
25
  end
22
26
  end
@@ -0,0 +1,8 @@
1
+ class AddTranslationGroupIdToLatoCmsPages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Pages sharing the same translation_group_id are translations of each other
4
+ # (one page per locale within a group). Nil means the page is not linked.
5
+ add_column :lato_cms_pages, :translation_group_id, :string
6
+ add_index :lato_cms_pages, :translation_group_id
7
+ end
8
+ end
@@ -0,0 +1,18 @@
1
+ class ChangeLatoCmsAdminToLatoCmsAdminRoleOnLatoUser < ActiveRecord::Migration[8.1]
2
+ def up
3
+ add_column :lato_users, :lato_cms_admin_role, :integer, default: 0, null: false
4
+
5
+ # Backfill: true -> 1, false/nil -> 0 (already the column default)
6
+ execute 'UPDATE lato_users SET lato_cms_admin_role = 1 WHERE lato_cms_admin = 1'
7
+
8
+ remove_column :lato_users, :lato_cms_admin
9
+ end
10
+
11
+ def down
12
+ add_column :lato_users, :lato_cms_admin, :boolean, default: false
13
+
14
+ execute 'UPDATE lato_users SET lato_cms_admin = 1 WHERE lato_cms_admin_role = 1'
15
+
16
+ remove_column :lato_users, :lato_cms_admin_role
17
+ end
18
+ end
@@ -3,11 +3,18 @@ module LatoCms
3
3
  # This class contains the default configuration of the engine.
4
4
  ##
5
5
  class Config
6
- attr_accessor :locales, :templates_path
6
+ attr_accessor :locales, :templates_path, :admin_roles
7
7
 
8
8
  def initialize
9
9
  @locales = [:en]
10
10
  @templates_path = 'config/lato_cms'
11
+
12
+ # Admin roles exposed on Lato::User#lato_cms_admin_role and rendered
13
+ # as a select by lato_users. Ordered map of role key => integer value;
14
+ # labels are resolved via i18n (lato_cms.admin_roles.<key>).
15
+ # `operator` has read/edit access; `admin` also manages pages
16
+ # (create, update, delete) and translation links.
17
+ @admin_roles = { none: 0, operator: 1, admin: 2 }
11
18
  end
12
19
  end
13
20
  end
@@ -9,5 +9,32 @@ module LatoCms
9
9
  initializer "lato_cms.precompile" do |app|
10
10
  app.config.assets.precompile << "lato_cms_manifest.js"
11
11
  end
12
+
13
+ # Expose the CMS admin role options on the shared Lato::User model.
14
+ # lato_users (when mounted) discovers the `lato_cms_admin_role` column by
15
+ # convention and calls this method to build the role selector. Defined
16
+ # here so it works regardless of whether lato_users is installed.
17
+ config.to_prepare do
18
+ Lato::User.class_eval do
19
+ # Role options for the lato_cms_admin_role permission.
20
+ # Returns [[label, value], ...] with labels resolved at call time.
21
+ def self.lato_cms_admin_role_options
22
+ LatoCms.config.admin_roles.map do |key, value|
23
+ [I18n.t("lato_cms.admin_roles.#{key}"), value]
24
+ end
25
+ end
26
+
27
+ # True when the user can access the CMS section (operator or admin).
28
+ def lato_cms_access?
29
+ lato_cms_admin_role.to_i.positive?
30
+ end
31
+
32
+ # True when the user has the admin role: full page management
33
+ # (create, update, delete) and translation links.
34
+ def lato_cms_admin?
35
+ lato_cms_admin_role.to_i >= LatoCms.config.admin_roles.fetch(:admin, 0)
36
+ end
37
+ end
38
+ end
12
39
  end
13
40
  end
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.0.5"
2
+ VERSION = "3.0.7"
3
3
  end
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.5
4
+ version: 3.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante
@@ -120,6 +120,7 @@ files:
120
120
  - app/views/lato_cms/pages/_form_update.html.erb
121
121
  - app/views/lato_cms/pages/_repeater.html.erb
122
122
  - app/views/lato_cms/pages/_repeater_item.html.erb
123
+ - app/views/lato_cms/pages/_translations.html.erb
123
124
  - app/views/lato_cms/pages/create.html.erb
124
125
  - app/views/lato_cms/pages/fields/_boolean.html.erb
125
126
  - app/views/lato_cms/pages/fields/_color.html.erb
@@ -137,6 +138,7 @@ files:
137
138
  - app/views/lato_cms/pages/fields/_textarea.html.erb
138
139
  - app/views/lato_cms/pages/index.html.erb
139
140
  - app/views/lato_cms/pages/show.html.erb
141
+ - app/views/lato_cms/pages/translations.html.erb
140
142
  - app/views/lato_cms/pages/update.html.erb
141
143
  - config/importmap.rb
142
144
  - config/locales/en.yml
@@ -145,6 +147,8 @@ files:
145
147
  - db/migrate/20250328072343_add_lato_cms_admin_to_lato_user.rb
146
148
  - db/migrate/20260320070819_create_lato_cms_pages.rb
147
149
  - db/migrate/20260323171241_create_lato_cms_page_fields.rb
150
+ - db/migrate/20260705000000_add_translation_group_id_to_lato_cms_pages.rb
151
+ - db/migrate/20260712092241_change_lato_cms_admin_to_lato_cms_admin_role_on_lato_user.rb
148
152
  - lib/lato_cms.rb
149
153
  - lib/lato_cms/config.rb
150
154
  - lib/lato_cms/engine.rb