lato_cms 3.4.0 → 3.5.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.
- checksums.yaml +4 -4
- data/README.md +137 -9
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_advanced_editor_controller.js +58 -6
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_color_field_controller.js +11 -1
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_form_controller.js +48 -5
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_toggle_controller.js +6 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_json_field_controller.js +3 -2
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +40 -2
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +6 -5
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +25 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_page_preview_controller.js +6 -1
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_repeater_controller.js +8 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_text_field_controller.js +2 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_upload_controller.js +4 -1
- data/app/assets/stylesheets/lato_cms/application.scss +57 -0
- data/app/controllers/lato_cms/application_controller.rb +23 -1
- data/app/controllers/lato_cms/media_controller.rb +4 -2
- data/app/controllers/lato_cms/pages_controller.rb +51 -22
- data/app/helpers/lato_cms/media_helper.rb +1 -1
- data/app/helpers/lato_cms/pages_helper.rb +15 -12
- data/app/jobs/lato_cms/export_sitemap_csv_job.rb +8 -1
- data/app/models/lato_cms/llm_client.rb +11 -0
- data/app/models/lato_cms/media.rb +41 -11
- data/app/models/lato_cms/page.rb +45 -7
- data/app/models/lato_cms/page_field.rb +64 -4
- data/app/models/lato_cms/template_manager.rb +49 -3
- data/app/views/lato_cms/media/_form_update.html.erb +9 -7
- data/app/views/lato_cms/media/_translations_field.html.erb +8 -5
- data/app/views/lato_cms/media/_upload_progress.html.erb +4 -2
- data/app/views/lato_cms/media/picker_action.html.erb +21 -14
- data/app/views/lato_cms/media/show.html.erb +2 -1
- data/app/views/lato_cms/pages/_component_accordion.html.erb +14 -8
- data/app/views/lato_cms/pages/_form_update.html.erb +5 -0
- data/app/views/lato_cms/pages/_repeater_item.html.erb +6 -4
- data/app/views/lato_cms/pages/_translations.html.erb +2 -1
- data/app/views/lato_cms/pages/fields/_color.html.erb +14 -6
- data/app/views/lato_cms/pages/fields/_json.html.erb +5 -2
- data/app/views/lato_cms/pages/fields/_media_field.html.erb +12 -5
- data/app/views/lato_cms/pages/fields/_media_item.html.erb +14 -3
- data/app/views/lato_cms/pages/fields/_text.html.erb +31 -22
- data/app/views/lato_cms/pages/index.html.erb +3 -2
- data/app/views/lato_cms/pages/show.html.erb +26 -9
- data/config/locales/en.yml +27 -1
- data/config/locales/it.yml +27 -1
- data/lib/lato_cms/config.rb +26 -1
- data/lib/lato_cms/version.rb +1 -1
- data/lib/tasks/lato_cms_tasks.rake +18 -11
- metadata +1 -15
|
@@ -22,6 +22,7 @@ module LatoCms
|
|
|
22
22
|
validates :field_id, presence: true
|
|
23
23
|
validates :field_id, uniqueness: { scope: [:page_id, :template_component_id] }
|
|
24
24
|
validate :media_present_if_required
|
|
25
|
+
validate :value_matches_field_type
|
|
25
26
|
|
|
26
27
|
REPEATER_ORDER_FIELD_ID = "__repeater_order".freeze
|
|
27
28
|
|
|
@@ -50,16 +51,32 @@ module LatoCms
|
|
|
50
51
|
end
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
RICH_TEXT_ALIGNMENTS = %w[left center right justify].freeze
|
|
55
|
+
COLOR_FORMAT = /\A#(\h{3}|\h{6})\z/
|
|
56
|
+
|
|
57
|
+
# Same allowlist as the editor's client-side sanitizer: `style` is reduced
|
|
58
|
+
# to text-align, the only style the toolbar produces, so a value posted
|
|
59
|
+
# outside the editor cannot keep arbitrary CSS either.
|
|
53
60
|
def self.sanitize_rich_text(value)
|
|
54
|
-
|
|
61
|
+
html = ActionController::Base.helpers.sanitize(value, tags: RICH_TEXT_TAGS, attributes: RICH_TEXT_ATTRIBUTES).to_s
|
|
62
|
+
return String.new(html) unless html.include?('style=')
|
|
63
|
+
|
|
64
|
+
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
|
|
65
|
+
fragment.css('[style]').each do |element|
|
|
66
|
+
alignment = element['style'][/(?:\A|;)\s*text-align\s*:\s*([a-z]+)/i, 1].to_s.downcase
|
|
67
|
+
RICH_TEXT_ALIGNMENTS.include?(alignment) ? element['style'] = "text-align: #{alignment};" : element.remove_attribute('style')
|
|
68
|
+
end
|
|
69
|
+
String.new(fragment.to_html)
|
|
55
70
|
end
|
|
56
71
|
|
|
72
|
+
# The field definition comes from the template this field was saved
|
|
73
|
+
# under, never from the stored component_id: that id used to be taken
|
|
74
|
+
# from the request, and a forged one turned a `text` field (sanitized)
|
|
75
|
+
# into a `string` one (not sanitized).
|
|
57
76
|
def field_config
|
|
58
77
|
return nil if repeater_order?
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
return nil unless component
|
|
62
|
-
component.dig('fields', base_field_id)
|
|
79
|
+
LatoCms::TemplateManager.find_template_component_fields(template_id, template_component_id)&.dig(base_field_id)
|
|
63
80
|
end
|
|
64
81
|
|
|
65
82
|
def repeater_order?
|
|
@@ -177,6 +194,49 @@ module LatoCms
|
|
|
177
194
|
errors.add(:base, I18n.t('lato_cms.field_required_attachment_error')) if page_field_media.reload.empty?
|
|
178
195
|
end
|
|
179
196
|
|
|
197
|
+
# Server-side counterpart of the browser checks (`required`, option
|
|
198
|
+
# lists, number and color inputs): a request built by hand skips those,
|
|
199
|
+
# and the API hands the stored value to the frontend as-is.
|
|
200
|
+
def value_matches_field_type
|
|
201
|
+
return if repeater_order? || ATTACHMENT_FIELD_TYPES.include?(field_type)
|
|
202
|
+
|
|
203
|
+
if value_blank_for_type?
|
|
204
|
+
errors.add(:value, :blank) if field_required?
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
case field_type
|
|
209
|
+
when 'number'
|
|
210
|
+
errors.add(:value, :not_a_number) unless value.to_s.strip.match?(/\A-?\d+(\.\d+)?\z/)
|
|
211
|
+
when 'color'
|
|
212
|
+
errors.add(:value, :invalid) unless value.to_s.match?(COLOR_FORMAT)
|
|
213
|
+
when 'select'
|
|
214
|
+
errors.add(:value, :inclusion) if option_values.any? && !option_values.include?(value.to_s)
|
|
215
|
+
when 'multiselect'
|
|
216
|
+
errors.add(:value, :inclusion) if option_values.any? && (Array(parsed_value).map(&:to_s) - option_values).any?
|
|
217
|
+
when 'date', 'datetime'
|
|
218
|
+
errors.add(:value, :invalid) unless valid_date_value?
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def value_blank_for_type?
|
|
223
|
+
return true if value.blank?
|
|
224
|
+
|
|
225
|
+
field_type == 'multiselect' && Array(parsed_value).compact_blank.empty?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def valid_date_value?
|
|
229
|
+
field_type == 'date' ? Date.iso8601(value.to_s) : DateTime.parse(value.to_s)
|
|
230
|
+
true
|
|
231
|
+
rescue ArgumentError, Date::Error
|
|
232
|
+
false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# `options` may list plain strings or `{ value:, label: }` hashes.
|
|
236
|
+
def option_values
|
|
237
|
+
Array(field_settings['options']).map { |option| (option.is_a?(Hash) ? option['value'] : option).to_s }
|
|
238
|
+
end
|
|
239
|
+
|
|
180
240
|
def parse_value
|
|
181
241
|
return if value.blank?
|
|
182
242
|
|
|
@@ -10,11 +10,11 @@ module LatoCms
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def templates
|
|
13
|
-
|
|
13
|
+
cached_yaml_files(templates_path)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def components
|
|
17
|
-
|
|
17
|
+
cached_yaml_files(components_path)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def find_template(template_id)
|
|
@@ -27,6 +27,15 @@ module LatoCms
|
|
|
27
27
|
components.find { |c| c['id'] == component_id.to_s }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# The field definitions of a template component, resolved through the
|
|
31
|
+
# template (template -> template component -> component). This is the
|
|
32
|
+
# authoritative source for a field's type: the component id stored on a
|
|
33
|
+
# PageField, or sent by a client, is never trusted to pick it.
|
|
34
|
+
def find_template_component_fields(template_id, template_component_id)
|
|
35
|
+
component_id = find_template(template_id)&.dig('components', template_component_id.to_s, 'component_id')
|
|
36
|
+
find_component(component_id)&.dig('fields')
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
def template_options
|
|
31
40
|
templates.map { |t| [t['name'], t['id']] }
|
|
32
41
|
end
|
|
@@ -49,12 +58,49 @@ module LatoCms
|
|
|
49
58
|
end
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
def reset_cache!
|
|
62
|
+
@cache = {}
|
|
63
|
+
end
|
|
64
|
+
|
|
52
65
|
private
|
|
53
66
|
|
|
67
|
+
# Parsed YAML is kept per process: every field read (type, name,
|
|
68
|
+
# settings) goes through here, so re-reading the files each time cost
|
|
69
|
+
# hundreds of parses per request, on a public endpoint too. When code
|
|
70
|
+
# reloading is on (development) the cache is keyed by the files' mtimes,
|
|
71
|
+
# so edited templates show up without a restart. Entries are deep-frozen
|
|
72
|
+
# because they are shared by every caller.
|
|
73
|
+
def cached_yaml_files(path)
|
|
74
|
+
@cache ||= {}
|
|
75
|
+
fingerprint = reloading? ? yaml_fingerprint(path) : nil
|
|
76
|
+
entry = @cache[path.to_s]
|
|
77
|
+
return entry[:data] if entry && entry[:fingerprint] == fingerprint
|
|
78
|
+
|
|
79
|
+
data = deep_freeze(load_yaml_files(path))
|
|
80
|
+
@cache[path.to_s] = { fingerprint: fingerprint, data: data }
|
|
81
|
+
data
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reloading?
|
|
85
|
+
Rails.application.config.respond_to?(:enable_reloading) && Rails.application.config.enable_reloading
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def yaml_fingerprint(path)
|
|
89
|
+
Dir.glob(File.join(path, '*.yml')).sort.map { |file| [file, File.mtime(file).to_f] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def deep_freeze(object)
|
|
93
|
+
case object
|
|
94
|
+
when Hash then object.each_value { |value| deep_freeze(value) }
|
|
95
|
+
when Array then object.each { |value| deep_freeze(value) }
|
|
96
|
+
end
|
|
97
|
+
object.freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
54
100
|
def load_yaml_files(path)
|
|
55
101
|
return [] unless Dir.exist?(path)
|
|
56
102
|
|
|
57
|
-
Dir.glob(File.join(path, '*.yml')).filter_map do |file|
|
|
103
|
+
Dir.glob(File.join(path, '*.yml')).sort.filter_map do |file|
|
|
58
104
|
YAML.load_file(file)
|
|
59
105
|
rescue Psych::SyntaxError => e
|
|
60
106
|
Rails.logger.error("LatoCms: Failed to parse YAML file #{file}: #{e.message}")
|
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
<%# Two tabs: the metadata form, and the file swap — same media, but editing
|
|
6
6
|
its name is a local change while replacing its file edits every page
|
|
7
7
|
using it, so they don't share a submit button. %>
|
|
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="#<%= tab_id %>_details" type="button"
|
|
8
|
+
<ul class="nav nav-tabs mb-3" role="tablist">
|
|
9
|
+
<li class="nav-item" role="presentation">
|
|
10
|
+
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#<%= tab_id %>_details" type="button"
|
|
11
|
+
role="tab" id="<%= tab_id %>_details_tab" aria-controls="<%= tab_id %>_details" aria-selected="true">
|
|
11
12
|
<%= t('lato_cms.media_update_tab_details') %>
|
|
12
13
|
</button>
|
|
13
14
|
</li>
|
|
14
15
|
<% if lato_cms_admin? %>
|
|
15
|
-
<li class="nav-item">
|
|
16
|
-
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#<%= tab_id %>_replace" type="button"
|
|
16
|
+
<li class="nav-item" role="presentation">
|
|
17
|
+
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#<%= tab_id %>_replace" type="button"
|
|
18
|
+
role="tab" id="<%= tab_id %>_replace_tab" aria-controls="<%= tab_id %>_replace" aria-selected="false">
|
|
17
19
|
<%= t('lato_cms.media_replace_file_title') %>
|
|
18
20
|
</button>
|
|
19
21
|
</li>
|
|
@@ -21,7 +23,7 @@
|
|
|
21
23
|
</ul>
|
|
22
24
|
|
|
23
25
|
<div class="tab-content">
|
|
24
|
-
<div class="tab-pane fade show active" id="<%= tab_id %>_details">
|
|
26
|
+
<div class="tab-pane fade show active" id="<%= tab_id %>_details" role="tabpanel" aria-labelledby="<%= tab_id %>_details_tab">
|
|
25
27
|
<%= 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| %>
|
|
26
28
|
<%= lato_form_notices class: %w[mb-3] %>
|
|
27
29
|
<%= lato_form_errors media, class: %w[mb-3] %>
|
|
@@ -64,7 +66,7 @@
|
|
|
64
66
|
</div>
|
|
65
67
|
|
|
66
68
|
<% if lato_cms_admin? %>
|
|
67
|
-
<div class="tab-pane fade" id="<%= tab_id %>_replace">
|
|
69
|
+
<div class="tab-pane fade" id="<%= tab_id %>_replace" role="tabpanel" aria-labelledby="<%= tab_id %>_replace_tab">
|
|
68
70
|
<%= render 'lato_cms/media/form_replace_file', media: media %>
|
|
69
71
|
</div>
|
|
70
72
|
<% end %>
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
<% label = t("lato_cms.media_#{attribute}") %>
|
|
5
5
|
|
|
6
6
|
<div class="mb-3">
|
|
7
|
-
<
|
|
7
|
+
<div class="form-label" id="<%= dom_id(media, "#{attribute}_label") %>"><%= label %></div>
|
|
8
8
|
|
|
9
|
-
<ul class="nav nav-tabs">
|
|
9
|
+
<ul class="nav nav-tabs" role="tablist" aria-labelledby="<%= dom_id(media, "#{attribute}_label") %>">
|
|
10
10
|
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
11
|
-
<li class="nav-item">
|
|
11
|
+
<li class="nav-item" role="presentation">
|
|
12
12
|
<button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
|
|
13
|
+
role="tab" id="<%= dom_id(media, "#{attribute}_#{locale}_tab") %>" aria-selected="<%= index.zero? %>"
|
|
14
|
+
aria-controls="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>"
|
|
13
15
|
data-bs-target="#<%= dom_id(media, "#{attribute}_#{locale}_pane") %>" type="button">
|
|
14
16
|
<%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
|
|
15
17
|
</button>
|
|
@@ -19,8 +21,9 @@
|
|
|
19
21
|
|
|
20
22
|
<div class="tab-content border border-top-0 rounded-bottom p-3">
|
|
21
23
|
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
22
|
-
<div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>"
|
|
23
|
-
|
|
24
|
+
<div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>"
|
|
25
|
+
role="tabpanel" aria-labelledby="<%= dom_id(media, "#{attribute}_#{locale}_tab") %>">
|
|
26
|
+
<%= lato_form_item_input_text form, :"#{attribute}_#{locale}", 'aria-label': "#{label} (#{locale.to_s.upcase})" %>
|
|
24
27
|
</div>
|
|
25
28
|
<% end %>
|
|
26
29
|
</div>
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
submit starts, then fed the real bytes-sent percentage. %>
|
|
3
3
|
<div class="d-none my-3" data-lato-cms-upload-target="progress">
|
|
4
4
|
<div class="progress" style="height: 6px;">
|
|
5
|
-
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%;" role="progressbar"
|
|
5
|
+
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%;" role="progressbar"
|
|
6
|
+
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-labelledby="<%= progress_label_id = "lato-cms-upload-label-#{SecureRandom.hex(4)}" %>"
|
|
7
|
+
data-lato-cms-upload-target="bar"></div>
|
|
6
8
|
</div>
|
|
7
|
-
<div class="small text-muted mt-1" data-lato-cms-upload-target="label">0%</div>
|
|
9
|
+
<div class="small text-muted mt-1" id="<%= progress_label_id %>" role="status" aria-live="polite" data-lato-cms-upload-target="label">0%</div>
|
|
8
10
|
</div>
|
|
@@ -5,21 +5,23 @@
|
|
|
5
5
|
data-lato-cms-media-picker-multiple-value="<%= multiple %>"
|
|
6
6
|
data-lato-cms-media-picker-selected-label-value="<%= t('lato_cms.media_picker_selected_label') %>">
|
|
7
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"
|
|
8
|
+
<ul class="nav nav-tabs mb-3" role="tablist">
|
|
9
|
+
<li class="nav-item" role="presentation">
|
|
10
|
+
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-browse" type="button"
|
|
11
|
+
role="tab" id="lato-cms-media-picker-browse-tab" aria-controls="lato-cms-media-picker-browse" aria-selected="true">
|
|
11
12
|
<%= t('lato_cms.media_picker_tab_browse') %>
|
|
12
13
|
</button>
|
|
13
14
|
</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"
|
|
15
|
+
<li class="nav-item" role="presentation">
|
|
16
|
+
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-upload" type="button"
|
|
17
|
+
role="tab" id="lato-cms-media-picker-upload-tab" aria-controls="lato-cms-media-picker-upload" aria-selected="false">
|
|
16
18
|
<%= t('lato_cms.media_picker_tab_upload') %>
|
|
17
19
|
</button>
|
|
18
20
|
</li>
|
|
19
21
|
</ul>
|
|
20
22
|
|
|
21
23
|
<div class="tab-content">
|
|
22
|
-
<div class="tab-pane fade show active" id="lato-cms-media-picker-browse">
|
|
24
|
+
<div class="tab-pane fade show active" id="lato-cms-media-picker-browse" role="tabpanel" aria-labelledby="lato-cms-media-picker-browse-tab">
|
|
23
25
|
<%= form_with url: lato_cms.media_picker_action_path, method: :get, data: { turbo_frame: params[:frame_id] } do %>
|
|
24
26
|
<%= hidden_field_tag :frame_id, params[:frame_id] %>
|
|
25
27
|
<%= hidden_field_tag :multiple, multiple %>
|
|
@@ -32,8 +34,9 @@
|
|
|
32
34
|
class: "btn btn-sm #{params[:type] == type ? 'btn-primary' : 'btn-outline-secondary'}", data: { turbo_frame: params[:frame_id] } %>
|
|
33
35
|
<% end %>
|
|
34
36
|
</div>
|
|
35
|
-
<%=
|
|
36
|
-
|
|
37
|
+
<%= search_field_tag :q, params[:q], class: 'form-control form-control-sm', placeholder: t('lato_cms.media_search_placeholder'),
|
|
38
|
+
'aria-label': t('lato_cms.media_search_label') %>
|
|
39
|
+
<button type="submit" class="btn btn-sm btn-outline-primary" aria-label="<%= t('lato_cms.media_search_cta') %>"><i class="bi bi-search" aria-hidden="true"></i></button>
|
|
37
40
|
</div>
|
|
38
41
|
<% end %>
|
|
39
42
|
|
|
@@ -45,7 +48,11 @@
|
|
|
45
48
|
<% else %>
|
|
46
49
|
<div class="lato-cms-media-picker__grid mb-3" data-lato-cms-media-picker-target="grid">
|
|
47
50
|
<% @media.each do |media| %>
|
|
48
|
-
|
|
51
|
+
<%# A button: reachable and operable from the keyboard. In
|
|
52
|
+
multiple mode aria-pressed carries the selection state. %>
|
|
53
|
+
<button type="button" class="lato-cms-media-picker__item"
|
|
54
|
+
aria-label="<%= t('lato_cms.media_field_select', name: media.name) %> (<%= t("lato_cms.media_type_#{media.media_type}", default: media.media_type) %>)"
|
|
55
|
+
<% if multiple %>aria-pressed="false"<% end %>
|
|
49
56
|
data-media-id="<%= media.id %>"
|
|
50
57
|
data-media-name="<%= media.name %>"
|
|
51
58
|
data-media-thumb="<%= media.thumbnail_url %>"
|
|
@@ -55,8 +62,8 @@
|
|
|
55
62
|
data-action="click->lato-cms-media-picker#select"
|
|
56
63
|
data-lato-cms-media-picker-target="item">
|
|
57
64
|
<%= lato_cms_media_thumb(media, size: 100) %>
|
|
58
|
-
<span class="small text-truncate d-block"><%= media.name %></span>
|
|
59
|
-
</
|
|
65
|
+
<span class="small text-truncate d-block" aria-hidden="true"><%= media.name %></span>
|
|
66
|
+
</button>
|
|
60
67
|
<% end %>
|
|
61
68
|
</div>
|
|
62
69
|
|
|
@@ -65,7 +72,7 @@
|
|
|
65
72
|
|
|
66
73
|
<% if multiple %>
|
|
67
74
|
<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>
|
|
75
|
+
<span data-lato-cms-media-picker-target="selectedCount" role="status" aria-live="polite">0 <%= t('lato_cms.media_picker_selected_label') %></span>
|
|
69
76
|
<button type="button" class="btn btn-primary" data-action="lato-cms-media-picker#confirm" data-lato-cms-media-picker-target="confirmButton" disabled>
|
|
70
77
|
<%= t('lato_cms.media_picker_confirm') %>
|
|
71
78
|
</button>
|
|
@@ -73,8 +80,8 @@
|
|
|
73
80
|
<% end %>
|
|
74
81
|
</div>
|
|
75
82
|
|
|
76
|
-
<div class="tab-pane fade" id="lato-cms-media-picker-upload">
|
|
77
|
-
<div data-lato-cms-media-picker-target="uploadNotice"></div>
|
|
83
|
+
<div class="tab-pane fade" id="lato-cms-media-picker-upload" role="tabpanel" aria-labelledby="lato-cms-media-picker-upload-tab">
|
|
84
|
+
<div data-lato-cms-media-picker-target="uploadNotice" role="status" aria-live="polite"></div>
|
|
78
85
|
<%# Name/alt text are intentionally not collected here: they default
|
|
79
86
|
to the filename on upload and are only ever edited afterwards
|
|
80
87
|
from the Media library's own edit form.
|
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
<div class="d-flex align-items-center gap-3">
|
|
39
39
|
<div class="form-check form-switch m-0">
|
|
40
40
|
<%= hidden_field_tag :enabled, '0' %>
|
|
41
|
-
<%= check_box_tag :enabled, '1', enabled_component, class: 'form-check-input', id: "toggle-#{accordion_id}",
|
|
41
|
+
<%= check_box_tag :enabled, '1', enabled_component, class: 'form-check-input', id: "toggle-#{accordion_id}", 'aria-label': t('lato_cms.component_enabled_label'), data: { lato_cms_component_toggle_target: 'checkbox', action: 'change->lato-cms-component-toggle#submit' } %>
|
|
42
42
|
</div>
|
|
43
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
44
|
<i class="<%= enabled_component ? 'bi bi-eye' : 'bi bi-eye-slash' %>" data-lato-cms-component-toggle-target="stateIcon"></i>
|
|
45
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
46
|
</div>
|
|
47
|
-
<span data-lato-cms-component-toggle-target="status"></span>
|
|
47
|
+
<span data-lato-cms-component-toggle-target="status" role="status" aria-live="polite"></span>
|
|
48
48
|
</div>
|
|
49
49
|
<% end %>
|
|
50
50
|
<% end %>
|
|
@@ -52,8 +52,9 @@
|
|
|
52
52
|
|
|
53
53
|
<% if clonable_translations.any? %>
|
|
54
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
|
-
|
|
55
|
+
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
|
56
|
+
aria-label="<%= t('lato_cms.component_clone_cta') %>">
|
|
57
|
+
<i class="bi bi-files" aria-hidden="true"></i>
|
|
57
58
|
</button>
|
|
58
59
|
<ul class="dropdown-menu dropdown-menu-end">
|
|
59
60
|
<% clonable_translations.each do |sibling| %>
|
|
@@ -93,9 +94,14 @@
|
|
|
93
94
|
<div data-lato-cms-component-toggle-target="fieldsContainer" class="<%= enabled_component ? '' : 'd-none' %>">
|
|
94
95
|
<% end %>
|
|
95
96
|
<%= form_with url: lato_cms.pages_save_fields_action_path(page), method: :post, multipart: true,
|
|
96
|
-
data: {
|
|
97
|
+
data: {
|
|
98
|
+
turbo: false, controller: 'lato-cms-component-form',
|
|
99
|
+
lato_cms_component_form_saving_label_value: t('lato_cms.fields_saving'),
|
|
100
|
+
lato_cms_component_form_saved_label_value: t('lato_cms.fields_saved'),
|
|
101
|
+
lato_cms_component_form_failed_label_value: t('lato_cms.fields_save_failed'),
|
|
102
|
+
lato_cms_component_form_error_label_value: t('lato_cms.fields_save_error')
|
|
103
|
+
} do |form| %>
|
|
97
104
|
<%= hidden_field_tag :template_component_id, tc[:template_component_id] %>
|
|
98
|
-
<%= hidden_field_tag :component_id, tc[:component_id] %>
|
|
99
105
|
|
|
100
106
|
<% if tc[:repeater] %>
|
|
101
107
|
<%= render 'lato_cms/pages/repeater', tc: tc, component_fields: component_fields %>
|
|
@@ -103,7 +109,7 @@
|
|
|
103
109
|
<% tc[:fields].each do |field_id, field_config| %>
|
|
104
110
|
<% page_field = component_fields.find { |f| f.field_id == field_id } %>
|
|
105
111
|
|
|
106
|
-
<div class="mb-3">
|
|
112
|
+
<div class="mb-3" data-lato-cms-persisted-field-id="<%= field_id %>">
|
|
107
113
|
<%# The dom id prefix carries the template component: every
|
|
108
114
|
component of the page is rendered at once, and two of them
|
|
109
115
|
can expose the same field id (a template mounting one
|
|
@@ -124,7 +130,7 @@
|
|
|
124
130
|
<% end %>
|
|
125
131
|
|
|
126
132
|
<div class="d-flex justify-content-end align-items-center gap-3 mt-1">
|
|
127
|
-
<span data-lato-cms-component-form-target="status"></span>
|
|
133
|
+
<span data-lato-cms-component-form-target="status" role="status" aria-live="polite"></span>
|
|
128
134
|
<button type="submit" class="btn btn-sm btn-success" data-lato-cms-component-form-target="submitBtn">
|
|
129
135
|
<i class="bi bi-check-lg me-1"></i><%= t('lato_cms.fields_save_component') %>
|
|
130
136
|
</button>
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
<% template_options = [[t('lato_cms.form_no_template_option'), '']] + LatoCms::TemplateManager.template_options %>
|
|
25
25
|
<%= lato_form_item_label form, :template_id, t('lato_cms.form_template') %>
|
|
26
26
|
<%= lato_form_item_input_select form, :template_id, template_options %>
|
|
27
|
+
<% if page.persisted? && page.fields.exists? %>
|
|
28
|
+
<div class="form-text">
|
|
29
|
+
<i class="bi bi-info-circle me-1" aria-hidden="true"></i><%= t('lato_cms.form_template_change_warning') %>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
27
32
|
<% if page.template_id.present? && !page.template_available? %>
|
|
28
33
|
<div class="form-text text-danger">
|
|
29
34
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
<button type="button" class="lato-cms-repeater-item__toggle btn btn-link p-0 flex-grow-1 text-start text-decoration-none text-body <%= 'collapsed' unless expanded %>"
|
|
8
8
|
data-bs-toggle="collapse" data-bs-target="#<%= collapse_id %>" aria-expanded="<%= expanded %>" aria-controls="<%= collapse_id %>">
|
|
9
9
|
<i class="bi bi-chevron-down me-2 small"></i>
|
|
10
|
-
<strong class="small"><%= tc[:name] %> #<span data-lato-cms-repeater-target="position"><%= item_index.to_i + 1 %></span></strong>
|
|
10
|
+
<strong class="small" id="repeater-title-<%= item_id %>"><%= tc[:name] %> #<span data-lato-cms-repeater-target="position"><%= item_index.to_i + 1 %></span></strong>
|
|
11
11
|
</button>
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
<%# Named "Remove <component> #n": the title span keeps the live position. %>
|
|
13
|
+
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" data-action="lato-cms-repeater#remove"
|
|
14
|
+
id="repeater-remove-<%= item_id %>" aria-labelledby="repeater-remove-<%= item_id %> repeater-title-<%= item_id %>">
|
|
15
|
+
<i class="bi bi-trash me-1" aria-hidden="true"></i><%= t('lato_cms.repeater_remove_item') %>
|
|
14
16
|
</button>
|
|
15
17
|
</div>
|
|
16
18
|
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
<% input_name_prefix = "repeater_items[#{item_id}][#{field_id}]" %>
|
|
26
28
|
<% dom_id_prefix = "repeater_items_#{item_id}_#{field_id}" %>
|
|
27
29
|
|
|
28
|
-
<div class="mb-3">
|
|
30
|
+
<div class="mb-3" data-lato-cms-persisted-field-id="<%= item_id %>.<%= field_id %>">
|
|
29
31
|
<%= lato_cms_render_field(
|
|
30
32
|
field_id: field_id,
|
|
31
33
|
field_config: field_config,
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
class: 'd-flex align-items-center gap-2 flex-grow-1 justify-content-end' do %>
|
|
29
29
|
<%= select_tag :translation_page_id,
|
|
30
30
|
options_from_collection_for_select(candidates, :id, :title),
|
|
31
|
-
class: 'form-select form-select-sm', style: 'max-width: 260px;'
|
|
31
|
+
class: 'form-select form-select-sm', style: 'max-width: 260px;',
|
|
32
|
+
id: "translation_page_id_#{locale}", 'aria-label': t('lato_cms.translation_select_label', locale: locale.upcase) %>
|
|
32
33
|
<%= submit_tag t('lato_cms.translation_link_cta'), class: 'btn btn-sm btn-outline-primary' %>
|
|
33
34
|
<% end %>
|
|
34
35
|
<% else %>
|
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
<% label = field_config['name'] || field_id.humanize %>
|
|
2
2
|
<% required = field_config['required'] == true %>
|
|
3
|
-
<% current_value = page_field&.value.presence
|
|
3
|
+
<% current_value = page_field&.value.presence %>
|
|
4
4
|
<% input_name = lato_cms_field_input_name(field_id, input_name_prefix: local_assigns[:input_name_prefix]) %>
|
|
5
5
|
<% input_id = lato_cms_field_dom_id(field_id, dom_id_prefix: local_assigns[:dom_id_prefix]) %>
|
|
6
6
|
|
|
7
7
|
<label class="form-label" for="<%= input_id %>">
|
|
8
8
|
<%= label %><%= ' *' if required %>
|
|
9
9
|
</label>
|
|
10
|
-
|
|
10
|
+
<%# The submitted value lives in the hidden input: a native color input always
|
|
11
|
+
holds a color (#000000 when empty), so an untouched field used to be saved
|
|
12
|
+
as black. It stays empty until a color is picked, and can be cleared. %>
|
|
13
|
+
<div class="d-flex align-items-center gap-2" data-controller="lato-cms-color-field"
|
|
14
|
+
data-lato-cms-color-field-empty-label-value="<%= t('lato_cms.field_color_empty') %>">
|
|
15
|
+
<input type="hidden" name="<%= input_name %>" value="<%= current_value %>" data-lato-cms-color-field-target="value">
|
|
11
16
|
<input type="color"
|
|
12
17
|
class="form-control form-control-color"
|
|
13
|
-
name="<%= input_name %>"
|
|
14
18
|
id="<%= input_id %>"
|
|
15
|
-
value="<%= current_value %>"
|
|
19
|
+
value="<%= current_value || '#000000' %>"
|
|
16
20
|
data-lato-cms-color-field-target="input"
|
|
17
21
|
data-action="input->lato-cms-color-field#update"
|
|
18
|
-
<%= 'required' if required %>>
|
|
19
|
-
<code class="text-muted small" data-lato-cms-color-field-target="hex"><%= current_value
|
|
22
|
+
<%= 'aria-required="true"'.html_safe if required %>>
|
|
23
|
+
<code class="text-muted small" data-lato-cms-color-field-target="hex" aria-live="polite"><%= current_value&.upcase || t('lato_cms.field_color_empty') %></code>
|
|
24
|
+
<% unless required %>
|
|
25
|
+
<button type="button" class="btn btn-sm btn-link px-0" data-action="lato-cms-color-field#clear"
|
|
26
|
+
aria-label="<%= t('lato_cms.field_color_clear') %>: <%= label %>"><%= t('lato_cms.field_color_clear') %></button>
|
|
27
|
+
<% end %>
|
|
20
28
|
</div>
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
<label class="form-label" for="<%= input_id %>">
|
|
12
12
|
<%= label %><%= ' *' if required %>
|
|
13
13
|
</label>
|
|
14
|
-
<div data-controller="lato-cms-json-field"
|
|
14
|
+
<div data-controller="lato-cms-json-field"
|
|
15
|
+
data-lato-cms-json-field-valid-label-value="<%= t('lato_cms.field_json_valid') %>"
|
|
16
|
+
data-lato-cms-json-field-invalid-label-value="<%= t('lato_cms.field_json_invalid', error: '%{error}') %>">
|
|
15
17
|
<textarea
|
|
16
18
|
class="form-control font-monospace"
|
|
17
19
|
name="<%= input_name %>"
|
|
@@ -19,7 +21,8 @@
|
|
|
19
21
|
rows="<%= settings['rows'] || 6 %>"
|
|
20
22
|
data-lato-cms-json-field-target="input"
|
|
21
23
|
data-action="input->lato-cms-json-field#validate"
|
|
24
|
+
aria-describedby="<%= input_id %>_status"
|
|
22
25
|
<%= 'required' if required %>
|
|
23
26
|
<% if settings['placeholder'] %>placeholder="<%= settings['placeholder'] %>"<% end %>><%= current_value %></textarea>
|
|
24
|
-
<div data-lato-cms-json-field-target="status" class="form-text"></div>
|
|
27
|
+
<div data-lato-cms-json-field-target="status" class="form-text" id="<%= input_id %>_status" role="status" aria-live="polite"></div>
|
|
25
28
|
</div>
|
|
@@ -12,11 +12,17 @@
|
|
|
12
12
|
<% picker_type = kind == 'file' ? nil : kind %>
|
|
13
13
|
<% wide = !multiple && %w[image video].include?(kind) %>
|
|
14
14
|
|
|
15
|
-
<label
|
|
15
|
+
<%# Not a <label>: there is no single input to point it at, so the field is
|
|
16
|
+
a group labelled by this element. %>
|
|
17
|
+
<div class="form-label" id="<%= dom_id %>_label">
|
|
16
18
|
<%= label %><%= ' *' if required %>
|
|
17
|
-
</
|
|
19
|
+
</div>
|
|
18
20
|
|
|
19
|
-
<div
|
|
21
|
+
<div role="group" aria-labelledby="<%= dom_id %>_label"
|
|
22
|
+
data-controller="lato-cms-media-field<%= ' lato-cms-media-reorder' if multiple %>"
|
|
23
|
+
data-lato-cms-media-field-remove-label-value="<%= t('lato_cms.field_file_remove') %>"
|
|
24
|
+
data-lato-cms-media-field-move-before-label-value="<%= t('lato_cms.media_move_before') %>"
|
|
25
|
+
data-lato-cms-media-field-move-after-label-value="<%= t('lato_cms.media_move_after') %>"
|
|
20
26
|
data-lato-cms-media-field-kind-value="<%= kind %>"
|
|
21
27
|
data-lato-cms-media-field-multiple-value="<%= multiple %>"
|
|
22
28
|
data-lato-cms-media-field-frame-id-value="<%= frame_id %>"
|
|
@@ -57,7 +63,7 @@
|
|
|
57
63
|
action: 'click->lato-cms-media-field#openPicker',
|
|
58
64
|
action_title: t('lato_cms.media_picker_title'), action_size: 'lg'
|
|
59
65
|
} do %>
|
|
60
|
-
<i class="bi bi-images"></i>
|
|
66
|
+
<i class="bi bi-images" aria-hidden="true"></i>
|
|
61
67
|
<span><%= t('lato_cms.media_choose_from_library') %></span>
|
|
62
68
|
<% end %>
|
|
63
69
|
</div>
|
|
@@ -65,7 +71,8 @@
|
|
|
65
71
|
<%# Real-but-hidden input anchors the browser's native required-validation
|
|
66
72
|
bubble (a display:none required input fails silently as "not
|
|
67
73
|
focusable"). Kept in sync with selection state by the Stimulus controller. %>
|
|
68
|
-
<input type="text" class="visually-hidden" value="<%= current_media.first&.id %>"
|
|
74
|
+
<input type="text" class="visually-hidden" value="<%= current_media.first&.id %>" tabindex="-1"
|
|
75
|
+
aria-labelledby="<%= dom_id %>_label"
|
|
69
76
|
data-lato-cms-media-field-target="validationAnchor"
|
|
70
77
|
<%= 'required' if required && current_media.empty? %>>
|
|
71
78
|
</div>
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<video controls preload="<%= media.poster_url ? 'none' : 'metadata' %>" poster="<%= media.poster_url %>"
|
|
17
17
|
class="lato-cms-media-field__player" src="<%= media.url %>"></video>
|
|
18
18
|
<% elsif media.thumbnail_url %>
|
|
19
|
-
<img src="<%= media.thumbnail_url %>" class="lato-cms-media-field__thumb" alt="<%= media.alt_text %>">
|
|
19
|
+
<img src="<%= media.thumbnail_url %>" class="lato-cms-media-field__thumb" alt="<%= media.alt_text.presence || media.name %>">
|
|
20
20
|
<% else %>
|
|
21
21
|
<div class="lato-cms-media-field__icon"><i class="bi bi-paperclip"></i></div>
|
|
22
22
|
<% end %>
|
|
@@ -25,9 +25,20 @@
|
|
|
25
25
|
<span class="lato-cms-media-field__name"><%= media.name %></span>
|
|
26
26
|
</div>
|
|
27
27
|
|
|
28
|
-
<button type="button" class="lato-cms-media-field__remove" title="<%= t('lato_cms.field_file_remove') %>" aria-label="<%= t('lato_cms.field_file_remove') %>" data-action="lato-cms-media-field#remove">
|
|
29
|
-
<i class="bi bi-x-lg"></i>
|
|
28
|
+
<button type="button" class="lato-cms-media-field__remove" title="<%= t('lato_cms.field_file_remove') %>" aria-label="<%= t('lato_cms.field_file_remove') %> <%= media.name %>" data-action="lato-cms-media-field#remove">
|
|
29
|
+
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
|
30
30
|
</button>
|
|
31
|
+
<% if multiple %>
|
|
32
|
+
<%# Keyboard alternative to drag & drop reordering. %>
|
|
33
|
+
<button type="button" class="lato-cms-media-field__move lato-cms-media-field__move--before" title="<%= t('lato_cms.media_move_before') %>"
|
|
34
|
+
aria-label="<%= t('lato_cms.media_move_before') %> <%= media.name %>" data-action="lato-cms-media-reorder#moveBefore">
|
|
35
|
+
<i class="bi bi-arrow-left" aria-hidden="true"></i>
|
|
36
|
+
</button>
|
|
37
|
+
<button type="button" class="lato-cms-media-field__move lato-cms-media-field__move--after" title="<%= t('lato_cms.media_move_after') %>"
|
|
38
|
+
aria-label="<%= t('lato_cms.media_move_after') %> <%= media.name %>" data-action="lato-cms-media-reorder#moveAfter">
|
|
39
|
+
<i class="bi bi-arrow-right" aria-hidden="true"></i>
|
|
40
|
+
</button>
|
|
41
|
+
<% end %>
|
|
31
42
|
|
|
32
43
|
<input type="hidden" name="<%= hidden_name %>" value="<%= media.id %>" data-lato-cms-media-field-target="hiddenInput">
|
|
33
44
|
</div>
|