added player stats to profile
This commit is contained in:
@@ -99,6 +99,243 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlayerAllTimeStats holds aggregated all-time stats for a single player
|
||||||
|
type PlayerAllTimeStats struct {
|
||||||
|
GamesPlayed int `bun:"games_played"`
|
||||||
|
PeriodsPlayed int `bun:"total_periods_played"`
|
||||||
|
Goals int `bun:"total_goals"`
|
||||||
|
Assists int `bun:"total_assists"`
|
||||||
|
Saves int `bun:"total_saves"`
|
||||||
|
Shots int `bun:"total_shots"`
|
||||||
|
Blocks int `bun:"total_blocks"`
|
||||||
|
Passes int `bun:"total_passes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerAllTimeStats returns aggregated all-time stats for a player
|
||||||
|
// across all finalized fixture results (period 3 totals).
|
||||||
|
func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
stats := new(PlayerAllTimeStats)
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||||
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||||
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||||
|
COALESCE(SUM(frps.passes), 0) AS total_passes
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND frps.player_id = ?
|
||||||
|
AND frps.period_num = 3
|
||||||
|
`, playerID).Scan(ctx, stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerStatsBySeason returns aggregated stats for a player filtered by season.
|
||||||
|
func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
if seasonID == 0 {
|
||||||
|
return nil, errors.New("seasonID not provided")
|
||||||
|
}
|
||||||
|
stats := new(PlayerAllTimeStats)
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||||
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||||
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||||
|
COALESCE(SUM(frps.passes), 0) AS total_passes
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
JOIN fixtures f ON f.id = fr.fixture_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND frps.player_id = ?
|
||||||
|
AND frps.period_num = 3
|
||||||
|
AND f.season_id = ?
|
||||||
|
`, playerID, seasonID).Scan(ctx, stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerStatsByTeam returns aggregated stats for a player filtered by team.
|
||||||
|
func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
if teamID == 0 {
|
||||||
|
return nil, errors.New("teamID not provided")
|
||||||
|
}
|
||||||
|
stats := new(PlayerAllTimeStats)
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||||
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||||
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||||
|
COALESCE(SUM(frps.passes), 0) AS total_passes
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND frps.player_id = ?
|
||||||
|
AND frps.period_num = 3
|
||||||
|
AND frps.team_id = ?
|
||||||
|
`, playerID, teamID).Scan(ctx, stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerTeamInfo holds a team the player has played on and how many seasons
|
||||||
|
type PlayerTeamInfo struct {
|
||||||
|
Team *Team
|
||||||
|
SeasonsCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerTeams returns all teams the player has been rostered on,
|
||||||
|
// with a count of distinct seasons per team.
|
||||||
|
func GetPlayerTeams(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerTeamInfo, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
type teamRow struct {
|
||||||
|
TeamID int `bun:"team_id"`
|
||||||
|
SeasonsCount int `bun:"seasons_count"`
|
||||||
|
Name string `bun:"name"`
|
||||||
|
ShortName string `bun:"short_name"`
|
||||||
|
AltShortName string `bun:"alt_short_name"`
|
||||||
|
Color string `bun:"color"`
|
||||||
|
}
|
||||||
|
var rows []teamRow
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
t.id AS team_id,
|
||||||
|
t.name AS name,
|
||||||
|
t.short_name AS short_name,
|
||||||
|
t.alt_short_name AS alt_short_name,
|
||||||
|
t.color AS color,
|
||||||
|
COUNT(DISTINCT tr.season_id) AS seasons_count
|
||||||
|
FROM team_rosters tr
|
||||||
|
JOIN teams t ON t.id = tr.team_id
|
||||||
|
WHERE tr.player_id = ?
|
||||||
|
GROUP BY t.id, t.name, t.short_name, t.alt_short_name, t.color
|
||||||
|
ORDER BY seasons_count DESC
|
||||||
|
`, playerID).Scan(ctx, &rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []*PlayerTeamInfo
|
||||||
|
for _, row := range rows {
|
||||||
|
results = append(results, &PlayerTeamInfo{
|
||||||
|
Team: &Team{
|
||||||
|
ID: row.TeamID,
|
||||||
|
Name: row.Name,
|
||||||
|
ShortName: row.ShortName,
|
||||||
|
AltShortName: row.AltShortName,
|
||||||
|
Color: row.Color,
|
||||||
|
},
|
||||||
|
SeasonsCount: row.SeasonsCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerSeasonInfo holds info about a player's participation in a specific season
|
||||||
|
type PlayerSeasonInfo struct {
|
||||||
|
Season *Season
|
||||||
|
League *League
|
||||||
|
Team *Team
|
||||||
|
IsManager bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerSeasons returns all season/league/team combos the player has been rostered in.
|
||||||
|
func GetPlayerSeasons(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerSeasonInfo, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
var rosters []*TeamRoster
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&rosters).
|
||||||
|
Where("tr.player_id = ?", playerID).
|
||||||
|
Relation("Season").
|
||||||
|
Relation("League").
|
||||||
|
Relation("Team").
|
||||||
|
Order("season.start_date DESC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []*PlayerSeasonInfo
|
||||||
|
for _, r := range rosters {
|
||||||
|
results = append(results, &PlayerSeasonInfo{
|
||||||
|
Season: r.Season,
|
||||||
|
League: r.League,
|
||||||
|
Team: r.Team,
|
||||||
|
IsManager: r.IsManager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerSeasonsList returns distinct seasons the player has participated in (for filter dropdowns).
|
||||||
|
func GetPlayerSeasonsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Season, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
var seasons []*Season
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&seasons).
|
||||||
|
Join("JOIN team_rosters tr ON tr.season_id = s.id").
|
||||||
|
Where("tr.player_id = ?", playerID).
|
||||||
|
GroupExpr("s.id").
|
||||||
|
Order("s.start_date DESC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return seasons, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerTeamsList returns distinct teams the player has played on (for filter dropdowns).
|
||||||
|
func GetPlayerTeamsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Team, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
var teams []*Team
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&teams).
|
||||||
|
Join("JOIN team_rosters tr ON tr.team_id = t.id").
|
||||||
|
Where("tr.player_id = ?", playerID).
|
||||||
|
GroupExpr("t.id").
|
||||||
|
Order("t.name ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return teams, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
|
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
|
||||||
players, err := GetList[Player](tx).Relation("User").
|
players, err := GetList[Player](tx).Relation("User").
|
||||||
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").
|
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").
|
||||||
|
|||||||
@@ -906,12 +906,6 @@
|
|||||||
.border-green {
|
.border-green {
|
||||||
border-color: var(--green);
|
border-color: var(--green);
|
||||||
}
|
}
|
||||||
.border-green\/30 {
|
|
||||||
border-color: var(--green);
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
border-color: color-mix(in oklab, var(--green) 30%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.border-overlay0 {
|
.border-overlay0 {
|
||||||
border-color: var(--overlay0);
|
border-color: var(--overlay0);
|
||||||
}
|
}
|
||||||
@@ -1008,12 +1002,6 @@
|
|||||||
.bg-green {
|
.bg-green {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
}
|
}
|
||||||
.bg-green\/10 {
|
|
||||||
background-color: var(--green);
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
background-color: color-mix(in oklab, var(--green) 10%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.bg-green\/20 {
|
.bg-green\/20 {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1317,6 +1305,9 @@
|
|||||||
.text-mantle {
|
.text-mantle {
|
||||||
color: var(--mantle);
|
color: var(--mantle);
|
||||||
}
|
}
|
||||||
|
.text-mauve {
|
||||||
|
color: var(--mauve);
|
||||||
|
}
|
||||||
.text-overlay0 {
|
.text-overlay0 {
|
||||||
color: var(--overlay0);
|
color: var(--overlay0);
|
||||||
}
|
}
|
||||||
@@ -1338,6 +1329,9 @@
|
|||||||
color: color-mix(in oklab, var(--red) 80%, transparent);
|
color: color-mix(in oklab, var(--red) 80%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.text-sky {
|
||||||
|
color: var(--sky);
|
||||||
|
}
|
||||||
.text-subtext0 {
|
.text-subtext0 {
|
||||||
color: var(--subtext0);
|
color: var(--subtext0);
|
||||||
}
|
}
|
||||||
@@ -2059,6 +2053,11 @@
|
|||||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
scale: var(--tw-scale-x) var(--tw-scale-y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:grid-cols-4 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:flex-row {
|
.sm\:flex-row {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
93
internal/handlers/player_stats_filter.go
Normal file
93
internal/handlers/player_stats_filter.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayerStatsFilter handles HTMX POST requests to filter player stats
|
||||||
|
// by season or team. Only one filter can be active at a time.
|
||||||
|
// Query params: filter=season|team, filter_id=<id>
|
||||||
|
func PlayerStatsFilter(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
player, _, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filterType := r.URL.Query().Get("filter")
|
||||||
|
filterIDStr := r.URL.Query().Get("filter_id")
|
||||||
|
|
||||||
|
var stats *db.PlayerAllTimeStats
|
||||||
|
var seasons []*db.Season
|
||||||
|
var teams []*db.Team
|
||||||
|
var activeFilter string
|
||||||
|
var activeFilterID int
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Load filter dropdown data
|
||||||
|
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
|
||||||
|
}
|
||||||
|
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
filterID, _ := strconv.Atoi(filterIDStr)
|
||||||
|
switch filterType {
|
||||||
|
case "season":
|
||||||
|
if filterID > 0 {
|
||||||
|
stats, err = db.GetPlayerStatsBySeason(ctx, tx, player.ID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerStatsBySeason")
|
||||||
|
}
|
||||||
|
activeFilter = "season"
|
||||||
|
activeFilterID = filterID
|
||||||
|
}
|
||||||
|
case "team":
|
||||||
|
if filterID > 0 {
|
||||||
|
stats, err = db.GetPlayerStatsByTeam(ctx, tx, player.ID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerStatsByTeam")
|
||||||
|
}
|
||||||
|
activeFilter = "team"
|
||||||
|
activeFilterID = filterID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to all-time stats if no valid filter
|
||||||
|
if stats == nil {
|
||||||
|
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
|
||||||
|
}
|
||||||
|
activeFilter = ""
|
||||||
|
activeFilterID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(playersview.PlayerStatsTab(
|
||||||
|
player, stats, seasons, teams,
|
||||||
|
activeFilter, activeFilterID,
|
||||||
|
), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -32,53 +32,155 @@ func ProfileRedirect(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayerView renders the player profile page.
|
// resolvePlayerAndOwner is a helper that resolves the player from the URL path
|
||||||
// If the player has no SlapID and the viewer is the player's owner, show the link prompt.
|
// and determines if the current user is the owner of the player.
|
||||||
// If the player has no SlapID and the viewer is not the owner, show 404.
|
// Returns false from the outer handler if resolution failed (404 already thrown).
|
||||||
func PlayerView(
|
func resolvePlayerAndOwner(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) (player *db.Player, isOwner bool, ok bool) {
|
||||||
|
playerIDStr := r.PathValue("player_id")
|
||||||
|
playerID, err := strconv.Atoi(playerIDStr)
|
||||||
|
if err != nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
player, err = db.GetPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayer")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
if user != nil && player.UserID != nil && *player.UserID == user.ID {
|
||||||
|
isOwner = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If player has no SlapID and viewer is not the owner, show 404
|
||||||
|
if player.SlapID == nil && !isOwner {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return player, isOwner, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerViewStats renders the player profile page with the stats tab active.
|
||||||
|
// GET renders the full page layout. POST renders just the tab content.
|
||||||
|
func PlayerViewStats(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
playerIDStr := r.PathValue("player_id")
|
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
playerID, err := strconv.Atoi(playerIDStr)
|
|
||||||
if err != nil {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var player *db.Player
|
var stats *db.PlayerAllTimeStats
|
||||||
var isOwner bool
|
var seasons []*db.Season
|
||||||
|
var teams []*db.Team
|
||||||
|
|
||||||
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
|
||||||
player, err = db.GetPlayer(ctx, tx, playerID)
|
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayer")
|
|
||||||
}
|
}
|
||||||
|
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
|
||||||
// Check if the current user owns this player
|
if err != nil {
|
||||||
user := db.CurrentUser(ctx)
|
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
|
||||||
if user != nil && player.UserID != nil && *player.UserID == user.ID {
|
}
|
||||||
isOwner = true
|
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If player has no SlapID and viewer is not the owner, show 404
|
if r.Method == "GET" {
|
||||||
if player.SlapID == nil && !isOwner {
|
renderSafely(playersview.PlayerStatsPage(player, isOwner, stats, seasons, teams), s, r, w)
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
} else {
|
||||||
|
renderSafely(playersview.PlayerStatsTab(player, stats, seasons, teams, "", 0), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerViewTeams renders the teams tab of the player profile page.
|
||||||
|
func PlayerViewTeams(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(playersview.PlayerPage(player, isOwner), s, r, w)
|
var teamInfos []*db.PlayerTeamInfo
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
teamInfos, err = db.GetPlayerTeams(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerTeams")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(playersview.PlayerTeamsPage(player, isOwner, teamInfos), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(playersview.PlayerTeamsTab(teamInfos), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerViewSeasons renders the seasons tab of the player profile page.
|
||||||
|
func PlayerViewSeasons(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasonInfos []*db.PlayerSeasonInfo
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
seasonInfos, err = db.GetPlayerSeasons(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerSeasons")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(playersview.PlayerSeasonsPage(player, isOwner, seasonInfos), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(playersview.PlayerSeasonsTab(seasonInfos), s, r, w)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,7 +304,27 @@ func addRoutes(
|
|||||||
{
|
{
|
||||||
Path: "/players/{player_id}",
|
Path: "/players/{player_id}",
|
||||||
Method: hws.MethodGET,
|
Method: hws.MethodGET,
|
||||||
Handler: handlers.PlayerView(s, conn),
|
Handler: handlers.PlayerViewStats(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/players/{player_id}/stats",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.PlayerViewStats(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/players/{player_id}/teams",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.PlayerViewTeams(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/players/{player_id}/seasons",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.PlayerViewSeasons(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/players/{player_id}/stats/filter",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.PlayerStatsFilter(s, conn),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: "/players/{player_id}/link-slapid",
|
Path: "/players/{player_id}/link-slapid",
|
||||||
|
|||||||
@@ -4,35 +4,94 @@ import "git.haelnorr.com/h/oslstats/internal/db"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ PlayerPage(player *db.Player, isOwner bool) {
|
templ PlayerLayout(activeSection string, player *db.Player, isOwner bool) {
|
||||||
@baseview.Layout(player.DisplayName() + " - Player Profile") {
|
@baseview.Layout(player.DisplayName() + " - Player Profile") {
|
||||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
<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 flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold text-text">{ player.DisplayName() }</h1>
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
<h1 class="text-4xl font-bold text-text">{ player.DisplayName() }</h1>
|
||||||
if player.SlapID != nil {
|
|
||||||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
|
||||||
Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
if isOwner {
|
if isOwner {
|
||||||
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||||
Your Profile
|
Your Profile
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
if player.SlapID != nil {
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Content -->
|
<!-- SlapID Link Prompt (if needed) -->
|
||||||
<div class="p-6">
|
if player.SlapID == nil && isOwner {
|
||||||
@SlapIDSection(player, isOwner)
|
<div class="px-6 pt-6">
|
||||||
</div>
|
@SlapIDSection(player, isOwner)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="player-content">
|
||||||
|
<ul class="flex flex-wrap">
|
||||||
|
@playerNavItem("stats", "Stats", activeSection, player)
|
||||||
|
@playerNavItem("teams", "Teams", activeSection, player)
|
||||||
|
@playerNavItem("seasons", "Seasons", activeSection, player)
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<!-- Content Area -->
|
||||||
|
<main class="bg-crust p-6" id="player-content">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/static/js/tabs.js" defer></script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ playerNavItem(section string, label string, activeSection string, player *db.Player) {
|
||||||
|
{{
|
||||||
|
isActive := section == activeSection
|
||||||
|
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||||
|
activeClasses := "border-blue text-blue font-semibold"
|
||||||
|
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||||
|
url := fmt.Sprintf("/players/%d/%s", player.ID, section)
|
||||||
|
}}
|
||||||
|
<li class="inline-block">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(url) }
|
||||||
|
hx-post={ url }
|
||||||
|
hx-target="#player-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url={ url }
|
||||||
|
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||||
|
>
|
||||||
|
{ label }
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full page wrappers (for GET requests / direct navigation)
|
||||||
|
|
||||||
|
templ PlayerStatsPage(player *db.Player, isOwner bool, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team) {
|
||||||
|
@PlayerLayout("stats", player, isOwner) {
|
||||||
|
@PlayerStatsTab(player, stats, seasons, teams, "", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ PlayerTeamsPage(player *db.Player, isOwner bool, teamInfos []*db.PlayerTeamInfo) {
|
||||||
|
@PlayerLayout("teams", player, isOwner) {
|
||||||
|
@PlayerTeamsTab(teamInfos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ PlayerSeasonsPage(player *db.Player, isOwner bool, seasonInfos []*db.PlayerSeasonInfo) {
|
||||||
|
@PlayerLayout("seasons", player, isOwner) {
|
||||||
|
@PlayerSeasonsTab(seasonInfos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
internal/view/playersview/player_seasons_tab.templ
Normal file
73
internal/view/playersview/player_seasons_tab.templ
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ PlayerSeasonsTab(seasonInfos []*db.PlayerSeasonInfo) {
|
||||||
|
if len(seasonInfos) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No season history yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">This player has not participated in any seasons.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Season</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">League</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||||
|
<th class="px-4 py-3 text-center text-sm font-semibold text-text">Role</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, info := range seasonInfos {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s", info.Season.ShortName)) }
|
||||||
|
class="text-blue hover:text-blue/80 transition"
|
||||||
|
>
|
||||||
|
{ info.Season.Name }
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-subtext0">
|
||||||
|
{ info.League.Name }
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf(
|
||||||
|
"/seasons/%s/leagues/%s/teams/%d",
|
||||||
|
info.Season.ShortName, info.League.ShortName, info.Team.ID,
|
||||||
|
)) }
|
||||||
|
class="text-blue hover:text-blue/80 transition"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if info.Team.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full border border-surface1 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<span>{ info.Team.Name }</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-center">
|
||||||
|
if info.IsManager {
|
||||||
|
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
Manager
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-subtext1 text-xs">Player</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/view/playersview/player_stats_tab.templ
Normal file
130
internal/view/playersview/player_stats_tab.templ
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ PlayerStatsTab(player *db.Player, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team, activeFilter string, activeFilterID int) {
|
||||||
|
<div class="space-y-6" data-filter-url={ fmt.Sprintf("/players/%d/stats/filter", player.ID) }>
|
||||||
|
<!-- Filter Controls -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<!-- Season Filter -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Season</label>
|
||||||
|
<select
|
||||||
|
name="season_id"
|
||||||
|
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
onchange={ handleFilterChange("season") }
|
||||||
|
>
|
||||||
|
<option value="">All Seasons</option>
|
||||||
|
for _, s := range seasons {
|
||||||
|
<option
|
||||||
|
value={ fmt.Sprint(s.ID) }
|
||||||
|
selected?={ activeFilter == "season" && activeFilterID == s.ID }
|
||||||
|
>
|
||||||
|
{ s.Name }
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Team Filter -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Team</label>
|
||||||
|
<select
|
||||||
|
name="team_id"
|
||||||
|
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
onchange={ handleFilterChange("team") }
|
||||||
|
>
|
||||||
|
<option value="">All Teams</option>
|
||||||
|
for _, t := range teams {
|
||||||
|
<option
|
||||||
|
value={ fmt.Sprint(t.ID) }
|
||||||
|
selected?={ activeFilter == "team" && activeFilterID == t.ID }
|
||||||
|
>
|
||||||
|
{ t.Name }
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Filter Label -->
|
||||||
|
<div class="text-sm text-subtext0">
|
||||||
|
if activeFilter == "" {
|
||||||
|
Showing <span class="text-text font-medium">All-Time</span> stats
|
||||||
|
} else if activeFilter == "season" {
|
||||||
|
Showing stats for season:
|
||||||
|
<span class="text-text font-medium">
|
||||||
|
{ getSeasonName(seasons, activeFilterID) }
|
||||||
|
</span>
|
||||||
|
} else if activeFilter == "team" {
|
||||||
|
Showing stats for team:
|
||||||
|
<span class="text-text font-medium">
|
||||||
|
{ getTeamName(teams, activeFilterID) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
@playerStatsGrid(stats)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ playerStatsGrid(stats *db.PlayerAllTimeStats) {
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
@statCard("Games Played", fmt.Sprint(stats.GamesPlayed), "text-blue")
|
||||||
|
@statCard("Goals", fmt.Sprint(stats.Goals), "text-green")
|
||||||
|
@statCard("Assists", fmt.Sprint(stats.Assists), "text-teal")
|
||||||
|
@statCard("Saves", fmt.Sprint(stats.Saves), "text-yellow")
|
||||||
|
@statCard("Shots", fmt.Sprint(stats.Shots), "text-peach")
|
||||||
|
@statCard("Blocks", fmt.Sprint(stats.Blocks), "text-mauve")
|
||||||
|
@statCard("Passes", fmt.Sprint(stats.Passes), "text-sky")
|
||||||
|
@statCard("Periods Played", fmt.Sprint(stats.PeriodsPlayed), "text-subtext0")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ statCard(label string, value string, colorClass string) {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-xs text-subtext0 uppercase font-medium mb-1">{ label }</p>
|
||||||
|
<p class={ "text-2xl font-bold", colorClass }>{ value }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
script handleFilterChange(filterType string) {
|
||||||
|
var container = event.target.closest("[data-filter-url]")
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
var baseUrl = container.getAttribute("data-filter-url")
|
||||||
|
var seasonSelect = container.querySelector("select[name='season_id']")
|
||||||
|
var teamSelect = container.querySelector("select[name='team_id']")
|
||||||
|
|
||||||
|
// Reset the other filter when one is selected
|
||||||
|
if (filterType === "season" && teamSelect) {
|
||||||
|
teamSelect.value = ""
|
||||||
|
} else if (filterType === "team" && seasonSelect) {
|
||||||
|
seasonSelect.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = event.target.value
|
||||||
|
var url = baseUrl
|
||||||
|
if (value) {
|
||||||
|
url += "?filter=" + filterType + "&filter_id=" + value
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.ajax("POST", url, {target: "#player-content", swap: "innerHTML"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSeasonName(seasons []*db.Season, id int) string {
|
||||||
|
for _, s := range seasons {
|
||||||
|
if s.ID == id {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTeamName(teams []*db.Team, id int) string {
|
||||||
|
for _, t := range teams {
|
||||||
|
if t.ID == id {
|
||||||
|
return t.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
51
internal/view/playersview/player_teams_tab.templ
Normal file
51
internal/view/playersview/player_teams_tab.templ
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ PlayerTeamsTab(teamInfos []*db.PlayerTeamInfo) {
|
||||||
|
if len(teamInfos) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No team history yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">This player has not been on any teams.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||||
|
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Seasons Played</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, info := range teamInfos {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", info.Team.ID)) }
|
||||||
|
class="text-blue hover:text-blue/80 transition"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
if info.Team.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 rounded-full border border-surface1 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<span>{ info.Team.Name }</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-subtext0 text-right">
|
||||||
|
{ fmt.Sprint(info.SeasonsCount) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ templ SlapIDSection(player *db.Player, isOwner bool) {
|
|||||||
<div id="slap-id-section">
|
<div id="slap-id-section">
|
||||||
if player.SlapID == nil && isOwner {
|
if player.SlapID == nil && isOwner {
|
||||||
@slapIDLinkPrompt(player)
|
@slapIDLinkPrompt(player)
|
||||||
} else if player.SlapID != nil {
|
|
||||||
@slapIDLinked(player)
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -52,26 +50,3 @@ templ slapIDLinkPrompt(player *db.Player) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ slapIDLinked(player *db.Player) {
|
|
||||||
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-5 h-5 text-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-text">
|
|
||||||
Slapshot ID linked:
|
|
||||||
<span class="font-mono text-subtext0">
|
|
||||||
if player.SlapID != nil {
|
|
||||||
{ fmt.Sprintf("%d", *player.SlapID) }
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user