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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec7709eaa0439b111b7df2111f38417b25d01a1e338ba32d865d955c5a3d0749
|
|
4
|
+
data.tar.gz: 903e3934c379b277635d4d5aff32dddb584c234f2a1a1bb0a78205482c83ca05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
-
$
|
|
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
|
|
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
|
-
//
|
|
16
|
-
this.
|
|
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)
|
|
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>
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
21
|
+
this.statusTarget.textContent = this.validLabelValue
|
|
21
22
|
this.statusTarget.className = "form-text text-success"
|
|
22
23
|
} catch (e) {
|
|
23
|
-
this.statusTarget.textContent = "
|
|
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 = {
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
|