added seasons list

This commit is contained in:
2026-02-01 13:25:11 +11:00
parent 96d534f045
commit 81d4ceb354
17 changed files with 660 additions and 679 deletions

96
internal/db/paginate.go Normal file
View 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
View 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
}

View File

@@ -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
}

View 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)
})
}

View File

@@ -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

View 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">&lt;&lt;</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">&lt;</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">&gt;</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">&gt;&gt;</span>
</button>
</div>
}
</div>
}

View 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>
}

View File

@@ -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>

View 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>
}