defmodule SimpleshopTheme.Media do @moduledoc """ The Media context for managing images and file uploads. """ import Ecto.Query, warn: false alias SimpleshopTheme.Repo alias SimpleshopTheme.Media.Image, as: ImageSchema alias SimpleshopTheme.Images.Optimizer alias SimpleshopTheme.Images.OptimizeWorker @doc """ Uploads an image and stores it in the database. For non-SVG images: - Converts to lossless WebP for storage (26-41% smaller than PNG) - Extracts source dimensions for responsive variant generation - Enqueues background job to generate optimized variants (AVIF, WebP, JPEG at multiple sizes) ## Examples iex> upload_image(%{image_type: "logo", filename: "logo.png", ...}) {:ok, %Image{}} """ def upload_image(attrs) do attrs = prepare_image_attrs(attrs) case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do {:ok, image} -> # Enqueue background job for non-SVG images unless image.is_svg do OptimizeWorker.enqueue(image.id) end {:ok, image} error -> error end end # Prepares image attributes, converting to lossless WebP and extracting dimensions defp prepare_image_attrs(%{data: data, content_type: content_type} = attrs) when is_binary(data) do if is_svg?(content_type, attrs[:filename]) do attrs else case Optimizer.to_optimized_webp(data) do {:ok, webp_data, width, height} -> attrs |> Map.put(:data, webp_data) |> Map.put(:content_type, "image/webp") |> Map.put(:file_size, byte_size(webp_data)) |> Map.put(:source_width, width) |> Map.put(:source_height, height) |> Map.put(:variants_status, "pending") {:error, _reason} -> # If conversion fails, store original image attrs end end end defp prepare_image_attrs(attrs), do: attrs defp is_svg?(content_type, filename) do content_type == "image/svg+xml" or String.ends_with?(filename || "", ".svg") end @doc """ Uploads an image from a LiveView upload entry. This handles consuming the upload and extracting metadata from the entry. ## Examples iex> upload_from_entry(socket, :logo_upload, fn path, entry -> ... end) {:ok, %Image{}} """ def upload_from_entry(path, entry, image_type) do file_binary = File.read!(path) upload_image(%{ image_type: image_type, filename: entry.client_name, content_type: entry.client_type, file_size: entry.client_size, data: file_binary }) end @doc """ Gets a single image by ID. ## Examples iex> get_image(id) %Image{} iex> get_image("nonexistent") nil """ def get_image(id) do Repo.get(ImageSchema, id) end @doc """ Gets the current logo image. ## Examples iex> get_logo() %Image{} """ def get_logo do Repo.one( from i in ImageSchema, where: i.image_type == "logo", order_by: [desc: i.inserted_at], limit: 1 ) end @doc """ Gets the current header image. ## Examples iex> get_header() %Image{} """ def get_header do Repo.one( from i in ImageSchema, where: i.image_type == "header", order_by: [desc: i.inserted_at], limit: 1 ) end @doc """ Deletes an image. ## Examples iex> delete_image(image) {:ok, %Image{}} """ def delete_image(%ImageSchema{} = image) do Repo.delete(image) end @doc """ Lists all images of a specific type. ## Examples iex> list_images_by_type("logo") [%Image{}, ...] """ def list_images_by_type(type) do Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at]) end @doc """ Gets the path to a thumbnail on disk. ## Examples iex> get_thumbnail_path("abc123-def456") "priv/static/image_cache/abc123-def456-thumb.jpg" """ def get_thumbnail_path(image_id) do Path.join(Optimizer.cache_dir(), "#{image_id}-thumb.jpg") end @doc """ Gets the path to a variant on disk. ## Examples iex> get_variant_path("abc123-def456", 800, :webp) "priv/static/image_cache/abc123-def456-800.webp" """ def get_variant_path(image_id, width, format) do ext = Atom.to_string(format) Path.join(Optimizer.cache_dir(), "#{image_id}-#{width}.#{ext}") end end