diff --git a/internal/db/fixture.go b/internal/db/fixture.go index fedb6ac..37987a6 100644 --- a/internal/db/fixture.go +++ b/internal/db/fixture.go @@ -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 { diff --git a/internal/db/migrations/20260220174806_team_rosters.go b/internal/db/migrations/20260220174806_team_rosters.go new file mode 100644 index 0000000..e7e2f82 --- /dev/null +++ b/internal/db/migrations/20260220174806_team_rosters.go @@ -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 + }, + ) +} diff --git a/internal/db/player.go b/internal/db/player.go index 774b20d..b256dd8 100644 --- a/internal/db/player.go +++ b/internal/db/player.go @@ -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 +} diff --git a/internal/db/seasonleague.go b/internal/db/seasonleague.go index 7ab55ea..a6cc4c4 100644 --- a/internal/db/seasonleague.go +++ b/internal/db/seasonleague.go @@ -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") diff --git a/internal/db/setup.go b/internal/db/setup.go index a26769b..164ba3a 100644 --- a/internal/db/setup.go +++ b/internal/db/setup.go @@ -24,6 +24,7 @@ func (db *DB) RegisterModels() []any { (*UserRole)(nil), (*SeasonLeague)(nil), (*TeamParticipation)(nil), + (*TeamRoster)(nil), (*User)(nil), (*DiscordToken)(nil), (*Season)(nil), diff --git a/internal/db/team.go b/internal/db/team.go index b0d9054..52174db 100644 --- a/internal/db/team.go +++ b/internal/db/team.go @@ -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) { diff --git a/internal/db/teamroster.go b/internal/db/teamroster.go new file mode 100644 index 0000000..6361cb6 --- /dev/null +++ b/internal/db/teamroster.go @@ -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 +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index de0cd50..74829ca 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -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)); diff --git a/internal/embedfs/web/vendored/sortablejs@1.15.6.min.js b/internal/embedfs/web/vendored/sortablejs@1.15.6.min.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/internal/embedfs/web/vendored/sortablejs@1.15.6.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY +
+ +
+
+
+ if team.Color != "" { +
+ } +
+

{ team.Name }

+
+ + { team.ShortName } + + + { team.AltShortName } + + + { season.Name } — { league.Name } + +
+
+
+ + Back to Teams + +
+
+ +
+ +
+ @TeamRosterSection(twr, available) + @teamFixturesPane(twr.Team, fixtures) +
+ +
+ @teamStatsSection() +
+
+
+ + + } +} + +// 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 + }} +
+
+

Roster

+ if canManagePlayers { + + } +
+ if !hasRoster { +
+

No players on this roster yet.

+ if canManagePlayers { +

Click "Manage Players" to add players to this team.

+ } +
+ } else { +
+ if twr.Manager != nil { +
+ { twr.Manager.Name } + + ★ Manager + +
+ } + for _, player := range rosterPlayers { +
+ { player.Name } +
+ } +
+ } + if canManagePlayers { + @manageRosterModal(twr, available, rosterPlayers) + } +
+} + +templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPlayers []*db.Player) { + + +} + +templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) { +
+ +
+

Upcoming

+ if len(fixtures) == 0 { +
+

No upcoming fixtures.

+
+ } else { +
+ for _, fixture := range fixtures { + @teamFixtureRow(team, fixture) + } +
+ } +
+ +
+

Results

+
+

Results coming soon.

+

Match results will appear here once game data is recorded.

+
+
+
+} + +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 + } + }} +
+
+ + GW{ fmt.Sprint(*fixture.GameWeek) } + + if isHome { + + HOME + + } else { + + AWAY + + } + vs + + { opponent } + +
+ + TBD + +
+} + +templ teamStatsSection() { +
+
+

Stats

+
+
+

Stats coming soon.

+

Team statistics will appear here once game data is available.

+
+
+} diff --git a/internal/view/seasonsview/season_league_teams.templ b/internal/view/seasonsview/season_league_teams.templ index 2ae3944..2892246 100644 --- a/internal/view/seasonsview/season_league_teams.templ +++ b/internal/view/seasonsview/season_league_teams.templ @@ -122,7 +122,10 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team, } else { }