Manager — Paginacja HTMX
Strategia
Paginacja w Manager-Content oparta jest na offset-based pagination z parametrami query:
| Parametr | Typ | Domyslna | Opis |
|---|---|---|---|
page | int | 1 | Numer biezacej strony |
per_page | int | 20 | Liczba elementow na strone |
Paginacja jest realizowana przez HTMX — klikniecie w numer strony podmienia zawartosc tabeli bez przeladowania calej strony.
Schemat dzialania
Uzytkownik klika "Strona 2"
|
v
HTMX wysyla GET /partials/task-list?page=2&per_page=20
|
v
Go handler zwraca HTML partial (tylko wiersze tabeli + pagination controls)
|
v
HTMX podmienia #table-body (innerHTML) z animacja fadeImplementacja HTML (template)
Glowna strona z tabela
html
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-gray-100">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tytul
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Typ
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Akcje
</th>
</tr>
</thead>
<!-- HTMX target: podmieniana zawartosc -->
<tbody id="table-body"
hx-get="/partials/task-list?page=1&per_page=20"
hx-trigger="load"
hx-swap="innerHTML transition:true"
class="divide-y divide-gray-50">
<!-- Ladowanie poczatkowe (htmx-indicator) -->
<tr class="htmx-indicator">
<td colspan="4" class="px-6 py-12 text-center">
<div class="inline-flex items-center gap-2 text-sm text-gray-500">
<svg class="animate-spin w-4 h-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Ladowanie...
</div>
</td>
</tr>
</tbody>
</table>
<!-- Pagination controls (rowniez podmieniane) -->
<div id="pagination-controls" class="px-6 py-4 border-t border-gray-100">
<!-- Ladowane przez HTMX razem z trescia tabeli -->
</div>
</div>Partial: wiersze tabeli (zwracany przez serwer)
html
{{/* partials/task-list.html */}}
{{ range .Tasks }}
<tr class="hover:bg-gray-50 transition-colors duration-150">
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ .Title }}</div>
<div class="text-xs text-gray-500 mt-0.5">{{ .Description | truncate 60 }}</div>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center gap-1.5 text-sm text-gray-600">
<i data-lucide="{{ .TypeIcon }}" class="w-4 h-4"></i>
{{ .TypeLabel }}
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{{ if eq .Status "active" }}bg-green-50 text-green-700{{ end }}
{{ if eq .Status "draft" }}bg-gray-100 text-gray-600{{ end }}
{{ if eq .Status "review" }}bg-amber-50 text-amber-700{{ end }}">
{{ .StatusLabel }}
</span>
</td>
<td class="px-6 py-4 text-right">
<a href="/content/{{ .ID }}"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">
Edytuj
</a>
</td>
</tr>
{{ end }}
<!-- Pagination controls (OOB swap) -->
<div id="pagination-controls"
hx-swap-oob="innerHTML"
class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
<div class="text-sm text-gray-500">
Wyniki {{ .From }}–{{ .To }} z {{ .Total }}
</div>
<nav class="flex items-center gap-1">
{{ if .HasPrev }}
<button hx-get="/partials/task-list?page={{ .PrevPage }}&per_page={{ .PerPage }}"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
class="px-3 py-2 text-sm text-gray-600 rounded-lg
hover:bg-gray-100 transition-colors duration-150">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</button>
{{ end }}
{{ range .Pages }}
<button hx-get="/partials/task-list?page={{ .Number }}&per_page={{ $.PerPage }}"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
class="px-3 py-2 text-sm rounded-lg transition-colors duration-150
{{ if .IsCurrent }}
bg-indigo-50 text-indigo-700 font-medium
{{ else }}
text-gray-600 hover:bg-gray-100
{{ end }}">
{{ .Number }}
</button>
{{ end }}
{{ if .HasNext }}
<button hx-get="/partials/task-list?page={{ .NextPage }}&per_page={{ .PerPage }}"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
class="px-3 py-2 text-sm text-gray-600 rounded-lg
hover:bg-gray-100 transition-colors duration-150">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
{{ end }}
</nav>
</div>Go Handler
go
package handlers
import (
"html/template"
"math"
"net/http"
"strconv"
)
type PaginationData struct {
Tasks []Task
Page int
PerPage int
Total int
From int
To int
Pages []PageItem
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
type PageItem struct {
Number int
IsCurrent bool
}
func TaskListPartialHandler(w http.ResponseWriter, r *http.Request) {
// Parsowanie parametrow
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
if perPage < 1 || perPage > 100 {
perPage = 20
}
// Pobranie danych z bazy
offset := (page - 1) * perPage
tasks, total := taskRepo.FindPaginated(r.Context(), offset, perPage)
// Obliczenie paginacji
totalPages := int(math.Ceil(float64(total) / float64(perPage)))
from := offset + 1
to := offset + len(tasks)
// Generowanie numerow stron
pages := make([]PageItem, 0)
for i := 1; i <= totalPages; i++ {
pages = append(pages, PageItem{
Number: i,
IsCurrent: i == page,
})
}
data := PaginationData{
Tasks: tasks,
Page: page,
PerPage: perPage,
Total: total,
From: from,
To: to,
Pages: pages,
HasPrev: page > 1,
HasNext: page < totalPages,
PrevPage: page - 1,
NextPage: page + 1,
}
// Renderowanie partiala HTML
tmpl := template.Must(template.ParseFiles("templates/partials/task-list.html"))
w.Header().Set("Content-Type", "text/html")
tmpl.Execute(w, data)
}Rejestracja route
go
mux.HandleFunc("GET /partials/task-list", TaskListPartialHandler)Animacje przejsc
CSS dla HTMX transitions
css
/* Fade out stara tresc, fade in nowa */
#table-body.htmx-swapping > tr {
opacity: 0;
transition: opacity 150ms ease-out;
}
#table-body.htmx-settling > tr {
opacity: 1;
transition: opacity 200ms ease-in;
}
/* Loading state: przyciemnij tabele podczas ladowania */
#table-body.htmx-request {
opacity: 0.5;
pointer-events: none;
transition: opacity 100ms ease;
}Loading indicator
html
<!-- Globalny indicator (np. w top bar) -->
<div class="htmx-indicator fixed top-0 left-0 right-0 z-50">
<div class="h-0.5 bg-indigo-600 animate-pulse"></div>
</div>Paginacja z filtrami
Gdy tabela ma filtry (search, dropdown), dolacz je jako query params:
html
<!-- Search input -->
<input type="search"
name="search"
placeholder="Szukaj zadan..."
hx-get="/partials/task-list"
hx-trigger="keyup changed delay:300ms"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
hx-include="[name='type_filter'],[name='age_filter']"
class="px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg ...">
<!-- Filtr typu -->
<select name="type_filter"
hx-get="/partials/task-list"
hx-trigger="change"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
hx-include="[name='search'],[name='age_filter']"
class="px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg ...">
<option value="">Wszystkie typy</option>
<option value="quiz">Quiz</option>
<option value="task">Zadanie</option>
</select>W Go handlerze:
go
func TaskListPartialHandler(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
search := r.URL.Query().Get("search")
typeFilter := r.URL.Query().Get("type_filter")
ageFilter := r.URL.Query().Get("age_filter")
// Reset na strone 1 przy nowym wyszukiwaniu
if search != "" || typeFilter != "" || ageFilter != "" {
page = 1
}
// ...budowanie query z filtrami...
}Podsumowanie
| Element | Atrybut HTMX |
|---|---|
| Ladowanie tabeli | hx-get + hx-trigger="load" |
| Klikniecie strony | hx-get + hx-target="#table-body" |
| Swap | hx-swap="innerHTML transition:true" |
| Pagination controls | hx-swap-oob="innerHTML" (out-of-band) |
| Search | hx-trigger="keyup changed delay:300ms" |
| Filtry | hx-trigger="change" + hx-include |
| Loading | klasa htmx-indicator + CSS |