3 Commits

Author SHA1 Message Date
Priec
e5cac27010 card can be vertical or horizontal
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 19:50:01 +02:00
Priec
a45f9ef030 fixing product card 2026-06-22 19:40:08 +02:00
Priec
51155f2fd2 I can move the images around now 2026-06-22 19:03:33 +02:00
10 changed files with 174 additions and 100 deletions

View File

@@ -307,6 +307,8 @@ confirm-delete = Delete this for good?
shop-title = Shop
shop-subtitle = browse our products.
shop-empty = There are no products here yet.
view-grid = Grid view
view-list = List view
categories = Categories
all-products = All products
uncategorized = Uncategorized

View File

@@ -307,6 +307,8 @@ confirm-delete = Naozaj zmazať?
shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty.
view-grid = Zobrazenie v mriežke
view-list = Zobrazenie v zozname
categories = Kategórie
all-products = Všetky produkty
uncategorized = Bez kategórie

File diff suppressed because one or more lines are too long

View File

@@ -127,62 +127,71 @@
</div>
{# --- 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`. #}
<script id="images-data" type="application/json">{% if product %}{{ product.images | json_encode() | safe }}{% else %}[]{% endif %}</script>
<div class="space-y-2" x-data="{
images: JSON.parse(document.getElementById('images-data').textContent),
staged: [],
init() {
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(),
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) {
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.rebuildDt();
},
addFiles(e) {
for (const f of e.target.files) { this.dt.items.add(f); this.staged.push({ url: URL.createObjectURL(f) }); }
this.$refs.holder.files = this.dt.files;
for (const f of e.target.files) {
this.items.push({ type: 'new', file: f, url: URL.createObjectURL(f) });
}
this.rebuildDt();
e.target.value = '';
},
removeStaged(i) {
this.dt.items.remove(i);
URL.revokeObjectURL(this.staged[i].url);
this.staged.splice(i, 1);
this.$refs.holder.files = this.dt.files;
remove(i) {
const it = this.items[i];
if (it.type === 'new') URL.revokeObjectURL(it.url);
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>
<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">
<template x-for="(im, i) in images" :key="im.id">
<div class="flex flex-wrap gap-3" x-show="items.length">
<template x-for="(it, i) in items" :key="it.type === 'existing' ? it.id : it.url">
<div draggable="true"
@dragstart="dragIndex = i"
@dragover.prevent
@drop.prevent="onDrop(i)"
:class="dragIndex === i ? 'opacity-50' : ''"
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"
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)"
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)"
<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>

View File

@@ -3,32 +3,38 @@
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
the shop grid) are dropped; the whole card links to the product page. #}
{# Layout adapts to the `view` Alpine state set by _product_grid.html:
'grid' (default) → vertical card; 'list' → horizontal row. On pages without
that state (e.g. home) `view` is undefined, so the grid layout applies. #}
<article
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark">
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
class="group flex overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark"
:class="view === 'list' ? 'flex-row flex-wrap' : 'flex-col'">
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
:class="view === 'list' ? 'flex-row' : 'flex-col'">
<!-- Image -->
<div class="h-44 overflow-hidden bg-surface-alt md:h-64 dark:bg-surface-dark">
<div class="overflow-hidden bg-surface-alt dark:bg-surface-dark"
:class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'">
{% if product.image %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
{% endif %}
</div>
<!-- Content -->
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
<!-- Header: Title & Price -->
<div class="flex justify-between gap-4">
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
<div class="flex min-w-0 flex-1 flex-col gap-1"
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{% if product.on_sale %}
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
</span>
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
</div>
{% else %}
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
{% endif %}
</div>
</div>
</a>
<div class="flex flex-col gap-2 p-6 pt-0">
<div class="flex flex-col gap-2"
:class="view === 'list' ? 'w-full justify-center p-4 sm:w-56 sm:p-5' : 'p-6 pt-0'">
{% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}

View File

@@ -0,0 +1,39 @@
{# Product collection with a grid / list view toggle.
The chosen view is held in Alpine and persisted to localStorage so it
survives navigation; `_card.html` reads the same `view` state to switch
its own layout between a vertical card and a horizontal row. #}
<div x-data="{ view: localStorage.getItem('shopView') === 'list' ? 'list' : 'grid' }"
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
class="space-y-4">
<!-- View toggle -->
<div class="flex justify-end">
<div class="inline-flex gap-0.5 rounded-radius border border-outline p-0.5 dark:border-outline-dark" role="group"
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }} / {{ t(key='view-list', lang=lang | default(value='sk')) }}">
<button type="button" @click="view = 'grid'" :aria-pressed="view === 'grid'"
class="inline-flex size-8 items-center justify-center rounded-radius transition"
:class="view === 'grid' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }}"
title="{{ t(key='view-grid', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
<path d="M3 3h6v6H3V3Zm8 0h6v6h-6V3ZM3 11h6v6H3v-6Zm8 0h6v6h-6v-6Z" />
</svg>
</button>
<button type="button" @click="view = 'list'" :aria-pressed="view === 'list'"
class="inline-flex size-8 items-center justify-center rounded-radius transition"
:class="view === 'list' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
aria-label="{{ t(key='view-list', lang=lang | default(value='sk')) }}"
title="{{ t(key='view-list', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
<path d="M3 4h14v2.5H3V4Zm0 4.75h14v2.5H3v-2.5ZM3 13.5h14V16H3v-2.5Z" />
</svg>
</button>
</div>
</div>
<!-- Products -->
<div :class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4'">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
</div>

View File

@@ -29,11 +29,7 @@
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% include "shop/_product_grid.html" %}
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}

View File

@@ -11,11 +11,7 @@
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% include "shop/_product_grid.html" %}
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}

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,19 +494,19 @@ 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 {
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?;
position += 1;
}
}
for data in new_images {
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),
@@ -509,6 +517,9 @@ async fn sync_images<C: ConnectionTrait>(
}
.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")