From 847b5f3e5e553ae0c1334e0d96fac34f557ef723 Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 27 Feb 2026 22:20:51 +0000 Subject: [PATCH] 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 --- PROGRESS.md | 37 +- assets/css/admin/components.css | 374 +++++++++++++++- docs/plans/media-library.md | 265 +++++++++++ lib/berrypod/media.ex | 204 ++++++++- lib/berrypod/media/image.ex | 16 +- lib/berrypod/pages/block_types.ex | 13 +- .../components/block_editor_components.ex | 47 ++ .../components/layouts/admin.html.heex | 8 + lib/berrypod_web/live/admin/media.ex | 421 ++++++++++++++++++ lib/berrypod_web/page_renderer.ex | 44 +- lib/berrypod_web/router.ex | 1 + lib/mix/tasks/berrypod/backfill_alt_text.ex | 87 ++++ .../20260227212335_add_image_metadata.exs | 11 + test/berrypod/media_test.exs | 163 +++++++ test/berrypod_web/live/admin/media_test.exs | 154 +++++++ 15 files changed, 1828 insertions(+), 17 deletions(-) create mode 100644 docs/plans/media-library.md create mode 100644 lib/berrypod_web/live/admin/media.ex create mode 100644 lib/mix/tasks/berrypod/backfill_alt_text.ex create mode 100644 priv/repo/migrations/20260227212335_add_image_metadata.exs create mode 100644 test/berrypod_web/live/admin/media_test.exs diff --git a/PROGRESS.md b/PROGRESS.md index 6069f8d..c16b3ac 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -9,7 +9,7 @@ - Image optimization pipeline (AVIF/WebP/JPEG responsive variants) - Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms) - Mobile-first design with bottom navigation -- 1309 tests passing, 100% PageSpeed score +- 1398 tests passing, 100% PageSpeed score - SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit) - Variant selector with color swatches and size buttons - Session-based cart with real variant data (add/remove/quantity, cross-tab sync) @@ -30,7 +30,7 @@ Ordered by dependency level — admin shell chain first (unblocks most downstream work). -Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) | [analytics-v2.md](docs/plans/analytics-v2.md) | [page-builder.md](docs/plans/page-builder.md) +Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) | [analytics-v2.md](docs/plans/analytics-v2.md) | [page-builder.md](docs/plans/page-builder.md) | [media-library.md](docs/plans/media-library.md) | # | Task | Depends on | Est | Status | |---|------|------------|-----|--------| @@ -137,6 +137,11 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | ~~83~~ | ~~`LegalPages` module — generate accurate privacy, delivery, and terms content from settings + provider + shipping data~~ | — | 2.5h | done | | ~~84~~ | ~~Wire `LegalPages` into `Content` LiveView — replace `PreviewData` calls, add tests~~ | 83 | 45m | done | | 85 | Page editor integration — "Regenerate" button, auto-regenerate on settings change, customised vs auto label | 83, 19 | 1.5h | planned | +| | **Media library** ([plan](docs/plans/media-library.md)) | | | | +| ~~93~~ | ~~Schema + context — alt/caption/tags on images, media type, find_usages, orphan detection~~ | — | 1.5h | done | +| ~~94~~ | ~~Admin media library UI — grid, filters, upload, detail panel, orphan management~~ | 93 | 2.5h | done | +| ~~95~~ | ~~Image picker for page builder — `:image` field type, image_id resolution in renderer~~ | 94 | 2h | done | +| 96 | Polish — theme editor alt text, full modal picker, orphan cleanup on ref removal | 95 | 1h | planned | | | **Platform site** | | | | | 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned | | 74 | Separation of concerns: platform site vs AGPL open source core | 73 | TBD | planned | @@ -457,8 +462,25 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan +### Media Library +**Status:** Complete — Stages 1-3 done, stage 4 (polish) planned + +Admin media library at `/admin/media` with image grid, type/search/orphan filters, upload with alt text, detail panel (metadata editing, usage tracking, delete with protection). Image schema extended with `alt`, `caption`, `tags` fields and `"media"` type. `find_usages/1` scans product_images, theme settings, favicon variants, and page blocks. `delete_with_cleanup/1` refuses deletion of in-use images and cleans up disk variants. `:image` field type in block editor with image preview and ID input. Page renderer resolves `image_id` to responsive variant URLs, falling back to legacy `image_url`/`image_src` strings. Alt text backfill mix task. 1398 tests. + +**Key files:** +- `lib/berrypod/media.ex` — extended context (list, update_metadata, find_usages, used_image_ids, delete_with_cleanup) +- `lib/berrypod/media/image.ex` — alt/caption/tags fields, media type, metadata_changeset +- `lib/berrypod_web/live/admin/media.ex` — admin media library LiveView +- `lib/berrypod_web/components/block_editor_components.ex` — `:image` field type +- `lib/berrypod_web/page_renderer.ex` — image_id resolution helpers +- `lib/mix/tasks/berrypod/backfill_alt_text.ex` — one-time backfill task +- `test/berrypod/media_test.exs` — 36 tests (17 new) +- `test/berrypod_web/live/admin/media_test.exs` — 11 tests + +See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan + ### Page Editor -**Status:** In progress — Stage 7b of 9 complete, 1326 tests +**Status:** In progress — Stage 8 of 9 complete, 1370 tests Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven). @@ -471,8 +493,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON 6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`) 7. ~~Admin editor — inline block settings editing~~ ✅ (`3f97742`) 7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`) -8. **Next →** Live preview — split layout with real-time preview -9. Undo/redo + polish — history stacks, keyboard shortcuts, animations +8. ~~Live page editor sidebar — collapsible sidebar on shop pages, backdrop dismiss, portable content block~~ ✅ (`a039c8d`) +9. **Next →** Undo/redo + polish — history stacks, keyboard shortcuts, animations **Key files created:** - `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) @@ -482,7 +504,12 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON - `test/berrypod/pages_test.exs` — 34 tests - `test/berrypod_web/page_renderer_test.exs` — 18 tests - `lib/berrypod/pages/settings_field.ex` — typed struct for settings schema fields +- `lib/berrypod/pages/block_editor.ex` — pure functions for block manipulation +- `lib/berrypod_web/components/block_editor_components.ex` — shared editor UI components +- `lib/berrypod_web/page_editor_hook.ex` — on_mount hook for live page editing on shop pages - `test/berrypod_web/live/admin/pages_test.exs` — 42 tests +- `test/berrypod/pages/block_editor_test.exs` — 26 tests +- `test/berrypod_web/page_editor_hook_test.exs` — 13 tests See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index d4e1f2b..e741568 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -474,11 +474,31 @@ font-size: 0.625rem; } +.admin-badge-info { + background-color: color-mix(in oklch, var(--t-accent) 15%, transparent); + color: var(--t-accent); +} + +.admin-badge-accent { + background-color: color-mix(in oklch, var(--t-status-success, #22c55e) 15%, transparent); + color: var(--t-status-success, #16a34a); +} + .admin-badge-warning { background-color: color-mix(in oklch, var(--t-status-warning, #f59e0b) 15%, transparent); color: var(--t-status-warning, #b45309); } +.admin-badge-danger { + background-color: color-mix(in oklch, var(--t-status-error, #ef4444) 15%, transparent); + color: var(--t-status-error, #dc2626); +} + +.admin-badge-neutral { + background-color: var(--t-surface-sunken); + color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); +} + /* ── Dropdown ── */ .admin-dropdown { @@ -677,7 +697,7 @@ .analytics-y-labels, .analytics-x-labels { font-size: 0.6875rem; - color: color-mix(in oklch, var(--color-base-content) 55%, transparent); + color: color-mix(in oklch, var(--t-text-primary) 55%, transparent); font-variant-numeric: tabular-nums; } @@ -1566,4 +1586,356 @@ } } +/* ═══════════════════════════════════════════════════════════════════ + Image field (block editor) + ═══════════════════════════════════════════════════════════════════ */ + +.image-field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.image-field-preview { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border: 1px solid var(--t-surface-sunken); + border-radius: 0.375rem; + background: var(--t-surface-base); +} + +.image-field-thumb { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 0.25rem; + flex-shrink: 0; +} + +.image-field-svg { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--t-surface-sunken); + border-radius: 0.25rem; + flex-shrink: 0; + color: var(--t-text-primary); + opacity: 0.5; +} + +.image-field-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.image-field-filename { + font-size: 0.8125rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-field-alt { + font-size: 0.75rem; + color: var(--t-text-primary); + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-field-empty { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + border: 2px dashed var(--t-border-default); + border-radius: 0.375rem; + color: var(--t-text-primary); + opacity: 0.5; + font-size: 0.8125rem; +} + +/* ═══════════════════════════════════════════════════════════════════ + Media library + ═══════════════════════════════════════════════════════════════════ */ + +.media-layout { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.media-upload-zone { + border: 2px dashed var(--t-border-default); + border-radius: 0.5rem; + padding: 1rem; + transition: border-color 0.2s; +} + +.media-upload-zone[phx-drop-active] { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 5%, transparent); +} + +.media-upload-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.media-upload-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.media-upload-progress { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.media-upload-progress progress { + flex: 1; + height: 0.5rem; +} + + +.media-main { + display: flex; + gap: 1rem; + min-height: 400px; +} + +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; + flex: 1; + min-width: 0; + align-content: start; +} + +.media-card { + border: 2px solid var(--t-surface-sunken); + border-radius: 0.5rem; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + background: var(--t-surface-base); +} + +.media-card:hover { + border-color: var(--t-border-default); +} + +.media-card-selected { + border-color: var(--color-accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 25%, transparent); +} + +.media-card-thumb { + aspect-ratio: 1; + overflow: hidden; + background: var(--t-surface-sunken); + display: flex; + align-items: center; + justify-content: center; +} + +.media-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.media-card-svg-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + color: var(--t-text-primary); + opacity: 0.5; + font-size: 0.75rem; +} + +.media-card-info { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.media-card-filename { + font-size: 0.75rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-card-meta { + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--t-text-primary); + opacity: 0.7; +} + +.media-card-no-alt { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.625rem; + color: var(--color-warning); +} + +/* Detail panel */ + +.media-detail { + width: 320px; + min-width: 320px; + border: 1px solid var(--t-surface-sunken); + border-radius: 0.5rem; + background: var(--t-surface-base); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + max-height: calc(100vh - 12rem); + overflow-y: auto; + position: sticky; + top: 5rem; +} + +.media-detail-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.media-detail-header h3 { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.media-detail-preview { + aspect-ratio: 16/9; + overflow: hidden; + border-radius: 0.375rem; + background: var(--t-surface-sunken); + display: flex; + align-items: center; + justify-content: center; +} + +.media-detail-preview img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.media-detail-svg { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + color: var(--t-text-primary); + opacity: 0.5; +} + +.media-detail-meta { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.75rem; + font-size: 0.8125rem; +} + +.media-detail-meta dt { + font-weight: 500; + color: var(--t-text-primary); + opacity: 0.7; +} + +.media-detail-meta dd { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-detail-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.media-detail-usages { + border-top: 1px solid var(--t-surface-sunken); + padding-top: 0.75rem; +} + +.media-detail-usages h4 { + font-size: 0.875rem; + font-weight: 600; + margin: 0 0 0.5rem; +} + +.media-detail-usages ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8125rem; +} + +.media-detail-usages li { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.media-detail-actions { + border-top: 1px solid var(--t-surface-sunken); + padding-top: 0.75rem; +} + +@media (max-width: 47.99em) { + .media-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + } + + .media-detail { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + min-width: unset; + max-height: 70vh; + border-radius: 0.75rem 0.75rem 0 0; + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.15); + z-index: 40; + animation: media-sheet-up 0.25s ease-out; + } +} + +@keyframes media-sheet-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + } /* @layer admin */ diff --git a/docs/plans/media-library.md b/docs/plans/media-library.md new file mode 100644 index 0000000..eeb6807 --- /dev/null +++ b/docs/plans/media-library.md @@ -0,0 +1,265 @@ +# 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-hoc** — `Products.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 JSON** — `logo_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 uploads** — `image_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` + +```elixir +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. + +```elixir +# 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` + +```elixir +@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` + +```elixir +@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` + +```elixir +@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) + +```elixir +# 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: + +```elixir +# 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: + +```elixir +<.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 cleanup** — `Products.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 | diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index 4009760..603da11 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -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). """ diff --git a/lib/berrypod/media/image.ex b/lib/berrypod/media/image.ex index 9f47b83..312e1e2 100644 --- a/lib/berrypod/media/image.ex +++ b/lib/berrypod/media/image.ex @@ -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) diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index 1f6219c..d05e9b6 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -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: "" + } ] }, diff --git a/lib/berrypod_web/components/block_editor_components.ex b/lib/berrypod_web/components/block_editor_components.ex index beda210..5ac60c3 100644 --- a/lib/berrypod_web/components/block_editor_components.ex +++ b/lib/berrypod_web/components/block_editor_components.ex @@ -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""" +
+ {@field.label} +
+ <%= if @image do %> +
+ <%= if @image.is_svg do %> +
+ <.icon name="hero-code-bracket" class="size-6" /> +
+ <% else %> + {@image.alt + <% end %> +
+ {@image.filename} + {@image.alt} +
+
+ <% else %> +
+ <.icon name="hero-photo" class="size-6" /> + No image selected +
+ <% end %> + +
+
+ """ + end + def block_field(%{field: %{type: :repeater}} = assigns) do items = if is_list(assigns.value), do: assigns.value, else: [] item_count = length(items) diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 15f58f9..4051d7a 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -102,6 +102,14 @@ <.icon name="hero-document" class="size-5" /> Pages +
  • + <.link + navigate={~p"/admin/media"} + class={admin_nav_active?(@current_path, "/admin/media")} + > + <.icon name="hero-photo" class="size-5" /> Media + +
  • <.link href={~p"/admin/theme"} diff --git a/lib/berrypod_web/live/admin/media.ex b/lib/berrypod_web/live/admin/media.ex new file mode 100644 index 0000000..5759321 --- /dev/null +++ b/lib/berrypod_web/live/admin/media.ex @@ -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 + + +
    + <%!-- upload zone --%> +
    +
    +
    + + +
    +
    + <%= for entry <- @uploads.media_upload.entries do %> +
    + {entry.client_name} + {entry.progress}% +
    + <% end %> + <%= for err <- upload_errors(@uploads.media_upload) do %> +

    {Phoenix.Naming.humanize(err)}

    + <% end %> +
    + + <%!-- filter bar --%> +
    +
    + +
    + + +
    + +
    + <%!-- image grid --%> +
    +
    +
    + <%= if image.is_svg do %> +
    + <.icon name="hero-code-bracket" class="size-8" /> + SVG +
    + <% else %> + <%= if thumb = image_thumbnail_url(image) do %> + {image.alt + <% else %> +
    + <.icon name="hero-photo" class="size-8" /> +
    + <% end %> + <% end %> +
    +
    + {image.filename} +
    + {image.image_type} + {format_file_size(image.file_size)} +
    + + <.icon name="hero-exclamation-triangle" class="size-3" /> No alt text + +
    +
    +
    + + <%!-- detail panel --%> + +
    +
    + """ + end +end diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index 03172be..7a9e422 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -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" diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index e41f09d..bd8c4cb 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -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 diff --git a/lib/mix/tasks/berrypod/backfill_alt_text.ex b/lib/mix/tasks/berrypod/backfill_alt_text.ex new file mode 100644 index 0000000..aa54da5 --- /dev/null +++ b/lib/mix/tasks/berrypod/backfill_alt_text.ex @@ -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 diff --git a/priv/repo/migrations/20260227212335_add_image_metadata.exs b/priv/repo/migrations/20260227212335_add_image_metadata.exs new file mode 100644 index 0000000..798221b --- /dev/null +++ b/priv/repo/migrations/20260227212335_add_image_metadata.exs @@ -0,0 +1,11 @@ +defmodule Berrypod.Repo.Migrations.AddImageMetadata do + use Ecto.Migration + + def change do + alter table(:images) do + add :alt, :string + add :caption, :string + add :tags, :string + end + end +end diff --git a/test/berrypod/media_test.exs b/test/berrypod/media_test.exs index 708a1fe..5da4d00 100644 --- a/test/berrypod/media_test.exs +++ b/test/berrypod/media_test.exs @@ -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 diff --git a/test/berrypod_web/live/admin/media_test.exs b/test/berrypod_web/live/admin/media_test.exs new file mode 100644 index 0000000..8b9edff --- /dev/null +++ b/test/berrypod_web/live/admin/media_test.exs @@ -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: "" + } + + @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