I can move the images around now
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,19 +494,19 @@ 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 {
|
||||||
|
match slot {
|
||||||
|
ImageSlot::Existing(id) => {
|
||||||
if let Some(model) = by_id.get(id) {
|
if let Some(model) = by_id.get(id) {
|
||||||
let mut active = model.clone().into_active_model();
|
let mut active = model.clone().into_active_model();
|
||||||
active.position = Set(position);
|
active.position = Set(position);
|
||||||
active.update(txn).await?;
|
active.update(txn).await?;
|
||||||
position += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ImageSlot::New => {
|
||||||
for data in new_images {
|
if let Some(data) = new_iter.next() {
|
||||||
let filename = store_image(ctx, data.clone()).await?;
|
let filename = store_image(ctx, data.clone()).await?;
|
||||||
product_images::ActiveModel {
|
product_images::ActiveModel {
|
||||||
product_id: Set(product_id),
|
product_id: Set(product_id),
|
||||||
@@ -509,6 +517,9 @@ async fn sync_images<C: ConnectionTrait>(
|
|||||||
}
|
}
|
||||||
.insert(txn)
|
.insert(txn)
|
||||||
.await?;
|
.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")
|
||||||
|
|||||||
Reference in New Issue
Block a user