scaffolding for new seasons
This commit is contained in:
@@ -15,7 +15,7 @@ import (
|
||||
"git.haelnorr.com/h/oslstats/internal/store"
|
||||
)
|
||||
|
||||
func setupHttpServer(
|
||||
func setupHTTPServer(
|
||||
staticFS *fs.FS,
|
||||
cfg *config.Config,
|
||||
logger *hlog.Logger,
|
||||
|
||||
@@ -72,6 +72,16 @@ func addRoutes(
|
||||
Method: hws.MethodPOST,
|
||||
Handler: handlers.SeasonsList(s, conn),
|
||||
},
|
||||
{
|
||||
Path: "/seasons/new",
|
||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||
Handler: handlers.NewSeason(s, conn),
|
||||
},
|
||||
{
|
||||
Path: "/seasons/{season_short_name}",
|
||||
Method: hws.MethodGET,
|
||||
Handler: handlers.SeasonPage(s, conn),
|
||||
},
|
||||
}
|
||||
|
||||
htmxRoutes := []hws.Route{
|
||||
@@ -80,6 +90,16 @@ func addRoutes(
|
||||
Method: hws.MethodPOST,
|
||||
Handler: handlers.IsUsernameUnique(s, conn, cfg, store),
|
||||
},
|
||||
{
|
||||
Path: "/htmx/isseasonnameunique",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: handlers.IsSeasonNameUnique(s, conn),
|
||||
},
|
||||
{
|
||||
Path: "/htmx/isseasonshortnameunique",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: handlers.IsSeasonShortNameUnique(s, conn),
|
||||
},
|
||||
}
|
||||
|
||||
wsRoutes := []hws.Route{
|
||||
|
||||
@@ -29,7 +29,6 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "setupDBConn")
|
||||
}
|
||||
defer closedb()
|
||||
|
||||
// Setup embedded files
|
||||
logger.Debug().Msg("Getting embedded files")
|
||||
@@ -50,7 +49,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Setting up HTTP server")
|
||||
httpServer, err := setupHttpServer(&staticFS, cfg, logger, bun, store, discordAPI)
|
||||
httpServer, err := setupHTTPServer(&staticFS, cfg, logger, bun, store, discordAPI)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "setupHttpServer")
|
||||
}
|
||||
@@ -72,7 +71,11 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
|
||||
logger.Info().Msg("Shut down requested, waiting 60 seconds...")
|
||||
err := httpServer.Shutdown(shutdownCtx)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Graceful shutdown failed")
|
||||
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Error during HTTP server shutdown")
|
||||
}
|
||||
err = closedb()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "closedb"))).Msg("Error during database close")
|
||||
}
|
||||
})
|
||||
wg.Wait()
|
||||
|
||||
4
go.sum
4
go.sum
@@ -6,12 +6,8 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI
|
||||
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
|
||||
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
|
||||
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
||||
git.haelnorr.com/h/golib/hws v0.4.3 h1:rpqe0Dcbm3b5XZ/Bfy0LUhph6RR7+bmANrSA/W81l0A=
|
||||
git.haelnorr.com/h/golib/hws v0.4.3/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo=
|
||||
git.haelnorr.com/h/golib/hws v0.4.4 h1:tV9UjZ4q96UlOdJKsC7b3kDV+bpQYqKVPQuaV1n3U3k=
|
||||
git.haelnorr.com/h/golib/hws v0.4.4/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.5.3 h1:Vgw8khDQZJRCc3m7z9QlbL9CYPyFB9JXUC3+omKRZPc=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.5.3/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.5.4 h1:nuaiVpJHHXgKVRPoQSE/v3CJHSkivViK5h3SVhEcbbM=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.5.4/go.mod h1:eIjRPeGycvxRWERkxCoRVMEEhHuUdiPDvjpzzZOhQ0w=
|
||||
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
||||
|
||||
2
internal/db/doc.go
Normal file
2
internal/db/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package db is an internal package for all the database models and related methods
|
||||
package db
|
||||
@@ -3,6 +3,8 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
@@ -11,9 +13,13 @@ import (
|
||||
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"`
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
Name string `bun:"name,unique"`
|
||||
ShortName string `bun:"short_name,unique"`
|
||||
StartDate time.Time `bun:"start_date,notnull"`
|
||||
EndDate bun.NullTime `bun:"end_date"`
|
||||
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
|
||||
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
|
||||
}
|
||||
|
||||
type SeasonList struct {
|
||||
@@ -22,7 +28,7 @@ type SeasonList struct {
|
||||
PageOpts PageOpts
|
||||
}
|
||||
|
||||
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) {
|
||||
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("name cannot be empty")
|
||||
}
|
||||
@@ -31,7 +37,8 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season,
|
||||
}
|
||||
season := &Season{
|
||||
Name: name,
|
||||
ShortName: shortname,
|
||||
ShortName: strings.ToUpper(shortname),
|
||||
StartDate: start.Truncate(time.Hour * 24),
|
||||
}
|
||||
_, err := tx.NewInsert().
|
||||
Model(season).
|
||||
@@ -81,3 +88,38 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis
|
||||
}
|
||||
return sl, nil
|
||||
}
|
||||
|
||||
func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) {
|
||||
var season *Season
|
||||
err := tx.NewSelect().
|
||||
Model(season).
|
||||
Where("short_name = ?", strings.ToUpper(shortname)).
|
||||
Limit(1).
|
||||
Scan(ctx)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return season, nil
|
||||
}
|
||||
|
||||
func IsSeasonNameUnique(ctx context.Context, tx bun.Tx, name string) (bool, error) {
|
||||
count, err := tx.NewSelect().
|
||||
Model((*Season)(nil)).
|
||||
Where("name = ?", name).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
func IsSeasonShortNameUnique(ctx context.Context, tx bun.Tx, shortname string) (bool, error) {
|
||||
count, err := tx.NewSelect().
|
||||
Model((*Season)(nil)).
|
||||
Where("short_name = ?", shortname).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
@@ -26,20 +26,6 @@ func (user *User) GetID() int {
|
||||
return user.ID
|
||||
}
|
||||
|
||||
// Change the user's username
|
||||
func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername string) error {
|
||||
_, err := tx.NewUpdate().
|
||||
Model(user).
|
||||
Set("username = ?", newUsername).
|
||||
Where("id = ?", user.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewUpdate")
|
||||
}
|
||||
user.Username = newUsername
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user with the given username and password
|
||||
func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User) (*User, error) {
|
||||
if discorduser == nil {
|
||||
|
||||
2
internal/handlers/doc.go
Normal file
2
internal/handlers/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package handlers contains all the functions for handling http requests and serving content
|
||||
package handlers
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"github.com/a-h/templ"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -166,3 +167,10 @@ func parseErrorDetails(details string) (int, string) {
|
||||
|
||||
return errDetails.Code, errDetails.Stacktrace
|
||||
}
|
||||
|
||||
func renderSafely(page templ.Component, s *hws.Server, r *http.Request, w http.ResponseWriter) {
|
||||
err := page.Render(r.Context(), w)
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Failed to render page", errors.Wrap(err, "page."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ import (
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
)
|
||||
|
||||
// Handles responses to the / path. Also serves a 404 Page for paths that
|
||||
// Index handles responses to the / path. Also serves a 404 Page for paths that
|
||||
// don't have explicit handlers
|
||||
func Index(server *hws.Server) http.Handler {
|
||||
func Index(s *hws.Server) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
throwNotFound(server, w, r, r.URL.Path)
|
||||
throwNotFound(s, w, r, r.URL.Path)
|
||||
}
|
||||
page.Index().Render(r.Context(), w)
|
||||
renderSafely(page.Index(), s, r, w)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
105
internal/handlers/newseason.go
Normal file
105
internal/handlers/newseason.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"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 NewSeason(
|
||||
s *hws.Server,
|
||||
conn *bun.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
renderSafely(page.NewSeason(), s, r, w)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func NewSeasonSubmit(
|
||||
s *hws.Server,
|
||||
conn *bun.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func IsSeasonNameUnique(
|
||||
s *hws.Server,
|
||||
conn *bun.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Error notifying client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
unique, err := db.IsSeasonNameUnique(ctx, tx, name)
|
||||
if err != nil {
|
||||
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique"))
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Error notifying client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !unique {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func IsSeasonShortNameUnique(
|
||||
s *hws.Server,
|
||||
conn *bun.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Error notifying client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
shortname := r.FormValue("short_name")
|
||||
unique, err := db.IsSeasonShortNameUnique(ctx, tx, shortname)
|
||||
if err != nil {
|
||||
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique"))
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Error notifying client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !unique {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func Register(
|
||||
method := r.Method
|
||||
if method == "GET" {
|
||||
tx.Commit()
|
||||
page.Register(details.DiscordUser.Username).Render(r.Context(), w)
|
||||
renderSafely(page.Register(details.DiscordUser.Username), s, r, w)
|
||||
return
|
||||
}
|
||||
if method == "POST" {
|
||||
|
||||
41
internal/handlers/season.go
Normal file
41
internal/handlers/season.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"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 SeasonPage(
|
||||
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()
|
||||
seasonStr := r.PathValue("season_short_name")
|
||||
season, err := db.GetSeason(ctx, tx, seasonStr)
|
||||
if err != nil {
|
||||
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetSeason"))
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
if season == nil {
|
||||
throwNotFound(s, w, r, r.URL.Path)
|
||||
return
|
||||
}
|
||||
renderSafely(page.SeasonPage(season), s, r, w)
|
||||
})
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func SeasonsPage(
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
page.SeasonsPage(seasons).Render(r.Context(), w)
|
||||
renderSafely(page.SeasonsList(seasons), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,6 +123,6 @@ func SeasonsList(
|
||||
tx.Commit()
|
||||
|
||||
// Return only the list component (hx-push-url handles URL update client-side)
|
||||
page.SeasonsList(seasons).Render(r.Context(), w)
|
||||
renderSafely(page.SeasonsList(seasons), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
)
|
||||
|
||||
// Handles requests for static files, without allowing access to the
|
||||
// StaticFS handles requests for static files, without allowing access to the
|
||||
// directory viewer and returning 404 if an exact file is not found
|
||||
func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler {
|
||||
// Create the file server once, not on every request
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"git.haelnorr.com/h/oslstats/internal/view/page"
|
||||
)
|
||||
|
||||
// Handles responses to the / path. Also serves a 404 Page for paths that
|
||||
// NotifyTester handles responses to the / path. Also serves a 404 Page for paths that
|
||||
// don't have explicit handlers
|
||||
func NotifyTester(s *hws.Server) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
@@ -22,9 +22,9 @@ func NotifyTester(s *hws.Server) http.Handler {
|
||||
// Error: testErr,
|
||||
// })
|
||||
// page.Render(r.Context(), w)
|
||||
page.Test().Render(r.Context(), w)
|
||||
renderSafely(page.Test(), s, r, w)
|
||||
} else {
|
||||
r.ParseForm()
|
||||
_ = r.ParseForm()
|
||||
// target := r.Form.Get("target")
|
||||
title := r.Form.Get("title")
|
||||
level := r.Form.Get("type")
|
||||
|
||||
4
internal/view/component/form/new_season.templ
Normal file
4
internal/view/component/form/new_season.templ
Normal file
@@ -0,0 +1,4 @@
|
||||
package form
|
||||
|
||||
templ NewSeason() {
|
||||
}
|
||||
9
internal/view/page/new_season.templ
Normal file
9
internal/view/page/new_season.templ
Normal file
@@ -0,0 +1,9 @@
|
||||
package page
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/form"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||
|
||||
templ NewSeason() {
|
||||
@layout.Global("New Season")
|
||||
@form.NewSeason()
|
||||
}
|
||||
202
internal/view/page/season.templ
Normal file
202
internal/view/page/season.templ
Normal file
@@ -0,0 +1,202 @@
|
||||
package page
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||
import "time"
|
||||
import "strconv"
|
||||
|
||||
templ SeasonPage(season *db.Season) {
|
||||
@layout.Global(season.Name) {
|
||||
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||
@SeasonDetails(season)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ SeasonDetails(season *db.Season) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Header Section -->
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name }</h1>
|
||||
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
|
||||
{ season.ShortName }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href={ templ.SafeURL("/seasons/" + season.ShortName + "/edit") }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-blue hover:bg-blue/75 text-mantle transition"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
<a
|
||||
href="/seasons"
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface1 hover:bg-surface2 text-text transition"
|
||||
>
|
||||
Back to Seasons
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Information Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
|
||||
<!-- Regular Season Section -->
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
|
||||
<span class="text-blue">●</span>
|
||||
Regular Season
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-subtext0">Start Date:</span>
|
||||
<span class="text-text font-semibold">
|
||||
{ formatDate(season.StartDate) }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-subtext0">End Date:</span>
|
||||
<span class="text-text font-semibold">
|
||||
if !season.EndDate.IsZero() {
|
||||
{ formatDate(season.EndDate.Time) }
|
||||
} else {
|
||||
<span class="text-subtext1 italic">Not set</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-subtext0">Duration:</span>
|
||||
<span class="text-text font-semibold">
|
||||
if !season.EndDate.IsZero() {
|
||||
{ formatDuration(season.StartDate, season.EndDate.Time) }
|
||||
} else {
|
||||
<span class="text-subtext1 italic">Ongoing</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Finals Section -->
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
|
||||
<span class="text-yellow">★</span>
|
||||
Finals
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-subtext0">Start Date:</span>
|
||||
<span class="text-text font-semibold">
|
||||
if !season.FinalsStartDate.IsZero() {
|
||||
{ formatDate(season.FinalsStartDate.Time) }
|
||||
} else {
|
||||
<span class="text-subtext1 italic">Not set</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-subtext0">End Date:</span>
|
||||
<span class="text-text font-semibold">
|
||||
if !season.FinalsEndDate.IsZero() {
|
||||
{ formatDate(season.FinalsEndDate.Time) }
|
||||
} else {
|
||||
<span class="text-subtext1 italic">Not set</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-subtext0">Duration:</span>
|
||||
<span class="text-text font-semibold">
|
||||
if !season.FinalsStartDate.IsZero() && !season.FinalsEndDate.IsZero() {
|
||||
{ formatDuration(season.FinalsStartDate.Time, season.FinalsEndDate.Time) }
|
||||
} else {
|
||||
<span class="text-subtext1 italic">Not scheduled</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status Section -->
|
||||
<div class="px-6 pb-6">
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-text mb-4">Status</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
@SeasonStatus(season)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ SeasonStatus(season *db.Season) {
|
||||
{{
|
||||
now := time.Now()
|
||||
status := ""
|
||||
statusColor := ""
|
||||
statusBg := ""
|
||||
|
||||
if now.Before(season.StartDate) {
|
||||
status = "Upcoming"
|
||||
statusColor = "text-blue"
|
||||
statusBg = "bg-blue/10 border-blue"
|
||||
} else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
|
||||
status = "Completed"
|
||||
statusColor = "text-green"
|
||||
statusBg = "bg-green/10 border-green"
|
||||
} else if !season.FinalsStartDate.IsZero() && now.After(season.FinalsStartDate.Time) {
|
||||
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
|
||||
status = "Completed"
|
||||
statusColor = "text-green"
|
||||
statusBg = "bg-green/10 border-green"
|
||||
} else {
|
||||
status = "Finals in Progress"
|
||||
statusColor = "text-yellow"
|
||||
statusBg = "bg-yellow/10 border-yellow"
|
||||
}
|
||||
} else {
|
||||
status = "In Progress"
|
||||
statusColor = "text-green"
|
||||
statusBg = "bg-green/10 border-green"
|
||||
}
|
||||
}}
|
||||
<div class={ "px-4 py-2 rounded-lg border-2 inline-flex items-center gap-2 " + statusBg }>
|
||||
<span class={ "text-2xl " + statusColor }>●</span>
|
||||
<span class={ "text-lg font-semibold " + statusColor }>{ status }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
func formatDate(t time.Time) string {
|
||||
return t.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func formatDuration(start, end time.Time) string {
|
||||
days := int(end.Sub(start).Hours() / 24)
|
||||
if days == 0 {
|
||||
return "Same day"
|
||||
} else if days == 1 {
|
||||
return "1 day"
|
||||
} else if days < 7 {
|
||||
return strconv.Itoa(days) + " days"
|
||||
} else if days < 30 {
|
||||
weeks := days / 7
|
||||
if weeks == 1 {
|
||||
return "1 week"
|
||||
}
|
||||
return strconv.Itoa(weeks) + " weeks"
|
||||
} else if days < 365 {
|
||||
months := days / 30
|
||||
if months == 1 {
|
||||
return "1 month"
|
||||
}
|
||||
return strconv.Itoa(months) + " months"
|
||||
} else {
|
||||
years := days / 365
|
||||
if years == 1 {
|
||||
return "1 year"
|
||||
}
|
||||
return strconv.Itoa(years) + " years"
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,15 @@ templ SeasonsList(seasons *db.SeasonList) {
|
||||
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>
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6 px-4">
|
||||
<div class="flex gap-4 items-center">
|
||||
<span class="text-3xl font-bold">Seasons</span>
|
||||
<a
|
||||
href="/seasons/new"
|
||||
class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm
|
||||
bg-green hover:bg-green/75 text-mantle transition"
|
||||
>Add season</a>
|
||||
</div>
|
||||
@sort.Dropdown(seasons.PageOpts, sortOpts)
|
||||
</div>
|
||||
<!-- Results section -->
|
||||
|
||||
@@ -310,6 +310,9 @@
|
||||
.mb-2 {
|
||||
margin-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
@@ -343,6 +346,9 @@
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.size-5 {
|
||||
width: calc(var(--spacing) * 5);
|
||||
height: calc(var(--spacing) * 5);
|
||||
@@ -477,6 +483,13 @@
|
||||
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-3 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.gap-x-2 {
|
||||
column-gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -529,6 +542,10 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
}
|
||||
.border-b {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.border-blue {
|
||||
border-color: var(--blue);
|
||||
}
|
||||
@@ -556,6 +573,12 @@
|
||||
.bg-blue {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
.bg-blue\/10 {
|
||||
background-color: var(--blue);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--blue) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-crust {
|
||||
background-color: var(--crust);
|
||||
}
|
||||
@@ -577,6 +600,12 @@
|
||||
.bg-green {
|
||||
background-color: var(--green);
|
||||
}
|
||||
.bg-green\/10 {
|
||||
background-color: var(--green);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--green) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-mantle {
|
||||
background-color: var(--mantle);
|
||||
}
|
||||
@@ -589,12 +618,21 @@
|
||||
.bg-surface0 {
|
||||
background-color: var(--surface0);
|
||||
}
|
||||
.bg-surface1 {
|
||||
background-color: var(--surface1);
|
||||
}
|
||||
.bg-teal {
|
||||
background-color: var(--teal);
|
||||
}
|
||||
.bg-yellow {
|
||||
background-color: var(--yellow);
|
||||
}
|
||||
.bg-yellow\/10 {
|
||||
background-color: var(--yellow);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--yellow) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.p-2 {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -742,6 +780,9 @@
|
||||
.text-yellow {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.opacity-50 {
|
||||
opacity: 50%;
|
||||
}
|
||||
@@ -789,6 +830,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-blue\/75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--blue);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--blue) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-crust {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
|
||||
Reference in New Issue
Block a user