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:
@@ -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
|
||||
|
||||
154
test/berrypod_web/live/admin/media_test.exs
Normal file
154
test/berrypod_web/live/admin/media_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user