Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
66 changes: 66 additions & 0 deletions app/controllers/spree/admin/content_assets_controller.rb
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions app/controllers/spree/api/v1/content_assets_controller.rb
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions app/models/content_asset.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions app/views/spree/admin/content_assets/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<%= render 'spree/admin/shared/error_messages', target: @content_asset %>

<div class="row">
<div class="col-md-8">
<fieldset class="no-border-top">
<div class="form-group">
<%= 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? %>
<div style="margin-top: 8px;">
<%= image_tag main_app.rails_blob_path(@content_asset.file, disposition: 'inline'),
style: "max-width: 300px; border-radius: 4px;",
alt: @content_asset.alt_text %>
</div>
<% end %>
</div>

<div class="form-group">
<%= f.label :alt_text, Spree.t('admin.content_assets.alt_text') %>
<%= f.text_field :alt_text, class: 'form-control', placeholder: 'Descriptive text for accessibility' %>
</div>

<div class="form-group">
<%= f.label :tag, Spree.t('admin.content_assets.tag') %>
<%= f.text_field :tag, class: 'form-control', placeholder: 'e.g. homepage, hero, footer' %>
</div>
</fieldset>
</div>
</div>
60 changes: 60 additions & 0 deletions app/views/spree/admin/content_assets/_picker_modal.html.erb
Original file line number Diff line number Diff line change
@@ -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' %>

<button type="button" class="btn btn-sm btn-outline-secondary content-asset-picker-btn"
data-target-field="<%= target_field_id %>"
data-toggle="modal"
data-target="#contentAssetPickerModal_<%= target_field_id %>">
Choose Image
</button>

<span class="content-asset-preview" id="preview_<%= target_field_id %>"></span>

<div class="modal fade content-asset-picker-modal"
id="contentAssetPickerModal_<%= target_field_id %>"
tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Choose Image</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<input type="text" class="form-control content-asset-search"
placeholder="Search by filename or alt text..."
data-modal-id="contentAssetPickerModal_<%= target_field_id %>">
</div>
<div class="col-md-3">
<select class="form-control content-asset-tag-filter"
data-modal-id="contentAssetPickerModal_<%= target_field_id %>">
<option value="">All Tags</option>
</select>
</div>
<div class="col-md-3">
<label class="btn btn-success btn-block mb-0">
Upload New
<input type="file" class="content-asset-inline-upload d-none"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
data-modal-id="contentAssetPickerModal_<%= target_field_id %>">
</label>
</div>
</div>
<div class="content-asset-grid" data-modal-id="contentAssetPickerModal_<%= target_field_id %>"
style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; max-height: 400px; overflow-y: auto;">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary content-asset-select-btn" disabled
data-target-field="<%= target_field_id %>"
data-modal-id="contentAssetPickerModal_<%= target_field_id %>">
Select
</button>
</div>
</div>
</div>
</div>
15 changes: 15 additions & 0 deletions app/views/spree/admin/content_assets/edit.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>

<div class="form-actions">
<%= button Spree.t(:update), 'save.svg' %>
<span class="or"><%= Spree.t(:or) %></span>
<%= link_to Spree.t(:cancel), admin_content_assets_path, class: 'btn btn-default' %>
</div>
<% end %>
Loading