Show user's active games on home page after login
This commit is contained in:
@@ -209,6 +209,60 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getUserActiveGames = `-- name: GetUserActiveGames :many
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.status,
|
||||||
|
g.current_turn,
|
||||||
|
g.updated_at,
|
||||||
|
gp_user.color as my_color,
|
||||||
|
gp_opponent.nickname as opponent_nickname
|
||||||
|
FROM games g
|
||||||
|
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
||||||
|
LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot
|
||||||
|
WHERE g.status < 2
|
||||||
|
ORDER BY g.updated_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserActiveGamesRow struct {
|
||||||
|
ID string
|
||||||
|
Status int64
|
||||||
|
CurrentTurn int64
|
||||||
|
UpdatedAt sql.NullTime
|
||||||
|
MyColor int64
|
||||||
|
OpponentNickname sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveGamesRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getUserActiveGames, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetUserActiveGamesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetUserActiveGamesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Status,
|
||||||
|
&i.CurrentTurn,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.MyColor,
|
||||||
|
&i.OpponentNickname,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const updateGame = `-- name: UpdateGame :exec
|
const updateGame = `-- name: UpdateGame :exec
|
||||||
UPDATE games
|
UPDATE games
|
||||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
|
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -29,3 +29,17 @@ SELECT g.* FROM games g
|
|||||||
JOIN game_players gp ON g.id = gp.game_id
|
JOIN game_players gp ON g.id = gp.game_id
|
||||||
WHERE gp.user_id = ?
|
WHERE gp.user_id = ?
|
||||||
ORDER BY g.updated_at DESC;
|
ORDER BY g.updated_at DESC;
|
||||||
|
|
||||||
|
-- name: GetUserActiveGames :many
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.status,
|
||||||
|
g.current_turn,
|
||||||
|
g.updated_at,
|
||||||
|
gp_user.color as my_color,
|
||||||
|
gp_opponent.nickname as opponent_nickname
|
||||||
|
FROM games g
|
||||||
|
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
||||||
|
LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot
|
||||||
|
WHERE g.status < 2
|
||||||
|
ORDER BY g.updated_at DESC;
|
||||||
|
|||||||
79
main.go
79
main.go
@@ -43,6 +43,24 @@ func main() {
|
|||||||
username := c.Session().GetString("username")
|
username := c.Session().GetString("username")
|
||||||
isLoggedIn := userID != ""
|
isLoggedIn := userID != ""
|
||||||
|
|
||||||
|
var userGames []ui.GameListItem
|
||||||
|
if isLoggedIn {
|
||||||
|
ctx := context.Background()
|
||||||
|
games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true})
|
||||||
|
if err == nil {
|
||||||
|
for _, g := range games {
|
||||||
|
isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor
|
||||||
|
userGames = append(userGames, ui.GameListItem{
|
||||||
|
ID: g.ID,
|
||||||
|
Status: int(g.Status),
|
||||||
|
OpponentName: g.OpponentNickname.String,
|
||||||
|
IsMyTurn: isMyTurn,
|
||||||
|
LastPlayed: g.UpdatedAt.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nickname := c.Signal("")
|
nickname := c.Signal("")
|
||||||
if isLoggedIn {
|
if isLoggedIn {
|
||||||
nickname = c.Signal(username)
|
nickname = c.Signal(username)
|
||||||
@@ -72,6 +90,7 @@ func main() {
|
|||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
username,
|
username,
|
||||||
logout.OnClick(),
|
logout.OnClick(),
|
||||||
|
userGames,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -489,4 +508,64 @@ const gameCSS = `
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-list {
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-entry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--pico-muted-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-entry:hover {
|
||||||
|
background: var(--pico-secondary-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-entry-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opponent-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-status {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-status.your-turn {
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-status.waiting {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-ago {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
101
ui/gamelist.go
Normal file
101
ui/gamelist.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameListItem struct {
|
||||||
|
ID string
|
||||||
|
Status int
|
||||||
|
OpponentName string
|
||||||
|
IsMyTurn bool
|
||||||
|
LastPlayed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameList(games []GameListItem) h.H {
|
||||||
|
if len(games) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []h.H
|
||||||
|
for _, g := range games {
|
||||||
|
items = append(items, gameListEntry(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
listItems := []h.H{h.Class("game-list-items")}
|
||||||
|
listItems = append(listItems, items...)
|
||||||
|
|
||||||
|
return h.Div(h.Class("game-list"),
|
||||||
|
h.H3(h.Text("Your Games")),
|
||||||
|
h.Div(listItems...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameListEntry(g GameListItem) h.H {
|
||||||
|
statusText, statusClass := getStatusDisplay(g)
|
||||||
|
|
||||||
|
return h.A(
|
||||||
|
h.Href("/game/"+g.ID),
|
||||||
|
h.Class("game-entry"),
|
||||||
|
h.Div(h.Class("game-entry-main"),
|
||||||
|
h.Span(h.Class("opponent-name"), h.Text(getOpponentDisplay(g))),
|
||||||
|
h.Span(h.Class("game-status "+statusClass), h.Text(statusText)),
|
||||||
|
),
|
||||||
|
h.Div(h.Class("game-entry-meta"),
|
||||||
|
h.Span(h.Class("time-ago"), h.Text(formatTimeAgo(g.LastPlayed))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStatusDisplay(g GameListItem) (string, string) {
|
||||||
|
switch g.Status {
|
||||||
|
case 0: // Waiting
|
||||||
|
return "Waiting for opponent", "waiting"
|
||||||
|
case 1: // In progress
|
||||||
|
if g.IsMyTurn {
|
||||||
|
return "Your turn!", "your-turn"
|
||||||
|
}
|
||||||
|
return "Opponent's turn", "opponent-turn"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOpponentDisplay(g GameListItem) string {
|
||||||
|
if g.OpponentName == "" {
|
||||||
|
return "Waiting for opponent..."
|
||||||
|
}
|
||||||
|
return "vs " + g.OpponentName
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTimeAgo(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
duration := time.Since(t)
|
||||||
|
|
||||||
|
if duration < time.Minute {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
if duration < time.Hour {
|
||||||
|
mins := int(duration.Minutes())
|
||||||
|
if mins == 1 {
|
||||||
|
return "1 minute ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes ago", mins)
|
||||||
|
}
|
||||||
|
if duration < 24*time.Hour {
|
||||||
|
hours := int(duration.Hours())
|
||||||
|
if hours == 1 {
|
||||||
|
return "1 hour ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours ago", hours)
|
||||||
|
}
|
||||||
|
days := int(duration.Hours() / 24)
|
||||||
|
if days == 1 {
|
||||||
|
return "yesterday"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days ago", days)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H) h.H {
|
func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H, userGames []GameListItem) h.H {
|
||||||
var authSection h.H
|
var authSection h.H
|
||||||
if isLoggedIn {
|
if isLoggedIn {
|
||||||
authSection = AuthHeader(username, logoutClick)
|
authSection = AuthHeader(username, logoutClick)
|
||||||
@@ -35,6 +35,7 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
|
|||||||
createGameClick,
|
createGameClick,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GameList(userGames),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user