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

@@ -189,4 +189,167 @@ defmodule Berrypod.MediaTest do
assert Media.get_favicon_variants() == nil
end
end
# ── Media library functions ──────────────────────────────────────
describe "upload_image/1 with media type" do
test "accepts media image type" do
attrs = Map.put(@valid_attrs, :image_type, "media")
assert {:ok, image} = Media.upload_image(attrs)
assert image.image_type == "media"
end
test "stores alt, caption, and tags on upload" do
attrs =
@valid_attrs
|> Map.put(:alt, "A scenic landscape")
|> Map.put(:caption, "Taken at sunset")
|> Map.put(:tags, "nature, landscape")
assert {:ok, image} = Media.upload_image(attrs)
assert image.alt == "A scenic landscape"
assert image.caption == "Taken at sunset"
assert image.tags == "nature, landscape"
end
end
describe "list_images/1" do
test "lists all images without BLOBs" do
{:ok, _} = Media.upload_image(@valid_attrs)
{:ok, _} = Media.upload_image(Map.put(@valid_attrs, :image_type, "header"))
images = Media.list_images()
assert length(images) == 2
assert Enum.all?(images, &is_nil(&1.data))
end
test "filters by type" do
{:ok, _} = Media.upload_image(@valid_attrs)
{:ok, _} = Media.upload_image(Map.put(@valid_attrs, :image_type, "header"))
logos = Media.list_images(type: "logo")
assert length(logos) == 1
assert hd(logos).image_type == "logo"
end
test "filters by search on filename" do
{:ok, _} = Media.upload_image(@valid_attrs)
{:ok, _} = Media.upload_image(Map.put(@valid_attrs, :filename, "banner.png"))
results = Media.list_images(search: "banner")
assert length(results) == 1
assert hd(results).filename == "banner.png"
end
test "filters by search on alt text" do
{:ok, _} = Media.upload_image(Map.merge(@valid_attrs, %{alt: "Mountain sunrise"}))
{:ok, _} = Media.upload_image(Map.merge(@valid_attrs, %{alt: "Beach sunset"}))
results = Media.list_images(search: "Mountain")
assert length(results) == 1
assert hd(results).alt == "Mountain sunrise"
end
test "filters by tag" do
{:ok, _} = Media.upload_image(Map.merge(@valid_attrs, %{tags: "hero, homepage"}))
{:ok, _} = Media.upload_image(Map.merge(@valid_attrs, %{tags: "product, gallery"}))
results = Media.list_images(tag: "hero")
assert length(results) == 1
assert hd(results).tags == "hero, homepage"
end
test "returns empty list with no matches" do
{:ok, _} = Media.upload_image(@valid_attrs)
assert Media.list_images(type: "product") == []
end
end
describe "update_image_metadata/2" do
test "updates alt, caption, and tags" do
{:ok, image} = Media.upload_image(@valid_attrs)
assert {:ok, updated} =
Media.update_image_metadata(image, %{
alt: "New alt text",
caption: "A caption",
tags: "tag1, tag2"
})
assert updated.alt == "New alt text"
assert updated.caption == "A caption"
assert updated.tags == "tag1, tag2"
end
test "does not affect image data fields" do
{:ok, image} = Media.upload_image(@valid_attrs)
original_type = image.image_type
{:ok, updated} = Media.update_image_metadata(image, %{alt: "Updated"})
assert updated.image_type == original_type
end
end
describe "find_usages/1" do
test "returns empty list for unreferenced image" do
{:ok, image} = Media.upload_image(@valid_attrs)
assert Media.find_usages(image.id) == []
end
test "finds theme setting usages" do
{:ok, image} = Media.upload_image(@valid_attrs)
Berrypod.Settings.update_theme_settings(%{logo_image_id: image.id})
usages = Media.find_usages(image.id)
assert [%{type: :theme, label: "Logo"}] = usages
end
test "finds favicon variant usage" do
{:ok, image} = Media.upload_image(@valid_attrs)
Media.store_favicon_variants(%{
source_image_id: image.id,
png_32: <<1>>,
png_180: <<2>>,
png_192: <<3>>,
png_512: <<4>>
})
usages = Media.find_usages(image.id)
assert [%{type: :favicon, label: "Favicon source"}] = usages
end
end
describe "used_image_ids/0" do
test "collects IDs from theme settings" do
{:ok, image} = Media.upload_image(@valid_attrs)
Berrypod.Settings.update_theme_settings(%{logo_image_id: image.id})
ids = Media.used_image_ids()
assert MapSet.member?(ids, image.id)
end
test "returns empty set when nothing is referenced" do
{:ok, _} = Media.upload_image(@valid_attrs)
ids = Media.used_image_ids()
assert MapSet.size(ids) == 0
end
end
describe "delete_with_cleanup/1" do
test "deletes an unreferenced image" do
{:ok, image} = Media.upload_image(@valid_attrs)
assert {:ok, _} = Media.delete_with_cleanup(image)
assert Media.get_image(image.id) == nil
end
test "refuses to delete an image that is in use" do
{:ok, image} = Media.upload_image(@valid_attrs)
Berrypod.Settings.update_theme_settings(%{logo_image_id: image.id})
assert {:error, :in_use, usages} = Media.delete_with_cleanup(image)
assert length(usages) == 1
assert Media.get_image(image.id) != nil
end
end
end

View File

@@ -0,0 +1,154 @@
defmodule BerrypodWeb.Admin.MediaTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.Media
@svg_attrs %{
image_type: "logo",
filename: "logo.svg",
content_type: "image/svg+xml",
file_size: 512,
data: "<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\"/></svg>"
}
@raster_attrs %{
image_type: "media",
filename: "banner.png",
content_type: "image/png",
file_size: 1024,
data: <<137, 80, 78, 71>>,
alt: "A banner image"
}
setup do
user = user_fixture()
%{user: user}
end
describe "media library page" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "renders media library for admin", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/media")
assert html =~ "Media"
assert html =~ "Upload image"
end
test "shows images in grid", %{conn: conn} do
{:ok, _} = Media.upload_image(@svg_attrs)
{:ok, _} = Media.upload_image(@raster_attrs)
{:ok, _view, html} = live(conn, ~p"/admin/media")
assert html =~ "logo.svg"
assert html =~ "banner.png"
end
test "filters by type", %{conn: conn} do
{:ok, _} = Media.upload_image(@svg_attrs)
{:ok, _} = Media.upload_image(@raster_attrs)
{:ok, view, _html} = live(conn, ~p"/admin/media")
html = render_change(view, "filter_type", %{"type" => "media"})
assert html =~ "banner.png"
refute html =~ "logo.svg"
end
test "filters by search", %{conn: conn} do
{:ok, _} = Media.upload_image(@svg_attrs)
{:ok, _} = Media.upload_image(@raster_attrs)
{:ok, view, _html} = live(conn, ~p"/admin/media")
html = render_keyup(view, "filter_search", %{"value" => "banner"})
assert html =~ "banner.png"
refute html =~ "logo.svg"
end
test "selects image and shows detail panel", %{conn: conn} do
{:ok, image} = Media.upload_image(@raster_attrs)
{:ok, view, _html} = live(conn, ~p"/admin/media")
html = render_click(view, "select_image", %{"id" => image.id})
assert html =~ "Image details"
assert html =~ "banner.png"
assert html =~ "Alt text"
end
test "updates metadata", %{conn: conn} do
{:ok, image} = Media.upload_image(@raster_attrs)
{:ok, view, _html} = live(conn, ~p"/admin/media")
render_click(view, "select_image", %{"id" => image.id})
render_submit(view, "update_metadata", %{
"metadata" => %{"alt" => "Updated alt", "caption" => "New caption", "tags" => "hero"}
})
updated = Media.get_image(image.id)
assert updated.alt == "Updated alt"
assert updated.caption == "New caption"
assert updated.tags == "hero"
end
test "deletes orphaned image", %{conn: conn} do
{:ok, image} = Media.upload_image(@raster_attrs)
{:ok, view, _html} = live(conn, ~p"/admin/media")
render_click(view, "select_image", %{"id" => image.id})
render_click(view, "confirm_delete")
html = render_click(view, "delete_image")
assert html =~ "Image deleted"
assert Media.get_image(image.id) == nil
end
test "refuses to delete in-use image", %{conn: conn} do
{:ok, image} = Media.upload_image(@svg_attrs)
Berrypod.Settings.update_theme_settings(%{logo_image_id: image.id})
{:ok, view, _html} = live(conn, ~p"/admin/media")
render_click(view, "select_image", %{"id" => image.id})
render_click(view, "confirm_delete")
html = render_click(view, "delete_image")
assert html =~ "Cannot delete"
assert Media.get_image(image.id) != nil
end
test "orphan filter shows only unreferenced images", %{conn: conn} do
{:ok, logo} = Media.upload_image(@svg_attrs)
{:ok, _orphan} = Media.upload_image(@raster_attrs)
Berrypod.Settings.update_theme_settings(%{logo_image_id: logo.id})
{:ok, view, _html} = live(conn, ~p"/admin/media")
html = render_click(view, "toggle_orphans")
assert html =~ "banner.png"
refute html =~ "logo.svg"
end
test "shows usage info for referenced image", %{conn: conn} do
{:ok, image} = Media.upload_image(@svg_attrs)
Berrypod.Settings.update_theme_settings(%{logo_image_id: image.id})
{:ok, view, _html} = live(conn, ~p"/admin/media")
html = render_click(view, "select_image", %{"id" => image.id})
assert html =~ "Used in"
assert html =~ "Logo"
end
test "shows no-alt-text warning for images without alt", %{conn: conn} do
{:ok, _} = Media.upload_image(Map.delete(@raster_attrs, :alt))
{:ok, _view, html} = live(conn, ~p"/admin/media")
assert html =~ "No alt text"
end
end
end