refactored view package

This commit is contained in:
2026-02-09 19:30:47 +11:00
parent 24733098b4
commit d01485d139
47 changed files with 653 additions and 490 deletions

View File

@@ -1,6 +0,0 @@
package admin
import "git.haelnorr.com/h/oslstats/internal/db"
templ UserList(users *db.List[db.User]) {
}

View File

@@ -1,154 +0,0 @@
# 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
<form x-data="{
dateIsEmpty: true,
dateError: '',
canSubmit: false,
updateCanSubmit() {
this.canSubmit = !this.dateIsEmpty;
}
}">
@datepicker.DatePicker(
"event_date",
"event_date",
"Event Date",
"DD/MM/YYYY",
true,
"dateIsEmpty = $el.value === ''; updateCanSubmit();"
)
<!-- Error message -->
<p x-show="dateError" x-text="dateError"></p>
<!-- Submit button -->
<button x-bind:disabled="!canSubmit">Submit</button>
</form>
```
## 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.

View File

@@ -1,239 +0,0 @@
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) {
<div
x-data="{
showPicker: false,
selectedDate: '',
displayDate: '',
tempYear: new Date().getFullYear(),
tempMonth: new Date().getMonth(),
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
init() {
// Ensure dropdowns are initialized with current month/year
this.tempYear = new Date().getFullYear();
this.tempMonth = new Date().getMonth();
},
getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
},
getFirstDayOfMonth(year, month) {
return new Date(year, month, 1).getDay();
},
selectDate(day) {
const d = String(day).padStart(2, '0');
const m = String(this.tempMonth + 1).padStart(2, '0');
const y = this.tempYear;
this.displayDate = d + '/' + m + '/' + y;
this.selectedDate = this.displayDate;
$refs.dateInput.value = this.displayDate;
this.showPicker = false;
// Manually trigger input event so Alpine.js @input handler fires
$refs.dateInput.dispatchEvent(new Event('input', { bubbles: true }));
},
prevMonth() {
if (this.tempMonth === 0) {
this.tempMonth = 11;
this.tempYear--;
} else {
this.tempMonth--;
}
},
nextMonth() {
if (this.tempMonth === 11) {
this.tempMonth = 0;
this.tempYear++;
} else {
this.tempMonth++;
}
},
isToday(day) {
const today = new Date();
return day === today.getDate() &&
this.tempMonth === today.getMonth() &&
this.tempYear === today.getFullYear();
}
}"
>
<label for={ id } class="block text-sm font-medium mb-2">{ label }</label>
<div class="relative">
<input
type="text"
id={ id }
name={ name }
x-ref="dateInput"
class="py-3 px-4 pr-10 block w-full rounded-lg text-sm bg-base border-2 border-overlay0 focus:border-blue outline-none"
placeholder={ placeholder }
pattern="\d{2}/\d{2}/\d{4}"
if required {
required
}
readonly
x-model="displayDate"
@click="showPicker = !showPicker"
if onChange != "" {
@input={ onChange }
}
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-subtext0 hover:text-text"
@click="showPicker = !showPicker"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</button>
<!-- Date Picker Dropdown -->
<div
x-show="showPicker"
x-cloak
@click.away="showPicker = false"
class="absolute z-50 mt-2 bg-mantle border-2 border-surface1 rounded-lg shadow-lg p-4 w-80"
>
<!-- Month/Year Navigation -->
<div class="flex items-center justify-between gap-2 mb-4">
<button type="button" @click="prevMonth()" class="p-1 hover:bg-surface0 rounded shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div class="flex gap-2 flex-1">
<select
x-model.number="tempMonth"
class="flex-1 px-2 py-1 text-sm bg-surface0 border border-surface1 rounded outline-none focus:border-blue"
>
<option value="0">January</option>
<option value="1">February</option>
<option value="2">March</option>
<option value="3">April</option>
<option value="4">May</option>
<option value="5">June</option>
<option value="6">July</option>
<option value="7">August</option>
<option value="8">September</option>
<option value="9">October</option>
<option value="10">November</option>
<option value="11">December</option>
</select>
<select
x-model.number="tempYear"
class="px-2 py-1 text-sm bg-surface0 border border-surface1 rounded outline-none focus:border-blue"
>
<option value="2001">2001</option>
<option value="2002">2002</option>
<option value="2003">2003</option>
<option value="2004">2004</option>
<option value="2005">2005</option>
<option value="2006">2006</option>
<option value="2007">2007</option>
<option value="2008">2008</option>
<option value="2009">2009</option>
<option value="2010">2010</option>
<option value="2011">2011</option>
<option value="2012">2012</option>
<option value="2013">2013</option>
<option value="2014">2014</option>
<option value="2015">2015</option>
<option value="2016">2016</option>
<option value="2017">2017</option>
<option value="2018">2018</option>
<option value="2019">2019</option>
<option value="2020">2020</option>
<option value="2021">2021</option>
<option value="2022">2022</option>
<option value="2023">2023</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
<option value="2026">2026</option>
<option value="2027">2027</option>
<option value="2028">2028</option>
<option value="2029">2029</option>
<option value="2030">2030</option>
<option value="2031">2031</option>
<option value="2032">2032</option>
<option value="2033">2033</option>
<option value="2034">2034</option>
<option value="2035">2035</option>
<option value="2036">2036</option>
<option value="2037">2037</option>
<option value="2038">2038</option>
<option value="2039">2039</option>
<option value="2040">2040</option>
<option value="2041">2041</option>
<option value="2042">2042</option>
<option value="2043">2043</option>
<option value="2044">2044</option>
<option value="2045">2045</option>
<option value="2046">2046</option>
<option value="2047">2047</option>
<option value="2048">2048</option>
<option value="2049">2049</option>
<option value="2050">2050</option>
<option value="2051">2051</option>
</select>
</div>
<button type="button" @click="nextMonth()" class="p-1 hover:bg-surface0 rounded shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<!-- Day Headers -->
<div class="grid grid-cols-7 gap-1 mb-2">
<div class="text-center text-xs font-medium text-subtext0 py-1">Su</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Mo</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Tu</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">We</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Th</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Fr</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Sa</div>
</div>
<!-- Calendar Days -->
<div class="grid grid-cols-7 gap-1">
<template x-for="blank in getFirstDayOfMonth(tempYear, tempMonth)" :key="'blank-' + blank">
<div class="text-center py-2"></div>
</template>
<template x-for="day in getDaysInMonth(tempYear, tempMonth)" :key="'day-' + day">
<button
type="button"
@click="selectDate(day)"
class="text-center py-2 rounded hover:bg-surface0 transition"
:class="{
'bg-blue text-mantle font-bold': isToday(day),
'hover:bg-surface1': !isToday(day)
}"
x-text="day"
></button>
</template>
</div>
</div>
</div>
</div>
}

View File

@@ -1,116 +0,0 @@
package footer
type FooterItem struct {
name string
href string
}
// Specify the links to show in the footer
func getFooterItems() []FooterItem {
return []FooterItem{
{
name: "About",
href: "/about",
},
}
}
// Returns the template fragment for the Footer
templ Footer() {
<footer class="bg-mantle mt-10">
<div
class="relative mx-auto max-w-screen-xl px-4 py-8 sm:px-6 lg:px-8"
>
<div class="absolute end-4 top-4 sm:end-6 lg:end-8">
<a
class="inline-block rounded-full bg-teal p-2 text-crust
shadow-sm transition hover:bg-teal/75"
href="#main-content"
>
<span class="sr-only">Back to top</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293
3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4
4a1 1 0 010 1.414z"
clip-rule="evenodd"
></path>
</svg>
</a>
</div>
<div class="lg:flex lg:items-end lg:justify-between">
<div>
<div class="flex justify-center text-text lg:justify-start">
// TODO: logo/branding here
<span class="text-2xl">OSL Stats</span>
</div>
<p
class="mx-auto max-w-md text-center leading-relaxed
text-subtext0"
>placeholder text</p>
</div>
<ul
class="mt-12 flex flex-wrap justify-center gap-6 md:gap-8
lg:mt-0 lg:justify-end lg:gap-12"
>
for _, item := range getFooterItems() {
<li>
<a
class="transition hover:text-subtext1"
href={ templ.SafeURL(item.href) }
>{ item.name }</a>
</li>
}
</ul>
</div>
<div class="lg:flex lg:items-end lg:justify-between">
<div>
<p class="mt-4 text-center text-sm text-overlay0">
by Haelnorr | placeholder text
</p>
</div>
<div>
<div class="mt-2 text-center">
<label
for="theme-select"
class="hidden lg:inline"
>Theme</label>
<select
name="ThemeSelect"
id="theme-select"
class="mt-1.5 inline rounded-lg bg-surface0 p-2 w-fit"
x-model="theme"
>
<template
x-for="themeopt in [
'dark',
'light',
'system',
]"
>
<option
x-text="displayThemeName(themeopt)"
:value="themeopt"
:selected="theme === themeopt"
></option>
</template>
</select>
<script>
const displayThemeName = (value) => {
if (value === "dark") return "Dark (Mocha)";
if (value === "light") return "Light (Latte)";
if (value === "system") return "System";
};
</script>
</div>
</div>
</div>
</div>
</footer>
}

View File

@@ -1,177 +0,0 @@
package form
import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker"
templ NewSeason() {
<form
hx-post="/seasons/new"
hx-swap="none"
x-data={ templ.JSFuncCall("newSeasonFormData").CallInline }
@submit="handleSubmit()"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful) { isSubmitting=false; buttonText='Create Season'; generalError='An error occurred. Please try again.'; }"
>
<script>
function newSeasonFormData() {
return {
canSubmit: false,
buttonText: "Create Season",
// Name validation state
nameError: "",
nameIsChecking: false,
nameIsUnique: false,
nameIsEmpty: true,
// Short name validation state
shortNameError: "",
shortNameIsChecking: false,
shortNameIsUnique: false,
shortNameIsEmpty: true,
// Date validation state
dateError: "",
dateIsEmpty: true,
// Form state
isSubmitting: false,
generalError: "",
submitTimeout: null,
// Reset name errors
resetNameErr() {
this.nameError = "";
this.nameIsChecking = false;
this.nameIsUnique = false;
},
// Reset short name errors
resetShortNameErr() {
this.shortNameError = "";
this.shortNameIsChecking = false;
this.shortNameIsUnique = false;
},
// Reset date errors
resetDateErr() {
this.dateError = "";
},
// Check if form can be submitted
updateCanSubmit() {
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 = '';
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttonText = 'Create Season';
this.generalError = 'Request timed out. Please try again.';
}, 10000);
}
};
}
</script>
<div class="grid gap-y-5">
<!-- Name Field -->
<div>
<label for="name" class="block text-sm font-medium mb-2">Season Name</label>
<div class="relative">
<input
type="text"
id="name"
name="name"
maxlength="20"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
'border-overlay0 focus:border-blue': !nameIsUnique && !nameError,
'border-green focus:border-green': nameIsUnique && !nameIsChecking && !nameError,
'border-red focus:border-red': nameError && !nameIsChecking && !isSubmitting
}"
required
placeholder="e.g. Season 1"
@input="resetNameErr(); nameIsEmpty = $el.value.trim() === ''; if(nameIsEmpty) { nameError='Season name is required'; nameIsUnique=false; } updateCanSubmit();"
hx-post="/htmx/isseasonnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { nameIsEmpty=true; return; } nameIsEmpty=false; nameIsChecking=true; nameIsUnique=false; nameError=''; updateCanSubmit();"
@htmx:after-request="nameIsChecking=false; if($event.detail.successful) { nameIsUnique=true; } else if($event.detail.xhr.status === 409) { nameError='This season name is already taken'; nameIsUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 20 characters</p>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="nameError && !isSubmitting"
x-cloak
x-text="nameError"
></p>
</div>
<!-- Short Name Field -->
<div>
<label for="short_name" class="block text-sm font-medium mb-2">Short Name</label>
<div class="relative">
<input
type="text"
id="short_name"
name="short_name"
maxlength="6"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none uppercase': true,
'border-overlay0 focus:border-blue': !shortNameIsUnique && !shortNameError,
'border-green focus:border-green': shortNameIsUnique && !shortNameIsChecking && !shortNameError,
'border-red focus:border-red': shortNameError && !shortNameIsChecking && !isSubmitting
}"
required
placeholder="e.g. S1"
pattern="[A-Z0-9]+"
@input="
let val = $el.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
$el.value = val;
resetShortNameErr();
shortNameIsEmpty = val.trim() === '';
if(shortNameIsEmpty) {
shortNameError='Short name is required';
shortNameIsUnique=false;
}
updateCanSubmit();
"
hx-post="/htmx/isseasonshortnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { shortNameIsEmpty=true; return; } shortNameIsEmpty=false; shortNameIsChecking=true; shortNameIsUnique=false; shortNameError=''; updateCanSubmit();"
@htmx:after-request="shortNameIsChecking=false; if($event.detail.successful) { shortNameIsUnique=true; } else if($event.detail.xhr.status === 409) { shortNameError='This short name is already taken'; shortNameIsUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 6 characters, alphanumeric only (auto-capitalized)</p>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="shortNameError && !isSubmitting"
x-cloak
x-text="shortNameError"
></p>
</div>
<!-- Start Date Field -->
@datepicker.DatePicker("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "dateIsEmpty = $el.value === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();")
<p
class="text-center text-xs text-red mt-2"
x-show="dateError && !isSubmitting"
x-cloak
x-text="dateError"
></p>
<!-- General Error Message -->
<p
class="text-center text-sm text-red"
x-show="generalError"
x-cloak
x-text="generalError"
></p>
<!-- Submit Button -->
<button
x-bind:disabled="!canSubmit || isSubmitting"
x-text="buttonText"
type="submit"
class="w-full py-3 px-4 inline-flex justify-center items-center
gap-x-2 rounded-lg border border-transparent transition text-base font-semibold
bg-blue hover:bg-blue/75 text-mantle hover:cursor-pointer
disabled:bg-blue/40 disabled:cursor-not-allowed"
></button>
</div>
</form>
}

View File

@@ -1,89 +0,0 @@
package form
templ RegisterForm(username string) {
<form
hx-post="/register"
hx-swap="none"
x-data={ templ.JSFuncCall("registerFormData").CallInline }
@submit="handleSubmit()"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful) { isSubmitting=false; buttontext='Register'; if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; } else { errorMessage='An error occurred. Please try again.'; } }"
>
<script>
function registerFormData() {
return {
canSubmit: false,
buttontext: "Register",
errorMessage: "",
isChecking: false,
isUnique: false,
isEmpty: true,
isSubmitting: false,
submitTimeout: null,
resetErr() {
this.errorMessage = "";
this.isChecking = false;
this.isUnique = false;
},
enableSubmit() {
this.canSubmit = true;
},
handleSubmit() {
this.isSubmitting = true;
this.buttontext = 'Loading...';
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttontext = 'Register';
this.errorMessage = 'Request timed out. Please try again.';
}, 10000);
}
};
}
</script>
<div
class="grid gap-y-4"
>
<div>
<div class="relative">
<input
type="text"
id="username"
name="username"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
'border-overlay0 focus:border-blue': !isUnique && !errorMessage,
'border-green focus:border-green': isUnique && !isChecking && !errorMessage,
'border-red focus:border-red': errorMessage && !isChecking && !isSubmitting
}"
required
aria-describedby="username-error"
value={ username }
@input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }"
hx-post="/htmx/isusernameunique"
hx-trigger="load delay:100ms, input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { isEmpty=true; return; } isEmpty=false; isChecking=true; isUnique=false; errorMessage=''"
@htmx:after-request="isChecking=false; if($event.detail.successful) { isUnique=true; canSubmit=true; } else if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; canSubmit=false; }"
/>
<p
class="text-center text-xs text-red mt-2"
id="username-error"
x-show="errorMessage && !isSubmitting"
x-cloak
x-text="errorMessage"
></p>
</div>
</div>
<button
x-bind:disabled="isEmpty || !isUnique || isChecking || isSubmitting"
x-text="buttontext"
type="submit"
class="w-full py-3 px-4 inline-flex justify-center items-center
gap-x-2 rounded-lg border border-transparent transition
bg-green hover:bg-green/75 text-mantle hover:cursor-pointer
disabled:bg-green/60 disabled:cursor-default"
></button>
</div>
</form>
}

View File

@@ -1,41 +0,0 @@
package nav
type NavItem struct {
name string // Label to display
href string // Link reference
}
// Return the list of navbar links
func getNavItems() []NavItem {
return []NavItem{
{
name: "Seasons",
href: "/seasons",
},
}
}
// Returns the navbar template fragment
templ Navbar() {
{{ navItems := getNavItems() }}
<div x-data="{ open: false }">
<header class="bg-crust">
<div
class="mx-auto flex h-16 max-w-7xl items-center gap-8
px-4 sm:px-6 lg:px-8"
>
<a class="block" href="/">
<!-- logo here -->
<span class="text-3xl font-bold transition hover:text-green">
OSL Stats
</span>
</a>
<div class="flex flex-1 items-center justify-end sm:justify-between">
@navLeft(navItems)
@navRight()
</div>
</div>
</header>
@sideNav(navItems)
</div>
}

View File

@@ -1,19 +0,0 @@
package nav
// Returns the left portion of the navbar
templ navLeft(navItems []NavItem) {
<nav aria-label="Global" class="hidden sm:block">
<ul class="flex items-center gap-6 text-xl">
for _, item := range navItems {
<li>
<a
class="text-subtext1 hover:text-green transition"
href={ templ.SafeURL(item.href) }
>
{ item.name }
</a>
</li>
}
</ul>
</nav>
}

View File

@@ -1,131 +0,0 @@
package nav
import (
"context"
"git.haelnorr.com/h/oslstats/internal/contexts"
"git.haelnorr.com/h/oslstats/internal/db"
)
type ProfileItem struct {
name string // Label to display
href string // Link reference
}
// Return the list of profile links
func getProfileItems(ctx context.Context) []ProfileItem {
items := []ProfileItem{
{
name: "Profile",
href: "/profile",
},
{
name: "Account",
href: "/account",
},
}
// Add admin link if user has admin role
cache := contexts.Permissions(ctx)
if cache != nil && cache.Roles["admin"] {
items = append(items, ProfileItem{
name: "Admin Panel",
href: "/admin",
})
}
return items
}
// Returns the right portion of the navbar
templ navRight() {
{{ user := db.CurrentUser(ctx) }}
{{ items := getProfileItems(ctx) }}
<div class="flex items-center gap-2">
<div class="sm:flex sm:gap-2">
if user != nil {
<div x-data="{ isActive: false }" class="relative">
<div
class="inline-flex items-center overflow-hidden
rounded-lg bg-sapphire hover:bg-sapphire/75 transition"
>
<button
x-on:click="isActive = !isActive"
class="h-full py-2 px-4 text-mantle hover:cursor-pointer"
>
<span class="sr-only">Profile</span>
{ user.Username }
</button>
</div>
<div
class="absolute end-0 z-10 mt-2 w-36 divide-y
divide-surface2 rounded-lg border border-surface1
bg-surface0 shadow-lg"
role="menu"
x-cloak
x-transition
x-show="isActive"
x-on:click.away="isActive = false"
x-on:keydown.escape.window="isActive = false"
>
<div class="p-2">
for _, item := range items {
<a
href={ templ.SafeURL(item.href) }
class="block rounded-lg px-4 py-2 text-md
hover:bg-crust"
role="menuitem"
>
{ item.name }
</a>
}
</div>
<div class="p-2">
<form hx-post="/logout">
<button
type="submit"
class="flex w-full items-center gap-2
rounded-lg px-4 py-2 text-md text-red
hover:bg-red/25 hover:cursor-pointer"
role="menuitem"
@click="isActive=false"
>
Logout
</button>
</form>
</div>
</div>
</div>
} else {
<button
class="hidden rounded-lg px-4 py-2 sm:block hover:cursor-pointer
bg-green hover:bg-green/75 text-mantle transition"
hx-post="/login"
hx-swap="none"
>
Login
</button>
}
</div>
<button
@click="open = !open"
class="block rounded-lg p-2.5 sm:hidden transition
bg-surface0 text-subtext0 hover:text-overlay2/75"
>
<span class="sr-only">Toggle menu</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
</button>
</div>
}

View File

@@ -1,45 +0,0 @@
package nav
import "git.haelnorr.com/h/oslstats/internal/db"
// Returns the mobile version of the navbar thats only visible when activated
templ sideNav(navItems []NavItem) {
{{ user := db.CurrentUser(ctx) }}
<div
x-show="open"
x-transition
class="absolute w-full bg-mantle sm:hidden z-10"
>
<div class="px-4 py-6">
<ul class="space-y-1">
for _, item := range navItems {
<li>
<a
href={ templ.SafeURL(item.href) }
class="block rounded-lg px-4 py-2 text-lg
bg-surface0 text-text transition hover:bg-surface2"
>
{ item.name }
</a>
</li>
}
</ul>
</div>
if user == nil {
<div class="px-4 pb-6">
<ul class="space-y-1">
<li class="flex justify-center items-center gap-2">
<a
class="w-26 px-4 py-2 rounded-lg
bg-green text-mantle transition hover:bg-green/75
text-center"
href="/login"
>
Login
</a>
</li>
</ul>
</div>
}
</div>
}

View File

@@ -1,101 +0,0 @@
package pagination
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
templ Pagination(opts db.PageOpts, total int) {
<div class="mt-6 flex flex-col gap-4">
<input type="hidden" name="page" id="pagination-page"/>
<input type="hidden" name="per_page" id="pagination-per-page"/>
<!-- Page info and per-page selector -->
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-subtext0">
<div>
if total > 0 {
Showing { fmt.Sprintf("%d", opts.StartItem()) } - { fmt.Sprintf("%d", opts.EndItem(total)) } of { fmt.Sprintf("%d", total) } results
} else {
No results
}
</div>
<div class="flex items-center gap-2">
<label for="per-page-select">Per page:</label>
<select
id="per-page-select"
class="py-1 px-2 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
x-model.number="perPage"
@change="setPerPage(perPage)"
>
<option value="1">1</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<!-- Pagination buttons -->
if total > 0 && opts.TotalPages(total) > 1 {
<div class="flex flex-wrap justify-center items-center gap-2">
<!-- First button -->
<button
type="button"
@click="goToPage(1)"
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">First</span>
<span class="sm:hidden">&lt;&lt;</span>
</button>
<!-- Previous button -->
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", opts.Page-1) }
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">Previous</span>
<span class="sm:hidden">&lt;</span>
</button>
<!-- Page numbers -->
for _, pageNum := range opts.GetPageRange(total, 7) {
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", pageNum) }
class={ "px-3 py-2 rounded-lg border transition",
templ.KV("bg-blue border-blue text-mantle font-bold", pageNum == opts.Page),
templ.KV("bg-mantle border-surface1 text-text hover:bg-surface0 hover:border-blue cursor-pointer", pageNum != opts.Page) }
>
{ fmt.Sprintf("%d", pageNum) }
</button>
}
<!-- Next button -->
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", opts.Page+1) }
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">Next</span>
<span class="sm:hidden">&gt;</span>
</button>
<!-- Last button -->
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", opts.TotalPages(total)) }
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">Last</span>
<span class="sm:hidden">&gt;&gt;</span>
</button>
</div>
}
</div>
}

View File

@@ -1,6 +0,0 @@
package popup
// ErrorModalContainer provides the target div for WebSocket error modal OOB swaps
templ ErrorModalContainer() {
<div id="error-modal-ws-container"></div>
}

View File

@@ -1,64 +0,0 @@
package popup
import "strconv"
import "fmt"
import "git.haelnorr.com/h/golib/notify"
// ErrorModalWS displays a WebSocket-triggered error modal
// Matches design of error page (page.ErrorWithDetails)
templ ErrorModalWS(code int, stacktrace string, nt notify.Notification, id int) {
<div
id="error-modal-ws-container"
hx-swap-oob="morph:#error-modal-ws-container"
>
<!-- Fullscreen backdrop overlay -->
<div class="fixed inset-0 z-50 bg-crust/80 flex items-center justify-center p-4">
<!-- Modal card -->
<div class="bg-base rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-8 text-center">
<!-- Error code -->
<h1 class="text-9xl text-text">{ strconv.Itoa(code) }</h1>
<!-- Error title -->
<p class="text-2xl font-bold tracking-tight text-subtext1 sm:text-4xl">
{ nt.Title }
</p>
<!-- User message -->
<p class="mt-4 text-subtext0">{ nt.Message }</p>
<!-- Details dropdown -->
if stacktrace != "" {
<div class="mt-8 text-left">
<details class="bg-surface0 rounded-lg p-4 text-right">
<summary class="text-left cursor-pointer text-subtext1 font-semibold select-none hover:text-text">
Details
<span class="text-xs text-subtext0 ml-2">(click to expand)</span>
</summary>
<div class="text-left mt-4 relative">
<pre
id={ fmt.Sprintf("error-modal-ws-details-%d", id) }
class="text-xs text-subtext0 font-mono whitespace-pre-wrap break-all bg-mantle p-4 rounded overflow-x-auto max-h-96"
>{ stacktrace }</pre>
</div>
<button
onclick={ templ.JSFuncCall("copyToClipboard", fmt.Sprintf("error-modal-ws-details-%v", id), "copyButton") }
id="copyButton"
class="mt-2 bg-mauve text-crust px-3 py-1 rounded text-xs hover:bg-mauve/75 transition hover:cursor-pointer"
title="Copy to clipboard"
>
Copy
</button>
</details>
</div>
}
<!-- Close button -->
<div class="mt-6">
<button
onclick="document.getElementById('error-modal-ws-container').innerHTML = ''"
class="inline-block rounded-lg bg-mauve px-5 py-3 text-sm text-crust transition hover:bg-mauve/75 hover:cursor-pointer"
>
Close Modal
</button>
</div>
</div>
</div>
<script src="/static/js/copytoclipboard.js"></script>
</div>
}

View File

@@ -1,116 +0,0 @@
package popup
import "fmt"
import "git.haelnorr.com/h/golib/notify"
import "git.haelnorr.com/h/golib/hws"
// ToastNotificationWS creates a toast notification sent via WebSocket with OOB swap
// Backend should pass: notifType ("success"|"warning"|"info"), title, message
templ Toast(nt notify.Notification, id, duration int) {
{{
type style struct {
bg string
fg string
border string
bgdark string
}
// Determine classes server-side based on notifType
var colors style
switch nt.Level {
case notify.LevelSuccess:
colors = style{bg: "bg-green", fg: "text-green", border: "border-green", bgdark: "bg-dark-green"}
case notify.LevelWarn, hws.LevelShutdown:
colors = style{bg: "bg-yellow", fg: "text-yellow", border: "border-yellow", bgdark: "bg-dark-yellow"}
case notify.LevelInfo:
colors = style{bg: "bg-blue", fg: "text-blue", border: "border-blue", bgdark: "bg-dark-blue"}
}
}}
<div
id="toast-ws-container"
hx-swap-oob="beforeend:#toast-ws-container"
>
<div
id={ fmt.Sprintf("ws-toast-%d", id) }
class={ fmt.Sprintf("pointer-events-auto rounded-lg shadow-lg overflow-hidden w-full mb-2 border-2 %s %s", colors.bgdark, colors.border) }
role="alert"
x-data={ templ.JSFuncCall("progressBar", fmt.Sprintf("ws-toast-%d", id), duration).CallInline }
@mouseenter="paused = true"
@mouseleave="paused = false"
>
<!-- Toast Content -->
<div class="p-4 max-w-80">
<div class="flex justify-between items-start gap-3">
<!-- Icon + Content -->
<div class="flex items-start gap-3 flex-1">
<!-- Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class={ fmt.Sprintf("size-5 shrink-0 %s", colors.fg) }
>
switch nt.Level {
case notify.LevelSuccess:
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
></path>
case notify.LevelWarn:
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
default:
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
}
</svg>
<!-- Text Content -->
<div class="flex-1 min-w-0 max-w-48 text-wrap">
<p class={ fmt.Sprintf("font-semibold %s", colors.fg) }>
{ nt.Title }
</p>
<p class="text-sm text-subtext0 mt-1 text-wrap">
{ nt.Message }
</p>
</div>
</div>
<!-- Close Button -->
<button
onclick="this.closest('[id^=ws-toast-]').remove()"
class="text-subtext0 hover:text-text transition shrink-0 hover:cursor-pointer"
aria-label="Close notification"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
<!-- Progress Bar -->
<div class="h-1">
<div
id={ fmt.Sprintf("ws-toast-%d-progress", id) }
class={ fmt.Sprintf("h-full %s", colors.bg) }
style="width: 0"
></div>
</div>
</div>
</div>
}

View File

@@ -1,10 +0,0 @@
package popup
// ToastContainer displays stacked toast notifications (success, warning, info)
// Supports both regular toasts (AlpineJS) and WebSocket toasts (HTMX OOB swaps)
templ ToastContainer() {
<script src="/static/js/toasts.js" defer></script>
<div class="fixed top-20 right-5 z-40 flex flex-col gap-3 max-w-sm pointer-events-none">
<div id="toast-ws-container"></div>
</div>
}

View File

@@ -1,71 +0,0 @@
package season
import "git.haelnorr.com/h/oslstats/internal/db"
import "time"
// StatusBadge renders a season status badge
// Parameters:
// - season: pointer to db.Season
// - compact: bool - true for list view (text only, small), false for detail view (icon + text, large)
// - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress"
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) {
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
status = "Completed"
statusColor = "text-green"
statusBg = "bg-green/10 border-green"
} else {
if useShortLabels {
status = "Finals"
} else {
status = "Finals in Progress"
}
statusColor = "text-yellow"
statusBg = "bg-yellow/10 border-yellow"
}
} else {
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"
}
}}
<div class={ "rounded-lg border-2 inline-flex items-center gap-2 " + sizeClasses + " " + statusBg }>
if !compact {
<span class={ iconSize + " " + statusColor }>●</span>
}
<span class={ textSize + " font-semibold " + statusColor }>{ status }</span>
</div>
}

View File

@@ -1,26 +0,0 @@
package sort
import "git.haelnorr.com/h/oslstats/internal/db"
import "strings"
templ Dropdown(pageopts db.PageOpts, orderopts []db.OrderOpts) {
<div class="flex items-center gap-2">
<input type="hidden" name="order" id="sort-order"/>
<input type="hidden" name="order_by" id="sort-order-by"/>
<label for="sort-select" class="text-sm text-subtext0">Sort by:</label>
<select
id="sort-select"
class="py-2 px-3 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
@change="handleSortChange($event.target.value)"
>
for _, opt := range orderopts {
<option
value={ strings.Join([]string{opt.OrderBy, string(opt.Order)}, "|") }
selected?={ pageopts.OrderBy == opt.OrderBy && pageopts.Order == opt.Order }
>
{ opt.Label }
</option>
}
</select>
</div>
}