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:
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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: ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
421
lib/berrypod_web/live/admin/media.ex
Normal file
421
lib/berrypod_web/live/admin/media.ex
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
87
lib/mix/tasks/berrypod/backfill_alt_text.ex
Normal file
87
lib/mix/tasks/berrypod/backfill_alt_text.ex
Normal 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
|
||||
Reference in New Issue
Block a user