add admin media library with image management and block picker integration

- Schema: alt, caption, tags fields on images table with metadata changeset
- Context: list_images with filters, find_usages, used_image_ids, delete_with_cleanup
- Admin UI: /admin/media with grid view, upload, filters, detail panel, metadata editing
- Block editor: :image field type for image_text and content_body blocks
- Page renderer: image_id resolution with legacy URL fallback
- Mobile: bottom sheet detail panel with slide-up animation
- CSS: uses correct --t-* admin theme tokens, admin-badge colour variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-27 22:20:51 +00:00
parent a039c8d53c
commit 847b5f3e5e
15 changed files with 1828 additions and 17 deletions

View File

@@ -82,16 +82,18 @@ defmodule Berrypod.Media do
{:ok, %Image{}}
"""
def upload_from_entry(path, entry, image_type) do
def upload_from_entry(path, entry, image_type, extra_attrs \\ %{}) do
file_binary = File.read!(path)
upload_image(%{
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 """
@@ -184,6 +186,202 @@ defmodule Berrypod.Media do
)
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_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_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).
"""

View File

@@ -16,6 +16,9 @@ defmodule Berrypod.Media.Image do
field :source_width, :integer
field :source_height, :integer
field :variants_status, :string, default: "pending"
field :alt, :string
field :caption, :string
field :tags, :string
timestamps(type: :utc_datetime)
end
@@ -35,14 +38,23 @@ defmodule Berrypod.Media.Image do
:svg_content,
:source_width,
:source_height,
:variants_status
:variants_status,
:alt,
:caption,
:tags
])
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|> validate_inclusion(:image_type, ~w(logo header product icon))
|> validate_inclusion(:image_type, ~w(logo header product icon media))
|> validate_number(:file_size, less_than: @max_file_size)
|> detect_svg()
end
@doc "Changeset for editing metadata only (alt, caption, tags)."
def metadata_changeset(image, attrs) do
image
|> cast(attrs, [:alt, :caption, :tags])
end
defp detect_svg(changeset) do
content_type = get_change(changeset, :content_type)

View File

@@ -92,7 +92,8 @@ defmodule Berrypod.Pages.BlockTypes do
settings_schema: [
%SettingsField{key: "title", label: "Title", type: :text, default: ""},
%SettingsField{key: "description", label: "Description", type: :textarea, default: ""},
%SettingsField{key: "image_url", label: "Image URL", type: :text, default: ""},
%SettingsField{key: "image_id", label: "Image", type: :image, default: nil},
%SettingsField{key: "image_url", label: "Image URL (legacy)", type: :text, default: ""},
%SettingsField{key: "link_text", label: "Link text", type: :text, default: ""},
%SettingsField{key: "link_href", label: "Link URL", type: :text, default: ""}
]
@@ -236,8 +237,14 @@ defmodule Berrypod.Pages.BlockTypes do
allowed_on: :all,
settings_schema: [
%SettingsField{key: "content", label: "Content", type: :textarea, default: ""},
%SettingsField{key: "image_src", label: "Image", type: :text, default: ""},
%SettingsField{key: "image_alt", label: "Image alt text", type: :text, default: ""}
%SettingsField{key: "image_id", label: "Image", type: :image, default: nil},
%SettingsField{key: "image_src", label: "Image URL (legacy)", type: :text, default: ""},
%SettingsField{
key: "image_alt",
label: "Image alt text (legacy)",
type: :text,
default: ""
}
]
},

View File

@@ -206,6 +206,53 @@ defmodule BerrypodWeb.BlockEditorComponents do
"""
end
def block_field(%{field: %{type: :image}} = assigns) do
image_id = assigns.value
image = if is_binary(image_id) && image_id != "", do: Berrypod.Media.get_image(image_id)
assigns = assign(assigns, :image, image)
~H"""
<div class="admin-fieldset">
<span class="admin-label">{@field.label}</span>
<div class="image-field">
<%= if @image do %>
<div class="image-field-preview">
<%= if @image.is_svg do %>
<div class="image-field-svg">
<.icon name="hero-code-bracket" class="size-6" />
</div>
<% else %>
<img
src={"/image_cache/#{@image.id}-thumb.jpg"}
alt={@image.alt || @image.filename}
class="image-field-thumb"
/>
<% end %>
<div class="image-field-info">
<span class="image-field-filename">{@image.filename}</span>
<span :if={@image.alt} class="image-field-alt">{@image.alt}</span>
</div>
</div>
<% else %>
<div class="image-field-empty">
<.icon name="hero-photo" class="size-6" />
<span>No image selected</span>
</div>
<% end %>
<input
type="text"
name={"block_settings[#{@field.key}]"}
id={"block-#{@block_id}-#{@field.key}"}
value={if(@image, do: @image.id, else: "")}
placeholder="Paste image ID from media library"
class="admin-input admin-input-sm"
phx-debounce="blur"
/>
</div>
</div>
"""
end
def block_field(%{field: %{type: :repeater}} = assigns) do
items = if is_list(assigns.value), do: assigns.value, else: []
item_count = length(items)

View File

@@ -102,6 +102,14 @@
<.icon name="hero-document" class="size-5" /> Pages
</.link>
</li>
<li>
<.link
navigate={~p"/admin/media"}
class={admin_nav_active?(@current_path, "/admin/media")}
>
<.icon name="hero-photo" class="size-5" /> Media
</.link>
</li>
<li>
<.link
href={~p"/admin/theme"}

View File

@@ -0,0 +1,421 @@
defmodule BerrypodWeb.Admin.Media do
use BerrypodWeb, :live_view
alias Berrypod.Media
@impl true
def mount(_params, _session, socket) do
images = Media.list_images()
socket =
socket
|> assign(:page_title, "Media")
|> assign(:filter_type, nil)
|> assign(:filter_search, "")
|> assign(:filter_orphans, false)
|> assign(:selected_image, nil)
|> assign(:selected_usages, [])
|> assign(:edit_form, nil)
|> assign(:upload_alt, "")
|> assign(:confirm_delete, false)
|> stream(:images, images)
|> allow_upload(:media_upload,
accept: ~w(.png .jpg .jpeg .webp .svg .gif),
max_entries: 1,
max_file_size: 5_000_000,
auto_upload: true,
progress: &handle_progress/3
)
{:ok, socket}
end
defp handle_progress(:media_upload, entry, socket) do
if entry.done? do
alt = socket.assigns.upload_alt
consume_uploaded_entries(socket, :media_upload, fn %{path: path}, entry ->
extra = if alt != "", do: %{alt: alt}, else: %{}
Media.upload_from_entry(path, entry, "media", extra)
end)
|> case do
[image | _] ->
# Reload without BLOB to insert into stream
image_without_blob = Media.get_image(image.id) |> Map.put(:data, nil)
{:noreply,
socket
|> stream_insert(:images, image_without_blob, at: 0)
|> assign(:upload_alt, "")
|> put_flash(:info, "Image uploaded")}
_ ->
{:noreply, put_flash(socket, :error, "Upload failed")}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("filter_type", %{"type" => type}, socket) do
type = if type == "", do: nil, else: type
{:noreply, reload_images(assign(socket, :filter_type, type))}
end
def handle_event("filter_search", %{"value" => value}, socket) do
{:noreply, reload_images(assign(socket, :filter_search, value))}
end
def handle_event("toggle_orphans", _params, socket) do
{:noreply, reload_images(assign(socket, :filter_orphans, !socket.assigns.filter_orphans))}
end
def handle_event("select_image", %{"id" => id}, socket) do
image = Media.get_image(id)
if image do
usages = Media.find_usages(id)
form =
to_form(
%{
"alt" => image.alt || "",
"caption" => image.caption || "",
"tags" => image.tags || ""
},
as: :metadata
)
{:noreply,
socket
|> assign(:selected_image, Map.put(image, :data, nil))
|> assign(:selected_usages, usages)
|> assign(:edit_form, form)
|> assign(:confirm_delete, false)}
else
{:noreply, socket}
end
end
def handle_event("deselect_image", _params, socket) do
{:noreply,
assign(socket,
selected_image: nil,
selected_usages: [],
edit_form: nil,
confirm_delete: false
)}
end
def handle_event("update_metadata", %{"metadata" => params}, socket) do
image = socket.assigns.selected_image
case Media.update_image_metadata(image, params) do
{:ok, updated} ->
updated_no_blob = Map.put(updated, :data, nil)
{:noreply,
socket
|> stream_insert(:images, updated_no_blob)
|> assign(:selected_image, updated_no_blob)
|> put_flash(:info, "Metadata updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to update metadata")}
end
end
def handle_event("confirm_delete", _params, socket) do
{:noreply, assign(socket, :confirm_delete, true)}
end
def handle_event("cancel_delete", _params, socket) do
{:noreply, assign(socket, :confirm_delete, false)}
end
def handle_event("delete_image", _params, socket) do
image = socket.assigns.selected_image
case Media.delete_with_cleanup(image) do
{:ok, _} ->
{:noreply,
socket
|> stream_delete(:images, image)
|> assign(:selected_image, nil)
|> assign(:selected_usages, [])
|> assign(:edit_form, nil)
|> assign(:confirm_delete, false)
|> put_flash(:info, "Image deleted")}
{:error, :in_use, _usages} ->
{:noreply, put_flash(socket, :error, "Cannot delete — image is still in use")}
end
end
def handle_event("set_upload_alt", %{"value" => value}, socket) do
{:noreply, assign(socket, :upload_alt, value)}
end
# ── Private helpers ──────────────────────────────────────────────
defp reload_images(socket) do
opts =
[
type: socket.assigns.filter_type,
search: if(socket.assigns.filter_search != "", do: socket.assigns.filter_search),
tag: nil
]
|> Enum.reject(fn {_, v} -> is_nil(v) end)
images = Media.list_images(opts)
images =
if socket.assigns.filter_orphans do
used = Media.used_image_ids()
Enum.reject(images, &MapSet.member?(used, &1.id))
else
images
end
stream(socket, :images, images, reset: true)
end
defp format_file_size(nil), do: ""
defp format_file_size(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_file_size(bytes) when bytes < 1_048_576 do
kb = Float.round(bytes / 1024, 1)
"#{kb} KB"
end
defp format_file_size(bytes) do
mb = Float.round(bytes / 1_048_576, 1)
"#{mb} MB"
end
defp format_dimensions(nil, _), do: ""
defp format_dimensions(_, nil), do: ""
defp format_dimensions(w, h), do: "#{w} × #{h}"
defp type_badge_class("product"), do: "admin-badge admin-badge-sm admin-badge-info"
defp type_badge_class("media"), do: "admin-badge admin-badge-sm admin-badge-accent"
defp type_badge_class("logo"), do: "admin-badge admin-badge-sm admin-badge-warning"
defp type_badge_class("header"), do: "admin-badge admin-badge-sm admin-badge-warning"
defp type_badge_class("icon"), do: "admin-badge admin-badge-sm admin-badge-warning"
defp type_badge_class(_), do: "admin-badge admin-badge-sm admin-badge-neutral"
defp image_thumbnail_url(image) do
cond do
image.is_svg -> nil
image.variants_status == "complete" -> "/image_cache/#{image.id}-thumb.jpg"
true -> nil
end
end
@impl true
def render(assigns) do
~H"""
<.header>
Media
</.header>
<div class="media-layout">
<%!-- upload zone --%>
<div class="media-upload-zone" phx-drop-target={@uploads.media_upload.ref}>
<form phx-change="set_upload_alt" class="media-upload-form">
<div class="media-upload-row">
<label class="admin-btn admin-btn-primary">
<.icon name="hero-arrow-up-tray" class="size-4" /> Upload image
<.live_file_input upload={@uploads.media_upload} class="sr-only" />
</label>
<input
type="text"
name="value"
value={@upload_alt}
placeholder="Alt text (recommended)"
class="admin-input flex-1"
phx-debounce="200"
/>
</div>
</form>
<%= for entry <- @uploads.media_upload.entries do %>
<div class="media-upload-progress">
<span>{entry.client_name}</span>
<progress value={entry.progress} max="100">{entry.progress}%</progress>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.media_upload) do %>
<p class="text-error text-sm">{Phoenix.Naming.humanize(err)}</p>
<% end %>
</div>
<%!-- filter bar --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap items-center">
<form phx-change="filter_type" class="contents">
<select name="type" class="admin-select">
<option value="" selected={is_nil(@filter_type)}>All types</option>
<option value="media" selected={@filter_type == "media"}>Media</option>
<option value="product" selected={@filter_type == "product"}>Product</option>
<option value="logo" selected={@filter_type == "logo"}>Logo</option>
<option value="header" selected={@filter_type == "header"}>Header</option>
<option value="icon" selected={@filter_type == "icon"}>Icon</option>
</select>
</form>
<input
type="search"
placeholder="Search filename or alt text..."
value={@filter_search}
phx-keyup="filter_search"
phx-debounce="300"
class="admin-input flex-1"
/>
<button
phx-click="toggle_orphans"
class={[
"admin-btn admin-btn-sm",
@filter_orphans && "admin-btn-primary",
!@filter_orphans && "admin-btn-ghost"
]}
>
<.icon name="hero-trash" class="size-4" /> Orphans
</button>
</div>
<div class="media-main">
<%!-- image grid --%>
<div id="media-grid" phx-update="stream" class="media-grid">
<div
:for={{dom_id, image} <- @streams.images}
id={dom_id}
phx-click="select_image"
phx-value-id={image.id}
class={[
"media-card",
@selected_image && @selected_image.id == image.id && "media-card-selected"
]}
>
<div class="media-card-thumb">
<%= if image.is_svg do %>
<div class="media-card-svg-placeholder">
<.icon name="hero-code-bracket" class="size-8" />
<span>SVG</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(image) do %>
<img src={thumb} alt={image.alt || image.filename} loading="lazy" />
<% else %>
<div class="media-card-svg-placeholder">
<.icon name="hero-photo" class="size-8" />
</div>
<% end %>
<% end %>
</div>
<div class="media-card-info">
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta">
<span class={type_badge_class(image.image_type)}>{image.image_type}</span>
<span class="text-xs">{format_file_size(image.file_size)}</span>
</div>
<span
:if={!image.alt || image.alt == ""}
class="media-card-no-alt"
title="Missing alt text"
>
<.icon name="hero-exclamation-triangle" class="size-3" /> No alt text
</span>
</div>
</div>
</div>
<%!-- detail panel --%>
<aside :if={@selected_image} class="media-detail">
<div class="media-detail-header">
<h3>Image details</h3>
<button phx-click="deselect_image" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-x-mark" class="size-4" />
</button>
</div>
<div class="media-detail-preview">
<%= if @selected_image.is_svg do %>
<div class="media-detail-svg">
<.icon name="hero-code-bracket" class="size-12" />
<span>SVG image</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(@selected_image) do %>
<img src={thumb} alt={@selected_image.alt || @selected_image.filename} />
<% end %>
<% end %>
</div>
<dl class="media-detail-meta">
<dt>Filename</dt>
<dd>{@selected_image.filename}</dd>
<dt>Type</dt>
<dd>{@selected_image.image_type}</dd>
<dt>Size</dt>
<dd>{format_file_size(@selected_image.file_size)}</dd>
<dt>Dimensions</dt>
<dd>{format_dimensions(@selected_image.source_width, @selected_image.source_height)}</dd>
<dt>Uploaded</dt>
<dd>{Calendar.strftime(@selected_image.inserted_at, "%d %b %Y %H:%M")}</dd>
</dl>
<.form for={@edit_form} phx-submit="update_metadata" class="media-detail-form">
<.input field={@edit_form[:alt]} label="Alt text" placeholder="Describe this image..." />
<.input field={@edit_form[:caption]} label="Caption" placeholder="Optional caption..." />
<.input field={@edit_form[:tags]} label="Tags" placeholder="hero, homepage, banner..." />
<button type="submit" class="admin-btn admin-btn-primary admin-btn-sm">
Save metadata
</button>
</.form>
<%= if @selected_usages != [] do %>
<div class="media-detail-usages">
<h4>Used in</h4>
<ul>
<%= for usage <- @selected_usages do %>
<li>
<span class={type_badge_class(to_string(usage.type))}>{usage.type}</span>
{usage.label}
</li>
<% end %>
</ul>
</div>
<% end %>
<div class="media-detail-actions">
<%= if @confirm_delete do %>
<p class="text-sm text-error">
<%= if @selected_usages != [] do %>
This image is in use. Deleting it may break pages.
<% else %>
Are you sure?
<% end %>
</p>
<div class="flex gap-2">
<button phx-click="delete_image" class="admin-btn admin-btn-sm admin-btn-danger">
Yes, delete
</button>
<button phx-click="cancel_delete" class="admin-btn admin-btn-sm admin-btn-ghost">
Cancel
</button>
</div>
<% else %>
<button
phx-click="confirm_delete"
class="admin-btn admin-btn-sm admin-btn-ghost text-error"
>
<.icon name="hero-trash" class="size-4" /> Delete image
</button>
<% end %>
</div>
</aside>
</div>
</div>
"""
end
end

View File

@@ -249,12 +249,13 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "image_text"}} = assigns) do
settings = assigns.block["settings"] || %{}
image_url = resolve_block_image_url(settings["image_id"], settings["image_url"])
assigns =
assigns
|> assign(:section_title, settings["title"] || "")
|> assign(:section_description, settings["description"] || "")
|> assign(:image_url, settings["image_url"] || "")
|> assign(:image_url, image_url)
|> assign(:link_text, settings["link_text"])
|> assign(:link_href, settings["link_href"])
@@ -560,11 +561,12 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "content_body"}} = assigns) do
settings = assigns.block["settings"] || %{}
content = settings["content"] || ""
{image_src, image_alt} = resolve_content_image(settings)
assigns =
assigns
|> assign(:image_src, settings["image_src"])
|> assign(:image_alt, settings["image_alt"] || "")
|> assign(:image_src, image_src)
|> assign(:image_alt, image_alt)
|> assign(:content, content)
~H"""
@@ -998,6 +1000,42 @@ defmodule BerrypodWeb.PageRenderer do
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"
# Resolves an image_id to a URL, falling back to a legacy URL string
defp resolve_block_image_url(image_id, fallback_url) do
case resolve_image(image_id) do
{url, _alt} -> url
nil -> fallback_url || ""
end
end
# Resolves image_id for content_body blocks, returning {src, alt}
defp resolve_content_image(settings) do
case resolve_image(settings["image_id"]) do
{src, alt} -> {src, alt}
nil -> {settings["image_src"], settings["image_alt"] || ""}
end
end
defp resolve_image(nil), do: nil
defp resolve_image(""), do: nil
defp resolve_image(image_id) do
case Berrypod.Media.get_image(image_id) do
nil ->
nil
image ->
url =
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
"/image_cache/#{image.id}-800.webp"
end
{url, image.alt || image.filename}
end
end
def format_order_status("unfulfilled"), do: "Being prepared"
def format_order_status("submitted"), do: "Sent to printer"
def format_order_status("processing"), do: "In production"

View File

@@ -232,6 +232,7 @@ defmodule BerrypodWeb.Router do
live "/settings/email", Admin.EmailSettings, :index
live "/pages", Admin.Pages.Index, :index
live "/pages/:slug", Admin.Pages.Editor, :edit
live "/media", Admin.Media, :index
live "/redirects", Admin.Redirects, :index
end

View File

@@ -0,0 +1,87 @@
defmodule Mix.Tasks.Berrypod.BackfillAltText do
@shortdoc "Backfill alt text on existing images"
@moduledoc """
One-time task to populate alt text on images that were uploaded before
the alt field existed.
- Product images: copies alt from `product_images.alt`, falls back to product title
- Logo: uses site name from theme settings
- Header: "Header background"
- Icon: "Site icon"
- Skips images that already have alt text set
## Usage
mix berrypod.backfill_alt_text
"""
use Mix.Task
alias Berrypod.Repo
alias Berrypod.Media.Image
alias Berrypod.Products.ProductImage
alias Berrypod.Products.Product
import Ecto.Query
@impl true
def run(_args) do
Mix.Task.run("app.start")
backfill_product_images()
backfill_theme_images()
Mix.shell().info("Alt text backfill complete.")
end
defp backfill_product_images do
# Find product images with linked image records missing alt text
results =
from(pi in ProductImage,
join: i in Image,
on: i.id == pi.image_id,
join: p in Product,
on: p.id == pi.product_id,
where: is_nil(i.alt) and not is_nil(pi.image_id),
select: {i.id, pi.alt, p.title}
)
|> Repo.all()
count =
Enum.reduce(results, 0, fn {image_id, pi_alt, product_title}, acc ->
alt = pi_alt || product_title || "Product image"
from(i in Image, where: i.id == ^image_id)
|> Repo.update_all(set: [alt: alt])
acc + 1
end)
Mix.shell().info(" Updated #{count} product image(s)")
end
defp backfill_theme_images do
theme = Berrypod.Settings.get_theme_settings()
mapping = [
{theme.logo_image_id, theme.site_name || "Shop logo"},
{theme.header_image_id, "Header background"},
{theme.icon_image_id, "Site icon"}
]
count =
Enum.reduce(mapping, 0, fn {image_id, alt}, acc ->
if image_id do
{updated, _} =
from(i in Image, where: i.id == ^image_id and is_nil(i.alt))
|> Repo.update_all(set: [alt: alt])
acc + updated
else
acc
end
end)
Mix.shell().info(" Updated #{count} theme image(s)")
end
end