266 lines
12 KiB
Markdown
266 lines
12 KiB
Markdown
|
|
# 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 |
|