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:
jamey 2026-02-16 08:20:55 +00:00
parent e226e64c0b
commit ccc14aa5e1
5 changed files with 149 additions and 69 deletions

View File

@ -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 for keyboard navigation */
.skip-link { .skip-link {
position: absolute; position: absolute;

View File

@ -290,17 +290,10 @@ const Lightbox = {
const ProductImageScroll = { const ProductImageScroll = {
mounted() { 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', () => { this.el.addEventListener('scroll', () => {
const {dots, thumbButtons, lightbox} = this._queryDom()
const index = Math.round(this.el.scrollLeft / this.el.offsetWidth) 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) dot.classList.toggle('product-image-dot-active', i === index)
}) })
thumbButtons.forEach((btn, i) => { thumbButtons.forEach((btn, i) => {
@ -316,15 +309,32 @@ const ProductImageScroll = {
this.el.addEventListener('pdp:scroll-prev', () => { this.el.addEventListener('pdp:scroll-prev', () => {
const current = Math.round(this.el.scrollLeft / this.el.offsetWidth) 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.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'})
}) })
this.el.addEventListener('pdp:scroll-next', () => { this.el.addEventListener('pdp:scroll-next', () => {
const current = Math.round(this.el.scrollLeft / this.el.offsetWidth) 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'}) 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')
}
} }
} }

View File

@ -153,7 +153,8 @@ defmodule SimpleshopTheme.Products do
@doc """ @doc """
Lists distinct categories from visible, active products. 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 def list_categories do
from(p in Product, from(p in Product,
@ -163,7 +164,29 @@ defmodule SimpleshopTheme.Products do
order_by: p.category order_by: p.category
) )
|> Repo.all() |> 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 end
@doc """ @doc """
@ -480,11 +503,23 @@ defmodule SimpleshopTheme.Products do
end end
@doc """ @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 def delete_product_images(%Product{id: product_id}) do
from(i in ProductImage, where: i.product_id == ^product_id) image_ids =
|> Repo.delete_all() 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 end
@doc """ @doc """
@ -507,55 +542,84 @@ defmodule SimpleshopTheme.Products do
|> MapSet.new() |> MapSet.new()
# Delete orphaned positions (images no longer in the list) # Delete orphaned positions (images no longer in the list)
orphaned_ids = orphaned =
existing_by_position existing_by_position
|> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end) |> 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 if orphaned_ids != [] do
from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all() from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all()
end end
# Upsert incoming images # Upsert incoming images, collecting image_ids displaced by URL changes
images {results, replaced_image_ids} =
|> Enum.with_index() images
|> Enum.map(fn {image_data, index} -> |> Enum.with_index()
position = image_data[:position] || index |> Enum.map_reduce([], fn {image_data, index}, acc ->
src = image_data[:src] position = image_data[:position] || index
existing = Map.get(existing_by_position, position) src = image_data[:src]
existing = Map.get(existing_by_position, position)
cond do cond do
# Same URL at position - update color if needed, preserve image_id # Same URL at position - update color if needed, preserve image_id
existing && existing.src == src -> existing && existing.src == src ->
if existing.color != image_data[:color] do result =
existing if existing.color != image_data[:color] do
|> ProductImage.changeset(%{color: image_data[:color]}) existing
|> Repo.update() |> ProductImage.changeset(%{color: image_data[:color]})
else |> Repo.update()
{:ok, existing} else
end {:ok, existing}
end
# Different URL at position - update src, clear image_id (triggers re-download) {result, acc}
existing ->
existing
|> ProductImage.changeset(%{
src: src,
alt: image_data[:alt],
color: image_data[:color],
image_id: nil
})
|> Repo.update()
# New position - create new # Different URL at position - update src, clear image_id (triggers re-download)
true -> existing ->
attrs = acc = if existing.image_id, do: [existing.image_id | acc], else: acc
image_data
|> Map.put(:product_id, product_id)
|> Map.put(:position, position)
create_product_image(attrs) result =
end existing
end) |> ProductImage.changeset(%{
src: src,
alt: image_data[:alt],
color: image_data[:color],
image_id: nil
})
|> Repo.update()
{result, acc}
# New position - create new
true ->
attrs =
image_data
|> Map.put(:product_id, product_id)
|> Map.put(:position, position)
{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 end
# ============================================================================= # =============================================================================

View File

@ -366,20 +366,7 @@ defmodule SimpleshopTheme.Theme.PreviewData do
end end
defp get_real_categories do defp get_real_categories do
Products.list_products(visible: true, status: "active") Products.list_categories()
|> 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)
end end
defp mock_products do defp mock_products do

View File

@ -135,7 +135,10 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
defp collection_filter_bar(assigns) do defp collection_filter_bar(assigns) do
~H""" ~H"""
<div class="flex flex-wrap items-center justify-between gap-3 mb-6"> <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"> <ul class="flex gap-1.5 sm:flex-wrap sm:gap-2">
<li class="shrink-0"> <li class="shrink-0">
<.link <.link