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

@@ -213,6 +213,20 @@ defmodule Berrypod.Media do
|> Repo.all()
end
@doc """
Like `list_images/1` but returns a `%Pagination{}` struct.
"""
def list_images_paginated(opts \\ []) do
from(i in ImageSchema,
select: %{i | data: nil},
order_by: [desc: i.inserted_at]
)
|> filter_by_type(opts[:type])
|> filter_by_search(opts[:search])
|> filter_by_tag(opts[:tag])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 36)
end
defp filter_by_type(query, nil), do: query
defp filter_by_type(query, ""), do: query
defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type)

View File

@@ -162,6 +162,29 @@ defmodule Berrypod.Newsletter do
Repo.all(query)
end
@doc """
Like `list_subscribers/1` but returns a `%Pagination{}` struct.
"""
def list_subscribers_paginated(opts \\ []) do
query = from(s in Subscriber, order_by: [desc: s.inserted_at])
query =
case Keyword.get(opts, :status) do
nil -> query
"all" -> query
status -> from(s in query, where: s.status == ^status)
end
query =
case Keyword.get(opts, :search) do
nil -> query
"" -> query
term -> from(s in query, where: like(s.email, ^"%#{term}%"))
end
Berrypod.Pagination.paginate(query, page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc "Returns subscriber counts grouped by status."
def count_subscribers_by_status do
from(s in Subscriber,
@@ -228,6 +251,11 @@ defmodule Berrypod.Newsletter do
|> Repo.all()
end
def list_campaigns_paginated(opts \\ []) do
from(c in Campaign, order_by: [desc: c.inserted_at])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
def get_campaign!(id), do: Repo.get!(Campaign, id)
def create_campaign(attrs) do

View File

@@ -146,6 +146,7 @@ defmodule Berrypod.Newsletter.Notifier do
@doc false
def wrap_html(shop_name, content, unsubscribe_url \\ nil) do
logo_html = build_logo_html(shop_name)
footer =
if unsubscribe_url do
"""
@@ -244,7 +245,10 @@ defmodule Berrypod.Newsletter.Notifier do
defp has_favicon_icon? do
require Ecto.Query
Berrypod.Repo.exists?(Ecto.Query.from(i in Berrypod.Media.Image, where: i.image_type == "icon"))
Berrypod.Repo.exists?(
Ecto.Query.from(i in Berrypod.Media.Image, where: i.image_type == "icon")
)
end
# Turns bare URLs in escaped text into clickable links.

View File

@@ -35,6 +35,17 @@ defmodule Berrypod.Orders do
|> Repo.all()
end
@doc """
Like `list_orders/1` but returns a `%Pagination{}` struct.
"""
def list_orders_paginated(opts \\ []) do
Order
|> maybe_filter_status(opts[:status])
|> order_by([o], desc: o.inserted_at)
|> preload(:items)
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
defp maybe_filter_status(query, nil), do: query
defp maybe_filter_status(query, "all"), do: query
defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status)

119
lib/berrypod/pagination.ex Normal file
View File

@@ -0,0 +1,119 @@
defmodule Berrypod.Pagination do
@moduledoc """
Offset-based pagination for Ecto queries.
Returns a `%Berrypod.Pagination{}` struct with items, page metadata,
and total count. Context modules pipe their filtered/ordered queries
into `paginate/2` instead of `Repo.all/0`.
"""
import Ecto.Query
alias Berrypod.Repo
@enforce_keys [:items, :page, :per_page, :total_count, :total_pages]
defstruct [:items, :page, :per_page, :total_count, :total_pages]
@type t :: %__MODULE__{
items: [any()],
page: pos_integer(),
per_page: pos_integer(),
total_count: non_neg_integer(),
total_pages: non_neg_integer()
}
@doc """
Paginates an Ecto query.
Options:
* `:page` - current page (default 1, clamped to valid range)
* `:per_page` - items per page (default 25)
"""
def paginate(query, opts \\ []) do
page = max(opts[:page] || 1, 1)
per_page = opts[:per_page] || 25
count_query =
query
|> exclude(:preload)
|> exclude(:select)
|> exclude(:order_by)
total_count = Repo.aggregate(count_query, :count)
total_pages = max(ceil(total_count / per_page), 1)
page = min(page, total_pages)
items =
query
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> Repo.all()
%__MODULE__{
items: items,
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
}
end
@doc """
Returns "Showing X\u2013Y of Z" for display, or "No results" when empty.
"""
def showing_text(%__MODULE__{total_count: 0}), do: "No results"
def showing_text(%__MODULE__{} = p) do
first = (p.page - 1) * p.per_page + 1
last = min(p.page * p.per_page, p.total_count)
"Showing #{first}\u2013#{last} of #{p.total_count}"
end
@doc """
Computes page numbers with `:ellipsis` gaps for display.
Always shows first, last, and a window around the current page.
Returns a flat list like `[1, :ellipsis, 4, 5, 6, :ellipsis, 20]`.
"""
def page_numbers(%__MODULE__{total_pages: total}) when total <= 7 do
Enum.to_list(1..total)
end
def page_numbers(%__MODULE__{page: current, total_pages: total}) do
window =
[current - 1, current, current + 1]
|> Enum.filter(&(&1 >= 1 and &1 <= total))
pages = Enum.uniq([1] ++ window ++ [total]) |> Enum.sort()
# Insert :ellipsis between non-consecutive numbers
pages
|> Enum.chunk_every(2, 1, :discard)
|> Enum.flat_map(fn [a, b] ->
if b - a > 1, do: [a, :ellipsis], else: [a]
end)
|> Kernel.++([List.last(pages)])
end
@doc """
Parses a page number from request params. Always returns >= 1.
"""
def parse_page(params, key \\ "page") do
case params[key] do
nil ->
1
val when is_binary(val) ->
case Integer.parse(val) do
{n, _} when n > 0 -> n
_ -> 1
end
val when is_integer(val) and val > 0 ->
val
_ ->
1
end
end
end

View File

@@ -171,6 +171,21 @@ defmodule Berrypod.Products do
|> Repo.all()
end
@doc """
Like `list_visible_products/1` but returns a `%Pagination{}` struct.
Accepts the same filter/sort options plus `:page` and `:per_page`.
"""
def list_visible_products_paginated(opts \\ []) do
Product
|> where([p], p.visible == true and p.status == "active")
|> apply_visible_filters(opts)
|> apply_sort(opts[:sort])
|> maybe_exclude(opts[:exclude])
|> preload(^@listing_preloads)
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
end
@doc """
Lists distinct categories from visible, active products.
Returns a list of `%{name, slug, image_url}` where `image_url` is the
@@ -308,6 +323,20 @@ defmodule Berrypod.Products do
|> Repo.all()
end
@doc """
Like `list_products_admin/1` but returns a `%Pagination{}` struct.
Accepts the same filter options plus `:page` and `:per_page`.
"""
def list_products_admin_paginated(opts \\ []) do
Product
|> apply_product_filters(opts)
|> maybe_filter_in_stock(opts[:in_stock])
|> apply_sort(opts[:sort])
|> preload([:provider_connection, images: :image, variants: []])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc """
Returns distinct category names from all products (including hidden/draft).
"""

View File

@@ -217,6 +217,11 @@ defmodule Berrypod.Redirects do
|> Repo.all()
end
def list_redirects_paginated(opts \\ []) do
from(r in Redirect, order_by: [desc: r.inserted_at])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc """
Gets a single redirect by ID.
"""
@@ -275,6 +280,16 @@ defmodule Berrypod.Redirects do
|> Repo.all()
end
def list_broken_urls_paginated(opts \\ []) do
status = opts[:status] || "pending"
from(b in BrokenUrl,
where: b.status == ^status,
order_by: [desc: b.prior_analytics_hits, desc: b.recent_404_count]
)
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc """
Resolves a broken URL by creating a redirect and updating the record.
"""