Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5cac27010 | ||
|
|
a45f9ef030 | ||
|
|
51155f2fd2 | ||
|
|
2d2aa012ec | ||
|
|
125be1798e | ||
|
|
f724e9763f |
@@ -285,6 +285,10 @@ currency = Currency
|
||||
category = Category
|
||||
no-category = No category
|
||||
image = Image
|
||||
images = Images
|
||||
main-image = Main
|
||||
gallery-hint = The first image is the main one. Drag to reorder, click ✕ to remove.
|
||||
add-images = Add images
|
||||
slug = URL slug
|
||||
slug-auto = generated automatically
|
||||
position = Position
|
||||
@@ -303,6 +307,8 @@ confirm-delete = Delete this for good?
|
||||
shop-title = Shop
|
||||
shop-subtitle = browse our products.
|
||||
shop-empty = There are no products here yet.
|
||||
view-grid = Grid view
|
||||
view-list = List view
|
||||
categories = Categories
|
||||
all-products = All products
|
||||
uncategorized = Uncategorized
|
||||
|
||||
@@ -285,6 +285,10 @@ currency = Mena
|
||||
category = Kategória
|
||||
no-category = Bez kategórie
|
||||
image = Obrázok
|
||||
images = Obrázky
|
||||
main-image = Hlavný
|
||||
gallery-hint = Prvý obrázok je hlavný. Potiahnutím zmeníte poradie, krížikom obrázok odstránite.
|
||||
add-images = Pridať obrázky
|
||||
slug = URL adresa
|
||||
slug-auto = vygeneruje sa automaticky
|
||||
position = Poradie
|
||||
@@ -303,6 +307,8 @@ confirm-delete = Naozaj zmazať?
|
||||
shop-title = Obchod
|
||||
shop-subtitle = prezrite si našu ponuku produktov.
|
||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||
view-grid = Zobrazenie v mriežke
|
||||
view-list = Zobrazenie v zozname
|
||||
categories = Kategórie
|
||||
all-products = Všetky produkty
|
||||
uncategorized = Bez kategórie
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -126,12 +126,86 @@
|
||||
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||
{% if product and product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
||||
{% endif %}
|
||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
||||
{# --- Images gallery ------------------------------------------------------- #}
|
||||
{# Unified drag-orderable gallery: existing images (with id) and new uploads #}
|
||||
{# (placeholder blobs) live in a single list. The full order is submitted as #}
|
||||
{# repeated `image_order` fields — an integer id for kept images or `new` for #}
|
||||
{# each uploaded file. The DataTransfer backing the hidden `image` file input #}
|
||||
{# is rebuilt after every reorder / add / remove so the file-part order matches #}
|
||||
{# the relative order of `new` slots in `image_order`. #}
|
||||
<script id="images-data" type="application/json">{% if product %}{{ product.images | json_encode() | safe }}{% else %}[]{% endif %}</script>
|
||||
<div class="space-y-2" x-data="{
|
||||
init() {
|
||||
const existing = JSON.parse(document.getElementById('images-data').textContent);
|
||||
this.items = existing.map(im => ({ type: 'existing', id: im.id, image_id: im.image_id }));
|
||||
},
|
||||
items: [],
|
||||
dt: new DataTransfer(),
|
||||
dragIndex: null,
|
||||
|
||||
rebuildDt() {
|
||||
this.dt = new DataTransfer();
|
||||
for (const it of this.items) {
|
||||
if (it.type === 'new') this.dt.items.add(it.file);
|
||||
}
|
||||
this.$refs.holder.files = this.dt.files;
|
||||
},
|
||||
|
||||
onDrop(i) {
|
||||
if (this.dragIndex === null || this.dragIndex === i) { this.dragIndex = null; return; }
|
||||
this.items.splice(i, 0, this.items.splice(this.dragIndex, 1)[0]);
|
||||
this.dragIndex = null;
|
||||
this.rebuildDt();
|
||||
},
|
||||
|
||||
addFiles(e) {
|
||||
for (const f of e.target.files) {
|
||||
this.items.push({ type: 'new', file: f, url: URL.createObjectURL(f) });
|
||||
}
|
||||
this.rebuildDt();
|
||||
e.target.value = '';
|
||||
},
|
||||
|
||||
remove(i) {
|
||||
const it = this.items[i];
|
||||
if (it.type === 'new') URL.revokeObjectURL(it.url);
|
||||
this.items.splice(i, 1);
|
||||
this.rebuildDt();
|
||||
},
|
||||
}">
|
||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="images", lang=lang | default(value='sk')) }}</span>
|
||||
<p class="{{ sublabel }}">{{ t(key="gallery-hint", lang=lang | default(value='sk')) }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3" x-show="items.length">
|
||||
<template x-for="(it, i) in items" :key="it.type === 'existing' ? it.id : it.url">
|
||||
<div draggable="true"
|
||||
@dragstart="dragIndex = i"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop(i)"
|
||||
:class="dragIndex === i ? 'opacity-50' : ''"
|
||||
class="group relative size-24 cursor-move overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||
|
||||
<input type="hidden" name="image_order" :value="it.type === 'existing' ? it.id : 'new'">
|
||||
|
||||
<img :src="it.type === 'existing' ? `/images/${it.image_id}` : it.url" alt="" class="size-full object-cover">
|
||||
|
||||
<span x-show="i === 0"
|
||||
class="absolute left-1 top-1 rounded-radius bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="main-image", lang=lang | default(value='sk')) }}</span>
|
||||
<button type="button" @click="remove(i)"
|
||||
class="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-surface/70 text-xs text-danger opacity-0 transition group-hover:opacity-100 dark:bg-surface-dark/70"
|
||||
title="{{ t(key='delete', lang=lang | default(value='sk')) }}">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Hidden input carries the accumulated files on submit; the visible picker #}
|
||||
{# only feeds addFiles() and is reset after each pick so selections stack. #}
|
||||
<input type="file" name="image" multiple class="hidden" x-ref="holder">
|
||||
<input type="file" accept="image/*" multiple class="hidden" x-ref="picker" @change="addFiles($event)">
|
||||
<button type="button" @click="$refs.picker.click()"
|
||||
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt/50">
|
||||
+ {{ t(key="add-images", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
||||
|
||||
@@ -3,32 +3,38 @@
|
||||
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
|
||||
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
|
||||
the shop grid) are dropped; the whole card links to the product page. #}
|
||||
{# Layout adapts to the `view` Alpine state set by _product_grid.html:
|
||||
'grid' (default) → vertical card; 'list' → horizontal row. On pages without
|
||||
that state (e.g. home) `view` is undefined, so the grid layout applies. #}
|
||||
<article
|
||||
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark">
|
||||
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
||||
class="group flex overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark"
|
||||
:class="view === 'list' ? 'flex-row flex-wrap' : 'flex-col'">
|
||||
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
|
||||
:class="view === 'list' ? 'flex-row' : 'flex-col'">
|
||||
<!-- Image -->
|
||||
<div class="h-44 overflow-hidden bg-surface-alt md:h-64 dark:bg-surface-dark">
|
||||
<div class="overflow-hidden bg-surface-alt dark:bg-surface-dark"
|
||||
:class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'">
|
||||
{% if product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
|
||||
<!-- Header: Title & Price -->
|
||||
<div class="flex justify-between gap-4">
|
||||
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||
{% if product.on_sale %}
|
||||
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
|
||||
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
|
||||
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
||||
{% endif %}
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1"
|
||||
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
|
||||
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
|
||||
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||
{% if product.on_sale %}
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
|
||||
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
||||
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-col gap-2 p-6 pt-0">
|
||||
<div class="flex flex-col gap-2"
|
||||
:class="view === 'list' ? 'w-full justify-center p-4 sm:w-56 sm:p-5' : 'p-6 pt-0'">
|
||||
{% if product.has_options %}
|
||||
{# Multiple variants: customer must pick on the product page. #}
|
||||
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
|
||||
|
||||
39
assets/views/shop/_product_grid.html
Normal file
39
assets/views/shop/_product_grid.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{# Product collection with a grid / list view toggle.
|
||||
The chosen view is held in Alpine and persisted to localStorage so it
|
||||
survives navigation; `_card.html` reads the same `view` state to switch
|
||||
its own layout between a vertical card and a horizontal row. #}
|
||||
<div x-data="{ view: localStorage.getItem('shopView') === 'list' ? 'list' : 'grid' }"
|
||||
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
|
||||
class="space-y-4">
|
||||
<!-- View toggle -->
|
||||
<div class="flex justify-end">
|
||||
<div class="inline-flex gap-0.5 rounded-radius border border-outline p-0.5 dark:border-outline-dark" role="group"
|
||||
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }} / {{ t(key='view-list', lang=lang | default(value='sk')) }}">
|
||||
<button type="button" @click="view = 'grid'" :aria-pressed="view === 'grid'"
|
||||
class="inline-flex size-8 items-center justify-center rounded-radius transition"
|
||||
:class="view === 'grid' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
|
||||
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }}"
|
||||
title="{{ t(key='view-grid', lang=lang | default(value='sk')) }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
|
||||
<path d="M3 3h6v6H3V3Zm8 0h6v6h-6V3ZM3 11h6v6H3v-6Zm8 0h6v6h-6v-6Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" @click="view = 'list'" :aria-pressed="view === 'list'"
|
||||
class="inline-flex size-8 items-center justify-center rounded-radius transition"
|
||||
:class="view === 'list' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
|
||||
aria-label="{{ t(key='view-list', lang=lang | default(value='sk')) }}"
|
||||
title="{{ t(key='view-list', lang=lang | default(value='sk')) }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
|
||||
<path d="M3 4h14v2.5H3V4Zm0 4.75h14v2.5H3v-2.5ZM3 13.5h14V16H3v-2.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products -->
|
||||
<div :class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4'">
|
||||
{% for product in products %}
|
||||
{% include "shop/_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,11 +29,7 @@
|
||||
</header>
|
||||
|
||||
{% if products | length > 0 %}
|
||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{% for product in products %}
|
||||
{% include "shop/_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% include "shop/_product_grid.html" %}
|
||||
{% else %}
|
||||
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
||||
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
|
||||
|
||||
@@ -11,11 +11,7 @@
|
||||
</header>
|
||||
|
||||
{% if products | length > 0 %}
|
||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{% for product in products %}
|
||||
{% include "shop/_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% include "shop/_product_grid.html" %}
|
||||
{% else %}
|
||||
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
||||
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
|
||||
|
||||
@@ -198,7 +198,7 @@ async fn create(
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let fields = parse_category_fields(&ctx, &form, None).await?;
|
||||
let image_id = match form.image {
|
||||
let image_id = match form.single_image() {
|
||||
Some(data) => Some(store_image(&ctx, data).await?),
|
||||
None => None,
|
||||
};
|
||||
@@ -252,7 +252,7 @@ async fn update(
|
||||
category.position = Set(fields.position);
|
||||
category.published = Set(fields.published);
|
||||
category.parent_id = Set(fields.parent_id);
|
||||
if let Some(data) = form.image {
|
||||
if let Some(data) = form.single_image() {
|
||||
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||
}
|
||||
category.update(&ctx.db).await?;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Multipart form handling shared by the product and category admin forms.
|
||||
//!
|
||||
//! Both forms submit a mix of text fields and an optional `image` file part;
|
||||
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
|
||||
//! uploaded image through the configured storage driver.
|
||||
//! Both forms submit a mix of text fields and `image` file part(s); this
|
||||
//! collects them into an easy-to-query [`MultipartForm`] and stores any
|
||||
//! uploaded image through the configured storage driver. The product form can
|
||||
//! upload several images at once and submits a unified gallery order as
|
||||
//! repeated `image_order` fields — each either an existing image's id or the
|
||||
//! literal `new` (a placeholder consumed, in order, from the uploaded files).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -18,11 +21,24 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
|
||||
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
||||
/// One slot in the unified gallery order submitted by the product form.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum ImageSlot {
|
||||
/// An existing image kept in the gallery.
|
||||
Existing(i32),
|
||||
/// A placeholder for one newly-uploaded file, consumed from [`MultipartForm::images`]
|
||||
/// in the order these slots appear.
|
||||
New,
|
||||
}
|
||||
|
||||
/// Collected multipart form: text fields keyed by name, the raw bytes of every
|
||||
/// `image` file part uploaded (empty file inputs are ignored, submission order
|
||||
/// preserved), and the full gallery order as repeated `image_order` fields —
|
||||
/// each either an existing image's id or the literal `new`.
|
||||
pub(crate) struct MultipartForm {
|
||||
fields: HashMap<String, String>,
|
||||
pub(crate) image: Option<Vec<u8>>,
|
||||
pub(crate) images: Vec<Vec<u8>>,
|
||||
pub(crate) image_order: Vec<ImageSlot>,
|
||||
}
|
||||
|
||||
impl MultipartForm {
|
||||
@@ -31,6 +47,12 @@ impl MultipartForm {
|
||||
normalize_empty(self.fields.get(key).cloned())
|
||||
}
|
||||
|
||||
/// The single uploaded image, for forms (like categories) that accept only
|
||||
/// one. Consumes the first uploaded part; any extras are ignored.
|
||||
pub(crate) fn single_image(self) -> Option<Vec<u8>> {
|
||||
self.images.into_iter().next()
|
||||
}
|
||||
|
||||
/// Whether a checkbox-style field is checked.
|
||||
pub(crate) fn checked(&self, key: &str) -> bool {
|
||||
matches!(
|
||||
@@ -59,7 +81,8 @@ impl MultipartForm {
|
||||
|
||||
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
||||
let mut fields = HashMap::new();
|
||||
let mut image = None;
|
||||
let mut images = Vec::new();
|
||||
let mut image_order = Vec::new();
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
@@ -82,8 +105,20 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
|
||||
)));
|
||||
}
|
||||
}
|
||||
// An empty file part (no file chosen in a slot) is ignored.
|
||||
if !data.is_empty() {
|
||||
image = Some(data);
|
||||
images.push(data);
|
||||
}
|
||||
} else if name == "image_order" {
|
||||
let value = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||
let trimmed = value.trim();
|
||||
if trimmed == "new" {
|
||||
image_order.push(ImageSlot::New);
|
||||
} else if let Ok(id) = trimmed.parse::<i32>() {
|
||||
image_order.push(ImageSlot::Existing(id));
|
||||
}
|
||||
} else {
|
||||
let value = field
|
||||
@@ -94,7 +129,11 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MultipartForm { fields, image })
|
||||
Ok(MultipartForm {
|
||||
fields,
|
||||
images,
|
||||
image_order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store an uploaded image's bytes and return its generated filename.
|
||||
|
||||
@@ -20,7 +20,7 @@ use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||
admin_form::{read_multipart_form, store_image, ImageSlot, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
},
|
||||
@@ -458,24 +458,73 @@ async fn create(
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
sync_variants(&txn, product.id, &variants).await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(product.id),
|
||||
image_id: Set(filename),
|
||||
position: Set(0),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
sync_images(&ctx, &txn, product.id, &form.image_order, &form.images).await?;
|
||||
txn.commit().await?;
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
/// Reconcile a product's images inside `txn` with the submitted unified
|
||||
/// `image_order`: for each [`ImageSlot::Existing`] entry re-number the
|
||||
/// corresponding image to its slot position; for each [`ImageSlot::New`]
|
||||
/// consume the next freshly uploaded file from `new_images`, storing and
|
||||
/// inserting it at that position. Any existing image not referenced in
|
||||
/// `image_order` is deleted.
|
||||
async fn sync_images<C: ConnectionTrait>(
|
||||
ctx: &AppContext,
|
||||
txn: &C,
|
||||
product_id: i32,
|
||||
image_order: &[ImageSlot],
|
||||
new_images: &[Vec<u8>],
|
||||
) -> Result<()> {
|
||||
let existing = product_images::for_product(txn, product_id).await?;
|
||||
let by_id: HashMap<i32, product_images::Model> =
|
||||
existing.iter().map(|m| (m.id, m.clone())).collect();
|
||||
let keep: HashSet<i32> = image_order
|
||||
.iter()
|
||||
.filter_map(|slot| match slot {
|
||||
ImageSlot::Existing(id) => Some(*id),
|
||||
ImageSlot::New => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for image in &existing {
|
||||
if !keep.contains(&image.id) {
|
||||
image.clone().delete(txn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_iter = new_images.iter();
|
||||
let mut position = 0i32;
|
||||
for slot in image_order {
|
||||
match slot {
|
||||
ImageSlot::Existing(id) => {
|
||||
if let Some(model) = by_id.get(id) {
|
||||
let mut active = model.clone().into_active_model();
|
||||
active.position = Set(position);
|
||||
active.update(txn).await?;
|
||||
}
|
||||
}
|
||||
ImageSlot::New => {
|
||||
if let Some(data) = new_iter.next() {
|
||||
let filename = store_image(ctx, data.clone()).await?;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(product_id),
|
||||
image_id: Set(filename),
|
||||
position: Set(position),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(txn)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
position += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn edit(
|
||||
auth: auth::JWT,
|
||||
@@ -486,10 +535,10 @@ async fn edit(
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
let image = product_images::first_for(&ctx, id).await?;
|
||||
let images = product_images::for_product(&ctx.db, id).await?;
|
||||
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||
let mut context = form_context(&ctx, &jar).await?;
|
||||
context["product"] = view::product_form(&product, image);
|
||||
context["product"] = view::product_form(&product, &images);
|
||||
context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
|
||||
format::view(&v, "admin/catalog/product_form.html", context)
|
||||
}
|
||||
@@ -523,20 +572,7 @@ async fn update(
|
||||
}
|
||||
product.update(&txn).await?;
|
||||
sync_variants(&txn, id, &variants).await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
let next_position = product_images::count_for(&ctx, id).await?;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(id),
|
||||
image_id: Set(filename),
|
||||
position: Set(next_position),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
sync_images(&ctx, &txn, id, &form.image_order, &form.images).await?;
|
||||
txn.commit().await?;
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
@@ -699,7 +735,9 @@ async fn sync_profiles(
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||
// Several images may be uploaded in one submission; allow a generous total
|
||||
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
|
||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES * 12 + 1024 * 1024);
|
||||
Routes::new()
|
||||
.add("/admin/catalog/products", get(index))
|
||||
.add("/admin/catalog/products/new", get(new))
|
||||
|
||||
@@ -37,11 +37,15 @@ pub async fn first_for(ctx: &AppContext, product_id: i32) -> Result<Option<Strin
|
||||
.map(|image| image.image_id))
|
||||
}
|
||||
|
||||
/// Number of images already attached to a product, used to position new uploads.
|
||||
pub async fn count_for(ctx: &AppContext, product_id: i32) -> Result<i32> {
|
||||
use sea_orm::PaginatorTrait;
|
||||
/// All of a product's images in display order (lowest position first). Takes a
|
||||
/// connection so it can run inside the update transaction.
|
||||
pub async fn for_product<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
product_id: i32,
|
||||
) -> Result<Vec<Model>> {
|
||||
Ok(Entity::find()
|
||||
.filter(Column::ProductId.eq(product_id))
|
||||
.count(&ctx.db)
|
||||
.await? as i32)
|
||||
.order_by_asc(Column::Position)
|
||||
.all(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::models::_entities::{categories, product_variants, products};
|
||||
use crate::models::_entities::{categories, product_images, product_variants, products};
|
||||
use crate::shared::money::format_price;
|
||||
use crate::shared::pricing::PricedProduct;
|
||||
|
||||
@@ -61,9 +61,9 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
|
||||
}
|
||||
|
||||
/// Shape used to pre-fill the admin product form (exposes `category_id` rather
|
||||
/// than a resolved name, and the current primary image). Variants are supplied
|
||||
/// separately by the controller.
|
||||
pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
|
||||
/// than a resolved name, and the current images in display order — first is the
|
||||
/// main one). Variants are supplied separately by the controller.
|
||||
pub fn product_form(product: &products::Model, images: &[product_images::Model]) -> Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
@@ -72,7 +72,10 @@ pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
|
||||
"currency": product.currency,
|
||||
"published": product.published,
|
||||
"category_id": product.category_id,
|
||||
"image": image,
|
||||
"images": images
|
||||
.iter()
|
||||
.map(|im| json!({ "id": im.id, "image_id": im.image_id }))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user