lato_cms 3.0.13 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +143 -0
  3. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +110 -0
  4. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +37 -0
  5. data/app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js +44 -0
  6. data/app/assets/stylesheets/lato_cms/application.scss +131 -158
  7. data/app/controllers/lato_cms/application_controller.rb +4 -0
  8. data/app/controllers/lato_cms/media_controller.rb +121 -0
  9. data/app/controllers/lato_cms/pages_controller.rb +75 -91
  10. data/app/helpers/lato_cms/media_helper.rb +50 -0
  11. data/app/jobs/lato_cms/export_sitemap_csv_job.rb +33 -0
  12. data/app/jobs/lato_cms/generate_alt_text_job.rb +21 -0
  13. data/app/jobs/lato_cms/generate_video_poster_job.rb +7 -5
  14. data/app/jobs/lato_cms/translate_component_fields_job.rb +16 -0
  15. data/app/models/lato_cms/llm_client.rb +31 -0
  16. data/app/models/lato_cms/media.rb +270 -0
  17. data/app/models/lato_cms/page.rb +40 -0
  18. data/app/models/lato_cms/page_field.rb +52 -80
  19. data/app/models/lato_cms/page_field_media.rb +8 -0
  20. data/app/views/lato_cms/media/_form_create.html.erb +20 -0
  21. data/app/views/lato_cms/media/_form_update.html.erb +60 -0
  22. data/app/views/lato_cms/media/create.html.erb +13 -0
  23. data/app/views/lato_cms/media/index.html.erb +22 -0
  24. data/app/views/lato_cms/media/picker_action.html.erb +98 -0
  25. data/app/views/lato_cms/media/update.html.erb +13 -0
  26. data/app/views/lato_cms/pages/_component_accordion.html.erb +15 -0
  27. data/app/views/lato_cms/pages/fields/_file.html.erb +4 -55
  28. data/app/views/lato_cms/pages/fields/_gallery.html.erb +4 -60
  29. data/app/views/lato_cms/pages/fields/_image.html.erb +4 -68
  30. data/app/views/lato_cms/pages/fields/_media_field.html.erb +62 -0
  31. data/app/views/lato_cms/pages/fields/_media_item.html.erb +27 -0
  32. data/app/views/lato_cms/pages/fields/_video.html.erb +4 -69
  33. data/app/views/lato_cms/pages/index.html.erb +17 -0
  34. data/app/views/lato_cms/pages/show.html.erb +2 -1
  35. data/config/locales/en.yml +36 -12
  36. data/config/locales/it.yml +36 -12
  37. data/config/routes.rb +12 -0
  38. data/db/migrate/20260728175413_create_lato_cms_media.rb +11 -0
  39. data/db/migrate/20260728175414_create_lato_cms_page_field_media.rb +12 -0
  40. data/db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb +25 -0
  41. data/lib/lato_cms/config.rb +12 -1
  42. data/lib/lato_cms/version.rb +1 -1
  43. metadata +38 -5
  44. data/app/assets/javascripts/lato_cms/controllers/lato_cms_file_field_controller.js +0 -225
  45. data/app/assets/javascripts/lato_cms/controllers/lato_cms_gallery_field_controller.js +0 -190
  46. data/app/assets/javascripts/lato_cms/controllers/lato_cms_image_field_controller.js +0 -169
  47. data/app/assets/javascripts/lato_cms/controllers/lato_cms_video_field_controller.js +0 -180
@@ -153,7 +153,8 @@ body.controller-pages.action-show {
153
153
  opacity: 0;
154
154
  }
155
155
 
156
- .controller-pages {
156
+ .controller-pages,
157
+ .controller-media {
157
158
  .lato-index-desk-col-label-actions {
158
159
  width: 1px;
159
160
 
@@ -192,214 +193,186 @@ body.controller-pages.action-show {
192
193
  }
193
194
  }
194
195
 
195
- // ── File field ─────────────────────────────────────────────────────────────────
196
- .lato-cms-file-field__item {
197
- align-items: center;
198
- background: #fff;
199
- border: 1px solid rgba(33, 37, 41, 0.12);
200
- border-radius: 8px;
201
- box-shadow: 0 1px 2px rgba(33, 37, 41, 0.04);
196
+ // ── Media field (image/video/file/gallery, picked from the Media library) ──────
197
+ // Square tiles, preview-only: name and remove button only reveal on hover, so
198
+ // a filled field reads as a clean grid of thumbnails, not a form-y file list.
199
+ .lato-cms-media-field__grid {
202
200
  display: flex;
203
- gap: 0.75rem;
204
- justify-content: space-between;
205
- margin-bottom: 0.6rem;
206
- min-height: 56px;
207
- padding: 0.65rem 0.75rem;
208
- transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
201
+ flex-wrap: wrap;
202
+ gap: 10px;
203
+ min-height: 24px;
204
+ }
205
+
206
+ .lato-cms-media-field__tile {
207
+ border-radius: 10px;
208
+ flex: 0 0 auto;
209
+ height: 110px;
210
+ overflow: hidden;
211
+ position: relative;
212
+ width: 110px;
213
+ }
214
+
215
+ .lato-cms-media-field__item {
216
+ background: var(--bs-light);
217
+ border: 1px solid rgba(33, 37, 41, 0.1);
218
+ transition: box-shadow 0.15s;
209
219
 
210
220
  &:hover {
211
- border-color: rgba(13, 110, 253, 0.25);
212
- box-shadow: 0 3px 10px rgba(33, 37, 41, 0.07);
221
+ box-shadow: 0 3px 10px rgba(33, 37, 41, 0.15);
213
222
  }
214
- }
215
223
 
216
- .lato-cms-file-field__info {
217
- align-items: center;
218
- display: flex;
219
- flex-wrap: wrap;
220
- gap: 0.35rem;
221
- min-width: 0;
222
- flex: 1;
224
+ &[draggable="true"] {
225
+ cursor: grab;
223
226
 
224
- > span:first-of-type {
225
- overflow-wrap: anywhere;
227
+ &:active {
228
+ cursor: grabbing;
229
+ }
226
230
  }
227
231
 
228
- > .bi {
229
- color: var(--bs-secondary);
230
- font-size: 1rem;
232
+ &--dragging {
233
+ opacity: 0.35;
234
+ box-shadow: 0 0 0 2px var(--bs-primary);
231
235
  }
232
236
  }
233
237
 
234
- .lato-cms-file-field__removed {
235
- align-items: center;
236
- display: flex;
237
- gap: 0.75rem;
238
- justify-content: space-between;
238
+ .lato-cms-media-field__thumb,
239
+ .lato-cms-media-field__player {
240
+ display: block;
241
+ height: 100%;
242
+ object-fit: cover;
239
243
  width: 100%;
240
244
  }
241
245
 
242
- .lato-cms-attachment-field__remove {
246
+ .lato-cms-media-field__icon {
243
247
  align-items: center;
244
- align-self: center;
245
- background: #f8d7da;
246
- border: 0;
247
- border-radius: 50%;
248
- color: var(--bs-danger);
249
- display: inline-flex;
250
- flex: 0 0 auto;
251
- height: 32px;
248
+ color: var(--bs-secondary);
249
+ display: flex;
250
+ font-size: 2rem;
251
+ height: 100%;
252
252
  justify-content: center;
253
- padding: 0;
254
- transition: background 0.15s, color 0.15s;
255
- width: 32px;
256
-
257
- &:hover,
258
- &:focus {
259
- background: #f1aeb5;
260
- color: var(--bs-danger);
261
- }
262
- }
263
-
264
- .lato-cms-file-field__item--removing {
265
- background: rgba(220, 53, 69, 0.06);
266
- border-color: rgba(220, 53, 69, 0.35);
267
- box-shadow: none;
268
-
269
- .lato-cms-file-field__info,
270
- .lato-cms-image-field__preview,
271
- .lato-cms-video-field__preview,
272
- .lato-cms-attachment-field__remove,
273
- > .btn {
274
- display: none;
275
- }
253
+ width: 100%;
276
254
  }
277
255
 
278
- .lato-cms-file-field__item--new {
279
- background: rgba(25, 135, 84, 0.04);
280
- border-color: rgba(25, 135, 84, 0.45);
281
-
282
- .badge {
283
- font-weight: 500;
284
- }
256
+ .lato-cms-media-field__badge {
257
+ bottom: 4px;
258
+ color: #fff;
259
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
260
+ font-size: 0.9rem;
261
+ pointer-events: none;
262
+ position: absolute;
263
+ right: 4px;
285
264
  }
286
265
 
287
- // ── Image field ────────────────────────────────────────────────────────────────
288
- .lato-cms-image-field__item {
289
- align-items: stretch;
266
+ // Name overlay: hidden until the tile is hovered.
267
+ .lato-cms-media-field__overlay {
268
+ bottom: 0;
269
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0) 65%);
270
+ left: 0;
271
+ opacity: 0;
272
+ padding: 0.4rem;
273
+ pointer-events: none;
274
+ position: absolute;
275
+ right: 0;
276
+ transition: opacity 0.15s;
290
277
  }
291
278
 
292
- .lato-cms-image-field__preview {
293
- align-items: center;
294
- align-self: center;
295
- background: var(--bs-light);
296
- border: 1px solid rgba(33, 37, 41, 0.08);
297
- border-radius: 6px;
298
- display: flex;
299
- flex: 0 0 auto;
300
- height: 64px;
301
- justify-content: center;
302
- overflow: hidden;
303
- width: 64px;
279
+ .lato-cms-media-field__item:hover .lato-cms-media-field__overlay {
280
+ opacity: 1;
304
281
  }
305
282
 
306
- .lato-cms-image-field__thumb {
307
- width: 100%;
308
- height: 100%;
309
- object-fit: cover;
283
+ .lato-cms-media-field__name {
284
+ color: #fff;
310
285
  display: block;
311
- transition: opacity 0.15s, filter 0.15s;
286
+ font-size: 0.7rem;
287
+ line-height: 1.2;
288
+ overflow: hidden;
289
+ text-overflow: ellipsis;
290
+ white-space: nowrap;
312
291
  }
313
292
 
314
- .lato-cms-image-field__remove-btn {
315
- position: absolute;
316
- top: 4px;
317
- right: 4px;
318
- width: 22px;
319
- height: 22px;
320
- border-radius: 50%;
293
+ // Remove button: only clickable once revealed, so it can't be hit while invisible.
294
+ .lato-cms-media-field__remove {
295
+ align-items: center;
296
+ background: rgba(220, 53, 69, 0.9);
321
297
  border: 0;
322
- background: rgba(220, 53, 69, 0.85);
298
+ border-radius: 50%;
323
299
  color: #fff;
324
300
  display: flex;
325
- align-items: center;
301
+ font-size: 11px;
302
+ height: 22px;
326
303
  justify-content: center;
327
- font-size: 10px;
328
- cursor: pointer;
329
- transition: background 0.15s;
304
+ opacity: 0;
305
+ pointer-events: none;
306
+ position: absolute;
307
+ right: 4px;
308
+ top: 4px;
309
+ transition: opacity 0.15s, background 0.15s;
310
+ width: 22px;
330
311
 
331
312
  &:hover {
332
313
  background: rgb(220, 53, 69);
333
314
  }
334
315
  }
335
316
 
336
- .lato-cms-image-field__new-badge {
337
- position: absolute;
338
- bottom: 4px;
339
- left: 4px;
340
- font-size: 10px;
317
+ .lato-cms-media-field__item:hover .lato-cms-media-field__remove {
318
+ opacity: 1;
319
+ pointer-events: auto;
341
320
  }
342
321
 
343
- // ── Video field ────────────────────────────────────────────────────────────────
344
- .lato-cms-video-field__item {
345
- align-items: stretch;
346
- }
322
+ // Add tile: dashed placeholder, both the empty state and the "add more" affordance.
323
+ .lato-cms-media-field__tile--add {
324
+ align-items: center;
325
+ background: transparent;
326
+ border: 1px dashed rgba(33, 37, 41, 0.25);
327
+ color: var(--bs-secondary);
328
+ display: flex;
329
+ flex-direction: column;
330
+ font-size: 0.7rem;
331
+ gap: 0.25rem;
332
+ justify-content: center;
333
+ padding: 0.5rem;
334
+ text-align: center;
335
+ text-decoration: none;
336
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
347
337
 
348
- .lato-cms-video-field__preview {
349
- align-self: center;
350
- background: #000;
351
- border: 1px solid rgba(33, 37, 41, 0.08);
352
- border-radius: 6px;
353
- flex: 0 0 auto;
354
- overflow: hidden;
355
- width: 180px;
356
- }
338
+ .bi {
339
+ font-size: 1.25rem;
340
+ }
357
341
 
358
- .lato-cms-video-field__player {
359
- display: block;
360
- max-height: 110px;
361
- width: 100%;
342
+ &:hover {
343
+ background: rgba(13, 110, 253, 0.05);
344
+ border-color: var(--bs-primary);
345
+ color: var(--bs-primary);
346
+ }
362
347
  }
363
348
 
364
- // ── Gallery field ──────────────────────────────────────────────────────────────
365
- .lato-cms-gallery-field__grid {
366
- display: flex;
367
- flex-wrap: wrap;
368
- gap: 8px;
369
- min-height: 24px;
349
+ // ── Media picker modal ──────────────────────────────────────────────────────────
350
+ .lato-cms-media-picker__grid {
351
+ display: grid;
352
+ gap: 10px;
353
+ grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
354
+ max-height: 360px;
355
+ overflow-y: auto;
370
356
  }
371
357
 
372
- .lato-cms-gallery-field__item {
373
- position: relative;
374
- border-radius: 6px;
375
- overflow: hidden;
376
- cursor: grab;
377
- user-select: none;
378
- transition: opacity 0.15s, box-shadow 0.15s;
358
+ .lato-cms-media-picker__item {
359
+ border: 2px solid transparent;
360
+ border-radius: 8px;
361
+ cursor: pointer;
362
+ padding: 4px;
363
+ text-align: center;
364
+ transition: border-color 0.15s, background 0.15s;
379
365
 
380
- &:active {
381
- cursor: grabbing;
366
+ &:hover {
367
+ background: var(--bs-light);
382
368
  }
383
369
 
384
- &--dragging {
385
- opacity: 0.35;
386
- box-shadow: 0 0 0 2px var(--bs-primary);
370
+ &--selected {
371
+ border-color: var(--bs-primary);
372
+ background: rgba(13, 110, 253, 0.08);
387
373
  }
388
374
  }
389
375
 
390
- .lato-cms-gallery-field__thumb {
391
- width: 90px;
392
- height: 90px;
393
- object-fit: cover;
394
- display: block;
395
- }
396
-
397
- .lato-cms-gallery-field__remove {
398
- position: absolute;
399
- right: 4px;
400
- top: 4px;
401
- }
402
-
403
376
  // ── Repeater items ─────────────────────────────────────────────────────────────
404
377
 
405
378
  .lato-cms-repeater-item__toggle {
@@ -19,6 +19,10 @@ module LatoCms
19
19
  @query_pages ||= LatoCms::Page.for_lato_spaces_group(@session.get(:spaces_group_id))
20
20
  end
21
21
 
22
+ def query_media
23
+ @query_media ||= LatoCms::Media.for_lato_spaces_group(@session.get(:spaces_group_id))
24
+ end
25
+
22
26
  # Section-level guard: any CMS role (operator or admin) may enter.
23
27
  def authenticate_lato_cms_access
24
28
  return true if @session.user&.lato_cms_access?
@@ -0,0 +1,121 @@
1
+ module LatoCms
2
+ class MediaController < ApplicationController
3
+ ADMIN_ONLY_ACTIONS = %i[destroy_action].freeze
4
+
5
+ before_action { active_sidebar(:lato_cms_media) }
6
+ before_action :authenticate_lato_cms_admin, only: ADMIN_ONLY_ACTIONS
7
+
8
+ def index
9
+ @media = lato_index_collection(
10
+ query_media.order(created_at: :desc),
11
+ columns: %i[name media_type actions],
12
+ sortable_columns: %i[name media_type created_at],
13
+ searchable_columns: %i[name alt_text],
14
+ default_sort_by: 'created_at|DESC',
15
+ pagination: 24
16
+ )
17
+ end
18
+
19
+ # Searchable/filterable grid used inside the field media picker modal
20
+ # (distinct from `index`, which is the full standalone library page).
21
+ def picker_action
22
+ media = query_media
23
+ media = media.of_type(params[:type]) if params[:type].present?
24
+ media = media.where('LOWER(lato_cms_media.name) LIKE :q', q: "%#{params[:q].to_s.downcase}%") if params[:q].present?
25
+
26
+ @media = media.order(created_at: :desc).page(params[:page]).per(24)
27
+ end
28
+
29
+ def create
30
+ @media = LatoCms::Media.new
31
+ end
32
+
33
+ def create_action
34
+ @media = LatoCms::Media.new(create_params.merge(lato_spaces_group_id: @session.get(:spaces_group_id)))
35
+
36
+ respond_to do |format|
37
+ if @media.save
38
+ format.html { redirect_to lato_cms.media_path, notice: t('lato_cms.media_created') }
39
+ format.json { render json: @media }
40
+ else
41
+ format.html { render :create, status: :unprocessable_entity }
42
+ format.json { render json: @media.errors, status: :unprocessable_entity }
43
+ end
44
+ end
45
+ end
46
+
47
+ def update
48
+ @media = query_media.find(params[:id])
49
+ end
50
+
51
+ def update_action
52
+ @media = query_media.find(params[:id])
53
+
54
+ respond_to do |format|
55
+ if @media.update(update_params)
56
+ format.html { redirect_to lato_cms.media_path, notice: t('lato_cms.media_updated') }
57
+ format.json { render json: @media }
58
+ else
59
+ format.html { render :update, status: :unprocessable_entity }
60
+ format.json { render json: @media.errors, status: :unprocessable_entity }
61
+ end
62
+ end
63
+ end
64
+
65
+ # Runs as a Lato::Operation (see GenerateAltTextJob) rather than inline:
66
+ # an LLM call can take a while, and blocking the request risks timing it
67
+ # out. The admin instead lands on a live progress page.
68
+ def regenerate_alt_text_action
69
+ @media = query_media.find(params[:id])
70
+
71
+ unless @media.image? && LatoCms.config.llm_configured?
72
+ respond_to do |format|
73
+ message = t('lato_cms.media_alt_text_regenerate_unavailable')
74
+ format.html { redirect_to lato_cms.media_update_path(@media), alert: message }
75
+ format.json { render json: { error: message }, status: :unprocessable_entity }
76
+ end
77
+ return
78
+ end
79
+
80
+ operation = Lato::Operation.generate('LatoCms::GenerateAltTextJob', { media_id: @media.id }, @session.user_id)
81
+
82
+ if operation.start
83
+ redirect_to lato.operation_path(operation)
84
+ else
85
+ redirect_to lato_cms.media_update_path(@media), alert: t('lato_cms.media_alt_text_regenerate_failed')
86
+ end
87
+ end
88
+
89
+ def destroy_action
90
+ @media = query_media.find(params[:id])
91
+ in_use = @media.usage_count.positive?
92
+ force = ActiveModel::Type::Boolean.new.cast(params[:force])
93
+
94
+ respond_to do |format|
95
+ if (!in_use || force) && @media.destroy
96
+ format.html { redirect_to lato_cms.media_path, notice: t('lato_cms.media_deleted') }
97
+ format.json { render json: { message: t('lato_cms.media_deleted') } }
98
+ else
99
+ message = in_use ? t('lato_cms.media_delete_in_use', count: @media.usage_count) : t('lato_cms.media_delete_failed')
100
+ format.html { redirect_to lato_cms.media_path, alert: message }
101
+ format.json { render json: { error: message, usage_count: @media.usage_count }, status: :unprocessable_entity }
102
+ end
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def create_params
109
+ params.require(:media).permit(:file, :name, :alt_text)
110
+ end
111
+
112
+ # :file is intentionally not permitted here: the underlying file is
113
+ # immutable once a Media exists (it can be reused by many fields across
114
+ # many pages, so replacing it in place would silently change what renders
115
+ # everywhere it's referenced). A different file means a new Media.
116
+ def update_params
117
+ alt_text_keys = LatoCms.config.locales.map { |locale| :"alt_text_#{locale}" }
118
+ params.require(:media).permit(:name, *alt_text_keys)
119
+ end
120
+ end
121
+ end