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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
119
lib/berrypod/pagination.ex
Normal 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
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user