muiltiple images in carousel
This commit is contained in:
@@ -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,10 @@
|
||||
//! 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 the ids of the existing images it
|
||||
//! keeps (in display order) as repeated `existing_images` fields.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -18,11 +20,15 @@ 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).
|
||||
/// 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 ids of the existing images the form kept, in the display
|
||||
/// order it submitted them (`existing_images`, repeated — drives reorder and
|
||||
/// delete on the edit form).
|
||||
pub(crate) struct MultipartForm {
|
||||
fields: HashMap<String, String>,
|
||||
pub(crate) image: Option<Vec<u8>>,
|
||||
pub(crate) images: Vec<Vec<u8>>,
|
||||
pub(crate) kept_image_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
impl MultipartForm {
|
||||
@@ -31,6 +37,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 +71,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 kept_image_ids = Vec::new();
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
@@ -82,8 +95,17 @@ 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 == "existing_images" {
|
||||
let value = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||
if let Ok(id) = value.trim().parse::<i32>() {
|
||||
kept_image_ids.push(id);
|
||||
}
|
||||
} else {
|
||||
let value = field
|
||||
@@ -94,7 +116,11 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MultipartForm { fields, image })
|
||||
Ok(MultipartForm {
|
||||
fields,
|
||||
images,
|
||||
kept_image_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store an uploaded image's bytes and return its generated filename.
|
||||
|
||||
@@ -458,24 +458,62 @@ 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.kept_image_ids, &form.images).await?;
|
||||
txn.commit().await?;
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
/// Reconcile a product's images inside `txn` with the submitted form: keep the
|
||||
/// existing images named in `kept_ids`, re-numbering their positions to that
|
||||
/// order (first = main); delete any existing image no longer named; then store
|
||||
/// and append the freshly uploaded `new_images` after the kept ones.
|
||||
async fn sync_images<C: ConnectionTrait>(
|
||||
ctx: &AppContext,
|
||||
txn: &C,
|
||||
product_id: i32,
|
||||
kept_ids: &[i32],
|
||||
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> = kept_ids.iter().copied().collect();
|
||||
|
||||
for image in &existing {
|
||||
if !keep.contains(&image.id) {
|
||||
image.clone().delete(txn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-number the kept images to their submitted order. Ids that no longer
|
||||
// exist (a stale form) are simply skipped.
|
||||
let mut position = 0i32;
|
||||
for id in kept_ids {
|
||||
if let Some(model) = by_id.get(id) {
|
||||
let mut active = model.clone().into_active_model();
|
||||
active.position = Set(position);
|
||||
active.update(txn).await?;
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for data in new_images {
|
||||
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 +524,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 +561,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(&txn, 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.kept_image_ids, &form.images).await?;
|
||||
txn.commit().await?;
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
@@ -699,7 +724,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,15 +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.
|
||||
///
|
||||
/// Takes a connection rather than the `AppContext` so it can run on an open
|
||||
/// transaction; calling it on `ctx.db` while a transaction holds the pool's only
|
||||
/// connection would deadlock the pool.
|
||||
pub async fn count_for<C: ConnectionTrait>(db: &C, 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(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