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

@@ -127,62 +127,71 @@
</div> </div>
{# --- Images gallery ------------------------------------------------------- #} {# --- Images gallery ------------------------------------------------------- #}
{# Existing images are reorderable (drag) and removable; the kept set is #} {# Unified drag-orderable gallery: existing images (with id) and new uploads #}
{# submitted in order as repeated `existing_images` ids. New uploads accumulate #} {# (placeholder blobs) live in a single list. The full order is submitted as #}
{# across separate "Add images" clicks into a DataTransfer that backs the hidden #} {# repeated `image_order` fields — an integer id for kept images or `new` for #}
{# `image` input (a native file input would otherwise replace its selection on #} {# each uploaded file. The DataTransfer backing the hidden `image` file input #}
{# every pick); the controller stores and appends them after the kept images. #} {# 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> <script id="images-data" type="application/json">{% if product %}{{ product.images | json_encode() | safe }}{% else %}[]{% endif %}</script>
<div class="space-y-2" x-data="{ <div class="space-y-2" x-data="{
images: JSON.parse(document.getElementById('images-data').textContent), init() {
staged: [], 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(), dt: new DataTransfer(),
dragIndex: null, 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) { onDrop(i) {
if (this.dragIndex === null || this.dragIndex === i) { this.dragIndex = null; return; } if (this.dragIndex === null || this.dragIndex === i) { this.dragIndex = null; return; }
this.images.splice(i, 0, this.images.splice(this.dragIndex, 1)[0]); this.items.splice(i, 0, this.items.splice(this.dragIndex, 1)[0]);
this.dragIndex = null; this.dragIndex = null;
this.rebuildDt();
}, },
addFiles(e) { addFiles(e) {
for (const f of e.target.files) { this.dt.items.add(f); this.staged.push({ url: URL.createObjectURL(f) }); } for (const f of e.target.files) {
this.$refs.holder.files = this.dt.files; this.items.push({ type: 'new', file: f, url: URL.createObjectURL(f) });
}
this.rebuildDt();
e.target.value = ''; e.target.value = '';
}, },
removeStaged(i) {
this.dt.items.remove(i); remove(i) {
URL.revokeObjectURL(this.staged[i].url); const it = this.items[i];
this.staged.splice(i, 1); if (it.type === 'new') URL.revokeObjectURL(it.url);
this.$refs.holder.files = this.dt.files; 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> <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> <p class="{{ sublabel }}">{{ t(key="gallery-hint", lang=lang | default(value='sk')) }}</p>
<div class="flex flex-wrap gap-3" x-show="images.length || staged.length"> <div class="flex flex-wrap gap-3" x-show="items.length">
<template x-for="(im, i) in images" :key="im.id"> <template x-for="(it, i) in items" :key="it.type === 'existing' ? it.id : it.url">
<div draggable="true" <div draggable="true"
@dragstart="dragIndex = i" @dragstart="dragIndex = i"
@dragover.prevent @dragover.prevent
@drop.prevent="onDrop(i)" @drop.prevent="onDrop(i)"
:class="dragIndex === i ? 'opacity-50' : ''" :class="dragIndex === i ? 'opacity-50' : ''"
class="group relative size-24 cursor-move overflow-hidden rounded-radius border border-outline dark:border-outline-dark"> class="group relative size-24 cursor-move overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<input type="hidden" name="existing_images" :value="im.id">
<img :src="`/images/${im.image_id}`" alt="" class="size-full object-cover"> <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" <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> 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="images.splice(i, 1)" <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>
{# Newly staged uploads (not yet saved): previews + remove. #}
<template x-for="(f, i) in staged" :key="f.url">
<div class="group relative size-24 overflow-hidden rounded-radius border border-dashed border-outline dark:border-outline-dark">
<img :src="f.url" alt="" class="size-full object-cover">
<span x-show="images.length === 0 && 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="removeStaged(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" 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> title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
</div> </div>

View File

@@ -3,8 +3,9 @@
//! Both forms submit a mix of text fields and `image` file part(s); this //! 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 //! collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver. The product form can //! 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 //! upload several images at once and submits a unified gallery order as
//! keeps (in display order) as repeated `existing_images` fields. //! 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; 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 /// Collected multipart form: text fields keyed by name, the raw bytes of every
/// `image` file part uploaded (empty file inputs are ignored, submission order /// `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 /// preserved), and the full gallery order as repeated `image_order` fields —
/// order it submitted them (`existing_images`, repeated — drives reorder and /// each either an existing image's id or the literal `new`.
/// delete on the edit form).
pub(crate) struct MultipartForm { pub(crate) struct MultipartForm {
fields: HashMap<String, String>, fields: HashMap<String, String>,
pub(crate) images: Vec<Vec<u8>>, pub(crate) images: Vec<Vec<u8>>,
pub(crate) kept_image_ids: Vec<i32>, pub(crate) image_order: Vec<ImageSlot>,
} }
impl MultipartForm { impl MultipartForm {
@@ -72,7 +82,7 @@ impl MultipartForm {
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> { pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
let mut fields = HashMap::new(); let mut fields = HashMap::new();
let mut images = Vec::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 while let Some(mut field) = multipart
.next_field() .next_field()
@@ -99,13 +109,16 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
if !data.is_empty() { if !data.is_empty() {
images.push(data); images.push(data);
} }
} else if name == "existing_images" { } else if name == "image_order" {
let value = field let value = field
.text() .text()
.await .await
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
if let Ok(id) = value.trim().parse::<i32>() { let trimmed = value.trim();
kept_image_ids.push(id); if trimmed == "new" {
image_order.push(ImageSlot::New);
} else if let Ok(id) = trimmed.parse::<i32>() {
image_order.push(ImageSlot::Existing(id));
} }
} else { } else {
let value = field let value = field
@@ -119,7 +132,7 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
Ok(MultipartForm { Ok(MultipartForm {
fields, fields,
images, images,
kept_image_ids, image_order,
}) })
} }

View File

@@ -20,7 +20,7 @@ use serde_json::json;
use crate::{ use crate::{
controllers::{ controllers::{
admin_form::{read_multipart_form, store_image, MultipartForm}, admin_form::{read_multipart_form, store_image, ImageSlot, MultipartForm},
i18n::current_lang, i18n::current_lang,
media::IMAGE_MAX_BYTES, media::IMAGE_MAX_BYTES,
}, },
@@ -458,27 +458,35 @@ async fn create(
.insert(&txn) .insert(&txn)
.await?; .await?;
sync_variants(&txn, product.id, &variants).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?; txn.commit().await?;
format::redirect("/admin/catalog/products") format::redirect("/admin/catalog/products")
} }
/// Reconcile a product's images inside `txn` with the submitted form: keep the /// Reconcile a product's images inside `txn` with the submitted unified
/// existing images named in `kept_ids`, re-numbering their positions to that /// `image_order`: for each [`ImageSlot::Existing`] entry re-number the
/// order (first = main); delete any existing image no longer named; then store /// corresponding image to its slot position; for each [`ImageSlot::New`]
/// and append the freshly uploaded `new_images` after the kept ones. /// 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>( async fn sync_images<C: ConnectionTrait>(
ctx: &AppContext, ctx: &AppContext,
txn: &C, txn: &C,
product_id: i32, product_id: i32,
kept_ids: &[i32], image_order: &[ImageSlot],
new_images: &[Vec<u8>], new_images: &[Vec<u8>],
) -> Result<()> { ) -> Result<()> {
let existing = product_images::for_product(txn, product_id).await?; let existing = product_images::for_product(txn, product_id).await?;
let by_id: HashMap<i32, product_images::Model> = let by_id: HashMap<i32, product_images::Model> =
existing.iter().map(|m| (m.id, m.clone())).collect(); 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 { for image in &existing {
if !keep.contains(&image.id) { 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 let mut new_iter = new_images.iter();
// exist (a stale form) are simply skipped.
let mut position = 0i32; let mut position = 0i32;
for id in kept_ids { for slot in image_order {
if let Some(model) = by_id.get(id) { match slot {
let mut active = model.clone().into_active_model(); ImageSlot::Existing(id) => {
active.position = Set(position); if let Some(model) = by_id.get(id) {
active.update(txn).await?; let mut active = model.clone().into_active_model();
position += 1; 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; position += 1;
} }
Ok(()) Ok(())
@@ -561,7 +572,7 @@ async fn update(
} }
product.update(&txn).await?; product.update(&txn).await?;
sync_variants(&txn, id, &variants).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?; txn.commit().await?;
format::redirect("/admin/catalog/products") format::redirect("/admin/catalog/products")