add pagination across all admin and shop views
All checks were successful
deploy / deploy (push) Successful in 1m38s
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:
@@ -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 --%>
|
||||
|
||||
Reference in New Issue
Block a user