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 for keyboard navigation */
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 =
|
||||||
|
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()
|
|> Repo.delete_all()
|
||||||
|
|
||||||
|
cleanup_orphaned_images(image_ids)
|
||||||
|
|
||||||
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@ -507,19 +542,26 @@ 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
|
||||||
|
{results, replaced_image_ids} =
|
||||||
images
|
images
|
||||||
|> Enum.with_index()
|
|> Enum.with_index()
|
||||||
|> Enum.map(fn {image_data, index} ->
|
|> Enum.map_reduce([], fn {image_data, index}, acc ->
|
||||||
position = image_data[:position] || index
|
position = image_data[:position] || index
|
||||||
src = image_data[:src]
|
src = image_data[:src]
|
||||||
existing = Map.get(existing_by_position, position)
|
existing = Map.get(existing_by_position, position)
|
||||||
@ -527,6 +569,7 @@ defmodule SimpleshopTheme.Products do
|
|||||||
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 ->
|
||||||
|
result =
|
||||||
if existing.color != image_data[:color] do
|
if existing.color != image_data[:color] do
|
||||||
existing
|
existing
|
||||||
|> ProductImage.changeset(%{color: image_data[:color]})
|
|> ProductImage.changeset(%{color: image_data[:color]})
|
||||||
@ -535,8 +578,13 @@ defmodule SimpleshopTheme.Products do
|
|||||||
{:ok, existing}
|
{:ok, existing}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{result, acc}
|
||||||
|
|
||||||
# Different URL at position - update src, clear image_id (triggers re-download)
|
# Different URL at position - update src, clear image_id (triggers re-download)
|
||||||
existing ->
|
existing ->
|
||||||
|
acc = if existing.image_id, do: [existing.image_id | acc], else: acc
|
||||||
|
|
||||||
|
result =
|
||||||
existing
|
existing
|
||||||
|> ProductImage.changeset(%{
|
|> ProductImage.changeset(%{
|
||||||
src: src,
|
src: src,
|
||||||
@ -546,6 +594,8 @@ defmodule SimpleshopTheme.Products do
|
|||||||
})
|
})
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|
||||||
|
{result, acc}
|
||||||
|
|
||||||
# New position - create new
|
# New position - create new
|
||||||
true ->
|
true ->
|
||||||
attrs =
|
attrs =
|
||||||
@ -553,9 +603,23 @@ defmodule SimpleshopTheme.Products do
|
|||||||
|> Map.put(:product_id, product_id)
|
|> Map.put(:product_id, product_id)
|
||||||
|> Map.put(:position, position)
|
|> Map.put(:position, position)
|
||||||
|
|
||||||
create_product_image(attrs)
|
{create_product_image(attrs), acc}
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user