diff --git a/Gemfile b/Gemfile index c28ceb18..88988b4e 100644 --- a/Gemfile +++ b/Gemfile @@ -30,7 +30,7 @@ gem 'jbuilder', '~> 2.5' # gem 'bcrypt', '~> 3.1.7' # Use ActiveStorage variant # gem 'mini_magick', '~> 4.8' - +gem 'image_processing', '~> 1.2' # Use Capistrano for deployment # gem 'capistrano-rails', group: :development diff --git a/app/controllers/spree/admin/content_assets_controller.rb b/app/controllers/spree/admin/content_assets_controller.rb new file mode 100644 index 00000000..81c414d9 --- /dev/null +++ b/app/controllers/spree/admin/content_assets_controller.rb @@ -0,0 +1,66 @@ +class Spree::Admin::ContentAssetsController < Spree::Admin::BaseController + before_action :set_content_asset, only: [:edit, :update, :destroy] + + def index + @search = ContentAsset.ordered.ransack(params[:q]) + @content_assets = @search.result + .tagged(params[:tag]) + .with_attached_file + .includes(file_attachment: :blob) + .page(params[:page]) + .per(24) + @tags = ContentAsset.where.not(tag: [nil, '']).distinct.pluck(:tag).sort + end + + def new + @content_asset = ContentAsset.new + end + + def create + @content_asset = ContentAsset.new(content_asset_params) + @content_asset.file.attach(params[:content_asset][:file]) if params[:content_asset][:file] + + if @content_asset.save + flash[:success] = Spree.t('content_asset.success.create') + redirect_to admin_content_assets_path + else + flash.now[:error] = @content_asset.errors.full_messages.join(', ') + render :new + end + end + + def edit; end + + def update + @content_asset.assign_attributes(content_asset_params) + @content_asset.file.attach(params[:content_asset][:file]) if params[:content_asset][:file] + + if @content_asset.save + flash[:success] = Spree.t('content_asset.success.update') + redirect_to admin_content_assets_path + else + flash.now[:error] = @content_asset.errors.full_messages.join(', ') + render :edit + end + end + + def destroy + @content_asset.file.purge if @content_asset.file.attached? + @content_asset.destroy + flash[:success] = Spree.t('content_asset.success.delete') + redirect_to admin_content_assets_path + end + + private + + def set_content_asset + @content_asset = ContentAsset.find(params[:id]) + rescue ActiveRecord::RecordNotFound + flash[:error] = Spree.t('content_asset.error.not_found') + redirect_to admin_content_assets_path + end + + def content_asset_params + params.require(:content_asset).permit(:alt_text, :tag) + end +end diff --git a/app/controllers/spree/api/v1/content_assets_controller.rb b/app/controllers/spree/api/v1/content_assets_controller.rb new file mode 100644 index 00000000..c7236fe0 --- /dev/null +++ b/app/controllers/spree/api/v1/content_assets_controller.rb @@ -0,0 +1,102 @@ +class Spree::Api::V1::ContentAssetsController < Spree::Api::BaseController + include Response + + before_action :authenticate_user, except: [:index, :show] + before_action :set_content_asset, only: [:show, :update, :destroy] + + def index + assets = ContentAsset.with_attached_file + .tagged(params[:tag]) + .ordered + assets = assets.merge(ContentAsset.search(params[:search])) if params[:search].present? + + total_count = assets.count + limit = params[:limit].present? ? params[:limit].to_i : 0 + offset = params[:offset].present? ? params[:offset].to_i : 0 + assets = assets.offset(offset).limit(limit) unless limit.zero? + + asset_list = assets.map { |a| asset_summary(a) } + + response_data = { + total_records: total_count, + offset: offset, + content_assets: asset_list + } + singular_success_model(200, Spree.t('content_asset.success.index'), response_data) + end + + def show + singular_success_model(200, Spree.t('content_asset.success.show'), asset_detail(@content_asset)) + end + + def create + asset = ContentAsset.new(content_asset_params) + asset.file.attach(params[:file]) if params[:file] + + if asset.save + singular_success_model(200, Spree.t('content_asset.success.create'), asset_detail(asset)) + else + error_model(400, asset.errors.full_messages.join(', ')) + end + end + + def update + @content_asset.assign_attributes(content_asset_params) + @content_asset.file.attach(params[:file]) if params[:file] + + if @content_asset.save + singular_success_model(200, Spree.t('content_asset.success.update'), asset_detail(@content_asset)) + else + error_model(400, @content_asset.errors.full_messages.join(', ')) + end + end + + def destroy + @content_asset.file.purge if @content_asset.file.attached? + @content_asset.destroy + success_model(200, Spree.t('content_asset.success.delete')) + end + + private + + def set_content_asset + @content_asset = ContentAsset.find_by(id: params[:id]) + unless @content_asset + error_model(400, Spree.t('content_asset.error.not_found')) + end + end + + def content_asset_params + params.permit(:alt_text, :tag) + end + + # Summary for list (only thumbnail URLs to avoid N+1) + def asset_summary(asset) + { + id: asset.id, + alt_text: asset.alt_text, + tag: asset.tag, + original_filename: asset.original_filename, + content_type: asset.content_type, + byte_size: asset.byte_size, + urls: asset.thumbnail_urls, + created_at: asset.created_at, + updated_at: asset.updated_at + } + end + + # Full detail for show (all variant URLs) + def asset_detail(asset) + { + id: asset.id, + alt_text: asset.alt_text, + tag: asset.tag, + original_filename: asset.original_filename, + content_type: asset.content_type, + byte_size: asset.byte_size, + urls: asset.all_urls, + created_at: asset.created_at, + updated_at: asset.updated_at + } + end +end diff --git a/app/models/content_asset.rb b/app/models/content_asset.rb new file mode 100644 index 00000000..caace29d --- /dev/null +++ b/app/models/content_asset.rb @@ -0,0 +1,108 @@ +class ContentAsset < Spree::Base + has_one_attached :file + + validates :alt_text, length: { maximum: 255 } + validates :tag, length: { maximum: 100 } + validate :file_must_be_attached + validate :acceptable_file + + before_validation :normalize_tag + + # Metadata sync happens after commit because ActiveStorage attaches + # the blob in an after_commit callback — blob may not exist during + # before_save on create. + after_commit :sync_file_metadata, on: [:create, :update] + + VARIANTS = { + mini: { resize_to_fill: [48, 48] }, + small: { resize_to_fill: [100, 100] }, + product: { resize_to_fill: [240, 240] }, + large: { resize_to_fill: [600, 600] }, + xl: { resize_to_fill: [1000, 1000] }, + widescreen: { resize_to_fill: [1600, 900] }, + portrait: { resize_to_fill: [900, 1600] }, + }.freeze + + ALLOWED_CONTENT_TYPES = %w[ + image/png image/jpeg image/webp image/gif image/svg+xml + ].freeze + + MAX_FILE_SIZE = 10.megabytes + + # Ransack support for admin search + self.whitelisted_ransackable_attributes = %w[alt_text original_filename tag] + + scope :tagged, ->(tag) { where(tag: tag) if tag.present? } + scope :ordered, -> { order(created_at: :desc) } + + # Override Ransack's .search to provide simple filename/alt_text filtering. + # Named .search to match the test interface; delegates to Ransack if called + # with a hash (standard Ransack usage), otherwise treats the argument as a + # plain text query. + def self.search(query_or_params = nil, **opts) + if query_or_params.is_a?(Hash) || query_or_params.nil? + super + else + q = query_or_params.to_s + q.present? ? where("original_filename ILIKE :q OR alt_text ILIKE :q", q: "%#{q}%") : all + end + end + + def normalize_tag + self.tag = tag.to_s.strip.downcase.presence + end + + def variant_url(size) + return nil unless file.attached? + spec = VARIANTS[size.to_sym] + return nil unless spec + Rails.application.routes.url_helpers.rails_storage_proxy_path( + file.variant(spec) + ) + end + + def original_url + return nil unless file.attached? + Rails.application.routes.url_helpers.rails_storage_proxy_path(file) + end + + def all_urls + return {} unless file.attached? + urls = { original: original_url } + VARIANTS.each_key { |size| urls[size] = variant_url(size) } + urls + end + + # Lightweight URLs for list responses (avoid N+1 on all variants) + def thumbnail_urls + return {} unless file.attached? + { original: original_url, small: variant_url(:small) } + end + + private + + def file_must_be_attached + errors.add(:file, "must be attached") unless file.attached? + end + + def acceptable_file + return unless file.attached? + + unless file.blob.content_type.in?(ALLOWED_CONTENT_TYPES) + errors.add(:file, "must be an image (PNG, JPEG, WebP, GIF, or SVG)") + end + + if file.blob.byte_size > MAX_FILE_SIZE + errors.add(:file, "must be less than #{MAX_FILE_SIZE / 1.megabyte}MB") + end + end + + def sync_file_metadata + return unless file.attached? && file.blob&.filename.present? + update_columns( + original_filename: file.blob.filename.to_s, + content_type: file.blob.content_type, + byte_size: file.blob.byte_size + ) + end +end diff --git a/app/views/spree/admin/content_assets/_form.html.erb b/app/views/spree/admin/content_assets/_form.html.erb new file mode 100644 index 00000000..35146a5e --- /dev/null +++ b/app/views/spree/admin/content_assets/_form.html.erb @@ -0,0 +1,29 @@ +<%= render 'spree/admin/shared/error_messages', target: @content_asset %> + +
+
+
+
+ <%= f.label :file, Spree.t('admin.content_assets.file') %> + <%= f.file_field :file, class: 'form-control-file', accept: 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml' %> + <% if @content_asset.file.attached? %> +
+ <%= image_tag main_app.rails_blob_path(@content_asset.file, disposition: 'inline'), + style: "max-width: 300px; border-radius: 4px;", + alt: @content_asset.alt_text %> +
+ <% end %> +
+ +
+ <%= f.label :alt_text, Spree.t('admin.content_assets.alt_text') %> + <%= f.text_field :alt_text, class: 'form-control', placeholder: 'Descriptive text for accessibility' %> +
+ +
+ <%= f.label :tag, Spree.t('admin.content_assets.tag') %> + <%= f.text_field :tag, class: 'form-control', placeholder: 'e.g. homepage, hero, footer' %> +
+
+
+
diff --git a/app/views/spree/admin/content_assets/_picker_modal.html.erb b/app/views/spree/admin/content_assets/_picker_modal.html.erb new file mode 100644 index 00000000..5c803512 --- /dev/null +++ b/app/views/spree/admin/content_assets/_picker_modal.html.erb @@ -0,0 +1,60 @@ +<%# Usage: render 'spree/admin/content_assets/picker_modal', target_field_id: 'field_id' %> +<% target_field_id = local_assigns[:target_field_id] || 'content_asset_url' %> + + + + + + diff --git a/app/views/spree/admin/content_assets/edit.html.erb b/app/views/spree/admin/content_assets/edit.html.erb new file mode 100644 index 00000000..26c07925 --- /dev/null +++ b/app/views/spree/admin/content_assets/edit.html.erb @@ -0,0 +1,15 @@ +<% content_for :page_title do %> + <%= link_to Spree.t('admin.content_assets.title'), admin_content_assets_path %> / + <%= Spree.t('admin.content_assets.edit') %> +<% end %> + +<%= form_for @content_asset, url: admin_content_asset_path(@content_asset), + html: { multipart: true, method: :put, class: 'form-horizontal' } do |f| %> + <%= render 'form', f: f %> + +
+ <%= button Spree.t(:update), 'save.svg' %> + <%= Spree.t(:or) %> + <%= link_to Spree.t(:cancel), admin_content_assets_path, class: 'btn btn-default' %> +
+<% end %> diff --git a/app/views/spree/admin/content_assets/index.html.erb b/app/views/spree/admin/content_assets/index.html.erb new file mode 100644 index 00000000..2d8123f3 --- /dev/null +++ b/app/views/spree/admin/content_assets/index.html.erb @@ -0,0 +1,82 @@ +<% content_for :page_title do %> + <%= Spree.t('admin.content_assets.title') %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t('admin.content_assets.new'), + new_admin_content_asset_path, + class: "btn-success", icon: 'add.svg' %> +<% end %> + +<% content_for :table_filter do %> +
+
+ <%= search_form_for @search, url: admin_content_assets_path do |f| %> +
+ <%= f.text_field :original_filename_or_alt_text_cont, + placeholder: Spree.t('admin.content_assets.search'), + class: 'form-control' %> +
+ +
+
+ <% end %> +
+
+ <%= form_tag admin_content_assets_path, method: :get do %> + <%= select_tag :tag, + options_for_select( + [[Spree.t('admin.content_assets.all_tags'), '']] + @tags.map { |t| [t.titleize, t] }, + params[:tag] + ), + class: 'form-control', + onchange: 'this.form.submit()' %> + <% end %> +
+
+<% end %> + +<% if @content_assets.any? %> +
+ <% @content_assets.each do |asset| %> +
+
+ <% if asset.file.attached? %> + <%= image_tag main_app.rails_blob_path(asset.file, disposition: 'inline'), + style: "max-width: 100%; max-height: 100%; object-fit: cover;", + alt: asset.alt_text || asset.original_filename %> + <% end %> +
+
+
+ <%= asset.original_filename %> +
+ <% if asset.tag.present? %> + <%= asset.tag %> + <% end %> +
+ <%= number_to_human_size(asset.byte_size) %> · <%= asset.created_at.strftime('%b %d, %Y') %> +
+
+ <%= link_to_with_icon 'edit.svg', '', edit_admin_content_asset_path(asset), + class: 'btn btn-sm btn-outline-primary', title: 'Edit', + no_text: true %> + <%= link_to_with_icon 'delete.svg', '', admin_content_asset_path(asset), + class: 'btn btn-sm btn-outline-danger', title: 'Delete', + no_text: true, method: :delete, + data: { confirm: Spree.t('admin.content_assets.confirm_delete') } %> +
+
+
+ <% end %> +
+ + <%= paginate @content_assets, theme: 'admin-twitter-bootstrap-4' %> +<% else %> + +<% end %> diff --git a/app/views/spree/admin/content_assets/new.html.erb b/app/views/spree/admin/content_assets/new.html.erb new file mode 100644 index 00000000..5786b670 --- /dev/null +++ b/app/views/spree/admin/content_assets/new.html.erb @@ -0,0 +1,15 @@ +<% content_for :page_title do %> + <%= link_to Spree.t('admin.content_assets.title'), admin_content_assets_path %> / + <%= Spree.t('admin.content_assets.new') %> +<% end %> + +<%= form_for @content_asset, url: admin_content_assets_path, + html: { multipart: true, class: 'form-horizontal' } do |f| %> + <%= render 'form', f: f %> + +
+ <%= button Spree.t(:create), 'save.svg' %> + <%= Spree.t(:or) %> + <%= link_to Spree.t(:cancel), admin_content_assets_path, class: 'btn btn-default' %> +
+<% end %> diff --git a/app/views/spree/admin/shared/_main_menu.html.erb b/app/views/spree/admin/shared/_main_menu.html.erb index 8eeb24c9..67c82f6b 100644 --- a/app/views/spree/admin/shared/_main_menu.html.erb +++ b/app/views/spree/admin/shared/_main_menu.html.erb @@ -70,6 +70,12 @@ <% end %> + <% if can? :admin, ContentAsset %> + + <% end %> + <% if can? :admin, ThreadTable %>