league #1

Merged
h merged 41 commits from league into master 2026-02-15 19:59:31 +11:00
25 changed files with 1327 additions and 52 deletions
Showing only changes of commit c92c722ad5 - Show all commits

View File

@@ -33,10 +33,12 @@ func registerDBModels(conn *bun.DB) []any {
(*db.RolePermission)(nil),
(*db.UserRole)(nil),
(*db.SeasonLeague)(nil),
(*db.TeamParticipation)(nil),
(*db.User)(nil),
(*db.DiscordToken)(nil),
(*db.Season)(nil),
(*db.League)(nil),
(*db.Team)(nil),
(*db.Role)(nil),
(*db.Permission)(nil),
(*db.AuditLog)(nil),

View File

@@ -0,0 +1,49 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, dbConn *bun.DB) error {
// Add your migration code here
_, err := dbConn.NewCreateTable().
Model((*db.Team)(nil)).
Exec(ctx)
if err != nil {
return err
}
_, err = dbConn.NewCreateTable().
Model((*db.TeamParticipation)(nil)).
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, dbConn *bun.DB) error {
// Add your rollback code here
_, err := dbConn.NewDropTable().
Model((*db.TeamParticipation)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
_, err = dbConn.NewDropTable().
Model((*db.Team)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
)
}

View File

@@ -31,11 +31,6 @@ func addRoutes(
) error {
// Create the routes
pageroutes := []hws.Route{
{
Path: "/permtest",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.PermTester(s, conn),
},
{
Path: "/static/",
Method: hws.MethodGET,
@@ -108,6 +103,11 @@ func addRoutes(
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}",
Method: hws.MethodGET,
Handler: handlers.SeasonLeaguePage(s, conn),
},
{
Path: "/seasons/{season_short_name}/leagues/add/{league_short_name}",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.SeasonsAddLeague)(handlers.SeasonAddLeague(s, conn, audit)),
},
@@ -116,6 +116,11 @@ func addRoutes(
Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.SeasonsRemoveLeague)(handlers.SeasonRemoveLeague(s, conn, audit)),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/teams/add",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn, audit)),
},
{
Path: "/leagues",
Method: hws.MethodGET,
@@ -131,6 +136,26 @@ func addRoutes(
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeagueSubmit(s, conn, audit)),
},
{
Path: "/teams",
Method: hws.MethodGET,
Handler: handlers.TeamsPage(s, conn),
},
{
Path: "/teams",
Method: hws.MethodPOST,
Handler: handlers.TeamsList(s, conn),
},
{
Path: "/teams/new",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamPage(s, conn)),
},
{
Path: "/teams/new",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn, audit)),
},
}
htmxRoutes := []hws.Route{
@@ -159,6 +184,16 @@ func addRoutes(
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "short_name"),
},
{
Path: "/htmx/isteamnameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.Team)(nil), "name"),
},
{
Path: "/htmx/isteamshortnamesunique",
Method: hws.MethodPOST,
Handler: handlers.IsTeamShortNamesUnique(s, conn),
},
}
wsRoutes := []hws.Route{

View File

@@ -16,6 +16,7 @@ type League struct {
Description string `bun:"description"`
Seasons []Season `bun:"m2m:season_leagues,join:League=Season"`
Teams []Team `bun:"m2m:team_participations,join:League=Team"`
}
type SeasonLeague struct {
@@ -35,3 +36,42 @@ func GetLeague(ctx context.Context, tx bun.Tx, shortname string) (*League, error
}
return GetByField[League](tx, "short_name", shortname).Relation("Seasons").Get(ctx)
}
// GetSeasonLeague retrieves a specific season-league combination with teams
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
if seasonShortName == "" {
return nil, nil, nil, errors.New("season short_name cannot be empty")
}
if leagueShortName == "" {
return nil, nil, nil, errors.New("league short_name cannot be empty")
}
// Get the season
season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason")
}
// Get the league
league, err := GetLeague(ctx, tx, leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague")
}
if season == nil || league == nil || !season.HasLeague(league.ID) {
return nil, nil, nil, nil
}
// Get all teams participating in this season+league
var teams []*Team
err = tx.NewSelect().
Model(&teams).
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
Order("t.name ASC").
Scan(ctx)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
}
return season, league, teams, nil
}

View File

@@ -22,6 +22,7 @@ type Season struct {
SlapVersion string `bun:"slap_version,notnull,default:'rebound'"`
Leagues []League `bun:"m2m:season_leagues,join:Season=League"`
Teams []Team `bun:"m2m:team_participations,join:Season=Team"`
}
// NewSeason returns a new season. It does not add it to the database
@@ -49,7 +50,7 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error
if shortname == "" {
return nil, errors.New("short_name not provided")
}
return GetByField[Season](tx, "short_name", shortname).Relation("Leagues").Get(ctx)
return GetByField[Season](tx, "short_name", shortname).Relation("Leagues").Relation("Teams").Get(ctx)
}
// Update updates the season struct. It does not insert to the database
@@ -66,3 +67,39 @@ func (s *Season) Update(version string, start, end, finalsStart, finalsEnd time.
s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24)
}
}
func (s *Season) MapTeamsToLeagues(ctx context.Context, tx bun.Tx) ([]LeagueWithTeams, error) {
// For each league, get the teams
leaguesWithTeams := make([]LeagueWithTeams, len(s.Leagues))
for i, league := range s.Leagues {
var teams []*Team
err := tx.NewSelect().
Model(&teams).
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
Where("tp.season_id = ? AND tp.league_id = ?", s.ID, league.ID).
Order("t.name ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
leaguesWithTeams[i] = LeagueWithTeams{
League: &league,
Teams: teams,
}
}
return leaguesWithTeams, nil
}
type LeagueWithTeams struct {
League *League
Teams []*Team
}
func (s *Season) HasLeague(leagueID int) bool {
for _, league := range s.Leagues {
if league.ID == leagueID {
return true
}
}
return false
}

58
internal/db/team.go Normal file
View File

@@ -0,0 +1,58 @@
package db
import (
"context"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Team struct {
bun.BaseModel `bun:"table:teams,alias:t"`
ID int `bun:"id,pk,autoincrement"`
Name string `bun:"name,unique,notnull"`
ShortName string `bun:"short_name,notnull,unique:short_names"`
AltShortName string `bun:"alt_short_name,notnull,unique:short_names"`
Color string `bun:"color"`
Seasons []Season `bun:"m2m:team_participations,join:Team=Season"`
Leagues []League `bun:"m2m:team_participations,join:Team=League"`
}
type TeamParticipation struct {
SeasonID int `bun:",pk,unique:season_team"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
LeagueID int `bun:",pk"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
TeamID int `bun:",pk,unique:season_team"`
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
}
func ListTeams(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Team], error) {
defaults := &PageOpts{
1,
10,
bun.OrderAsc,
"name",
}
return GetList[Team](tx).GetPaged(ctx, pageOpts, defaults)
}
func GetTeam(ctx context.Context, tx bun.Tx, id int) (*Team, error) {
if id == 0 {
return nil, errors.New("id not provided")
}
return GetByID[Team](tx, id).Relation("Seasons").Relation("Leagues").Get(ctx)
}
func TeamShortNamesUnique(ctx context.Context, tx bun.Tx, shortname, altshortname string) (bool, error) {
// Check if this combination of short_name and alt_short_name exists
count, err := tx.NewSelect().
Model((*Team)(nil)).
Where("short_name = ? AND alt_short_name = ?", shortname, altshortname).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.Select")
}
return count == 0, nil
}

View File

@@ -410,6 +410,9 @@
.h-1 {
height: calc(var(--spacing) * 1);
}
.h-3 {
height: calc(var(--spacing) * 3);
}
.h-4 {
height: calc(var(--spacing) * 4);
}
@@ -443,6 +446,9 @@
.min-h-full {
min-height: 100%;
}
.w-3 {
width: calc(var(--spacing) * 3);
}
.w-4 {
width: calc(var(--spacing) * 4);
}
@@ -455,6 +461,9 @@
.w-12 {
width: calc(var(--spacing) * 12);
}
.w-20 {
width: calc(var(--spacing) * 20);
}
.w-26 {
width: calc(var(--spacing) * 26);
}
@@ -560,6 +569,9 @@
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-7 {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
@@ -612,6 +624,13 @@
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-2 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-3 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@@ -654,6 +673,11 @@
border-color: var(--surface2);
}
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-hidden {
overflow: hidden;
}
@@ -869,6 +893,9 @@
.py-8 {
padding-block: calc(var(--spacing) * 8);
}
.pt-4 {
padding-top: calc(var(--spacing) * 4);
}
.pt-5 {
padding-top: calc(var(--spacing) * 5);
}
@@ -878,6 +905,9 @@
.pr-10 {
padding-right: calc(var(--spacing) * 10);
}
.pb-3 {
padding-bottom: calc(var(--spacing) * 3);
}
.pb-4 {
padding-bottom: calc(var(--spacing) * 4);
}
@@ -1205,6 +1235,13 @@
}
}
}
.hover\:text-blue {
&:hover {
@media (hover: hover) {
color: var(--blue);
}
}
}
.hover\:text-green {
&:hover {
@media (hover: hover) {
@@ -1301,6 +1338,14 @@
}
}
}
.disabled\:bg-green\/40 {
&:disabled {
background-color: var(--green);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--green) 40%, transparent);
}
}
}
.disabled\:bg-green\/60 {
&:disabled {
background-color: var(--green);
@@ -1359,6 +1404,16 @@
height: calc(var(--spacing) * 10);
}
}
.sm\:w-1\/3 {
@media (width >= 40rem) {
width: calc(1/3 * 100%);
}
}
.sm\:w-2\/3 {
@media (width >= 40rem) {
width: calc(2/3 * 100%);
}
}
.sm\:w-10 {
@media (width >= 40rem) {
width: calc(var(--spacing) * 10);

View File

@@ -1,25 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/roles"
"git.haelnorr.com/h/oslstats/internal/throw"
"github.com/uptrace/bun"
)
func PermTester(s *hws.Server, conn *bun.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := db.CurrentUser(r.Context())
tx, _ := conn.BeginTx(r.Context(), nil)
isAdmin, err := user.HasRole(r.Context(), tx, roles.Admin)
tx.Rollback()
if err != nil {
throw.InternalServiceError(s, w, r, "Error", err)
}
_, _ = w.Write([]byte(strconv.FormatBool(isAdmin)))
})
}

View File

@@ -19,12 +19,23 @@ func SeasonPage(
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
var season *db.Season
var leaguesWithTeams []db.LeagueWithTeams
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
}
if season == nil {
return true, nil
}
leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx)
if err != nil {
return false, errors.Wrap(err, "season.MapTeamsToLeagues")
}
return true, nil
}); !ok {
return
@@ -33,6 +44,6 @@ func SeasonPage(
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(seasonsview.DetailPage(season), s, r, w)
renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w)
})
}

View File

@@ -0,0 +1,119 @@
package handlers
import (
"context"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/auditlog"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func SeasonLeagueAddTeam(
s *hws.Server,
conn *bun.DB,
audit *auditlog.Logger,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
teamID := getter.Int("team_id").Required().Value
if ok := getter.ValidateAndNotify(s, w, r); !ok {
return
}
var season *db.Season
var league *db.League
var team *db.Team
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
// Get season
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
}
if season == nil {
notify.Warn(s, w, r, "Not Found", "Season not found.", nil)
return false, nil
}
// Get league
league, err = db.GetLeague(ctx, tx, leagueStr)
if err != nil {
return false, errors.Wrap(err, "db.GetLeague")
}
if league == nil {
notify.Warn(s, w, r, "Not Found", "League not found.", nil)
return false, nil
}
if !season.HasLeague(league.ID) {
notify.Warn(s, w, r, "Invalid League", "This league is not associated with this season.", nil)
return false, nil
}
// Get team
team, err = db.GetTeam(ctx, tx, teamID)
if err != nil {
return false, errors.Wrap(err, "db.GetTeam")
}
if team == nil {
notify.Warn(s, w, r, "Not Found", "Team not found.", nil)
return false, nil
}
// Check if team is already in this season (in any league)
var tpCount int
tpCount, err = tx.NewSelect().
Model((*db.TeamParticipation)(nil)).
Where("season_id = ? AND team_id = ?", season.ID, team.ID).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
if tpCount > 0 {
notify.Warn(s, w, r, "Already In Season", fmt.Sprintf(
"Team '%s' is already participating in this season.",
team.Name,
), nil)
return false, nil
}
// Add team to league
participation := &db.TeamParticipation{
SeasonID: season.ID,
LeagueID: league.ID,
TeamID: team.ID,
}
err = db.Insert(tx, participation).WithAudit(r, audit.Callback()).Exec(ctx)
if err != nil {
return false, errors.Wrap(err, "db.Insert")
}
return true, nil
}); !ok {
return
}
// Redirect to refresh the page
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s/leagues/%s", season.ShortName, league.ShortName))
w.WriteHeader(http.StatusOK)
notify.Success(s, w, r, "Team Added", fmt.Sprintf(
"Successfully added '%s' to the league.",
team.Name,
), nil)
})
}

View File

@@ -0,0 +1,53 @@
package handlers
import (
"context"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func SeasonLeaguePage(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var teams []*db.Team
var allTeams []*db.Team
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
// Get all teams for the dropdown (to add teams)
allTeams, err = db.GetList[db.Team](tx).GetAll(ctx)
if err != nil {
return false, errors.Wrap(err, "db.GetList[Team]")
}
return true, nil
}); !ok {
return
}
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(seasonsview.SeasonLeaguePage(season, league, teams, allTeams), s, r, w)
})
}

View File

@@ -0,0 +1,57 @@
package handlers
import (
"context"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// IsTeamShortNamesUnique checks if the combination of short_name and alt_short_name is unique
// and also validates that they are different from each other
func IsTeamShortNamesUnique(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, err := validation.ParseForm(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
shortName := getter.String("short_name").TrimSpace().ToUpper().MaxLength(3).Value
altShortName := getter.String("alt_short_name").TrimSpace().ToUpper().MaxLength(3).Value
if shortName == "" || altShortName == "" {
w.WriteHeader(http.StatusOK)
return
}
if shortName == altShortName {
w.WriteHeader(http.StatusConflict)
return
}
var isUnique bool
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
isUnique, err = db.TeamShortNamesUnique(ctx, tx, shortName, altShortName)
if err != nil {
return false, errors.Wrap(err, "db.TeamShortNamesUnique")
}
return true, nil
}); !ok {
return
}
if isUnique {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusConflict)
}
})
}

View File

@@ -0,0 +1,62 @@
package handlers
import (
"context"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// TeamsPage renders the full page with the teams list, for use with GET requests
func TeamsPage(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pageOpts := pageOptsFromQuery(s, w, r)
if pageOpts == nil {
return
}
var teams *db.List[db.Team]
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
teams, err = db.ListTeams(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.ListTeams")
}
return true, nil
}); !ok {
return
}
renderSafely(teamsview.ListPage(teams), s, r, w)
})
}
// TeamsList renders just the teams list, for use with POST requests and HTMX
func TeamsList(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pageOpts := pageOptsFromForm(s, w, r)
if pageOpts == nil {
return
}
var teams *db.List[db.Team]
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
teams, err = db.ListTeams(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.ListTeams")
}
return true, nil
}); !ok {
return
}
renderSafely(teamsview.TeamsList(teams), s, r, w)
})
}

View File

@@ -0,0 +1,105 @@
package handlers
import (
"context"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
"github.com/pkg/errors"
"github.com/uptrace/bun"
"git.haelnorr.com/h/oslstats/internal/auditlog"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/validation"
teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview"
)
func NewTeamPage(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
renderSafely(teamsview.NewPage(), s, r, w)
})
}
func NewTeamSubmit(
s *hws.Server,
conn *bun.DB,
audit *auditlog.Logger,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
name := getter.String("name").
TrimSpace().Required().
MaxLength(25).MinLength(3).Value
shortname := getter.String("short_name").
TrimSpace().Required().
MaxLength(3).MinLength(3).Value
altShortname := getter.String("alt_short_name").
TrimSpace().Required().
MaxLength(3).MinLength(3).Value
color := getter.String("color").
TrimSpace().MaxLength(7).Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
// Check that short names are different
if shortname == altShortname {
notify.Warn(s, w, r, "Invalid Short Names", "Short name and alternative short name must be different.", nil)
return
}
nameUnique := false
shortNameComboUnique := false
var team *db.Team
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
nameUnique, err = db.IsUnique(ctx, tx, (*db.Team)(nil), "name", name)
if err != nil {
return false, errors.Wrap(err, "db.IsTeamNameUnique")
}
shortNameComboUnique, err = db.TeamShortNamesUnique(ctx, tx, shortname, altShortname)
if err != nil {
return false, errors.Wrap(err, "db.TeamShortNamesUnique")
}
if !nameUnique || !shortNameComboUnique {
return true, nil
}
team = &db.Team{
Name: name,
ShortName: shortname,
AltShortName: altShortname,
Color: color,
}
err = db.Insert(tx, team).WithAudit(r, audit.Callback()).Exec(ctx)
if err != nil {
return false, errors.Wrap(err, "db.Insert")
}
return true, nil
}); !ok {
return
}
if !nameUnique {
notify.Warn(s, w, r, "Duplicate Name", "This team name is already taken.", nil)
return
}
if !shortNameComboUnique {
notify.Warn(s, w, r, "Duplicate Short Names", "This combination of short names is already taken.", nil)
return
}
w.Header().Set("HX-Redirect", "/teams")
w.WriteHeader(http.StatusOK)
notify.SuccessWithDelay(s, w, r, "Team Created", fmt.Sprintf("Successfully created team: %s", name), nil)
})
}

View File

@@ -23,6 +23,12 @@ const (
LeaguesUpdate Permission = "leagues.update"
LeaguesDelete Permission = "leagues.delete"
// Teams permissions
TeamsCreate Permission = "teams.create"
TeamsUpdate Permission = "teams.update"
TeamsDelete Permission = "teams.delete"
TeamsAddToLeague Permission = "teams.add_to_league"
// Users permissions
UsersUpdate Permission = "users.update"
UsersBan Permission = "users.ban"

View File

@@ -21,6 +21,7 @@ func getNavItems() []NavItem {
return []NavItem{
{Name: "Seasons", Href: "/seasons"},
{Name: "Leagues", Href: "/leagues"},
{Name: "Teams", Href: "/teams"},
}
}
@@ -30,7 +31,7 @@ func getProfileItems(ctx context.Context) []ProfileItem {
{Name: "Profile", Href: "/profile"},
{Name: "Account", Href: "/account"},
}
cache := contexts.Permissions(ctx)
if cache != nil && cache.Roles["admin"] {
items = append(items, ProfileItem{
@@ -38,7 +39,7 @@ func getProfileItems(ctx context.Context) []ProfileItem {
Href: "/admin",
})
}
return items
}

View File

@@ -6,7 +6,7 @@ templ NewForm() {
hx-swap="none"
x-data={ templ.JSFuncCall("newLeagueFormData").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 League'; generalError='An error occurred. Please try again.'; }"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful && $event.detail.xhr.status !== 409) { isSubmitting=false; buttonText='Create League'; generalError='An error occurred. Please try again.'; }"
>
<script>
function newLeagueFormData() {

View File

@@ -7,15 +7,15 @@ import "strconv"
import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts"
templ DetailPage(season *db.Season) {
templ DetailPage(season *db.Season, leaguesWithTeams []db.LeagueWithTeams) {
@baseview.Layout(season.Name) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
@SeasonDetails(season)
@SeasonDetails(season, leaguesWithTeams)
</div>
}
}
templ SeasonDetails(season *db.Season) {
templ SeasonDetails(season *db.Season, leaguesWithTeams []db.LeagueWithTeams) {
{{
permCache := contexts.Permissions(ctx)
canEditSeason := permCache.HasPermission(permissions.SeasonsUpdate)
@@ -135,18 +135,41 @@ templ SeasonDetails(season *db.Season) {
<div class="px-6 pb-6">
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4">Leagues</h2>
if len(season.Leagues) == 0 {
if len(leaguesWithTeams) == 0 {
<p class="text-subtext0 text-sm">No leagues assigned to this season.</p>
} else {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
for _, league := range season.Leagues {
<a
href={ templ.SafeURL("/seasons/" + season.ShortName + "/leagues/" + league.ShortName) }
class="bg-mantle border border-surface1 rounded-lg p-4 hover:bg-surface1 transition-colors"
>
<h3 class="font-semibold text-text mb-1">{ league.Name }</h3>
<span class="text-xs text-subtext0 font-mono">{ league.ShortName }</span>
</a>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, lwt := range leaguesWithTeams {
<div class="bg-mantle border border-surface1 rounded-lg p-4 flex flex-col">
<!-- League Header -->
<a
href={ templ.SafeURL("/seasons/" + season.ShortName + "/leagues/" + lwt.League.ShortName) }
class="mb-3 pb-3 border-b border-surface1"
>
<h3 class="font-semibold text-text text-lg mb-1 hover:text-blue transition-colors">{ lwt.League.Name }</h3>
<span class="text-xs text-subtext0 font-mono">{ lwt.League.ShortName }</span>
</a>
<!-- Teams List -->
<div class="flex-1">
if len(lwt.Teams) == 0 {
<p class="text-sm text-subtext1 italic">No teams yet</p>
} else {
<div class="space-y-2">
for _, team := range lwt.Teams {
<div class="flex items-center gap-2 px-3 py-2 bg-surface0 border border-surface1 rounded hover:bg-surface1 transition-colors">
if team.Color != "" {
<div
class="w-3 h-3 rounded-full border border-surface1 shrink-0"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></div>
}
<span class="text-sm text-text truncate">{ team.Name }</span>
</div>
}
</div>
}
</div>
</div>
}
</div>
}

View File

@@ -26,7 +26,7 @@ templ EditForm(season *db.Season, allLeagues []*db.League) {
hx-swap="none"
x-data={ templ.JSFuncCall("editSeasonFormData", startDateStr, endDateStr, finalsStartDateStr, finalsEndDateStr).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='Save Changes'; generalError='An error occurred. Please try again.'; }"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful && $event.detail.xhr.status !== 409) { isSubmitting=false; buttonText='Save Changes'; generalError='An error occurred. Please try again.'; }"
>
<script>
function editSeasonFormData(

View File

@@ -9,7 +9,7 @@ templ LeaguesSection(season *db.Season, allLeagues []*db.League) {
permCache := contexts.Permissions(ctx)
canAddLeague := permCache.HasPermission(permissions.SeasonsAddLeague)
canRemoveLeague := permCache.HasPermission(permissions.SeasonsRemoveLeague)
// Create a map of assigned league IDs for quick lookup
assignedLeagueIDs := make(map[int]bool)
for _, league := range season.Leagues {
@@ -66,7 +66,7 @@ templ LeaguesSection(season *db.Season, allLeagues []*db.League) {
for _, league := range availableLeagues {
<button
type="button"
hx-post={ "/seasons/" + season.ShortName + "/leagues/" + league.ShortName }
hx-post={ "/seasons/" + season.ShortName + "/leagues/add/" + league.ShortName }
hx-target="#leagues-section"
hx-swap="outerHTML"
class="flex items-center gap-2 bg-surface1 hover:bg-surface2 border border-overlay0 rounded-lg px-3 py-2 transition hover:cursor-pointer"

View File

@@ -8,7 +8,7 @@ templ NewForm() {
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.'; }"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful && $event.detail.xhr.status !== 409) { isSubmitting=false; buttonText='Create Season'; generalError='An error occurred. Please try again.'; }"
>
<script>
function newSeasonFormData() {

View File

@@ -0,0 +1,234 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts"
import "fmt"
templ SeasonLeaguePage(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team) {
@baseview.Layout(fmt.Sprintf("%s - %s", season.Name, league.Name)) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
@SeasonLeagueDetails(season, league, teams, allTeams)
</div>
}
}
templ SeasonLeagueDetails(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team) {
{{
permCache := contexts.Permissions(ctx)
canAddTeam := permCache.HasPermission(permissions.TeamsAddToLeague)
}}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden" x-data="{ showAddTeamModal: false, selectedTeamId: '' }">
<!-- Header Section -->
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
<div>
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name } - { league.Name }</h1>
<div class="flex items-center gap-2 flex-wrap">
<span class="inline-block bg-surface1 px-3 py-1 rounded text-sm text-subtext0 font-mono">
{ season.ShortName }
</span>
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
{ league.ShortName }
</span>
@SlapVersionBadge(season.SlapVersion)
</div>
</div>
<div class="flex gap-2">
<a
href={ templ.SafeURL("/seasons/" + season.ShortName) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Season
</a>
</div>
</div>
<!-- Season Dates -->
<div class="mt-4 pt-4 border-t border-surface1">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Regular Season -->
<div class="bg-mantle border border-surface1 rounded-lg p-4">
<h3 class="text-sm font-semibold text-text mb-3 flex items-center justify-center gap-2">
<span class="text-blue">●</span>
Regular Season
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-xs text-subtext0 uppercase mb-1">Start</div>
<div class="text-sm text-text font-medium">{ formatDateLong(season.StartDate) }</div>
</div>
<div class="text-center">
<div class="text-xs text-subtext0 uppercase mb-1">Finish</div>
if !season.EndDate.IsZero() {
<div class="text-sm text-text font-medium">{ formatDateLong(season.EndDate.Time) }</div>
} else {
<div class="text-sm text-subtext1 italic">Not set</div>
}
</div>
</div>
</div>
<!-- Finals -->
<div class="bg-mantle border border-surface1 rounded-lg p-4">
<h3 class="text-sm font-semibold text-text mb-3 flex items-center justify-center gap-2">
<span class="text-yellow">★</span>
Finals
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-xs text-subtext0 uppercase mb-1">Start</div>
if !season.FinalsStartDate.IsZero() {
<div class="text-sm text-text font-medium">{ formatDateLong(season.FinalsStartDate.Time) }</div>
} else {
<div class="text-sm text-subtext1 italic">Not set</div>
}
</div>
<div class="text-center">
<div class="text-xs text-subtext0 uppercase mb-1">Finish</div>
if !season.FinalsEndDate.IsZero() {
<div class="text-sm text-text font-medium">{ formatDateLong(season.FinalsEndDate.Time) }</div>
} else {
<div class="text-sm text-subtext1 italic">Not set</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Teams Section -->
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-text">Teams ({ fmt.Sprint(len(teams)) })</h2>
if canAddTeam {
<button
@click="showAddTeamModal = true"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition"
>
Add Team
</button>
}
</div>
if len(teams) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No teams in this league yet.</p>
if canAddTeam {
<p class="text-subtext1 text-sm mt-2">Click "Add Team" to get started.</p>
}
</div>
} else {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, team := range teams {
<div class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors">
<div class="flex justify-between items-start mb-3">
<h3 class="text-xl font-bold text-text">{ team.Name }</h3>
if team.Color != "" {
<div
class="w-6 h-6 rounded-full border-2 border-surface1"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></div>
}
</div>
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono">
{ team.ShortName }
</span>
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono">
{ team.AltShortName }
</span>
</div>
</div>
}
</div>
}
</div>
if canAddTeam {
@AddTeamModal(season, league, teams, allTeams)
}
</div>
}
templ AddTeamModal(season *db.Season, league *db.League, existingTeams []*db.Team, allTeams []*db.Team) {
{{
// Filter out teams already in this league
existingTeamIDs := make(map[int]bool)
for _, t := range existingTeams {
existingTeamIDs[t.ID] = true
}
availableTeams := []*db.Team{}
for _, t := range allTeams {
if !existingTeamIDs[t.ID] {
availableTeams = append(availableTeams, t)
}
}
}}
<div
x-show="showAddTeamModal"
@keydown.escape.window="showAddTeamModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-crust/80 transition-opacity"
@click="showAddTeamModal = false"
></div>
<!-- Modal -->
<div class="flex min-h-full items-center justify-center p-4">
<div
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-md w-full p-6"
@click.stop
>
<h3 class="text-2xl font-bold text-text mb-4">Add Team to League</h3>
<form
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/teams/add", season.ShortName, league.ShortName) }
hx-swap="none"
>
if len(availableTeams) == 0 {
<p class="text-subtext0 mb-4">All teams are already in this league.</p>
} else {
<div class="mb-4">
<label for="team_id" class="block text-sm font-medium mb-2">Select Team</label>
<select
id="team_id"
name="team_id"
x-model="selectedTeamId"
required
class="w-full py-3 px-4 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none"
>
<option value="">Choose a team...</option>
for _, team := range availableTeams {
<option value={ fmt.Sprint(team.ID) }>
{ team.Name } ({ team.ShortName })
</option>
}
</select>
</div>
}
<div class="flex gap-3 justify-end">
<button
type="button"
@click="showAddTeamModal = false"
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition"
>
Cancel
</button>
if len(availableTeams) > 0 {
<button
type="submit"
:disabled="!selectedTeamId"
class="px-4 py-2 rounded-lg bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed"
>
Add Team
</button>
}
</div>
</form>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,114 @@
package teamsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/view/pagination"
import "git.haelnorr.com/h/oslstats/internal/view/sort"
import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/permissions"
import "github.com/uptrace/bun"
templ ListPage(teams *db.List[db.Team]) {
@baseview.Layout("Teams") {
<div class="max-w-screen-2xl mx-auto px-2">
@TeamsList(teams)
</div>
}
}
templ TeamsList(teams *db.List[db.Team]) {
{{
permCache := contexts.Permissions(ctx)
canAddTeam := permCache.HasPermission(permissions.TeamsCreate)
sortOpts := []db.OrderOpts{
{
Order: bun.OrderAsc,
OrderBy: "name",
Label: "Name (A-Z)",
},
{
Order: bun.OrderDesc,
OrderBy: "name",
Label: "Name (Z-A)",
},
{
Order: bun.OrderAsc,
OrderBy: "short_name",
Label: "Short Name (A-Z)",
},
{
Order: bun.OrderDesc,
OrderBy: "short_name",
Label: "Short Name (Z-A)",
},
}
}}
<div id="teams-list-container">
<form
id="teams-form"
hx-target="#teams-list-container"
hx-swap="outerHTML"
hx-push-url="true"
x-data={ templ.JSFuncCall("paginateData",
"teams-form",
"/teams",
teams.PageOpts.Page,
teams.PageOpts.PerPage,
teams.PageOpts.Order,
teams.PageOpts.OrderBy).CallInline }
>
<!-- Header with title and sort controls -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6 px-4">
<div class="flex gap-4 items-center">
<span class="text-3xl font-bold">Teams</span>
if canAddTeam {
<a
href="/teams/new"
class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition"
>Add team</a>
}
</div>
@sort.Dropdown(teams.PageOpts, sortOpts)
</div>
<!-- Results section -->
if len(teams.Items) == 0 {
<div class="bg-mantle border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No teams found</p>
</div>
} else {
<!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, t := range teams.Items {
<div
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors flex flex-col"
>
<!-- Header: Name with color indicator -->
<div class="flex justify-between items-start mb-3">
<h3 class="text-xl font-bold text-text">{ t.Name }</h3>
if t.Color != "" {
<div
class="w-6 h-6 rounded-full border-2 border-surface1"
style={ "background-color: " + templ.SafeCSS(t.Color) }
></div>
}
</div>
<!-- Info Row: Short Names -->
<div class="flex items-center gap-2 text-sm mb-3 flex-wrap">
<span class="px-2 py-1 bg-surface1 rounded text-subtext0 font-mono">
{ t.ShortName }
</span>
<span class="px-2 py-1 bg-surface0 border border-surface1 rounded text-subtext0 font-mono">
{ t.AltShortName }
</span>
</div>
</div>
}
</div>
<!-- Pagination controls -->
@pagination.Pagination(teams.PageOpts, teams.Total)
}
</form>
<script src="/static/js/pagination.js"></script>
</div>
}

View File

@@ -0,0 +1,216 @@
package teamsview
templ NewForm() {
<form
hx-post="/teams/new"
hx-swap="none"
x-data={ templ.JSFuncCall("newTeamFormData").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 && $event.detail.xhr.status !== 409) { isSubmitting=false; buttonText='Create Team'; generalError='An error occurred. Please try again.'; }"
>
<script>
function newTeamFormData() {
return {
canSubmit: false,
buttonText: "Create Team",
// Name validation state
nameError: "",
nameIsChecking: false,
nameIsUnique: false,
nameIsEmpty: true,
// Short names validation state
shortNamesError: "",
shortNamesIsChecking: false,
shortNamesAreUnique: false,
shortNameIsEmpty: true,
altShortNameIsEmpty: true,
// Form state
isSubmitting: false,
generalError: "",
submitTimeout: null,
// Reset name errors
resetNameErr() {
this.nameError = "";
this.nameIsChecking = false;
this.nameIsUnique = false;
},
// Reset short names errors
resetShortNamesErr() {
this.shortNamesError = "";
this.shortNamesIsChecking = false;
this.shortNamesAreUnique = false;
},
// Check if short names are the same
checkShortNamesSame() {
const shortName = document.getElementById('short_name').value.trim();
const altShortName = document.getElementById('alt_short_name').value.trim();
if (shortName && altShortName && shortName === altShortName) {
this.shortNamesError = 'Short name and alt short name must be different';
this.shortNamesAreUnique = false;
return true;
}
return false;
},
// Check if form can be submitted
updateCanSubmit() {
this.canSubmit =
!this.nameIsEmpty &&
this.nameIsUnique &&
!this.nameIsChecking &&
!this.shortNameIsEmpty &&
!this.altShortNameIsEmpty &&
this.shortNamesAreUnique &&
!this.shortNamesIsChecking;
},
// 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 Team";
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">Team Name</label>
<div class="relative">
<input
type="text"
id="name"
name="name"
maxlength="50"
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. Blueberry FC"
@input="resetNameErr(); nameIsEmpty = $el.value.trim() === ''; if(nameIsEmpty) { nameError='Team name is required'; nameIsUnique=false; } updateCanSubmit();"
hx-post="/htmx/isteamnameunique"
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 team name is already taken'; nameIsUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 50 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="10"
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': !shortNamesAreUnique && !shortNamesError,
'border-green focus:border-green': shortNamesAreUnique && !shortNamesIsChecking && !shortNamesError,
'border-red focus:border-red': shortNamesError && !shortNamesIsChecking && !isSubmitting
}"
required
placeholder="e.g. BLUE"
@input="
resetShortNamesErr();
shortNameIsEmpty = $el.value.trim() === '';
updateCanSubmit();
"
hx-post="/htmx/isteamshortnamesunique"
hx-trigger="input changed delay:500ms from:#short_name, input changed delay:500ms from:#alt_short_name"
hx-include="[name='alt_short_name']"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '' || document.getElementById('alt_short_name').value.trim() === '') { return; } if(checkShortNamesSame()) { updateCanSubmit(); return; } shortNamesIsChecking=true; shortNamesAreUnique=false; shortNamesError=''; updateCanSubmit();"
@htmx:after-request="shortNamesIsChecking=false; if($event.detail.successful) { shortNamesAreUnique=true; } else if($event.detail.xhr.status === 409) { shortNamesError='This combination of short names is already taken or they are the same'; shortNamesAreUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 10 characters</p>
</div>
</div>
<!-- Alternative Short Name Field -->
<div>
<label for="alt_short_name" class="block text-sm font-medium mb-2">Alternative Short Name</label>
<div class="relative">
<input
type="text"
id="alt_short_name"
name="alt_short_name"
maxlength="10"
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': !shortNamesAreUnique && !shortNamesError,
'border-green focus:border-green': shortNamesAreUnique && !shortNamesIsChecking && !shortNamesError,
'border-red focus:border-red': shortNamesError && !shortNamesIsChecking && !isSubmitting
}"
required
placeholder="e.g. BFC"
@input="
resetShortNamesErr();
altShortNameIsEmpty = $el.value.trim() === '';
updateCanSubmit();
"
hx-post="/htmx/isteamshortnamesunique"
hx-trigger="input changed delay:500ms from:#short_name, input changed delay:500ms from:#alt_short_name"
hx-include="[name='short_name']"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '' || document.getElementById('short_name').value.trim() === '') { return; } if(checkShortNamesSame()) { updateCanSubmit(); return; } shortNamesIsChecking=true; shortNamesAreUnique=false; shortNamesError=''; updateCanSubmit();"
@htmx:after-request="shortNamesIsChecking=false; if($event.detail.successful) { shortNamesAreUnique=true; } else if($event.detail.xhr.status === 409) { shortNamesError='This combination of short names is already taken or they are the same'; shortNamesAreUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 10 characters. Must be different from short name.</p>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="shortNamesError && !isSubmitting"
x-cloak
x-text="shortNamesError"
></p>
</div>
<!-- Color Field (Optional) -->
<div>
<label for="color" class="block text-sm font-medium mb-2">Team Color (Optional)</label>
<div class="flex gap-3 items-center">
<input
type="color"
id="color"
name="color"
class="h-12 w-20 rounded cursor-pointer border-2 border-overlay0"
/>
<p class="text-xs text-subtext1">Choose a color to represent this team</p>
</div>
</div>
<!-- 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 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

@@ -0,0 +1,23 @@
package teamsview
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
templ NewPage() {
@baseview.Layout("New Team") {
<div class="max-w-5xl 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 Team</h1>
<p class="mt-2 text-sm text-subtext0">
Add a new team to the system. All fields except color are required.
</p>
</div>
<div class="max-w-md mx-auto">
@NewForm()
</div>
</div>
</div>
</div>
}
}