feat: add product image download pipeline for PageSpeed 100%
Downloads Printify CDN images via ImageDownloadWorker, processes through Media pipeline (WebP conversion, AVIF/WebP variant generation), and links to ProductImage via new image_id FK. - Add image_id to product_images table - ImageDownloadWorker downloads and processes external images - sync_product_images preserves image_id when URL unchanged - PreviewData uses local images for responsive <picture> elements - VariantCache enqueues pending downloads on startup - mix simpleshop.download_images backfill task Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c818d0399c
commit
1b49b470f2
11
PROGRESS.md
11
PROGRESS.md
@ -43,6 +43,11 @@
|
|||||||
- Disk cache for variants (regenerable from DB)
|
- Disk cache for variants (regenerable from DB)
|
||||||
- `mix optimize_images` task for mockups
|
- `mix optimize_images` task for mockups
|
||||||
- On-demand JPEG fallback generation
|
- On-demand JPEG fallback generation
|
||||||
|
- Product image download pipeline (downloads Printify CDN images, processes through Media pipeline)
|
||||||
|
- ImageDownloadWorker downloads and links images to ProductImage
|
||||||
|
- PreviewData uses local images for responsive `<picture>` elements
|
||||||
|
- Startup recovery re-enqueues pending downloads
|
||||||
|
- `mix simpleshop.download_images` backfill task
|
||||||
|
|
||||||
See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for implementation details
|
See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for implementation details
|
||||||
|
|
||||||
@ -64,6 +69,12 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im
|
|||||||
- [ ] Add variant selector component (~2hr)
|
- [ ] Add variant selector component (~2hr)
|
||||||
|
|
||||||
#### Recently Completed
|
#### Recently Completed
|
||||||
|
- [x] Product image download pipeline
|
||||||
|
- Downloads Printify CDN images via ImageDownloadWorker
|
||||||
|
- Processes through Media pipeline (WebP conversion, AVIF/WebP variants)
|
||||||
|
- PreviewData uses local images for responsive `<picture>` elements
|
||||||
|
- sync_product_images preserves image_id when URL unchanged
|
||||||
|
- Startup recovery and `mix simpleshop.download_images` backfill
|
||||||
- [x] Wire shop LiveViews to Products context
|
- [x] Wire shop LiveViews to Products context
|
||||||
- PreviewData now uses real products when available
|
- PreviewData now uses real products when available
|
||||||
- Fixed Printify image sync (position was string, not integer)
|
- Fixed Printify image sync (position was string, not integer)
|
||||||
|
|||||||
41
lib/mix/tasks/simpleshop/download_images.ex
Normal file
41
lib/mix/tasks/simpleshop/download_images.ex
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
defmodule Mix.Tasks.Simpleshop.DownloadImages do
|
||||||
|
@moduledoc """
|
||||||
|
Enqueues download jobs for product images that haven't been processed yet.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
mix simpleshop.download_images
|
||||||
|
|
||||||
|
This task finds all product images with a `src` URL but no linked `image_id`
|
||||||
|
and enqueues them for download via the ImageDownloadWorker.
|
||||||
|
|
||||||
|
Use this to backfill existing products after enabling the image download pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
@shortdoc "Enqueue product image downloads"
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run(_args) do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Sync.ImageDownloadWorker
|
||||||
|
|
||||||
|
pending = Products.list_pending_downloads(limit: 10_000)
|
||||||
|
count = length(pending)
|
||||||
|
|
||||||
|
if count == 0 do
|
||||||
|
Mix.shell().info("No pending product images to download.")
|
||||||
|
else
|
||||||
|
Mix.shell().info("Enqueueing #{count} product images for download...")
|
||||||
|
|
||||||
|
Enum.each(pending, fn image ->
|
||||||
|
ImageDownloadWorker.enqueue(image.id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
Mix.shell().info("Done. Images will be processed by Oban workers.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -15,6 +15,8 @@ defmodule SimpleshopTheme.Images.VariantCache do
|
|||||||
alias SimpleshopTheme.Repo
|
alias SimpleshopTheme.Repo
|
||||||
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
||||||
alias SimpleshopTheme.Images.{Optimizer, OptimizeWorker}
|
alias SimpleshopTheme.Images.{Optimizer, OptimizeWorker}
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Sync.ImageDownloadWorker
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
@mockup_dir "priv/static/mockups"
|
@mockup_dir "priv/static/mockups"
|
||||||
@ -35,6 +37,7 @@ defmodule SimpleshopTheme.Images.VariantCache do
|
|||||||
|
|
||||||
ensure_database_image_variants()
|
ensure_database_image_variants()
|
||||||
ensure_mockup_variants()
|
ensure_mockup_variants()
|
||||||
|
ensure_product_image_downloads()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_database_image_variants do
|
defp ensure_database_image_variants do
|
||||||
@ -100,4 +103,15 @@ defmodule SimpleshopTheme.Images.VariantCache do
|
|||||||
dir = Path.dirname(source_path)
|
dir = Path.dirname(source_path)
|
||||||
File.exists?(Path.join(dir, "#{basename}-800.webp"))
|
File.exists?(Path.join(dir, "#{basename}-800.webp"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp ensure_product_image_downloads do
|
||||||
|
pending = Products.list_pending_downloads(limit: 500)
|
||||||
|
|
||||||
|
if pending == [] do
|
||||||
|
Logger.info("[VariantCache] All product images downloaded")
|
||||||
|
else
|
||||||
|
Logger.info("[VariantCache] Enqueueing #{length(pending)} product images for download")
|
||||||
|
Enum.each(pending, fn image -> ImageDownloadWorker.enqueue(image.id) end)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -294,6 +294,40 @@ defmodule SimpleshopTheme.Products do
|
|||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single product image by ID.
|
||||||
|
"""
|
||||||
|
def get_product_image(id) do
|
||||||
|
Repo.get(ProductImage, id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Links a product image to a Media.Image by setting its image_id.
|
||||||
|
"""
|
||||||
|
def link_product_image(%ProductImage{} = product_image, image_id) do
|
||||||
|
product_image
|
||||||
|
|> ProductImage.changeset(%{image_id: image_id})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists product images that need downloading (have src but no image_id).
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
* `:limit` - maximum number of images to return (default: 100)
|
||||||
|
"""
|
||||||
|
def list_pending_downloads(opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit, 100)
|
||||||
|
|
||||||
|
from(i in ProductImage,
|
||||||
|
where: not is_nil(i.src) and is_nil(i.image_id),
|
||||||
|
order_by: [asc: i.inserted_at],
|
||||||
|
limit: ^limit
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes all images for a product.
|
Deletes all images for a product.
|
||||||
"""
|
"""
|
||||||
@ -305,20 +339,60 @@ defmodule SimpleshopTheme.Products do
|
|||||||
@doc """
|
@doc """
|
||||||
Syncs product images from a list of image data.
|
Syncs product images from a list of image data.
|
||||||
|
|
||||||
Deletes existing images and inserts new ones.
|
Preserves existing image_id references when the URL hasn't changed.
|
||||||
|
Returns a list of {:ok, image} tuples for images that need downloading.
|
||||||
"""
|
"""
|
||||||
def sync_product_images(%Product{id: product_id} = product, images) when is_list(images) do
|
def sync_product_images(%Product{id: product_id}, images) when is_list(images) do
|
||||||
delete_product_images(product)
|
# Build map of existing images by position
|
||||||
|
existing_by_position =
|
||||||
|
from(i in ProductImage, where: i.product_id == ^product_id)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Map.new(&{&1.position, &1})
|
||||||
|
|
||||||
|
incoming_positions =
|
||||||
|
images
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {image_data, index} -> image_data[:position] || index end)
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
|
# Delete orphaned positions (images no longer in the list)
|
||||||
|
orphaned_ids =
|
||||||
|
existing_by_position
|
||||||
|
|> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end)
|
||||||
|
|> Enum.map(fn {_position, img} -> img.id end)
|
||||||
|
|
||||||
|
if orphaned_ids != [] do
|
||||||
|
from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Upsert incoming images
|
||||||
images
|
images
|
||||||
|> Enum.with_index()
|
|> Enum.with_index()
|
||||||
|> Enum.map(fn {image_data, index} ->
|
|> Enum.map(fn {image_data, index} ->
|
||||||
attrs =
|
position = image_data[:position] || index
|
||||||
image_data
|
src = image_data[:src]
|
||||||
|> Map.put(:product_id, product_id)
|
existing = Map.get(existing_by_position, position)
|
||||||
|> Map.put_new(:position, index)
|
|
||||||
|
|
||||||
create_product_image(attrs)
|
cond do
|
||||||
|
# Same URL at position - keep existing (preserve image_id)
|
||||||
|
existing && existing.src == src ->
|
||||||
|
{:ok, existing}
|
||||||
|
|
||||||
|
# Different URL at position - update src, clear image_id (triggers re-download)
|
||||||
|
existing ->
|
||||||
|
existing
|
||||||
|
|> ProductImage.changeset(%{src: src, alt: image_data[:alt], image_id: nil})
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
# New position - create new
|
||||||
|
true ->
|
||||||
|
attrs =
|
||||||
|
image_data
|
||||||
|
|> Map.put(:product_id, product_id)
|
||||||
|
|> Map.put(:position, position)
|
||||||
|
|
||||||
|
create_product_image(attrs)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,10 @@ defmodule SimpleshopTheme.Products.ProductImage do
|
|||||||
field :src, :string
|
field :src, :string
|
||||||
field :position, :integer, default: 0
|
field :position, :integer, default: 0
|
||||||
field :alt, :string
|
field :alt, :string
|
||||||
|
field :image_id, :binary_id
|
||||||
|
|
||||||
belongs_to :product, SimpleshopTheme.Products.Product
|
belongs_to :product, SimpleshopTheme.Products.Product
|
||||||
|
belongs_to :image, SimpleshopTheme.Media.Image, define_field: false
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
@ -26,8 +28,9 @@ defmodule SimpleshopTheme.Products.ProductImage do
|
|||||||
"""
|
"""
|
||||||
def changeset(product_image, attrs) do
|
def changeset(product_image, attrs) do
|
||||||
product_image
|
product_image
|
||||||
|> cast(attrs, [:product_id, :src, :position, :alt])
|
|> cast(attrs, [:product_id, :src, :position, :alt, :image_id])
|
||||||
|> validate_required([:product_id, :src])
|
|> validate_required([:product_id, :src])
|
||||||
|> foreign_key_constraint(:product_id)
|
|> foreign_key_constraint(:product_id)
|
||||||
|
|> foreign_key_constraint(:image_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
139
lib/simpleshop_theme/sync/image_download_worker.ex
Normal file
139
lib/simpleshop_theme/sync/image_download_worker.ex
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
defmodule SimpleshopTheme.Sync.ImageDownloadWorker do
|
||||||
|
@moduledoc """
|
||||||
|
Oban worker for downloading product images from external URLs.
|
||||||
|
|
||||||
|
Downloads images from Printify CDN, processes through the Media pipeline
|
||||||
|
(WebP conversion, AVIF/WebP variant generation), and links to ProductImage.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
# Enqueue a download for a product image
|
||||||
|
ImageDownloadWorker.enqueue(product_image_id)
|
||||||
|
|
||||||
|
## Job Args
|
||||||
|
|
||||||
|
* `product_image_id` - The ID of the ProductImage to download
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Oban.Worker, queue: :images, max_attempts: 3
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Media
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: %{"product_image_id" => product_image_id}}) do
|
||||||
|
case Products.get_product_image(product_image_id) do
|
||||||
|
nil ->
|
||||||
|
{:cancel, :product_image_not_found}
|
||||||
|
|
||||||
|
%{image_id: image_id} when not is_nil(image_id) ->
|
||||||
|
# Already has a linked image, skip
|
||||||
|
:ok
|
||||||
|
|
||||||
|
product_image ->
|
||||||
|
download_and_link(product_image)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Enqueue an image download for a product image.
|
||||||
|
"""
|
||||||
|
def enqueue(product_image_id) do
|
||||||
|
%{product_image_id: product_image_id}
|
||||||
|
|> new()
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp download_and_link(product_image) do
|
||||||
|
case download_image(product_image.src) do
|
||||||
|
{:ok, data, content_type} ->
|
||||||
|
upload_and_link(product_image, data, content_type)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"[ImageDownloadWorker] Failed to download #{product_image.src}: #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp download_image(url) do
|
||||||
|
case Req.get(url, receive_timeout: 30_000) do
|
||||||
|
{:ok, %Req.Response{status: 200, body: body, headers: headers}} ->
|
||||||
|
content_type = get_content_type(headers)
|
||||||
|
{:ok, body, content_type}
|
||||||
|
|
||||||
|
{:ok, %Req.Response{status: status}} ->
|
||||||
|
{:error, {:http_error, status}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_content_type(headers) do
|
||||||
|
headers
|
||||||
|
|> Enum.find(fn {k, _v} -> String.downcase(k) == "content-type" end)
|
||||||
|
|> case do
|
||||||
|
{_, value} when is_binary(value) -> value |> String.split(";") |> hd() |> String.trim()
|
||||||
|
{_, [value | _]} -> value |> String.split(";") |> hd() |> String.trim()
|
||||||
|
_ -> "image/jpeg"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upload_and_link(product_image, data, content_type) do
|
||||||
|
filename = extract_filename(product_image.src, content_type)
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
image_type: "product",
|
||||||
|
filename: filename,
|
||||||
|
content_type: content_type,
|
||||||
|
file_size: byte_size(data),
|
||||||
|
data: data
|
||||||
|
}
|
||||||
|
|
||||||
|
case Media.upload_image(attrs) do
|
||||||
|
{:ok, image} ->
|
||||||
|
case Products.link_product_image(product_image, image.id) do
|
||||||
|
{:ok, _} ->
|
||||||
|
Logger.info(
|
||||||
|
"[ImageDownloadWorker] Downloaded and linked image for #{product_image.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("[ImageDownloadWorker] Failed to link image: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("[ImageDownloadWorker] Failed to upload image: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_filename(url, content_type) do
|
||||||
|
# Extract filename from URL path, fall back to generated name
|
||||||
|
uri = URI.parse(url)
|
||||||
|
path_parts = String.split(uri.path || "", "/")
|
||||||
|
basename = List.last(path_parts) || "image"
|
||||||
|
|
||||||
|
# Ensure it has an extension
|
||||||
|
if Path.extname(basename) == "" do
|
||||||
|
ext = extension_for_content_type(content_type)
|
||||||
|
"#{basename}#{ext}"
|
||||||
|
else
|
||||||
|
basename
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extension_for_content_type("image/jpeg"), do: ".jpg"
|
||||||
|
defp extension_for_content_type("image/png"), do: ".png"
|
||||||
|
defp extension_for_content_type("image/webp"), do: ".webp"
|
||||||
|
defp extension_for_content_type("image/gif"), do: ".gif"
|
||||||
|
defp extension_for_content_type(_), do: ".jpg"
|
||||||
|
end
|
||||||
@ -20,6 +20,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
alias SimpleshopTheme.Products
|
alias SimpleshopTheme.Products
|
||||||
alias SimpleshopTheme.Products.ProviderConnection
|
alias SimpleshopTheme.Products.ProviderConnection
|
||||||
alias SimpleshopTheme.Providers.Provider
|
alias SimpleshopTheme.Providers.Provider
|
||||||
|
alias SimpleshopTheme.Sync.ImageDownloadWorker
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@ -157,7 +158,13 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Products.sync_product_images(product, images)
|
image_results = Products.sync_product_images(product, images)
|
||||||
|
|
||||||
|
# Enqueue downloads for images without image_id
|
||||||
|
Enum.each(image_results, fn
|
||||||
|
{:ok, %{image_id: nil, id: id}} -> ImageDownloadWorker.enqueue(id)
|
||||||
|
_ -> :ok
|
||||||
|
end)
|
||||||
|
|
||||||
# Sync variants
|
# Sync variants
|
||||||
variants =
|
variants =
|
||||||
|
|||||||
@ -185,7 +185,11 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp get_real_products do
|
defp get_real_products do
|
||||||
Products.list_products(visible: true, status: "active", preload: [:images, :variants])
|
Products.list_products(
|
||||||
|
visible: true,
|
||||||
|
status: "active",
|
||||||
|
preload: [images: :image, variants: []]
|
||||||
|
)
|
||||||
|> Enum.map(&product_to_map/1)
|
|> Enum.map(&product_to_map/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -228,16 +232,22 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
in_stock = Enum.any?(available_variants)
|
in_stock = Enum.any?(available_variants)
|
||||||
on_sale = Enum.any?(product.variants, &SimpleshopTheme.Products.ProductVariant.on_sale?/1)
|
on_sale = Enum.any?(product.variants, &SimpleshopTheme.Products.ProductVariant.on_sale?/1)
|
||||||
|
|
||||||
|
# Use local image if available, fall back to CDN URL
|
||||||
|
{image_url, image_id, source_width} = image_attrs(first_image)
|
||||||
|
{hover_image_url, hover_image_id, hover_source_width} = image_attrs(second_image)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
id: product.slug,
|
id: product.slug,
|
||||||
name: product.title,
|
name: product.title,
|
||||||
description: product.description,
|
description: product.description,
|
||||||
price: if(cheapest_variant, do: cheapest_variant.price, else: 0),
|
price: if(cheapest_variant, do: cheapest_variant.price, else: 0),
|
||||||
compare_at_price: if(cheapest_variant, do: cheapest_variant.compare_at_price, else: nil),
|
compare_at_price: if(cheapest_variant, do: cheapest_variant.compare_at_price, else: nil),
|
||||||
image_url: if(first_image, do: first_image.src, else: nil),
|
image_url: image_url,
|
||||||
hover_image_url: if(second_image, do: second_image.src, else: nil),
|
image_id: image_id,
|
||||||
source_width: nil,
|
hover_image_url: hover_image_url,
|
||||||
hover_source_width: nil,
|
hover_image_id: hover_image_id,
|
||||||
|
source_width: source_width,
|
||||||
|
hover_source_width: hover_source_width,
|
||||||
category: product.category,
|
category: product.category,
|
||||||
slug: product.slug,
|
slug: product.slug,
|
||||||
in_stock: in_stock,
|
in_stock: in_stock,
|
||||||
@ -246,6 +256,20 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Extract image attributes, preferring local Media.Image when available
|
||||||
|
defp image_attrs(nil), do: {nil, nil, nil}
|
||||||
|
|
||||||
|
defp image_attrs(%{image_id: image_id, image: %{source_width: source_width}})
|
||||||
|
when not is_nil(image_id) do
|
||||||
|
# Local image available - use image_id for responsive <picture> element
|
||||||
|
{nil, image_id, source_width}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp image_attrs(%{src: src}) do
|
||||||
|
# Fall back to CDN URL
|
||||||
|
{src, nil, nil}
|
||||||
|
end
|
||||||
|
|
||||||
# Default source width for mockup variants (max generated size)
|
# Default source width for mockup variants (max generated size)
|
||||||
@mockup_source_width 1200
|
@mockup_source_width 1200
|
||||||
|
|
||||||
|
|||||||
@ -19,16 +19,24 @@
|
|||||||
|
|
||||||
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<.breadcrumb
|
<.breadcrumb
|
||||||
items={[
|
items={
|
||||||
%{label: "Home", page: "home", href: "/"},
|
[
|
||||||
%{
|
%{label: "Home", page: "home", href: "/"}
|
||||||
label: @product.category,
|
] ++
|
||||||
page: "collection",
|
if @product.category do
|
||||||
href:
|
[
|
||||||
"/collections/#{@product.category |> String.downcase() |> String.replace(" ", "-")}"
|
%{
|
||||||
},
|
label: @product.category,
|
||||||
%{label: @product.name, current: true}
|
page: "collection",
|
||||||
]}
|
href:
|
||||||
|
"/collections/#{@product.category |> String.downcase() |> String.replace(" ", "-")}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end ++
|
||||||
|
[%{label: @product.name, current: true}]
|
||||||
|
}
|
||||||
mode={@mode}
|
mode={@mode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1404,7 +1404,8 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
|
|
||||||
src =
|
src =
|
||||||
if image_id do
|
if image_id do
|
||||||
"/images/#{image_id}/variant"
|
# Trailing slash so build_srcset produces /images/{id}/variant/800.webp
|
||||||
|
"/images/#{image_id}/variant/"
|
||||||
else
|
else
|
||||||
image_url
|
image_url
|
||||||
end
|
end
|
||||||
@ -4012,10 +4013,15 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
available = Optimizer.applicable_widths(assigns.source_width)
|
available = Optimizer.applicable_widths(assigns.source_width)
|
||||||
default_width = Enum.max(available)
|
default_width = Enum.max(available)
|
||||||
|
|
||||||
|
# Database images end with / (e.g., /images/{id}/variant/)
|
||||||
|
# Mockups use - separator (e.g., /mockups/product-1)
|
||||||
|
separator = if String.ends_with?(assigns.src, "/"), do: "", else: "-"
|
||||||
|
|
||||||
assigns =
|
assigns =
|
||||||
assigns
|
assigns
|
||||||
|> assign(:available_widths, available)
|
|> assign(:available_widths, available)
|
||||||
|> assign(:default_width, default_width)
|
|> assign(:default_width, default_width)
|
||||||
|
|> assign(:separator, separator)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<picture>
|
<picture>
|
||||||
@ -4030,7 +4036,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
sizes={@sizes}
|
sizes={@sizes}
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
src={"#{@src}-#{@default_width}.jpg"}
|
src={"#{@src}#{@separator}#{@default_width}.jpg"}
|
||||||
srcset={build_srcset(@src, @available_widths, "jpg")}
|
srcset={build_srcset(@src, @available_widths, "jpg")}
|
||||||
sizes={@sizes}
|
sizes={@sizes}
|
||||||
alt={@alt}
|
alt={@alt}
|
||||||
@ -4046,9 +4052,13 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp build_srcset(base, widths, format) do
|
defp build_srcset(base, widths, format) do
|
||||||
|
# Database images end with / (e.g., /images/{id}/variant/)
|
||||||
|
# Mockups use - separator (e.g., /mockups/product-1)
|
||||||
|
separator = if String.ends_with?(base, "/"), do: "", else: "-"
|
||||||
|
|
||||||
widths
|
widths
|
||||||
|> Enum.sort()
|
|> Enum.sort()
|
||||||
|> Enum.map(&"#{base}-#{&1}.#{format} #{&1}w")
|
|> Enum.map(&"#{base}#{separator}#{&1}.#{format} #{&1}w")
|
||||||
|> Enum.join(", ")
|
|> Enum.join(", ")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -34,13 +34,11 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|
|||||||
|> Enum.reject(fn p -> p.id == product.id end)
|
|> Enum.reject(fn p -> p.id == product.id end)
|
||||||
|> Enum.take(4)
|
|> Enum.take(4)
|
||||||
|
|
||||||
# Build gallery images (filter out nils)
|
# Build gallery images from local image_id or external URL
|
||||||
gallery_images =
|
gallery_images =
|
||||||
[
|
[
|
||||||
product.image_url,
|
image_src(product[:image_id], product[:image_url]),
|
||||||
product.hover_image_url,
|
image_src(product[:hover_image_id], product[:hover_image_url])
|
||||||
product.image_url,
|
|
||||||
product.hover_image_url
|
|
||||||
]
|
]
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
@ -70,6 +68,14 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|
|||||||
List.first(products)
|
List.first(products)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Build image source URL - prefer local image_id, fall back to external URL
|
||||||
|
defp image_src(image_id, _url) when is_binary(image_id) do
|
||||||
|
"/images/#{image_id}/variant/1200.webp"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp image_src(_, url) when is_binary(url), do: url
|
||||||
|
defp image_src(_, _), do: nil
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
defmodule SimpleshopTheme.Repo.Migrations.AddImageIdToProductImages do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:product_images) do
|
||||||
|
add :image_id, references(:images, type: :binary_id, on_delete: :nilify_all)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:product_images, [:image_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user