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,
|
||||
homeTeamID, awayTeamID, round int, audit *AuditMeta,
|
||||
) (*Fixture, error) {
|
||||
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
||||
if err != nil {
|
||||
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,
|
||||
round int, audit *AuditMeta,
|
||||
) ([]*Fixture, error) {
|
||||
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetSeasonLeague")
|
||||
}
|
||||
@@ -71,22 +71,22 @@ func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName s
|
||||
return fixtures, nil
|
||||
}
|
||||
|
||||
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) {
|
||||
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) {
|
||||
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||
if err != nil {
|
||||
return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague")
|
||||
return nil, nil, errors.Wrap(err, "GetSeasonLeague")
|
||||
}
|
||||
fixtures, err := GetList[Fixture](tx).
|
||||
Where("season_id = ?", season.ID).
|
||||
Where("league_id = ?", league.ID).
|
||||
Where("season_id = ?", sl.SeasonID).
|
||||
Where("league_id = ?", sl.LeagueID).
|
||||
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
|
||||
Relation("HomeTeam").
|
||||
Relation("AwayTeam").
|
||||
GetAll(ctx)
|
||||
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) {
|
||||
@@ -98,6 +98,22 @@ func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
|
||||
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) {
|
||||
fixtures, err := GetList[Fixture](tx).
|
||||
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 {
|
||||
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetSeasonLeague")
|
||||
}
|
||||
err = DeleteItem[Fixture](tx).
|
||||
Where("season_id = ?", season.ID).
|
||||
Where("league_id = ?", league.ID).
|
||||
Where("season_id = ?", sl.SeasonID).
|
||||
Where("league_id = ?", sl.LeagueID).
|
||||
WithAudit(audit, nil).
|
||||
Delete(ctx)
|
||||
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
|
||||
}
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||
"github.com/pkg/errors"
|
||||
@@ -15,8 +16,36 @@ type SeasonLeague struct {
|
||||
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||
}
|
||||
|
||||
// GetSeasonLeague retrieves a specific season-league combination with teams
|
||||
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
|
||||
// GetSeasonLeague retrieves a specific season-league combination
|
||||
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 == "" {
|
||||
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").
|
||||
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
|
||||
|
||||
@@ -24,6 +24,7 @@ func (db *DB) RegisterModels() []any {
|
||||
(*UserRole)(nil),
|
||||
(*SeasonLeague)(nil),
|
||||
(*TeamParticipation)(nil),
|
||||
(*TeamRoster)(nil),
|
||||
(*User)(nil),
|
||||
(*DiscordToken)(nil),
|
||||
(*Season)(nil),
|
||||
|
||||
@@ -17,6 +17,7 @@ type Team struct {
|
||||
|
||||
Seasons []Season `bun:"m2m:team_participations,join:Team=Season" 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) {
|
||||
|
||||
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-2xl: 42rem;
|
||||
--container-3xl: 48rem;
|
||||
--container-4xl: 56rem;
|
||||
--container-5xl: 64rem;
|
||||
--container-7xl: 80rem;
|
||||
--text-xs: 0.75rem;
|
||||
@@ -458,6 +459,9 @@
|
||||
.max-h-60 {
|
||||
max-height: calc(var(--spacing) * 60);
|
||||
}
|
||||
.max-h-80 {
|
||||
max-height: calc(var(--spacing) * 80);
|
||||
}
|
||||
.max-h-96 {
|
||||
max-height: calc(var(--spacing) * 96);
|
||||
}
|
||||
@@ -467,6 +471,12 @@
|
||||
.max-h-\[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-height: calc(var(--spacing) * 48);
|
||||
}
|
||||
@@ -518,6 +528,9 @@
|
||||
.max-w-3xl {
|
||||
max-width: var(--container-3xl);
|
||||
}
|
||||
.max-w-4xl {
|
||||
max-width: var(--container-4xl);
|
||||
}
|
||||
.max-w-5xl {
|
||||
max-width: var(--container-5xl);
|
||||
}
|
||||
@@ -908,6 +921,12 @@
|
||||
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 {
|
||||
background-color: var(--mantle);
|
||||
}
|
||||
@@ -1837,6 +1856,11 @@
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-2 {
|
||||
@media (width >= 64rem) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-3 {
|
||||
@media (width >= 64rem) {
|
||||
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
|
||||
}
|
||||
|
||||
var season *db.Season
|
||||
var league *db.League
|
||||
var sl *db.SeasonLeague
|
||||
var fixtures []*db.Fixture
|
||||
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))
|
||||
@@ -43,7 +42,7 @@ func GenerateFixtures(
|
||||
}
|
||||
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 {
|
||||
return false, errors.Wrap(err, "db.GetFixtures")
|
||||
}
|
||||
@@ -52,7 +51,7 @@ func GenerateFixtures(
|
||||
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) {
|
||||
var err error
|
||||
_, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||
_, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
|
||||
|
||||
@@ -21,11 +21,11 @@ func SeasonLeaguePage(
|
||||
seasonStr := r.PathValue("season_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) {
|
||||
var err error
|
||||
season, _, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
@@ -38,7 +38,7 @@ func SeasonLeaguePage(
|
||||
return
|
||||
}
|
||||
|
||||
defaultTab := season.GetDefaultTab()
|
||||
defaultTab := sl.Season.GetDefaultTab()
|
||||
redirectURL := fmt.Sprintf(
|
||||
"/seasons/%s/leagues/%s/%s",
|
||||
seasonStr, leagueStr, defaultTab,
|
||||
|
||||
@@ -21,12 +21,11 @@ func SeasonLeagueFinalsPage(
|
||||
seasonStr := r.PathValue("season_short_name")
|
||||
leagueStr := r.PathValue("league_short_name")
|
||||
|
||||
var season *db.Season
|
||||
var league *db.League
|
||||
var sl *db.SeasonLeague
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
@@ -40,7 +39,7 @@ func SeasonLeagueFinalsPage(
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league), s, r, w)
|
||||
renderSafely(seasonsview.SeasonLeagueFinalsPage(sl.Season, sl.League), s, r, w)
|
||||
} else {
|
||||
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
|
||||
}
|
||||
|
||||
@@ -22,13 +22,12 @@ func SeasonLeagueFixturesPage(
|
||||
seasonShortName := r.PathValue("season_short_name")
|
||||
leagueShortName := r.PathValue("league_short_name")
|
||||
|
||||
var season *db.Season
|
||||
var league *db.League
|
||||
var sl *db.SeasonLeague
|
||||
var fixtures []*db.Fixture
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
@@ -42,9 +41,9 @@ func SeasonLeagueFixturesPage(
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
leagueShortName := r.PathValue("league_short_name")
|
||||
|
||||
var season *db.Season
|
||||
var league *db.League
|
||||
var sl *db.SeasonLeague
|
||||
var fixtures []*db.Fixture
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
@@ -76,7 +74,7 @@ func SeasonLeagueManageFixturesPage(
|
||||
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")
|
||||
leagueShortName := r.PathValue("league_short_name")
|
||||
|
||||
var season *db.Season
|
||||
var league *db.League
|
||||
var sl *db.SeasonLeague
|
||||
var fixtures []*db.Fixture
|
||||
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))
|
||||
@@ -100,7 +97,7 @@ func SeasonLeagueDeleteFixtures(
|
||||
}
|
||||
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 {
|
||||
return false, errors.Wrap(err, "db.GetFixtures")
|
||||
}
|
||||
@@ -109,6 +106,6 @@ func SeasonLeagueDeleteFixtures(
|
||||
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")
|
||||
leagueStr := r.PathValue("league_short_name")
|
||||
|
||||
var season *db.Season
|
||||
var league *db.League
|
||||
var sl *db.SeasonLeague
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
@@ -40,7 +39,7 @@ func SeasonLeagueStatsPage(
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w)
|
||||
renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w)
|
||||
} else {
|
||||
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
|
||||
}
|
||||
|
||||
@@ -21,12 +21,11 @@ func SeasonLeagueTablePage(
|
||||
seasonStr := r.PathValue("season_short_name")
|
||||
leagueStr := r.PathValue("league_short_name")
|
||||
|
||||
var season *db.Season
|
||||
var league *db.League
|
||||
var sl *db.SeasonLeague
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
@@ -39,7 +38,7 @@ func SeasonLeagueTablePage(
|
||||
return
|
||||
}
|
||||
if r.Method == "GET" {
|
||||
renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w)
|
||||
renderSafely(seasonsview.SeasonLeagueTablePage(sl.Season, sl.League), s, r, w)
|
||||
} else {
|
||||
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) {
|
||||
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 db.IsBadRequest(err) {
|
||||
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},
|
||||
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",
|
||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||
@@ -206,6 +211,11 @@ func addRoutes(
|
||||
Method: hws.MethodPOST,
|
||||
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{
|
||||
|
||||
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 {
|
||||
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 {
|
||||
@@ -72,6 +83,10 @@ func (f *FormGetter) Int(key string) *IntField {
|
||||
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 {
|
||||
return newTimeField(key, format, f)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ func (q *QueryGetter) Int(key string) *IntField {
|
||||
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 {
|
||||
return newTimeField(key, format, q)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Getter interface {
|
||||
AddCheck(check *ValidationRule)
|
||||
String(key string) *StringField
|
||||
Int(key string) *IntField
|
||||
Bool(key string) *BoolField
|
||||
Time(key string, format *timefmt.Format) *TimeField
|
||||
StringList(key string) *StringList
|
||||
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 {
|
||||
<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 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">
|
||||
<h3 class="text-xl font-bold text-text">{ team.Name }</h3>
|
||||
if team.Color != "" {
|
||||
@@ -140,7 +143,7 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
|
||||
{ team.AltShortName }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user