league #1
@@ -74,9 +74,14 @@ func addRoutes(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/new",
|
Path: "/seasons/new",
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Method: hws.MethodGET,
|
||||||
Handler: handlers.NewSeason(s, conn),
|
Handler: handlers.NewSeason(s, conn),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/new",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.NewSeasonSubmit(s, conn),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/{season_short_name}",
|
Path: "/seasons/{season_short_name}",
|
||||||
Method: hws.MethodGET,
|
Method: hws.MethodGET,
|
||||||
|
|||||||
@@ -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) {
|
func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) {
|
||||||
var season *Season
|
season := new(Season)
|
||||||
err := tx.NewSelect().
|
err := tx.NewSelect().
|
||||||
Model(season).
|
Model(season).
|
||||||
Where("short_name = ?", strings.ToUpper(shortname)).
|
Where("short_name = ?", strings.ToUpper(shortname)).
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
@@ -29,9 +31,162 @@ func NewSeasonSubmit(
|
|||||||
conn *bun.DB,
|
conn *bun.DB,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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(
|
func IsSeasonNameUnique(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
conn *bun.DB,
|
conn *bun.DB,
|
||||||
@@ -52,7 +207,11 @@ func IsSeasonNameUnique(
|
|||||||
}
|
}
|
||||||
return
|
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)
|
unique, err := db.IsSeasonNameUnique(ctx, tx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique"))
|
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique"))
|
||||||
@@ -61,10 +220,15 @@ func IsSeasonNameUnique(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
if !unique {
|
if !unique {
|
||||||
w.WriteHeader(http.StatusConflict)
|
w.WriteHeader(http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +252,11 @@ func IsSeasonShortNameUnique(
|
|||||||
}
|
}
|
||||||
return
|
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)
|
unique, err := db.IsSeasonShortNameUnique(ctx, tx, shortname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique"))
|
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique"))
|
||||||
@@ -97,9 +265,14 @@ func IsSeasonShortNameUnique(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
if !unique {
|
if !unique {
|
||||||
w.WriteHeader(http.StatusConflict)
|
w.WriteHeader(http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
154
internal/view/component/datepicker/README.md
Normal file
154
internal/view/component/datepicker/README.md
Normal file
@@ -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
|
||||||
|
<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.
|
||||||
239
internal/view/component/datepicker/datepicker.templ
Normal file
239
internal/view/component/datepicker/datepicker.templ
Normal file
@@ -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) {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
@@ -1,4 +1,177 @@
|
|||||||
package form
|
package form
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker"
|
||||||
|
|
||||||
templ NewSeason() {
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ import "git.haelnorr.com/h/oslstats/internal/view/component/form"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||||
|
|
||||||
templ NewSeason() {
|
templ NewSeason() {
|
||||||
@layout.Global("New Season")
|
@layout.Global("New Season") {
|
||||||
@form.NewSeason()
|
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-xl">
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-text">Create New Season</h1>
|
||||||
|
<p class="mt-2 text-sm text-subtext0">
|
||||||
|
Add a new season to the system. All fields are required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
@form.NewSeason()
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
|
--breakpoint-lg: 64rem;
|
||||||
--breakpoint-xl: 80rem;
|
--breakpoint-xl: 80rem;
|
||||||
--breakpoint-2xl: 96rem;
|
--breakpoint-2xl: 96rem;
|
||||||
--container-sm: 24rem;
|
--container-sm: 24rem;
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
--text-6xl--line-height: 1;
|
--text-6xl--line-height: 1;
|
||||||
--text-9xl: 8rem;
|
--text-9xl: 8rem;
|
||||||
--text-9xl--line-height: 1;
|
--text-9xl--line-height: 1;
|
||||||
|
--font-weight-medium: 500;
|
||||||
--font-weight-semibold: 600;
|
--font-weight-semibold: 600;
|
||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
--tracking-tight: -0.025em;
|
--tracking-tight: -0.025em;
|
||||||
@@ -241,6 +243,9 @@
|
|||||||
.top-0 {
|
.top-0 {
|
||||||
top: calc(var(--spacing) * 0);
|
top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.top-1\/2 {
|
||||||
|
top: calc(1/2 * 100%);
|
||||||
|
}
|
||||||
.top-4 {
|
.top-4 {
|
||||||
top: calc(var(--spacing) * 4);
|
top: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -250,6 +255,9 @@
|
|||||||
.right-0 {
|
.right-0 {
|
||||||
right: calc(var(--spacing) * 0);
|
right: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.right-3 {
|
||||||
|
right: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.right-5 {
|
.right-5 {
|
||||||
right: calc(var(--spacing) * 5);
|
right: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
@@ -356,6 +364,9 @@
|
|||||||
.h-1 {
|
.h-1 {
|
||||||
height: calc(var(--spacing) * 1);
|
height: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.h-5 {
|
||||||
|
height: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.h-16 {
|
.h-16 {
|
||||||
height: calc(var(--spacing) * 16);
|
height: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
@@ -374,12 +385,18 @@
|
|||||||
.min-h-\[calc\(100vh-200px\)\] {
|
.min-h-\[calc\(100vh-200px\)\] {
|
||||||
min-height: calc(100vh - 200px);
|
min-height: calc(100vh - 200px);
|
||||||
}
|
}
|
||||||
|
.w-5 {
|
||||||
|
width: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.w-26 {
|
.w-26 {
|
||||||
width: calc(var(--spacing) * 26);
|
width: calc(var(--spacing) * 26);
|
||||||
}
|
}
|
||||||
.w-36 {
|
.w-36 {
|
||||||
width: calc(var(--spacing) * 36);
|
width: calc(var(--spacing) * 36);
|
||||||
}
|
}
|
||||||
|
.w-80 {
|
||||||
|
width: calc(var(--spacing) * 80);
|
||||||
|
}
|
||||||
.w-fit {
|
.w-fit {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
@@ -407,6 +424,9 @@
|
|||||||
.max-w-screen-2xl {
|
.max-w-screen-2xl {
|
||||||
max-width: var(--breakpoint-2xl);
|
max-width: var(--breakpoint-2xl);
|
||||||
}
|
}
|
||||||
|
.max-w-screen-lg {
|
||||||
|
max-width: var(--breakpoint-lg);
|
||||||
|
}
|
||||||
.max-w-screen-xl {
|
.max-w-screen-xl {
|
||||||
max-width: var(--breakpoint-xl);
|
max-width: var(--breakpoint-xl);
|
||||||
}
|
}
|
||||||
@@ -419,9 +439,16 @@
|
|||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-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 {
|
||||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
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-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.grid-cols-7 {
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -461,6 +491,9 @@
|
|||||||
.justify-end {
|
.justify-end {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
.gap-1 {
|
||||||
|
gap: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.gap-2 {
|
.gap-2 {
|
||||||
gap: calc(var(--spacing) * 2);
|
gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -496,6 +529,9 @@
|
|||||||
.gap-y-4 {
|
.gap-y-4 {
|
||||||
row-gap: calc(var(--spacing) * 4);
|
row-gap: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.gap-y-5 {
|
||||||
|
row-gap: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.divide-y {
|
.divide-y {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-divide-y-reverse: 0;
|
--tw-divide-y-reverse: 0;
|
||||||
@@ -633,6 +669,9 @@
|
|||||||
background-color: color-mix(in oklab, var(--yellow) 10%, transparent);
|
background-color: color-mix(in oklab, var(--yellow) 10%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.p-1 {
|
||||||
|
padding: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.p-2 {
|
.p-2 {
|
||||||
padding: calc(var(--spacing) * 2);
|
padding: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -678,6 +717,9 @@
|
|||||||
.py-8 {
|
.py-8 {
|
||||||
padding-block: calc(var(--spacing) * 8);
|
padding-block: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.pr-10 {
|
||||||
|
padding-right: calc(var(--spacing) * 10);
|
||||||
|
}
|
||||||
.pb-6 {
|
.pb-6 {
|
||||||
padding-bottom: calc(var(--spacing) * 6);
|
padding-bottom: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
@@ -733,6 +775,10 @@
|
|||||||
--tw-font-weight: var(--font-weight-bold);
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
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 {
|
.font-semibold {
|
||||||
--tw-font-weight: var(--font-weight-semibold);
|
--tw-font-weight: var(--font-weight-semibold);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
@@ -750,6 +796,9 @@
|
|||||||
.whitespace-pre-wrap {
|
.whitespace-pre-wrap {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
.text-base {
|
||||||
|
color: var(--base);
|
||||||
|
}
|
||||||
.text-blue {
|
.text-blue {
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
@@ -780,6 +829,9 @@
|
|||||||
.text-yellow {
|
.text-yellow {
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
.uppercase {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -894,6 +946,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-surface1 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--surface1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-surface2 {
|
.hover\:bg-surface2 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -984,6 +1043,19 @@
|
|||||||
cursor: default;
|
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\:bg-green\/60 {
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
@@ -1047,6 +1119,11 @@
|
|||||||
padding: calc(var(--spacing) * 7);
|
padding: calc(var(--spacing) * 7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:p-8 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
padding: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:px-6 {
|
.sm\:px-6 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
@@ -1227,6 +1304,21 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: italic;
|
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 {
|
@property --tw-rotate-x {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -1342,6 +1434,9 @@
|
|||||||
@layer properties {
|
@layer properties {
|
||||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
@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 {
|
*, ::before, ::after, ::backdrop {
|
||||||
|
--tw-translate-x: 0;
|
||||||
|
--tw-translate-y: 0;
|
||||||
|
--tw-translate-z: 0;
|
||||||
--tw-rotate-x: initial;
|
--tw-rotate-x: initial;
|
||||||
--tw-rotate-y: initial;
|
--tw-rotate-y: initial;
|
||||||
--tw-rotate-z: initial;
|
--tw-rotate-z: initial;
|
||||||
|
|||||||
Reference in New Issue
Block a user