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

@@ -333,34 +333,36 @@ defmodule BerrypodWeb.CoreComponents do
end
~H"""
<table class="admin-table admin-table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
<div class="admin-table-wrap">
<table class="admin-table admin-table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@@ -622,4 +624,88 @@ defmodule BerrypodWeb.CoreComponents do
|> JS.exec("close()", to: "##{id}")
|> JS.pop_focus()
end
# ── Pagination ───────────────────────────────────────────────────
@doc """
Renders pagination controls for admin lists.
Hidden when there's only one page. Shows "Showing X-Y of Z" on the left,
page number buttons with ellipsis on the right.
## Attributes
* `page` - Required. A `%Berrypod.Pagination{}` struct.
* `patch` - Required. Base URL path for pagination links (e.g. "/admin/products").
* `params` - Extra query params to preserve (e.g. %{"tab" => "broken"}). Default `%{}`.
"""
attr :page, Berrypod.Pagination, required: true
attr :patch, :string, required: true
attr :params, :map, default: %{}
def admin_pagination(assigns) do
assigns =
assigns
|> assign(:showing, Berrypod.Pagination.showing_text(assigns.page))
|> assign(:numbers, Berrypod.Pagination.page_numbers(assigns.page))
~H"""
<nav
:if={@page.total_pages > 1}
aria-label="Pagination"
class="admin-pagination"
>
<p class="admin-pagination-showing">{@showing}</p>
<div class="admin-pagination-buttons">
<.link
patch={admin_page_url(@patch, @page.page - 1, @params)}
class={["admin-btn admin-btn-sm admin-btn-ghost", @page.page == 1 && "admin-btn-disabled"]}
aria-label="Previous page"
aria-disabled={@page.page == 1 && "true"}
tabindex={@page.page == 1 && "-1"}
>
<.icon name="hero-chevron-left" class="size-4" />
</.link>
<%= for item <- @numbers do %>
<%= case item do %>
<% :ellipsis -> %>
<span class="admin-pagination-ellipsis" aria-hidden="true">&hellip;</span>
<% n -> %>
<.link
patch={admin_page_url(@patch, n, @params)}
aria-label={"Page #{n}"}
aria-current={n == @page.page && "page"}
class={[
"admin-btn admin-btn-sm",
if(n == @page.page, do: "admin-btn-primary", else: "admin-btn-ghost")
]}
>
{n}
</.link>
<% end %>
<% end %>
<.link
patch={admin_page_url(@patch, @page.page + 1, @params)}
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@page.page == @page.total_pages && "admin-btn-disabled"
]}
aria-label="Next page"
aria-disabled={@page.page == @page.total_pages && "true"}
tabindex={@page.page == @page.total_pages && "-1"}
>
<.icon name="hero-chevron-right" class="size-4" />
</.link>
</div>
</nav>
"""
end
defp admin_page_url(base, page, params) do
query = if page > 1, do: Map.put(params, "page", to_string(page)), else: params
if query == %{}, do: base, else: base <> "?" <> URI.encode_query(query)
end
end

View File

@@ -1714,4 +1714,84 @@ defmodule BerrypodWeb.ShopComponents.Product do
</div>
"""
end
# ── Shop pagination ────────────────────────────────────────────
@doc """
Renders pagination controls for the shop collection page.
Uses URL navigation (`<.link navigate=...>`) so pages are bookmarkable.
## Attributes
* `page` - Required. A `%Berrypod.Pagination{}` struct.
* `base_path` - Required. The base URL path (e.g. "/collections/all").
* `params` - Extra query params to preserve (e.g. %{"sort" => "newest"}).
"""
attr :page, Berrypod.Pagination, required: true
attr :base_path, :string, required: true
attr :params, :map, default: %{}
def shop_pagination(assigns) do
assigns =
assigns
|> assign(:showing, Berrypod.Pagination.showing_text(assigns.page))
|> assign(:numbers, Berrypod.Pagination.page_numbers(assigns.page))
~H"""
<nav
:if={@page.total_pages > 1}
aria-label="Pagination"
class="shop-pagination"
>
<p class="shop-pagination-showing">{@showing}</p>
<div class="shop-pagination-buttons">
<.link
:if={@page.page > 1}
navigate={pagination_url(@base_path, @page.page - 1, @params)}
class="shop-pagination-btn"
aria-label="Previous page"
>
&lsaquo; Prev
</.link>
<%= for item <- @numbers do %>
<%= case item do %>
<% :ellipsis -> %>
<span class="shop-pagination-ellipsis" aria-hidden="true">&hellip;</span>
<% n -> %>
<.link
navigate={pagination_url(@base_path, n, @params)}
aria-label={"Page #{n}"}
aria-current={n == @page.page && "page"}
class={["shop-pagination-btn", n == @page.page && "shop-pagination-btn-active"]}
>
{n}
</.link>
<% end %>
<% end %>
<.link
:if={@page.page < @page.total_pages}
navigate={pagination_url(@base_path, @page.page + 1, @params)}
class="shop-pagination-btn"
aria-label="Next page"
>
Next &rsaquo;
</.link>
</div>
</nav>
"""
end
defp pagination_url(base_path, page, params) do
query = if page > 1, do: Map.put(params, "page", to_string(page)), else: params
if query == %{} do
base_path
else
base_path <> "?" <> URI.encode_query(query)
end
end
end