Add game deletion with authorization check

- Add Delete method to GameStore and Persister interface
- Add delete button to game list on home page
- Verify user owns game before allowing deletion
- Use status constants instead of magic numbers
- Remove unused variable in persister
This commit is contained in:
Ryan Hamamura
2026-01-14 17:44:09 -10:00
parent d96f7dcc29
commit 5f452914f8
5 changed files with 91 additions and 22 deletions

View File

@@ -19,7 +19,7 @@ func NewGamePersister(q *gen.Queries) *GamePersister {
func (p *GamePersister) SaveGame(g *game.Game) error { func (p *GamePersister) SaveGame(g *game.Game) error {
ctx := context.Background() ctx := context.Background()
existing, err := p.queries.GetGame(ctx, g.ID) _, err := p.queries.GetGame(ctx, g.ID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
_, err = p.queries.CreateGame(ctx, gen.CreateGameParams{ _, err = p.queries.CreateGame(ctx, gen.CreateGameParams{
ID: g.ID, ID: g.ID,
@@ -43,7 +43,6 @@ func (p *GamePersister) SaveGame(g *game.Game) error {
winningCells = sql.NullString{String: wc, Valid: true} winningCells = sql.NullString{String: wc, Valid: true}
} }
_ = existing
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{ return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
Board: g.BoardToJSON(), Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn), CurrentTurn: int64(g.CurrentTurn),
@@ -124,3 +123,8 @@ func (p *GamePersister) LoadGamePlayers(gameID string) ([]*game.Player, error) {
return players, nil return players, nil
} }
func (p *GamePersister) DeleteGame(id string) error {
ctx := context.Background()
return p.queries.DeleteGame(ctx, id)
}

View File

@@ -21,6 +21,7 @@ type Persister interface {
LoadGame(id string) (*Game, error) LoadGame(id string) (*Game, error)
SaveGamePlayer(gameID string, player *Player, slot int) error SaveGamePlayer(gameID string, player *Player, slot int) error
LoadGamePlayers(gameID string) ([]*Player, error) LoadGamePlayers(gameID string) ([]*Player, error)
DeleteGame(id string) error
} }
type GameStore struct { type GameStore struct {
@@ -99,6 +100,21 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
return gi, true return gi, true
} }
func (gs *GameStore) Delete(id string) error {
gs.gamesMu.Lock()
gi, ok := gs.games[id]
if ok {
delete(gs.games, id)
close(gi.done)
}
gs.gamesMu.Unlock()
if gs.persister != nil {
return gs.persister.DeleteGame(id)
}
return nil
}
func GenerateID(size int) string { func GenerateID(size int) string {
b := make([]byte, size) b := make([]byte, size)
rand.Read(b) rand.Read(b)

48
main.go
View File

@@ -82,6 +82,18 @@ func main() {
c.Redirectf("/game/%s", gi.ID()) c.Redirectf("/game/%s", gi.ID())
}) })
deleteGame := func(id string) h.H {
return c.Action(func() {
for _, g := range userGames {
if g.ID == id {
store.Delete(id)
break
}
}
c.Redirect("/")
}).OnClick()
}
c.View(func() h.H { c.View(func() h.H {
return ui.LobbyView( return ui.LobbyView(
nickname.Bind(), nickname.Bind(),
@@ -91,6 +103,7 @@ func main() {
username, username,
logout.OnClick(), logout.OnClick(),
userGames, userGames,
deleteGame,
) )
}) })
}) })
@@ -527,13 +540,11 @@ const gameCSS = `
.game-entry { .game-entry {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 0.75rem 1rem; gap: 0.5rem;
padding: 0.5rem;
background: var(--pico-muted-background); background: var(--pico-muted-background);
border-radius: 8px; border-radius: 8px;
text-decoration: none;
color: inherit;
transition: background 0.2s; transition: background 0.2s;
} }
@@ -541,12 +552,41 @@ const gameCSS = `
background: var(--pico-secondary-background); background: var(--pico-secondary-background);
} }
.game-entry-link {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
text-decoration: none;
color: inherit;
}
.game-entry-main { .game-entry-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.game-delete-btn {
width: 2rem;
height: 2rem;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: var(--pico-muted-color);
font-size: 1.25rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.game-delete-btn:hover {
background: #dc2626;
color: white;
}
.opponent-name { .opponent-name {
font-weight: bold; font-weight: bold;
} }

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -15,14 +16,14 @@ type GameListItem struct {
LastPlayed time.Time LastPlayed time.Time
} }
func GameList(games []GameListItem) h.H { func GameList(games []GameListItem, deleteClick func(id string) h.H) h.H {
if len(games) == 0 { if len(games) == 0 {
return nil return nil
} }
var items []h.H var items []h.H
for _, g := range games { for _, g := range games {
items = append(items, gameListEntry(g)) items = append(items, gameListEntry(g, deleteClick))
} }
listItems := []h.H{h.Class("game-list-items")} listItems := []h.H{h.Class("game-list-items")}
@@ -34,27 +35,35 @@ func GameList(games []GameListItem) h.H {
) )
} }
func gameListEntry(g GameListItem) h.H { func gameListEntry(g GameListItem, deleteClick func(id string) h.H) h.H {
statusText, statusClass := getStatusDisplay(g) statusText, statusClass := getStatusDisplay(g)
return h.A( return h.Div(h.Class("game-entry"),
h.Href("/game/"+g.ID), h.A(
h.Class("game-entry"), h.Href("/game/"+g.ID),
h.Div(h.Class("game-entry-main"), h.Class("game-entry-link"),
h.Span(h.Class("opponent-name"), h.Text(getOpponentDisplay(g))), h.Div(h.Class("game-entry-main"),
h.Span(h.Class("game-status "+statusClass), h.Text(statusText)), 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))),
),
), ),
h.Div(h.Class("game-entry-meta"), h.Button(
h.Span(h.Class("time-ago"), h.Text(formatTimeAgo(g.LastPlayed))), h.Type("button"),
h.Class("game-delete-btn"),
h.Text("\u00d7"),
deleteClick(g.ID),
), ),
) )
} }
func getStatusDisplay(g GameListItem) (string, string) { func getStatusDisplay(g GameListItem) (string, string) {
switch g.Status { switch game.GameStatus(g.Status) {
case 0: // Waiting case game.StatusWaitingForPlayer:
return "Waiting for opponent", "waiting" return "Waiting for opponent", "waiting"
case 1: // In progress case game.StatusInProgress:
if g.IsMyTurn { if g.IsMyTurn {
return "Your turn!", "your-turn" return "Your turn!", "your-turn"
} }

View File

@@ -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, userGames []GameListItem) h.H { func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H, userGames []GameListItem, deleteGameClick func(id string) h.H) h.H {
var authSection h.H var authSection h.H
if isLoggedIn { if isLoggedIn {
authSection = AuthHeader(username, logoutClick) authSection = AuthHeader(username, logoutClick)
@@ -35,7 +35,7 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
createGameClick, createGameClick,
), ),
), ),
GameList(userGames), GameList(userGames, deleteGameClick),
), ),
) )
} }