Skip to content

Manager — Paginacja HTMX

Strategia

Paginacja w Manager-Content oparta jest na offset-based pagination z parametrami query:

ParametrTypDomyslnaOpis
pageint1Numer biezacej strony
per_pageint20Liczba 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 fade

Implementacja 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

ElementAtrybut HTMX
Ladowanie tabelihx-get + hx-trigger="load"
Klikniecie stronyhx-get + hx-target="#table-body"
Swaphx-swap="innerHTML transition:true"
Pagination controlshx-swap-oob="innerHTML" (out-of-band)
Searchhx-trigger="keyup changed delay:300ms"
Filtryhx-trigger="change" + hx-include
Loadingklasa htmx-indicator + CSS

Lumos Islands v2 - Dokumentacja Projektowa