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""" +
{Phoenix.Naming.humanize(err)}
+ <% end %> +