fix category images, gallery reset, scroll hint and orphan image cleanup
- category_nav pulls first product image per category from DB - ProductImageScroll JS hook resets to index 0 on updated() - collection filter bar gets CSS fade gradient scroll hint on mobile - sync_product_images and delete_product_images now clean up orphaned Media.Image records to prevent DB bloat from repeated syncs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e226e64c0b
commit
ccc14aa5e1
@ -206,6 +206,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Horizontal scroll fade hint for collection category pills (mobile only) */
|
||||
.collection-filter-scroll {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 2rem), transparent);
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 2rem), transparent);
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
mask-image: none;
|
||||
-webkit-mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skip link for keyboard navigation */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
|
||||
@ -290,17 +290,10 @@ const Lightbox = {
|
||||
|
||||
const ProductImageScroll = {
|
||||
mounted() {
|
||||
const container = this.el.parentElement
|
||||
const dots = container.querySelector('.product-image-dots')
|
||||
const spans = dots ? dots.querySelectorAll('.product-image-dot') : []
|
||||
const lightbox = container.parentElement.querySelector('dialog')
|
||||
const thumbs = container.parentElement.querySelector('.pdp-gallery-thumbs')
|
||||
const thumbButtons = thumbs ? thumbs.querySelectorAll('.pdp-thumbnail') : []
|
||||
const imageCount = this.el.children.length
|
||||
|
||||
this.el.addEventListener('scroll', () => {
|
||||
const {dots, thumbButtons, lightbox} = this._queryDom()
|
||||
const index = Math.round(this.el.scrollLeft / this.el.offsetWidth)
|
||||
spans.forEach((dot, i) => {
|
||||
dots.forEach((dot, i) => {
|
||||
dot.classList.toggle('product-image-dot-active', i === index)
|
||||
})
|
||||
thumbButtons.forEach((btn, i) => {
|
||||
@ -316,15 +309,32 @@ const ProductImageScroll = {
|
||||
|
||||
this.el.addEventListener('pdp:scroll-prev', () => {
|
||||
const current = Math.round(this.el.scrollLeft / this.el.offsetWidth)
|
||||
const target = (current - 1 + imageCount) % imageCount
|
||||
const count = this.el.children.length
|
||||
const target = (current - 1 + count) % count
|
||||
this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'})
|
||||
})
|
||||
|
||||
this.el.addEventListener('pdp:scroll-next', () => {
|
||||
const current = Math.round(this.el.scrollLeft / this.el.offsetWidth)
|
||||
const target = (current + 1) % imageCount
|
||||
const count = this.el.children.length
|
||||
const target = (current + 1) % count
|
||||
this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'})
|
||||
})
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.el.scrollTo({left: 0, behavior: 'instant'})
|
||||
},
|
||||
|
||||
_queryDom() {
|
||||
const container = this.el.parentElement
|
||||
const dotsEl = container.querySelector('.product-image-dots')
|
||||
return {
|
||||
dots: dotsEl ? dotsEl.querySelectorAll('.product-image-dot') : [],
|
||||
thumbButtons: container.parentElement.querySelector('.pdp-gallery-thumbs')
|
||||
?.querySelectorAll('.pdp-thumbnail') || [],
|
||||
lightbox: container.parentElement.querySelector('dialog')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -153,7 +153,8 @@ defmodule SimpleshopTheme.Products do
|
||||
|
||||
@doc """
|
||||
Lists distinct categories from visible, active products.
|
||||
Returns a list of %{name: "Category", slug: "category"}.
|
||||
Returns a list of `%{name, slug, image_url}` where `image_url` is the
|
||||
first product image for a representative product in that category.
|
||||
"""
|
||||
def list_categories do
|
||||
from(p in Product,
|
||||
@ -163,7 +164,29 @@ defmodule SimpleshopTheme.Products do
|
||||
order_by: p.category
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn name -> %{name: name, slug: Slug.slugify(name)} end)
|
||||
|> Enum.map(fn name ->
|
||||
image_url = category_image_url(name)
|
||||
%{name: name, slug: Slug.slugify(name), image_url: image_url}
|
||||
end)
|
||||
end
|
||||
|
||||
defp category_image_url(category_name) do
|
||||
from(pi in ProductImage,
|
||||
join: p in Product,
|
||||
on: pi.product_id == p.id,
|
||||
where:
|
||||
p.visible == true and p.status == "active" and
|
||||
p.category == ^category_name,
|
||||
order_by: [asc: pi.position],
|
||||
limit: 1,
|
||||
select: {pi.image_id, pi.src}
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
{id, _src} when not is_nil(id) -> "/images/#{id}/variant/400.webp"
|
||||
{_, src} when is_binary(src) -> src
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -480,11 +503,23 @@ defmodule SimpleshopTheme.Products do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes all images for a product.
|
||||
Deletes all images for a product, including their backing Media.Image records.
|
||||
"""
|
||||
def delete_product_images(%Product{id: product_id}) do
|
||||
from(i in ProductImage, where: i.product_id == ^product_id)
|
||||
image_ids =
|
||||
from(pi in ProductImage,
|
||||
where: pi.product_id == ^product_id and not is_nil(pi.image_id),
|
||||
select: pi.image_id
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
result =
|
||||
from(pi in ProductImage, where: pi.product_id == ^product_id)
|
||||
|> Repo.delete_all()
|
||||
|
||||
cleanup_orphaned_images(image_ids)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -507,19 +542,26 @@ defmodule SimpleshopTheme.Products do
|
||||
|> MapSet.new()
|
||||
|
||||
# Delete orphaned positions (images no longer in the list)
|
||||
orphaned_ids =
|
||||
orphaned =
|
||||
existing_by_position
|
||||
|> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end)
|
||||
|> Enum.map(fn {_position, img} -> img.id end)
|
||||
|
||||
orphaned_ids = Enum.map(orphaned, fn {_position, img} -> img.id end)
|
||||
|
||||
orphaned_image_ids =
|
||||
orphaned
|
||||
|> Enum.map(fn {_position, img} -> img.image_id end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
if orphaned_ids != [] do
|
||||
from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all()
|
||||
end
|
||||
|
||||
# Upsert incoming images
|
||||
# Upsert incoming images, collecting image_ids displaced by URL changes
|
||||
{results, replaced_image_ids} =
|
||||
images
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {image_data, index} ->
|
||||
|> Enum.map_reduce([], fn {image_data, index}, acc ->
|
||||
position = image_data[:position] || index
|
||||
src = image_data[:src]
|
||||
existing = Map.get(existing_by_position, position)
|
||||
@ -527,6 +569,7 @@ defmodule SimpleshopTheme.Products do
|
||||
cond do
|
||||
# Same URL at position - update color if needed, preserve image_id
|
||||
existing && existing.src == src ->
|
||||
result =
|
||||
if existing.color != image_data[:color] do
|
||||
existing
|
||||
|> ProductImage.changeset(%{color: image_data[:color]})
|
||||
@ -535,8 +578,13 @@ defmodule SimpleshopTheme.Products do
|
||||
{:ok, existing}
|
||||
end
|
||||
|
||||
{result, acc}
|
||||
|
||||
# Different URL at position - update src, clear image_id (triggers re-download)
|
||||
existing ->
|
||||
acc = if existing.image_id, do: [existing.image_id | acc], else: acc
|
||||
|
||||
result =
|
||||
existing
|
||||
|> ProductImage.changeset(%{
|
||||
src: src,
|
||||
@ -546,6 +594,8 @@ defmodule SimpleshopTheme.Products do
|
||||
})
|
||||
|> Repo.update()
|
||||
|
||||
{result, acc}
|
||||
|
||||
# New position - create new
|
||||
true ->
|
||||
attrs =
|
||||
@ -553,9 +603,23 @@ defmodule SimpleshopTheme.Products do
|
||||
|> Map.put(:product_id, product_id)
|
||||
|> Map.put(:position, position)
|
||||
|
||||
create_product_image(attrs)
|
||||
{create_product_image(attrs), acc}
|
||||
end
|
||||
end)
|
||||
|
||||
cleanup_orphaned_images(orphaned_image_ids ++ replaced_image_ids)
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
# Deletes Media.Image records that are no longer referenced by any product_image.
|
||||
defp cleanup_orphaned_images([]), do: :ok
|
||||
|
||||
defp cleanup_orphaned_images(image_ids) do
|
||||
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
||||
|
||||
from(i in ImageSchema, where: i.id in ^image_ids)
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@ -366,20 +366,7 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
||||
end
|
||||
|
||||
defp get_real_categories do
|
||||
Products.list_products(visible: true, status: "active")
|
||||
|> Enum.map(& &1.category)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.frequencies()
|
||||
|> Enum.map(fn {name, count} ->
|
||||
%{
|
||||
id: Slug.slugify(name),
|
||||
name: name,
|
||||
slug: Slug.slugify(name),
|
||||
product_count: count,
|
||||
image_url: nil
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.name)
|
||||
Products.list_categories()
|
||||
end
|
||||
|
||||
defp mock_products do
|
||||
|
||||
@ -135,7 +135,10 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|
||||
defp collection_filter_bar(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<nav aria-label="Collection filters" class="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<nav
|
||||
aria-label="Collection filters"
|
||||
class="collection-filter-scroll overflow-x-auto py-1 -mx-4 px-4 sm:overflow-visible sm:mx-0 sm:px-0 sm:py-0"
|
||||
>
|
||||
<ul class="flex gap-1.5 sm:flex-wrap sm:gap-2">
|
||||
<li class="shrink-0">
|
||||
<.link
|
||||
|
||||
Loading…
Reference in New Issue
Block a user