diff --git a/assets/views/admin/catalog/product_form.html b/assets/views/admin/catalog/product_form.html
index 3eeae61..bd02266 100644
--- a/assets/views/admin/catalog/product_form.html
+++ b/assets/views/admin/catalog/product_form.html
@@ -127,62 +127,71 @@
{# --- Images gallery ------------------------------------------------------- #}
- {# Existing images are reorderable (drag) and removable; the kept set is #}
- {# submitted in order as repeated `existing_images` ids. New uploads accumulate #}
- {# across separate "Add images" clicks into a DataTransfer that backs the hidden #}
- {# `image` input (a native file input would otherwise replace its selection on #}
- {# every pick); the controller stores and appends them after the kept images. #}
+ {# 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`. #}
diff --git a/src/controllers/admin_form.rs b/src/controllers/admin_form.rs
index 0def3bd..124ee44 100644
--- a/src/controllers/admin_form.rs
+++ b/src/controllers/admin_form.rs
@@ -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) -> Option {
})
}
+/// 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,
pub(crate) images: Vec>,
- pub(crate) kept_image_ids: Vec,
+ pub(crate) image_order: Vec,
}
impl MultipartForm {
@@ -72,7 +82,7 @@ impl MultipartForm {
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result {
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() {
- kept_image_ids.push(id);
+ let trimmed = value.trim();
+ if trimmed == "new" {
+ image_order.push(ImageSlot::New);
+ } else if let Ok(id) = trimmed.parse::() {
+ 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(
ctx: &AppContext,
txn: &C,
product_id: i32,
- kept_ids: &[i32],
+ image_order: &[ImageSlot],
new_images: &[Vec],
) -> Result<()> {
let existing = product_images::for_product(txn, product_id).await?;
let by_id: HashMap =
existing.iter().map(|m| (m.id, m.clone())).collect();
- let keep: HashSet = kept_ids.iter().copied().collect();
+ let keep: HashSet = 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(
}
}
- // 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")