From 8ed64beff738c31271a1c39365155f2609338dfb Mon Sep 17 00:00:00 2001 From: Adrian Marin Date: Thu, 30 Jan 2025 16:12:08 +0200 Subject: [PATCH] feature: media library/gallery (#3561) * feature: media library * renamve components * make it work for trix and rhino too * add request.js * wip * wip * wip * wip * wip * wip * can upload * add configuration * add helpers * add authorization * fix update and destroy * wip * wip * wip * wip * Potential fix for code scanning alert no. 43: DOM text reinterpreted as HTML Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * properly escape the filename * lint * 18n-tasks translate-missing * revert unique_id for trix input * npx update-browserslist-db@latest * fix unique_selector --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Paul Bob <69730720+Paul-Bob@users.noreply.github.com> Co-authored-by: Paul Bob --- app/assets/stylesheets/avo.base.css | 12 ++ app/assets/stylesheets/css/fields/trix.css | 7 + app/components/avo/base_component.rb | 2 + .../tiptap_field/show_component.html.erb | 10 +- .../fields/trix_field/edit_component.html.erb | 12 +- .../avo/fields/trix_field/edit_component.rb | 22 ++- .../fields/trix_field/show_component.html.erb | 10 +- .../item_details_component.html.erb | 32 ++++ .../media_library/item_details_component.rb | 12 ++ .../avo/media_library/list_component.html.erb | 59 +++++++ .../avo/media_library/list_component.rb | 28 +++ .../list_item_component.html.erb | 26 +++ .../avo/media_library/list_item_component.rb | 34 ++++ .../avo/paginator_component.html.erb | 2 +- app/components/avo/paginator_component.rb | 9 - app/components/avo/sidebar_component.html.erb | 2 + app/controllers/avo/actions_controller.rb | 2 +- .../avo/media_library_controller.rb | 42 +++++ app/helpers/avo/application_helper.rb | 9 + app/helpers/avo/resources_helper.rb | 13 ++ app/javascript/js/controllers.js | 4 + .../copy_to_clipboard_controller.js | 15 +- .../fields/trix_field_controller.js | 148 ++++++++++++---- .../media_library_attach_controller.js | 160 ++++++++++++++++++ .../controllers/media_library_controller.js | 88 ++++++++++ .../js/controllers/modal_controller.js | 10 +- app/javascript/js/helpers/index.js | 5 + app/views/avo/actions/show.html.erb | 2 +- app/views/avo/media_library/_form.html.erb | 40 +++++ .../avo/media_library/_information.html.erb | 50 ++++++ app/views/avo/media_library/index.html.erb | 9 + app/views/avo/media_library/show.html.erb | 5 + app/views/avo/partials/_javascript.html.erb | 4 + config/routes.rb | 3 + lib/avo.rb | 7 +- lib/avo/media_library/configuration.rb | 22 +++ .../avo/templates/locales/avo.ar.yml | 3 + .../avo/templates/locales/avo.de.yml | 3 + .../avo/templates/locales/avo.en.yml | 3 + .../avo/templates/locales/avo.es.yml | 3 + .../avo/templates/locales/avo.fr.yml | 3 + .../avo/templates/locales/avo.it.yml | 3 + .../avo/templates/locales/avo.ja.yml | 3 + .../avo/templates/locales/avo.nb.yml | 3 + .../avo/templates/locales/avo.nl.yml | 3 + .../avo/templates/locales/avo.nn.yml | 3 + .../avo/templates/locales/avo.pl.yml | 3 + .../avo/templates/locales/avo.pt-BR.yml | 3 + .../avo/templates/locales/avo.pt.yml | 3 + .../avo/templates/locales/avo.ro.yml | 3 + .../avo/templates/locales/avo.ru.yml | 3 + .../avo/templates/locales/avo.tr.yml | 3 + .../avo/templates/locales/avo.uk.yml | 3 + .../avo/templates/locales/avo.zh.yml | 3 + package.json | 1 + .../resource_tools/_fish_information.html.erb | 8 +- spec/dummy/config/initializers/avo.rb | 7 + spec/dummy/config/locales/avo.en.yml | 3 + tailwind.preset.js | 4 +- yarn.lock | 28 +-- 60 files changed, 915 insertions(+), 107 deletions(-) create mode 100644 app/components/avo/media_library/item_details_component.html.erb create mode 100644 app/components/avo/media_library/item_details_component.rb create mode 100644 app/components/avo/media_library/list_component.html.erb create mode 100644 app/components/avo/media_library/list_component.rb create mode 100644 app/components/avo/media_library/list_item_component.html.erb create mode 100644 app/components/avo/media_library/list_item_component.rb create mode 100644 app/controllers/avo/media_library_controller.rb create mode 100644 app/javascript/js/controllers/media_library_attach_controller.js create mode 100644 app/javascript/js/controllers/media_library_controller.js create mode 100644 app/javascript/js/helpers/index.js create mode 100644 app/views/avo/media_library/_form.html.erb create mode 100644 app/views/avo/media_library/_information.html.erb create mode 100644 app/views/avo/media_library/index.html.erb create mode 100644 app/views/avo/media_library/show.html.erb create mode 100644 lib/avo/media_library/configuration.rb diff --git a/app/assets/stylesheets/avo.base.css b/app/assets/stylesheets/avo.base.css index 2fdf6fcdba..956131a321 100644 --- a/app/assets/stylesheets/avo.base.css +++ b/app/assets/stylesheets/avo.base.css @@ -93,6 +93,18 @@ dialog#turbo-confirm { @apply bg-transparent; } +dl { + @apply text-sm; + + dt { + @apply font-bold inline-block mt-1; + } + + dd { + @apply inline-block ml-0; + } +} + /* TODO: make content like tailwindcss */ .floating-row-controls { &:before { diff --git a/app/assets/stylesheets/css/fields/trix.css b/app/assets/stylesheets/css/fields/trix.css index 53c2046296..5a4044408f 100644 --- a/app/assets/stylesheets/css/fields/trix.css +++ b/app/assets/stylesheets/css/fields/trix.css @@ -35,3 +35,10 @@ trix-toolbar { @apply ml-0; } } + +/* Hack to remove border from trix when rendered from ActionText */ +.trix-content { + & .trix-content { + border: none; + } +} diff --git a/app/components/avo/base_component.rb b/app/components/avo/base_component.rb index a9f6abbb18..72dfe5ba75 100644 --- a/app/components/avo/base_component.rb +++ b/app/components/avo/base_component.rb @@ -14,6 +14,8 @@ def has_with_trial(ability) Avo.license.has_with_trial(ability) end + def component_name = self.class.name.to_s.underscore + private # Use the @parent_resource to fetch the field using the @reflection name. diff --git a/app/components/avo/fields/tiptap_field/show_component.html.erb b/app/components/avo/fields/tiptap_field/show_component.html.erb index 0fe89ae013..0787a2d7cd 100644 --- a/app/components/avo/fields/tiptap_field/show_component.html.erb +++ b/app/components/avo/fields/tiptap_field/show_component.html.erb @@ -1,13 +1,9 @@ -<%= field_wrapper(**field_wrapper_args) do %> - <% - content_classes = 'tiptap__content py-2 max-w-4xl' - content_classes << ' hidden' unless @field.always_show - %> -
+<%= field_wrapper(**field_wrapper_args, data: { controller: 'hidden-input' }) do %> +
<% unless @field.always_show %> <%= link_to t('avo.show_content'), 'javascript:void(0);', class: 'font-bold inline-block', data: { action: 'click->hidden-input#showContent' } %> <% end %> -
+
" data-hidden-input-target="content"> <%= sanitize @field.value.to_s %>
diff --git a/app/components/avo/fields/trix_field/edit_component.html.erb b/app/components/avo/fields/trix_field/edit_component.html.erb index 7318cffc7e..b7e1b7fbe2 100644 --- a/app/components/avo/fields/trix_field/edit_component.html.erb +++ b/app/components/avo/fields/trix_field/edit_component.html.erb @@ -1,18 +1,14 @@ <%= field_wrapper(**field_wrapper_args) do %> <%= content_tag :div, - class: "relative block overflow-x-auto max-w-4xl", - data: { - controller: "trix-field", - trix_field_target: "controller", - **data_values, - } do %> + class: class_names("relative block overflow-x-auto max-w-4xl", @input_id), + data: do %> <%= content_tag 'trix-editor', class: 'trix-content', data: { "trix-field-target": "editor", **@field.get_html(:data, view: view, element: :input) }, - input: trix_id, + input: @input_id, placeholder: @field.placeholder do %> <%= sanitize @field.value.to_s %> <% end %> @@ -21,7 +17,7 @@ class: classes("w-full hidden"), data: @field.get_html(:data, view: view, element: :input), disabled: disabled?, - id: trix_id, + id: @input_id, placeholder: @field.placeholder, style: @field.get_html(:style, view: view, element: :input) %> diff --git a/app/components/avo/fields/trix_field/edit_component.rb b/app/components/avo/fields/trix_field/edit_component.rb index af067ce428..a19ebbef84 100644 --- a/app/components/avo/fields/trix_field/edit_component.rb +++ b/app/components/avo/fields/trix_field/edit_component.rb @@ -9,20 +9,21 @@ def initialize(**args) @resource_name = args[:resource_name] || @resource&.singular_route_key super(**args) - end - def trix_id - if @resource_name.present? - "trix_#{@resource_name}_#{@field.id}" + @input_id = if @resource_name.present? + "#{@field.type}_#{@resource_name}_#{@field.id}" elsif form.present? - "trix_#{form.index}_#{@field.id}" + "#{@field.type}_#{form.index}_#{@field.id}" end end - def data_values - { + # The controller element should have a unique_selector attribute. + # It's used to identify the specific editor for the media library to delegate the attach event to. + def data + values = { resource_name: @resource_name, resource_id: @resource_id, + unique_selector: ".#{@input_id}", # mandatory attachments_disabled: @field.attachments_disabled, attachment_key: @field.attachment_key, hide_attachment_filename: @field.hide_attachment_filename, @@ -33,5 +34,12 @@ def data_values attachment_disable_warning: t("avo.this_field_has_attachments_disabled"), attachment_key_warning: t("avo.you_havent_set_attachment_key") }.transform_keys { |key| "trix_field_#{key}_value" } + + { + controller: "trix-field", + trix_field_target: "controller", + action: "insert-attachment->trix-field#insertAttachment", + **values, + } end end diff --git a/app/components/avo/fields/trix_field/show_component.html.erb b/app/components/avo/fields/trix_field/show_component.html.erb index 3b053fd740..fb1ccdc8f7 100644 --- a/app/components/avo/fields/trix_field/show_component.html.erb +++ b/app/components/avo/fields/trix_field/show_component.html.erb @@ -1,15 +1,11 @@ -<%= field_wrapper(**field_wrapper_args) do %> - <% - content_classes = 'trix-content py-2 max-w-4xl' - content_classes << ' hidden' unless @field.always_show - button_classes = 'font-bold inline-block pt-3' - %> +<%= field_wrapper(**field_wrapper_args, full_width: true) do %> + <% button_classes = 'font-semibold inline-block pt-3 text-sm' %> <%= content_tag :div, data: { controller: "trix-body", trix_body_always_show_value: @field.always_show } do %> -
+
" data-trix-body-target="content"> <%= sanitize @field.value.to_s %>
<% unless @field.always_show %> diff --git a/app/components/avo/media_library/item_details_component.html.erb b/app/components/avo/media_library/item_details_component.html.erb new file mode 100644 index 0000000000..e294c38eeb --- /dev/null +++ b/app/components/avo/media_library/item_details_component.html.erb @@ -0,0 +1,32 @@ +
+ <%= link_to helpers.svg('heroicons/outline/x-mark', class: "size-6"), helpers.avo.media_library_index_path, + class: "absolute z-10 inset-auto right-0 top-0 mt-2 mr-2 block bg-white p-1 rounded-lg text-slate-600 hover:text-slate-900", + title: t('avo.close'), + data: { + tippy: :tooltip, + } %> +
+
+ <% if @blob.image? %> + <%= image_tag helpers.main_app.url_for(@blob), class: "max-w-full rounded-lg max-h-xl", loading: :lazy %> + <% elsif @blob.audio? %> + <%= audio_tag(helpers.main_app.url_for(@blob), controls: true, preload: false, class: 'w-full') %> + <% elsif @blob.video? %> + <%= video_tag(helpers.main_app.url_for(@blob), controls: true, preload: false, class: 'w-full') %> + <% else %> +
+ <%= helpers.svg "heroicons/outline/document-text", class: 'h-10 text-gray-600 mb-2' %> +
+ <% end %> +
+ <%= link_to "Download", helpers.main_app.url_for(@blob), download: true %> + <%= link_to "Copy URL to clipboard", helpers.main_app.url_for(@blob), data: {controller: "copy-to-clipboard", text: helpers.main_app.url_for(@blob), action: "click->copy-to-clipboard#copy"} %> + <%= link_to "Delete", helpers.avo.media_library_path(@blob), class: "text-red-500", data: {turbo_confirm: "Are you sure you want to destroy this attachment?", turbo_method: :delete} %> +
+
+
+ <%= render partial: "avo/media_library/information", locals: {blob: @blob} %> + <%= render partial: "avo/media_library/form", locals: {blob: @blob} %> +
+
+
diff --git a/app/components/avo/media_library/item_details_component.rb b/app/components/avo/media_library/item_details_component.rb new file mode 100644 index 0000000000..ac3edad0f5 --- /dev/null +++ b/app/components/avo/media_library/item_details_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Avo + module MediaLibrary + class ItemDetailsComponent < Avo::BaseComponent + include Turbo::FramesHelper + include Avo::ApplicationHelper + + prop :blob + end + end +end diff --git a/app/components/avo/media_library/list_component.html.erb b/app/components/avo/media_library/list_component.html.erb new file mode 100644 index 0000000000..3fa1c6c87e --- /dev/null +++ b/app/components/avo/media_library/list_component.html.erb @@ -0,0 +1,59 @@ +<%= render Avo::PanelComponent.new title: t("avo.media_library.title"), + data: { + controller: 'media-library', + media_library_controller_selector_value: params[:controller_selector], + media_library_controller_name_value: params[:controller_name], + media_library_item_details_frame_id_value: ::Avo::MEDIA_LIBRARY_ITEM_DETAILS_FRAME_ID, + } do |c| %> + <%= c.with_tools do %> + <% if false && @attaching %> + <%= a_button data: { + action: 'click->media-library#selectItems', + } do %> + Attach + <% end %> + <% end %> + <% end %> + <% c.with_body do %> + <% if !@attaching %> + <%= content_tag :div, class: "p-4 pb-0", + data: { + controller: 'media-library-attach', + media_library_attach_direct_uploads_url_value: helpers.main_app.rails_direct_uploads_url, + } do %> + <%= content_tag :div, + class: 'dropzone relative py-6 text-center border-dashed border-2 border-gray-300 rounded-lg justify-center items-center flex flex-col text-gray-400 hover:border-primary-500 cursor-pointer', + data: { + media_library_attach_target: 'dropzone', + action: 'click->media-library-attach#triggerFileBrowser', + } do %> + <%= helpers.svg 'heroicons/outline/cloud-arrow-up', class: 'size-6 text-gray-400' %> + Upload a file + Click to browse or drag and drop + <% end %> + + <%= content_tag :div, "", class: "m-2 flex flex-wrap gap-2 empty:m-0", data: { + media_library_attach_target: 'uploadingContainer', + turbo_permanent: true, + } %> + <% end %> + <% end %> +
+
+ <%= render Avo::MediaLibrary::ListItemComponent.with_collection(@blobs, attaching: @attaching, multiple: @attaching) %> +
+ <%# TODO: fix the extra margin %> + <%= helpers.turbo_frame_tag ::Avo::MEDIA_LIBRARY_ITEM_DETAILS_FRAME_ID, class: 'relative empty:hidden bg-white inset-0 w-full h-full block empty:-ml-4 max-h-full', style: 'grid-area: stack;' %> +
+ <% end %> + <% c.with_bare_content do %> +
+
+
<%== helpers.pagy_info @pagy %>
+ <% if @pagy.pages > 1 %> + <%== helpers.pagy_nav(@pagy, xanchor_string: "data-turbo-frame=\"#{@turbo_frame}\"") %> + <% end %> +
+
+ <% end %> +<% end %> diff --git a/app/components/avo/media_library/list_component.rb b/app/components/avo/media_library/list_component.rb new file mode 100644 index 0000000000..b9b06a7aeb --- /dev/null +++ b/app/components/avo/media_library/list_component.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Avo + module MediaLibrary + class ListComponent < Avo::BaseComponent + include Avo::ApplicationHelper + include Pagy::Backend + + def initialize(attaching: false, turbo_frame: nil) + @attaching = attaching + @pagy, @blobs = pagy(query, limit:) + turbo_frame ||= params[:turbo_frame] + @turbo_frame = turbo_frame.present? ? CGI.escapeHTML(turbo_frame.to_s) : :_top + end + + def controller = Avo::Current.view_context.controller + + def query + ActiveStorage::Blob.includes(:attachments) + # ignore blobs who are just a variant to avoid "n+1" blob creation + .where.not(id: ActiveStorage::Attachment.where(record_type: "ActiveStorage::VariantRecord").pluck(:blob_id)) + .order(created_at: :desc) + end + + def limit = @attaching ? 12 : 24 + end + end +end diff --git a/app/components/avo/media_library/list_item_component.html.erb b/app/components/avo/media_library/list_item_component.html.erb new file mode 100644 index 0000000000..2d1d124405 --- /dev/null +++ b/app/components/avo/media_library/list_item_component.html.erb @@ -0,0 +1,26 @@ +<%= link_to helpers.avo.media_library_path(blob), + id: dom_id(blob), + class: "relative group min-h-full max-w-full flex-1 flex flex-col justify-between gap-2 border border-slate-200 p-1.5 rounded-xl hover:border-blue-500 hover:outline data-[selected=true]:border-blue-500 data-[selected=true]:outline outline-2 outline-blue-500", + data: do %> + <% if false && @attaching %> +
<%= helpers.svg "heroicons/outline/check", class: 'group-data-[selected=true]:opacity-100 opacity-0 size-4' %>
+ <% end %> +
+ <% if blob.image? %> + <%= image_tag helpers.main_app.url_for(blob.variant(resize_to_limit: [600, 600])), class: "max-w-full self-start #{@extra_classes}", loading: :lazy, width: blob.metadata["width"], height: blob.metadata["height"] %> + <% elsif blob.audio? %> + <%= audio_tag(helpers.main_app.url_for(blob), controls: true, preload: false, class: 'w-full') %> + <% elsif blob.video? %> + <%= video_tag(helpers.main_app.url_for(blob), controls: true, preload: false, class: 'w-full') %> + <% else %> +
+ <%= helpers.svg "heroicons/outline/document-text", class: 'h-10 text-gray-600 mb-2' %> +
+ <% end %> +
+
+ <% if @display_filename %> + <%= blob.filename %> + <% end %> +
+<% end %> diff --git a/app/components/avo/media_library/list_item_component.rb b/app/components/avo/media_library/list_item_component.rb new file mode 100644 index 0000000000..872b270fa8 --- /dev/null +++ b/app/components/avo/media_library/list_item_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Avo + module MediaLibrary + class ListItemComponent < Avo::BaseComponent + with_collection_parameter :blob + + prop :blob, reader: :public + prop :display_filename, default: true + prop :attaching, default: false + prop :multiple, default: false + + def data + { + component: component_name, + blob_id: blob.id, + media_library_blob_param: blob.as_json, + media_library_path_param: helpers.main_app.url_for(blob), + media_library_attaching_param: @attaching, + media_library_multiple_param: @multiple, + media_library_selected_item: params[:controller_selector], + action: "click->media-library#selectItem" + }.tap do |result| + if @attaching + result[:turbo_frame] = Avo::MEDIA_LIBRARY_ITEM_DETAILS_FRAME_ID + result[:turbo_prefetch] = false + else + result[:turbo_prefetch] = true + end + end + end + end + end +end diff --git a/app/components/avo/paginator_component.html.erb b/app/components/avo/paginator_component.html.erb index c5c8b649b7..976add7fa7 100644 --- a/app/components/avo/paginator_component.html.erb +++ b/app/components/avo/paginator_component.html.erb @@ -24,7 +24,7 @@
-
+
<% if @resource.pagination_type.default? %>
<%== helpers.pagy_info @pagy %>
<% end %> diff --git a/app/components/avo/paginator_component.rb b/app/components/avo/paginator_component.rb index 318197b7fa..cfac81f33d 100644 --- a/app/components/avo/paginator_component.rb +++ b/app/components/avo/paginator_component.rb @@ -39,13 +39,4 @@ def per_page_options options.sort.uniq end end - - def pagy_major_version - return nil unless defined?(Pagy::VERSION) - version = Pagy::VERSION&.split(".")&.first&.to_i - - return "8-or-more" if version >= 8 - - version - end end diff --git a/app/components/avo/sidebar_component.html.erb b/app/components/avo/sidebar_component.html.erb index f43d39f811..8284ebc6f7 100644 --- a/app/components/avo/sidebar_component.html.erb +++ b/app/components/avo/sidebar_component.html.erb @@ -12,6 +12,8 @@
<%= render Avo::Sidebar::LinkComponent.new label: 'Get started', path: helpers.avo.root_path, active: :exclusive if Rails.env.development? && Avo.configuration.home_path.nil? %> + <%= render Avo::Sidebar::LinkComponent.new label: 'Media Library', path: helpers.avo.media_library_index_path, active: :exclusive if Avo::MediaLibrary.configuration.visible? %> + <% if Avo.plugin_manager.installed?(:avo_menu) && Avo.has_main_menu? %> <% Avo.main_menu.items.each do |item| %> <%= render Avo::Sidebar::ItemSwitcherComponent.new item: item %> diff --git a/app/controllers/avo/actions_controller.rb b/app/controllers/avo/actions_controller.rb index 256449902f..eb0cd67e44 100644 --- a/app/controllers/avo/actions_controller.rb +++ b/app/controllers/avo/actions_controller.rb @@ -33,7 +33,7 @@ def build_background_url params = URI.decode_www_form(uri.query || "").to_h params.delete("action_id") - params[:turbo_frame] = ACTIONS_BACKGROUND_FRAME + params[:turbo_frame] = ACTIONS_BACKGROUND_FRAME_ID # Reconstruct the query string new_query_string = URI.encode_www_form(params) diff --git a/app/controllers/avo/media_library_controller.rb b/app/controllers/avo/media_library_controller.rb new file mode 100644 index 0000000000..6e9c4280fe --- /dev/null +++ b/app/controllers/avo/media_library_controller.rb @@ -0,0 +1,42 @@ +module Avo + class MediaLibraryController < ApplicationController + include Pagy::Backend + before_action :authorize_access! + + def index + @attaching = false + end + + def show + @blob = ActiveStorage::Blob.find(params[:id]) + end + + def destroy + @blob = ActiveStorage::Blob.find(params[:id]) + @blob.destroy! + + redirect_to avo.media_library_index_path + end + + def update + @blob = ActiveStorage::Blob.find(params[:id]) + @blob.update!(blob_params) + end + + def attach + @attaching = true + + render :index + end + + private + + def blob_params + params.require(:blob).permit(:filename, metadata: [:title, :alt, :description]) + end + + def authorize_access! + raise_404 unless Avo::MediaLibrary.configuration.visible? + end + end +end diff --git a/app/helpers/avo/application_helper.rb b/app/helpers/avo/application_helper.rb index 0daa474c82..7b1ba7c001 100644 --- a/app/helpers/avo/application_helper.rb +++ b/app/helpers/avo/application_helper.rb @@ -145,6 +145,15 @@ def possibly_rails_authentication? defined?(Authentication) && Authentication.private_instance_methods.include?(:require_authentication) && Authentication.private_instance_methods.include?(:authenticated?) end + def pagy_major_version + return nil unless defined?(Pagy::VERSION) + version = Pagy::VERSION&.split(".")&.first&.to_i + + return "8-or-more" if version >= 8 + + version + end + def container_is_full_width? if @container_full_width.present? @container_full_width diff --git a/app/helpers/avo/resources_helper.rb b/app/helpers/avo/resources_helper.rb index de16039368..54055f9abd 100644 --- a/app/helpers/avo/resources_helper.rb +++ b/app/helpers/avo/resources_helper.rb @@ -57,5 +57,18 @@ def resource_show_path(resource:, parent_resource: nil, parent_record: nil, pare resource_path(record: resource.record, resource: parent_or_child_resource, **args) end + + def resource_for_record(record) + klass = Avo.resource_manager.get_resource_by_model_class(record.class) + klass.new(record: record) + end + + def record_title(record) + resource_for_record(record).record_title + end + + def record_path(record) + resource_for_record(record).record_path + end end end diff --git a/app/javascript/js/controllers.js b/app/javascript/js/controllers.js index 48ad154aee..3876d7104e 100644 --- a/app/javascript/js/controllers.js +++ b/app/javascript/js/controllers.js @@ -22,6 +22,8 @@ import ItemSelectAllController from './controllers/item_select_all_controller' import ItemSelectorController from './controllers/item_selector_controller' import KeyValueController from './controllers/fields/key_value_controller' import LoadingButtonController from './controllers/loading_button_controller' +import MediaLibraryAttachController from './controllers/media_library_attach_controller' +import MediaLibraryController from './controllers/media_library_controller' import MenuController from './controllers/menu_controller' import ModalController from './controllers/modal_controller' import MultipleSelectFilterController from './controllers/multiple_select_filter_controller' @@ -68,6 +70,8 @@ application.register('input-autofocus', InputAutofocusController) application.register('item-select-all', ItemSelectAllController) application.register('item-selector', ItemSelectorController) application.register('loading-button', LoadingButtonController) +application.register('media-library', MediaLibraryController) +application.register('media-library-attach', MediaLibraryAttachController) application.register('menu', MenuController) application.register('modal', ModalController) application.register('multiple-select-filter', MultipleSelectFilterController) diff --git a/app/javascript/js/controllers/copy_to_clipboard_controller.js b/app/javascript/js/controllers/copy_to_clipboard_controller.js index 4ff80bf602..47d4e2551b 100644 --- a/app/javascript/js/controllers/copy_to_clipboard_controller.js +++ b/app/javascript/js/controllers/copy_to_clipboard_controller.js @@ -1,7 +1,10 @@ import { Controller } from '@hotwired/stimulus' +// Connects to data-controller="copy-to-clipboard" +//
export default class extends Controller { - copy() { + copy(event) { + event.preventDefault() const str = this.context.element.dataset.text /* ——— Derived from: https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f improved to add iOS device compatibility——— */ @@ -33,5 +36,15 @@ export default class extends Controller { el.contentEditable = storeContentEditable el.readOnly = storeReadOnly + + const target = this.element + const originalHTML = target.innerHTML + target.innerHTML = 'Copied 👌' + target.classList.add('transition', 'opacity-80', 'bg-green-100') + + setTimeout(() => { + target.innerHTML = originalHTML + target.classList.remove('opacity-80', 'bg-green-100') + }, 1500) } } diff --git a/app/javascript/js/controllers/fields/trix_field_controller.js b/app/javascript/js/controllers/fields/trix_field_controller.js index 9b9796c352..a4131468d5 100644 --- a/app/javascript/js/controllers/fields/trix_field_controller.js +++ b/app/javascript/js/controllers/fields/trix_field_controller.js @@ -1,9 +1,14 @@ +/* eslint-disable no-console */ +/* eslint-disable camelcase */ /* eslint-disable no-alert */ import 'trix' import URI from 'urijs' import { Controller } from '@hotwired/stimulus' +// eslint-disable-next-line max-len +const galleryButtonSVG = '' + export default class extends Controller { static targets = ['editor', 'controller'] @@ -21,63 +26,132 @@ export default class extends Controller { attachmentKeyWarning: String, } + get rootPath() { + return new URI(window.Avo.configuration.root_path) + } + get uploadUrl() { // Parse the current URL const url = new URI(window.location.origin) - // Parse the root path - const rootPath = new URI(window.Avo.configuration.root_path) // Build the trix field path - url.path(`${rootPath.path()}/avo_api/resources/${this.resourceNameValue}/${this.resourceIdValue}/attachments`) + url.path(`${this.rootPath.path()}/avo_api/resources/${this.resourceNameValue}/${this.resourceIdValue}/attachments`) // Add the params back - url.query(rootPath.query()) + url.query(this.rootPath.query()) return url.toString() } connect() { - if (this.attachmentsDisabledValue) { - // Remove the attachments button - window.addEventListener('trix-initialize', (event) => { - if (event.target === this.editorTarget) { - this.controllerTarget.querySelector('.trix-button-group--file-tools').remove() - } - }) - } + this.#attachTrixListeners() + } - window.addEventListener('trix-file-accept', (event) => { - if (event.target === this.editorTarget) { - // Prevent file uploads for fields that have attachments disabled. - if (this.attachmentsDisabledValue) { - event.preventDefault() - alert(this.attachmentDisableWarningValue) + disconnect() { + this.#removeTrixListeners() + } - return - } + // Invoked by the other controllers (media-library) + insertAttachments(attachments, event) { + if (!attachments) { + console.warning('[Avo->] No attachments present.') - // Prevent file uploads for resources that haven't been saved yet. - if (!this.resourceIdValue) { - event.preventDefault() - alert(this.uploadWarningValue) + return + } - return - } + attachments.forEach((attachment) => { + const { path, blob } = attachment - // Prevent file uploads for fields without an attachment key. - // When is rich text, attachment key is not needed. - if (!this.isActionTextValue && !this.attachmentKeyValue) { - event.preventDefault() - alert(this.attachmentKeyWarningValue) - } + const payload = { + url: path, + filename: blob.filename, + contentType: blob.content_type, + previewable: true, } + + this.#injectAttachment(payload, event) }) + } + + // eslint-disable-next-line no-unused-vars + #injectAttachment(attachment, event) { + const model = new window.Trix.models.Attachment(attachment) + this.editorTarget.editorController.editor.insertAttachment(model) + } - window.addEventListener('trix-attachment-add', (event) => { + #removeTrixListeners() { + this.element.removeEventListener('trix-file-accept', this.#trixFileAccept.bind(this)) + this.element.removeEventListener('trix-attachment-add', this.#trixAttachmentAdd.bind(this)) + this.element.removeEventListener('trix-initialize', this.#trixInitialize.bind(this)) + } + + #attachTrixListeners() { + this.element.addEventListener('trix-file-accept', this.#trixFileAccept.bind(this)) + this.element.addEventListener('trix-attachment-add', this.#trixAttachmentAdd.bind(this)) + this.element.addEventListener('trix-initialize', this.#trixInitialize.bind(this)) + } + + #trixInitialize(event) { + // Remove the attachments button from the toolbar if the field has attachments disabled + if (this.attachmentsDisabledValue) { if (event.target === this.editorTarget) { - if (event.attachment.file) { - this.uploadFileAttachment(event.attachment) - } + this.controllerTarget.querySelector('.trix-button-group--file-tools').remove() } - }) + } + + const controllerElement = this.element.closest('[data-trix-field-target="controller"]') + const params = { + resource_name: this.resourceNameValue, + record_id: this.resourceIdValue, + controller_selector: controllerElement.dataset.trixFieldUniqueSelectorValue, + controller_name: this.identifier, + } + + const mediaLibraryPath = new URI(`${this.rootPath.path()}/attach-media`) + mediaLibraryPath.addSearch(params) + const mediaLibraryVisible = window.Avo.configuration.media_library.visible && window.Avo.configuration.media_library.enabled + + // Add the gallery button to the toolbar + // const buttonHTML = `` + const buttonHTML = `${galleryButtonSVG}` + if (mediaLibraryVisible && event.target.toolbarElement && event.target.toolbarElement.querySelector('.trix-button-group--file-tools')) { + event.target.toolbarElement + .querySelector('.trix-button-group--file-tools') + .insertAdjacentHTML('beforeend', buttonHTML) + } + } + + #trixAttachmentAdd(event) { + if (event.target === this.editorTarget) { + if (event.attachment.file) { + this.uploadFileAttachment(event.attachment) + } + } + } + + #trixFileAccept(event) { + if (event.target === this.editorTarget) { + // Prevent file uploads for fields that have attachments disabled. + if (this.attachmentsDisabledValue) { + event.preventDefault() + alert(this.attachmentDisableWarningValue) + + return + } + + // Prevent file uploads for resources that haven't been saved yet. + if (!this.resourceIdValue) { + event.preventDefault() + alert(this.uploadWarningValue) + + return + } + + // Prevent file uploads for fields without an attachment key. + // When is rich text, attachment key is not needed. + if (!this.isActionTextValue && !this.attachmentKeyValue) { + event.preventDefault() + alert(this.attachmentKeyWarningValue) + } + } } uploadFileAttachment(attachment) { diff --git a/app/javascript/js/controllers/media_library_attach_controller.js b/app/javascript/js/controllers/media_library_attach_controller.js new file mode 100644 index 0000000000..e740c47bdd --- /dev/null +++ b/app/javascript/js/controllers/media_library_attach_controller.js @@ -0,0 +1,160 @@ +/* eslint-disable no-console */ +// eslint-disable-next-line max-classes-per-file +import { Controller } from '@hotwired/stimulus' +import { DirectUpload } from '@rails/activestorage' +import { escape } from 'lodash' + +class UploadObject extends EventTarget { + constructor(file, controller) { + super() + + this.file = file + this.controller = controller + this.finished = false + } + + hasFinished() { + return this.finished + } + + handle() { + const upload = new DirectUpload(this.file, this.controller.directUploadsUrlValue, this) + + this.#addUploadingItem(this.file) + + upload.create((error, blob) => { + if (error) { + console.log('Error', error) + } + this.listItem.classList.add('text-gray-400') + + this.finished = true + this.dispatchEvent(new Event('done')) + }) + } + + #addUploadingItem(file) { + const div = document.createElement('div') + div.classList.add('flex', 'justify-between', 'gap-2', 'text-sm') + div.innerHTML = `
${escape(file.name)}
0%
` + this.listItem = this.controller.uploadingContainerTarget.appendChild(div) + } + + directUploadWillStoreFileWithXHR(request) { + request.upload.addEventListener( + 'progress', + (event) => this.#directUploadDidProgress(event), + ) + } + + #directUploadDidProgress(event) { + const progress = (event.loaded / event.total) * 100 + const element = this.listItem.querySelector('.progress') + element.textContent = `${progress.toFixed(2)}%` + } +} + +// Connects to data-controller="media-library-new" +export default class extends Controller { + static targets = ['dropzone', 'uploadingContainer'] + + static values = { + directUploadsUrl: String, + } + + dragCopy = 'drag file or click to upload' + + dropCopy = "drop it like it's hot" + + droppedCopy = 'uploading file. hang tight' + + unsupportedCopy = 'wrong file type. try again' + + draggingClasses = ['dropzone-dragging', '!border-gray-700', '!text-gray-700'] + + connect() { + this.attachHandlers() + this.setupFileInput() + } + + setupFileInput() { + // Create a hidden file input element + this.fileInput = document.createElement('input') + this.fileInput.type = 'file' + this.fileInput.multiple = true + this.fileInput.style.display = 'none' + this.element.appendChild(this.fileInput) + + // Handle file selection + this.fileInput.addEventListener('change', (e) => { + const files = Array.from(e.target.files) + this.#submitFiles(files) + this.fileInput.value = '' // Reset the input for future selections + }) + } + + triggerFileBrowser() { + this.fileInput.click() + } + + attachHandlers() { + const vm = this + + Array.from(['drag', 'dragstart', 'dragend', 'dragover', 'dragenter', 'dragleave', 'drop']).forEach((event) => { + vm.dropzoneTarget.addEventListener(event, (e) => { + e.preventDefault() + e.stopPropagation() + }) + }) + + this.dropzoneTarget.addEventListener('dragover', (e) => { + vm.dragAction() + }, false) + + this.dropzoneTarget.addEventListener('dragleave', (e) => { + vm.dropAction() + }, false) + + this.dropzoneTarget.addEventListener('drop', (e) => { + vm.dropAction() + const { files } = e.dataTransfer + this.#submitFiles(files) + }, false) + } + + #submitFiles(files) { + this.uploadObjects = [] + Array.from(files).forEach((file) => { + const uploadObject = new UploadObject(file, this) + this.uploadObjects.push(uploadObject) + uploadObject.addEventListener('done', () => { + this.#checkIfAllFinished() + }) + uploadObject.handle() + }) + } + + #checkIfAllFinished() { + if (this.uploadObjects.every((uploadObject) => uploadObject.hasFinished())) { + this.#refreshPage() + } + } + + #refreshPage() { + const streamElement = document.createElement('turbo-stream') + streamElement.setAttribute('action', 'refresh') + streamElement.setAttribute('method', 'morph') + streamElement.setAttribute('render', 'morph') + this.element.appendChild(streamElement) + } + + dragAction() { + this.dropzoneTarget.classList.add(...this.draggingClasses) + this.dropzoneTarget.innerHTML = this.dropCopy + } + + dropAction() { + this.dropzoneTarget.classList.remove(...this.draggingClasses) + this.dropzoneTarget.innerHTML = this.droppedCopy + } +} diff --git a/app/javascript/js/controllers/media_library_controller.js b/app/javascript/js/controllers/media_library_controller.js new file mode 100644 index 0000000000..b6746c4d02 --- /dev/null +++ b/app/javascript/js/controllers/media_library_controller.js @@ -0,0 +1,88 @@ +/* eslint-disable no-console */ +import { Controller } from '@hotwired/stimulus' +import { closeModal } from '../helpers' + +// Connects to data-controller="media-library" +export default class extends Controller { + static outlets = ['field'] + + static values = { + itemDetailsFrameId: String, + controllerSelector: String, + controllerName: String, + selectedItems: Array, + } + + // get the controller for the selector and name + get otherController() { + return this.application.getControllerForElementAndIdentifier(document.querySelector(this.controllerSelectorValue), this.controllerNameValue) + } + + // get the controller for the selector and name + get modalController() { + return this.application.getControllerForElementAndIdentifier(document.querySelector('[data-controller="modal"]'), 'modal') + } + + // selectItems(event) { + // // Search the DOM for the media library list component + // const mediaLibraryList = document.querySelector('[data-controller="media-library"]') + + // // Get the elements that have the data-selected attribute set to true + // const selectedItems = mediaLibraryList.querySelectorAll('[data-selected="true"]') + + // // get the attachment and blob information from the selected items + // const attachments = Array.from(selectedItems).map((item) => { + // const attachment = JSON.parse(item.dataset.mediaLibraryAttachmentParam) + // const blob = JSON.parse(item.dataset.mediaLibraryBlobParam) + // const path = item.dataset.mediaLibraryPathParam + + // return { attachment, blob, path } + // }) + + // this.insertAttachments(attachments, event) + // this.modalController.closeModal() + // } + + selectItem(event) { + const { params } = event + const { attaching, multiple } = params + const item = event.target.closest('[data-component="avo/media_library/list_item_component"]') + const attachments = [this.#extractMetadataFromItem(item)] + // When attaching, we want to prevent showing the details screen and instead just check the attachment + if (attaching) { + event.preventDefault() + this.insertAttachments(attachments, event) + closeModal() + + // TODO: allow multiple attachments + // if (multiple) { + // const element = document.querySelector(`[data-attachment-id="${params.attachment.id}"]`) + // element.dataset.selected = !(element.dataset.selected === 'true') + // } else { + // this.insertAttachments(attachments, event) + // } + } + } + + insertAttachments(attachments, event) { + // show an error if the controller is not found + if (!this.otherController) { + console.error('[Avo->] The Media Library failed to find any field outlets to inject the asset.') + + return + } + // Trigger the insertAttachment action on the other controller + this.otherController.insertAttachments(attachments, event) + } + + closeItemDetails() { + document.querySelector(`turbo-frame#${this.itemDetailsFrameIdValue}`).innerHTML = '' + } + + #extractMetadataFromItem(item) { + const blob = JSON.parse(item.dataset.mediaLibraryBlobParam) + const path = item.dataset.mediaLibraryPathParam + + return { blob, path } + } +} diff --git a/app/javascript/js/controllers/modal_controller.js b/app/javascript/js/controllers/modal_controller.js index f071983aa4..8d84d6f736 100644 --- a/app/javascript/js/controllers/modal_controller.js +++ b/app/javascript/js/controllers/modal_controller.js @@ -1,5 +1,6 @@ import { Controller } from '@hotwired/stimulus' +// Connects to data-controller="modal" export default class extends Controller { static targets = ['modal', 'backdrop'] @@ -7,11 +8,16 @@ export default class extends Controller { closeModalOnBackdropClick: true, } - close() { + close(event) { if (event.target === this.backdropTarget && !this.closeModalOnBackdropClickValue) return + this.closeModal() + } + + // May be invoked by the other controllers + closeModal() { this.modalTarget.remove() - document.dispatchEvent(new Event('actions-modal:close')) + document.dispatchEvent(new Event('modal-controller:close')) } } diff --git a/app/javascript/js/helpers/index.js b/app/javascript/js/helpers/index.js new file mode 100644 index 0000000000..19c94e1b9f --- /dev/null +++ b/app/javascript/js/helpers/index.js @@ -0,0 +1,5 @@ +const closeModal = () => { + document.querySelector(`turbo-frame#${window.Avo.configuration.modal_frame_id}`).innerHTML = '' +} + +export { closeModal } diff --git a/app/views/avo/actions/show.html.erb b/app/views/avo/actions/show.html.erb index c56ce68fa3..68cdaf5f75 100644 --- a/app/views/avo/actions/show.html.erb +++ b/app/views/avo/actions/show.html.erb @@ -1,4 +1,4 @@ -<%= turbo_frame_tag Avo::ACTIONS_BACKGROUND_FRAME, src: @background_url, loading: :lazy, target: :_top, class: "block" do %> +<%= turbo_frame_tag Avo::ACTIONS_BACKGROUND_FRAME_ID, src: @background_url, loading: :lazy, target: :_top, class: "block" do %> <%= render Avo::LoadingComponent.new(title: "...") %> <% end %> diff --git a/app/views/avo/media_library/_form.html.erb b/app/views/avo/media_library/_form.html.erb new file mode 100644 index 0000000000..ce3064d534 --- /dev/null +++ b/app/views/avo/media_library/_form.html.erb @@ -0,0 +1,40 @@ +<%= form_with model: @blob, url: avo.media_library_path(@blob), method: :patch, class: "pb-6" do |f| %> + <%= field_container do %> + <%= avo_edit_field :filename, as: :text, short: true, stacked: true, form: f, required: true %> + <%= f.fields_for :metadata do |meta_form| %> + <%= avo_edit_field :title, + as: :text, + value: @blob.metadata[:title], + short: true, + stacked: true, + form: meta_form, + required: true + %> + <%= avo_edit_field :alt, + as: :text, + value: @blob.metadata[:alt], + short: true, + stacked: true, + form: meta_form + %> + <%= avo_edit_field :description, + as: :textarea, + value: @blob.metadata[:description], + short: true, + stacked: true, + form: meta_form + %> + <% end %> + <% end %> + +
+ <%= a_button type: :submit, + icon: "avo/save", + size: :sm, + style: :outline, + data: { + } do %> + Update + <% end %> +
+<% end %> diff --git a/app/views/avo/media_library/_information.html.erb b/app/views/avo/media_library/_information.html.erb new file mode 100644 index 0000000000..21f2d075d1 --- /dev/null +++ b/app/views/avo/media_library/_information.html.erb @@ -0,0 +1,50 @@ +
+
+
Filename:
+
<%= @blob.filename %>
+
+
+
Key:
+
<%= @blob.key %>
+
+
+
Content type:
+
<%= @blob.content_type %>
+
+
+
Size:
+
<%= number_to_human_size(@blob.byte_size) %>
+
+
+
Service name:
+
<%= @blob.service_name %>
+
+
+
Created at:
+
<%= @blob.created_at %>
+
+
+ <% if @blob.attachments.present? %> +
Attached to:
+
+ <% @blob.attachments.each do |attachment| %> + <%= link_to record_title(attachment.record), record_path(attachment.record) %> + <% end %> +
+ <% end %> +
+
+
Type:
+
<%= @blob.filename %>
+
+
+
Metadata:
+
+ <% @blob.metadata.each do |key, value| %> +
+ <%= key %>: <%= value %> +
+ <% end %> +
+
+
diff --git a/app/views/avo/media_library/index.html.erb b/app/views/avo/media_library/index.html.erb new file mode 100644 index 0000000000..aef9076e22 --- /dev/null +++ b/app/views/avo/media_library/index.html.erb @@ -0,0 +1,9 @@ +<%= render Avo::TurboFrameWrapperComponent.new(turbo_frame_request_id) do %> + <% if turbo_frame_request_id %> + <%= render Avo::ModalComponent.new do |c| %> + <%= render Avo::MediaLibrary::ListComponent.new attaching: @attaching %> + <% end %> + <% else %> + <%= render Avo::MediaLibrary::ListComponent.new attaching: @attaching %> + <% end %> +<% end %> diff --git a/app/views/avo/media_library/show.html.erb b/app/views/avo/media_library/show.html.erb new file mode 100644 index 0000000000..dae6705382 --- /dev/null +++ b/app/views/avo/media_library/show.html.erb @@ -0,0 +1,5 @@ +<%= render Avo::PanelComponent.new title: t("avo.media_library.title") do |c| %> + <%= c.with_body do %> + <%= render Avo::MediaLibrary::ItemDetailsComponent.new(blob: @blob) %> + <% end %> +<% end %> diff --git a/app/views/avo/partials/_javascript.html.erb b/app/views/avo/partials/_javascript.html.erb index 7e8fc0fbfb..b88831b87e 100644 --- a/app/views/avo/partials/_javascript.html.erb +++ b/app/views/avo/partials/_javascript.html.erb @@ -13,4 +13,8 @@ <% end %> Avo.configuration.modal_frame_id = '<%= ::Avo::MODAL_FRAME_ID %>' Avo.configuration.stimulus_controllers = [] + Avo.configuration.media_library = { + enabled: <%= Avo::MediaLibrary.configuration.enabled %>, + visible: <%= Avo::MediaLibrary.configuration.visible? %> + } <% end %> diff --git a/config/routes.rb b/config/routes.rb index e5480b2117..41afeb9569 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,9 @@ instance_exec(&Avo.mount_engines) end + resources :media_library, only: [:index, :show, :update, :destroy], path: "media-library" + get "attach-media", to: "media_library#attach" + post "/rails/active_storage/direct_uploads", to: "/active_storage/direct_uploads#create" scope "avo_api", as: "avo_api" do diff --git a/lib/avo.rb b/lib/avo.rb index 90e03ceb9d..2518696edb 100644 --- a/lib/avo.rb +++ b/lib/avo.rb @@ -17,10 +17,13 @@ module Avo IN_DEVELOPMENT = ENV["AVO_IN_DEVELOPMENT"] == "1" PACKED = !IN_DEVELOPMENT COOKIES_KEY = "avo" - MODAL_FRAME_ID = :modal_frame - ACTIONS_BACKGROUND_FRAME = :actions_background CACHED_SVGS = {} + # Frame IDs + MODAL_FRAME_ID = :modal_frame + MEDIA_LIBRARY_ITEM_DETAILS_FRAME_ID = :media_library_item_details + ACTIONS_BACKGROUND_FRAME_ID = :actions_background + class LicenseVerificationTemperedError < StandardError; end class LicenseInvalidError < StandardError; end diff --git a/lib/avo/media_library/configuration.rb b/lib/avo/media_library/configuration.rb new file mode 100644 index 0000000000..5d796caca5 --- /dev/null +++ b/lib/avo/media_library/configuration.rb @@ -0,0 +1,22 @@ +module Avo + module MediaLibrary + class Configuration + include ActiveSupport::Configurable + + config_accessor(:visible) { true } + config_accessor(:enabled) { false } + + def visible? + Avo::ExecutionContext.new(target: config[:visible]).handle + end + end + + def self.configuration + @configuration ||= Configuration.new + end + + def self.configure + yield configuration + end + end +end diff --git a/lib/generators/avo/templates/locales/avo.ar.yml b/lib/generators/avo/templates/locales/avo.ar.yml index 9c132a9fc1..00c5b43005 100644 --- a/lib/generators/avo/templates/locales/avo.ar.yml +++ b/lib/generators/avo/templates/locales/avo.ar.yml @@ -28,6 +28,7 @@ ar: choose_item: اختر %{item} clear_value: مسح القيمة click_to_reveal_filters: انقر لإظهار المرشحات + close: إغلاق close_modal: أغلق النافذة confirm: تأكيد copy: نسخ @@ -71,6 +72,8 @@ ar: less_content: محتوى أقل list_is_empty: القائمة فارغة loading: جاري التحميل + media_library: + title: مكتبة الوسائط more: المزيد more_content: المزيد من المحتوى more_records_available: هناك المزيد من السجلات المتاحة. diff --git a/lib/generators/avo/templates/locales/avo.de.yml b/lib/generators/avo/templates/locales/avo.de.yml index 7b470689e7..35ea2ff2c3 100644 --- a/lib/generators/avo/templates/locales/avo.de.yml +++ b/lib/generators/avo/templates/locales/avo.de.yml @@ -22,6 +22,7 @@ de: choose_item: "%{item} auswählen" clear_value: Wert löschen click_to_reveal_filters: Klicken, um Filter anzuzeigen + close: Schließen close_modal: Modal schließen confirm: Bestätigen copy: Kopieren @@ -61,6 +62,8 @@ de: less_content: Weniger Inhalt list_is_empty: Liste ist leer loading: Lade... + media_library: + title: Medienbibliothek more: Mehr more_content: Mehr Inhalt more_records_available: Es sind weitere Datensätze verfügbar. diff --git a/lib/generators/avo/templates/locales/avo.en.yml b/lib/generators/avo/templates/locales/avo.en.yml index ca4b5e87e2..b6fa650c94 100644 --- a/lib/generators/avo/templates/locales/avo.en.yml +++ b/lib/generators/avo/templates/locales/avo.en.yml @@ -22,6 +22,7 @@ en: choose_item: Choose %{item} clear_value: Clear value click_to_reveal_filters: Click to reveal filters + close: Close close_modal: Close modal confirm: Confirm copy: Copy @@ -61,6 +62,8 @@ en: less_content: Less content list_is_empty: List is empty loading: Loading + media_library: + title: Media Library more: More more_content: More content more_records_available: There are more records available. diff --git a/lib/generators/avo/templates/locales/avo.es.yml b/lib/generators/avo/templates/locales/avo.es.yml index 5b8f4f968e..3b26bfa1ef 100644 --- a/lib/generators/avo/templates/locales/avo.es.yml +++ b/lib/generators/avo/templates/locales/avo.es.yml @@ -24,6 +24,7 @@ es: choose_item: Elige %{item} clear_value: Borrar el valor click_to_reveal_filters: Pulsa para mostrar los filtros + close: Cerrar close_modal: Cerrar modal confirm: Confirmar copy: Copiar @@ -63,6 +64,8 @@ es: less_content: Menos contenido list_is_empty: La lista está vacía loading: Cargando + media_library: + title: Biblioteca de medios more: Más more_content: Más contenido more_records_available: Hay más registros disponibles. diff --git a/lib/generators/avo/templates/locales/avo.fr.yml b/lib/generators/avo/templates/locales/avo.fr.yml index 469734a976..64cf8360c8 100644 --- a/lib/generators/avo/templates/locales/avo.fr.yml +++ b/lib/generators/avo/templates/locales/avo.fr.yml @@ -24,6 +24,7 @@ fr: choose_item: Choisir %{item} clear_value: Effacer la valeur click_to_reveal_filters: Cliquez pour révéler les filtres + close: Fermer close_modal: Fermer la fenêtre modale confirm: Confirmer copy: Copier @@ -63,6 +64,8 @@ fr: less_content: Moins de contenu list_is_empty: La liste est vide loading: Chargement + media_library: + title: Bibliothèque multimédia more: Plus more_content: Plus de contenu more_records_available: Il y a plus d'enregistrements disponibles. diff --git a/lib/generators/avo/templates/locales/avo.it.yml b/lib/generators/avo/templates/locales/avo.it.yml index 4948c695b3..088ab48029 100644 --- a/lib/generators/avo/templates/locales/avo.it.yml +++ b/lib/generators/avo/templates/locales/avo.it.yml @@ -22,6 +22,7 @@ it: choose_item: Scegli %{item} clear_value: Cancella valore click_to_reveal_filters: Clicca per mostrare i filtri + close: Chiudi close_modal: Chiudi modale confirm: Conferma copy: Copia @@ -61,6 +62,8 @@ it: less_content: Meno contenuti list_is_empty: La lista è vuota loading: Caricamento in corso + media_library: + title: Libreria multimediale more: Altro more_content: Più contenuti more_records_available: Sono disponibili più record. diff --git a/lib/generators/avo/templates/locales/avo.ja.yml b/lib/generators/avo/templates/locales/avo.ja.yml index 29503a65c4..8b6516a595 100644 --- a/lib/generators/avo/templates/locales/avo.ja.yml +++ b/lib/generators/avo/templates/locales/avo.ja.yml @@ -24,6 +24,7 @@ ja: choose_item: "%{item}を選択" clear_value: 値をクリア click_to_reveal_filters: フィルターを表示するにはクリック + close: 閉じる close_modal: モーダルを閉じる confirm: 確認 copy: コピー @@ -63,6 +64,8 @@ ja: less_content: コンテンツが少ない list_is_empty: リストは空です loading: 読み込み中 + media_library: + title: メディアライブラリ more: もっと more_content: さらなるコンテンツ more_records_available: さらに多くのレコードが利用可能です。 diff --git a/lib/generators/avo/templates/locales/avo.nb.yml b/lib/generators/avo/templates/locales/avo.nb.yml index 804688b0ef..3b5f59bb02 100644 --- a/lib/generators/avo/templates/locales/avo.nb.yml +++ b/lib/generators/avo/templates/locales/avo.nb.yml @@ -24,6 +24,7 @@ nb: choose_item: Velge %{item} clear_value: Nullstill verdi click_to_reveal_filters: Vis filter + close: Lukk close_modal: Lukk modal confirm: Bekreft copy: Kopier @@ -63,6 +64,8 @@ nb: less_content: Mindre innhold list_is_empty: Listen er tom loading: Laster + media_library: + title: Mediebibliotek more: Mer more_content: Mer innhold more_records_available: Det er flere poster tilgjengelig. diff --git a/lib/generators/avo/templates/locales/avo.nl.yml b/lib/generators/avo/templates/locales/avo.nl.yml index 3cf83b2f0a..dcaf35263c 100644 --- a/lib/generators/avo/templates/locales/avo.nl.yml +++ b/lib/generators/avo/templates/locales/avo.nl.yml @@ -22,6 +22,7 @@ nl: choose_item: Kies %{item} clear_value: Waarde wissen click_to_reveal_filters: Klik om filters te tonen + close: Sluiten close_modal: Modaal sluiten confirm: Bevestigen copy: Kopie @@ -61,6 +62,8 @@ nl: less_content: Minder inhoud list_is_empty: Lijst is leeg loading: Laden... + media_library: + title: Mediabibliotheek more: Meer more_content: Meer inhoud more_records_available: Er zijn meer records beschikbaar. diff --git a/lib/generators/avo/templates/locales/avo.nn.yml b/lib/generators/avo/templates/locales/avo.nn.yml index 91006ffc43..e5b9098427 100644 --- a/lib/generators/avo/templates/locales/avo.nn.yml +++ b/lib/generators/avo/templates/locales/avo.nn.yml @@ -24,6 +24,7 @@ nn: choose_item: Vel %{item} clear_value: Nullstill verdi click_to_reveal_filters: Vis filter + close: Lukk close_modal: Lukk modal confirm: Stadfest copy: Kopier @@ -63,6 +64,8 @@ nn: less_content: Mindre innhold list_is_empty: Lista er tom loading: Lastar + media_library: + title: Media bibliotek more: Meir more_content: Mer innhold more_records_available: Det finst fleire opptak tilgjengelege. diff --git a/lib/generators/avo/templates/locales/avo.pl.yml b/lib/generators/avo/templates/locales/avo.pl.yml index 225c5e1d8a..4f3a0189da 100644 --- a/lib/generators/avo/templates/locales/avo.pl.yml +++ b/lib/generators/avo/templates/locales/avo.pl.yml @@ -22,6 +22,7 @@ pl: choose_item: Wybierz %{item} clear_value: Wyczyść wartość click_to_reveal_filters: Kliknij, aby pokazać filtry + close: Zamknij close_modal: Zamknij okno modalne confirm: Potwierdź copy: Kopiuj @@ -63,6 +64,8 @@ pl: less_content: Pokaż mniej list_is_empty: Lista jest pusta loading: Ładowanie + media_library: + title: Biblioteka mediów more: Więcej more_content: Pokaż wiecej more_records_available: Dostępnych jest więcej rekordów. diff --git a/lib/generators/avo/templates/locales/avo.pt-BR.yml b/lib/generators/avo/templates/locales/avo.pt-BR.yml index 6fc43325d4..82d220bb10 100644 --- a/lib/generators/avo/templates/locales/avo.pt-BR.yml +++ b/lib/generators/avo/templates/locales/avo.pt-BR.yml @@ -24,6 +24,7 @@ pt-BR: choose_item: Escolher %{item} clear_value: Limpar valor click_to_reveal_filters: Clique para revelar os filtros + close: Fechar close_modal: Fechar modal confirm: Confirmar copy: Copiar @@ -63,6 +64,8 @@ pt-BR: less_content: Menos conteúdo list_is_empty: Lista vazia loading: Carregando + media_library: + title: Biblioteca de Mídia more: Mais more_content: Mais conteúdo more_records_available: Existem mais registros disponíveis. diff --git a/lib/generators/avo/templates/locales/avo.pt.yml b/lib/generators/avo/templates/locales/avo.pt.yml index 1af99f480c..bb163a138d 100644 --- a/lib/generators/avo/templates/locales/avo.pt.yml +++ b/lib/generators/avo/templates/locales/avo.pt.yml @@ -24,6 +24,7 @@ pt: choose_item: Escolher %{item} clear_value: Apagar valor click_to_reveal_filters: Clique para mostrar os filtros + close: Fechar close_modal: Fechar modal confirm: Confirmar copy: Copiar @@ -63,6 +64,8 @@ pt: less_content: Menos conteúdo list_is_empty: Lista vazia loading: A carregar + media_library: + title: Biblioteca de Mídia more: Mais more_content: Mais conteúdo more_records_available: Existem mais registos disponíveis. diff --git a/lib/generators/avo/templates/locales/avo.ro.yml b/lib/generators/avo/templates/locales/avo.ro.yml index 0e55df44f2..027d0fff1d 100644 --- a/lib/generators/avo/templates/locales/avo.ro.yml +++ b/lib/generators/avo/templates/locales/avo.ro.yml @@ -25,6 +25,7 @@ ro: choose_item: Alege %{item} clear_value: Șterge valoarea click_to_reveal_filters: Faceți clic pentru a afișa filtrele + close: Închide close_modal: Închide modalul confirm: Confirm copy: Copiază @@ -65,6 +66,8 @@ ro: less_content: Mai puțin conținut list_is_empty: Lista este goală loading: Se incarcă + media_library: + title: Bibliotecă media more: Mai multe more_content: Mai mult conținut more_records_available: Sunt mai multe înregistrări disponibile. diff --git a/lib/generators/avo/templates/locales/avo.ru.yml b/lib/generators/avo/templates/locales/avo.ru.yml index 4176255b50..f939543240 100644 --- a/lib/generators/avo/templates/locales/avo.ru.yml +++ b/lib/generators/avo/templates/locales/avo.ru.yml @@ -22,6 +22,7 @@ ru: choose_item: Выбрать %{item} clear_value: Очистить значение click_to_reveal_filters: Нажмите, чтобы открыть фильтры + close: Закрыть close_modal: Закрыть модальное окно confirm: Подтвердить copy: Копировать @@ -63,6 +64,8 @@ ru: less_content: Меньше контента list_is_empty: Список пуст loading: Загрузка... + media_library: + title: Медиатека more: Ещё more_content: Больше контента more_records_available: Доступно больше записей. diff --git a/lib/generators/avo/templates/locales/avo.tr.yml b/lib/generators/avo/templates/locales/avo.tr.yml index 85736fe6cc..edfcc498cb 100644 --- a/lib/generators/avo/templates/locales/avo.tr.yml +++ b/lib/generators/avo/templates/locales/avo.tr.yml @@ -24,6 +24,7 @@ tr: choose_item: "%{item} seçin" clear_value: Değeri temizle click_to_reveal_filters: Filtreleri ortaya çıkarmak için tıklayın + close: Kapat close_modal: Modali kapat confirm: Onayla copy: Kopya @@ -63,6 +64,8 @@ tr: less_content: Daha az içerik list_is_empty: Boş liste loading: Yükleniyor + media_library: + title: Ortam Kütüphanesi more: Daha fazla more_content: Daha fazla içerik more_records_available: Daha fazla kayıt mevcut. diff --git a/lib/generators/avo/templates/locales/avo.uk.yml b/lib/generators/avo/templates/locales/avo.uk.yml index f3bbb8f73f..4fdc4a0724 100644 --- a/lib/generators/avo/templates/locales/avo.uk.yml +++ b/lib/generators/avo/templates/locales/avo.uk.yml @@ -22,6 +22,7 @@ uk: choose_item: Виберіть %{item} clear_value: Очистити значення click_to_reveal_filters: Натисніть, щоб показати фільтри + close: Закрити close_modal: Закрити вікно confirm: Підтвердити copy: Копіювати @@ -63,6 +64,8 @@ uk: less_content: Менше вмісту list_is_empty: Список порожній loading: Завантаження + media_library: + title: Медіатека more: Ще more_content: Більше вмісту more_records_available: Є більше доступних записів. diff --git a/lib/generators/avo/templates/locales/avo.zh.yml b/lib/generators/avo/templates/locales/avo.zh.yml index 9e6b8b4070..5910022bc0 100644 --- a/lib/generators/avo/templates/locales/avo.zh.yml +++ b/lib/generators/avo/templates/locales/avo.zh.yml @@ -22,6 +22,7 @@ zh: choose_item: 选择 %{item} clear_value: 清除值 click_to_reveal_filters: 点击以显示筛选器 + close: 关闭 close_modal: 关闭模态框 confirm: 确认 copy: 复制 @@ -61,6 +62,8 @@ zh: less_content: 内容较少 list_is_empty: 列表为空 loading: 加载中... + media_library: + title: 媒体库 more: 更多 more_content: 更多内容 more_records_available: 还有更多记录可供参考。 diff --git a/package.json b/package.json index 889ba15e2e..31a99407fc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@rails/activestorage": "^6.1.710", "@stimulus-components/clipboard": "^5.0.0", "@stimulus-components/password-visibility": "^3.0.0", + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", "@tiptap/core": "^2.11.3", diff --git a/spec/dummy/app/views/avo/resource_tools/_fish_information.html.erb b/spec/dummy/app/views/avo/resource_tools/_fish_information.html.erb index 591cbc49c7..f75219d79b 100644 --- a/spec/dummy/app/views/avo/resource_tools/_fish_information.html.erb +++ b/spec/dummy/app/views/avo/resource_tools/_fish_information.html.erb @@ -8,10 +8,10 @@ <% if form.present? %> <% c.with_body do %>
-
- <%= avo_edit_field :fish_type, as: :text, form: form, help: "Set the fish type", required: true, component_options: {compact: false, stacked: false} %> - <%= avo_edit_field :properties, as: :text, form: form, name: "Property 1", help: "Prop 1", required: true, component_options: {multiple: true} %> -
+
+ <%= avo_edit_field :fish_type, as: :text, form: form, help: "Set the fish type", required: true, component_options: {compact: false, stacked: false} %> + <%= avo_edit_field :properties, as: :text, form: form, name: "Property 1", help: "Prop 1", required: true, component_options: {multiple: true} %> +
<%= avo_edit_field :properties, as: :text, form: form, name: "Property 2", help: "Prop 2", required: true, component_options: {multiple: true} %> <%= form.fields_for :information do |information_form| %> diff --git a/spec/dummy/config/initializers/avo.rb b/spec/dummy/config/initializers/avo.rb index fb438bf5c7..715a034407 100644 --- a/spec/dummy/config/initializers/avo.rb +++ b/spec/dummy/config/initializers/avo.rb @@ -109,6 +109,13 @@ end end +if defined?(Avo::MediaLibrary) + Avo::MediaLibrary.configure do |config| + config.visible = -> { Avo::Current.user.is_developer? } + config.enabled = true + end +end + Rails.configuration.to_prepare do Avo::Fields::BaseField.include ActionView::Helpers::UrlHelper Avo::Fields::BaseField.include ActionView::Context diff --git a/spec/dummy/config/locales/avo.en.yml b/spec/dummy/config/locales/avo.en.yml index 94c1036381..5f22ca6f1f 100644 --- a/spec/dummy/config/locales/avo.en.yml +++ b/spec/dummy/config/locales/avo.en.yml @@ -1,6 +1,8 @@ --- en: avo: + media_library: + title: Media Library resource_translations: product: save: "Save the product!" @@ -25,6 +27,7 @@ en: clear_value: Clear value click_to_reveal_filters: Click to reveal filters close_modal: Close modal + close: Close confirm: Confirm create_new_item: Create new %{item} dashboard: Dashboard diff --git a/tailwind.preset.js b/tailwind.preset.js index 4b1aeac104..419c35a2f2 100644 --- a/tailwind.preset.js +++ b/tailwind.preset.js @@ -32,6 +32,7 @@ module.exports = { 'cover-sm': '9/2', 'cover-md': '9/3', 'cover-lg': '9/4', + 'media-library-item': '4/3', }, colors: { blue, @@ -132,7 +133,7 @@ module.exports = { }, }, variants: { - margin: ['responsive', 'hover', 'focus', 'group-hover', 'checked', 'kanban-dragging'], + margin: ['responsive', 'hover', 'focus', 'group-hover', 'checked', 'empty', 'kanban-dragging'], display: ['responsive', 'hover', 'focus', 'group-hover', 'checked', 'kanban-dragging'], padding: ['responsive', 'group-hover', 'kanban-dragging'], borderColor: ['responsive', 'hover', 'focus', 'disabled'], @@ -145,6 +146,7 @@ module.exports = { plugins: [ require('@tailwindcss/forms'), require('@tailwindcss/typography'), + require('@tailwindcss/container-queries'), plugin(({ addUtilities, addVariant }) => { const newUtilities = { '.backface-hidden': { diff --git a/yarn.lock b/yarn.lock index 1cadf718f9..3901b58d88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1590,6 +1590,11 @@ resolved "https://registry.yarnpkg.com/@stimulus-components/password-visibility/-/password-visibility-3.0.0.tgz#314c1bae571ba13b9a4da6014f3e5c5b776fe4cc" integrity sha512-ikyuZ/4VX4YrCckVHDc4H6UkTr5UjtkxSH+UdlEIgP9v93W1Bbvnoao0hgQODm8cvNWigmpLpKoGUzgyjBlrdw== +"@tailwindcss/container-queries@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz#9a759ce2cb8736a4c6a0cb93aeb740573a731974" + integrity sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA== + "@tailwindcss/forms@^0.5.10": version "0.5.10" resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.10.tgz#0a1cd67b6933402f1985a04595bd24f9785aa302" @@ -2099,25 +2104,10 @@ camelcase-css@^2.0.1: resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== -caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464: - version "1.0.30001486" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001486.tgz#56a08885228edf62cbe1ac8980f2b5dae159997e" - integrity sha512-uv7/gXuHi10Whlj0pp5q/tsK/32J2QSqVRKQhs2j8VsDCjgyruAh/eEXHF822VqO9yT6iZKw3nRwZRSPBE9OQg== - -caniuse-lite@^1.0.30001565: - version "1.0.30001566" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001566.tgz#61a8e17caf3752e3e426d4239c549ebbb37fef0d" - integrity sha512-ggIhCsTxmITBAMmK8yZjEhCO5/47jKXPu6Dha/wuCS4JePVL+3uiDEBuhu2aIoT+bqTOR8L76Ip1ARL9xYsEJA== - -caniuse-lite@^1.0.30001646: - version "1.0.30001650" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001650.tgz#dd1eba0938e39536d184c3c99b2569a13788bc16" - integrity sha512-fgEc7hP/LB7iicdXHUI9VsBsMZmUmlVJeQP2qqQW+3lkqVhbmjEU8zp+h5stWeilX+G7uXuIUIIlWlDw9jdt8g== - -caniuse-lite@^1.0.30001669: - version "1.0.30001676" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001676.tgz#fe133d41fe74af8f7cc93b8a714c3e86a86e6f04" - integrity sha512-Qz6zwGCiPghQXGJvgQAem79esjitvJ+CxSbSQkW9H/UX5hg8XM88d4lp2W+MEQ81j+Hip58Il+jGVdazk1z9cw== +caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464, caniuse-lite@^1.0.30001565, caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669: + version "1.0.30001696" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz" + integrity sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ== chalk@^2.4.2: version "2.4.2"