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 %> + +
Loading...
'; + + fetch(url) + .then(function(r) { return r.json(); }) + .then(function(data) { + var assets = data.response_data.content_assets || []; + grid.innerHTML = ''; + if (assets.length === 0) { + grid.innerHTML = 'No images found.
'; + return; + } + assets.forEach(function(asset) { + var thumb = asset.urls && (asset.urls.small || asset.urls.original) || ''; + var card = document.createElement('div'); + card.className = 'content-asset-thumb'; + card.setAttribute('data-asset-id', asset.id); + card.setAttribute('data-asset-url', asset.urls.original || ''); + card.setAttribute('data-modal-id', modalId); + card.style.cssText = 'cursor:pointer;border:2px solid transparent;border-radius:4px;overflow:hidden;background:#f8f9fa;'; + card.innerHTML = + 'Failed to load images.
'; + }); + } + + $(document).on('shown.bs.modal', '.content-asset-picker-modal', function() { + var modalId = this.id; + selectedAsset[modalId] = null; + var selectBtn = document.querySelector('.content-asset-select-btn[data-modal-id="' + modalId + '"]'); + if (selectBtn) selectBtn.disabled = true; + loadAssets(modalId, '', ''); + }); + + var searchTimeout; + $(document).on('input', '.content-asset-search', function() { + var input = this; + var modalId = input.getAttribute('data-modal-id'); + var tag = document.querySelector('.content-asset-tag-filter[data-modal-id="' + modalId + '"]'); + clearTimeout(searchTimeout); + searchTimeout = setTimeout(function() { + loadAssets(modalId, input.value, tag ? tag.value : ''); + }, 300); + }); + + $(document).on('change', '.content-asset-tag-filter', function() { + var modalId = this.getAttribute('data-modal-id'); + var search = document.querySelector('.content-asset-search[data-modal-id="' + modalId + '"]'); + loadAssets(modalId, search ? search.value : '', this.value); + }); + + $(document).on('click', '.content-asset-select-btn', function() { + var modalId = this.getAttribute('data-modal-id'); + var targetFieldId = this.getAttribute('data-target-field'); + var url = selectedAsset[modalId]; + if (url && targetFieldId) { + var field = document.getElementById(targetFieldId); + if (field) { + field.value = url; + field.dispatchEvent(new Event('change')); + } + var preview = document.getElementById('preview_' + targetFieldId); + if (preview) { + preview.innerHTML = '