berrypod/docs/plans/media-library.md
jamey 847b5f3e5e 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>
2026-02-27 22:20:51 +00:00

12 KiB

Media library plan

Status: Complete

Context

Images currently live in a images table as WebP BLOBs with an optimisation pipeline (AVIF/WebP/JPEG responsive variants via Oban). The system works well for its original use cases — product images (synced from providers), logo/header/icon (uploaded in the theme editor) — but has gaps that become obvious now the page builder lets admins add image blocks to any page.

Current gaps:

  1. No admin media UI — no way to browse, search, or manage uploaded images. You upload in context (theme editor, provider sync) and there's no central view.
  2. No alt text on the images table — only product_images.alt has it. Logo, header, icon, and any future general-purpose images have no alt text metadata.
  3. Page builder image references are raw URL strings — the image_src setting in content blocks is a free-text URL, not linked to the images table. No responsive variants, no alt text inheritance, no orphan tracking.
  4. Orphan detection is ad-hocProducts.cleanup_orphaned_images/1 handles product image cleanup after sync, but there's no general "which images are in use?" check.
  5. Theme settings store image IDs in JSONlogo_image_id, header_image_id etc. are plain binary fields in a JSON blob with no FK constraint. Deleting the image doesn't update the setting.
  6. No image type for general uploadsimage_type is constrained to logo, header, product, icon. The page builder and future features need a general-purpose upload type.

Design principles

  • Every image earns its place — if nothing references an image, it's an orphan and should be flagged for cleanup. Storage is finite (SQLite BLOBs).
  • Alt text is mandatory for new uploads — accessibility by default. Existing images get alt text backfilled where possible (product title, "Shop logo", etc.).
  • One image, many uses — an image uploaded for a page block should be reusable elsewhere. The media library is the single source of truth.
  • Upload in context, manage centrally — you can still upload directly from the page editor or theme editor, but every image lands in the media library. The admin UI lets you browse, edit metadata, and see where each image is used.
  • Don't break what works — product images from provider sync keep their existing flow. The media library wraps around it, not replaces it.

Data model changes

Migration: add metadata to images

alter table(:images) do
  add :alt, :string          # alt text for accessibility
  add :caption, :string      # optional caption/description
  add :tags, :string         # comma-separated tags for filtering (e.g. "hero, homepage")
end

Keep it simple. No separate tags table — comma-separated is fine for a single-tenant shop with maybe 50-200 images. Tags are for admin filtering, not public-facing.

Migration: expand image_type constraint

Add "media" as a valid image_type value. This is the general-purpose type for page builder uploads and any future use.

# In Image changeset, update the validate_inclusion
validate_inclusion(:image_type, ~w(logo header product icon media))

No separate references table

Usage tracking is derived at query time, not stored separately. With ~14 pages and a handful of theme settings, scanning for references is trivial. A denormalised references table would add complexity and sync headaches for zero performance benefit at this scale.

Reference sources to scan:

  • product_images where image_id = ?
  • theme_settings where logo_image_id / header_image_id / icon_image_id match
  • favicon_variants where source_image_id = ?
  • All pages' block settings where image_id = ? (14 pages, ~5-10 blocks each)

Media context additions

Media.list_images/1

@doc "List images with optional filters."
def list_images(opts \\ []) do
  # opts: type, tag, search (filename/alt), orphaned_only, order_by
end

Media.update_image_metadata/2

@doc "Update alt text, caption, and tags on an existing image."
def update_image_metadata(image, attrs) do
  image
  |> Image.metadata_changeset(attrs)
  |> Repo.update()
end

Media.find_usages/1

@doc """
Returns a list of places an image is referenced.

Each usage is a map: %{type: :product | :theme | :favicon | :page, label: "...", url: "..."}
"""
def find_usages(image_id) do
  product_usages = find_product_usages(image_id)
  theme_usages = find_theme_usages(image_id)
  favicon_usages = find_favicon_usages(image_id)
  page_usages = find_page_usages(image_id)

  product_usages ++ theme_usages ++ favicon_usages ++ page_usages
end

Media.list_orphaned_images/0

Returns images with zero usages. Uses find_usages/1 under the hood. Could be an Oban cron job that flags orphans periodically, but starting with an on-demand check in the admin UI is simpler.

Media.delete_with_cleanup/1

Deletes the image, its disk variants, and nullifies any dangling references (theme settings, page block settings). Product images already handle this via on_delete: :nilify_all.

Page builder integration

Image picker field type

Add a new SettingsField type: :image. Instead of a free-text URL input, this renders an image picker that:

  1. Shows the currently selected image (thumbnail + alt text)
  2. "Choose image" button opens a modal with the media library grid
  3. "Upload new" button allows direct upload from the picker
  4. Selected image stores image_id in the block setting (not a URL)
# In BlockTypes
%SettingsField{key: "image_id", label: "Image", type: :image, default: nil}

Renderer changes

When a block has an image_id setting, the renderer loads the image and uses its metadata:

# In render_block for content_body:
image = if settings["image_id"], do: Media.get_image(settings["image_id"])
assigns = assign(assigns, :block_image, image)

# In the template:
<.responsive_image
  src={"/image_cache/#{@block_image.id}"}
  source_width={@block_image.source_width}
  alt={@block_image.alt}
  sizes="(max-width: 800px) 100vw, 800px"
/>

This gives proper responsive variants (AVIF/WebP/JPEG at multiple widths) instead of serving a single URL, plus alt text from the image metadata.

Migration path for existing blocks

Existing content_body and image_text blocks use image_src (a URL string). These continue to work — the renderer checks for image_id first, falls back to image_src. No data migration needed.

New blocks created through the editor use the image picker and store image_id.

Admin UI

/admin/media — media library

Grid view (default):

  • Thumbnail grid of all images, newest first
  • Each card shows: thumbnail, filename, alt text (or "No alt text" warning), image type badge, file size
  • Click a card to open the detail/edit panel

Filters:

  • Type filter: all, media, product, logo/header/icon
  • Tag filter: dropdown of existing tags
  • Search: filename or alt text
  • "Orphaned" toggle: show only images with no references

Detail panel (slide-over or inline expand):

  • Larger preview
  • Editable fields: alt text, caption, tags
  • Read-only: filename, dimensions, file size, upload date, image type
  • "Used in" section: list of references with links (e.g. "Product: Mountain sunrise print", "Page: Home > Hero banner block")
  • Delete button (with confirmation, warns if image is in use)
  • Copy URL button (for external use)

Upload flow

  • Drag-and-drop zone at the top of the media library
  • Accepts JPEG, PNG, WebP, SVG, GIF
  • On upload: prompt for alt text (required), optional tags
  • Calls Media.upload_image/1 with image_type: "media"
  • Image appears in the grid immediately (thumbnail generates async)

Image picker component

Reusable component for the page editor and anywhere else an image is needed:

<.image_picker
  id="hero-image"
  selected_image_id={@block_settings["image_id"]}
  on_select="editor_update_block_settings"
  block_id={@block_id}
  field_key="image_id"
/>

Opens a modal version of the media library grid. Selecting an image fires the callback event. "Upload new" tab in the modal for quick uploads without leaving the editor.

Accessibility

  • Alt text required on upload — the upload form validates that alt text is present before saving. Existing images without alt text show a warning badge in the grid.
  • Alt text backfill — one-time migration/task:
    • Product images: use product_images.alt if set, else product title
    • Logo: "Shop logo" or shop name from settings
    • Header: "Header background"
    • Icon: "Site icon"
  • Alt text audit — the media library grid shows a warning icon on images missing alt text. A filter lets admins find and fix them.
  • Image picker shows alt text — when selecting an image in the page editor, the picker shows the alt text so admins know what they're selecting.

Orphan management

  • Orphan detection is on-demand (not a background job). The admin can filter for orphaned images in the media library.
  • Bulk delete — select multiple orphaned images and delete them in one action.
  • Soft warning before delete — if an image is in use, the delete button shows where it's used and requires explicit confirmation.
  • Automatic cleanupProducts.cleanup_orphaned_images/1 already handles post-sync cleanup. This extends to cover all image types when their reference is removed (e.g. theme setting changed, page block removed).

Stages

Stage 1: Schema + context (~1.5h)

  • Migration: add alt, caption, tags to images; add "media" to type constraint
  • Media.list_images/1 with filters
  • Media.update_image_metadata/2
  • Media.find_usages/1
  • Media.list_orphaned_images/0
  • Media.delete_with_cleanup/1
  • Alt text backfill task (mix berrypod.backfill_alt_text)
  • Tests for all new context functions

Stage 2: Admin media library UI (~2.5h)

  • /admin/media route + LiveView
  • Thumbnail grid with type/tag/search filters
  • Upload with drag-and-drop + alt text prompt
  • Detail panel: edit metadata, view usages, delete
  • Orphan filter + bulk delete
  • Admin nav link
  • Tests

Stage 3: Image picker for page builder (~2h)

  • :image field type in SettingsField
  • ImagePicker component (modal with media grid + upload)
  • Wire into BlockEditorComponents for rendering :image fields
  • Update content_body and image_text block schemas to use :image type
  • Renderer: load image by ID, use responsive variants + alt text
  • Backwards compatibility: image_src fallback for existing blocks
  • Tests

Stage 4: Polish + cleanup (~1h)

  • Theme editor: show image alt text, link to media library
  • Orphan cleanup: extend to theme setting changes and page block removals
  • Missing alt text audit warnings in media library
  • Update PROGRESS.md

Total estimate: ~7h

File inventory

File Purpose
lib/berrypod/media.ex Extended context (list, filter, usages, orphans)
lib/berrypod/media/image.ex New metadata_changeset/2, updated type constraint
lib/berrypod_web/live/admin/media/ New media library LiveView
lib/berrypod_web/components/image_picker.ex Reusable image picker modal
lib/berrypod/pages/settings_field.ex New :image field type
lib/berrypod_web/components/block_editor_components.ex Image picker rendering for :image fields
lib/berrypod_web/page_renderer.ex Image-by-ID loading in block renderer
priv/repo/migrations/*_add_image_metadata.exs Schema migration
test/berrypod/media_test.exs Context tests
test/berrypod_web/live/admin/media_test.exs LiveView tests