diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 90a4932..a37c837 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -74,9 +74,14 @@ func addRoutes( }, { Path: "/seasons/new", - Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Method: hws.MethodGET, Handler: handlers.NewSeason(s, conn), }, + { + Path: "/seasons/new", + Method: hws.MethodPOST, + Handler: handlers.NewSeasonSubmit(s, conn), + }, { Path: "/seasons/{season_short_name}", Method: hws.MethodGET, diff --git a/internal/db/season.go b/internal/db/season.go index a976b3d..6bc45d7 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -90,7 +90,7 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis } func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) { - var season *Season + season := new(Season) err := tx.NewSelect(). Model(season). Where("short_name = ?", strings.ToUpper(shortname)). diff --git a/internal/handlers/newseason.go b/internal/handlers/newseason.go index f019568..16a3c49 100644 --- a/internal/handlers/newseason.go +++ b/internal/handlers/newseason.go @@ -2,7 +2,9 @@ package handlers import ( "context" + "fmt" "net/http" + "strings" "time" "git.haelnorr.com/h/golib/hws" @@ -29,9 +31,162 @@ func NewSeasonSubmit( conn *bun.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse form data + err := r.ParseForm() + if err != nil { + err = notifyWarn(s, r, "Invalid Form", "Please check your input and try again.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Get form values + name := strings.TrimSpace(r.FormValue("name")) + shortName := strings.TrimSpace(strings.ToUpper(r.FormValue("short_name"))) + startDateStr := r.FormValue("start_date") + + // Validate required fields + if name == "" || shortName == "" || startDateStr == "" { + err = notifyWarn(s, r, "Missing Fields", "All fields are required.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Validate field lengths + if len(name) > 20 { + err = notifyWarn(s, r, "Invalid Name", "Season name must be 20 characters or less.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + if len(shortName) > 6 { + err = notifyWarn(s, r, "Invalid Short Name", "Short name must be 6 characters or less.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Validate short name is alphanumeric only + if !isAlphanumeric(shortName) { + err = notifyWarn(s, r, "Invalid Short Name", "Short name must contain only letters and numbers.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Parse start date (DD/MM/YYYY format) + startDate, err := time.Parse("02/01/2006", startDateStr) + if err != nil { + err = notifyWarn(s, r, "Invalid Date", "Please provide a valid start date in DD/MM/YYYY format.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Begin database transaction + 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 + } + defer tx.Rollback() + + // Double-check uniqueness (race condition protection) + nameUnique, 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 !nameUnique { + err = notifyWarn(s, r, "Duplicate Name", "This season name is already taken.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + shortNameUnique, 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 !shortNameUnique { + err = notifyWarn(s, r, "Duplicate Short Name", "This short name is already taken.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Create the season + season, err := db.NewSeason(ctx, tx, name, shortName, startDate) + if err != nil { + err = notifyInternalServiceError(s, r, "Failed to create season", errors.Wrap(err, "db.NewSeason")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Commit transaction + err = tx.Commit() + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "tx.Commit")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Send success notification + err = notifySuccess(s, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil) + if err != nil { + // Log but don't fail the request + s.LogError(hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "Failed to send success notification", + Error: err, + }) + } + + // Redirect to the season detail page + w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName)) + w.WriteHeader(http.StatusOK) }) } +// Helper function to validate alphanumeric strings +func isAlphanumeric(s string) bool { + for _, r := range s { + if !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} + func IsSeasonNameUnique( s *hws.Server, conn *bun.DB, @@ -52,7 +207,11 @@ func IsSeasonNameUnique( } return } - name := r.FormValue("name") + defer tx.Rollback() + + // Trim whitespace for consistency + name := strings.TrimSpace(r.FormValue("name")) + unique, err := db.IsSeasonNameUnique(ctx, tx, name) if err != nil { err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique")) @@ -61,10 +220,15 @@ func IsSeasonNameUnique( } return } + + tx.Commit() + if !unique { w.WriteHeader(http.StatusConflict) return } + + w.WriteHeader(http.StatusOK) }) } @@ -88,7 +252,11 @@ func IsSeasonShortNameUnique( } return } - shortname := r.FormValue("short_name") + defer tx.Rollback() + + // Get short name and convert to uppercase for consistency + shortname := strings.ToUpper(strings.TrimSpace(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")) @@ -97,9 +265,14 @@ func IsSeasonShortNameUnique( } return } + + tx.Commit() + if !unique { w.WriteHeader(http.StatusConflict) return } + + w.WriteHeader(http.StatusOK) }) } diff --git a/internal/view/component/datepicker/README.md b/internal/view/component/datepicker/README.md new file mode 100644 index 0000000..451859d --- /dev/null +++ b/internal/view/component/datepicker/README.md @@ -0,0 +1,154 @@ +# DatePicker Component + +A reusable, standalone date picker component for oslstats forms. + +## Features + +✨ **Interactive Calendar** +- Click-to-open dropdown calendar +- Month and year dropdown selectors for easy navigation +- Previous/next month arrow buttons +- Today's date highlighted in blue + +📅 **Date Format** +- DD/MM/YYYY format (the sensible way) +- Pattern validation built-in +- Read-only input (date selection via picker only) + +🎨 **Design** +- Consistent with Catppuccin theme +- Responsive layout +- Hover effects and visual feedback +- Calendar icon in input field + +🔧 **Technical** +- Built with Alpine.js (already in project) +- No external dependencies +- Year range: 2001-2051 +- Submits in DD/MM/YYYY format + +## Usage + +### Basic Example + +```templ +import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker" + +// Simple usage - required field, no custom onChange +@datepicker.DatePicker( + "event_date", // id + "event_date", // name + "Event Date", // label + "DD/MM/YYYY", // placeholder + true, // required + "" // onChange (empty = none) +) +``` + +### With Custom onChange Handler + +```templ +// With Alpine.js validation logic +@datepicker.DatePicker( + "start_date", + "start_date", + "Start Date", + "DD/MM/YYYY", + true, + "dateIsEmpty = false; validateForm();" +) +``` + +### Optional Field + +```templ +// Non-required field +@datepicker.DatePicker( + "end_date", + "end_date", + "End Date (Optional)", + "DD/MM/YYYY", + false, // not required + "" +) +``` + +## Parameters + +| Parameter | Type | Description | Example | +|-------------|--------|------------------------------------------------------|----------------------------------| +| `id` | string | Unique ID for the input element | `"start_date"` | +| `name` | string | Form field name (used in form submission) | `"start_date"` | +| `label` | string | Display label above the input | `"Start Date"` | +| `placeholder` | string | Placeholder text in the input | `"DD/MM/YYYY"` | +| `required` | bool | Whether the field is required | `true` or `false` | +| `onChange` | string | Alpine.js expression to run when date changes | `"updateForm();"` or `""` | + +## Server-Side Parsing + +The date picker submits dates in **DD/MM/YYYY** format. Parse on the server like this: + +```go +import "time" + +// Parse DD/MM/YYYY format +dateStr := r.FormValue("start_date") // e.g., "15/02/2026" +date, err := time.Parse("02/01/2006", dateStr) +if err != nil { + // Handle invalid date +} +``` + +**Important**: The format string is `"02/01/2006"` (DD/MM/YYYY), NOT `"01/02/2006"` (MM/DD/YYYY). + +## Integration with Alpine.js Forms + +The date picker works seamlessly with Alpine.js validation: + +```templ +
+ @datepicker.DatePicker( + "event_date", + "event_date", + "Event Date", + "DD/MM/YYYY", + true, + "dateIsEmpty = $el.value === ''; updateCanSubmit();" + ) + + +

+ + + +
+``` + +## Styling + +The component uses Tailwind CSS classes and Catppuccin theme colors: +- `bg-mantle`, `bg-surface0`, `bg-surface1` - backgrounds +- `border-surface1` - borders +- `text-subtext0` - muted text +- `bg-blue` - accent color (today's date) +- `focus:border-blue` - focus states + +All styling is built-in; no additional CSS required. + +## Browser Compatibility + +Works in all modern browsers that support: +- Alpine.js 3.x +- ES6 JavaScript (arrow functions, template literals, etc.) +- CSS Grid + +## Examples in Codebase + +See `internal/view/component/form/new_season.templ` for a real-world usage example. diff --git a/internal/view/component/datepicker/datepicker.templ b/internal/view/component/datepicker/datepicker.templ new file mode 100644 index 0000000..e2f4112 --- /dev/null +++ b/internal/view/component/datepicker/datepicker.templ @@ -0,0 +1,239 @@ +package datepicker + +// DatePicker renders a reusable date picker component with DD/MM/YYYY format +// +// Features: +// - Interactive calendar dropdown with month/year selectors +// - DD/MM/YYYY format (proper date format, none of that American nonsense) +// - Year range: 2001-2051 +// - Today's date highlighted in blue +// - Click outside to close +// - Consistent with Catppuccin theme +// +// Parameters: +// - id: unique ID for the input field (e.g., "start_date") +// - name: form field name (e.g., "start_date") +// - label: display label (e.g., "Start Date") +// - placeholder: input placeholder (default: "DD/MM/YYYY") +// - required: whether the field is required (true/false) +// - onChange: Alpine.js expression to run when date changes (e.g., "dateIsEmpty = false; updateCanSubmit();") +// Set to empty string "" if no custom onChange handler is needed +// +// Usage Example: +// import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker" +// +// @datepicker.DatePicker("birth_date", "birth_date", "Date of Birth", "DD/MM/YYYY", true, "handleDateChange();") +// +// 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) { +
+ +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+
Su
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
+ +
+ + +
+
+
+
+} diff --git a/internal/view/component/form/new_season.templ b/internal/view/component/form/new_season.templ index 9bda1b3..6ec0730 100644 --- a/internal/view/component/form/new_season.templ +++ b/internal/view/component/form/new_season.templ @@ -1,4 +1,177 @@ package form +import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker" + templ NewSeason() { +
+ +
+ +
+ +
+ +

Maximum 20 characters

+
+

+
+ +
+ +
+ +

Maximum 6 characters, alphanumeric only (auto-capitalized)

+
+

+
+ + @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/page/new_season.templ b/internal/view/page/new_season.templ index ac3eb8b..afb819f 100644 --- a/internal/view/page/new_season.templ +++ b/internal/view/page/new_season.templ @@ -4,6 +4,21 @@ 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() + @layout.Global("New Season") { +
+
+
+
+

Create New Season

+

+ Add a new season to the system. All fields are required. +

+
+
+ @form.NewSeason() +
+
+
+
+ } } diff --git a/pkg/embedfs/files/css/output.css b/pkg/embedfs/files/css/output.css index 47ae661..5b14b8c 100644 --- a/pkg/embedfs/files/css/output.css +++ b/pkg/embedfs/files/css/output.css @@ -9,6 +9,7 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --spacing: 0.25rem; + --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; --container-sm: 24rem; @@ -33,6 +34,7 @@ --text-6xl--line-height: 1; --text-9xl: 8rem; --text-9xl--line-height: 1; + --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --tracking-tight: -0.025em; @@ -241,6 +243,9 @@ .top-0 { top: calc(var(--spacing) * 0); } + .top-1\/2 { + top: calc(1/2 * 100%); + } .top-4 { top: calc(var(--spacing) * 4); } @@ -250,6 +255,9 @@ .right-0 { right: calc(var(--spacing) * 0); } + .right-3 { + right: calc(var(--spacing) * 3); + } .right-5 { right: calc(var(--spacing) * 5); } @@ -356,6 +364,9 @@ .h-1 { height: calc(var(--spacing) * 1); } + .h-5 { + height: calc(var(--spacing) * 5); + } .h-16 { height: calc(var(--spacing) * 16); } @@ -374,12 +385,18 @@ .min-h-\[calc\(100vh-200px\)\] { min-height: calc(100vh - 200px); } + .w-5 { + width: calc(var(--spacing) * 5); + } .w-26 { width: calc(var(--spacing) * 26); } .w-36 { width: calc(var(--spacing) * 36); } + .w-80 { + width: calc(var(--spacing) * 80); + } .w-fit { width: fit-content; } @@ -407,6 +424,9 @@ .max-w-screen-2xl { max-width: var(--breakpoint-2xl); } + .max-w-screen-lg { + max-width: var(--breakpoint-lg); + } .max-w-screen-xl { max-width: var(--breakpoint-xl); } @@ -419,9 +439,16 @@ .flex-1 { flex: 1; } + .flex-shrink-0 { + flex-shrink: 0; + } .shrink-0 { flex-shrink: 0; } + .-translate-y-1\/2 { + --tw-translate-y: calc(calc(1/2 * 100%) * -1); + 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,); } @@ -437,6 +464,9 @@ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } + .grid-cols-7 { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } .flex-col { flex-direction: column; } @@ -461,6 +491,9 @@ .justify-end { justify-content: flex-end; } + .gap-1 { + gap: calc(var(--spacing) * 1); + } .gap-2 { gap: calc(var(--spacing) * 2); } @@ -496,6 +529,9 @@ .gap-y-4 { row-gap: calc(var(--spacing) * 4); } + .gap-y-5 { + row-gap: calc(var(--spacing) * 5); + } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; @@ -633,6 +669,9 @@ background-color: color-mix(in oklab, var(--yellow) 10%, transparent); } } + .p-1 { + padding: calc(var(--spacing) * 1); + } .p-2 { padding: calc(var(--spacing) * 2); } @@ -678,6 +717,9 @@ .py-8 { padding-block: calc(var(--spacing) * 8); } + .pr-10 { + padding-right: calc(var(--spacing) * 10); + } .pb-6 { padding-bottom: calc(var(--spacing) * 6); } @@ -733,6 +775,10 @@ --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); @@ -750,6 +796,9 @@ .whitespace-pre-wrap { white-space: pre-wrap; } + .text-base { + color: var(--base); + } .text-blue { color: var(--blue); } @@ -780,6 +829,9 @@ .text-yellow { color: var(--yellow); } + .uppercase { + text-transform: uppercase; + } .italic { font-style: italic; } @@ -894,6 +946,13 @@ } } } + .hover\:bg-surface1 { + &:hover { + @media (hover: hover) { + background-color: var(--surface1); + } + } + } .hover\:bg-surface2 { &:hover { @media (hover: hover) { @@ -984,6 +1043,19 @@ cursor: default; } } + .disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } + } + .disabled\:bg-blue\/40 { + &:disabled { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 40%, transparent); + } + } + } .disabled\:bg-green\/60 { &:disabled { background-color: var(--green); @@ -1047,6 +1119,11 @@ padding: calc(var(--spacing) * 7); } } + .sm\:p-8 { + @media (width >= 40rem) { + padding: calc(var(--spacing) * 8); + } + } .sm\:px-6 { @media (width >= 40rem) { padding-inline: calc(var(--spacing) * 6); @@ -1227,6 +1304,21 @@ font-weight: 700; font-style: italic; } +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-rotate-x { syntax: "*"; inherits: false; @@ -1342,6 +1434,9 @@ @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 { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial;