berrypod/lib/berrypod_web/live/admin/media.ex
jamey 3480b326a9
All checks were successful
deploy / deploy (push) Successful in 1m38s
add pagination across all admin and shop views
URL-based offset pagination with ?page=N for bookmarkable pages.
Admin views use push_patch, shop collection uses navigate links.
Responsive on mobile with horizontal-scroll tables and stacking
pagination controls. Includes dev seed script for testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:42:34 +00:00

452 lines
15 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule BerrypodWeb.Admin.Media do
use BerrypodWeb, :live_view
alias Berrypod.Media
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, "Media")
|> assign(:filter_type, nil)
|> assign(:filter_search, "")
|> assign(:filter_orphans, false)
|> assign(:pagination, nil)
|> assign(:selected_image, nil)
|> assign(:selected_usages, [])
|> assign(:edit_form, nil)
|> assign(:upload_alt, "")
|> assign(:confirm_delete, false)
|> 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
@impl true
def handle_params(params, _uri, socket) do
page_num = Berrypod.Pagination.parse_page(params)
opts = image_filter_opts(socket)
page = Media.list_images_paginated([page: page_num] ++ opts)
socket =
socket
|> assign(:pagination, page)
|> stream(:images, page.items, reset: true)
{:noreply, 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,
socket
|> assign(:filter_type, type)
|> reload_images()}
end
def handle_event("filter_search", %{"value" => value}, socket) do
{:noreply,
socket
|> assign(:filter_search, value)
|> reload_images()}
end
def handle_event("toggle_orphans", _params, socket) do
{:noreply,
socket
|> assign(:filter_orphans, !socket.assigns.filter_orphans)
|> reload_images()}
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 image_filter_opts(socket) do
[
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)
end
defp reload_images(socket) do
if socket.assigns.filter_orphans do
# Orphan mode: load all, filter in Elixir (no pagination)
opts = image_filter_opts(socket)
images = Media.list_images(opts)
used = Media.used_image_ids()
orphans = Enum.reject(images, &MapSet.member?(used, &1.id))
socket
|> assign(:pagination, nil)
|> stream(:images, orphans, reset: true)
else
push_patch(socket, to: ~p"/admin/media")
end
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 + pagination --%>
<div class="media-grid-wrapper">
<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>
<.admin_pagination :if={@pagination} page={@pagination} patch={~p"/admin/media"} />
</div>
<%!-- detail panel --%>
<div :if={@selected_image} class="media-detail-scrim" phx-click="deselect_image"></div>
<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