add pagination across all admin and shop views
All checks were successful
deploy / deploy (push) Successful in 1m38s

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>
This commit is contained in:
jamey
2026-03-01 09:42:34 +00:00
parent 7f6fd012a5
commit 3480b326a9
21 changed files with 1485 additions and 211 deletions

View File

@@ -5,20 +5,18 @@ defmodule BerrypodWeb.Admin.Media do
@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(:pagination, nil)
|> 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,
@@ -30,6 +28,20 @@ defmodule BerrypodWeb.Admin.Media do
{: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
@@ -60,15 +72,25 @@ defmodule BerrypodWeb.Admin.Media do
@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))}
{:noreply,
socket
|> assign(:filter_type, type)
|> reload_images()}
end
def handle_event("filter_search", %{"value" => value}, socket) do
{:noreply, reload_images(assign(socket, :filter_search, value))}
{:noreply,
socket
|> assign(:filter_search, value)
|> reload_images()}
end
def handle_event("toggle_orphans", _params, socket) do
{:noreply, reload_images(assign(socket, :filter_orphans, !socket.assigns.filter_orphans))}
{:noreply,
socket
|> assign(:filter_orphans, !socket.assigns.filter_orphans)
|> reload_images()}
end
def handle_event("select_image", %{"id" => id}, socket) do
@@ -159,26 +181,29 @@ defmodule BerrypodWeb.Admin.Media do
# ── 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
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)
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))
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)
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: ""
@@ -284,49 +309,53 @@ defmodule BerrypodWeb.Admin.Media do
</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 %>
<%!-- 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-photo" class="size-8" />
<.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 %>
<% 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 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 --%>