Stage 1 of custom CMS pages. Adds type/published/meta/nav fields to pages schema, splits changeset into system vs custom (with slug format validation and reserved path exclusion), adds create/update/delete functions with auto-redirect on slug change, and warms custom pages in ETS cache. 62 pages tests, 1426 total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
409 lines
10 KiB
Elixir
409 lines
10 KiB
Elixir
defmodule Berrypod.Media do
|
|
@moduledoc """
|
|
The Media context for managing images and file uploads.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
alias Berrypod.Repo
|
|
alias Berrypod.Media.Image, as: ImageSchema
|
|
alias Berrypod.Media.FaviconVariant
|
|
alias Berrypod.Images.Optimizer
|
|
alias Berrypod.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, extra_attrs \\ %{}) do
|
|
file_binary = File.read!(path)
|
|
|
|
base = %{
|
|
image_type: image_type,
|
|
filename: entry.client_name,
|
|
content_type: entry.client_type,
|
|
file_size: entry.client_size,
|
|
data: file_binary
|
|
}
|
|
|
|
upload_image(Map.merge(base, extra_attrs))
|
|
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 current icon image (used as favicon source when not using the logo).
|
|
"""
|
|
def get_icon do
|
|
Repo.one(
|
|
from i in ImageSchema,
|
|
where: i.image_type == "icon",
|
|
order_by: [desc: i.inserted_at],
|
|
limit: 1
|
|
)
|
|
end
|
|
|
|
# ── Media library functions ──────────────────────────────────────
|
|
|
|
@doc """
|
|
Lists images with optional filters. Excludes BLOB data from the query
|
|
for performance — returned images have `data: nil`.
|
|
|
|
## Options
|
|
|
|
* `:type` — filter by image_type (e.g. "media", "product")
|
|
* `:search` — search filename and alt text (case-insensitive)
|
|
* `:tag` — filter by tag substring in comma-separated tags field
|
|
|
|
"""
|
|
def list_images(opts \\ []) do
|
|
query =
|
|
from(i in ImageSchema,
|
|
select: %{i | data: nil},
|
|
order_by: [desc: i.inserted_at]
|
|
)
|
|
|
|
query
|
|
|> filter_by_type(opts[:type])
|
|
|> filter_by_search(opts[:search])
|
|
|> filter_by_tag(opts[:tag])
|
|
|> Repo.all()
|
|
end
|
|
|
|
defp filter_by_type(query, nil), do: query
|
|
defp filter_by_type(query, ""), do: query
|
|
defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type)
|
|
|
|
defp filter_by_search(query, nil), do: query
|
|
defp filter_by_search(query, ""), do: query
|
|
|
|
defp filter_by_search(query, term) do
|
|
pattern = "%#{term}%"
|
|
where(query, [i], like(i.filename, ^pattern) or like(i.alt, ^pattern))
|
|
end
|
|
|
|
defp filter_by_tag(query, nil), do: query
|
|
defp filter_by_tag(query, ""), do: query
|
|
|
|
defp filter_by_tag(query, tag) do
|
|
pattern = "%#{tag}%"
|
|
where(query, [i], like(i.tags, ^pattern))
|
|
end
|
|
|
|
@doc "Updates alt text, caption, and tags on an existing image."
|
|
def update_image_metadata(%ImageSchema{} = image, attrs) do
|
|
image
|
|
|> ImageSchema.metadata_changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Returns a list of places an image is referenced.
|
|
|
|
Each usage is a map: `%{type: atom, label: String.t()}`.
|
|
Scans product_images, theme settings, favicon variants, and page blocks.
|
|
"""
|
|
def find_usages(image_id) when is_binary(image_id) do
|
|
find_product_usages(image_id) ++
|
|
find_theme_usages(image_id) ++
|
|
find_favicon_usages(image_id) ++
|
|
find_page_usages(image_id)
|
|
end
|
|
|
|
@doc """
|
|
Returns a MapSet of all image IDs that are referenced somewhere.
|
|
Used for orphan detection without per-image queries.
|
|
"""
|
|
def used_image_ids do
|
|
ids = MapSet.new()
|
|
|
|
# Product images
|
|
product_ids =
|
|
from(pi in Berrypod.Products.ProductImage,
|
|
where: not is_nil(pi.image_id),
|
|
select: pi.image_id
|
|
)
|
|
|> Repo.all()
|
|
|
|
ids = Enum.reduce(product_ids, ids, &MapSet.put(&2, &1))
|
|
|
|
# Theme settings
|
|
theme = Berrypod.Settings.get_theme_settings()
|
|
|
|
ids =
|
|
[:logo_image_id, :header_image_id, :icon_image_id]
|
|
|> Enum.reduce(ids, fn field, acc ->
|
|
case Map.get(theme, field) do
|
|
nil -> acc
|
|
id -> MapSet.put(acc, id)
|
|
end
|
|
end)
|
|
|
|
# Favicon variants
|
|
ids =
|
|
case get_favicon_variants() do
|
|
%{source_image_id: id} when not is_nil(id) -> MapSet.put(ids, id)
|
|
_ -> ids
|
|
end
|
|
|
|
# Page block settings
|
|
page_ids = scan_pages_for_image_ids()
|
|
Enum.reduce(page_ids, ids, &MapSet.put(&2, &1))
|
|
end
|
|
|
|
@doc """
|
|
Deletes an image and its disk variants.
|
|
|
|
Returns `{:error, :in_use, usages}` if the image is still referenced.
|
|
"""
|
|
def delete_with_cleanup(%ImageSchema{} = image) do
|
|
usages = find_usages(image.id)
|
|
|
|
if usages != [] do
|
|
{:error, :in_use, usages}
|
|
else
|
|
cleanup_disk_variants(image.id)
|
|
Repo.delete(image)
|
|
end
|
|
end
|
|
|
|
# ── Usage scanning helpers ─────────────────────────────────────
|
|
|
|
defp find_product_usages(image_id) do
|
|
from(pi in Berrypod.Products.ProductImage,
|
|
join: p in Berrypod.Products.Product,
|
|
on: p.id == pi.product_id,
|
|
where: pi.image_id == ^image_id,
|
|
select: p.title
|
|
)
|
|
|> Repo.all()
|
|
|> Enum.map(&%{type: :product, label: &1})
|
|
end
|
|
|
|
defp find_theme_usages(image_id) do
|
|
theme = Berrypod.Settings.get_theme_settings()
|
|
|
|
[
|
|
{:logo_image_id, "Logo"},
|
|
{:header_image_id, "Header background"},
|
|
{:icon_image_id, "Site icon"}
|
|
]
|
|
|> Enum.filter(fn {field, _} -> Map.get(theme, field) == image_id end)
|
|
|> Enum.map(fn {_, label} -> %{type: :theme, label: label} end)
|
|
end
|
|
|
|
defp find_favicon_usages(image_id) do
|
|
case get_favicon_variants() do
|
|
%{source_image_id: ^image_id} -> [%{type: :favicon, label: "Favicon source"}]
|
|
_ -> []
|
|
end
|
|
end
|
|
|
|
defp find_page_usages(image_id) do
|
|
Berrypod.Pages.list_all_pages()
|
|
|> Enum.flat_map(fn page ->
|
|
page.blocks
|
|
|> Enum.filter(fn block ->
|
|
settings = block["settings"] || %{}
|
|
settings["image_id"] == image_id
|
|
end)
|
|
|> Enum.map(fn block ->
|
|
%{type: :page, label: "#{page.title} — #{block["type"] || "block"}"}
|
|
end)
|
|
end)
|
|
end
|
|
|
|
defp scan_pages_for_image_ids do
|
|
Berrypod.Pages.list_all_pages()
|
|
|> Enum.flat_map(fn page ->
|
|
Enum.flat_map(page.blocks, fn block ->
|
|
case get_in(block, ["settings", "image_id"]) do
|
|
nil -> []
|
|
id -> [id]
|
|
end
|
|
end)
|
|
end)
|
|
end
|
|
|
|
defp cleanup_disk_variants(image_id) do
|
|
cache_dir = Optimizer.cache_dir()
|
|
|
|
if File.exists?(cache_dir) do
|
|
# Delete all files matching this image ID
|
|
cache_dir
|
|
|> File.ls!()
|
|
|> Enum.filter(&String.starts_with?(&1, image_id))
|
|
|> Enum.each(&File.rm(Path.join(cache_dir, &1)))
|
|
end
|
|
end
|
|
|
|
# ── Favicon functions ──────────────────────────────────────────
|
|
|
|
@doc """
|
|
Gets the current favicon variants (single row).
|
|
"""
|
|
def get_favicon_variants do
|
|
Repo.one(
|
|
from fv in FaviconVariant,
|
|
order_by: [desc: fv.generated_at],
|
|
limit: 1
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Stores favicon variants, replacing any existing set.
|
|
"""
|
|
def store_favicon_variants(attrs) do
|
|
Repo.delete_all(FaviconVariant)
|
|
|
|
%FaviconVariant{}
|
|
|> FaviconVariant.changeset(
|
|
Map.put(attrs, :generated_at, DateTime.utc_now() |> DateTime.truncate(:second))
|
|
)
|
|
|> Repo.insert()
|
|
end
|
|
end
|