diff --git a/assets/css/theme-semantic.css b/assets/css/theme-semantic.css index c0e9db5..54aac28 100644 --- a/assets/css/theme-semantic.css +++ b/assets/css/theme-semantic.css @@ -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; diff --git a/assets/js/app.js b/assets/js/app.js index fcf0b45..5c8f4ab 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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') + } } } diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex index fefede1..e4c208d 100644 --- a/lib/simpleshop_theme/products.ex +++ b/lib/simpleshop_theme/products.ex @@ -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) - |> Repo.delete_all() + 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,55 +542,84 @@ 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 - images - |> Enum.with_index() - |> Enum.map(fn {image_data, index} -> - position = image_data[:position] || index - src = image_data[:src] - existing = Map.get(existing_by_position, position) + # Upsert incoming images, collecting image_ids displaced by URL changes + {results, replaced_image_ids} = + images + |> Enum.with_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) - cond do - # Same URL at position - update color if needed, preserve image_id - existing && existing.src == src -> - if existing.color != image_data[:color] do - existing - |> ProductImage.changeset(%{color: image_data[:color]}) - |> Repo.update() - else - {:ok, existing} - end + 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]}) + |> Repo.update() + else + {:ok, existing} + end - # Different URL at position - update src, clear image_id (triggers re-download) - existing -> - existing - |> 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) + # 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 - create_product_image(attrs) - end - end) + result = + existing + |> 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 # ============================================================================= diff --git a/lib/simpleshop_theme/theme/preview_data.ex b/lib/simpleshop_theme/theme/preview_data.ex index ce3ec66..93f2d96 100644 --- a/lib/simpleshop_theme/theme/preview_data.ex +++ b/lib/simpleshop_theme/theme/preview_data.ex @@ -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 diff --git a/lib/simpleshop_theme_web/live/shop/collection.ex b/lib/simpleshop_theme_web/live/shop/collection.ex index cbc4057..0f55a46 100644 --- a/lib/simpleshop_theme_web/live/shop/collection.ex +++ b/lib/simpleshop_theme_web/live/shop/collection.ex @@ -135,7 +135,10 @@ defmodule SimpleshopThemeWeb.Shop.Collection do defp collection_filter_bar(assigns) do ~H"""