added team view to season_leagues
This commit is contained in:
@@ -33,7 +33,7 @@ type Fixture struct {
|
|||||||
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
||||||
homeTeamID, awayTeamID, round int, audit *AuditMeta,
|
homeTeamID, awayTeamID, round int, audit *AuditMeta,
|
||||||
) (*Fixture, error) {
|
) (*Fixture, error) {
|
||||||
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "GetSeasonLeague")
|
return nil, errors.Wrap(err, "GetSeasonLeague")
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName
|
|||||||
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
||||||
round int, audit *AuditMeta,
|
round int, audit *AuditMeta,
|
||||||
) ([]*Fixture, error) {
|
) ([]*Fixture, error) {
|
||||||
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "GetSeasonLeague")
|
return nil, errors.Wrap(err, "GetSeasonLeague")
|
||||||
}
|
}
|
||||||
@@ -71,22 +71,22 @@ func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName s
|
|||||||
return fixtures, nil
|
return fixtures, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) {
|
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) {
|
||||||
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague")
|
return nil, nil, errors.Wrap(err, "GetSeasonLeague")
|
||||||
}
|
}
|
||||||
fixtures, err := GetList[Fixture](tx).
|
fixtures, err := GetList[Fixture](tx).
|
||||||
Where("season_id = ?", season.ID).
|
Where("season_id = ?", sl.SeasonID).
|
||||||
Where("league_id = ?", league.ID).
|
Where("league_id = ?", sl.LeagueID).
|
||||||
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
|
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
|
||||||
Relation("HomeTeam").
|
Relation("HomeTeam").
|
||||||
Relation("AwayTeam").
|
Relation("AwayTeam").
|
||||||
GetAll(ctx)
|
GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, errors.Wrap(err, "GetList")
|
return nil, nil, errors.Wrap(err, "GetList")
|
||||||
}
|
}
|
||||||
return season, league, fixtures, nil
|
return sl, fixtures, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
|
func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
|
||||||
@@ -98,6 +98,22 @@ func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
|
|||||||
Get(ctx)
|
Get(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetFixturesForTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID int) ([]*Fixture, error) {
|
||||||
|
fixtures, err := GetList[Fixture](tx).
|
||||||
|
Where("season_id = ?", seasonID).
|
||||||
|
Where("league_id = ?", leagueID).
|
||||||
|
Where("game_week IS NOT NULL").
|
||||||
|
Where("(home_team_id = ? OR away_team_id = ?)", teamID, teamID).
|
||||||
|
Order("game_week ASC", "round ASC", "id ASC").
|
||||||
|
Relation("HomeTeam").
|
||||||
|
Relation("AwayTeam").
|
||||||
|
GetAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetList")
|
||||||
|
}
|
||||||
|
return fixtures, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
|
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
|
||||||
fixtures, err := GetList[Fixture](tx).
|
fixtures, err := GetList[Fixture](tx).
|
||||||
Where("season_id = ?", seasonID).
|
Where("season_id = ?", seasonID).
|
||||||
@@ -180,13 +196,13 @@ func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error {
|
func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error {
|
||||||
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "GetSeasonLeague")
|
return errors.Wrap(err, "GetSeasonLeague")
|
||||||
}
|
}
|
||||||
err = DeleteItem[Fixture](tx).
|
err = DeleteItem[Fixture](tx).
|
||||||
Where("season_id = ?", season.ID).
|
Where("season_id = ?", sl.SeasonID).
|
||||||
Where("league_id = ?", league.ID).
|
Where("league_id = ?", sl.LeagueID).
|
||||||
WithAudit(audit, nil).
|
WithAudit(audit, nil).
|
||||||
Delete(ctx)
|
Delete(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
30
internal/db/migrations/20260220174806_team_rosters.go
Normal file
30
internal/db/migrations/20260220174806_team_rosters.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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, conn *bun.DB) error {
|
||||||
|
// Add your migration code here
|
||||||
|
_, err := conn.NewCreateTable().
|
||||||
|
IfNotExists().
|
||||||
|
Model((*db.TeamRoster)(nil)).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// DOWN migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Add your rollback code here
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -90,3 +90,16 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
|
||||||
|
players, err := GetList[Player](tx).Relation("User").
|
||||||
|
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").
|
||||||
|
Where("NOT (tr.season_id = ? and tr.league_id = ?) OR (tr.season_id IS NULL and tr.league_id IS NULL)",
|
||||||
|
seasonID, leagueID).
|
||||||
|
Order("p.name ASC").
|
||||||
|
GetAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetList")
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/permissions"
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -15,8 +16,36 @@ type SeasonLeague struct {
|
|||||||
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSeasonLeague retrieves a specific season-league combination with teams
|
// GetSeasonLeague retrieves a specific season-league combination
|
||||||
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
|
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, error) {
|
||||||
|
if seasonShortName == "" {
|
||||||
|
return nil, errors.New("season short_name cannot be empty")
|
||||||
|
}
|
||||||
|
if leagueShortName == "" {
|
||||||
|
return nil, errors.New("league short_name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
sl := new(SeasonLeague)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(sl).
|
||||||
|
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Where("season.short_name = ?", seasonShortName)
|
||||||
|
}).
|
||||||
|
Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Where("league.short_name = ?", leagueShortName)
|
||||||
|
}).Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, BadRequestNotFound("season_league", "season.short_name,league.short_name", seasonShortName+","+leagueShortName)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSeasonLeagueWithTeams retrieves a specific season-league combination with teams
|
||||||
|
func GetSeasonLeagueWithTeams(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
|
||||||
if seasonShortName == "" {
|
if seasonShortName == "" {
|
||||||
return nil, nil, nil, errors.New("season short_name cannot be empty")
|
return nil, nil, nil, errors.New("season short_name cannot be empty")
|
||||||
}
|
}
|
||||||
@@ -41,6 +70,9 @@ func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor
|
|||||||
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
|
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).
|
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
|
||||||
Order("t.name ASC").
|
Order("t.name ASC").
|
||||||
|
Relation("Players", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Where("season_id = ? AND league_id = ?", season.ID, league.ID)
|
||||||
|
}).
|
||||||
Scan(ctx)
|
Scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
|
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func (db *DB) RegisterModels() []any {
|
|||||||
(*UserRole)(nil),
|
(*UserRole)(nil),
|
||||||
(*SeasonLeague)(nil),
|
(*SeasonLeague)(nil),
|
||||||
(*TeamParticipation)(nil),
|
(*TeamParticipation)(nil),
|
||||||
|
(*TeamRoster)(nil),
|
||||||
(*User)(nil),
|
(*User)(nil),
|
||||||
(*DiscordToken)(nil),
|
(*DiscordToken)(nil),
|
||||||
(*Season)(nil),
|
(*Season)(nil),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Team struct {
|
|||||||
|
|
||||||
Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
|
Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
|
||||||
Leagues []League `bun:"m2m:team_participations,join:Team=League" json:"-"`
|
Leagues []League `bun:"m2m:team_participations,join:Team=League" json:"-"`
|
||||||
|
Players []Player `bun:"m2m:team_rosters,join:Team=Player" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) {
|
func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) {
|
||||||
|
|||||||
176
internal/db/teamroster.go
Normal file
176
internal/db/teamroster.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TeamRoster struct {
|
||||||
|
bun.BaseModel `bun:"table:team_rosters,alias:tr"`
|
||||||
|
TeamID int `bun:",pk,notnull" json:"team_id"`
|
||||||
|
SeasonID int `bun:",pk,notnull,unique:player" json:"season_id"`
|
||||||
|
LeagueID int `bun:",pk,notnull,unique:player" json:"league_id"`
|
||||||
|
PlayerID int `bun:",pk,notnull,unique:player" json:"player_id"`
|
||||||
|
IsManager bool `bun:"is_manager,default:'false'" json:"is_manager"`
|
||||||
|
|
||||||
|
Team *Team `bun:"rel:belongs-to,join:team_id=id" json:"-"`
|
||||||
|
Player *Player `bun:"rel:belongs-to,join:player_id=id" json:"-"`
|
||||||
|
Season *Season `bun:"rel:belongs-to,join:season_id=id" json:"-"`
|
||||||
|
League *League `bun:"rel:belongs-to,join:league_id=id" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamWithRoster struct {
|
||||||
|
Team *Team
|
||||||
|
Season *Season
|
||||||
|
League *League
|
||||||
|
Manager *Player
|
||||||
|
Players []*Player
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTeamRoster(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, teamID int) (*TeamWithRoster, error) {
|
||||||
|
tr := []*TeamRoster{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&tr).
|
||||||
|
Relation("Team", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Where("team.id = ?", teamID)
|
||||||
|
}).
|
||||||
|
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Where("season.short_name = ?", seasonShortName)
|
||||||
|
}).
|
||||||
|
Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Where("league.short_name = ?", leagueShortName)
|
||||||
|
}).
|
||||||
|
Relation("Player").Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
team, err := GetTeam(ctx, tx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetTeam")
|
||||||
|
}
|
||||||
|
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetSeasonLeague")
|
||||||
|
}
|
||||||
|
var manager *Player
|
||||||
|
players := []*Player{}
|
||||||
|
for _, tp := range tr {
|
||||||
|
if tp.IsManager {
|
||||||
|
manager = tp.Player
|
||||||
|
} else {
|
||||||
|
players = append(players, tp.Player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
players = append([]*Player{manager}, players...)
|
||||||
|
twr := &TeamWithRoster{
|
||||||
|
team,
|
||||||
|
sl.Season,
|
||||||
|
sl.League,
|
||||||
|
manager,
|
||||||
|
players,
|
||||||
|
}
|
||||||
|
return twr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddPlayerToTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, playerID int, manager bool, audit *AuditMeta) error {
|
||||||
|
season, err := GetByID[Season](tx, seasonID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetSeason")
|
||||||
|
}
|
||||||
|
league, err := GetByID[League](tx, leagueID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetLeague")
|
||||||
|
}
|
||||||
|
team, err := GetByID[Team](tx, teamID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetTeam")
|
||||||
|
}
|
||||||
|
player, err := GetByID[Player](tx, playerID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetPlayer")
|
||||||
|
}
|
||||||
|
tr := &TeamRoster{
|
||||||
|
SeasonID: season.ID,
|
||||||
|
LeagueID: league.ID,
|
||||||
|
TeamID: team.ID,
|
||||||
|
PlayerID: player.ID,
|
||||||
|
IsManager: manager,
|
||||||
|
}
|
||||||
|
err = Insert(tx, tr).WithAudit(audit, nil).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManageTeamRoster replaces the entire roster for a team in a season/league.
|
||||||
|
// It deletes all existing roster entries and inserts the new ones.
|
||||||
|
func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, managerID int, playerIDs []int, audit *AuditMeta) error {
|
||||||
|
// Delete all existing roster entries for this team/season/league
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*TeamRoster)(nil)).
|
||||||
|
Where("season_id = ?", seasonID).
|
||||||
|
Where("league_id = ?", leagueID).
|
||||||
|
Where("team_id = ?", teamID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "delete existing roster")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert manager if provided
|
||||||
|
if managerID > 0 {
|
||||||
|
tr := &TeamRoster{
|
||||||
|
SeasonID: seasonID,
|
||||||
|
LeagueID: leagueID,
|
||||||
|
TeamID: teamID,
|
||||||
|
PlayerID: managerID,
|
||||||
|
IsManager: true,
|
||||||
|
}
|
||||||
|
err = Insert(tx, tr).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Insert manager")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert players
|
||||||
|
for _, playerID := range playerIDs {
|
||||||
|
if playerID == managerID {
|
||||||
|
continue // Already inserted as manager
|
||||||
|
}
|
||||||
|
tr := &TeamRoster{
|
||||||
|
SeasonID: seasonID,
|
||||||
|
LeagueID: leagueID,
|
||||||
|
TeamID: teamID,
|
||||||
|
PlayerID: playerID,
|
||||||
|
IsManager: false,
|
||||||
|
}
|
||||||
|
err = Insert(tx, tr).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Insert player")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the roster change
|
||||||
|
details := map[string]any{
|
||||||
|
"season_id": seasonID,
|
||||||
|
"league_id": leagueID,
|
||||||
|
"team_id": teamID,
|
||||||
|
"manager_id": managerID,
|
||||||
|
"player_ids": playerIDs,
|
||||||
|
}
|
||||||
|
info := &AuditInfo{
|
||||||
|
"teams.manage_players",
|
||||||
|
"team_roster",
|
||||||
|
fmt.Sprintf("%d-%d-%d", seasonID, leagueID, teamID),
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
err = LogSuccess(ctx, tx, audit, info)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "LogSuccess")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
--container-lg: 32rem;
|
--container-lg: 32rem;
|
||||||
--container-2xl: 42rem;
|
--container-2xl: 42rem;
|
||||||
--container-3xl: 48rem;
|
--container-3xl: 48rem;
|
||||||
|
--container-4xl: 56rem;
|
||||||
--container-5xl: 64rem;
|
--container-5xl: 64rem;
|
||||||
--container-7xl: 80rem;
|
--container-7xl: 80rem;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
@@ -458,6 +459,9 @@
|
|||||||
.max-h-60 {
|
.max-h-60 {
|
||||||
max-height: calc(var(--spacing) * 60);
|
max-height: calc(var(--spacing) * 60);
|
||||||
}
|
}
|
||||||
|
.max-h-80 {
|
||||||
|
max-height: calc(var(--spacing) * 80);
|
||||||
|
}
|
||||||
.max-h-96 {
|
.max-h-96 {
|
||||||
max-height: calc(var(--spacing) * 96);
|
max-height: calc(var(--spacing) * 96);
|
||||||
}
|
}
|
||||||
@@ -467,6 +471,12 @@
|
|||||||
.max-h-\[600px\] {
|
.max-h-\[600px\] {
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
}
|
}
|
||||||
|
.min-h-12 {
|
||||||
|
min-height: calc(var(--spacing) * 12);
|
||||||
|
}
|
||||||
|
.min-h-40 {
|
||||||
|
min-height: calc(var(--spacing) * 40);
|
||||||
|
}
|
||||||
.min-h-48 {
|
.min-h-48 {
|
||||||
min-height: calc(var(--spacing) * 48);
|
min-height: calc(var(--spacing) * 48);
|
||||||
}
|
}
|
||||||
@@ -518,6 +528,9 @@
|
|||||||
.max-w-3xl {
|
.max-w-3xl {
|
||||||
max-width: var(--container-3xl);
|
max-width: var(--container-3xl);
|
||||||
}
|
}
|
||||||
|
.max-w-4xl {
|
||||||
|
max-width: var(--container-4xl);
|
||||||
|
}
|
||||||
.max-w-5xl {
|
.max-w-5xl {
|
||||||
max-width: var(--container-5xl);
|
max-width: var(--container-5xl);
|
||||||
}
|
}
|
||||||
@@ -908,6 +921,12 @@
|
|||||||
background-color: color-mix(in oklab, var(--green) 20%, transparent);
|
background-color: color-mix(in oklab, var(--green) 20%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-green\/40 {
|
||||||
|
background-color: var(--green);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--green) 40%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-mantle {
|
.bg-mantle {
|
||||||
background-color: var(--mantle);
|
background-color: var(--mantle);
|
||||||
}
|
}
|
||||||
@@ -1837,6 +1856,11 @@
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.lg\:grid-cols-2 {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.lg\:grid-cols-3 {
|
.lg\:grid-cols-3 {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|||||||
2
internal/embedfs/web/vendored/sortablejs@1.15.6.min.js
vendored
Normal file
2
internal/embedfs/web/vendored/sortablejs@1.15.6.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -31,8 +31,7 @@ func GenerateFixtures(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
_, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAuditFromRequest(r))
|
_, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAuditFromRequest(r))
|
||||||
@@ -43,7 +42,7 @@ func GenerateFixtures(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.NewRound")
|
return false, errors.Wrap(err, "db.NewRound")
|
||||||
}
|
}
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetFixtures")
|
return false, errors.Wrap(err, "db.GetFixtures")
|
||||||
}
|
}
|
||||||
@@ -52,7 +51,7 @@ func GenerateFixtures(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueManageFixtures(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ func UpdateFixtures(
|
|||||||
|
|
||||||
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
_, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
_, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
|
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ func SeasonLeaguePage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, _, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -38,7 +38,7 @@ func SeasonLeaguePage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultTab := season.GetDefaultTab()
|
defaultTab := sl.Season.GetDefaultTab()
|
||||||
redirectURL := fmt.Sprintf(
|
redirectURL := fmt.Sprintf(
|
||||||
"/seasons/%s/leagues/%s/%s",
|
"/seasons/%s/leagues/%s/%s",
|
||||||
seasonStr, leagueStr, defaultTab,
|
seasonStr, leagueStr, defaultTab,
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ func SeasonLeagueFinalsPage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -40,7 +39,7 @@ func SeasonLeagueFinalsPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinalsPage(sl.Season, sl.League), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,12 @@ func SeasonLeagueFixturesPage(
|
|||||||
seasonShortName := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueShortName := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -42,9 +41,9 @@ func SeasonLeagueFixturesPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueFixtures(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,13 +56,12 @@ func SeasonLeagueManageFixturesPage(
|
|||||||
seasonShortName := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueShortName := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -76,7 +74,7 @@ func SeasonLeagueManageFixturesPage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +86,7 @@ func SeasonLeagueDeleteFixtures(
|
|||||||
seasonShortName := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueShortName := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r))
|
err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r))
|
||||||
@@ -100,7 +97,7 @@ func SeasonLeagueDeleteFixtures(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.DeleteAllFixtures")
|
return false, errors.Wrap(err, "db.DeleteAllFixtures")
|
||||||
}
|
}
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetFixtures")
|
return false, errors.Wrap(err, "db.GetFixtures")
|
||||||
}
|
}
|
||||||
@@ -109,6 +106,6 @@ func SeasonLeagueDeleteFixtures(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueManageFixtures(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ func SeasonLeagueStatsPage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -40,7 +39,7 @@ func SeasonLeagueStatsPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ func SeasonLeagueTablePage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -39,7 +38,7 @@ func SeasonLeagueTablePage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTablePage(sl.Season, sl.League), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueTable(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTable(), s, r, w)
|
||||||
}
|
}
|
||||||
|
|||||||
63
internal/handlers/season_league_team_detail.go
Normal file
63
internal/handlers/season_league_team_detail.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeasonLeagueTeamDetailPage renders the detail page for a team within a season league
|
||||||
|
func SeasonLeagueTeamDetailPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
teamIDStr := r.PathValue("team_id")
|
||||||
|
|
||||||
|
teamID, err := strconv.Atoi(teamIDStr)
|
||||||
|
if err != nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var twr *db.TeamWithRoster
|
||||||
|
var fixtures []*db.Fixture
|
||||||
|
var available []*db.Player
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
twr, err = db.GetTeamRoster(ctx, tx, seasonShortName, leagueShortName, teamID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetTeamRoster")
|
||||||
|
}
|
||||||
|
fixtures, err = db.GetFixturesForTeam(ctx, tx, twr.Season.ID, twr.League.ID, twr.Team.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFixturesForTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err = db.GetPlayersNotOnTeam(ctx, tx, twr.Season.ID, twr.League.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ func SeasonLeagueTeamsPage(
|
|||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
|||||||
89
internal/handlers/team_roster_manage.go
Normal file
89
internal/handlers/team_roster_manage.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
|
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManageTeamRoster handles saving a full team roster (manager + players)
|
||||||
|
func ManageTeamRoster(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
respond.BadRequest(w, errors.New("failed to parse form"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seasonID := getter.Int("season_id").Required().Value
|
||||||
|
leagueID := getter.Int("league_id").Required().Value
|
||||||
|
teamID := getter.Int("team_id").Required().Value
|
||||||
|
managerID := getter.Int("manager_id").Required().Value
|
||||||
|
playerIDs := getter.IntList("player_ids").Values()
|
||||||
|
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
respond.BadRequest(w, errors.New("invalid form data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write transaction: manage the roster
|
||||||
|
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
err := db.ManageTeamRoster(ctx, tx, seasonID, leagueID, teamID, managerID, playerIDs, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.ManageTeamRoster")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch updated data for HTMX swap
|
||||||
|
var twr *db.TeamWithRoster
|
||||||
|
var available []*db.Player
|
||||||
|
|
||||||
|
if !conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// We need season/league short names to call GetTeamRoster
|
||||||
|
season, err := db.GetByID[db.Season](tx, seasonID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetSeason")
|
||||||
|
}
|
||||||
|
league, err := db.GetByID[db.League](tx, leagueID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
twr, err = db.GetTeamRoster(ctx, tx, season.ShortName, league.ShortName, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetTeamRoster")
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err = db.GetPlayersNotOnTeam(ctx, tx, seasonID, leagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond with HTMX swap of the roster section
|
||||||
|
w.Header().Set("HX-Retarget", "#team-roster-section")
|
||||||
|
w.Header().Set("HX-Reswap", "outerHTML")
|
||||||
|
notify.Success(s, w, r, "Roster Updated", "Team roster has been saved successfully.", nil)
|
||||||
|
renderSafely(seasonsview.TeamRosterSection(twr, available), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -117,6 +117,11 @@ func addRoutes(
|
|||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
Handler: handlers.SeasonLeagueTeamsPage(s, conn),
|
Handler: handlers.SeasonLeagueTeamsPage(s, conn),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/teams/{team_id}",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: handlers.SeasonLeagueTeamDetailPage(s, conn),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/stats",
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/stats",
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
@@ -206,6 +211,11 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/teams/manage_roster",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
htmxRoutes := []hws.Route{
|
htmxRoutes := []hws.Route{
|
||||||
|
|||||||
24
internal/validation/boolfield.go
Normal file
24
internal/validation/boolfield.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BoolField struct {
|
||||||
|
FieldBase
|
||||||
|
Value bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoolField(key string, g Getter) *BoolField {
|
||||||
|
raw := g.Get(key)
|
||||||
|
val, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
g.AddCheck(newFailedCheck("Invalid boolean value",
|
||||||
|
fmt.Sprintf("Field %s requires a boolean value, %s given", key, raw)))
|
||||||
|
}
|
||||||
|
return &BoolField{
|
||||||
|
newField(key, g),
|
||||||
|
val,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,18 @@ func (f *FormGetter) Get(key string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *FormGetter) GetList(key string) []string {
|
func (f *FormGetter) GetList(key string) []string {
|
||||||
return strings.Split(f.Get(key), ",")
|
if f.r.Form == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values, ok := f.r.Form[key]
|
||||||
|
if !ok || len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Support both comma-separated single values and multiple form fields
|
||||||
|
if len(values) == 1 {
|
||||||
|
return strings.Split(values[0], ",")
|
||||||
|
}
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FormGetter) GetMaps(key string) []map[string]string {
|
func (f *FormGetter) GetMaps(key string) []map[string]string {
|
||||||
@@ -72,6 +83,10 @@ func (f *FormGetter) Int(key string) *IntField {
|
|||||||
return newIntField(key, f)
|
return newIntField(key, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FormGetter) Bool(key string) *BoolField {
|
||||||
|
return newBoolField(key, f)
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField {
|
func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField {
|
||||||
return newTimeField(key, format, f)
|
return newTimeField(key, format, f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ func (q *QueryGetter) Int(key string) *IntField {
|
|||||||
return newIntField(key, q)
|
return newIntField(key, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (q *QueryGetter) Bool(key string) *BoolField {
|
||||||
|
return newBoolField(key, q)
|
||||||
|
}
|
||||||
|
|
||||||
func (q *QueryGetter) Time(key string, format *timefmt.Format) *TimeField {
|
func (q *QueryGetter) Time(key string, format *timefmt.Format) *TimeField {
|
||||||
return newTimeField(key, format, q)
|
return newTimeField(key, format, q)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Getter interface {
|
|||||||
AddCheck(check *ValidationRule)
|
AddCheck(check *ValidationRule)
|
||||||
String(key string) *StringField
|
String(key string) *StringField
|
||||||
Int(key string) *IntField
|
Int(key string) *IntField
|
||||||
|
Bool(key string) *BoolField
|
||||||
Time(key string, format *timefmt.Format) *TimeField
|
Time(key string, format *timefmt.Format) *TimeField
|
||||||
StringList(key string) *StringList
|
StringList(key string) *StringList
|
||||||
IntList(key string) *IntList
|
IntList(key string) *IntList
|
||||||
|
|||||||
470
internal/view/seasonsview/season_league_team_detail.templ
Normal file
470
internal/view/seasonsview/season_league_team_detail.templ
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player) {
|
||||||
|
{{
|
||||||
|
team := twr.Team
|
||||||
|
season := twr.Season
|
||||||
|
league := twr.League
|
||||||
|
}}
|
||||||
|
@baseview.Layout(fmt.Sprintf("%s - %s - %s", team.Name, league.Name, season.Name)) {
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<!-- 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">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
if team.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
|
||||||
|
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
{ team.ShortName }
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
{ team.AltShortName }
|
||||||
|
</span>
|
||||||
|
<span class="text-subtext1 text-sm">
|
||||||
|
{ season.Name } — { league.Name }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Teams
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="bg-crust p-6">
|
||||||
|
<!-- Top row: Roster (left) + Fixtures (right) -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
@TeamRosterSection(twr, available)
|
||||||
|
@teamFixturesPane(twr.Team, fixtures)
|
||||||
|
</div>
|
||||||
|
<!-- Stats below both -->
|
||||||
|
<div class="mt-6">
|
||||||
|
@teamStatsSection()
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/vendored/sortablejs@1.15.6.min.js"></script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamRosterSection renders the roster section — exported so it can be used for HTMX swaps
|
||||||
|
templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
|
||||||
|
{{
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canManagePlayers := permCache.HasPermission(permissions.TeamsManagePlayers)
|
||||||
|
|
||||||
|
// Build the non-manager player list for display
|
||||||
|
rosterPlayers := []*db.Player{}
|
||||||
|
for _, p := range twr.Players {
|
||||||
|
if p != nil && (twr.Manager == nil || p.ID != twr.Manager.ID) {
|
||||||
|
rosterPlayers = append(rosterPlayers, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasRoster := twr.Manager != nil || len(rosterPlayers) > 0
|
||||||
|
}}
|
||||||
|
<section
|
||||||
|
id="team-roster-section"
|
||||||
|
x-data="{ showManageRosterModal: false }"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-text">Roster</h2>
|
||||||
|
if canManagePlayers {
|
||||||
|
<button
|
||||||
|
@click="showManageRosterModal = true"
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||||||
|
bg-blue hover:bg-blue/80 text-mantle transition"
|
||||||
|
>
|
||||||
|
Manage Players
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if !hasRoster {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No players on this roster yet.</p>
|
||||||
|
if canManagePlayers {
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Click "Manage Players" to add players to this team.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
|
if twr.Manager != nil {
|
||||||
|
<div class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<span class="text-text font-medium">{ twr.Manager.Name }</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
|
||||||
|
★ Manager
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
for _, player := range rosterPlayers {
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<span class="text-text">{ player.Name }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if canManagePlayers {
|
||||||
|
@manageRosterModal(twr, available, rosterPlayers)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPlayers []*db.Player) {
|
||||||
|
<div
|
||||||
|
x-show="showManageRosterModal"
|
||||||
|
@keydown.escape.window="showManageRosterModal = false"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
style="display: none;"
|
||||||
|
x-data="rosterManager()"
|
||||||
|
x-init="$watch('showManageRosterModal', val => { if (val) $nextTick(() => init()) })"
|
||||||
|
>
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-crust/80 transition-opacity"
|
||||||
|
@click="showManageRosterModal = 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-4xl w-full p-6"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-text">Manage Players</h3>
|
||||||
|
<button
|
||||||
|
@click="showManageRosterModal = false"
|
||||||
|
class="text-subtext0 hover:text-text transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Two column layout -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Left: Available Players -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-text mb-3">Available Players</h4>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="search"
|
||||||
|
@input="applySearch()"
|
||||||
|
placeholder="Search players..."
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none mb-3 text-sm"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="available-players-list"
|
||||||
|
class="bg-base border border-surface1 rounded-lg p-2 min-h-48 max-h-80 overflow-y-auto space-y-1"
|
||||||
|
>
|
||||||
|
for _, player := range available {
|
||||||
|
<div
|
||||||
|
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
|
||||||
|
text-text text-sm cursor-grab hover:bg-surface1 transition"
|
||||||
|
data-id={ fmt.Sprint(player.ID) }
|
||||||
|
data-name={ player.Name }
|
||||||
|
>
|
||||||
|
{ player.Name }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext1 text-xs mt-2">Drag players to the team roster</p>
|
||||||
|
</div>
|
||||||
|
<!-- Right: Team Roster -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-text mb-3">Team Roster</h4>
|
||||||
|
<!-- Manager slot -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="text-sm font-medium text-yellow mb-2 flex items-center gap-1">
|
||||||
|
★ Manager
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
id="manager-drop-zone"
|
||||||
|
class="bg-base border-2 border-dashed border-yellow/40 rounded-lg p-2 min-h-12
|
||||||
|
flex items-center justify-center"
|
||||||
|
>
|
||||||
|
if twr.Manager != nil {
|
||||||
|
<div
|
||||||
|
class="roster-player-chip px-3 py-2 bg-yellow/10 border border-yellow/30 rounded
|
||||||
|
text-text text-sm cursor-grab w-full"
|
||||||
|
data-id={ fmt.Sprint(twr.Manager.ID) }
|
||||||
|
data-name={ twr.Manager.Name }
|
||||||
|
>
|
||||||
|
{ twr.Manager.Name }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Players list -->
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-subtext0 mb-2 block">Players</label>
|
||||||
|
<div
|
||||||
|
id="roster-drop-zone"
|
||||||
|
class="bg-base border-2 border-dashed border-surface1 rounded-lg p-2 min-h-40
|
||||||
|
max-h-60 overflow-y-auto space-y-1"
|
||||||
|
>
|
||||||
|
for _, player := range rosterPlayers {
|
||||||
|
<div
|
||||||
|
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
|
||||||
|
text-text text-sm cursor-grab hover:bg-surface1 transition"
|
||||||
|
data-id={ fmt.Sprint(player.ID) }
|
||||||
|
data-name={ player.Name }
|
||||||
|
>
|
||||||
|
{ player.Name }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-between items-center mt-6 pt-4 border-t border-surface1">
|
||||||
|
<p
|
||||||
|
x-show="!hasManager"
|
||||||
|
class="text-sm text-red"
|
||||||
|
>
|
||||||
|
A manager is required
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showManageRosterModal = false"
|
||||||
|
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
id="roster-submit-form"
|
||||||
|
hx-post="/teams/manage_roster"
|
||||||
|
hx-swap="none"
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="season_id" value={ fmt.Sprint(twr.Season.ID) }/>
|
||||||
|
<input type="hidden" name="league_id" value={ fmt.Sprint(twr.League.ID) }/>
|
||||||
|
<input type="hidden" name="team_id" value={ fmt.Sprint(twr.Team.ID) }/>
|
||||||
|
<input type="hidden" name="manager_id" value="0"/>
|
||||||
|
<!-- player_ids inputs are added dynamically by submitRoster() -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="submitRoster()"
|
||||||
|
:disabled="!hasManager"
|
||||||
|
class="px-4 py-2 rounded-lg text-mantle transition"
|
||||||
|
:class="hasManager ? 'bg-green hover:bg-green/75 hover:cursor-pointer' : 'bg-green/40 cursor-not-allowed'"
|
||||||
|
>
|
||||||
|
Save Roster
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function rosterManager() {
|
||||||
|
return {
|
||||||
|
search: '',
|
||||||
|
hasManager: false,
|
||||||
|
sortableInstances: [],
|
||||||
|
updateHasManager() {
|
||||||
|
const zone = document.getElementById('manager-drop-zone');
|
||||||
|
this.hasManager = zone ? zone.querySelectorAll('.roster-player-chip').length > 0 : false;
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (typeof Sortable === 'undefined') return;
|
||||||
|
|
||||||
|
// Destroy any previous instances
|
||||||
|
this.sortableInstances.forEach(s => s.destroy());
|
||||||
|
this.sortableInstances = [];
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
this.sortableInstances.push(new Sortable(
|
||||||
|
document.getElementById('available-players-list'), {
|
||||||
|
group: { name: 'roster', pull: true, put: true },
|
||||||
|
sort: false,
|
||||||
|
animation: 150,
|
||||||
|
onAdd(evt) { self.applySearch(); self.updateHasManager(); },
|
||||||
|
onRemove(evt) { self.applySearch(); self.updateHasManager(); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.sortableInstances.push(new Sortable(
|
||||||
|
document.getElementById('manager-drop-zone'), {
|
||||||
|
group: { name: 'roster', pull: true, put: function(to) {
|
||||||
|
return to.el.querySelectorAll('.roster-player-chip').length === 0;
|
||||||
|
}},
|
||||||
|
sort: false,
|
||||||
|
animation: 150,
|
||||||
|
onAdd(evt) {
|
||||||
|
// Style the chip for manager zone
|
||||||
|
evt.item.classList.remove('bg-surface0', 'border-surface1');
|
||||||
|
evt.item.classList.add('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||||||
|
self.updateHasManager();
|
||||||
|
},
|
||||||
|
onRemove(evt) {
|
||||||
|
// Revert style
|
||||||
|
evt.item.classList.add('bg-surface0', 'border-surface1');
|
||||||
|
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||||||
|
self.updateHasManager();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.sortableInstances.push(new Sortable(
|
||||||
|
document.getElementById('roster-drop-zone'), {
|
||||||
|
group: { name: 'roster', pull: true, put: true },
|
||||||
|
sort: true,
|
||||||
|
animation: 150,
|
||||||
|
onAdd(evt) {
|
||||||
|
// Ensure surface styling
|
||||||
|
evt.item.classList.add('bg-surface0', 'border-surface1');
|
||||||
|
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.updateHasManager();
|
||||||
|
this.applySearch();
|
||||||
|
},
|
||||||
|
applySearch() {
|
||||||
|
const s = this.search.toLowerCase();
|
||||||
|
const list = document.getElementById('available-players-list');
|
||||||
|
if (!list) return;
|
||||||
|
const chips = list.querySelectorAll('.roster-player-chip');
|
||||||
|
chips.forEach(chip => {
|
||||||
|
const name = (chip.dataset.name || '').toLowerCase();
|
||||||
|
if (s === '' || name.includes(s)) {
|
||||||
|
chip.style.display = '';
|
||||||
|
} else {
|
||||||
|
chip.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
submitRoster() {
|
||||||
|
if (!this.hasManager) return;
|
||||||
|
|
||||||
|
const managerZone = document.getElementById('manager-drop-zone');
|
||||||
|
const rosterZone = document.getElementById('roster-drop-zone');
|
||||||
|
|
||||||
|
const managerChip = managerZone ? managerZone.querySelector('.roster-player-chip') : null;
|
||||||
|
const managerID = managerChip ? managerChip.dataset.id : '0';
|
||||||
|
|
||||||
|
const rosterChips = rosterZone ? rosterZone.querySelectorAll('.roster-player-chip') : [];
|
||||||
|
const playerIDs = Array.from(rosterChips).map(el => el.dataset.id);
|
||||||
|
|
||||||
|
// Build a hidden form dynamically and submit via HTMX
|
||||||
|
const form = document.getElementById('roster-submit-form');
|
||||||
|
// Clear previous dynamic inputs
|
||||||
|
form.querySelectorAll('.dynamic-input').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Set manager ID
|
||||||
|
form.querySelector('[name="manager_id"]').value = managerID;
|
||||||
|
|
||||||
|
// Add player_ids as multiple hidden inputs
|
||||||
|
playerIDs.forEach(id => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'player_ids';
|
||||||
|
input.value = id;
|
||||||
|
input.className = 'dynamic-input';
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger HTMX request on the form
|
||||||
|
htmx.trigger(form, 'submit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) {
|
||||||
|
<section class="space-y-6">
|
||||||
|
<!-- Upcoming -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
|
||||||
|
if len(fixtures) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
|
for _, fixture := range fixtures {
|
||||||
|
@teamFixtureRow(team, fixture)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Results -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">Results coming soon.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Match results will appear here once game data is recorded.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamFixtureRow(team *db.Team, fixture *db.Fixture) {
|
||||||
|
{{
|
||||||
|
isHome := fixture.HomeTeamID == team.ID
|
||||||
|
var opponent string
|
||||||
|
if isHome {
|
||||||
|
opponent = fixture.AwayTeam.Name
|
||||||
|
} else {
|
||||||
|
opponent = fixture.HomeTeam.Name
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
|
||||||
|
GW{ fmt.Sprint(*fixture.GameWeek) }
|
||||||
|
</span>
|
||||||
|
if isHome {
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
|
||||||
|
HOME
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
|
||||||
|
AWAY
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-sm text-subtext0 shrink-0">vs</span>
|
||||||
|
<span class="text-text font-medium truncate">
|
||||||
|
{ opponent }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-subtext1 shrink-0">
|
||||||
|
TBD
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamStatsSection() {
|
||||||
|
<section>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-text">Stats</h2>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">Stats coming soon.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once game data is available.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -122,7 +122,10 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
|
|||||||
} else {
|
} else {
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
for _, team := range teams {
|
for _, team := range teams {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors hover:cursor-pointer">
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
|
||||||
|
class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors hover:cursor-pointer block"
|
||||||
|
>
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<h3 class="text-xl font-bold text-text">{ team.Name }</h3>
|
<h3 class="text-xl font-bold text-text">{ team.Name }</h3>
|
||||||
if team.Color != "" {
|
if team.Color != "" {
|
||||||
@@ -140,7 +143,7 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
|
|||||||
{ team.AltShortName }
|
{ team.AltShortName }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user