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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -9
  3. data/app/assets/javascripts/lato_cms/controllers/lato_cms_advanced_editor_controller.js +58 -6
  4. data/app/assets/javascripts/lato_cms/controllers/lato_cms_color_field_controller.js +11 -1
  5. data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_form_controller.js +48 -5
  6. data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_toggle_controller.js +6 -0
  7. data/app/assets/javascripts/lato_cms/controllers/lato_cms_json_field_controller.js +3 -2
  8. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +40 -2
  9. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +6 -5
  10. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +25 -0
  11. data/app/assets/javascripts/lato_cms/controllers/lato_cms_page_preview_controller.js +6 -1
  12. data/app/assets/javascripts/lato_cms/controllers/lato_cms_repeater_controller.js +8 -0
  13. data/app/assets/javascripts/lato_cms/controllers/lato_cms_text_field_controller.js +2 -0
  14. data/app/assets/javascripts/lato_cms/controllers/lato_cms_upload_controller.js +4 -1
  15. data/app/assets/stylesheets/lato_cms/application.scss +57 -0
  16. data/app/controllers/lato_cms/application_controller.rb +23 -1
  17. data/app/controllers/lato_cms/media_controller.rb +4 -2
  18. data/app/controllers/lato_cms/pages_controller.rb +51 -22
  19. data/app/helpers/lato_cms/media_helper.rb +1 -1
  20. data/app/helpers/lato_cms/pages_helper.rb +15 -12
  21. data/app/jobs/lato_cms/export_sitemap_csv_job.rb +8 -1
  22. data/app/models/lato_cms/llm_client.rb +11 -0
  23. data/app/models/lato_cms/media.rb +41 -11
  24. data/app/models/lato_cms/page.rb +45 -7
  25. data/app/models/lato_cms/page_field.rb +64 -4
  26. data/app/models/lato_cms/template_manager.rb +49 -3
  27. data/app/views/lato_cms/media/_form_update.html.erb +9 -7
  28. data/app/views/lato_cms/media/_translations_field.html.erb +8 -5
  29. data/app/views/lato_cms/media/_upload_progress.html.erb +4 -2
  30. data/app/views/lato_cms/media/picker_action.html.erb +21 -14
  31. data/app/views/lato_cms/media/show.html.erb +2 -1
  32. data/app/views/lato_cms/pages/_component_accordion.html.erb +14 -8
  33. data/app/views/lato_cms/pages/_form_update.html.erb +5 -0
  34. data/app/views/lato_cms/pages/_repeater_item.html.erb +6 -4
  35. data/app/views/lato_cms/pages/_translations.html.erb +2 -1
  36. data/app/views/lato_cms/pages/fields/_color.html.erb +14 -6
  37. data/app/views/lato_cms/pages/fields/_json.html.erb +5 -2
  38. data/app/views/lato_cms/pages/fields/_media_field.html.erb +12 -5
  39. data/app/views/lato_cms/pages/fields/_media_item.html.erb +14 -3
  40. data/app/views/lato_cms/pages/fields/_text.html.erb +31 -22
  41. data/app/views/lato_cms/pages/index.html.erb +3 -2
  42. data/app/views/lato_cms/pages/show.html.erb +26 -9
  43. data/config/locales/en.yml +27 -1
  44. data/config/locales/it.yml +27 -1
  45. data/lib/lato_cms/config.rb +26 -1
  46. data/lib/lato_cms/version.rb +1 -1
  47. data/lib/tasks/lato_cms_tasks.rake +18 -11
  48. metadata +1 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 063d286f745ed3fb368105241f2b9cc7f753e153620c63bb9020fea5946a7fad
4
- data.tar.gz: 8e29ca9e75f3a2e1644facb4e534c21f0fe821c7ddcd72d2bb9bc4a0eb4a8921
3
+ metadata.gz: ec7709eaa0439b111b7df2111f38417b25d01a1e338ba32d865d955c5a3d0749
4
+ data.tar.gz: 903e3934c379b277635d4d5aff32dddb584c234f2a1a1bb0a78205482c83ca05
5
5
  SHA512:
6
- metadata.gz: a94293a1a09313278619701879933a56c2312ac39984a529e5c1b057f3d1bbb3ba0e24e62b23bd4cdd71366c0c25ef305d98c95da57b416b5033fe120773ae7d
7
- data.tar.gz: 822724a30d867951066e212a052535899e0f88b9b073667c0b0b8def5f996c86bfccd9c10f28ee6005b1f6747fbd68f713b3bdcc36d2950e4e6ce81563e7f1cb
6
+ metadata.gz: 48170ff3a508c042bd2ce973a6b47880fa6f4b236a1815a0bf790620a4f4c38ad0565837431078780f95eb8b5825aa7de8b0ff93a97c5b5647123e59f6a8c3bb
7
+ data.tar.gz: 8095af1aba57f48994f077dec5d109b259d4e33ea3e8b5f73ae9ee585d440a53d79f4bcc4544d81b93ecadea77401b1a48a70b3537cb80b82f641ddbe7bc4a25
data/README.md CHANGED
@@ -10,27 +10,36 @@ Add required dependencies to your application's Gemfile:
10
10
  ```ruby
11
11
  # Use lato as application panel
12
12
  gem "lato"
13
+ # Lato CMS scopes pages and media by Spaces group
14
+ gem "lato_spaces"
13
15
  gem "lato_cms"
14
16
  ```
15
17
 
16
- Install gem and run required tasks:
18
+ Install gems and run required tasks:
17
19
 
18
20
  ```bash
19
21
  $ bundle
22
+ $ rails lato_spaces:install:application
23
+ $ rails lato_spaces:install:migrations
20
24
  $ rails lato_cms:install:application
21
25
  $ rails lato_cms:install:migrations
22
26
  $ rails db:migrate
23
27
  ```
24
28
 
25
- Mount lato users routes on the **config/routes.rb** file:
29
+ `lato_cms:install:application` writes example templates and components into `config.templates_path`.
30
+
31
+ Mount the engines in **config/routes.rb**:
26
32
 
27
33
  ```ruby
28
34
  Rails.application.routes.draw do
29
- mount LatoCms::Engine => "/lato-users"
35
+ mount LatoSpaces::Engine => "/lato-spaces"
36
+ mount LatoCms::Engine => "/lato-cms"
30
37
  # ....
31
38
  end
32
39
  ```
33
40
 
41
+ Every CMS controller requires a selected Lato Spaces group (`authenticate_group`): pages and media belong to the selected group, so create a group and select it before using the CMS.
42
+
34
43
  Import Lato Scss on **app/assets/stylesheets/application.scss** file:
35
44
  ```scss
36
45
  @import 'lato_cms/application';
@@ -38,18 +47,135 @@ Import Lato Scss on **app/assets/stylesheets/application.scss** file:
38
47
  // ....
39
48
  ```
40
49
 
41
- Import Lato Users Js on **app/javascript/application.js** file:
50
+ Import Lato CMS JavaScript in **app/javascript/application.js**:
42
51
  ```js
43
52
  import "lato_cms/application";
44
53
 
45
54
  // ....
46
55
  ```
47
56
 
57
+ ## Roles
58
+
59
+ Access is controlled by the `lato_cms_admin_role` column of `Lato::User`. Roles come from `config.admin_roles` (default `{ none: 0, operator: 1, admin: 2 }`).
60
+
61
+ - **operator** (1): access the CMS, edit page fields, enable or disable components, clone components (including "Clone & translate" when an LLM is configured), upload media and edit its metadata, "Regenerate with AI", export the sitemap.
62
+ - **admin** (2): everything above, plus create and update page settings (title, permalink, frontend URL, template), delete pages, manage translation links, replace media files, delete media.
63
+
64
+ ```ruby
65
+ Lato::User.find_by(email: "admin@example.com").update!(lato_cms_admin_role: 2)
66
+ ```
67
+
68
+ Expensive operations (sitemap export, clone component, regenerate media text) are limited to 10 requests per user per minute, counted in `Rails.cache` (`LatoCms::ApplicationController::EXPENSIVE_ACTIONS_RATE_LIMIT`). A memory store limits per process; a null store disables the limit.
69
+
70
+ Deleting a media item is refused while it is used. An admin can pass `force=true` to the delete endpoint to delete it anyway, which removes it from every field.
71
+
72
+ ## Configuration
73
+
74
+ ```ruby
75
+ LatoCms.configure do |config|
76
+ config.locales = [:en, :it]
77
+ config.templates_path = "config/lato_cms"
78
+ config.media_url_mode = :proxy
79
+ config.media_max_file_size = 50.megabytes
80
+ end
81
+ ```
82
+
83
+ | Option | Default | Description |
84
+ | --- | --- | --- |
85
+ | `locales` | `[:en]` | Locales available for pages and media alt text/title. |
86
+ | `templates_path` | `"config/lato_cms"` | Templates live in `<path>/templates/*.yml`, components in `<path>/components/*.yml`. |
87
+ | `admin_roles` | `{ none: 0, operator: 1, admin: 2 }` | Role map for `lato_cms_admin_role`. |
88
+ | `media_url_mode` | `:redirect` | `:redirect` or `:proxy` (see Media URLs). |
89
+ | `media_allowed_content_types` | `LatoCms::Config::DEFAULT_MEDIA_ALLOWED_CONTENT_TYPES` | Allowed upload content types; a trailing `*` matches subtypes; `nil` allows everything. Checked server-side on the content type Active Storage identifies. The default covers `image/* video/* audio/*`, PDF, plain text, CSV, zip and office/OpenDocument types. HTML is excluded on purpose. |
90
+ | `media_max_file_size` | `100.megabytes` | Maximum upload size; `nil` means no limit. |
91
+ | `llm_api_url`, `llm_model`, `llm_api_key` | `nil` | OpenAI-compatible endpoint. All three are required to enable LLM features. |
92
+ | `llm_generate_alt_text`, `llm_generate_title` | `true` | Which media attributes the LLM generates. |
93
+ | `llm_alt_text_prompt`, `llm_title_prompt` | `nil` | Custom prompts; placeholders `{languages}` and `{urls}`. |
94
+ | `llm_max_image_size` | `20.megabytes` | Larger images are not sent to the LLM, because the file is read in memory and base64-encoded. |
95
+
96
+ A `llm_api_url` using plain `http` on a non-local host logs a warning, because the API key travels in clear.
97
+
98
+ Uploaded SVG and HTML files are served as attachments thanks to the Active Storage defaults (`content_types_to_serve_as_binary`). Do not change those defaults.
99
+
100
+ ## Templates, components and field types
101
+
102
+ Templates (`<templates_path>/templates/*.yml`) list the components of a page; components (`<templates_path>/components/*.yml`) define fields.
103
+
104
+ Template components accept `component_id`, `name`, `required: true` (cannot be disabled) and `repeater: true` (alias `repeatable`) with `settings.min` / `settings.max` item counts.
105
+
106
+ Field types: `string`, `textarea`, `text`, `number`, `date`, `datetime`, `boolean`, `select`, `multiselect`, `color`, `json`, `file`, `image`, `video`, `gallery`, `custom`.
107
+
108
+ | Type | Supported `settings` | `parsed_value` |
109
+ | --- | --- | --- |
110
+ | `string` | `placeholder`, `maxlength`, `minlength`, `pattern` | String |
111
+ | `textarea` | `rows`, `placeholder`, `maxlength` | String |
112
+ | `text` | none | Sanitized HTML string |
113
+ | `number` | `min`, `max`, `step`, `placeholder` | Integer or Float |
114
+ | `date`, `datetime` | `min`, `max` | `Date`, `DateTime` |
115
+ | `boolean` | none | `true` / `false` |
116
+ | `select`, `multiselect` | `options`: strings or `{ value:, label: }` | String, Array |
117
+ | `color` | none | String (`#rgb` or `#rrggbb`) |
118
+ | `json` | `rows`, `placeholder` | Parsed object |
119
+ | `file` | `multiple` | Media attachments |
120
+ | `image`, `gallery` | `sizes`: named variants with `width`, `height`, `resize` (`limit`, `fit`, `fill`) | Media attachments |
121
+ | `video` | none | Media attachment with poster |
122
+ | `custom` | any, read by your partial; requires `render` | String |
123
+
124
+ Server-side validation: `required` is enforced for scalar fields (blank values are refused), select/multiselect values must be among the options, numbers must be numeric, colors must be `#rgb` or `#rrggbb`, dates and datetimes must parse. An untouched color field is saved empty, and non-required colors can be cleared. Field types are resolved from the template and component YAML on the server (the component id sent by the client is ignored); saving a template component or field that is not in the page template is refused with a 422. Changing the template of a page hides the fields saved under the old one.
125
+
126
+ Rich text (`text`) HTML is sanitized on save and on output with an allowlist (`LatoCms::PageField::RICH_TEXT_TAGS` and `RICH_TEXT_ATTRIBUTES`; `style` is reduced to `text-align`).
127
+
128
+ ```yaml
129
+ # config/lato_cms/components/hero.yml
130
+ id: hero
131
+ name: Hero
132
+ fields:
133
+ title:
134
+ name: Title
135
+ type: string
136
+ required: true
137
+ cover:
138
+ name: Cover
139
+ type: image
140
+ settings:
141
+ sizes:
142
+ thumb: { width: 150, height: 150, resize: fill }
143
+ ```
144
+
145
+ ### Generators
146
+
147
+ ```bash
148
+ rails "lato_cms:generate:template[blog_post]"
149
+ rails "lato_cms:generate:component[hero,title:string,cover:image,gallery:gallery]"
150
+ rails "lato_cms:generate:custom_field[icon_note]"
151
+ ```
152
+
153
+ The component generator aborts on an unsupported type. It accepts every built-in type, but not `custom`, which needs a `render` path. The custom field generator creates `app/views/lato_cms/custom_fields/_icon_note.html.erb`; use it with `type: custom` and `render: lato_cms/custom_fields/icon_note`. Custom partials receive `field_id`, `field_config`, `page_field`, `input_name_prefix`, `dom_id_prefix` and `persisted_field_id`, and must build input names and ids with `lato_cms_field_input_name` and `lato_cms_field_dom_id` so they work inside repeaters. See the user documentation in the dummy app for a full example.
154
+
155
+ ## LLM features
156
+
157
+ With an LLM configured, Lato CMS generates image alt text and titles (after upload and with "Regenerate with AI") and offers "Clone & translate" for component text fields (`string`, `textarea`, `text`).
158
+
159
+ Instructions are sent as the system message and editor content as delimited data (`<data>...</data>`) in the user message. Generated media texts are capped at 500 characters, and translated `text` fields go through the rich text sanitizer. An LLM can still be steered by the content it reads (for example text inside an image), so review generated alt text, titles and translations, and enable LLM features only with trusted editors.
160
+
48
161
  ## Content security
49
162
 
50
163
  Rich text fields preserve common formatting, links, and images. HTML is sanitized on save and when rendered or returned through `parsed_value` and JSON, including existing content and AI translations. Scripts, event handlers, unsafe URL protocols, and unsupported elements or attributes are removed. Applications should consume `parsed_value` rather than render the raw database `value` as HTML.
51
164
 
52
- The read-only JSON API under `/api/pages` is public and requires no login. Its `group_id` parameter selects content; it is not an authorization token. Pages, translations, fields, and attachment URLs returned by this API must be suitable for public access. Applications with private content must restrict access to these endpoints in the host application or reverse proxy.
165
+ ## Public API
166
+
167
+ ```
168
+ GET /lato-cms/api/pages?group_id=ID&locale=xx # list, without fields
169
+ GET /lato-cms/api/pages/:id_or_permalink?group_id=ID # with fields
170
+ ```
171
+
172
+ A missing `group_id` returns 400, an unknown page 404. A page contains `id`, `title`, `permalink`, `locale`, `template_id`, `template_name`, `frontend_url` (only http/https values, otherwise `null`), `translations` (`locale => { id, permalink, frontend_url }`), `created_at`, `updated_at` and, on show, `fields`. `fields` is keyed by template component id with `{ component_id, name, fields }`; only enabled components are included. For a regular component `fields` is a hash of `field_id => { id, field_id, field_type, field_name, required, value, attachments }`; for a repeater it is an array of `{ id, fields }`. Attachments expose `media_id`, `name`, `alt_text`, `title`, `filename`, `content_type`, `byte_size`, `url`; images and galleries add `sizes`, videos add `poster_url`.
173
+
174
+ **Exposure warning.** The API is public and unauthenticated, keyed by sequential group ids, and has no draft or published state. Every page and media URL of every group is readable by anyone who can reach the endpoint, and signed blob URLs in redirect mode do not expire. Protect it at the host level (do not expose the route publicly, add a reverse proxy rule, or use Rack middleware and rate limiting) until per-group tokens and a publish state exist.
175
+
176
+ ## Sitemap export
177
+
178
+ Pages index, actions menu, "Export sitemap (CSV)" runs a `Lato::Operation` that produces a CSV with the columns `locale,title,permalink,frontend_url` for all pages of the current group. Cells starting with `=`, `+`, `-`, `@`, tab or CR are prefixed with an apostrophe to prevent formula injection.
53
179
 
54
180
  ## Media URLs
55
181
 
@@ -89,14 +215,16 @@ the default for a video would otherwise turn opening its address into a download
89
215
  Clone repository, install dependencies, run migrations and start:
90
216
 
91
217
  ```shell
92
- $ git clone https://github.com/Lato-GAM/lato_cms
218
+ $ git clone https://github.com/Lato-org/lato_cms
93
219
  $ cd lato_cms
94
220
  $ bundle
95
- $ rails db:migrate
96
- $ rails db:seed
97
- $ foreman start -f Procfile.dev
221
+ $ bin/rails db:migrate
222
+ $ bin/rails db:seed
223
+ $ bin/rails server
98
224
  ```
99
225
 
226
+ `bin/rails` runs the dummy app in `test/dummy`.
227
+
100
228
  ## Publish
101
229
 
102
230
  ```shell
@@ -1,28 +1,80 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ const COMPONENT_FORM = 'form[data-controller~="lato-cms-component-form"]'
4
+
3
5
  export default class extends Controller {
4
6
  static targets = ["editorCol", "reopenBtn"]
5
- static values = { listPath: String }
7
+ static values = { listPath: String, unsavedMessage: String }
6
8
 
7
9
  isSidebarOpen = true
8
10
 
9
11
  // ─── Advanced full-screen editor is the only view ────────────────────────────
10
12
  // The full-screen layout is rendered server-side (--active class + body CSS) so
11
- // it survives re-renders (e.g. component clone) with no flash. JS only wires up
12
- // Escape-to-back and the sidebar toggle.
13
+ // it survives re-renders (e.g. component clone) with no flash. JS wires up
14
+ // Escape-to-back, the unsaved-changes guard and the sidebar toggle.
13
15
 
14
16
  connect() {
15
- // Escape returns to the pages list instead of exiting to a base view.
16
- this._escHandler = (e) => { if (e.key === 'Escape') this.back() }
17
+ // Each component is its own form: the ones edited since their last save.
18
+ this.dirtyForms = new Set()
19
+
20
+ this._escHandler = (e) => this.onKeydown(e)
21
+ this._changeHandler = (e) => this.markDirty(e)
22
+ this._savedHandler = (e) => this.dirtyForms.delete(e.target.closest(COMPONENT_FORM))
23
+ this._beforeUnloadHandler = (e) => {
24
+ if (!this.hasUnsavedChanges()) return
25
+ e.preventDefault()
26
+ e.returnValue = ''
27
+ }
28
+ this._beforeVisitHandler = (e) => {
29
+ if (this.hasUnsavedChanges() && !window.confirm(this.unsavedMessageValue)) e.preventDefault()
30
+ }
31
+
17
32
  document.addEventListener('keydown', this._escHandler)
33
+ this.element.addEventListener('input', this._changeHandler)
34
+ this.element.addEventListener('change', this._changeHandler)
35
+ this.element.addEventListener('lato-cms:fields-save-success', this._savedHandler)
36
+ window.addEventListener('beforeunload', this._beforeUnloadHandler)
37
+ document.addEventListener('turbo:before-visit', this._beforeVisitHandler)
18
38
  }
19
39
 
20
40
  disconnect() {
21
41
  document.removeEventListener('keydown', this._escHandler)
42
+ this.element.removeEventListener('input', this._changeHandler)
43
+ this.element.removeEventListener('change', this._changeHandler)
44
+ this.element.removeEventListener('lato-cms:fields-save-success', this._savedHandler)
45
+ window.removeEventListener('beforeunload', this._beforeUnloadHandler)
46
+ document.removeEventListener('turbo:before-visit', this._beforeVisitHandler)
47
+ }
48
+
49
+ markDirty(event) {
50
+ const form = event.target.closest?.(COMPONENT_FORM)
51
+ if (form) this.dirtyForms.add(form)
52
+ }
53
+
54
+ hasUnsavedChanges() {
55
+ return this.dirtyForms.size > 0
56
+ }
57
+
58
+ // Escape goes back to the list only when nothing else owns the key: an
59
+ // open modal, dropdown or tooltip closes itself on Escape, and Escape while
60
+ // typing must not navigate away.
61
+ onKeydown(e) {
62
+ if (e.key !== 'Escape' || e.defaultPrevented) return
63
+ if (document.querySelector('.modal.show, .dropdown-menu.show')) return
64
+
65
+ const active = document.activeElement
66
+ if (active && (active.isContentEditable || active.matches('input, textarea, select'))) return
67
+
68
+ this.back()
22
69
  }
23
70
 
24
71
  back() {
25
- if (this.hasListPathValue) window.location.href = this.listPathValue
72
+ if (!this.hasListPathValue) return
73
+ if (this.hasUnsavedChanges() && !window.confirm(this.unsavedMessageValue)) return
74
+
75
+ // Already confirmed: skip the beforeunload prompt.
76
+ this.dirtyForms.clear()
77
+ window.location.href = this.listPathValue
26
78
  }
27
79
 
28
80
  // ─── Toggle sidebar within advanced mode ─────────────────────────────────────
@@ -1,9 +1,19 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // The hidden `value` target is what the form submits; the native color input
4
+ // is only the picker, since it can never be empty.
3
5
  export default class extends Controller {
4
- static targets = ["input", "hex"]
6
+ static targets = ["input", "value", "hex"]
7
+ static values = { emptyLabel: String }
5
8
 
6
9
  update() {
10
+ this.valueTarget.value = this.inputTarget.value
7
11
  this.hexTarget.textContent = this.inputTarget.value.toUpperCase()
8
12
  }
13
+
14
+ clear() {
15
+ this.valueTarget.value = ""
16
+ this.hexTarget.textContent = this.emptyLabelValue
17
+ this.element.dispatchEvent(new Event("change", { bubbles: true }))
18
+ }
9
19
  }
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static targets = ["submitBtn", "status"]
5
+ static values = { savingLabel: String, savedLabel: String, failedLabel: String, errorLabel: String }
5
6
 
6
7
  connect() {
7
8
  this.element.addEventListener("submit", this.handleSubmit.bind(this))
@@ -15,7 +16,9 @@ export default class extends Controller {
15
16
  const originalHTML = submitBtn.innerHTML
16
17
 
17
18
  submitBtn.disabled = true
18
- submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...'
19
+ submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>'
20
+ submitBtn.append(document.createTextNode(this.savingLabelValue))
21
+ this.clearFieldErrors()
19
22
 
20
23
  try {
21
24
  const formData = new FormData(form)
@@ -31,24 +34,63 @@ export default class extends Controller {
31
34
  const data = await response.json()
32
35
 
33
36
  if (response.ok) {
34
- this.showStatus('Fields saved successfully', 'success')
37
+ this.showStatus(data.message || this.savedLabelValue, 'success')
35
38
  form.dispatchEvent(new CustomEvent('lato-cms:fields-save-success', {
36
39
  bubbles: true,
37
40
  detail: { success: true, data }
38
41
  }))
39
42
  this.refreshPreview()
40
43
  } else {
41
- const errorMsg = data.errors?.map(e => `${e.field_id}: ${e.errors.join(', ')}`).join('; ') || 'Failed to save fields'
44
+ const errors = data.errors || []
45
+ const errorMsg = errors.map(e => `${e.field_name || e.field_id}: ${e.errors.join(', ')}`).join('; ') || data.error || this.failedLabelValue
42
46
  this.showStatus(errorMsg, 'danger')
47
+ this.showFieldErrors(errors)
43
48
  }
44
49
  } catch (error) {
45
- this.showStatus('An error occurred while saving', 'danger')
50
+ this.showStatus(this.errorLabelValue, 'danger')
46
51
  } finally {
47
52
  submitBtn.disabled = false
48
53
  submitBtn.innerHTML = originalHTML
49
54
  }
50
55
  }
51
56
 
57
+ // Marks each failing field (its wrapper carries the persisted field id)
58
+ // invalid, links the message to its inputs via aria-describedby and moves
59
+ // focus to the first one, so the error is not only a line at the bottom.
60
+ showFieldErrors(errors) {
61
+ const wrappers = Array.from(this.element.querySelectorAll('[data-lato-cms-persisted-field-id]'))
62
+ let firstInput = null
63
+
64
+ errors.forEach((error, index) => {
65
+ const wrapper = wrappers.find(element => element.dataset.latoCmsPersistedFieldId === error.field_id)
66
+ if (!wrapper) return
67
+
68
+ const feedback = document.createElement('div')
69
+ feedback.id = `lato-cms-field-error-${Date.now()}-${index}`
70
+ feedback.className = 'invalid-feedback d-block lato-cms-field-error'
71
+ feedback.textContent = error.errors.join(', ')
72
+ wrapper.append(feedback)
73
+
74
+ wrapper.querySelectorAll('input:not([type="hidden"]), select, textarea, [contenteditable="true"]').forEach(input => {
75
+ input.classList.add('is-invalid')
76
+ input.setAttribute('aria-invalid', 'true')
77
+ input.setAttribute('aria-describedby', feedback.id)
78
+ firstInput ||= input
79
+ })
80
+ })
81
+
82
+ firstInput?.focus()
83
+ }
84
+
85
+ clearFieldErrors() {
86
+ this.element.querySelectorAll('.lato-cms-field-error').forEach(element => element.remove())
87
+ this.element.querySelectorAll('[aria-invalid="true"]').forEach(input => {
88
+ input.classList.remove('is-invalid')
89
+ input.removeAttribute('aria-invalid')
90
+ input.removeAttribute('aria-describedby')
91
+ })
92
+ }
93
+
52
94
  refreshPreview() {
53
95
  const iframe = document.querySelector('[data-lato-cms-page-preview-target="iframe"]')
54
96
  if (iframe && iframe.src) {
@@ -89,10 +131,11 @@ export default class extends Controller {
89
131
 
90
132
  this.statusTarget.innerHTML = `
91
133
  <span class="d-inline-flex align-items-center gap-1 small ${color} lato-cms-component-form__status">
92
- <i class="bi ${icon}"></i>
134
+ <i class="bi ${icon}" aria-hidden="true"></i>
93
135
  </span>`
94
136
  this.statusTarget.firstElementChild.append(document.createTextNode(message))
95
137
 
138
+ // Only success fades out: an error stays until the next save.
96
139
  if (isSuccess) {
97
140
  this._statusTimeout = setTimeout(() => {
98
141
  const msg = this.statusTarget.querySelector('.lato-cms-component-form__status')
@@ -10,6 +10,12 @@ export default class extends Controller {
10
10
  errorLabel: String
11
11
  }
12
12
 
13
+ // Checkbox change -> submit, handled by toggle below (no inline onchange,
14
+ // so the page works under a strict CSP).
15
+ submit (event) {
16
+ event.target.form?.requestSubmit()
17
+ }
18
+
13
19
  async toggle (event) {
14
20
  event.preventDefault()
15
21
 
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static targets = ["input", "status"]
5
+ static values = { validLabel: String, invalidLabel: String }
5
6
 
6
7
  connect() {
7
8
  this.validate()
@@ -17,10 +18,10 @@ export default class extends Controller {
17
18
 
18
19
  try {
19
20
  JSON.parse(value)
20
- this.statusTarget.textContent = "Valid JSON"
21
+ this.statusTarget.textContent = this.validLabelValue
21
22
  this.statusTarget.className = "form-text text-success"
22
23
  } catch (e) {
23
- this.statusTarget.textContent = "Invalid JSON: " + e.message
24
+ this.statusTarget.textContent = this.invalidLabelValue.replace("%{error}", e.message)
24
25
  this.statusTarget.className = "form-text text-danger"
25
26
  }
26
27
  }
@@ -8,7 +8,16 @@ import { Controller } from '@hotwired/stimulus'
8
8
  // event correlated by this field's own turbo-frame id.
9
9
  export default class extends Controller {
10
10
  static targets = ['grid', 'addTile', 'validationAnchor']
11
- static values = { kind: String, multiple: Boolean, frameId: String, hiddenName: String, fieldId: String }
11
+ static values = {
12
+ kind: String,
13
+ multiple: Boolean,
14
+ frameId: String,
15
+ hiddenName: String,
16
+ fieldId: String,
17
+ removeLabel: String,
18
+ moveBeforeLabel: String,
19
+ moveAfterLabel: String
20
+ }
12
21
 
13
22
  connect () {
14
23
  this.onMediaSelected = this.onMediaSelected.bind(this)
@@ -65,11 +74,19 @@ export default class extends Controller {
65
74
  event.detail.items.forEach(item => this.appendItem(item))
66
75
 
67
76
  this.syncEmptyAndValidation()
77
+ this.notifyChange()
68
78
  }
69
79
 
70
80
  remove (event) {
71
81
  event.currentTarget.closest('.lato-cms-media-field__item')?.remove()
72
82
  this.syncEmptyAndValidation()
83
+ this.notifyChange()
84
+ }
85
+
86
+ // The selection lives in hidden inputs, which fire no input event: tell the
87
+ // editor's unsaved-changes guard explicitly.
88
+ notifyChange () {
89
+ this.element.dispatchEvent(new Event('change', { bubbles: true }))
73
90
  }
74
91
 
75
92
  afterSave (event) {
@@ -114,11 +131,20 @@ export default class extends Controller {
114
131
  <span class="lato-cms-media-field__name"></span>
115
132
  </div>
116
133
  <button type="button" class="lato-cms-media-field__remove" data-action="lato-cms-media-field#remove">
117
- <i class="bi bi-x-lg"></i>
134
+ <i class="bi bi-x-lg" aria-hidden="true"></i>
118
135
  </button>
119
136
  <input type="hidden">`
120
137
  wrapper.prepend(this.previewElement(item))
121
138
  wrapper.querySelector('.lato-cms-media-field__name').textContent = item.name
139
+ const removeButton = wrapper.querySelector('.lato-cms-media-field__remove')
140
+ removeButton.title = this.removeLabelValue
141
+ removeButton.setAttribute('aria-label', `${this.removeLabelValue} ${item.name}`)
142
+ if (this.multipleValue) {
143
+ removeButton.after(
144
+ this.moveButton('before', this.moveBeforeLabelValue, item.name),
145
+ this.moveButton('after', this.moveAfterLabelValue, item.name)
146
+ )
147
+ }
122
148
  const input = wrapper.querySelector('input')
123
149
  input.name = this.hiddenNameValue
124
150
  input.value = item.id
@@ -144,6 +170,7 @@ export default class extends Controller {
144
170
  const image = document.createElement('img')
145
171
  image.src = item.thumbnailUrl || item.url
146
172
  image.className = 'lato-cms-media-field__thumb'
173
+ image.alt = item.name || ''
147
174
  return image
148
175
  }
149
176
  const icon = document.createElement('div')
@@ -152,6 +179,17 @@ export default class extends Controller {
152
179
  return icon
153
180
  }
154
181
 
182
+ moveButton (direction, label, name) {
183
+ const button = document.createElement('button')
184
+ button.type = 'button'
185
+ button.className = `lato-cms-media-field__move lato-cms-media-field__move--${direction}`
186
+ button.title = label
187
+ button.setAttribute('aria-label', `${label} ${name}`)
188
+ button.dataset.action = `lato-cms-media-reorder#move${direction === 'before' ? 'Before' : 'After'}`
189
+ button.innerHTML = `<i class="bi bi-arrow-${direction === 'before' ? 'left' : 'right'}" aria-hidden="true"></i>`
190
+ return button
191
+ }
192
+
155
193
  itemTiles () {
156
194
  return Array.from(this.gridTarget.querySelectorAll('.lato-cms-media-field__item'))
157
195
  }
@@ -23,13 +23,14 @@ export default class extends Controller {
23
23
  return
24
24
  }
25
25
 
26
- if (this.pending.has(item.id)) {
27
- this.pending.delete(item.id)
28
- event.currentTarget.classList.remove('lato-cms-media-picker__item--selected')
29
- } else {
26
+ const selected = !this.pending.has(item.id)
27
+ if (selected) {
30
28
  this.pending.set(item.id, item)
31
- event.currentTarget.classList.add('lato-cms-media-picker__item--selected')
29
+ } else {
30
+ this.pending.delete(item.id)
32
31
  }
32
+ event.currentTarget.classList.toggle('lato-cms-media-picker__item--selected', selected)
33
+ event.currentTarget.setAttribute('aria-pressed', selected.toString())
33
34
 
34
35
  this.updateSelectionUi()
35
36
  }
@@ -34,4 +34,29 @@ export default class extends Controller {
34
34
  onGridDrop (event) {
35
35
  event.preventDefault()
36
36
  }
37
+
38
+ // Keyboard equivalents of a drag: swap with the previous/next tile and keep
39
+ // focus on the pressed button so it can be pressed again.
40
+ moveBefore (event) {
41
+ const item = event.currentTarget.closest('.lato-cms-media-field__item')
42
+ const previous = item?.previousElementSibling
43
+ if (!previous?.classList.contains('lato-cms-media-field__item')) return
44
+
45
+ this.gridTarget.insertBefore(item, previous)
46
+ this.afterMove(event.currentTarget)
47
+ }
48
+
49
+ moveAfter (event) {
50
+ const item = event.currentTarget.closest('.lato-cms-media-field__item')
51
+ const next = item?.nextElementSibling
52
+ if (!next?.classList.contains('lato-cms-media-field__item')) return
53
+
54
+ this.gridTarget.insertBefore(next, item)
55
+ this.afterMove(event.currentTarget)
56
+ }
57
+
58
+ afterMove (button) {
59
+ button.focus()
60
+ this.element.dispatchEvent(new Event('change', { bubbles: true }))
61
+ }
37
62
  }
@@ -72,6 +72,7 @@ export default class extends Controller {
72
72
 
73
73
  this.deviceBtnTargets.forEach(btn => {
74
74
  btn.classList.toggle('active', btn.dataset.device === device)
75
+ btn.setAttribute('aria-pressed', (btn.dataset.device === device).toString())
75
76
  })
76
77
 
77
78
  if (this.hasCustomWidthTarget) this.customWidthTarget.value = ''
@@ -83,7 +84,10 @@ export default class extends Controller {
83
84
 
84
85
  const width = parseInt(this.customWidthTarget.value, 10)
85
86
 
86
- this.deviceBtnTargets.forEach(btn => btn.classList.remove('active'))
87
+ this.deviceBtnTargets.forEach(btn => {
88
+ btn.classList.remove('active')
89
+ btn.setAttribute('aria-pressed', 'false')
90
+ })
87
91
  this.viewportTarget.classList.remove(
88
92
  'lato-cms-page-preview__viewport--tablet',
89
93
  'lato-cms-page-preview__viewport--mobile'
@@ -98,6 +102,7 @@ export default class extends Controller {
98
102
  this.iframeTarget.style.maxWidth = ''
99
103
  this.deviceBtnTargets.forEach(btn => {
100
104
  btn.classList.toggle('active', btn.dataset.device === 'desktop')
105
+ btn.setAttribute('aria-pressed', (btn.dataset.device === 'desktop').toString())
101
106
  })
102
107
  }
103
108
  }
@@ -59,6 +59,7 @@ export default class extends Controller {
59
59
  // The new item is rendered expanded: enforce single-open accordion behavior.
60
60
  this.collapseOthers(this.itemTargets[this.itemTargets.length - 1])
61
61
  this.refresh()
62
+ this.notifyChange()
62
63
  }
63
64
 
64
65
  remove (event) {
@@ -70,6 +71,13 @@ export default class extends Controller {
70
71
 
71
72
  item.remove()
72
73
  this.refresh()
74
+ this.notifyChange()
75
+ }
76
+
77
+ // Lets the editor's unsaved-changes guard see structural edits, which fire
78
+ // no input event of their own.
79
+ notifyChange () {
80
+ this.element.dispatchEvent(new Event('change', { bubbles: true }))
73
81
  }
74
82
 
75
83
  refresh () {
@@ -88,6 +88,7 @@ export default class extends Controller {
88
88
  this.sourceTarget.classList.remove("d-none")
89
89
  this.sourceTarget.style.height = this.editorTarget.offsetHeight + "px"
90
90
  this.sourceBtnTarget.classList.replace("btn-outline-secondary", "btn-secondary")
91
+ this.sourceBtnTarget.setAttribute("aria-pressed", "true")
91
92
  } else {
92
93
  // Apply edited source back to the editor
93
94
  this.editorTarget.innerHTML = this.sanitize(this.sourceTarget.value)
@@ -95,6 +96,7 @@ export default class extends Controller {
95
96
  this.sourceTarget.classList.add("d-none")
96
97
  this.editorTarget.classList.remove("d-none")
97
98
  this.sourceBtnTarget.classList.replace("btn-secondary", "btn-outline-secondary")
99
+ this.sourceBtnTarget.setAttribute("aria-pressed", "false")
98
100
  }
99
101
  }
100
102