lato_cms 3.2.1 → 3.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33e3be18772b49e7523f66ee62e85d74ca3cbdaf6e6b0f9f39e0abeeb48dbea4
4
- data.tar.gz: e32a97e06e949782083f87f951cba818c9372f17169703eec90cbb741cabba94
3
+ metadata.gz: 25204dad4edacdca80789144382c513eb02da298f4bbad559552d3d4ad3616c1
4
+ data.tar.gz: 48de425da30efd01481cee27f556da6429049862a987d63087d645b156f85d78
5
5
  SHA512:
6
- metadata.gz: f7ec56fd1e21c2ac94d1c4fee8cac7575f1f09eddc045f64c3f39aa603ad72d07f9f4ed0799edec2b1cc734d8dc690e389f8e374eae4d50436521cd4fd5ad4ac
7
- data.tar.gz: 217698bd6d223e2abfee2c937cffb2e380b5340b5f60c548600a624bb0ad8d92c689f1e14b188c71c2fd754088c5db4bb568921efa98b7ff9ef33efb7270a182
6
+ metadata.gz: 8db389eef91bc7ee627a5f91f57ae02872bcb7317706d6cdc89b9ff8040efbf1a6ab6a467681bb330e0cae415796885646afdeabbe87e9023ece8bc9beab91bc
7
+ data.tar.gz: 544be385fbb01838f1df124fb3582dc553f5bddbf2b18dbe1a7ff9ba4fb7d8a694bc8fb341473246d7327094140d191e002cf32d93f9e3d5dab0b545a2a49ace
@@ -6,7 +6,7 @@ import { Controller } from '@hotwired/stimulus'
6
6
  // `lato-cms:media-selected` event on `document`, correlated by the picker's
7
7
  // own turbo-frame id (unique per field instance, even inside a repeater).
8
8
  export default class extends Controller {
9
- static targets = ['grid', 'item', 'selectedCount', 'confirmButton', 'uploadForm', 'uploadNotice']
9
+ static targets = ['grid', 'item', 'selectedCount', 'confirmButton', 'uploadNotice']
10
10
  static values = { multiple: Boolean, selectedLabel: String }
11
11
 
12
12
  connect () {
@@ -41,22 +41,11 @@ export default class extends Controller {
41
41
  this.closeModal()
42
42
  }
43
43
 
44
- async upload (event) {
45
- event.preventDefault()
46
-
47
- const form = event.currentTarget
48
- const response = await fetch(form.action, {
49
- method: 'POST',
50
- headers: { Accept: 'application/json' },
51
- body: new FormData(form)
52
- })
53
- const data = await response.json()
54
-
55
- if (!response.ok) {
56
- this.uploadNoticeTarget.innerHTML = `<div class="alert alert-danger">${Object.values(data).flat().join(', ')}</div>`
57
- return
58
- }
59
-
44
+ // Result of the XHR upload driven by `lato-cms-upload` (which owns the
45
+ // request and its progress bar); this only turns the created media into a
46
+ // selection.
47
+ uploadCompleted (event) {
48
+ const data = event.detail
60
49
  const item = {
61
50
  id: data.id,
62
51
  name: data.name,
@@ -74,10 +63,13 @@ export default class extends Controller {
74
63
 
75
64
  this.pending.set(item.id, item)
76
65
  this.updateSelectionUi()
77
- form.reset()
78
66
  this.uploadNoticeTarget.innerHTML = `<div class="alert alert-success">${item.name}</div>`
79
67
  }
80
68
 
69
+ uploadFailed (event) {
70
+ this.uploadNoticeTarget.innerHTML = `<div class="alert alert-danger">${event.detail.message}</div>`
71
+ }
72
+
81
73
  buildItemFromElement (element) {
82
74
  return {
83
75
  id: element.dataset.mediaId,
@@ -0,0 +1,91 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Submits a file form over XHR so the real upload progress can be shown.
4
+ // Neither of the two alternatives can: a plain form submit just freezes the
5
+ // page until the server answers, and `fetch` exposes no upload progress
6
+ // events at all (only download ones). Large videos made that gap obvious.
7
+ //
8
+ // On success it dispatches `lato-cms:upload-complete` on the form with the
9
+ // parsed JSON body (the media picker uses it to select the fresh media), or
10
+ // navigates to `redirectUrlValue` when the form is a standalone page.
11
+ export default class extends Controller {
12
+ static targets = ['progress', 'bar', 'label', 'submit', 'notice']
13
+ static values = { redirectUrl: String, processingLabel: String, confirm: String }
14
+
15
+ submit (event) {
16
+ // This handler owns the submission, so Turbo never sees it and
17
+ // `data-turbo-confirm` would never fire: the confirmation is asked here.
18
+ event.preventDefault()
19
+ if (this.confirmValue && !window.confirm(this.confirmValue)) return
20
+
21
+ const xhr = new window.XMLHttpRequest()
22
+ xhr.open('POST', this.element.action, true)
23
+ xhr.setRequestHeader('Accept', 'application/json')
24
+ xhr.upload.addEventListener('progress', (e) => {
25
+ if (e.lengthComputable) this.setProgress((e.loaded / e.total) * 100)
26
+ })
27
+ xhr.addEventListener('load', () => this.finish(xhr))
28
+ xhr.addEventListener('error', () => this.fail())
29
+
30
+ this.start()
31
+ xhr.send(new window.FormData(this.element))
32
+ }
33
+
34
+ start () {
35
+ this.clearNotice()
36
+ if (this.hasSubmitTarget) this.submitTarget.disabled = true
37
+ if (this.hasProgressTarget) this.progressTarget.classList.remove('d-none')
38
+ this.setProgress(0)
39
+ }
40
+
41
+ // Past 100% the bytes are in, but the server is still attaching and
42
+ // processing them: the bar stays full and animated, the label says so.
43
+ setProgress (percent) {
44
+ const value = Math.round(percent)
45
+ if (this.hasBarTarget) this.barTarget.style.width = `${value}%`
46
+ if (this.hasLabelTarget) this.labelTarget.textContent = value >= 100 ? this.processingLabelValue : `${value}%`
47
+ }
48
+
49
+ finish (xhr) {
50
+ const data = this.parse(xhr.responseText)
51
+
52
+ if (xhr.status < 200 || xhr.status >= 300) {
53
+ this.fail(data)
54
+ return
55
+ }
56
+
57
+ if (this.redirectUrlValue) {
58
+ window.Turbo.visit(this.redirectUrlValue)
59
+ return
60
+ }
61
+
62
+ this.reset()
63
+ this.element.reset()
64
+ this.element.dispatchEvent(new CustomEvent('lato-cms:upload-complete', { bubbles: true, detail: data }))
65
+ }
66
+
67
+ fail (data) {
68
+ this.reset()
69
+ const message = data ? Object.values(data).flat().join(', ') : ''
70
+ this.element.dispatchEvent(new CustomEvent('lato-cms:upload-error', { bubbles: true, detail: { message } }))
71
+ if (this.hasNoticeTarget) this.noticeTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`
72
+ }
73
+
74
+ reset () {
75
+ if (this.hasSubmitTarget) this.submitTarget.disabled = false
76
+ if (this.hasProgressTarget) this.progressTarget.classList.add('d-none')
77
+ this.setProgress(0)
78
+ }
79
+
80
+ clearNotice () {
81
+ if (this.hasNoticeTarget) this.noticeTarget.innerHTML = ''
82
+ }
83
+
84
+ parse (body) {
85
+ try {
86
+ return JSON.parse(body)
87
+ } catch (err) {
88
+ return null
89
+ }
90
+ }
91
+ }
@@ -1,14 +1,19 @@
1
1
  module LatoCms
2
2
  class MediaController < ApplicationController
3
- ADMIN_ONLY_ACTIONS = %i[destroy_action].freeze
3
+ ADMIN_ONLY_ACTIONS = %i[replace_file_action destroy_action].freeze
4
4
 
5
5
  before_action { active_sidebar(:lato_cms_media) }
6
6
  before_action :authenticate_lato_cms_admin, only: ADMIN_ONLY_ACTIONS
7
7
 
8
8
  def index
9
+ media = query_media
10
+ # "Unused" = no page field references it at all: the only media that can
11
+ # be deleted, so it's worth being able to list just those.
12
+ media = media.where.missing(:page_field_media) if params[:usage] == 'unused'
13
+
9
14
  @media = lato_index_collection(
10
- query_media.order(created_at: :desc),
11
- columns: %i[name media_type actions],
15
+ media.order(created_at: :desc),
16
+ columns: %i[name media_type usages actions],
12
17
  sortable_columns: %i[name media_type created_at],
13
18
  searchable_columns: %i[name alt_text title],
14
19
  default_sort_by: 'created_at|DESC',
@@ -26,6 +31,13 @@ module LatoCms
26
31
  @media = media.order(created_at: :desc).page(params[:page]).per(24)
27
32
  end
28
33
 
34
+ # Read-only detail page: the media's own data plus where it's used. Kept
35
+ # apart from the edit form, which is a modal from the index and has no room
36
+ # for a usage list.
37
+ def show
38
+ @media = query_media.find(params[:id])
39
+ end
40
+
29
41
  def create
30
42
  @media = LatoCms::Media.new
31
43
  end
@@ -35,6 +47,12 @@ module LatoCms
35
47
 
36
48
  respond_to do |format|
37
49
  if @media.save
50
+ # The JSON branch serves XHR uploads (progress bar, see
51
+ # lato_cms_upload_controller.js). `notify` is sent only by forms that
52
+ # navigate somewhere afterwards, so the flash lands on that page; the
53
+ # media picker uploads in place and asks for no flash.
54
+ flash[:notice] = t('lato_cms.media_created') if params[:notify].present?
55
+
38
56
  format.html { redirect_to lato_cms.media_path, notice: t('lato_cms.media_created') }
39
57
  format.json { render json: @media }
40
58
  else
@@ -89,6 +107,34 @@ module LatoCms
89
107
  end
90
108
  end
91
109
 
110
+ # Replaces the file of an existing media in place, so every page already
111
+ # using it picks up the new file. Kept out of `update_action` (and off
112
+ # `update_params`) on purpose: this is a destructive, admin-only edit to
113
+ # every usage at once, not a metadata change.
114
+ def replace_file_action
115
+ @media = query_media.find(params[:id])
116
+ file = params.dig(:media, :file)
117
+
118
+ respond_to do |format|
119
+ if file.present? && @media.replace_file!(file)
120
+ format.html { redirect_to lato_cms.media_show_path(@media), notice: t('lato_cms.media_file_replaced') }
121
+ format.json { render json: @media }
122
+ else
123
+ message = t('lato_cms.media_file_replace_failed')
124
+ format.html { redirect_to lato_cms.media_update_path(@media), alert: message }
125
+ format.json { render json: { error: message }, status: :unprocessable_entity }
126
+ end
127
+ end
128
+ rescue StandardError => e
129
+ Rails.logger.error("LatoCms: failed to replace file for media #{params[:id]}: #{e.message}")
130
+ message = t('lato_cms.media_file_replace_failed')
131
+
132
+ respond_to do |format|
133
+ format.html { redirect_to lato_cms.media_update_path(@media), alert: message }
134
+ format.json { render json: { error: message }, status: :unprocessable_entity }
135
+ end
136
+ end
137
+
92
138
  def destroy_action
93
139
  @media = query_media.find(params[:id])
94
140
  in_use = @media.usage_count.positive?
@@ -112,10 +158,10 @@ module LatoCms
112
158
  params.require(:media).permit(:file, :name, :alt_text, :title)
113
159
  end
114
160
 
115
- # :file is intentionally not permitted here: the underlying file is
116
- # immutable once a Media exists (it can be reused by many fields across
117
- # many pages, so replacing it in place would silently change what renders
118
- # everywhere it's referenced). A different file means a new Media.
161
+ # :file is intentionally not permitted here: a media can be reused by many
162
+ # fields across many pages, so swapping its file silently changes what
163
+ # renders everywhere it's referenced. That swap is possible, but only
164
+ # through the explicit, admin-only `replace_file_action`.
119
165
  def update_params
120
166
  translation_keys = LatoCms::Media::TRANSLATABLE_ATTRIBUTES.product(LatoCms.config.locales).map { |attribute, locale| :"#{attribute}_#{locale}" }
121
167
  params.require(:media).permit(:name, *translation_keys)
@@ -30,21 +30,42 @@ module LatoCms
30
30
  end
31
31
  end
32
32
 
33
+ # Index usages cell: how many page fields reference this media, so the
34
+ # admin sees at a glance which files are actually in use (the detail is in
35
+ # the edit form, which lists the pages).
36
+ def lato_cms_media_usages(media)
37
+ count = media.usage_count
38
+ return content_tag(:span, t('lato_cms.media_usages_none'), class: 'text-muted small') if count.zero?
39
+
40
+ content_tag(:span, t('lato_cms.media_usages_count', count: count), class: 'badge bg-info text-dark')
41
+ end
42
+
33
43
  def lato_cms_media_media_type(media)
34
44
  content_tag(:span, media.media_type, class: 'badge bg-secondary')
35
45
  end
36
46
 
37
- # Index actions cell: edit metadata + delete (delete gated to admins, same
38
- # convention as pages).
47
+ # Index actions cell: detail page + edit metadata + delete (delete gated to
48
+ # admins, same convention as pages). Delete is rendered inert while the
49
+ # media is still referenced: the action would be refused server-side
50
+ # anyway, so the button says why instead of failing after the fact.
39
51
  def lato_cms_media_actions(media)
40
52
  content_tag(:div, class: 'btn-group btn-group-sm') do
53
+ concat link_to(t('lato_cms.cta_show'), lato_cms.media_show_path(media), class: 'btn btn-primary')
41
54
  concat link_to(t('lato_cms.cta_edit'), lato_cms.media_update_path(media), class: 'btn btn-secondary',
42
55
  data: { lato_action_target: 'trigger', turbo_frame: dom_id(media, 'form'), action_title: t('lato_cms.media_update_title') })
43
- if lato_cms_admin?
44
- concat link_to(t('lato_cms.cta_delete'), lato_cms.media_destroy_action_path(media), class: 'btn btn-danger',
45
- data: { turbo_method: 'DELETE', turbo_confirm: t('lato_cms.cta_delete_confirm') })
46
- end
56
+ concat lato_cms_media_delete_action(media) if lato_cms_admin?
47
57
  end
48
58
  end
59
+
60
+ private
61
+
62
+ def lato_cms_media_delete_action(media)
63
+ usage_count = media.usage_count
64
+ return link_to(t('lato_cms.cta_delete'), lato_cms.media_destroy_action_path(media), class: 'btn btn-danger',
65
+ data: { turbo_method: 'DELETE', turbo_confirm: t('lato_cms.cta_delete_confirm') }) if usage_count.zero?
66
+
67
+ content_tag(:span, t('lato_cms.cta_delete'), class: 'btn btn-danger disabled',
68
+ title: t('lato_cms.media_delete_in_use', count: usage_count), data: { controller: 'lato-tooltip' })
69
+ end
49
70
  end
50
71
  end
@@ -126,9 +126,18 @@ module LatoCms
126
126
  multiple ? "#{name}[]" : name
127
127
  end
128
128
 
129
+ # Sanitizes only: `parameterize` also downcased, which silently broke the
130
+ # repeater's "NEW_RECORD" placeholder. The repeater JS swaps that literal
131
+ # for a fresh uuid when an item is added (see
132
+ # lato_cms_repeater_controller#add), but it never reached the JS as
133
+ # "NEW_RECORD" — it arrived lowercased inside every dom id and, worse,
134
+ # inside each media field's picker frame id. Every item added in one
135
+ # session therefore shared those ids, and since the picker correlates its
136
+ # selection event by frame id, choosing a media for one item dropped it
137
+ # into all of them at once.
129
138
  def lato_cms_field_dom_id(field_id, suffix = 'value', dom_id_prefix: nil)
130
139
  base = dom_id_prefix.presence || "fields_#{field_id}"
131
- "#{base}_#{suffix}".parameterize(separator: '_')
140
+ "#{base}_#{suffix}".gsub(/[^a-zA-Z0-9_]/, '_')
132
141
  end
133
142
 
134
143
  private
@@ -78,6 +78,14 @@ module LatoCms
78
78
  page_field_media.count
79
79
  end
80
80
 
81
+ # Every page field referencing this media, grouped by page (sorted by page
82
+ # title). Drives the "used in" list in the admin: a media is shared, so
83
+ # both deleting it and replacing its file are edits to every page listed
84
+ # here, and the admin has to see that before doing either.
85
+ def usages
86
+ page_fields.includes(:page).group_by(&:page).sort_by { |page, _fields| page.title.to_s.downcase }
87
+ end
88
+
81
89
  def filename
82
90
  file.filename.to_s if file.attached?
83
91
  end
@@ -119,6 +127,22 @@ module LatoCms
119
127
  Rails.application.routes.url_helpers.rails_blob_path(poster_file, only_path: true) if poster_file.attached?
120
128
  end
121
129
 
130
+ # Swaps the underlying file while keeping the same record, so every field
131
+ # already referencing this media renders the new file: the point of the
132
+ # action (replace a logo everywhere at once) and its danger at the same
133
+ # time. Active Storage purges the previous blob on attach, the stale video
134
+ # poster is dropped explicitly, and media_type is re-inferred since the
135
+ # new file can be of a different kind. Variants need no cleanup: they are
136
+ # derived from the blob, so the old ones die with it.
137
+ def replace_file!(new_file)
138
+ file.attach(new_file)
139
+ poster_file.purge if poster_file.attached?
140
+ update!(media_type: self.class.infer_media_type(file.content_type))
141
+ enqueue_poster_generation if video?
142
+
143
+ true
144
+ end
145
+
122
146
  # Best effort: generates a poster from the video via Active Storage previews
123
147
  # (ffmpeg). Any failure is logged, the video keeps working without a poster.
124
148
  def generate_video_poster!
@@ -1,9 +1,21 @@
1
1
  <% media ||= LatoCms::Media.new %>
2
2
 
3
3
  <%= turbo_frame_tag dom_id(media, 'form') do %>
4
- <%= form_with model: media, url: lato_cms.media_create_action_path, data: { turbo_frame: '_self', controller: 'lato-form' } do |form| %>
4
+ <%# Submitted over XHR (see lato-cms-upload) to show upload progress; the
5
+ `notify` flag asks the JSON branch to leave a flash for the page we
6
+ navigate to once the upload is done. %>
7
+ <%= form_with model: media, url: lato_cms.media_create_action_path, data: {
8
+ turbo_frame: '_self',
9
+ controller: 'lato-form lato-cms-upload',
10
+ action: 'submit->lato-cms-upload#submit',
11
+ lato_cms_upload_redirect_url_value: lato_cms.media_path,
12
+ lato_cms_upload_processing_label_value: t('lato_cms.media_upload_processing')
13
+ } do |form| %>
5
14
  <%= lato_form_notices class: %w[mb-3] %>
6
15
  <%= lato_form_errors media, class: %w[mb-3] %>
16
+ <%= hidden_field_tag :notify, true %>
17
+
18
+ <div data-lato-cms-upload-target="notice"></div>
7
19
 
8
20
  <%# Name/alt text are edited afterwards from this same list's "edit"
9
21
  action — they default to the filename on upload, so there's nothing
@@ -13,8 +25,10 @@
13
25
  <%= lato_form_item_input_file_dropzone form, :file %>
14
26
  </div>
15
27
 
28
+ <%= render 'lato_cms/media/upload_progress' %>
29
+
16
30
  <div class="d-flex justify-content-end">
17
- <%= lato_form_submit form, t('lato_cms.media_create_cta'), class: %w[btn-success] %>
31
+ <%= lato_form_submit form, t('lato_cms.media_create_cta'), class: %w[btn-success], data: { lato_cms_upload_target: 'submit' } %>
18
32
  </div>
19
33
  <% end %>
20
34
  <% end %>
@@ -0,0 +1,25 @@
1
+ <%# Swaps the file behind an existing media: every page using it renders the
2
+ new file. Deliberately not part of the metadata form (different action,
3
+ admin only, destructive across every usage). %>
4
+ <p class="text-muted small">
5
+ <%= t('lato_cms.media_replace_file_description', count: media.usage_count) %>
6
+ </p>
7
+
8
+ <%= form_with model: media, url: lato_cms.media_replace_file_action_path(media), method: :patch, data: {
9
+ controller: 'lato-cms-upload',
10
+ action: 'submit->lato-cms-upload#submit',
11
+ lato_cms_upload_redirect_url_value: lato_cms.media_show_path(media),
12
+ lato_cms_upload_processing_label_value: t('lato_cms.media_upload_processing'),
13
+ lato_cms_upload_confirm_value: t('lato_cms.media_replace_file_confirm')
14
+ } do |form| %>
15
+ <div data-lato-cms-upload-target="notice"></div>
16
+
17
+ <%= lato_form_item_input_file_dropzone form, :file %>
18
+ <%= render 'lato_cms/media/upload_progress' %>
19
+
20
+ <div class="d-flex justify-content-end">
21
+ <button type="submit" class="btn btn-warning" data-lato-cms-upload-target="submit">
22
+ <%= t('lato_cms.media_replace_file_cta') %>
23
+ </button>
24
+ </div>
25
+ <% end %>
@@ -1,42 +1,54 @@
1
1
  <% media ||= LatoCms::Media.new %>
2
+ <% tab_id = dom_id(media, 'tab') %>
2
3
 
3
4
  <%= turbo_frame_tag dom_id(media, 'form') do %>
4
- <%= form_with model: media, url: lato_cms.media_update_action_path(media), method: :patch, data: { turbo_frame: '_self', controller: 'lato-form lato-cms-reload-on-modal-close' } do |form| %>
5
- <%= lato_form_notices class: %w[mb-3] %>
6
- <%= lato_form_errors media, class: %w[mb-3] %>
5
+ <%# Two tabs: the metadata form, and the file swap same media, but editing
6
+ its name is a local change while replacing its file edits every page
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">
11
+ <%= t('lato_cms.media_update_tab_details') %>
12
+ </button>
13
+ </li>
14
+ <% 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">
17
+ <%= t('lato_cms.media_replace_file_title') %>
18
+ </button>
19
+ </li>
20
+ <% end %>
21
+ </ul>
7
22
 
8
- <%# Preview: images/videos get a large view (the index thumbnail is too small
9
- to judge a file by), everything else keeps the small type icon. %>
10
- <div class="mb-3">
11
- <% if media.image? %>
12
- <%= link_to media.url, target: '_blank', rel: 'noopener', title: t('lato_cms.media_open_original'),
13
- class: 'd-block bg-light rounded overflow-hidden' do %>
14
- <%= image_tag media.preview_url, class: 'd-block mx-auto', style: 'max-width: 100%; max-height: 420px;', alt: media.alt_text %>
15
- <% end %>
16
- <% elsif media.video? %>
17
- <video controls preload="metadata" class="d-block w-100 rounded bg-black" style="max-height: 420px;" src="<%= media.url %>"></video>
18
- <% end %>
23
+ <div class="tab-content">
24
+ <div class="tab-pane fade show active" id="<%= tab_id %>_details">
25
+ <%= 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
+ <%= lato_form_notices class: %w[mb-3] %>
27
+ <%= lato_form_errors media, class: %w[mb-3] %>
19
28
 
20
- <div class="d-flex align-items-center gap-2 mt-2">
21
- <% unless media.image? || media.video? %>
22
- <%= lato_cms_media_thumb(media, size: 64) %>
29
+ <%= render 'lato_cms/media/preview', media: media %>
30
+
31
+ <div class="mb-3">
32
+ <%= lato_form_item_label form, :name %>
33
+ <%= lato_form_item_input_text form, :name, required: true %>
34
+ </div>
35
+
36
+ <% if media.image? %>
37
+ <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "alt_text" %>
23
38
  <% end %>
24
- <span class="text-muted small"><%= media.filename %></span>
25
- </div>
26
- </div>
39
+ <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "title" %>
27
40
 
28
- <div class="mb-3">
29
- <%= lato_form_item_label form, :name %>
30
- <%= lato_form_item_input_text form, :name, required: true %>
41
+ <div class="d-flex justify-content-between align-items-center">
42
+ <%= link_to t('lato_cms.media_usages_show_cta'), lato_cms.media_show_path(media), class: 'btn btn-link px-0' %>
43
+ <%= lato_form_submit form, t('lato_cms.cta_update'), class: %w[btn-success] %>
44
+ </div>
45
+ <% end %>
31
46
  </div>
32
47
 
33
- <% if media.image? %>
34
- <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "alt_text" %>
48
+ <% if lato_cms_admin? %>
49
+ <div class="tab-pane fade" id="<%= tab_id %>_replace">
50
+ <%= render 'lato_cms/media/form_replace_file', media: media %>
51
+ </div>
35
52
  <% end %>
36
- <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "title" %>
37
-
38
- <div class="d-flex justify-content-end">
39
- <%= lato_form_submit form, t('lato_cms.cta_update'), class: %w[btn-success] %>
40
- </div>
41
- <% end %>
53
+ </div>
42
54
  <% end %>
@@ -0,0 +1,19 @@
1
+ <%# Images and videos get a large view (the index thumbnail is too small to
2
+ judge a file by), everything else keeps the small type icon. %>
3
+ <div class="mb-3">
4
+ <% if media.image? %>
5
+ <%= link_to media.url, target: '_blank', rel: 'noopener', title: t('lato_cms.media_open_original'),
6
+ class: 'd-block bg-light rounded overflow-hidden' do %>
7
+ <%= image_tag media.preview_url, class: 'd-block mx-auto', style: 'max-width: 100%; max-height: 420px;', alt: media.alt_text %>
8
+ <% end %>
9
+ <% elsif media.video? %>
10
+ <video controls preload="metadata" class="d-block w-100 rounded bg-black" style="max-height: 420px;" src="<%= media.url %>"></video>
11
+ <% end %>
12
+
13
+ <div class="d-flex align-items-center gap-2 mt-2">
14
+ <% unless media.image? || media.video? %>
15
+ <%= lato_cms_media_thumb(media, size: 64) %>
16
+ <% end %>
17
+ <%= link_to media.filename, media.url, target: '_blank', rel: 'noopener', class: 'text-muted small' %>
18
+ </div>
19
+ </div>
@@ -0,0 +1,8 @@
1
+ <%# Progress UI driven by lato_cms_upload_controller.js: hidden until a
2
+ submit starts, then fed the real bytes-sent percentage. %>
3
+ <div class="d-none my-3" data-lato-cms-upload-target="progress">
4
+ <div class="progress" style="height: 6px;">
5
+ <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%;" role="progressbar" data-lato-cms-upload-target="bar"></div>
6
+ </div>
7
+ <div class="small text-muted mt-1" data-lato-cms-upload-target="label">0%</div>
8
+ </div>
@@ -0,0 +1,20 @@
1
+ <%# Where this media is actually rendered: a media is shared, so this is the
2
+ blast radius of replacing its file, and the reason deleting it can be
3
+ refused. %>
4
+ <% usages = media.usages %>
5
+
6
+ <% if usages.empty? %>
7
+ <p class="text-muted mb-0"><%= t('lato_cms.media_usages_empty') %></p>
8
+ <% else %>
9
+ <ul class="list-group list-group-flush">
10
+ <% usages.each do |page, fields| %>
11
+ <li class="list-group-item d-flex justify-content-between align-items-center gap-2 px-0">
12
+ <div>
13
+ <div><%= page.title %> <span class="badge bg-secondary ms-1"><%= page.locale.upcase %></span></div>
14
+ <div class="text-muted small"><%= fields.map(&:field_name).uniq.join(', ') %></div>
15
+ </div>
16
+ <%= link_to t('lato_cms.cta_show'), lato_cms.pages_show_path(page), class: 'btn btn-sm btn-outline-secondary' %>
17
+ </li>
18
+ <% end %>
19
+ </ul>
20
+ <% end %>
@@ -1,6 +1,14 @@
1
1
  <%= lato_page_head t('lato_cms.media_index_title'), [{ label: t('lato_cms.media_index_title') }] %>
2
2
 
3
3
  <div class="card">
4
+ <div class="card-header d-flex gap-2">
5
+ <%= link_to lato_cms.media_path, class: "btn btn-sm #{params[:usage].blank? ? 'btn-primary' : 'btn-outline-secondary'}" do %>
6
+ <i class="bi bi-images me-1"></i><%= t('lato_cms.media_filter_all') %>
7
+ <% end %>
8
+ <%= link_to lato_cms.media_path(usage: 'unused'), class: "btn btn-sm #{params[:usage] == 'unused' ? 'btn-primary' : 'btn-outline-secondary'}" do %>
9
+ <i class="bi bi-unlink me-1"></i><%= t('lato_cms.media_filter_unused') %>
10
+ <% end %>
11
+ </div>
4
12
  <div class="card-body">
5
13
  <%= lato_index @media,
6
14
  custom_actions: {
@@ -84,12 +84,17 @@
84
84
  ActiveRecord::Relation delegates an unrecognized `#name` call to
85
85
  its model class, silently pre-filling a `:name` field with the
86
86
  literal string "LatoCms::Media" (bit us once already here). %>
87
+ <%# Uploaded over XHR by `lato-cms-upload` (progress bar) instead of a
88
+ plain submit; the picker only reacts to the result events. %>
87
89
  <%= form_with model: LatoCms::Media.new, url: lato_cms.media_create_action_path, data: {
88
- lato_cms_media_picker_target: 'uploadForm', action: 'submit->lato-cms-media-picker#upload'
90
+ controller: 'lato-cms-upload',
91
+ action: 'submit->lato-cms-upload#submit lato-cms:upload-complete->lato-cms-media-picker#uploadCompleted lato-cms:upload-error->lato-cms-media-picker#uploadFailed',
92
+ lato_cms_upload_processing_label_value: t('lato_cms.media_upload_processing')
89
93
  } do |form| %>
90
94
  <%= lato_form_item_input_file_dropzone form, :file, accept: { 'image' => 'image/*', 'video' => 'video/*' }[params[:type]] %>
95
+ <%= render 'lato_cms/media/upload_progress' %>
91
96
  <div class="d-flex justify-content-end">
92
- <button type="submit" class="btn btn-success"><%= t('lato_cms.media_upload_cta') %></button>
97
+ <button type="submit" class="btn btn-success" data-lato-cms-upload-target="submit"><%= t('lato_cms.media_upload_cta') %></button>
93
98
  </div>
94
99
  <% end %>
95
100
  </div>
@@ -0,0 +1,47 @@
1
+ <%= lato_page_head @media.name, [
2
+ { label: t('lato_cms.media_index_title'), path: lato_cms.media_path },
3
+ { label: @media.name }
4
+ ] %>
5
+
6
+ <div class="card mb-4">
7
+ <div class="card-header d-flex justify-content-between align-items-center gap-2">
8
+ <h2 class="fs-4 mb-0"><%= t('lato_cms.media_summary_title') %></h2>
9
+ <%= link_to t('lato_cms.cta_edit'), lato_cms.media_update_path(@media), class: 'btn btn-sm btn-secondary' %>
10
+ </div>
11
+ <div class="card-body">
12
+ <%= render 'lato_cms/media/preview', media: @media %>
13
+
14
+ <dl class="row mb-0">
15
+ <dt class="col-sm-3"><%= LatoCms::Media.human_attribute_name(:name) %></dt>
16
+ <dd class="col-sm-9"><%= @media.name %></dd>
17
+
18
+ <dt class="col-sm-3"><%= t('lato_cms.media_type_label') %></dt>
19
+ <dd class="col-sm-9"><%= lato_cms_media_media_type(@media) %></dd>
20
+
21
+ <%# Alt text is image-only, mirroring the edit form. Both are translatable:
22
+ one row per configured locale, blanks included, so a missing
23
+ translation is visible instead of just absent. %>
24
+ <% attributes = @media.image? ? LatoCms::Media::TRANSLATABLE_ATTRIBUTES : %w[title] %>
25
+ <% attributes.each do |attribute| %>
26
+ <dt class="col-sm-3"><%= t("lato_cms.media_#{attribute}") %></dt>
27
+ <dd class="col-sm-9">
28
+ <% LatoCms.config.locales.each do |locale| %>
29
+ <div class="d-flex gap-2">
30
+ <span class="badge bg-secondary align-self-start"><%= locale.to_s.upcase %></span>
31
+ <span><%= @media.public_send(attribute, locale).presence || content_tag(:span, '—', class: 'text-muted') %></span>
32
+ </div>
33
+ <% end %>
34
+ </dd>
35
+ <% end %>
36
+ </dl>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="card">
41
+ <div class="card-header">
42
+ <h2 class="fs-4 mb-0"><%= t('lato_cms.media_usages_title') %></h2>
43
+ </div>
44
+ <div class="card-body">
45
+ <%= render 'lato_cms/media/usages', media: @media %>
46
+ </div>
47
+ </div>
@@ -3,6 +3,8 @@ en:
3
3
  attributes:
4
4
  lato/user:
5
5
  lato_cms_admin_role: Lato CMS role
6
+ lato_cms/media:
7
+ usages: Usages
6
8
  lato_cms:
7
9
  admin_roles:
8
10
  none: No access
@@ -121,6 +123,28 @@ en:
121
123
  media_picker_selected_label: selected
122
124
  media_search_placeholder: Search media...
123
125
  media_upload_cta: Upload
126
+ media_upload_processing: Processing...
127
+ media_usages_title: Used in
128
+ media_filter_all: All
129
+ media_filter_unused: Unused
130
+ media_summary_title: Media details
131
+ media_type_label: Type
132
+ media_update_tab_details: Details
133
+ media_usages_show_cta: See where it is used
134
+ media_usages_empty: This media is not used in any page yet.
135
+ media_usages_none: Unused
136
+ media_usages_count:
137
+ one: "%{count} usage"
138
+ other: "%{count} usages"
139
+ media_replace_file_title: Replace file
140
+ media_replace_file_description:
141
+ zero: "Uploads a new file in place of the current one, keeping the same media record. This media is not used yet."
142
+ one: "Uploads a new file in place of the current one, keeping the same media record: the %{count} page field using it will show the new file."
143
+ other: "Uploads a new file in place of the current one, keeping the same media record: the %{count} page fields using it will show the new file."
144
+ media_replace_file_cta: Replace file
145
+ media_replace_file_confirm: "The current file will be replaced everywhere this media is used. Continue?"
146
+ media_file_replaced: File replaced successfully
147
+ media_file_replace_failed: Failed to replace the file
124
148
  media_type_all: All
125
149
  media_type_image: Images
126
150
  media_type_video: Videos
@@ -3,6 +3,8 @@ it:
3
3
  attributes:
4
4
  lato/user:
5
5
  lato_cms_admin_role: Ruolo Lato CMS
6
+ lato_cms/media:
7
+ usages: Utilizzi
6
8
  lato_cms:
7
9
  admin_roles:
8
10
  none: Nessun accesso
@@ -121,6 +123,28 @@ it:
121
123
  media_picker_selected_label: selezionati
122
124
  media_search_placeholder: Cerca media...
123
125
  media_upload_cta: Carica
126
+ media_upload_processing: Elaborazione...
127
+ media_usages_title: Utilizzato in
128
+ media_filter_all: Tutti
129
+ media_filter_unused: Senza utilizzi
130
+ media_summary_title: Dettagli media
131
+ media_type_label: Tipo
132
+ media_update_tab_details: Dettagli
133
+ media_usages_show_cta: Vedi dove è utilizzato
134
+ media_usages_empty: Questo media non è ancora utilizzato in nessuna pagina.
135
+ media_usages_none: Non utilizzato
136
+ media_usages_count:
137
+ one: "%{count} utilizzo"
138
+ other: "%{count} utilizzi"
139
+ media_replace_file_title: Sostituisci file
140
+ media_replace_file_description:
141
+ zero: "Carica un nuovo file al posto di quello attuale, mantenendo lo stesso media. Questo media non è ancora utilizzato."
142
+ one: "Carica un nuovo file al posto di quello attuale, mantenendo lo stesso media: il %{count} campo che lo utilizza mostrerà il nuovo file."
143
+ other: "Carica un nuovo file al posto di quello attuale, mantenendo lo stesso media: i %{count} campi che lo utilizzano mostreranno il nuovo file."
144
+ media_replace_file_cta: Sostituisci file
145
+ media_replace_file_confirm: "Il file attuale verrà sostituito ovunque questo media sia utilizzato. Continuare?"
146
+ media_file_replaced: File sostituito con successo
147
+ media_file_replace_failed: Impossibile sostituire il file
124
148
  media_type_all: Tutti
125
149
  media_type_image: Immagini
126
150
  media_type_video: Video
data/config/routes.rb CHANGED
@@ -30,8 +30,10 @@ LatoCms::Engine.routes.draw do
30
30
  get 'picker', to: 'media#picker_action', as: :media_picker_action
31
31
  get 'create', to: 'media#create', as: :media_create
32
32
  post 'create', to: 'media#create_action', as: :media_create_action
33
+ get ':id', to: 'media#show', as: :media_show
33
34
  get ':id/update', to: 'media#update', as: :media_update
34
35
  patch ':id/update', to: 'media#update_action', as: :media_update_action
36
+ patch ':id/replace-file', to: 'media#replace_file_action', as: :media_replace_file_action
35
37
  post ':id/regenerate/:attribute', to: 'media#regenerate_text_action', as: :media_regenerate_text_action, constraints: { attribute: /alt_text|title/ }
36
38
  delete ':id', to: 'media#destroy_action', as: :media_destroy_action
37
39
  end
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.2.1"
2
+ VERSION = "3.2.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lato_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.1
4
+ version: 3.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante
@@ -118,6 +118,7 @@ files:
118
118
  - app/assets/javascripts/lato_cms/controllers/lato_cms_reload_on_modal_close_controller.js
119
119
  - app/assets/javascripts/lato_cms/controllers/lato_cms_repeater_controller.js
120
120
  - app/assets/javascripts/lato_cms/controllers/lato_cms_text_field_controller.js
121
+ - app/assets/javascripts/lato_cms/controllers/lato_cms_upload_controller.js
121
122
  - app/assets/stylesheets/lato_cms/application.scss
122
123
  - app/controllers/lato_cms/api/pages_controller.rb
123
124
  - app/controllers/lato_cms/application_controller.rb
@@ -139,11 +140,16 @@ files:
139
140
  - app/models/lato_cms/page_field_media.rb
140
141
  - app/models/lato_cms/template_manager.rb
141
142
  - app/views/lato_cms/media/_form_create.html.erb
143
+ - app/views/lato_cms/media/_form_replace_file.html.erb
142
144
  - app/views/lato_cms/media/_form_update.html.erb
145
+ - app/views/lato_cms/media/_preview.html.erb
143
146
  - app/views/lato_cms/media/_translations_field.html.erb
147
+ - app/views/lato_cms/media/_upload_progress.html.erb
148
+ - app/views/lato_cms/media/_usages.html.erb
144
149
  - app/views/lato_cms/media/create.html.erb
145
150
  - app/views/lato_cms/media/index.html.erb
146
151
  - app/views/lato_cms/media/picker_action.html.erb
152
+ - app/views/lato_cms/media/show.html.erb
147
153
  - app/views/lato_cms/media/update.html.erb
148
154
  - app/views/lato_cms/pages/_component_accordion.html.erb
149
155
  - app/views/lato_cms/pages/_fields_editor.html.erb