Skip to content

Commit cc44b5d

Browse files
committed
Direct upload + Product model with multiple images
1 parent f895c0d commit cc44b5d

26 files changed

+1089
-2
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,5 @@ group :test do
6666
gem "capybara"
6767
gem "selenium-webdriver"
6868
end
69+
70+
gem 'active_storage_dedup', github: 'coderhs/active_storage_dedup'

Gemfile.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
PATH
2+
remote: /Users/coderhs/sandbox/active_storage_dedup
3+
specs:
4+
active_storage_dedup (1.0.0.alpha)
5+
activestorage (>= 6.0.0)
6+
rails (>= 6.0.0)
7+
18
GEM
29
remote: https://rubygems.org/
310
specs:
@@ -396,6 +403,7 @@ PLATFORMS
396403
x86_64-linux-musl
397404

398405
DEPENDENCIES
406+
active_storage_dedup!
399407
bootsnap
400408
brakeman
401409
bundler-audit
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
class ProductsController < ApplicationController
2+
def index
3+
@products = Product.includes(images_attachments: :blob).order(created_at: :desc)
4+
@product = Product.new
5+
end
6+
7+
def create
8+
product_id = params[:product_id]
9+
@products = Product.includes(images_attachments: :blob).order(created_at: :desc)
10+
@was_new_product = false
11+
12+
if product_id.present? && product_id != "" && product_id != "new"
13+
# Attach to existing product
14+
@product = Product.find(product_id)
15+
if product_params[:images].present?
16+
@new_attachments = @product.images.attach(product_params[:images])
17+
if @new_attachments.present?
18+
@product.reload
19+
respond_to do |format|
20+
format.turbo_stream
21+
format.html { redirect_to products_path, notice: "Images added to product successfully." }
22+
end
23+
else
24+
respond_to do |format|
25+
format.turbo_stream do
26+
render turbo_stream: turbo_stream.replace("product_form", partial: "form", locals: { product: Product.new, products: @products })
27+
end
28+
format.html { render :index, status: :unprocessable_entity }
29+
end
30+
end
31+
else
32+
respond_to do |format|
33+
format.turbo_stream do
34+
render turbo_stream: turbo_stream.replace("product_form", partial: "form", locals: { product: Product.new, products: @products })
35+
end
36+
format.html { redirect_to products_path, alert: "Please select at least one image." }
37+
end
38+
end
39+
else
40+
# Create new product
41+
@product = Product.new(product_params.except(:images))
42+
@was_new_product = true
43+
44+
if @product.save
45+
if product_params[:images].present?
46+
@product.images.attach(product_params[:images])
47+
end
48+
@product.reload
49+
@products = Product.includes(images_attachments: :blob).order(created_at: :desc)
50+
respond_to do |format|
51+
format.turbo_stream
52+
format.html { redirect_to products_path, notice: "Product created successfully." }
53+
end
54+
else
55+
respond_to do |format|
56+
format.turbo_stream do
57+
render turbo_stream: turbo_stream.replace("product_form", partial: "form", locals: { product: @product, products: @products })
58+
end
59+
format.html { render :index, status: :unprocessable_entity }
60+
end
61+
end
62+
end
63+
end
64+
65+
private
66+
67+
def product_params
68+
params.require(:product).permit(:name, images: [])
69+
end
70+
end

app/controllers/uploads_controller.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,29 @@ def destroy
3737
end
3838
end
3939

40+
def direct_upload
41+
@uploads = Upload.includes(file_attachment: :blob).order(created_at: :desc)
42+
end
43+
44+
def create_direct_upload
45+
@upload = Upload.new(upload_params)
46+
47+
if @upload.save
48+
respond_to do |format|
49+
format.json { render json: { success: true, upload: { id: @upload.id, name: @upload.name } }, status: :created }
50+
format.html { redirect_to direct_upload_path, notice: "File uploaded successfully." }
51+
end
52+
else
53+
respond_to do |format|
54+
format.json { render json: { success: false, errors: @upload.errors.full_messages }, status: :unprocessable_entity }
55+
format.html do
56+
@uploads = Upload.includes(file_attachment: :blob).order(created_at: :desc)
57+
render :direct_upload, status: :unprocessable_entity
58+
end
59+
end
60+
end
61+
end
62+
4063
private
4164

4265
def upload_params

app/helpers/products_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module ProductsHelper
2+
end

app/javascript/application.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
22
import "@hotwired/turbo-rails"
33
import "controllers"
4+
import * as ActiveStorage from "@rails/activestorage"
5+
ActiveStorage.start()
6+
7+
// Make ActiveStorage available globally for direct upload
8+
window.ActiveStorage = ActiveStorage

app/models/product.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Product < ApplicationRecord
2+
has_many_attached :images, deduplicate: true
3+
4+
validates :name, presence: true
5+
end

app/models/upload.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class Upload < ApplicationRecord
2-
has_one_attached :file
2+
has_one_attached :file, deduplicate: false
33

44
validates :name, presence: true
55
validates :file, presence: true

app/views/products/_form.html.erb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<div id="product_form">
2+
<%= form_with model: product, url: products_path, class: "space-y-4" do |f| %>
3+
<% if product.errors.any? %>
4+
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
5+
<ul>
6+
<% product.errors.full_messages.each do |message| %>
7+
<li><%= message %></li>
8+
<% end %>
9+
</ul>
10+
</div>
11+
<% end %>
12+
13+
<div>
14+
<%= f.label :product_id, "Select Existing Product (or create new)", class: "block text-gray-700 text-sm font-bold mb-2" %>
15+
<%= select_tag :product_id,
16+
options_from_collection_for_select(products, :id, :name, ""),
17+
{
18+
include_blank: "Create New Product",
19+
class: "shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline",
20+
id: "product_select",
21+
onchange: "toggleProductNameField()"
22+
} %>
23+
</div>
24+
25+
<div id="product_name_field">
26+
<%= f.label :name, "Product Name", class: "block text-gray-700 text-sm font-bold mb-2" %>
27+
<%= f.text_field :name,
28+
class: "shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline",
29+
id: "product_name_input" %>
30+
</div>
31+
32+
<div>
33+
<%= f.label :images, "Images", class: "block text-gray-700 text-sm font-bold mb-2" %>
34+
<%= f.file_field :images,
35+
multiple: true,
36+
accept: "image/*",
37+
class: "block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" %>
38+
<p class="text-xs text-gray-500 mt-1">You can select multiple images</p>
39+
</div>
40+
41+
<div>
42+
<%= f.submit "Upload Images", class: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline cursor-pointer" %>
43+
</div>
44+
<% end %>
45+
</div>
46+
47+
<script>
48+
function toggleProductNameField() {
49+
const select = document.getElementById('product_select');
50+
const nameField = document.getElementById('product_name_field');
51+
const nameInput = document.getElementById('product_name_input');
52+
53+
if (select.value === '' || select.value === 'new') {
54+
// Show name field and make it required for new products
55+
nameField.style.display = 'block';
56+
nameInput.required = true;
57+
} else {
58+
// Hide name field and make it optional when attaching to existing
59+
nameField.style.display = 'none';
60+
nameInput.required = false;
61+
nameInput.value = '';
62+
}
63+
}
64+
65+
// Initialize on page load
66+
document.addEventListener('DOMContentLoaded', function() {
67+
toggleProductNameField();
68+
});
69+
</script>
70+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<div class="bg-white shadow-md rounded p-6" id="<%= dom_id(product) %>">
2+
<h3 class="font-semibold text-lg mb-2"><%= product.name %></h3>
3+
<p class="text-sm text-gray-500 mb-4">Created: <%= product.created_at.strftime("%Y-%m-%d %H:%M") %></p>
4+
5+
<% if product.images.attached? %>
6+
<div class="space-y-2">
7+
<p class="text-sm font-medium text-gray-700">Images (<%= product.images.count %>):</p>
8+
<div class="grid grid-cols-2 gap-2">
9+
<% product.images.each do |image| %>
10+
<div class="relative">
11+
<%= image_tag image, class: "w-full h-24 object-cover rounded", alt: image.filename.to_s %>
12+
<p class="text-xs text-gray-500 mt-1 truncate"><%= image.filename %></p>
13+
</div>
14+
<% end %>
15+
</div>
16+
</div>
17+
<% else %>
18+
<p class="text-sm text-gray-400">No images attached</p>
19+
<% end %>
20+
</div>
21+

0 commit comments

Comments
 (0)