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:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
48
main.go
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user