From 71181c43e935e7fb085cd9de47a375bb7dbc179b Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 19:51:27 +1100 Subject: [PATCH] player profile added --- internal/embedfs/web/css/output.css | 15 +++ internal/handlers/player_link_slapid.go | 104 ++++++++++++++++++ internal/handlers/player_view.go | 84 ++++++++++++++ internal/handlers/register.go | 8 +- internal/server/routes.go | 19 ++++ internal/view/playersview/player_page.templ | 38 +++++++ .../view/playersview/slap_id_section.templ | 77 +++++++++++++ 7 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 internal/handlers/player_link_slapid.go create mode 100644 internal/handlers/player_view.go create mode 100644 internal/view/playersview/player_page.templ create mode 100644 internal/view/playersview/slap_id_section.templ diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index b856653..0901023 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -906,6 +906,12 @@ .border-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-color: var(--overlay0); } @@ -1002,6 +1008,12 @@ .bg-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 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1371,6 +1383,9 @@ .italic { font-style: italic; } + .underline { + text-decoration-line: underline; + } .placeholder-subtext0 { &::placeholder { color: var(--subtext0); diff --git a/internal/handlers/player_link_slapid.go b/internal/handlers/player_link_slapid.go new file mode 100644 index 0000000..0f181b5 --- /dev/null +++ b/internal/handlers/player_link_slapid.go @@ -0,0 +1,104 @@ +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/notify" + "git.haelnorr.com/h/oslstats/internal/throw" + playersview "git.haelnorr.com/h/oslstats/internal/view/playersview" + "git.haelnorr.com/h/oslstats/pkg/slapshotapi" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// LinkPlayerSlapID handles the HTMX POST request to link a player's Slapshot ID +// via their Discord Steam connection. Only the player's owner can trigger this. +func LinkPlayerSlapID( + s *hws.Server, + conn *db.DB, + slapAPI *slapshotapi.SlapAPI, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + playerIDStr := r.PathValue("player_id") + + playerID, err := strconv.Atoi(playerIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + var player *db.Player + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + user := db.CurrentUser(ctx) + if user == nil { + throw.Unauthorized(s, w, r, "You must be logged in", errors.New("user not authenticated")) + return false, nil + } + + 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") + } + + // Verify the current user owns this player + if player.UserID == nil || *player.UserID != user.ID { + throw.ForbiddenSecurity(s, w, r, "You can only link your own player", errors.New("user does not own player")) + return false, nil + } + + // Player already has a SlapID + if player.SlapID != nil { + notify.Info(s, w, r, "Already Linked", "Your Slapshot ID is already linked", nil) + return false, nil + } + + // Get the user's discord token to look up steam connection + discordToken, err := user.GetDiscordToken(ctx, tx) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Link Failed", "Discord token not found. Please log out and log back in.", nil) + return false, nil + } + return false, errors.Wrap(err, "user.GetDiscordToken") + } + + audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user) + err = ConnectSlapID(ctx, tx, user, discordToken.Convert(), slapAPI, audit) + if err != nil { + return false, errors.Wrap(err, "ConnectSlapID") + } + + // Re-fetch the player to check if SlapID was set + player, err = db.GetPlayer(ctx, tx, playerID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayer") + } + + if player.SlapID == nil { + // ConnectSlapID returned nil (silent failure) - no steam or no slapID + notify.Warn(s, w, r, "Link Failed", + "Could not find your Slapshot ID. Make sure your Steam account is connected to Discord and you have played Slapshot: Rebound.", + nil) + } else { + notify.Success(s, w, r, "Success", "Your Slapshot ID has been linked!", nil) + } + + return true, nil + }); !ok { + return + } + + // Re-render the slap ID section with updated state + renderSafely(playersview.SlapIDSection(player, true), s, r, w) + }) +} diff --git a/internal/handlers/player_view.go b/internal/handlers/player_view.go new file mode 100644 index 0000000..11fbd80 --- /dev/null +++ b/internal/handlers/player_view.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/throw" + playersview "git.haelnorr.com/h/oslstats/internal/view/playersview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ProfileRedirect redirects the authenticated user to their own player page. +func ProfileRedirect( + s *hws.Server, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := db.CurrentUser(r.Context()) + if user == nil { + throw.Unauthorized(s, w, r, "You must be logged in to view your profile", errors.New("user not authenticated")) + return + } + if user.Player == nil { + throw.InternalServiceError(s, w, r, "Player profile not found", errors.New("user has no linked player")) + return + } + http.Redirect(w, r, fmt.Sprintf("/players/%d", user.Player.ID), http.StatusSeeOther) + }) +} + +// PlayerView renders the player profile page. +// If the player has no SlapID and the viewer is the player's owner, show the link prompt. +// If the player has no SlapID and the viewer is not the owner, show 404. +func PlayerView( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + playerIDStr := r.PathValue("player_id") + + playerID, err := strconv.Atoi(playerIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + var player *db.Player + var isOwner bool + + 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") + } + + // Check if the current user owns this player + user := db.CurrentUser(ctx) + if user != nil && player.UserID != nil && *player.UserID == user.ID { + isOwner = true + } + + return true, nil + }); !ok { + return + } + + // 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 + } + + renderSafely(playersview.PlayerPage(player, isOwner), s, r, w) + }) +} diff --git a/internal/handlers/register.go b/internal/handlers/register.go index f787f05..61bbb24 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -73,7 +73,7 @@ func Register( if err != nil { return false, errors.Wrap(err, "registerUser") } - err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit) + err = ConnectSlapID(ctx, tx, user, details.Token, slapAPI, audit) if err != nil { return false, errors.Wrap(err, "connectSlapID") } @@ -123,11 +123,11 @@ func registerUser(ctx context.Context, tx bun.Tx, return user, nil } -func connectSlapID(ctx context.Context, tx bun.Tx, user *db.User, +// ConnectSlapID attempts to link a player's Slapshot ID via their Discord Steam connection. +// If fails due to no steam connection or no slapID, fails silently and returns nil. +func ConnectSlapID(ctx context.Context, tx bun.Tx, user *db.User, token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta, ) error { - // Attempt to setup their player/slapID from steam connection - // If fails due to no steam connection or no slapID, fail silently and proceed with registration session, err := discord.NewOAuthSession(token) if err != nil { return errors.Wrap(err, "discord.NewOAuthSession") diff --git a/internal/server/routes.go b/internal/server/routes.go index 9d797c5..c74bc5b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -64,6 +64,11 @@ func addRoutes( Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)), }, + { + Path: "/profile", + Method: hws.MethodGET, + Handler: auth.LoginReq(handlers.ProfileRedirect(s)), + }, } seasonRoutes := []hws.Route{ @@ -295,6 +300,19 @@ func addRoutes( }, } + playerRoutes := []hws.Route{ + { + Path: "/players/{player_id}", + Method: hws.MethodGET, + Handler: handlers.PlayerView(s, conn), + }, + { + Path: "/players/{player_id}/link-slapid", + Method: hws.MethodPOST, + Handler: auth.LoginReq(handlers.LinkPlayerSlapID(s, conn, slapAPI)), + }, + } + teamRoutes := []hws.Route{ { Path: "/teams", @@ -468,6 +486,7 @@ func addRoutes( routes = append(routes, leagueRoutes...) routes = append(routes, fixturesRoutes...) routes = append(routes, teamRoutes...) + routes = append(routes, playerRoutes...) // Register the routes with the server err := s.AddRoutes(routes...) diff --git a/internal/view/playersview/player_page.templ b/internal/view/playersview/player_page.templ new file mode 100644 index 0000000..c302f9b --- /dev/null +++ b/internal/view/playersview/player_page.templ @@ -0,0 +1,38 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "fmt" + +templ PlayerPage(player *db.Player, isOwner bool) { + @baseview.Layout(player.DisplayName() + " - Player Profile") { +
+
+ +
+
+
+

{ player.DisplayName() }

+
+ if player.SlapID != nil { + + Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) } + + } + if isOwner { + + Your Profile + + } +
+
+
+
+ +
+ @SlapIDSection(player, isOwner) +
+
+
+ } +} diff --git a/internal/view/playersview/slap_id_section.templ b/internal/view/playersview/slap_id_section.templ new file mode 100644 index 0000000..2e13f0a --- /dev/null +++ b/internal/view/playersview/slap_id_section.templ @@ -0,0 +1,77 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ SlapIDSection(player *db.Player, isOwner bool) { +
+ if player.SlapID == nil && isOwner { + @slapIDLinkPrompt(player) + } else if player.SlapID != nil { + @slapIDLinked(player) + } +
+} + +templ slapIDLinkPrompt(player *db.Player) { +
+
+ + + +
+

Slapshot ID Not Linked

+

+ Your Slapshot ID is not linked. Please link your Steam account to your Discord account, then click the button below to connect your Slapshot ID. +

+

+ Need help linking Steam to Discord? + + Follow this guide + +

+ +
+
+
+} + +templ slapIDLinked(player *db.Player) { +
+
+ + + + + Slapshot ID linked: + + if player.SlapID != nil { + { fmt.Sprintf("%d", *player.SlapID) } + } + + +
+
+}