lato_cms 3.0.13 → 3.1.1
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 +4 -4
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +143 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +110 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +37 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js +44 -0
- data/app/assets/stylesheets/lato_cms/application.scss +131 -158
- data/app/controllers/lato_cms/application_controller.rb +4 -0
- data/app/controllers/lato_cms/media_controller.rb +121 -0
- data/app/controllers/lato_cms/pages_controller.rb +75 -91
- data/app/helpers/lato_cms/media_helper.rb +50 -0
- data/app/jobs/lato_cms/export_sitemap_csv_job.rb +33 -0
- data/app/jobs/lato_cms/generate_alt_text_job.rb +21 -0
- data/app/jobs/lato_cms/generate_video_poster_job.rb +7 -5
- data/app/jobs/lato_cms/translate_component_fields_job.rb +16 -0
- data/app/models/lato_cms/llm_client.rb +31 -0
- data/app/models/lato_cms/media.rb +270 -0
- data/app/models/lato_cms/page.rb +40 -0
- data/app/models/lato_cms/page_field.rb +52 -80
- data/app/models/lato_cms/page_field_media.rb +8 -0
- data/app/views/lato_cms/media/_form_create.html.erb +20 -0
- data/app/views/lato_cms/media/_form_update.html.erb +60 -0
- data/app/views/lato_cms/media/create.html.erb +13 -0
- data/app/views/lato_cms/media/index.html.erb +22 -0
- data/app/views/lato_cms/media/picker_action.html.erb +98 -0
- data/app/views/lato_cms/media/update.html.erb +13 -0
- data/app/views/lato_cms/pages/_component_accordion.html.erb +15 -0
- data/app/views/lato_cms/pages/fields/_file.html.erb +4 -55
- data/app/views/lato_cms/pages/fields/_gallery.html.erb +4 -60
- data/app/views/lato_cms/pages/fields/_image.html.erb +4 -68
- data/app/views/lato_cms/pages/fields/_media_field.html.erb +62 -0
- data/app/views/lato_cms/pages/fields/_media_item.html.erb +27 -0
- data/app/views/lato_cms/pages/fields/_video.html.erb +4 -69
- data/app/views/lato_cms/pages/index.html.erb +17 -0
- data/app/views/lato_cms/pages/show.html.erb +2 -1
- data/config/locales/en.yml +36 -12
- data/config/locales/it.yml +36 -12
- data/config/routes.rb +12 -0
- data/db/migrate/20260728175413_create_lato_cms_media.rb +11 -0
- data/db/migrate/20260728175414_create_lato_cms_page_field_media.rb +12 -0
- data/db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb +25 -0
- data/lib/lato_cms/config.rb +12 -1
- data/lib/lato_cms/version.rb +1 -1
- metadata +38 -5
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +0 -225
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +0 -190
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +0 -169
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_video_field_controller.js +0 -180
data/app/models/lato_cms/page.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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] =
|
|
121
|
+
result[:attachments] = media.map { |m| media_as_json(m) }
|
|
125
122
|
when 'image'
|
|
126
|
-
attached =
|
|
127
|
-
result[:attachments] = attached ? [
|
|
123
|
+
attached = media.first
|
|
124
|
+
result[:attachments] = attached ? [media_as_json(attached, with_variants: true)] : []
|
|
128
125
|
when 'video'
|
|
129
|
-
attached =
|
|
130
|
-
result[:attachments] = attached ? [
|
|
126
|
+
attached = media.first
|
|
127
|
+
result[:attachments] = attached ? [video_media_as_json(attached)] : []
|
|
131
128
|
when 'gallery'
|
|
132
|
-
|
|
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
|
|
139
|
+
def media_as_json(m, with_variants: false)
|
|
146
140
|
json = {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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] =
|
|
149
|
+
json[:sizes] = m.variant_urls(field_settings['sizes']) if with_variants
|
|
154
150
|
json
|
|
155
151
|
end
|
|
156
152
|
|
|
157
|
-
# Video
|
|
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
|
|
160
|
-
|
|
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
|
-
#
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
} %>
|