add admin media library with image management and block picker integration

- Schema: alt, caption, tags fields on images table with metadata changeset
- Context: list_images with filters, find_usages, used_image_ids, delete_with_cleanup
- Admin UI: /admin/media with grid view, upload, filters, detail panel, metadata editing
- Block editor: :image field type for image_text and content_body blocks
- Page renderer: image_id resolution with legacy URL fallback
- Mobile: bottom sheet detail panel with slide-up animation
- CSS: uses correct --t-* admin theme tokens, admin-badge colour variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-27 22:20:51 +00:00
parent a039c8d53c
commit 847b5f3e5e
15 changed files with 1828 additions and 17 deletions

View File

@ -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

View File

@ -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 */

265
docs/plans/media-library.md Normal file
View File

@ -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 |

View File

@ -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).
"""

View File

@ -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)

View File

@ -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: ""
}
]
},

View File

@ -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"""
<div class="admin-fieldset">
<span class="admin-label">{@field.label}</span>
<div class="image-field">
<%= if @image do %>
<div class="image-field-preview">
<%= if @image.is_svg do %>
<div class="image-field-svg">
<.icon name="hero-code-bracket" class="size-6" />
</div>
<% else %>
<img
src={"/image_cache/#{@image.id}-thumb.jpg"}
alt={@image.alt || @image.filename}
class="image-field-thumb"
/>
<% end %>
<div class="image-field-info">
<span class="image-field-filename">{@image.filename}</span>
<span :if={@image.alt} class="image-field-alt">{@image.alt}</span>
</div>
</div>
<% else %>
<div class="image-field-empty">
<.icon name="hero-photo" class="size-6" />
<span>No image selected</span>
</div>
<% end %>
<input
type="text"
name={"block_settings[#{@field.key}]"}
id={"block-#{@block_id}-#{@field.key}"}
value={if(@image, do: @image.id, else: "")}
placeholder="Paste image ID from media library"
class="admin-input admin-input-sm"
phx-debounce="blur"
/>
</div>
</div>
"""
end
def block_field(%{field: %{type: :repeater}} = assigns) do
items = if is_list(assigns.value), do: assigns.value, else: []
item_count = length(items)

View File

@ -102,6 +102,14 @@
<.icon name="hero-document" class="size-5" /> Pages
</.link>
</li>
<li>
<.link
navigate={~p"/admin/media"}
class={admin_nav_active?(@current_path, "/admin/media")}
>
<.icon name="hero-photo" class="size-5" /> Media
</.link>
</li>
<li>
<.link
href={~p"/admin/theme"}

View File

@ -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
</.header>
<div class="media-layout">
<%!-- upload zone --%>
<div class="media-upload-zone" phx-drop-target={@uploads.media_upload.ref}>
<form phx-change="set_upload_alt" class="media-upload-form">
<div class="media-upload-row">
<label class="admin-btn admin-btn-primary">
<.icon name="hero-arrow-up-tray" class="size-4" /> Upload image
<.live_file_input upload={@uploads.media_upload} class="sr-only" />
</label>
<input
type="text"
name="value"
value={@upload_alt}
placeholder="Alt text (recommended)"
class="admin-input flex-1"
phx-debounce="200"
/>
</div>
</form>
<%= for entry <- @uploads.media_upload.entries do %>
<div class="media-upload-progress">
<span>{entry.client_name}</span>
<progress value={entry.progress} max="100">{entry.progress}%</progress>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.media_upload) do %>
<p class="text-error text-sm">{Phoenix.Naming.humanize(err)}</p>
<% end %>
</div>
<%!-- filter bar --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap items-center">
<form phx-change="filter_type" class="contents">
<select name="type" class="admin-select">
<option value="" selected={is_nil(@filter_type)}>All types</option>
<option value="media" selected={@filter_type == "media"}>Media</option>
<option value="product" selected={@filter_type == "product"}>Product</option>
<option value="logo" selected={@filter_type == "logo"}>Logo</option>
<option value="header" selected={@filter_type == "header"}>Header</option>
<option value="icon" selected={@filter_type == "icon"}>Icon</option>
</select>
</form>
<input
type="search"
placeholder="Search filename or alt text..."
value={@filter_search}
phx-keyup="filter_search"
phx-debounce="300"
class="admin-input flex-1"
/>
<button
phx-click="toggle_orphans"
class={[
"admin-btn admin-btn-sm",
@filter_orphans && "admin-btn-primary",
!@filter_orphans && "admin-btn-ghost"
]}
>
<.icon name="hero-trash" class="size-4" /> Orphans
</button>
</div>
<div class="media-main">
<%!-- image grid --%>
<div id="media-grid" phx-update="stream" class="media-grid">
<div
:for={{dom_id, image} <- @streams.images}
id={dom_id}
phx-click="select_image"
phx-value-id={image.id}
class={[
"media-card",
@selected_image && @selected_image.id == image.id && "media-card-selected"
]}
>
<div class="media-card-thumb">
<%= if image.is_svg do %>
<div class="media-card-svg-placeholder">
<.icon name="hero-code-bracket" class="size-8" />
<span>SVG</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(image) do %>
<img src={thumb} alt={image.alt || image.filename} loading="lazy" />
<% else %>
<div class="media-card-svg-placeholder">
<.icon name="hero-photo" class="size-8" />
</div>
<% end %>
<% end %>
</div>
<div class="media-card-info">
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta">
<span class={type_badge_class(image.image_type)}>{image.image_type}</span>
<span class="text-xs">{format_file_size(image.file_size)}</span>
</div>
<span
:if={!image.alt || image.alt == ""}
class="media-card-no-alt"
title="Missing alt text"
>
<.icon name="hero-exclamation-triangle" class="size-3" /> No alt text
</span>
</div>
</div>
</div>
<%!-- detail panel --%>
<aside :if={@selected_image} class="media-detail">
<div class="media-detail-header">
<h3>Image details</h3>
<button phx-click="deselect_image" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-x-mark" class="size-4" />
</button>
</div>
<div class="media-detail-preview">
<%= if @selected_image.is_svg do %>
<div class="media-detail-svg">
<.icon name="hero-code-bracket" class="size-12" />
<span>SVG image</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(@selected_image) do %>
<img src={thumb} alt={@selected_image.alt || @selected_image.filename} />
<% end %>
<% end %>
</div>
<dl class="media-detail-meta">
<dt>Filename</dt>
<dd>{@selected_image.filename}</dd>
<dt>Type</dt>
<dd>{@selected_image.image_type}</dd>
<dt>Size</dt>
<dd>{format_file_size(@selected_image.file_size)}</dd>
<dt>Dimensions</dt>
<dd>{format_dimensions(@selected_image.source_width, @selected_image.source_height)}</dd>
<dt>Uploaded</dt>
<dd>{Calendar.strftime(@selected_image.inserted_at, "%d %b %Y %H:%M")}</dd>
</dl>
<.form for={@edit_form} phx-submit="update_metadata" class="media-detail-form">
<.input field={@edit_form[:alt]} label="Alt text" placeholder="Describe this image..." />
<.input field={@edit_form[:caption]} label="Caption" placeholder="Optional caption..." />
<.input field={@edit_form[:tags]} label="Tags" placeholder="hero, homepage, banner..." />
<button type="submit" class="admin-btn admin-btn-primary admin-btn-sm">
Save metadata
</button>
</.form>
<%= if @selected_usages != [] do %>
<div class="media-detail-usages">
<h4>Used in</h4>
<ul>
<%= for usage <- @selected_usages do %>
<li>
<span class={type_badge_class(to_string(usage.type))}>{usage.type}</span>
{usage.label}
</li>
<% end %>
</ul>
</div>
<% end %>
<div class="media-detail-actions">
<%= if @confirm_delete do %>
<p class="text-sm text-error">
<%= if @selected_usages != [] do %>
This image is in use. Deleting it may break pages.
<% else %>
Are you sure?
<% end %>
</p>
<div class="flex gap-2">
<button phx-click="delete_image" class="admin-btn admin-btn-sm admin-btn-danger">
Yes, delete
</button>
<button phx-click="cancel_delete" class="admin-btn admin-btn-sm admin-btn-ghost">
Cancel
</button>
</div>
<% else %>
<button
phx-click="confirm_delete"
class="admin-btn admin-btn-sm admin-btn-ghost text-error"
>
<.icon name="hero-trash" class="size-4" /> Delete image
</button>
<% end %>
</div>
</aside>
</div>
</div>
"""
end
end

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

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