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: ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user