From 2a3f4e486134745a670faee0cd45996da10280c4 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Tue, 10 Feb 2026 23:32:48 +1100 Subject: [PATCH] added leagues --- cmd/oslstats/db.go | 2 + .../migrations/20260210182212_add_leagues.go | 67 +++ cmd/oslstats/routes.go | 35 ++ internal/db/league.go | 37 ++ internal/db/season.go | 19 +- internal/embedfs/web/css/output.css | 383 ++++++++++++++++++ internal/handlers/leagues_list.go | 34 ++ internal/handlers/leagues_new.go | 96 +++++ internal/handlers/notifswebsocket.go | 4 + internal/handlers/season_edit.go | 21 +- internal/handlers/season_leagues.go | 134 ++++++ internal/handlers/seasons_list.go | 2 + internal/handlers/seasons_new.go | 10 +- internal/permissions/constants.go | 13 +- internal/view/baseview/layout.templ | 1 + internal/view/baseview/navbar.templ | 1 + internal/view/datepicker/datepicker.templ | 113 +++++- internal/view/leaguesview/list_page.templ | 73 ++++ internal/view/leaguesview/new_form.templ | 174 ++++++++ internal/view/leaguesview/new_page.templ | 23 ++ internal/view/popup/confirm_modal.templ | 94 +++++ internal/view/seasonsview/detail_page.templ | 44 +- internal/view/seasonsview/edit_form.templ | 21 +- internal/view/seasonsview/edit_page.templ | 4 +- .../view/seasonsview/leagues_section.templ | 88 ++++ internal/view/seasonsview/list_page.templ | 41 +- internal/view/seasonsview/new_form.templ | 35 +- internal/view/seasonsview/status_badge.templ | 64 ++- 28 files changed, 1544 insertions(+), 89 deletions(-) create mode 100644 cmd/oslstats/migrations/20260210182212_add_leagues.go create mode 100644 internal/db/league.go create mode 100644 internal/handlers/leagues_list.go create mode 100644 internal/handlers/leagues_new.go create mode 100644 internal/handlers/season_leagues.go create mode 100644 internal/view/leaguesview/list_page.templ create mode 100644 internal/view/leaguesview/new_form.templ create mode 100644 internal/view/leaguesview/new_page.templ create mode 100644 internal/view/popup/confirm_modal.templ create mode 100644 internal/view/seasonsview/leagues_section.templ diff --git a/cmd/oslstats/db.go b/cmd/oslstats/db.go index 96e075a..f952cf0 100644 --- a/cmd/oslstats/db.go +++ b/cmd/oslstats/db.go @@ -32,9 +32,11 @@ func registerDBModels(conn *bun.DB) []any { models := []any{ (*db.RolePermission)(nil), (*db.UserRole)(nil), + (*db.SeasonLeague)(nil), (*db.User)(nil), (*db.DiscordToken)(nil), (*db.Season)(nil), + (*db.League)(nil), (*db.Role)(nil), (*db.Permission)(nil), (*db.AuditLog)(nil), diff --git a/cmd/oslstats/migrations/20260210182212_add_leagues.go b/cmd/oslstats/migrations/20260210182212_add_leagues.go new file mode 100644 index 0000000..ec21071 --- /dev/null +++ b/cmd/oslstats/migrations/20260210182212_add_leagues.go @@ -0,0 +1,67 @@ +package migrations + +import ( + "context" + + "github.com/uptrace/bun" + + "git.haelnorr.com/h/oslstats/internal/db" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, dbConn *bun.DB) error { + // Add slap_version column to seasons table + _, err := dbConn.NewAddColumn(). + Model((*db.Season)(nil)). + ColumnExpr("slap_version VARCHAR NOT NULL DEFAULT 'rebound'"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create leagues table + _, err = dbConn.NewCreateTable(). + Model((*db.League)(nil)). + Exec(ctx) + if err != nil { + return err + } + + // Create season_leagues join table + _, err = dbConn.NewCreateTable(). + Model((*db.SeasonLeague)(nil)). + Exec(ctx) + return err + }, + // DOWN migration + func(ctx context.Context, dbConn *bun.DB) error { + // Drop season_leagues join table first + _, err := dbConn.NewDropTable(). + Model((*db.SeasonLeague)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + + // Drop leagues table + _, err = dbConn.NewDropTable(). + Model((*db.League)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + + // Remove slap_version column from seasons table + _, err = dbConn.NewDropColumn(). + Model((*db.Season)(nil)). + ColumnExpr("slap_version"). + Exec(ctx) + return err + }, + ) +} diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 354a882..c9dbbdb 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -106,6 +106,31 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditSubmit(s, conn, audit)), }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.SeasonsAddLeague)(handlers.SeasonAddLeague(s, conn, audit)), + }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}", + Method: hws.MethodDELETE, + Handler: perms.RequirePermission(s, permissions.SeasonsRemoveLeague)(handlers.SeasonRemoveLeague(s, conn, audit)), + }, + { + Path: "/leagues", + Method: hws.MethodGET, + Handler: handlers.LeaguesList(s, conn), + }, + { + Path: "/leagues/new", + Method: hws.MethodGET, + Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeague(s, conn)), + }, + { + Path: "/leagues/new", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeagueSubmit(s, conn, audit)), + }, } htmxRoutes := []hws.Route{ @@ -124,6 +149,16 @@ func addRoutes( Method: hws.MethodPOST, Handler: handlers.IsUnique(s, conn, (*db.Season)(nil), "short_name"), }, + { + Path: "/htmx/isleaguenameunique", + Method: hws.MethodPOST, + Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "name"), + }, + { + Path: "/htmx/isleagueshortnameunique", + Method: hws.MethodPOST, + Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "short_name"), + }, } wsRoutes := []hws.Route{ diff --git a/internal/db/league.go b/internal/db/league.go new file mode 100644 index 0000000..9737354 --- /dev/null +++ b/internal/db/league.go @@ -0,0 +1,37 @@ +package db + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type League struct { + bun.BaseModel `bun:"table:leagues,alias:l"` + + ID int `bun:"id,pk,autoincrement"` + Name string `bun:"name,unique,notnull"` + ShortName string `bun:"short_name,unique,notnull"` + Description string `bun:"description"` + + Seasons []Season `bun:"m2m:season_leagues,join:League=Season"` +} + +type SeasonLeague struct { + SeasonID int `bun:",pk"` + Season *Season `bun:"rel:belongs-to,join:season_id=id"` + LeagueID int `bun:",pk"` + League *League `bun:"rel:belongs-to,join:league_id=id"` +} + +func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) { + return GetList[League](tx).Relation("Seasons").GetAll(ctx) +} + +func GetLeague(ctx context.Context, tx bun.Tx, shortname string) (*League, error) { + if shortname == "" { + return nil, errors.New("shortname cannot be empty") + } + return GetByField[League](tx, "short_name", shortname).Relation("Seasons").Get(ctx) +} diff --git a/internal/db/season.go b/internal/db/season.go index ae3d6e9..9f8e364 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -19,14 +19,18 @@ type Season struct { EndDate bun.NullTime `bun:"end_date"` FinalsStartDate bun.NullTime `bun:"finals_start_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date"` + SlapVersion string `bun:"slap_version,notnull,default:'rebound'"` + + Leagues []League `bun:"m2m:season_leagues,join:Season=League"` } // NewSeason returns a new season. It does not add it to the database -func NewSeason(name, shortname string, start time.Time) *Season { +func NewSeason(name, version, shortname string, start time.Time) *Season { season := &Season{ - Name: name, - ShortName: strings.ToUpper(shortname), - StartDate: start.Truncate(time.Hour * 24), + Name: name, + ShortName: strings.ToUpper(shortname), + StartDate: start.Truncate(time.Hour * 24), + SlapVersion: version, } return season } @@ -38,18 +42,19 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Seas bun.OrderDesc, "start_date", } - return GetList[Season](tx).GetPaged(ctx, pageOpts, defaults) + return GetList[Season](tx).Relation("Leagues").GetPaged(ctx, pageOpts, defaults) } func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) { if shortname == "" { return nil, errors.New("short_name not provided") } - return GetByField[Season](tx, "short_name", shortname).Get(ctx) + return GetByField[Season](tx, "short_name", shortname).Relation("Leagues").Get(ctx) } // Update updates the season struct. It does not insert to the database -func (s *Season) Update(start, end, finalsStart, finalsEnd time.Time) { +func (s *Season) Update(version string, start, end, finalsStart, finalsEnd time.Time) { + s.SlapVersion = version s.StartDate = start.Truncate(time.Hour * 24) if !end.IsZero() { s.EndDate.Time = end.Truncate(time.Hour * 24) diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index f086ec1..fd20c43 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -12,9 +12,12 @@ --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; + --container-xs: 20rem; --container-sm: 24rem; --container-md: 28rem; + --container-lg: 32rem; --container-2xl: 42rem; + --container-5xl: 64rem; --container-7xl: 80rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); @@ -41,10 +44,42 @@ --leading-relaxed: 1.625; --radius-lg: 0.5rem; --radius-xl: 0.75rem; + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); + --color-rosewater: var(--rosewater); + --color-flamingo: var(--flamingo); + --color-pink: var(--pink); + --color-mauve: var(--mauve); + --color-red: var(--red); + --color-dark-red: var(--dark-red); + --color-maroon: var(--maroon); + --color-peach: var(--peach); + --color-yellow: var(--yellow); + --color-dark-yellow: var(--dark-yellow); + --color-green: var(--green); + --color-dark-green: var(--dark-green); + --color-teal: var(--teal); + --color-sky: var(--sky); + --color-sapphire: var(--sapphire); + --color-blue: var(--blue); + --color-dark-blue: var(--dark-blue); + --color-lavender: var(--lavender); + --color-text: var(--text); + --color-subtext1: var(--subtext1); + --color-subtext0: var(--subtext0); + --color-overlay2: var(--overlay2); + --color-overlay1: var(--overlay1); + --color-overlay0: var(--overlay0); + --color-surface2: var(--surface2); + --color-surface1: var(--surface1); + --color-surface0: var(--surface0); + --color-base: var(--base); + --color-mantle: var(--mantle); + --color-crust: var(--crust); } } @layer base { @@ -243,6 +278,9 @@ .top-0 { top: calc(var(--spacing) * 0); } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1/2 * 100%); } @@ -267,6 +305,12 @@ .left-0 { left: calc(var(--spacing) * 0); } + .z-4 { + z-index: 4; + } + .z-7 { + z-index: 7; + } .z-10 { z-index: 10; } @@ -276,6 +320,24 @@ .z-50 { z-index: 50; } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } .mx-auto { margin-inline: auto; } @@ -291,6 +353,9 @@ .mt-2 { margin-top: calc(var(--spacing) * 2); } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } .mt-4 { margin-top: calc(var(--spacing) * 4); } @@ -318,6 +383,12 @@ .mt-25 { margin-top: calc(var(--spacing) * 25); } + .mt-auto { + margin-top: auto; + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } @@ -336,9 +407,18 @@ .mb-auto { margin-bottom: auto; } + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } .ml-2 { margin-left: calc(var(--spacing) * 2); } + .line-clamp-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } .block { display: block; } @@ -370,9 +450,21 @@ .h-1 { height: calc(var(--spacing) * 1); } + .h-2 { + height: calc(var(--spacing) * 2); + } + .h-4 { + height: calc(var(--spacing) * 4); + } .h-5 { height: calc(var(--spacing) * 5); } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-12 { + height: calc(var(--spacing) * 12); + } .h-16 { height: calc(var(--spacing) * 16); } @@ -391,9 +483,21 @@ .min-h-\[calc\(100vh-200px\)\] { min-height: calc(100vh - 200px); } + .min-h-full { + min-height: 100%; + } + .w-4 { + width: calc(var(--spacing) * 4); + } .w-5 { width: calc(var(--spacing) * 5); } + .w-6 { + width: calc(var(--spacing) * 6); + } + .w-12 { + width: calc(var(--spacing) * 12); + } .w-26 { width: calc(var(--spacing) * 26); } @@ -412,6 +516,9 @@ .max-w-2xl { max-width: var(--container-2xl); } + .max-w-5xl { + max-width: var(--container-5xl); + } .max-w-7xl { max-width: var(--container-7xl); } @@ -439,19 +546,49 @@ .max-w-sm { max-width: var(--container-sm); } + .max-w-xs { + max-width: var(--container-xs); + } .min-w-0 { min-width: calc(var(--spacing) * 0); } .flex-1 { flex: 1; } + .flex-shrink { + flex-shrink: 1; + } + .flex-shrink-0 { + flex-shrink: 0; + } .shrink-0 { flex-shrink: 0; } + .flex-grow { + flex-grow: 1; + } + .grow { + flex-grow: 1; + } + .border-collapse { + border-collapse: collapse; + } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } + .translate-y-0 { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-y-4 { + --tw-translate-y: calc(var(--spacing) * 4); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } @@ -464,6 +601,12 @@ .resize { resize: both; } + .resize-none { + resize: none; + } + .appearance-none { + appearance: none; + } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -556,6 +699,11 @@ border-color: var(--surface2); } } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .overflow-hidden { overflow: hidden; } @@ -620,6 +768,12 @@ .bg-base { background-color: var(--base); } + .bg-base\/75 { + background-color: var(--base); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--base) 75%, transparent); + } + } .bg-blue { background-color: var(--blue); } @@ -662,6 +816,12 @@ .bg-mauve { background-color: var(--mauve); } + .bg-peach { + background-color: var(--peach); + } + .bg-red { + background-color: var(--red); + } .bg-red\/10 { background-color: var(--red); @supports (color: color-mix(in lab, red, red)) { @@ -689,6 +849,9 @@ background-color: color-mix(in oklab, var(--yellow) 10%, transparent); } } + .bg-no-repeat { + background-repeat: no-repeat; + } .p-1 { padding: calc(var(--spacing) * 1); } @@ -722,6 +885,12 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .py-0 { + padding-block: calc(var(--spacing) * 0); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } .py-1 { padding-block: calc(var(--spacing) * 1); } @@ -737,12 +906,27 @@ .py-8 { padding-block: calc(var(--spacing) * 8); } + .pt-5 { + padding-top: calc(var(--spacing) * 5); + } + .pr-2 { + padding-right: calc(var(--spacing) * 2); + } + .pr-8 { + padding-right: calc(var(--spacing) * 8); + } .pr-10 { padding-right: calc(var(--spacing) * 10); } + .pb-4 { + padding-bottom: calc(var(--spacing) * 4); + } .pb-6 { padding-bottom: calc(var(--spacing) * 6); } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } .text-center { text-align: center; } @@ -787,6 +971,10 @@ font-size: var(--text-xs); line-height: var(--tw-leading, var(--text-xs--line-height)); } + .leading-6 { + --tw-leading: calc(var(--spacing) * 6); + line-height: calc(var(--spacing) * 6); + } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); @@ -858,9 +1046,18 @@ .italic { font-style: italic; } + .underline { + text-decoration-line: underline; + } + .opacity-0 { + opacity: 0%; + } .opacity-50 { opacity: 50%; } + .opacity-100 { + opacity: 100%; + } .shadow-lg { --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -873,6 +1070,10 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -881,11 +1082,37 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-opacity { + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } + .ease-in { + --tw-ease: var(--ease-in); + transition-timing-function: var(--ease-in); + } + .ease-out { + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + } .outline-none { --tw-outline-style: none; outline-style: none; @@ -955,6 +1182,16 @@ } } } + .hover\:bg-red\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 75%, transparent); + } + } + } + } .hover\:bg-sapphire\/75 { &:hover { @media (hover: hover) { @@ -1013,6 +1250,16 @@ } } } + .hover\:text-red\/75 { + &:hover { + @media (hover: hover) { + color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--red) 75%, transparent); + } + } + } + } .hover\:text-subtext1 { &:hover { @media (hover: hover) { @@ -1100,6 +1347,21 @@ inset-inline-end: calc(var(--spacing) * 6); } } + .sm\:mx-0 { + @media (width >= 40rem) { + margin-inline: calc(var(--spacing) * 0); + } + } + .sm\:mt-0 { + @media (width >= 40rem) { + margin-top: calc(var(--spacing) * 0); + } + } + .sm\:ml-4 { + @media (width >= 40rem) { + margin-left: calc(var(--spacing) * 4); + } + } .sm\:block { @media (width >= 40rem) { display: block; @@ -1120,16 +1382,78 @@ display: inline; } } + .sm\:h-10 { + @media (width >= 40rem) { + height: calc(var(--spacing) * 10); + } + } + .sm\:w-10 { + @media (width >= 40rem) { + width: calc(var(--spacing) * 10); + } + } + .sm\:w-auto { + @media (width >= 40rem) { + width: auto; + } + } + .sm\:w-full { + @media (width >= 40rem) { + width: 100%; + } + } + .sm\:max-w-lg { + @media (width >= 40rem) { + max-width: var(--container-lg); + } + } + .sm\:translate-y-0 { + @media (width >= 40rem) { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .sm\:scale-95 { + @media (width >= 40rem) { + --tw-scale-x: 95%; + --tw-scale-y: 95%; + --tw-scale-z: 95%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + } + .sm\:scale-100 { + @media (width >= 40rem) { + --tw-scale-x: 100%; + --tw-scale-y: 100%; + --tw-scale-z: 100%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + } + .sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } .sm\:flex-row { @media (width >= 40rem) { flex-direction: row; } } + .sm\:flex-row-reverse { + @media (width >= 40rem) { + flex-direction: row-reverse; + } + } .sm\:items-center { @media (width >= 40rem) { align-items: center; } } + .sm\:items-start { + @media (width >= 40rem) { + align-items: flex-start; + } + } .sm\:justify-between { @media (width >= 40rem) { justify-content: space-between; @@ -1140,6 +1464,11 @@ gap: calc(var(--spacing) * 2); } } + .sm\:p-6 { + @media (width >= 40rem) { + padding: calc(var(--spacing) * 6); + } + } .sm\:p-7 { @media (width >= 40rem) { padding: calc(var(--spacing) * 7); @@ -1155,6 +1484,16 @@ padding-inline: calc(var(--spacing) * 6); } } + .sm\:pb-4 { + @media (width >= 40rem) { + padding-bottom: calc(var(--spacing) * 4); + } + } + .sm\:text-left { + @media (width >= 40rem) { + text-align: left; + } + } .sm\:text-4xl { @media (width >= 40rem) { font-size: var(--text-4xl); @@ -1166,6 +1505,11 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + .md\:grid-cols-3 { + @media (width >= 48rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } .md\:gap-8 { @media (width >= 48rem) { gap: calc(var(--spacing) * 8); @@ -1206,6 +1550,11 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .lg\:grid-cols-4 { + @media (width >= 64rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; @@ -1457,6 +1806,11 @@ inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -1510,6 +1864,29 @@ syntax: "*"; inherits: false; } +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -1541,6 +1918,7 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -1554,6 +1932,11 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-duration: initial; + --tw-ease: initial; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; } } } diff --git a/internal/handlers/leagues_list.go b/internal/handlers/leagues_list.go new file mode 100644 index 0000000..489a238 --- /dev/null +++ b/internal/handlers/leagues_list.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "context" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "github.com/pkg/errors" + "github.com/uptrace/bun" + + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/view/leaguesview" +) + +func LeaguesList( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var leagues []*db.League + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + leagues, err = db.GetLeagues(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetLeagues") + } + return true, nil + }); !ok { + return + } + + renderSafely(leaguesview.ListPage(leagues), s, r, w) + }) +} diff --git a/internal/handlers/leagues_new.go b/internal/handlers/leagues_new.go new file mode 100644 index 0000000..967b515 --- /dev/null +++ b/internal/handlers/leagues_new.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "github.com/pkg/errors" + "github.com/uptrace/bun" + + "git.haelnorr.com/h/oslstats/internal/auditlog" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/validation" + leaguesview "git.haelnorr.com/h/oslstats/internal/view/leaguesview" +) + +func NewLeague( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + renderSafely(leaguesview.NewPage(), s, r, w) + return + } + }) +} + +func NewLeagueSubmit( + s *hws.Server, + conn *bun.DB, + audit *auditlog.Logger, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + name := getter.String("name"). + TrimSpace().Required(). + MaxLength(50).MinLength(3).Value + shortname := getter.String("short_name"). + TrimSpace().Required(). + MaxLength(10).MinLength(2).Value + description := getter.String("description"). + TrimSpace().MaxLength(500).Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + nameUnique := false + shortNameUnique := false + var league *db.League + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + nameUnique, err = db.IsUnique(ctx, tx, (*db.League)(nil), "name", name) + if err != nil { + return false, errors.Wrap(err, "db.IsLeagueNameUnique") + } + shortNameUnique, err = db.IsUnique(ctx, tx, (*db.League)(nil), "short_name", shortname) + if err != nil { + return false, errors.Wrap(err, "db.IsLeagueShortNameUnique") + } + if !nameUnique || !shortNameUnique { + return true, nil + } + league = &db.League{ + Name: name, + ShortName: shortname, + Description: description, + } + err = db.Insert(tx, league).WithAudit(r, audit.Callback()).Exec(ctx) + if err != nil { + return false, errors.Wrap(err, "db.Insert") + } + return true, nil + }); !ok { + return + } + + if !nameUnique { + notify.Warn(s, w, r, "Duplicate Name", "This league name is already taken.", nil) + return + } + + if !shortNameUnique { + notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil) + return + } + w.Header().Set("HX-Redirect", fmt.Sprintf("/leagues/%s", league.ShortName)) + w.WriteHeader(http.StatusOK) + notify.SuccessWithDelay(s, w, r, "League Created", fmt.Sprintf("Successfully created league: %s", name), nil) + }) +} diff --git a/internal/handlers/notifswebsocket.go b/internal/handlers/notifswebsocket.go index 34c8978..870a57f 100644 --- a/internal/handlers/notifswebsocket.go +++ b/internal/handlers/notifswebsocket.go @@ -93,6 +93,10 @@ func notifyLoop(ctx context.Context, c *hws.Client, ws *websocket.Conn) error { // Parse error code and stacktrace from Details field code, stacktrace := parseErrorDetails(nt.Details) err = popup.ErrorModalWS(code, stacktrace, nt, count).Render(ctx, w) + case notify.LevelInfo: + err = popup.Toast(nt, count, 6000).Render(ctx, w) + case notify.LevelSuccess: + err = popup.Toast(nt, count, 3000).Render(ctx, w) default: err = popup.Toast(nt, count, 6000).Render(ctx, w) } diff --git a/internal/handlers/season_edit.go b/internal/handlers/season_edit.go index 20be076..38c66ee 100644 --- a/internal/handlers/season_edit.go +++ b/internal/handlers/season_edit.go @@ -24,12 +24,17 @@ func SeasonEditPage( return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seasonStr := r.PathValue("season_short_name") var season *db.Season + var allLeagues []*db.League if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error season, err = db.GetSeason(ctx, tx, seasonStr) if err != nil { return false, errors.Wrap(err, "db.GetSeason") } + allLeagues, err = db.GetLeagues(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetLeagues") + } return true, nil }); !ok { return @@ -38,7 +43,7 @@ func SeasonEditPage( throw.NotFound(s, w, r, r.URL.Path) return } - renderSafely(seasonsview.EditPage(season), s, r, w) + renderSafely(seasonsview.EditPage(season, allLeagues), s, r, w) }) } @@ -60,10 +65,12 @@ func SeasonEditSubmit( MonthNumeric2().Slash(). Year4().Build() - startDate := getter.Time("start_date", format).Required().Value - endDate := getter.Time("end_date", format).Value - finalsStartDate := getter.Time("finals_start_date", format).Value - finalsEndDate := getter.Time("finals_end_date", format).Value + version := getter.String("slap_version"). + TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value + start := getter.Time("start_date", format).Required().Value + end := getter.Time("end_date", format).Value + finalsStart := getter.Time("finals_start_date", format).Value + finalsEnd := getter.Time("finals_end_date", format).Value if !getter.ValidateAndNotify(s, w, r) { return @@ -79,9 +86,9 @@ func SeasonEditSubmit( if season == nil { return false, errors.New("season does not exist") } - season.Update(startDate, endDate, finalsStartDate, finalsEndDate) + season.Update(version, start, end, finalsStart, finalsEnd) err = db.Update(tx, season).WherePK(). - Column("start_date", "end_date", "finals_start_date", "finals_end_date"). + Column("slap_version", "start_date", "end_date", "finals_start_date", "finals_end_date"). WithAudit(r, audit.Callback()).Exec(ctx) if err != nil { return false, errors.Wrap(err, "db.Update") diff --git a/internal/handlers/season_leagues.go b/internal/handlers/season_leagues.go new file mode 100644 index 0000000..0079e45 --- /dev/null +++ b/internal/handlers/season_leagues.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "context" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "github.com/pkg/errors" + "github.com/uptrace/bun" + + "git.haelnorr.com/h/oslstats/internal/auditlog" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" +) + +func SeasonAddLeague( + s *hws.Server, + conn *bun.DB, + audit *auditlog.Logger, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonStr := r.PathValue("season_short_name") + leagueStr := r.PathValue("league_short_name") + + var season *db.Season + var allLeagues []*db.League + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + season, err = db.GetSeason(ctx, tx, seasonStr) + if err != nil { + return false, errors.Wrap(err, "db.GetSeason") + } + if season == nil { + return false, errors.New("season not found") + } + + league, err := db.GetLeague(ctx, tx, leagueStr) + if err != nil { + return false, errors.Wrap(err, "db.GetLeague") + } + if league == nil { + return false, errors.New("league not found") + } + + // Create the many-to-many relationship + seasonLeague := &db.SeasonLeague{ + SeasonID: season.ID, + LeagueID: league.ID, + } + err = db.Insert(tx, seasonLeague).WithAudit(r, audit.Callback()).Exec(ctx) + if err != nil { + return false, errors.Wrap(err, "db.Insert") + } + + // Reload season with updated leagues + season, err = db.GetSeason(ctx, tx, seasonStr) + if err != nil { + return false, errors.Wrap(err, "db.GetSeason") + } + + allLeagues, err = db.GetLeagues(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetLeagues") + } + + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "League Added", "League successfully added to season", nil) + renderSafely(seasonsview.LeaguesSection(season, allLeagues), s, r, w) + }) +} + +func SeasonRemoveLeague( + s *hws.Server, + conn *bun.DB, + audit *auditlog.Logger, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonStr := r.PathValue("season_short_name") + leagueStr := r.PathValue("league_short_name") + + var season *db.Season + var allLeagues []*db.League + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + season, err = db.GetSeason(ctx, tx, seasonStr) + if err != nil { + return false, errors.Wrap(err, "db.GetSeason") + } + if season == nil { + return false, errors.New("season not found") + } + + league, err := db.GetLeague(ctx, tx, leagueStr) + if err != nil { + return false, errors.Wrap(err, "db.GetLeague") + } + if league == nil { + return false, errors.New("league not found") + } + + // Delete the many-to-many relationship + err = db.DeleteItem[db.SeasonLeague](tx). + Where("season_id = ? AND league_id = ?", season.ID, league.ID). + WithAudit(r, audit.Callback()). + Delete(ctx) + if err != nil { + return false, errors.Wrap(err, "db.DeleteItem") + } + + // Reload season with updated leagues + season, err = db.GetSeason(ctx, tx, seasonStr) + if err != nil { + return false, errors.Wrap(err, "db.GetSeason") + } + + allLeagues, err = db.GetLeagues(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetLeagues") + } + + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "League Removed", "League successfully removed from season", nil) + renderSafely(seasonsview.LeaguesSection(season, allLeagues), s, r, w) + }) +} diff --git a/internal/handlers/seasons_list.go b/internal/handlers/seasons_list.go index bedb0b4..fbf826d 100644 --- a/internal/handlers/seasons_list.go +++ b/internal/handlers/seasons_list.go @@ -11,6 +11,7 @@ import ( "github.com/uptrace/bun" ) +// SeasonsPage renders the full page with the seasons list, for use with GET requests func SeasonsPage( s *hws.Server, conn *bun.DB, @@ -35,6 +36,7 @@ func SeasonsPage( }) } +// SeasonsList renders just the seasons list, for use with POST requests and HTMX func SeasonsList( s *hws.Server, conn *bun.DB, diff --git a/internal/handlers/seasons_new.go b/internal/handlers/seasons_new.go index ed7eadb..e534ef1 100644 --- a/internal/handlers/seasons_new.go +++ b/internal/handlers/seasons_new.go @@ -41,14 +41,16 @@ func NewSeasonSubmit( name := getter.String("name"). TrimSpace().Required(). MaxLength(20).MinLength(5).Value - shortName := getter.String("short_name"). + shortname := getter.String("short_name"). TrimSpace().ToUpper().Required(). MaxLength(6).MinLength(2).Value + version := getter.String("slap_version"). + TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value format := timefmt.NewBuilder(). DayNumeric2().Slash(). MonthNumeric2().Slash(). Year4().Build() - startDate := getter.Time("start_date", format).Required().Value + start := getter.Time("start_date", format).Required().Value if !getter.ValidateAndNotify(s, w, r) { return } @@ -62,14 +64,14 @@ func NewSeasonSubmit( if err != nil { return false, errors.Wrap(err, "db.IsSeasonNameUnique") } - shortNameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "short_name", shortName) + shortNameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "short_name", shortname) if err != nil { return false, errors.Wrap(err, "db.IsSeasonShortNameUnique") } if !nameUnique || !shortNameUnique { return true, nil } - season = db.NewSeason(name, shortName, startDate) + season = db.NewSeason(name, version, shortname, start) err = db.Insert(tx, season).WithAudit(r, audit.Callback()).Exec(ctx) if err != nil { return false, errors.Wrap(err, "db.Insert") diff --git a/internal/permissions/constants.go b/internal/permissions/constants.go index 8c474a0..e649c21 100644 --- a/internal/permissions/constants.go +++ b/internal/permissions/constants.go @@ -12,9 +12,16 @@ const ( Wildcard Permission = "*" // Seasons permissions - SeasonsCreate Permission = "seasons.create" - SeasonsUpdate Permission = "seasons.update" - SeasonsDelete Permission = "seasons.delete" + SeasonsCreate Permission = "seasons.create" + SeasonsUpdate Permission = "seasons.update" + SeasonsDelete Permission = "seasons.delete" + SeasonsAddLeague Permission = "seasons.add_league" + SeasonsRemoveLeague Permission = "seasons.remove_league" + + // Leagues permissions + LeaguesCreate Permission = "leagues.create" + LeaguesUpdate Permission = "leagues.update" + LeaguesDelete Permission = "leagues.delete" // Users permissions UsersUpdate Permission = "users.update" diff --git a/internal/view/baseview/layout.templ b/internal/view/baseview/layout.templ index 40fdcf9..1901a04 100644 --- a/internal/view/baseview/layout.templ +++ b/internal/view/baseview/layout.templ @@ -37,6 +37,7 @@ templ Layout(title string) { > @popup.ErrorModalContainer() @popup.ToastContainer() + @popup.ConfirmModal()
10) { + value = value.substring(0, 10); + } + + this.displayDate = value; + event.target.value = value; + + // Validate complete date + if (value.length === 10) { + const parts = value.split('/'); + if (parts.length === 3) { + const day = parseInt(parts[0]); + const month = parseInt(parts[1]); + const year = parseInt(parts[2]); + + // Basic validation + if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year >= 2001 && year <= 2051) { + this.selectedDate = value; + this.lastValidDate = value; + this.tempYear = year; + this.tempMonth = month - 1; + } + } + } + }, + handleBackspace(event) { + const input = event.target; + const cursorPos = input.selectionStart; + const value = input.value; + + // Check if we're about to delete a slash at position 2 or 5 + if (cursorPos === 3 && value.charAt(2) === '/') { + // Delete both the slash and the character before it + event.preventDefault(); + const newValue = value.substring(0, 1) + value.substring(3); + this.displayDate = newValue; + input.value = newValue; + this.$nextTick(() => { + input.setSelectionRange(1, 1); + }); + } else if (cursorPos === 6 && value.charAt(5) === '/') { + // Delete both the slash and the character before it + event.preventDefault(); + const newValue = value.substring(0, 4) + value.substring(6); + this.displayDate = newValue; + input.value = newValue; + this.$nextTick(() => { + input.setSelectionRange(4, 4); + }); + } + // Otherwise, let default backspace behavior work + }, + isValidDate(dateString) { + if (!dateString || dateString.length !== 10) return false; + const parts = dateString.split('/'); + if (parts.length !== 3) return false; + + const day = parseInt(parts[0]); + const month = parseInt(parts[1]); + const year = parseInt(parts[2]); + + // Check ranges + if (month < 1 || month > 12) return false; + if (year < 2001 || year > 2051) return false; + if (day < 1 || day > 31) return false; + + // Check valid day for month + const daysInMonth = new Date(year, month, 0).getDate(); + if (day > daysInMonth) return false; + + return true; + }, }; } -
+
+
+ +} diff --git a/internal/view/leaguesview/new_page.templ b/internal/view/leaguesview/new_page.templ new file mode 100644 index 0000000..0b4431c --- /dev/null +++ b/internal/view/leaguesview/new_page.templ @@ -0,0 +1,23 @@ +package leaguesview + +import "git.haelnorr.com/h/oslstats/internal/view/baseview" + +templ NewPage() { + @baseview.Layout("New League") { +
+
+
+
+

Create New League

+

+ Add a new league to the system. Name and short name are required. +

+
+
+ @NewForm() +
+
+
+
+ } +} diff --git a/internal/view/popup/confirm_modal.templ b/internal/view/popup/confirm_modal.templ new file mode 100644 index 0000000..fd83497 --- /dev/null +++ b/internal/view/popup/confirm_modal.templ @@ -0,0 +1,94 @@ +package popup + +// ConfirmModal provides a reusable confirmation modal for delete/remove actions +templ ConfirmModal() { + +} diff --git a/internal/view/seasonsview/detail_page.templ b/internal/view/seasonsview/detail_page.templ index c95c76e..2ff030d 100644 --- a/internal/view/seasonsview/detail_page.templ +++ b/internal/view/seasonsview/detail_page.templ @@ -26,9 +26,13 @@ templ SeasonDetails(season *db.Season) {

{ season.Name }

- - { season.ShortName } - +
+ + { season.ShortName } + + @SlapVersionBadge(season.SlapVersion) + @StatusBadge(season, false, false) +
if canEditSeason { @@ -127,13 +131,25 @@ templ SeasonDetails(season *db.Season) {
- +
-

Status

-
- @StatusBadge(season, false, false) -
+

Leagues

+ if len(season.Leagues) == 0 { +

No leagues assigned to this season.

+ } else { +
+ for _, league := range season.Leagues { + +

{ league.Name }

+ { league.ShortName } +
+ } +
+ }
@@ -169,3 +185,15 @@ func formatDuration(start, end time.Time) string { return strconv.Itoa(years) + " years" } } + +templ SlapVersionBadge(version string) { + if version == "rebound" { + + Rebound + + } else if version == "slapshot1" { + + Slapshot 1 + + } +} diff --git a/internal/view/seasonsview/edit_form.templ b/internal/view/seasonsview/edit_form.templ index 218e0ad..240d538 100644 --- a/internal/view/seasonsview/edit_form.templ +++ b/internal/view/seasonsview/edit_form.templ @@ -4,7 +4,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/datepicker" import "git.haelnorr.com/h/oslstats/internal/db" import "time" -templ EditForm(season *db.Season) { +templ EditForm(season *db.Season, allLeagues []*db.League) { {{ // Format dates for display (DD/MM/YYYY) startDateStr := formatDateInput(season.StartDate) @@ -86,9 +86,20 @@ templ EditForm(season *db.Season) {

Edit { season.Name }

- - { season.ShortName } - +
+ + { season.ShortName } + + +
+ + @LeaguesSection(season, allLeagues)
- @EditForm(season) + @EditForm(season, allLeagues)
} } diff --git a/internal/view/seasonsview/leagues_section.templ b/internal/view/seasonsview/leagues_section.templ new file mode 100644 index 0000000..409e0bc --- /dev/null +++ b/internal/view/seasonsview/leagues_section.templ @@ -0,0 +1,88 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/contexts" +import "git.haelnorr.com/h/oslstats/internal/permissions" + +templ LeaguesSection(season *db.Season, allLeagues []*db.League) { + {{ + permCache := contexts.Permissions(ctx) + canAddLeague := permCache.HasPermission(permissions.SeasonsAddLeague) + canRemoveLeague := permCache.HasPermission(permissions.SeasonsRemoveLeague) + + // Create a map of assigned league IDs for quick lookup + assignedLeagueIDs := make(map[int]bool) + for _, league := range season.Leagues { + assignedLeagueIDs[league.ID] = true + } + }} + if canAddLeague || canRemoveLeague { +
+
+

Leagues

+ + if len(season.Leagues) > 0 { +
+

Currently Assigned

+
+ for _, league := range season.Leagues { +
+ { league.Name } + ({ league.ShortName }) + if canRemoveLeague { + + } +
+ } +
+
+ } + + if canAddLeague && len(allLeagues) > 0 { + {{ + // Filter out already assigned leagues + availableLeagues := []*db.League{} + for _, league := range allLeagues { + if !assignedLeagueIDs[league.ID] { + availableLeagues = append(availableLeagues, league) + } + } + }} + if len(availableLeagues) > 0 { +
+

Add League

+
+ for _, league := range availableLeagues { + + } +
+
+ } + } +
+
+ } +} diff --git a/internal/view/seasonsview/list_page.templ b/internal/view/seasonsview/list_page.templ index 0de5bde..5623e67 100644 --- a/internal/view/seasonsview/list_page.templ +++ b/internal/view/seasonsview/list_page.templ @@ -83,7 +83,7 @@ templ SeasonsList(seasons *db.List[db.Season]) {
for _, s := range seasons.Items { @@ -91,14 +91,45 @@ templ SeasonsList(seasons *db.List[db.Season]) {

{ s.Name }

@StatusBadge(s, true, true)
- -
+ +
{ s.ShortName } - + @SlapVersionBadge(s.SlapVersion) +
+ + if len(s.Leagues) > 0 { +
+ for _, league := range s.Leagues { + + { league.ShortName } + + } +
+ } + + {{ + now := time.Now() + }} +
+ if now.Before(s.StartDate) { + Starts: { formatDate(s.StartDate) } + } else if !s.FinalsStartDate.IsZero() { + // Finals are scheduled + if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) { + Completed: { formatDate(s.FinalsEndDate.Time) } + } else if now.After(s.FinalsStartDate.Time) { + Finals Started: { formatDate(s.FinalsStartDate.Time) } + } else { + Finals Start: { formatDate(s.FinalsStartDate.Time) } + } + } else if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { + // No finals scheduled and regular season ended + Completed: { formatDate(s.EndDate.Time) } + } else { Started: { formatDate(s.StartDate) } - + }
} diff --git a/internal/view/seasonsview/new_form.templ b/internal/view/seasonsview/new_form.templ index cd86807..00e9774 100644 --- a/internal/view/seasonsview/new_form.templ +++ b/internal/view/seasonsview/new_form.templ @@ -50,22 +50,27 @@ templ NewForm() { }, // Check if form can be submitted updateCanSubmit() { - this.canSubmit = !this.nameIsEmpty && this.nameIsUnique && !this.nameIsChecking && - !this.shortNameIsEmpty && this.shortNameIsUnique && !this.shortNameIsChecking && + this.canSubmit = + !this.nameIsEmpty && + this.nameIsUnique && + !this.nameIsChecking && + !this.shortNameIsEmpty && + this.shortNameIsUnique && + !this.shortNameIsChecking && !this.dateIsEmpty; }, // Handle form submission handleSubmit() { this.isSubmitting = true; - this.buttonText = 'Creating...'; - this.generalError = ''; + this.buttonText = "Creating..."; + this.generalError = ""; // Set timeout for 10 seconds this.submitTimeout = setTimeout(() => { this.isSubmitting = false; - this.buttonText = 'Create Season'; - this.generalError = 'Request timed out. Please try again.'; + this.buttonText = "Create Season"; + this.generalError = "Request timed out. Please try again."; }, 10000); - } + }, }; } @@ -147,6 +152,20 @@ templ NewForm() { x-text="shortNameError" >

+ +
+ + +

Select the game version for this season

+
@datepicker.DatePicker("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "dateIsEmpty = $el.value === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();")

diff --git a/internal/view/seasonsview/status_badge.templ b/internal/view/seasonsview/status_badge.templ index 7878dc8..537e98a 100644 --- a/internal/view/seasonsview/status_badge.templ +++ b/internal/view/seasonsview/status_badge.templ @@ -12,60 +12,54 @@ templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) { {{ now := time.Now() status := "" - statusColor := "" statusBg := "" // Determine status based on dates 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) { + statusBg = "bg-blue" + } else if !season.FinalsStartDate.IsZero() { + // Finals are scheduled if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) { + // Finals have ended status = "Completed" - statusColor = "text-green" - statusBg = "bg-green/10 border-green" - } else { + statusBg = "bg-teal" + } else if now.After(season.FinalsStartDate.Time) { + // Finals are in progress if useShortLabels { status = "Finals" } else { status = "Finals in Progress" } - statusColor = "text-yellow" - statusBg = "bg-yellow/10 border-yellow" + statusBg = "bg-yellow" + } else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) { + // Regular season ended, finals upcoming + status = "Finals Soon" + statusBg = "bg-peach" + } else { + // Regular season active, finals scheduled for later + if useShortLabels { + status = "Active" + } else { + status = "In Progress" + } + statusBg = "bg-green" } + } else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) { + // No finals scheduled and regular season ended + status = "Completed" + statusBg = "bg-teal" } else { + // Regular season active, no finals scheduled if useShortLabels { status = "Active" } else { status = "In Progress" } - statusColor = "text-green" - statusBg = "bg-green/10 border-green" - } - - // Determine size classes - var sizeClasses string - var textSize string - var iconSize string - if compact { - sizeClasses = "px-2 py-1" - textSize = "text-xs" - iconSize = "text-sm" - } else { - sizeClasses = "px-4 py-2" - textSize = "text-lg" - iconSize = "text-2xl" + statusBg = "bg-green" } }} -

- if !compact { - - } - { status } -
+ + { status } + }