From bf3e526f1e7a8c5f567b1493df9db487015aa8b8 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Mon, 9 Feb 2026 20:35:04 +1100 Subject: [PATCH] added season edits --- cmd/oslstats/routes.go | 15 +- internal/db/season.go | 29 +++ internal/embedfs/web/css/output.css | 108 +++++++++++ .../handlers/{season.go => season_detail.go} | 0 internal/handlers/season_edit.go | 126 +++++++++++++ .../handlers/{seasons.go => seasons_list.go} | 0 .../handlers/{newseason.go => seasons_new.go} | 10 +- internal/view/datepicker/datepicker.templ | 138 +++++++++----- internal/view/seasonsview/detail_page.templ | 2 +- internal/view/seasonsview/edit_form.templ | 177 ++++++++++++++++++ internal/view/seasonsview/edit_page.templ | 12 ++ 11 files changed, 561 insertions(+), 56 deletions(-) rename internal/handlers/{season.go => season_detail.go} (100%) create mode 100644 internal/handlers/season_edit.go rename internal/handlers/{seasons.go => seasons_list.go} (100%) rename internal/handlers/{newseason.go => seasons_new.go} (87%) create mode 100644 internal/view/seasonsview/edit_form.templ create mode 100644 internal/view/seasonsview/edit_page.templ diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index b2d53ac..354a882 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -13,6 +13,7 @@ import ( "git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/handlers" + "git.haelnorr.com/h/oslstats/internal/permissions" "git.haelnorr.com/h/oslstats/internal/rbac" "git.haelnorr.com/h/oslstats/internal/store" ) @@ -83,18 +84,28 @@ func addRoutes( { Path: "/seasons/new", Method: hws.MethodGET, - Handler: handlers.NewSeason(s, conn), + Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeason(s, conn)), }, { Path: "/seasons/new", Method: hws.MethodPOST, - Handler: handlers.NewSeasonSubmit(s, conn), + Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeasonSubmit(s, conn, audit)), }, { Path: "/seasons/{season_short_name}", Method: hws.MethodGET, Handler: handlers.SeasonPage(s, conn), }, + { + Path: "/seasons/{season_short_name}/edit", + Method: hws.MethodGET, + Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditPage(s, conn)), + }, + { + Path: "/seasons/{season_short_name}/edit", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditSubmit(s, conn, audit)), + }, } htmxRoutes := []hws.Route{ diff --git a/internal/db/season.go b/internal/db/season.go index 32b6dc8..6059a9a 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -59,3 +59,32 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error } return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx) } + +func UpdateSeason(ctx context.Context, tx bun.Tx, season *Season) error { + if season == nil { + return errors.New("season cannot be nil") + } + if season.ID == 0 { + return errors.New("season ID cannot be 0") + } + // Truncate dates to day precision + season.StartDate = season.StartDate.Truncate(time.Hour * 24) + if !season.EndDate.IsZero() { + season.EndDate.Time = season.EndDate.Time.Truncate(time.Hour * 24) + } + if !season.FinalsStartDate.IsZero() { + season.FinalsStartDate.Time = season.FinalsStartDate.Time.Truncate(time.Hour * 24) + } + if !season.FinalsEndDate.IsZero() { + season.FinalsEndDate.Time = season.FinalsEndDate.Time.Truncate(time.Hour * 24) + } + _, err := tx.NewUpdate(). + Model(season). + Column("start_date", "end_date", "finals_start_date", "finals_end_date"). + Where("id = ?", season.ID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.NewUpdate") + } + return nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 4fe268b..618d038 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -45,6 +45,36 @@ --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 +273,9 @@ .top-0 { top: calc(var(--spacing) * 0); } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1/2 * 100%); } @@ -276,9 +309,30 @@ .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; } + .-mt-2 { + margin-top: calc(var(--spacing) * -2); + } .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -442,9 +496,25 @@ .flex-1 { flex: 1; } + .flex-shrink { + flex-shrink: 1; + } .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); @@ -523,6 +593,13 @@ margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } @@ -546,6 +623,11 @@ border-color: var(--surface2); } } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .overflow-hidden { overflow: hidden; } @@ -570,6 +652,10 @@ .rounded-xl { border-radius: var(--radius-xl); } + .rounded-t-lg { + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + } .border { border-style: var(--tw-border-style); border-width: 1px; @@ -648,6 +734,15 @@ .bg-mauve { background-color: var(--mauve); } + .bg-red { + background-color: var(--red); + } + .bg-red\/10 { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 10%, transparent); + } + } .bg-sapphire { background-color: var(--sapphire); } @@ -838,6 +933,9 @@ .italic { font-style: italic; } + .underline { + text-decoration-line: underline; + } .opacity-50 { opacity: 50%; } @@ -853,6 +951,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,); } @@ -1437,6 +1539,11 @@ inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -1521,6 +1628,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; diff --git a/internal/handlers/season.go b/internal/handlers/season_detail.go similarity index 100% rename from internal/handlers/season.go rename to internal/handlers/season_detail.go diff --git a/internal/handlers/season_edit.go b/internal/handlers/season_edit.go new file mode 100644 index 0000000..382ca84 --- /dev/null +++ b/internal/handlers/season_edit.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "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/permissions" + "git.haelnorr.com/h/oslstats/internal/throw" + "git.haelnorr.com/h/oslstats/internal/validation" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "git.haelnorr.com/h/timefmt" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func SeasonEditPage( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonStr := r.PathValue("season_short_name") + var season *db.Season + 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") + } + return true, nil + }); !ok { + return + } + if season == nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + renderSafely(seasonsview.EditPage(season), s, r, w) + }) +} + +func SeasonEditSubmit( + 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") + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + + format := timefmt.NewBuilder(). + DayNumeric2().Slash(). + 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 + + if !getter.ValidateAndNotify(s, w, r) { + return + } + + var season *db.Season + 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 true, nil + } + + // Update only the date fields + season.StartDate = startDate + if !endDate.IsZero() { + season.EndDate = bun.NullTime{Time: endDate} + } else { + season.EndDate = bun.NullTime{} + } + if !finalsStartDate.IsZero() { + season.FinalsStartDate = bun.NullTime{Time: finalsStartDate} + } else { + season.FinalsStartDate = bun.NullTime{} + } + if !finalsEndDate.IsZero() { + season.FinalsEndDate = bun.NullTime{Time: finalsEndDate} + } else { + season.FinalsEndDate = bun.NullTime{} + } + + err = db.UpdateSeason(ctx, tx, season) + if err != nil { + return false, errors.Wrap(err, "db.UpdateSeason") + } + user := db.CurrentUser(ctx) + err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(), + "season", season.ID, nil, r) + if err != nil { + return false, errors.Wrap(err, "audit.LogSuccess") + } + return true, nil + }); !ok { + return + } + + if season == nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + notify.Success(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil) + w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName)) + w.WriteHeader(http.StatusOK) + }) +} diff --git a/internal/handlers/seasons.go b/internal/handlers/seasons_list.go similarity index 100% rename from internal/handlers/seasons.go rename to internal/handlers/seasons_list.go diff --git a/internal/handlers/newseason.go b/internal/handlers/seasons_new.go similarity index 87% rename from internal/handlers/newseason.go rename to internal/handlers/seasons_new.go index 778b169..24e6e4b 100644 --- a/internal/handlers/newseason.go +++ b/internal/handlers/seasons_new.go @@ -6,8 +6,10 @@ import ( "net/http" "git.haelnorr.com/h/golib/hws" + "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/permissions" "git.haelnorr.com/h/oslstats/internal/validation" seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" "git.haelnorr.com/h/timefmt" @@ -30,6 +32,7 @@ func NewSeason( func NewSeasonSubmit( 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) @@ -71,6 +74,12 @@ func NewSeasonSubmit( if err != nil { return false, errors.Wrap(err, "db.NewSeason") } + user := db.CurrentUser(ctx) + err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(), + "season", season.ID, nil, r) + if err != nil { + return false, errors.Wrap(err, "audit.LogSuccess") + } return true, nil }); !ok { return @@ -85,7 +94,6 @@ func NewSeasonSubmit( notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil) return } - notify.Success(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil) w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName)) w.WriteHeader(http.StatusOK) diff --git a/internal/view/datepicker/datepicker.templ b/internal/view/datepicker/datepicker.templ index e2f4112..ed3ac57 100644 --- a/internal/view/datepicker/datepicker.templ +++ b/internal/view/datepicker/datepicker.templ @@ -27,60 +27,94 @@ package datepicker // The component submits the date in DD/MM/YYYY format. To parse on the server: // date, err := time.Parse("02/01/2006", dateString) templ DatePicker(id, name, label, placeholder string, required bool, onChange string) { + @DatePickerWithDefault(id, name, label, placeholder, required, onChange, "") +} + +// DatePickerWithDefault is the same as DatePicker but accepts a default value in DD/MM/YYYY format +templ DatePickerWithDefault(id, name, label, placeholder string, required bool, onChange, defaultValue string) {
+
+ +
+ +
+
+
+

Edit { season.Name }

+ + { season.ShortName } + +
+ +
+
+ +
+ +
+

+ + Regular Season +

+
+ @datepicker.DatePickerWithDefault("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "startDateIsEmpty = $el.value === ''; resetStartDateErr(); if(startDateIsEmpty) { startDateError='Start date is required'; } updateCanSubmit();", startDateStr) +

+ @datepicker.DatePickerWithDefault("end_date", "end_date", "End Date (Optional)", "DD/MM/YYYY", false, "resetEndDateErr();", endDateStr) +

+
+
+ +
+

+ + Finals +

+
+ @datepicker.DatePickerWithDefault("finals_start_date", "finals_start_date", "Start Date (Optional)", "DD/MM/YYYY", false, "resetFinalsStartDateErr();", finalsStartDateStr) +

+ @datepicker.DatePickerWithDefault("finals_end_date", "finals_end_date", "End Date (Optional)", "DD/MM/YYYY", false, "resetFinalsEndDateErr();", finalsEndDateStr) +

+
+
+
+ +
+
+

+
+
+
+ +} + +func formatDateInput(t time.Time) string { + return t.Format("02/01/2006") +} diff --git a/internal/view/seasonsview/edit_page.templ b/internal/view/seasonsview/edit_page.templ new file mode 100644 index 0000000..bb15382 --- /dev/null +++ b/internal/view/seasonsview/edit_page.templ @@ -0,0 +1,12 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "git.haelnorr.com/h/oslstats/internal/db" + +templ EditPage(season *db.Season) { + @baseview.Layout("Edit " + season.Name) { +
+ @EditForm(season) +
+ } +}