added seasons list
This commit is contained in:
96
internal/db/paginate.go
Normal file
96
internal/db/paginate.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package db
|
||||
|
||||
import "github.com/uptrace/bun"
|
||||
|
||||
type PageOpts struct {
|
||||
Page int
|
||||
PerPage int
|
||||
Order bun.Order
|
||||
OrderBy string
|
||||
}
|
||||
|
||||
type OrderOpts struct {
|
||||
Order bun.Order
|
||||
OrderBy string
|
||||
Label string
|
||||
}
|
||||
|
||||
// TotalPages calculates the total number of pages
|
||||
func (p *PageOpts) TotalPages(total int) int {
|
||||
if p.PerPage == 0 {
|
||||
return 0
|
||||
}
|
||||
pages := total / p.PerPage
|
||||
if total%p.PerPage > 0 {
|
||||
pages++
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// HasPrevPage checks if there is a previous page
|
||||
func (p *PageOpts) HasPrevPage() bool {
|
||||
return p.Page > 1
|
||||
}
|
||||
|
||||
// HasNextPage checks if there is a next page
|
||||
func (p *PageOpts) HasNextPage(total int) bool {
|
||||
return p.Page < p.TotalPages(total)
|
||||
}
|
||||
|
||||
// GetPageRange returns an array of page numbers to display
|
||||
// maxButtons controls how many page buttons to show
|
||||
func (p *PageOpts) GetPageRange(total int, maxButtons int) []int {
|
||||
totalPages := p.TotalPages(total)
|
||||
if totalPages == 0 {
|
||||
return []int{}
|
||||
}
|
||||
|
||||
// If total pages is less than max buttons, show all pages
|
||||
if totalPages <= maxButtons {
|
||||
pages := make([]int, totalPages)
|
||||
for i := 0; i < totalPages; i++ {
|
||||
pages[i] = i + 1
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// Calculate range around current page
|
||||
halfButtons := maxButtons / 2
|
||||
start := p.Page - halfButtons
|
||||
end := p.Page + halfButtons
|
||||
|
||||
// Adjust if at beginning
|
||||
if start < 1 {
|
||||
start = 1
|
||||
end = maxButtons
|
||||
}
|
||||
|
||||
// Adjust if at end
|
||||
if end > totalPages {
|
||||
end = totalPages
|
||||
start = totalPages - maxButtons + 1
|
||||
}
|
||||
|
||||
pages := make([]int, 0, maxButtons)
|
||||
for i := start; i <= end; i++ {
|
||||
pages = append(pages, i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// StartItem returns the number of the first item on the current page
|
||||
func (p *PageOpts) StartItem() int {
|
||||
if p.Page < 1 {
|
||||
return 0
|
||||
}
|
||||
return (p.Page-1)*p.PerPage + 1
|
||||
}
|
||||
|
||||
// EndItem returns the number of the last item on the current page
|
||||
func (p *PageOpts) EndItem(total int) int {
|
||||
end := p.Page * p.PerPage
|
||||
if end > total {
|
||||
return total
|
||||
}
|
||||
return end
|
||||
}
|
||||
83
internal/db/season.go
Normal file
83
internal/db/season.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type Season struct {
|
||||
bun.BaseModel `bun:"table:seasons,alias:s"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
Name string `bun:"name,unique"`
|
||||
ShortName string `bun:"short_name,unique"`
|
||||
}
|
||||
|
||||
type SeasonList struct {
|
||||
Seasons []Season
|
||||
Total int
|
||||
PageOpts PageOpts
|
||||
}
|
||||
|
||||
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("name cannot be empty")
|
||||
}
|
||||
if shortname == "" {
|
||||
return nil, errors.New("shortname cannot be empty")
|
||||
}
|
||||
season := &Season{
|
||||
Name: name,
|
||||
ShortName: shortname,
|
||||
}
|
||||
_, err := tx.NewInsert().
|
||||
Model(season).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewInsert")
|
||||
}
|
||||
return season, nil
|
||||
}
|
||||
|
||||
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonList, error) {
|
||||
if pageOpts == nil {
|
||||
pageOpts = &PageOpts{}
|
||||
}
|
||||
if pageOpts.Page == 0 {
|
||||
pageOpts.Page = 1
|
||||
}
|
||||
if pageOpts.PerPage == 0 {
|
||||
pageOpts.PerPage = 10
|
||||
}
|
||||
if pageOpts.Order == "" {
|
||||
pageOpts.Order = bun.OrderDesc
|
||||
}
|
||||
if pageOpts.OrderBy == "" {
|
||||
pageOpts.OrderBy = "name"
|
||||
}
|
||||
seasons := []Season{}
|
||||
err := tx.NewSelect().
|
||||
Model(&seasons).
|
||||
OrderBy(pageOpts.OrderBy, pageOpts.Order).
|
||||
Offset(pageOpts.PerPage * (pageOpts.Page - 1)).
|
||||
Limit(pageOpts.PerPage).
|
||||
Scan(ctx)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
total, err := tx.NewSelect().
|
||||
Model(&seasons).
|
||||
Count(ctx)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
sl := &SeasonList{
|
||||
Seasons: seasons,
|
||||
Total: total,
|
||||
PageOpts: *pageOpts,
|
||||
}
|
||||
return sl, nil
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername str
|
||||
Where("id = ?", user.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.Update")
|
||||
return errors.Wrap(err, "tx.NewUpdate")
|
||||
}
|
||||
user.Username = newUsername
|
||||
return nil
|
||||
@@ -55,7 +55,7 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
|
||||
Model(user).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.Insert")
|
||||
return nil, errors.Wrap(err, "tx.NewInsert")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
@@ -75,7 +75,7 @@ func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "tx.Select")
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "tx.Select")
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, er
|
||||
Where("username = ?", username).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "tx.Count")
|
||||
return false, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
128
internal/handlers/seasons.go
Normal file
128
internal/handlers/seasons.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/view/page"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func SeasonsPage(
|
||||
s *hws.Server,
|
||||
conn *bun.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var pageNum, perPage int
|
||||
var order bun.Order
|
||||
var orderBy string
|
||||
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
|
||||
pageNum, err = strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
throwBadRequest(s, w, r, "Invalid page number", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
|
||||
perPage, err = strconv.Atoi(perPageStr)
|
||||
if err != nil {
|
||||
throwBadRequest(s, w, r, "Invalid per_page number", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
order = bun.Order(r.URL.Query().Get("order"))
|
||||
orderBy = r.URL.Query().Get("order_by")
|
||||
pageOpts := &db.PageOpts{
|
||||
Page: pageNum,
|
||||
PerPage: perPage,
|
||||
Order: order,
|
||||
OrderBy: orderBy,
|
||||
}
|
||||
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
page.SeasonsPage(seasons).Render(r.Context(), w)
|
||||
})
|
||||
}
|
||||
|
||||
func SeasonsList(
|
||||
s *hws.Server,
|
||||
conn *bun.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Parse form values
|
||||
if err := r.ParseForm(); err != nil {
|
||||
throwBadRequest(s, w, r, "Invalid form data", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract pagination/sort params from form
|
||||
var pageNum, perPage int
|
||||
var order bun.Order
|
||||
var orderBy string
|
||||
var err error
|
||||
|
||||
if pageStr := r.FormValue("page"); pageStr != "" {
|
||||
pageNum, err = strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
throwBadRequest(s, w, r, "Invalid page number", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if perPageStr := r.FormValue("per_page"); perPageStr != "" {
|
||||
perPage, err = strconv.Atoi(perPageStr)
|
||||
if err != nil {
|
||||
throwBadRequest(s, w, r, "Invalid per_page number", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
order = bun.Order(r.FormValue("order"))
|
||||
orderBy = r.FormValue("order_by")
|
||||
|
||||
pageOpts := &db.PageOpts{
|
||||
Page: pageNum,
|
||||
PerPage: perPage,
|
||||
Order: order,
|
||||
OrderBy: orderBy,
|
||||
}
|
||||
fmt.Println(pageOpts)
|
||||
|
||||
// Database query
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
// Return only the list component (hx-push-url handles URL update client-side)
|
||||
page.SeasonsList(seasons).Render(r.Context(), w)
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,12 @@ type NavItem struct {
|
||||
|
||||
// Return the list of navbar links
|
||||
func getNavItems() []NavItem {
|
||||
return []NavItem{}
|
||||
return []NavItem{
|
||||
{
|
||||
name: "Seasons",
|
||||
href: "/seasons",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the navbar template fragment
|
||||
|
||||
101
internal/view/component/pagination/pagination.templ
Normal file
101
internal/view/component/pagination/pagination.templ
Normal file
@@ -0,0 +1,101 @@
|
||||
package pagination
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "fmt"
|
||||
|
||||
templ Pagination(opts db.PageOpts, total int) {
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
<input type="hidden" name="page" id="pagination-page"/>
|
||||
<input type="hidden" name="per_page" id="pagination-per-page"/>
|
||||
<!-- Page info and per-page selector -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-subtext0">
|
||||
<div>
|
||||
if total > 0 {
|
||||
Showing { fmt.Sprintf("%d", opts.StartItem()) } - { fmt.Sprintf("%d", opts.EndItem(total)) } of { fmt.Sprintf("%d", total) } results
|
||||
} else {
|
||||
No results
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="per-page-select">Per page:</label>
|
||||
<select
|
||||
id="per-page-select"
|
||||
class="py-1 px-2 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
|
||||
x-model.number="perPage"
|
||||
@change="setPerPage(perPage)"
|
||||
>
|
||||
<option value="1">1</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pagination buttons -->
|
||||
if total > 0 && opts.TotalPages(total) > 1 {
|
||||
<div class="flex flex-wrap justify-center items-center gap-2">
|
||||
<!-- First button -->
|
||||
<button
|
||||
type="button"
|
||||
@click="goToPage(1)"
|
||||
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
|
||||
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
|
||||
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||
>
|
||||
<span class="hidden sm:inline">First</span>
|
||||
<span class="sm:hidden"><<</span>
|
||||
</button>
|
||||
<!-- Previous button -->
|
||||
<button
|
||||
type="button"
|
||||
@click={ fmt.Sprintf("goToPage(%d)", opts.Page-1) }
|
||||
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
|
||||
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
|
||||
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||
>
|
||||
<span class="hidden sm:inline">Previous</span>
|
||||
<span class="sm:hidden"><</span>
|
||||
</button>
|
||||
<!-- Page numbers -->
|
||||
for _, pageNum := range opts.GetPageRange(total, 7) {
|
||||
<button
|
||||
type="button"
|
||||
@click={ fmt.Sprintf("goToPage(%d)", pageNum) }
|
||||
class={ "px-3 py-2 rounded-lg border transition",
|
||||
templ.KV("bg-blue border-blue text-mantle font-bold", pageNum == opts.Page),
|
||||
templ.KV("bg-mantle border-surface1 text-text hover:bg-surface0 hover:border-blue cursor-pointer", pageNum != opts.Page) }
|
||||
>
|
||||
{ fmt.Sprintf("%d", pageNum) }
|
||||
</button>
|
||||
}
|
||||
<!-- Next button -->
|
||||
<button
|
||||
type="button"
|
||||
@click={ fmt.Sprintf("goToPage(%d)", opts.Page+1) }
|
||||
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
|
||||
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
|
||||
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||
>
|
||||
<span class="hidden sm:inline">Next</span>
|
||||
<span class="sm:hidden">></span>
|
||||
</button>
|
||||
<!-- Last button -->
|
||||
<button
|
||||
type="button"
|
||||
@click={ fmt.Sprintf("goToPage(%d)", opts.TotalPages(total)) }
|
||||
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
|
||||
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
|
||||
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||
>
|
||||
<span class="hidden sm:inline">Last</span>
|
||||
<span class="sm:hidden">>></span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
26
internal/view/component/sort/dropdown.templ
Normal file
26
internal/view/component/sort/dropdown.templ
Normal file
@@ -0,0 +1,26 @@
|
||||
package sort
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "strings"
|
||||
|
||||
templ Dropdown(pageopts db.PageOpts, orderopts []db.OrderOpts) {
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="hidden" name="order" id="sort-order"/>
|
||||
<input type="hidden" name="order_by" id="sort-order-by"/>
|
||||
<label for="sort-select" class="text-sm text-subtext0">Sort by:</label>
|
||||
<select
|
||||
id="sort-select"
|
||||
class="py-2 px-3 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
|
||||
@change="handleSortChange($event.target.value)"
|
||||
>
|
||||
for _, opt := range orderopts {
|
||||
<option
|
||||
value={ strings.Join([]string{opt.OrderBy, string(opt.Order)}, "|") }
|
||||
selected?={ pageopts.OrderBy == opt.OrderBy && pageopts.Order == opt.Order }
|
||||
>
|
||||
{ opt.Label }
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||
// Page content for the index page
|
||||
templ Index() {
|
||||
@layout.Global("OSL Stats") {
|
||||
<div class="text-center mt-24">
|
||||
<div class="text-center mt-25">
|
||||
<div class="text-4xl lg:text-6xl">OSL Stats</div>
|
||||
<div>Placeholder text</div>
|
||||
</div>
|
||||
|
||||
85
internal/view/page/seasons_list.templ
Normal file
85
internal/view/page/seasons_list.templ
Normal file
@@ -0,0 +1,85 @@
|
||||
package page
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/pagination"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/sort"
|
||||
import "fmt"
|
||||
import "github.com/uptrace/bun"
|
||||
|
||||
templ SeasonsPage(seasons *db.SeasonList) {
|
||||
@layout.Global("Seasons") {
|
||||
<div class="max-w-screen-2xl mx-auto px-2">
|
||||
@SeasonsList(seasons)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ SeasonsList(seasons *db.SeasonList) {
|
||||
{{
|
||||
sortOpts := []db.OrderOpts{
|
||||
{
|
||||
Order: bun.OrderAsc,
|
||||
OrderBy: "name",
|
||||
Label: "Name (A-Z)",
|
||||
},
|
||||
{
|
||||
Order: bun.OrderDesc,
|
||||
OrderBy: "name",
|
||||
Label: "Name (Z-A)",
|
||||
},
|
||||
{
|
||||
Order: bun.OrderAsc,
|
||||
OrderBy: "short_name",
|
||||
Label: "Short Name (A-Z)",
|
||||
},
|
||||
{
|
||||
Order: bun.OrderDesc,
|
||||
OrderBy: "short_name",
|
||||
Label: "Short Name (Z-A)",
|
||||
},
|
||||
}
|
||||
}}
|
||||
<div id="seasons-list-container">
|
||||
<form
|
||||
id="seasons-form"
|
||||
hx-target="#seasons-list-container"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
x-data={ templ.JSFuncCall("paginateData",
|
||||
"seasons-form",
|
||||
"/seasons",
|
||||
seasons.PageOpts.Page,
|
||||
seasons.PageOpts.PerPage,
|
||||
seasons.PageOpts.Order,
|
||||
seasons.PageOpts.OrderBy).CallInline }
|
||||
>
|
||||
<!-- Header with title and sort controls -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-3xl font-bold">Seasons</h1>
|
||||
@sort.Dropdown(seasons.PageOpts, sortOpts)
|
||||
</div>
|
||||
<!-- Results section -->
|
||||
if len(seasons.Seasons) == 0 {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No seasons found</p>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
for _, season := range seasons.Seasons {
|
||||
<a
|
||||
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors"
|
||||
href={ fmt.Sprintf("/seasons/%s", season.ShortName) }
|
||||
>
|
||||
<h3 class="text-xl font-bold text-text mb-2">{ season.Name }</h3>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<!-- Pagination controls -->
|
||||
@pagination.Pagination(seasons.PageOpts, seasons.Total)
|
||||
}
|
||||
</form>
|
||||
<script src="/static/js/pagination.js"></script>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user