Files
pak-jabit/backend/app/views/spree/admin/image_studio/show.html.erb
T
juxtapoandClaude Opus 5 a58a61b98c Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling
A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.

What is here:
  storefront/  the Next.js app
  backend/     ONLY the custom Spree code. Spree itself is a prebuilt image
               that mounts no app code, so every file here needs an explicit
               volume entry in docker-compose.yml or it is silently ignored.
  catalogue/   the scripts that loaded and maintain the products
  docs/        manifest, mock registry, napkins

Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.

The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:09:55 +08:00

234 lines
9.2 KiB
ERB

<% content_for :page_title do %>
Image Studio
<% end %>
<div class="container-fluid">
<p class="text-muted">
Upload photos straight off your phone or camera — background, framing and size
are standardised, then posted to the store.
Nothing is saved until you press <strong>Apply</strong>.
</p>
<%= form_with url: spree.admin_preview_image_studio_path, method: :post,
local: true, multipart: true do |f| %>
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title mb-3">1 &middot; Photos</h5>
<div class="mb-3">
<label class="form-label" for="photos">Upload from this device</label>
<%= file_field_tag 'photos[]', multiple: true, accept: 'image/*',
id: 'photos', class: 'form-control' %>
<div class="form-text">
Pick several at once. On a phone this opens the camera roll.
Up to <%= @max_photos %> per batch — each takes a few seconds.
</div>
<!-- Choosing a file does nothing on its own; the button is what runs
it. Without this line people pick a photo and wait for something
to happen. -->
<div id="picked" class="alert alert-info mt-3 mb-0 py-2" style="display:none;">
<span id="picked_text"></span>
<button type="submit" class="btn btn-primary btn-sm ms-3">
Process now &rarr;
</button>
</div>
</div>
<details>
<summary class="text-muted small" style="cursor: pointer;">
…or reprocess an image already on a product
</summary>
<div class="mt-3">
<select name="slug" class="form-control custom-select">
<option value="">— none —</option>
<% @products.each do |product| %>
<option value="<%= product.slug %>" <%= 'selected' if @product&.slug == product.slug %>>
<%= product.name %>
</option>
<% end %>
</select>
<div class="form-text">Ignored when you upload files above.</div>
</div>
</details>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title mb-3">2 &middot; Look</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="background">Background</label>
<select name="background" id="background" class="form-control custom-select">
<% @backgrounds.each do |label, hex| %>
<option value="<%= hex %>" <%= 'selected' if params[:background] == hex %>>
<%= label %>
</option>
<% end %>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="size">Size</label>
<select name="size" id="size" class="form-control custom-select">
<% @sizes.each do |size| %>
<option value="<%= size %>" <%= 'selected' if params[:size] == size %>>
<%= size %>
</option>
<% end %>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<%= submit_tag 'Process photos', class: 'btn btn-primary w-100',
data: { disable_with: 'Processing… this can take a minute' } %>
</div>
</div>
</div>
</div>
<% end %>
<% if @results.present? %>
<div class="card mb-4" id="review">
<div class="card-body">
<h5 class="card-title mb-1">3 &middot; Review</h5>
<p class="text-muted small">
<%= @processed %> processed<%= ", #{@failed} failed" if @failed.to_i.positive? %>
&middot; <%= @size %>
</p>
<% @results.each_with_index do |r, i| %>
<div class="row align-items-start py-3 <%= 'border-top' if i.positive? %>">
<div class="col-12 mb-2">
<strong class="small"><%= r[:filename] %></strong>
</div>
<% if r[:error].present? %>
<div class="col-12">
<div class="alert alert-danger mb-0 py-2 small">
Failed: <%= r[:error] %>
</div>
</div>
<% else %>
<div class="col-6">
<div class="border rounded p-2 text-center" style="background:#f8f9fa;">
<%= image_tag r[:before_url], style: 'max-width:100%; height:auto;' %>
</div>
<p class="text-muted small mt-1 mb-0">
Before &middot; <%= number_to_human_size(r[:before_bytes]) %>
</p>
</div>
<div class="col-6">
<div class="border rounded p-2 text-center" style="background:#f8f9fa;">
<%= image_tag r[:after_url], style: 'max-width:100%; height:auto;' %>
</div>
<p class="text-muted small mt-1 mb-0">
After &middot; <%= number_to_human_size(r[:after_bytes]) %>
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<%= form_with url: spree.admin_apply_image_studio_path, method: :post, local: true do |f| %>
<% @results.each do |r| %>
<% next if r[:error].present? %>
<%= hidden_field_tag 'signed_ids[]', r[:signed_id], id: nil %>
<% end %>
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title mb-3">4 &middot; Post to store</h5>
<div class="form-check mb-2">
<%= radio_button_tag :destination, 'existing', true, class: 'form-check-input', id: 'dest_existing' %>
<label class="form-check-label" for="dest_existing">Add to an existing product</label>
</div>
<div class="mb-3 ps-4">
<select name="target_slug" class="form-control custom-select">
<% @products.each do |product| %>
<option value="<%= product.slug %>" <%= 'selected' if @product&.slug == product.slug %>>
<%= product.name %>
</option>
<% end %>
</select>
</div>
<div class="form-check mb-2">
<%= radio_button_tag :destination, 'new', false, class: 'form-check-input', id: 'dest_new' %>
<label class="form-check-label" for="dest_new">Create a new product</label>
</div>
<div class="row g-2 ps-4 mb-3">
<div class="col-md-6">
<%= text_field_tag :new_name, params[:new_name], class: 'form-control',
placeholder: 'Product name' %>
</div>
<div class="col-md-3">
<%= text_field_tag :new_price, params[:new_price], class: 'form-control',
placeholder: "Price (#{@currency})" %>
</div>
<div class="col-md-3 d-flex align-items-center">
<div class="form-check mb-0">
<%= check_box_tag :publish, '1', false, class: 'form-check-input', id: 'publish' %>
<label class="form-check-label small" for="publish">Publish now</label>
</div>
</div>
<div class="col-12">
<div class="form-text">
Left unpublished it is created as a draft, so you can add the
description and details before customers see it.
</div>
</div>
</div>
<hr>
<div class="form-check mb-3">
<%= check_box_tag :make_primary, '1', true, class: 'form-check-input', id: 'make_primary' %>
<label class="form-check-label" for="make_primary">
Use the first image as the product's main photo
</label>
<div class="form-text">
Unchecked, they are added to the end of the gallery and the current
main photo is left alone.
</div>
</div>
<%= submit_tag 'Apply to store', class: 'btn btn-primary' %>
<%= link_to 'Discard', spree.admin_image_studio_path, class: 'btn btn-outline-secondary ms-2' %>
<p class="text-muted small mt-3 mb-0">
Discarding leaves the catalogue untouched — nothing was attached.
</p>
</div>
</div>
<% end %>
<% end %>
</div>
<script>
(function () {
var input = document.getElementById('photos');
var box = document.getElementById('picked');
var text = document.getElementById('picked_text');
if (input && box && text) {
input.addEventListener('change', function () {
var n = input.files.length;
if (!n) { box.style.display = 'none'; return; }
var mb = 0;
for (var i = 0; i < n; i++) { mb += input.files[i].size; }
mb = (mb / 1048576).toFixed(1);
text.textContent = n + (n === 1 ? ' photo' : ' photos') + ' ready (' + mb + ' MB).';
box.style.display = '';
});
}
// The results render below the fold, so a successful run looks like
// nothing happened until you scroll. Land on them instead.
var review = document.getElementById('review');
if (review) { review.scrollIntoView({ block: 'start' }); }
})();
</script>