diff --git a/CLAUDE.md b/CLAUDE.md
index 93603864..654195fc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -507,25 +507,17 @@ Available at `/docs/api` (adapted from Tailwind UI Protocol template).
### Pagination in LiveView
-Simple offset-based pagination pattern for lists. Example from `DeviceLive.Index` (discovered devices tab):
+Simple offset-based pagination using the reusable `<.pagination>` component from `CoreComponents`.
-**1. Add pagination to socket assigns in `handle_params` or `apply_action`:**
+**1. Add pagination metadata to socket assigns in `handle_params` or `apply_action`:**
```elixir
def handle_params(params, _url, socket) do
- tab = Map.get(params, "tab", "existing")
- {:noreply, apply_action(socket, socket.assigns.live_action, params, tab)}
-end
-
-defp apply_action(socket, :index, params, "discovered") do
- organization = socket.assigns.current_scope.organization
-
- # Get page from params, default to 1
page = params |> Map.get("page", "1") |> String.to_integer()
per_page = 20
# Fetch all items (or use a paginated query for large datasets)
- all_items = MyContext.list_items(organization.id)
+ all_items = MyContext.list_items(socket.assigns.current_scope.organization.id)
total_count = length(all_items)
total_pages = ceil(total_count / per_page)
@@ -536,136 +528,62 @@ defp apply_action(socket, :index, params, "discovered") do
offset = (page - 1) * per_page
items = Enum.slice(all_items, offset, per_page)
- socket
- |> assign(:items, items)
- |> assign(:pagination, %{
- page: page,
- per_page: per_page,
- total_count: total_count,
- total_pages: total_pages
- })
+ {:noreply,
+ socket
+ |> assign(:items, items)
+ |> assign(:pagination, %{
+ page: page,
+ per_page: per_page,
+ total_count: total_count,
+ total_pages: total_pages
+ })}
end
```
-**2. Add pagination helper function to LiveView module:**
-
-```elixir
-# Generate pagination range with ellipsis for large page counts
-# Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20
-defp pagination_range(current_page, total_pages) do
- cond do
- total_pages <= 7 ->
- # Show all pages if 7 or fewer
- Enum.to_list(1..total_pages)
-
- current_page <= 4 ->
- # Near the start
- [1, 2, 3, 4, 5, :ellipsis, total_pages]
-
- current_page >= total_pages - 3 ->
- # Near the end
- [1, :ellipsis, total_pages - 4, total_pages - 3, total_pages - 2, total_pages - 1, total_pages]
-
- true ->
- # In the middle
- [1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
- end
-end
-```
-
-**3. Add pagination controls to template:**
+**2. Use the `<.pagination>` component in your template:**
```heex
-<%= if @pagination.total_pages > 1 do %>
-
-
-
- <.link
- :if={@pagination.page > 1}
- patch={~p"/path?page=#{@pagination.page - 1}"}
- class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
- >
- Previous
-
- <.link
- :if={@pagination.page < @pagination.total_pages}
- patch={~p"/path?page=#{@pagination.page + 1}"}
- class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
- >
- Next
-
-
+
+
+ <%= for item <- @items do %>
+
+ <% end %>
+
-
-
-
-
- Showing
- {(@pagination.page - 1) * @pagination.per_page + 1}
- to
- {min(@pagination.page * @pagination.per_page, @pagination.total_count)}
- of
- {@pagination.total_count}
- results
-
-
-
-
-
-
-
-<% end %>
+
+<.pagination meta={@pagination} path={~p"/your-path"} />
```
+**Preserving query parameters:**
+
+If you need to preserve other query params (like tabs or filters), pass them via the `params` attribute:
+
+```heex
+<.pagination
+ meta={@pagination}
+ path={~p"/devices"}
+ params={%{"tab" => @current_tab, "filter" => @filter}}
+/>
+```
+
+**Component Reference:**
+
+The `<.pagination>` component (defined in `CoreComponents`) accepts:
+- `meta` (required) - Map with `:page`, `:per_page`, `:total_count`, `:total_pages`
+- `path` (required) - Base path for pagination links (e.g., `~p"/admin"`)
+- `params` (optional) - Additional query params to preserve (default: `%{}`)
+
+**Features:**
+- Mobile: Simple Previous/Next buttons
+- Desktop: Full page numbers with smart ellipsis (shows [1] ... [4] [5] [6] ... [20])
+- Automatically builds pagination URLs preserving query params
+- Only renders if `total_pages > 1`
+- Uses `patch` navigation to avoid full page reload
+
**Notes**:
-- Use `patch` instead of `navigate` to keep LiveView mounted and avoid full page reload
-- Preserve other query params when changing pages (e.g., `~p"/path?tab=#{@tab}&page=#{page}"`)
- For very large datasets (>10,000 items), use database-level pagination with `limit/offset` instead of in-memory slicing
-- The `pagination_range/2` function creates smart ellipsis to avoid showing too many page numbers
-- Mobile shows simple Previous/Next, desktop shows full pagination controls
+- The component handles URL building automatically - just provide the base path and params
+- See `lib/towerops_web/live/admin/dashboard_live.ex` for a complete example
## Testing Patterns
diff --git a/lib/towerops_web/components/core_components.ex b/lib/towerops_web/components/core_components.ex
index d7acde65..388e2ad4 100644
--- a/lib/towerops_web/components/core_components.ex
+++ b/lib/towerops_web/components/core_components.ex
@@ -597,6 +597,153 @@ defmodule ToweropsWeb.CoreComponents do
)
end
+ @doc """
+ Renders pagination controls with page numbers and ellipsis.
+
+ Supports both mobile (Previous/Next only) and desktop (full page numbers) views.
+
+ ## Examples
+
+ <.pagination
+ meta={@pagination}
+ path={~p"/admin"}
+ />
+
+ <.pagination
+ meta={@pagination}
+ path={~p"/devices"}
+ params={%{"tab" => @current_tab}}
+ />
+
+ """
+ attr :meta, :map, required: true, doc: "Pagination metadata with :page, :per_page, :total_count, :total_pages"
+ attr :path, :string, required: true, doc: "Base path for pagination links"
+ attr :params, :map, default: %{}, doc: "Additional query params to preserve"
+
+ def pagination(assigns) do
+ ~H"""
+ <%= if @meta.total_pages > 1 do %>
+
+
+
+ <.link
+ :if={@meta.page > 1}
+ patch={build_pagination_path(@path, @params, @meta.page - 1)}
+ class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+ Previous
+
+ <.link
+ :if={@meta.page < @meta.total_pages}
+ patch={build_pagination_path(@path, @params, @meta.page + 1)}
+ class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+ Next
+
+
+
+
+
+
+ Showing {(@meta.page - 1) * @meta.per_page + 1}
+ to
+ {min(@meta.page * @meta.per_page, @meta.total_count)}
+ of {@meta.total_count}
+ results
+
+
+
+
+
+
+
+ <% end %>
+ """
+ end
+
+ # Generate pagination range with ellipsis for large page counts
+ # Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20
+ defp pagination_range(current_page, total_pages) do
+ cond do
+ total_pages <= 7 ->
+ # Show all pages if 7 or fewer
+ Enum.to_list(1..total_pages)
+
+ current_page <= 4 ->
+ # Near the start
+ [1, 2, 3, 4, 5, :ellipsis, total_pages]
+
+ current_page >= total_pages - 3 ->
+ # Near the end
+ [
+ 1,
+ :ellipsis,
+ total_pages - 4,
+ total_pages - 3,
+ total_pages - 2,
+ total_pages - 1,
+ total_pages
+ ]
+
+ true ->
+ # In the middle
+ [1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
+ end
+ end
+
+ # Build pagination path with preserved query params
+ defp build_pagination_path(base_path, params, page) do
+ query_params = Map.put(params, "page", page)
+ query_string = URI.encode_query(query_params)
+
+ if query_string == "" do
+ base_path
+ else
+ "#{base_path}?#{query_string}"
+ end
+ end
+
@doc """
Translates an error message using gettext.
"""
diff --git a/lib/towerops_web/live/admin/dashboard_live.ex b/lib/towerops_web/live/admin/dashboard_live.ex
index 53420988..05a7d0db 100644
--- a/lib/towerops_web/live/admin/dashboard_live.ex
+++ b/lib/towerops_web/live/admin/dashboard_live.ex
@@ -8,16 +8,42 @@ defmodule ToweropsWeb.Admin.DashboardLive do
@impl true
def mount(_params, _session, socket) do
- user_count = Admin.count_users()
- org_count = Admin.count_organizations()
- recent_logs = Admin.list_audit_logs(limit: 10)
-
{:ok,
socket
|> assign(:page_title, "Admin Dashboard")
- |> assign(:timezone, socket.assigns.current_scope.timezone)
+ |> assign(:timezone, socket.assigns.current_scope.timezone)}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ page = params |> Map.get("page", "1") |> String.to_integer()
+ per_page = 10
+
+ user_count = Admin.count_users()
+ org_count = Admin.count_organizations()
+
+ # Fetch all recent logs (limited to last 100 for performance)
+ all_logs = Admin.list_audit_logs(limit: 100)
+ total_count = length(all_logs)
+ total_pages = ceil(total_count / per_page)
+
+ # Ensure page is within valid range
+ page = max(1, min(page, max(1, total_pages)))
+
+ # Slice logs for current page
+ offset = (page - 1) * per_page
+ recent_logs = Enum.slice(all_logs, offset, per_page)
+
+ {:noreply,
+ socket
|> assign(:user_count, user_count)
|> assign(:org_count, org_count)
- |> assign(:recent_logs, recent_logs)}
+ |> assign(:recent_logs, recent_logs)
+ |> assign(:pagination, %{
+ page: page,
+ per_page: per_page,
+ total_count: total_count,
+ total_pages: total_pages
+ })}
end
end
diff --git a/lib/towerops_web/live/admin/dashboard_live.html.heex b/lib/towerops_web/live/admin/dashboard_live.html.heex
index ef28de37..227d3e6a 100644
--- a/lib/towerops_web/live/admin/dashboard_live.html.heex
+++ b/lib/towerops_web/live/admin/dashboard_live.html.heex
@@ -60,8 +60,14 @@
-
+
Recent Audit Logs
+ <.link
+ navigate={~p"/admin/audit"}
+ class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
+ >
+ View all →
+
<.table id="audit-logs" rows={@recent_logs}>
<:col :let={log} label="Action">{log.action}
@@ -71,6 +77,9 @@
{ToweropsWeb.TimeHelpers.format_iso8601(log.inserted_at, @timezone)}
+
+ <.pagination meta={@pagination} path={~p"/admin"} />
+