I can move the images around now

This commit is contained in:
Priec
2026-06-22 19:03:33 +02:00
parent 2d2aa012ec
commit 51155f2fd2
3 changed files with 106 additions and 73 deletions

View File

@@ -3,8 +3,9 @@
//! 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.
//! 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;
@@ -20,15 +21,24 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
})
}
/// 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 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).
/// 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) images: Vec<Vec<u8>>,
pub(crate) kept_image_ids: Vec<i32>,
pub(crate) image_order: Vec<ImageSlot>,
}
impl MultipartForm {
@@ -72,7 +82,7 @@ impl MultipartForm {
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
let mut fields = HashMap::new();
let mut images = Vec::new();
let mut kept_image_ids = Vec::new();
let mut image_order = Vec::new();
while let Some(mut field) = multipart
.next_field()
@@ -99,13 +109,16 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
if !data.is_empty() {
images.push(data);
}
} else if name == "existing_images" {
} else if name == "image_order" {
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);
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
@@ -119,7 +132,7 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
Ok(MultipartForm {
fields,
images,
kept_image_ids,
image_order,
})
}

View File

@@ -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,27 +458,35 @@ async fn create(
.insert(&txn)
.await?;
sync_variants(&txn, product.id, &variants).await?;
sync_images(&ctx, &txn, product.id, &form.kept_image_ids, &form.images).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 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.
/// 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,
kept_ids: &[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> = kept_ids.iter().copied().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) {
@@ -486,29 +494,32 @@ async fn sync_images<C: ConnectionTrait>(
}
}
// Re-number the kept images to their submitted order. Ids that no longer
// exist (a stale form) are simply skipped.
let mut new_iter = new_images.iter();
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 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?;
}
}
}
}
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(())
@@ -561,7 +572,7 @@ async fn update(
}
product.update(&txn).await?;
sync_variants(&txn, id, &variants).await?;
sync_images(&ctx, &txn, id, &form.kept_image_ids, &form.images).await?;
sync_images(&ctx, &txn, id, &form.image_order, &form.images).await?;
txn.commit().await?;
format::redirect("/admin/catalog/products")