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
@@ -77,6 +77,46 @@ module LatoCms
77
77
  true
78
78
  end
79
79
 
80
+ # Translates a component's free-text fields (see
81
+ # LatoCms::PageField::TRANSLATABLE_FIELD_TYPES) into this page's own
82
+ # locale via the configured LLM, in one batched call, overwriting their
83
+ # values in place. Used by PagesController#clone_component_action's
84
+ # "Clone & translate" option, as the async half run via
85
+ # LatoCms::TranslateComponentFieldsJob (a Lato::Operation): the fields
86
+ # themselves are already cloned by the time this runs, synchronously,
87
+ # so a failure here never loses the clone, only the translation. Raises
88
+ # on failure (LLM unreachable, malformed/short response) rather than
89
+ # swallowing it, since the admin is watching the operation's progress
90
+ # and a silent no-op would misreport success.
91
+ def translate_component_fields!(template_component_id)
92
+ targets = fields.where(template_component_id: template_component_id).reject(&:repeater_order?)
93
+ .select { |f| LatoCms::PageField::TRANSLATABLE_FIELD_TYPES.include?(f.field_type) && f.value.present? }
94
+ return if targets.empty?
95
+
96
+ content = LatoCms::LlmClient.chat(messages: [{ role: 'user', content: self.class.translate_fields_prompt(targets, locale) }])
97
+ translated = self.class.parse_translated_field_values(content, targets.size)
98
+ raise "The LLM response couldn't be parsed into #{targets.size} translated value(s)" if translated.blank?
99
+
100
+ targets.each_with_index { |field, index| field.update_column(:value, translated[index]) if translated[index].present? }
101
+ end
102
+
103
+ def self.translate_fields_prompt(fields, target_locale)
104
+ "Translate each string in this JSON array into #{target_locale} (language code). Preserve any HTML " \
105
+ "tags exactly as-is, translate only the visible text. Respond with a JSON array of the same length " \
106
+ "and in the same order, translated strings only, no markdown, no explanation.\n\n#{fields.map(&:value).to_json}"
107
+ end
108
+
109
+ def self.parse_translated_field_values(content, expected_count)
110
+ return [] if content.blank?
111
+
112
+ parsed = JSON.parse(content[/\[.*\]/m] || content)
113
+ return [] unless parsed.is_a?(Array) && parsed.size == expected_count
114
+
115
+ parsed.map(&:to_s)
116
+ rescue JSON::ParserError
117
+ []
118
+ end
119
+
80
120
  def template
81
121
  LatoCms::TemplateManager.find_template(template_id)
82
122
  end
@@ -2,13 +2,24 @@ module LatoCms
2
2
  class PageField < ApplicationRecord
3
3
  belongs_to :page, class_name: 'LatoCms::Page'
4
4
 
5
- has_many_attached :files
5
+ has_many :page_field_media, -> { order(:position) }, class_name: 'LatoCms::PageFieldMedia', dependent: :destroy
6
+ has_many :media, through: :page_field_media, class_name: 'LatoCms::Media'
7
+
8
+ ATTACHMENT_FIELD_TYPES = %w[file image video gallery].freeze
9
+
10
+ # Field types that hold free-form text, used to decide what a
11
+ # "clone & translate" (see PagesController#clone_component_fields) sends
12
+ # to the LLM. Everything else (select/multiselect options, booleans,
13
+ # numbers, dates, colors, json, custom) is either a fixed choice or
14
+ # structured data that translation would corrupt.
15
+ TRANSLATABLE_FIELD_TYPES = %w[string textarea text].freeze
6
16
 
7
17
  validates :template_id, presence: true
8
18
  validates :template_component_id, presence: true
9
19
  validates :component_id, presence: true
10
20
  validates :field_id, presence: true
11
21
  validates :field_id, uniqueness: { scope: [:page_id, :template_component_id] }
22
+ validate :media_present_if_required
12
23
 
13
24
  REPEATER_ORDER_FIELD_ID = "__repeater_order".freeze
14
25
 
@@ -75,36 +86,22 @@ module LatoCms
75
86
  field_config&.dig('settings') || {}
76
87
  end
77
88
 
78
- # A video field stores two attachments in `files`: the video itself and an
79
- # optional auto-generated poster image. Content type is the discriminator,
80
- # so no extra column or naming convention is needed.
81
- def video_attachment
82
- files.find { |file| file.content_type.to_s.start_with?("video/") }
83
- end
84
-
85
- def poster_attachment
86
- files.find { |file| file.content_type.to_s.start_with?("image/") }
87
- end
89
+ # Reconciles this field's media references to match the given ordered list
90
+ # of media ids: creates missing join rows, destroys removed ones, and
91
+ # updates `position` to match the given order. Callers must tenant-scope
92
+ # ids before calling this (see PagesController#assign_media_field).
93
+ def replace_media!(media_ids)
94
+ ids = Array(media_ids).reject(&:blank?).map(&:to_s).uniq
88
95
 
89
- # Generates a poster image from the video via Active Storage previews
90
- # (ffmpeg) and attaches it alongside the video. Best effort: any failure is
91
- # logged and the video keeps working without a poster.
92
- def generate_video_poster!
93
- video = video_attachment
94
- return unless video
95
- return if poster_attachment
96
-
97
- unless video.previewable?
98
- Rails.logger.warn("LatoCms: video preview unavailable (ffmpeg missing?) for attachment #{video.id}, skipping poster generation")
99
- return
96
+ transaction do
97
+ existing = page_field_media.index_by { |pfm| pfm.media_id.to_s }
98
+ (existing.keys - ids).each { |stale_id| existing[stale_id].destroy }
99
+ ids.each_with_index do |media_id, index|
100
+ (existing[media_id] || page_field_media.build(media_id: media_id)).update!(position: index)
101
+ end
100
102
  end
101
103
 
102
- preview = video.preview(resize_to_limit: [1280, 720]).processed
103
- preview.image.blob.open do |file|
104
- files.attach(io: file, filename: "#{video.filename.base}_poster.jpg", content_type: preview.image.blob.content_type)
105
- end
106
- rescue StandardError => e
107
- Rails.logger.warn("LatoCms: failed to generate video poster for field #{id}: #{e.message}")
104
+ page_field_media.reset
108
105
  end
109
106
 
110
107
  def as_json(_options = {})
@@ -121,18 +118,15 @@ module LatoCms
121
118
 
122
119
  case field_type
123
120
  when 'file'
124
- result[:attachments] = files.map { |f| attachment_as_json(f) }
121
+ result[:attachments] = media.map { |m| media_as_json(m) }
125
122
  when 'image'
126
- attached = files.first
127
- result[:attachments] = attached ? [attachment_as_json(attached, with_variants: true)] : []
123
+ attached = media.first
124
+ result[:attachments] = attached ? [media_as_json(attached, with_variants: true)] : []
128
125
  when 'video'
129
- attached = video_attachment
130
- result[:attachments] = attached ? [video_attachment_as_json(attached)] : []
126
+ attached = media.first
127
+ result[:attachments] = attached ? [video_media_as_json(attached)] : []
131
128
  when 'gallery'
132
- order = value ? (JSON.parse(value) rescue []) : []
133
- all_files = files.to_a
134
- ordered = order.any? ? all_files.sort_by { |f| order.index(f.id.to_s) || Float::INFINITY } : all_files
135
- result[:attachments] = ordered.map { |f| attachment_as_json(f, with_variants: true) }
129
+ result[:attachments] = media.map { |m| media_as_json(m, with_variants: true) }
136
130
  else
137
131
  result[:value] = parsed_value
138
132
  end
@@ -142,58 +136,36 @@ module LatoCms
142
136
 
143
137
  private
144
138
 
145
- def attachment_as_json(attachment, with_variants: false)
139
+ def media_as_json(m, with_variants: false)
146
140
  json = {
147
- id: attachment.id,
148
- filename: attachment.filename.to_s,
149
- content_type: attachment.content_type,
150
- byte_size: attachment.byte_size,
151
- url: Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
141
+ media_id: m.id,
142
+ name: m.name,
143
+ alt_text: m.alt_text(page.locale),
144
+ filename: m.filename,
145
+ content_type: m.file.attached? ? m.file.content_type : nil,
146
+ byte_size: m.file.attached? ? m.file.byte_size : nil,
147
+ url: m.url
152
148
  }
153
- json[:sizes] = attachment_variant_urls(attachment) if with_variants
149
+ json[:sizes] = m.variant_urls(field_settings['sizes']) if with_variants
154
150
  json
155
151
  end
156
152
 
157
- # Video attachment json gains a `poster_url` key (nil until the poster job
153
+ # Video media json gains a `poster_url` key (nil until the poster job
158
154
  # runs) so API consumers get video and poster URLs in a single object.
159
- def video_attachment_as_json(attachment)
160
- poster = poster_attachment
161
- attachment_as_json(attachment).merge(
162
- poster_url: poster && Rails.application.routes.url_helpers.rails_blob_path(poster, only_path: true)
163
- )
155
+ def video_media_as_json(m)
156
+ media_as_json(m).merge(poster_url: m.poster_url)
164
157
  end
165
158
 
166
- # Builds a map of { size_name => variant_url } from the field's `settings.sizes`.
167
- # Variants are processed lazily by Active Storage on first request to their URL.
168
- def attachment_variant_urls(attachment)
169
- sizes = field_settings['sizes']
170
- return {} if sizes.blank? || !sizes.respond_to?(:each_pair) || !attachment.variable?
171
-
172
- url_helpers = Rails.application.routes.url_helpers
173
- sizes.each_with_object({}) do |(name, opts), acc|
174
- transformation = variant_transformation(opts)
175
- next if transformation.blank?
176
-
177
- variant = attachment.variant(transformation)
178
- acc[name] = url_helpers.rails_representation_path(variant, only_path: true)
179
- end
180
- rescue StandardError => e
181
- Rails.logger.error("LatoCms: Failed to build image variants for attachment #{attachment.id}: #{e.message}")
182
- {}
183
- end
159
+ # A field can only see this validation once media presence is a real
160
+ # association (unlike direct attachments, which used to require a
161
+ # controller-level backstop after save). A brand new required field is
162
+ # saved once, unvalidated, to get an id before media can be attached, then
163
+ # saved again (validated) once media is in place — see
164
+ # PagesController#save_field.
165
+ def media_present_if_required
166
+ return unless ATTACHMENT_FIELD_TYPES.include?(field_type) && field_required?
184
167
 
185
- # Maps a size config (width/height/resize) to an Active Storage variant transformation.
186
- # resize modes: "limit" (default, scale down only), "fit" (scale to fit), "fill" (crop to exact size).
187
- def variant_transformation(opts)
188
- opts = {} unless opts.respond_to?(:[])
189
- dimensions = [opts['width'] || opts[:width], opts['height'] || opts[:height]]
190
- return nil if dimensions.compact.empty?
191
-
192
- case (opts['resize'] || opts[:resize] || 'limit').to_s
193
- when 'fill' then { resize_to_fill: dimensions }
194
- when 'fit' then { resize_to_fit: dimensions }
195
- else { resize_to_limit: dimensions }
196
- end
168
+ errors.add(:base, I18n.t('lato_cms.field_required_attachment_error')) if page_field_media.reload.empty?
197
169
  end
198
170
 
199
171
  def parse_value
@@ -0,0 +1,8 @@
1
+ module LatoCms
2
+ class PageFieldMedia < ApplicationRecord
3
+ belongs_to :page_field, class_name: 'LatoCms::PageField'
4
+ belongs_to :media, class_name: 'LatoCms::Media'
5
+
6
+ validates :media_id, uniqueness: { scope: :page_field_id }
7
+ end
8
+ end
@@ -0,0 +1,20 @@
1
+ <% media ||= LatoCms::Media.new %>
2
+
3
+ <%= turbo_frame_tag dom_id(media, 'form') do %>
4
+ <%= form_with model: media, url: lato_cms.media_create_action_path, data: { turbo_frame: '_self', controller: 'lato-form' } do |form| %>
5
+ <%= lato_form_notices class: %w[mb-3] %>
6
+ <%= lato_form_errors media, class: %w[mb-3] %>
7
+
8
+ <%# Name/alt text are edited afterwards from this same list's "edit"
9
+ action — they default to the filename on upload, so there's nothing
10
+ to fill in here beyond the file itself. %>
11
+ <div class="mb-3">
12
+ <%= lato_form_item_label form, :file %>
13
+ <%= lato_form_item_input_file_dropzone form, :file %>
14
+ </div>
15
+
16
+ <div class="d-flex justify-content-end">
17
+ <%= lato_form_submit form, t('lato_cms.media_create_cta'), class: %w[btn-success] %>
18
+ </div>
19
+ <% end %>
20
+ <% end %>
@@ -0,0 +1,60 @@
1
+ <% media ||= LatoCms::Media.new %>
2
+
3
+ <%= turbo_frame_tag dom_id(media, 'form') do %>
4
+ <%= form_with model: media, url: lato_cms.media_update_action_path(media), method: :patch, data: { turbo_frame: '_self', controller: 'lato-form lato-cms-reload-on-modal-close' } do |form| %>
5
+ <%= lato_form_notices class: %w[mb-3] %>
6
+ <%= lato_form_errors media, class: %w[mb-3] %>
7
+
8
+ <div class="mb-3 d-flex align-items-center gap-2">
9
+ <%= lato_cms_media_thumb(media, size: 64) %>
10
+ <span class="text-muted small"><%= media.filename %></span>
11
+ </div>
12
+
13
+ <div class="mb-3">
14
+ <%= lato_form_item_label form, :name %>
15
+ <%= lato_form_item_input_text form, :name, required: true %>
16
+ </div>
17
+
18
+ <% if media.image? %>
19
+ <div class="mb-3">
20
+ <div class="d-flex align-items-center justify-content-between">
21
+ <label class="form-label mb-0"><%= t('lato_cms.media_alt_text') %></label>
22
+ <% if LatoCms.config.llm_configured? %>
23
+ <%= link_to t('lato_cms.media_alt_text_regenerate'), lato_cms.media_regenerate_alt_text_action_path(media),
24
+ class: 'btn btn-sm btn-outline-secondary',
25
+ data: {
26
+ lato_action_target: 'trigger',
27
+ turbo_method: :post,
28
+ turbo_frame: 'lato_operation',
29
+ turbo_confirm: t('lato_cms.media_alt_text_regenerate_confirm'),
30
+ action_title: t('lato_cms.media_alt_text_regenerate')
31
+ } %>
32
+ <% end %>
33
+ </div>
34
+
35
+ <ul class="nav nav-tabs">
36
+ <% LatoCms.config.locales.each_with_index do |locale, index| %>
37
+ <li class="nav-item">
38
+ <button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
39
+ data-bs-target="#<%= dom_id(media, "alt_text_#{locale}_pane") %>" type="button">
40
+ <%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
41
+ </button>
42
+ </li>
43
+ <% end %>
44
+ </ul>
45
+
46
+ <div class="tab-content border border-top-0 rounded-bottom p-3">
47
+ <% LatoCms.config.locales.each_with_index do |locale, index| %>
48
+ <div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "alt_text_#{locale}_pane") %>">
49
+ <%= lato_form_item_input_text form, :"alt_text_#{locale}" %>
50
+ </div>
51
+ <% end %>
52
+ </div>
53
+ </div>
54
+ <% end %>
55
+
56
+ <div class="d-flex justify-content-end">
57
+ <%= lato_form_submit form, t('lato_cms.cta_update'), class: %w[btn-success] %>
58
+ </div>
59
+ <% end %>
60
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= lato_page_head t('lato_cms.media_create_title'), [
2
+ { label: t('lato_cms.media_index_title'), path: lato_cms.media_path },
3
+ { label: t('lato_cms.media_create_title') }
4
+ ] %>
5
+
6
+ <div class="card mb-4">
7
+ <div class="card-header">
8
+ <h2 class="fs-4 mb-0"><%= t('lato_cms.media_create_title') %></h2>
9
+ </div>
10
+ <div class="card-body">
11
+ <%= render 'lato_cms/media/form_create', media: @media %>
12
+ </div>
13
+ </div>
@@ -0,0 +1,22 @@
1
+ <%= lato_page_head t('lato_cms.media_index_title'), [{ label: t('lato_cms.media_index_title') }] %>
2
+
3
+ <div class="card">
4
+ <div class="card-body">
5
+ <%= lato_index @media,
6
+ custom_actions: {
7
+ create: {
8
+ path: lato_cms.media_create_path,
9
+ icon: 'bi bi-plus',
10
+ title: t('lato_cms.media_create_cta'),
11
+ data: {
12
+ controller: 'lato-tooltip',
13
+ lato_action_target: 'trigger',
14
+ turbo_frame: dom_id(LatoCms::Media.new, 'form'),
15
+ action_title: t('lato_cms.media_create_title')
16
+ }
17
+ }
18
+ },
19
+ pagination_options: [12, 24, 48, 96]
20
+ %>
21
+ </div>
22
+ </div>
@@ -0,0 +1,98 @@
1
+ <% multiple = ActiveModel::Type::Boolean.new.cast(params[:multiple]) %>
2
+
3
+ <%= turbo_frame_tag params[:frame_id] do %>
4
+ <div data-controller="lato-cms-media-picker"
5
+ data-lato-cms-media-picker-multiple-value="<%= multiple %>"
6
+ data-lato-cms-media-picker-selected-label-value="<%= t('lato_cms.media_picker_selected_label') %>">
7
+
8
+ <ul class="nav nav-tabs mb-3">
9
+ <li class="nav-item">
10
+ <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-browse" type="button">
11
+ <%= t('lato_cms.media_picker_tab_browse') %>
12
+ </button>
13
+ </li>
14
+ <li class="nav-item">
15
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-upload" type="button">
16
+ <%= t('lato_cms.media_picker_tab_upload') %>
17
+ </button>
18
+ </li>
19
+ </ul>
20
+
21
+ <div class="tab-content">
22
+ <div class="tab-pane fade show active" id="lato-cms-media-picker-browse">
23
+ <%= form_with url: lato_cms.media_picker_action_path, method: :get, data: { turbo_frame: params[:frame_id] } do %>
24
+ <%= hidden_field_tag :frame_id, params[:frame_id] %>
25
+ <%= hidden_field_tag :multiple, multiple %>
26
+ <div class="d-flex gap-2 mb-3">
27
+ <div class="btn-group">
28
+ <%= link_to t('lato_cms.media_type_all'), lato_cms.media_picker_action_path(frame_id: params[:frame_id], multiple: multiple, q: params[:q]),
29
+ class: "btn btn-sm #{params[:type].blank? ? 'btn-primary' : 'btn-outline-secondary'}", data: { turbo_frame: params[:frame_id] } %>
30
+ <% %w[image video file].each do |type| %>
31
+ <%= link_to t("lato_cms.media_type_#{type}"), lato_cms.media_picker_action_path(frame_id: params[:frame_id], multiple: multiple, type: type, q: params[:q]),
32
+ class: "btn btn-sm #{params[:type] == type ? 'btn-primary' : 'btn-outline-secondary'}", data: { turbo_frame: params[:frame_id] } %>
33
+ <% end %>
34
+ </div>
35
+ <%= text_field_tag :q, params[:q], class: 'form-control form-control-sm', placeholder: t('lato_cms.media_search_placeholder') %>
36
+ <button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-search"></i></button>
37
+ </div>
38
+ <% end %>
39
+
40
+ <% if @media.empty? %>
41
+ <div class="text-center text-muted py-5">
42
+ <i class="bi bi-images fs-1 d-block mb-2"></i>
43
+ <%= t('lato_cms.media_picker_empty') %>
44
+ </div>
45
+ <% else %>
46
+ <div class="lato-cms-media-picker__grid mb-3" data-lato-cms-media-picker-target="grid">
47
+ <% @media.each do |media| %>
48
+ <div class="lato-cms-media-picker__item"
49
+ data-media-id="<%= media.id %>"
50
+ data-media-name="<%= media.name %>"
51
+ data-media-thumb="<%= media.thumbnail_url %>"
52
+ data-media-url="<%= media.url %>"
53
+ data-media-poster-url="<%= media.poster_url %>"
54
+ data-media-type="<%= media.media_type %>"
55
+ data-action="click->lato-cms-media-picker#select"
56
+ data-lato-cms-media-picker-target="item">
57
+ <%= lato_cms_media_thumb(media, size: 100) %>
58
+ <span class="small text-truncate d-block"><%= media.name %></span>
59
+ </div>
60
+ <% end %>
61
+ </div>
62
+
63
+ <%= paginate @media, param_name: :page %>
64
+ <% end %>
65
+
66
+ <% if multiple %>
67
+ <div class="d-flex justify-content-between align-items-center mt-2">
68
+ <span data-lato-cms-media-picker-target="selectedCount">0 <%= t('lato_cms.media_picker_selected_label') %></span>
69
+ <button type="button" class="btn btn-primary" data-action="lato-cms-media-picker#confirm" data-lato-cms-media-picker-target="confirmButton" disabled>
70
+ <%= t('lato_cms.media_picker_confirm') %>
71
+ </button>
72
+ </div>
73
+ <% end %>
74
+ </div>
75
+
76
+ <div class="tab-pane fade" id="lato-cms-media-picker-upload">
77
+ <div data-lato-cms-media-picker-target="uploadNotice"></div>
78
+ <%# Name/alt text are intentionally not collected here: they default
79
+ to the filename on upload and are only ever edited afterwards
80
+ from the Media library's own edit form.
81
+ `model:` (not `scope: :media`) so the form binds to a real new
82
+ record — `scope:` alone falls back to the controller's `@media`
83
+ (the browse tab's paginated collection), and an
84
+ ActiveRecord::Relation delegates an unrecognized `#name` call to
85
+ its model class, silently pre-filling a `:name` field with the
86
+ literal string "LatoCms::Media" (bit us once already here). %>
87
+ <%= form_with model: LatoCms::Media.new, url: lato_cms.media_create_action_path, data: {
88
+ lato_cms_media_picker_target: 'uploadForm', action: 'submit->lato-cms-media-picker#upload'
89
+ } do |form| %>
90
+ <%= lato_form_item_input_file_dropzone form, :file, accept: { 'image' => 'image/*', 'video' => 'video/*' }[params[:type]] %>
91
+ <div class="d-flex justify-content-end">
92
+ <button type="submit" class="btn btn-success"><%= t('lato_cms.media_upload_cta') %></button>
93
+ </div>
94
+ <% end %>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= lato_page_head t('lato_cms.media_update_title'), [
2
+ { label: t('lato_cms.media_index_title'), path: lato_cms.media_path },
3
+ { label: t('lato_cms.media_update_title') }
4
+ ] %>
5
+
6
+ <div class="card mb-4">
7
+ <div class="card-header">
8
+ <h2 class="fs-4 mb-0"><%= t('lato_cms.media_update_title') %></h2>
9
+ </div>
10
+ <div class="card-body">
11
+ <%= render 'lato_cms/media/form_update', media: @media %>
12
+ </div>
13
+ </div>
@@ -64,6 +64,21 @@
64
64
  <%= locale_to_flag(sibling.locale) %> <%= t('lato_cms.component_clone_from', lang: sibling.locale.upcase) %>
65
65
  <% end %>
66
66
  </li>
67
+ <% if LatoCms.config.llm_configured? %>
68
+ <li>
69
+ <%= link_to lato_cms.pages_clone_component_action_path(page, tc[:template_component_id], source_page_id: sibling.id, translate: true),
70
+ class: 'dropdown-item',
71
+ data: {
72
+ lato_action_target: 'trigger',
73
+ turbo_method: :post,
74
+ turbo_frame: 'lato_operation',
75
+ turbo_confirm: t('lato_cms.component_clone_translate_confirm', lang: sibling.locale.upcase),
76
+ action_title: t('lato_cms.component_clone_translate_from', lang: sibling.locale.upcase)
77
+ } do %>
78
+ <i class="bi bi-translate me-1"></i><%= locale_to_flag(sibling.locale) %> <%= t('lato_cms.component_clone_translate_from', lang: sibling.locale.upcase) %>
79
+ <% end %>
80
+ </li>
81
+ <% end %>
67
82
  <% end %>
68
83
  </ul>
69
84
  </div>
@@ -1,56 +1,5 @@
1
- <% label = field_config['name'] || field_id.humanize %>
2
- <% required = field_config['required'] == true %>
3
1
  <% settings = field_config['settings'] || {} %>
4
- <% multiple = settings['multiple'] == true %>
5
- <% accept = settings['accept'] %>
6
- <% files_input_name = lato_cms_field_input_name(field_id, 'files', input_name_prefix: local_assigns[:input_name_prefix], multiple: true) %>
7
- <% remove_input_name = lato_cms_field_input_name(field_id, 'remove_file_ids', input_name_prefix: local_assigns[:input_name_prefix], multiple: true) %>
8
- <% input_id = lato_cms_field_dom_id(field_id, 'files', dom_id_prefix: local_assigns[:dom_id_prefix]) %>
9
-
10
- <label class="form-label">
11
- <%= label %><%= ' *' if required %>
12
- </label>
13
-
14
- <div data-controller="lato-cms-file-field"
15
- data-field-id="<%= field_id %>"
16
- data-field-required="<%= required %>"
17
- data-lato-cms-file-field-multiple-value="<%= multiple %>"
18
- data-new-badge-label="<%= t('lato_cms.field_file_new_badge') %>"
19
- data-remove-label="<%= t('lato_cms.field_file_remove') %>"
20
- data-remove-pending-label="<%= t('lato_cms.field_file_remove_pending') %>"
21
- data-undo-label="<%= t('lato_cms.field_file_remove_undo') %>">
22
- <div class="mb-2" data-lato-cms-file-field-target="list">
23
- <% if page_field&.files&.attached? %>
24
- <% page_field.files.each do |file| %>
25
- <div class="lato-cms-file-field__item" data-lato-cms-file-field-target="item" data-attachment-id="<%= file.id %>">
26
- <div class="lato-cms-file-field__info">
27
- <i class="bi bi-paperclip"></i>
28
- <span><%= file.blob.filename %></span>
29
- <span class="text-muted small">(<%= number_to_human_size(file.blob.byte_size) %>)</span>
30
- </div>
31
- <button type="button" class="lato-cms-attachment-field__remove" title="<%= t('lato_cms.field_file_remove') %>" aria-label="<%= t('lato_cms.field_file_remove') %>" data-action="lato-cms-file-field#remove">
32
- <i class="bi bi-trash"></i>
33
- </button>
34
- <div class="lato-cms-file-field__removed d-none" data-lato-cms-file-field-target="removedNotice">
35
- <span class="text-danger small">
36
- <i class="bi bi-trash me-1"></i><%= t('lato_cms.field_file_remove_pending') %>
37
- </span>
38
- <button type="button" class="btn btn-link btn-sm p-0" data-action="lato-cms-file-field#undo">
39
- <%= t('lato_cms.field_file_remove_undo') %>
40
- </button>
41
- </div>
42
- </div>
43
- <% end %>
44
- <% end %>
45
- </div>
46
-
47
- <input type="file"
48
- class="form-control"
49
- name="<%= files_input_name %>"
50
- id="<%= input_id %>"
51
- data-lato-cms-file-field-target="input"
52
- data-action="change->lato-cms-file-field#addFiles"
53
- <%= 'required' if required && !page_field&.files&.attached? %>
54
- <%= 'multiple' if multiple %>
55
- <% if accept %>accept="<%= accept %>"<% end %>>
56
- </div>
2
+ <%= render partial: 'lato_cms/pages/fields/media_field', locals: {
3
+ field_id: field_id, field_config: field_config, page_field: page_field, kind: 'file', multiple: settings['multiple'] == true,
4
+ input_name_prefix: local_assigns[:input_name_prefix], dom_id_prefix: local_assigns[:dom_id_prefix]
5
+ } %>
@@ -1,60 +1,4 @@
1
- <% label = field_config['name'] || field_id.humanize %>
2
- <% required = field_config['required'] == true %>
3
- <% settings = field_config['settings'] || {} %>
4
- <% accept = settings['accept'] || 'image/*' %>
5
- <% files_input_name = lato_cms_field_input_name(field_id, 'files', input_name_prefix: local_assigns[:input_name_prefix], multiple: true) %>
6
-
7
- <%# Build ordered file list from value JSON or default order %>
8
- <% ordered_files = begin
9
- if page_field&.files&.attached?
10
- order = JSON.parse(page_field.value || '[]') rescue []
11
- files = page_field.files.to_a
12
- order.filter_map { |id| files.find { |f| f.id.to_s == id.to_s } } +
13
- files.reject { |f| order.include?(f.id.to_s) }
14
- else
15
- []
16
- end
17
- end %>
18
-
19
- <label class="form-label">
20
- <%= label %><%= ' *' if required %>
21
- </label>
22
-
23
- <div data-controller="lato-cms-gallery-field" data-lato-cms-gallery-field-field-id-value="<%= field_id %>" data-lato-cms-gallery-field-input-name-prefix-value="<%= local_assigns[:input_name_prefix].presence || "fields[#{field_id}]" %>" data-field-required="<%= required %>" data-remove-label="<%= t('lato_cms.field_file_remove') %>">
24
- <div class="lato-cms-gallery-field__grid mb-2"
25
- data-lato-cms-gallery-field-target="grid"
26
- data-action="dragover->lato-cms-gallery-field#onGridDragOver drop->lato-cms-gallery-field#onGridDrop">
27
- <% ordered_files.each do |file| %>
28
- <div class="lato-cms-gallery-field__item"
29
- data-gallery-item
30
- data-attachment-id="<%= file.id %>"
31
- draggable="true"
32
- data-action="dragstart->lato-cms-gallery-field#onDragStart dragend->lato-cms-gallery-field#onDragEnd">
33
- <img src="<%= lato_cms_attachment_path(file) %>" class="lato-cms-gallery-field__thumb" alt="<%= file.filename %>">
34
- <button type="button" class="lato-cms-attachment-field__remove lato-cms-gallery-field__remove" title="<%= t('lato_cms.field_file_remove') %>" aria-label="<%= t('lato_cms.field_file_remove') %>" data-action="click->lato-cms-gallery-field#remove">
35
- <i class="bi bi-trash"></i>
36
- </button>
37
- </div>
38
- <% end %>
39
- </div>
40
-
41
- <% if ordered_files.empty? %>
42
- <p class="text-muted small mb-2" data-lato-cms-gallery-field-target="emptyMsg"><%= t('lato_cms.field_gallery_empty') %></p>
43
- <% end %>
44
-
45
- <label class="btn btn-sm btn-outline-secondary">
46
- <i class="bi bi-plus-lg me-1"></i><%= t('lato_cms.field_gallery_add_images') %>
47
- <%# visually-hidden (not d-none): a required display:none input makes native
48
- validation fail silently with a "not focusable" error, while a clipped
49
- 1px input stays focusable so the browser can anchor its message here. %>
50
- <input type="file"
51
- class="visually-hidden"
52
- name="<%= files_input_name %>"
53
- accept="<%= accept %>"
54
- multiple
55
- data-lato-cms-gallery-field-target="fileInput"
56
- data-action="change->lato-cms-gallery-field#addFiles"
57
- <%= 'required' if required && ordered_files.empty? %>>
58
- </label>
59
- <small class="text-muted ms-2"><%= t('lato_cms.field_gallery_drag_to_reorder') %></small>
60
- </div>
1
+ <%= render partial: 'lato_cms/pages/fields/media_field', locals: {
2
+ field_id: field_id, field_config: field_config, page_field: page_field, kind: 'image', multiple: true,
3
+ input_name_prefix: local_assigns[:input_name_prefix], dom_id_prefix: local_assigns[:dom_id_prefix]
4
+ } %>