Add user authentication and game persistence with SQLite
- User registration/login with bcrypt password hashing - SQLite database with goose migrations and sqlc-generated queries - Games and players persisted to database, resumable after restart - Guest play still supported alongside authenticated users - Auth UI components (login/register forms, auth header, guest banner)
This commit is contained in:
35
auth/auth.go
Normal file
35
auth/auth.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func CheckPassword(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func ValidateUsername(username string) error {
|
||||
if !usernameRegex.MatchString(username) {
|
||||
return errors.New("username must be 3-20 characters, alphanumeric and underscore only")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return errors.New("password must be at least 8 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
33
db/db.go
Normal file
33
db/db.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Init(dbPath string) error {
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
DB.SetMaxOpenConns(1)
|
||||
|
||||
goose.SetBaseFS(migrations)
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := goose.Up(DB, "migrations"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
31
db/gen/db.go
Normal file
31
db/gen/db.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
237
db/gen/games.sql.go
Normal file
237
db/gen/games.sql.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: games.sql
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createGame = `-- name: CreateGame :one
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateGameParams struct {
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, createGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.CurrentTurn,
|
||||
arg.Status,
|
||||
)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createGamePlayer = `-- name: CreateGamePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateGamePlayerParams struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createGamePlayer,
|
||||
arg.GameID,
|
||||
arg.UserID,
|
||||
arg.GuestPlayerID,
|
||||
arg.Nickname,
|
||||
arg.Color,
|
||||
arg.Slot,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteGame = `-- name: DeleteGame :exec
|
||||
DELETE FROM games WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteGame(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteGame, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getActiveGames = `-- name: GetActiveGames :many
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE status < 2
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActiveGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); 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 getGame = `-- name: GetGame :one
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGame, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGamePlayers = `-- name: GetGamePlayers :many
|
||||
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GamePlayer
|
||||
for rows.Next() {
|
||||
var i GamePlayer
|
||||
if err := rows.Scan(
|
||||
&i.GameID,
|
||||
&i.UserID,
|
||||
&i.GuestPlayerID,
|
||||
&i.Nickname,
|
||||
&i.Color,
|
||||
&i.Slot,
|
||||
&i.CreatedAt,
|
||||
); 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 getGamesByUserID = `-- name: GetGamesByUserID :many
|
||||
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at FROM games g
|
||||
JOIN game_players gp ON g.id = gp.game_id
|
||||
WHERE gp.user_id = ?
|
||||
ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ([]Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); 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
|
||||
UPDATE games
|
||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateGameParams struct {
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateGame,
|
||||
arg.Board,
|
||||
arg.CurrentTurn,
|
||||
arg.Status,
|
||||
arg.WinnerUserID,
|
||||
arg.WinningCells,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
37
db/gen/models.go
Normal file
37
db/gen/models.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
66
db/gen/users.sql.go
Normal file
66
db/gen/users.sql.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: users.sql
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id, username, password_hash, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, password_hash, created_at FROM users WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, created_at FROM users WHERE username = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
40
db/migrations/001_initial_schema.sql
Normal file
40
db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
|
||||
CREATE TABLE games (
|
||||
id TEXT PRIMARY KEY,
|
||||
board TEXT NOT NULL,
|
||||
current_turn INTEGER NOT NULL DEFAULT 1,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
winner_user_id TEXT,
|
||||
winning_cells TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE game_players (
|
||||
game_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_player_id TEXT,
|
||||
nickname TEXT NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
slot INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (game_id, slot),
|
||||
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
CHECK (user_id IS NOT NULL OR guest_player_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX idx_game_players_user ON game_players(user_id);
|
||||
CREATE INDEX idx_game_players_guest ON game_players(guest_player_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS game_players;
|
||||
DROP TABLE IF EXISTS games;
|
||||
DROP TABLE IF EXISTS users;
|
||||
126
db/persister.go
Normal file
126
db/persister.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
)
|
||||
|
||||
type GamePersister struct {
|
||||
queries *gen.Queries
|
||||
}
|
||||
|
||||
func NewGamePersister(q *gen.Queries) *GamePersister {
|
||||
return &GamePersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *GamePersister) SaveGame(g *game.Game) error {
|
||||
ctx := context.Background()
|
||||
|
||||
existing, err := p.queries.GetGame(ctx, g.ID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = p.queries.CreateGame(ctx, gen.CreateGameParams{
|
||||
ID: g.ID,
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
})
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var winnerUserID sql.NullString
|
||||
if g.Winner != nil && g.Winner.UserID != nil {
|
||||
winnerUserID = sql.NullString{String: *g.Winner.UserID, Valid: true}
|
||||
}
|
||||
|
||||
winningCells := sql.NullString{}
|
||||
if wc := g.WinningCellsToJSON(); wc != "" {
|
||||
winningCells = sql.NullString{String: wc, Valid: true}
|
||||
}
|
||||
|
||||
_ = existing
|
||||
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
WinningCells: winningCells,
|
||||
ID: g.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
|
||||
ctx := context.Background()
|
||||
row, err := p.queries.GetGame(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g := &game.Game{
|
||||
ID: row.ID,
|
||||
CurrentTurn: int(row.CurrentTurn),
|
||||
Status: game.GameStatus(row.Status),
|
||||
}
|
||||
|
||||
if err := g.BoardFromJSON(row.Board); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if row.WinningCells.Valid {
|
||||
g.WinningCellsFromJSON(row.WinningCells.String)
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (p *GamePersister) SaveGamePlayer(gameID string, player *game.Player, slot int) error {
|
||||
ctx := context.Background()
|
||||
|
||||
var userID, guestPlayerID sql.NullString
|
||||
if player.UserID != nil {
|
||||
userID = sql.NullString{String: *player.UserID, Valid: true}
|
||||
} else {
|
||||
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.CreateGamePlayer(ctx, gen.CreateGamePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Color),
|
||||
Slot: int64(slot),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *GamePersister) LoadGamePlayers(gameID string) ([]*game.Player, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.queries.GetGamePlayers(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
players := make([]*game.Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
player := &game.Player{
|
||||
Nickname: row.Nickname,
|
||||
Color: int(row.Color),
|
||||
}
|
||||
|
||||
if row.UserID.Valid {
|
||||
player.UserID = &row.UserID.String
|
||||
player.ID = game.PlayerID(row.UserID.String)
|
||||
} else if row.GuestPlayerID.Valid {
|
||||
player.ID = game.PlayerID(row.GuestPlayerID.String)
|
||||
}
|
||||
|
||||
players = append(players, player)
|
||||
}
|
||||
|
||||
return players, nil
|
||||
}
|
||||
31
db/queries/games.sql
Normal file
31
db/queries/games.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- name: CreateGame :one
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetGame :one
|
||||
SELECT * FROM games WHERE id = ?;
|
||||
|
||||
-- name: UpdateGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteGame :exec
|
||||
DELETE FROM games WHERE id = ?;
|
||||
|
||||
-- name: GetActiveGames :many
|
||||
SELECT * FROM games WHERE status < 2;
|
||||
|
||||
-- name: CreateGamePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetGamePlayers :many
|
||||
SELECT * FROM game_players WHERE game_id = ?;
|
||||
|
||||
-- name: GetGamesByUserID :many
|
||||
SELECT g.* FROM games g
|
||||
JOIN game_players gp ON g.id = gp.game_id
|
||||
WHERE gp.user_id = ?
|
||||
ORDER BY g.updated_at DESC;
|
||||
10
db/queries/users.sql
Normal file
10
db/queries/users.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT * FROM users WHERE id = ?;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM users WHERE username = ?;
|
||||
9
db/sqlc.yaml
Normal file
9
db/sqlc.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "sqlite"
|
||||
queries: "queries"
|
||||
schema: "migrations"
|
||||
gen:
|
||||
go:
|
||||
package: "gen"
|
||||
out: "gen"
|
||||
@@ -16,9 +16,17 @@ type PlayerSession struct {
|
||||
Sync Syncable
|
||||
}
|
||||
|
||||
type Persister interface {
|
||||
SaveGame(g *Game) error
|
||||
LoadGame(id string) (*Game, error)
|
||||
SaveGamePlayer(gameID string, player *Player, slot int) error
|
||||
LoadGamePlayers(gameID string) ([]*Player, error)
|
||||
}
|
||||
|
||||
type GameStore struct {
|
||||
games map[string]*GameInstance
|
||||
gamesMu sync.RWMutex
|
||||
persister Persister
|
||||
}
|
||||
|
||||
func NewGameStore() *GameStore {
|
||||
@@ -27,21 +35,68 @@ func NewGameStore() *GameStore {
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetPersister(p Persister) {
|
||||
gs.persister = p
|
||||
}
|
||||
|
||||
func (gs *GameStore) Create() *GameInstance {
|
||||
id := GenerateID(4)
|
||||
gi := NewGameInstance(id)
|
||||
gi.persister = gs.persister
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
if gs.persister != nil {
|
||||
gs.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
go gi.run()
|
||||
return gi
|
||||
}
|
||||
|
||||
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
||||
gs.gamesMu.RLock()
|
||||
defer gs.gamesMu.RUnlock()
|
||||
gi, ok := gs.games[id]
|
||||
return gi, ok
|
||||
gs.gamesMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gi, true
|
||||
}
|
||||
|
||||
// Try to load from database
|
||||
if gs.persister == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
game, err := gs.persister.LoadGame(id)
|
||||
if err != nil || game == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := gs.persister.LoadGamePlayers(id)
|
||||
for _, p := range players {
|
||||
if p.Color == 1 {
|
||||
game.Players[0] = p
|
||||
} else if p.Color == 2 {
|
||||
game.Players[1] = p
|
||||
}
|
||||
}
|
||||
|
||||
gi = &GameInstance{
|
||||
game: game,
|
||||
players: make(map[PlayerID]Syncable),
|
||||
leave: make(chan PlayerID, 5),
|
||||
done: make(chan struct{}),
|
||||
persister: gs.persister,
|
||||
}
|
||||
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
go gi.run()
|
||||
return gi, true
|
||||
}
|
||||
|
||||
func GenerateID(size int) string {
|
||||
@@ -58,6 +113,7 @@ type GameInstance struct {
|
||||
leave chan PlayerID
|
||||
done chan struct{}
|
||||
dirty bool
|
||||
persister Persister
|
||||
}
|
||||
|
||||
func NewGameInstance(id string) *GameInstance {
|
||||
@@ -79,14 +135,17 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
var slot int
|
||||
// Assign player to an open slot
|
||||
if gi.game.Players[0] == nil {
|
||||
ps.Player.Color = 1 // Red
|
||||
gi.game.Players[0] = ps.Player
|
||||
slot = 0
|
||||
} else if gi.game.Players[1] == nil {
|
||||
ps.Player.Color = 2 // Yellow
|
||||
gi.game.Players[1] = ps.Player
|
||||
gi.game.Status = StatusInProgress
|
||||
slot = 1
|
||||
} else {
|
||||
return false // Game is full
|
||||
}
|
||||
@@ -95,6 +154,11 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||
gi.players[ps.Player.ID] = ps.Sync
|
||||
gi.playersMu.Unlock()
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot)
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.dirty = true
|
||||
return true
|
||||
}
|
||||
@@ -138,6 +202,10 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
||||
gi.game.SwitchTurn()
|
||||
}
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.dirty = true
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package game
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type PlayerID string
|
||||
|
||||
type Player struct {
|
||||
ID PlayerID
|
||||
UserID *string // UUID for authenticated users, nil for guests
|
||||
Nickname string
|
||||
Color int // 1 = Red, 2 = Yellow
|
||||
}
|
||||
@@ -35,3 +38,27 @@ func NewGame(id string) *Game {
|
||||
Status: StatusWaitingForPlayer,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) BoardToJSON() string {
|
||||
data, _ := json.Marshal(g.Board)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (g *Game) BoardFromJSON(data string) error {
|
||||
return json.Unmarshal([]byte(data), &g.Board)
|
||||
}
|
||||
|
||||
func (g *Game) WinningCellsToJSON() string {
|
||||
if g.WinningCells == nil {
|
||||
return ""
|
||||
}
|
||||
data, _ := json.Marshal(g.WinningCells)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (g *Game) WinningCellsFromJSON(data string) error {
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal([]byte(data), &g.WinningCells)
|
||||
}
|
||||
|
||||
17
go.mod
17
go.mod
@@ -8,8 +8,25 @@ require (
|
||||
github.com/CAFxX/httpcompression v0.0.9 // indirect
|
||||
github.com/alexedwards/scs/v2 v2.9.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pressly/goose/v3 v3.26.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/starfederation/datastar-go v1.0.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
maragu.dev/gomponents v1.2.0 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.44.0 // indirect
|
||||
)
|
||||
|
||||
35
go.sum
35
go.sum
@@ -8,18 +8,34 @@ github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUS
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/ryanhamamura/via v0.2.3 h1:Fmq2Gws9Ph7njZxYI3O03PhTyfFTOBv5xm+4s053c3E=
|
||||
github.com/ryanhamamura/via v0.2.3/go.mod h1:z1f0pajcta/pD2LEBMVmuBXf/J2yF0obMVKA8FshR9I=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
||||
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -37,9 +53,28 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g
|
||||
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc=
|
||||
maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
|
||||
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.44.0 h1:YjCKJnzZde2mLVy0cMKTSL4PxCmbIguOq9lGp8ZvGOc=
|
||||
modernc.org/sqlite v1.44.0/go.mod h1:2Dq41ir5/qri7QJJJKNZcP4UF7TsX/KNeykYgPDtGhE=
|
||||
|
||||
179
main.go
179
main.go
@@ -1,6 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ryanhamamura/c4/auth"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
"github.com/ryanhamamura/via"
|
||||
@@ -8,8 +16,15 @@ import (
|
||||
)
|
||||
|
||||
var store = game.NewGameStore()
|
||||
var queries *gen.Queries
|
||||
|
||||
func main() {
|
||||
if err := db.Init("c4.db"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
queries = gen.New(db.DB)
|
||||
store.SetPersister(db.NewGamePersister(queries))
|
||||
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
LogLvl: via.LogLevelDebug,
|
||||
@@ -24,7 +39,19 @@ func main() {
|
||||
|
||||
// Home page - enter nickname and create game
|
||||
v.Page("/", func(c *via.Context) {
|
||||
userID := c.Session().GetString("user_id")
|
||||
username := c.Session().GetString("username")
|
||||
isLoggedIn := userID != ""
|
||||
|
||||
nickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
nickname = c.Signal(username)
|
||||
}
|
||||
|
||||
logout := c.Action(func() {
|
||||
c.Session().Clear()
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
createGame := c.Action(func() {
|
||||
name := nickname.String()
|
||||
@@ -42,6 +69,113 @@ func main() {
|
||||
nickname.Bind(),
|
||||
createGame.OnKeyDown("Enter"),
|
||||
createGame.OnClick(),
|
||||
isLoggedIn,
|
||||
username,
|
||||
logout.OnClick(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Login page
|
||||
v.Page("/login", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
login := c.Action(func() {
|
||||
ctx := context.Background()
|
||||
user, err := queries.GetUserByUsername(ctx, username.String())
|
||||
if err == sql.ErrNoRows {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(password.String(), user.PasswordHash) {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LoginView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
login.OnKeyDown("Enter"),
|
||||
login.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Register page
|
||||
v.Page("/register", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
confirm := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
register := c.Action(func() {
|
||||
if err := auth.ValidateUsername(username.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(password.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if password.String() != confirm.String() {
|
||||
errorMsg.SetValue("Passwords do not match")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password.String())
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
id := uuid.New().String()
|
||||
user, err := queries.CreateUser(ctx, gen.CreateUserParams{
|
||||
ID: id,
|
||||
Username: username.String(),
|
||||
PasswordHash: hash,
|
||||
})
|
||||
if err != nil {
|
||||
errorMsg.SetValue("Username already taken")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.RegisterView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
confirm.Bind(),
|
||||
register.OnKeyDown("Enter"),
|
||||
register.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -50,6 +184,7 @@ func main() {
|
||||
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
colSignal := c.Signal(0)
|
||||
@@ -69,6 +204,11 @@ func main() {
|
||||
c.Session().Set("player_id", string(playerID))
|
||||
}
|
||||
|
||||
// Use user_id as player_id if logged in
|
||||
if sessionUserID != "" {
|
||||
playerID = game.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
@@ -85,6 +225,9 @@ func main() {
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
Sync: c,
|
||||
@@ -112,6 +255,9 @@ func main() {
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
Sync: c,
|
||||
@@ -310,4 +456,37 @@ const gameCSS = `
|
||||
.copy-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--pico-muted-background);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.auth-header button {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.guest-banner {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--pico-muted-background);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`
|
||||
|
||||
127
ui/auth.go
Normal file
127
ui/auth.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func LoginView(usernameBind, passwordBind, loginKeyDown, loginClick h.H, errorMsg string) h.H {
|
||||
var errorEl h.H
|
||||
if errorMsg != "" {
|
||||
errorEl = h.P(h.Class("error"), h.Text(errorMsg))
|
||||
}
|
||||
|
||||
return h.Main(h.Class("container"),
|
||||
h.Div(h.Class("lobby"),
|
||||
h.H1(h.Text("Login")),
|
||||
h.P(h.Text("Sign in to your account")),
|
||||
errorEl,
|
||||
h.Form(
|
||||
h.FieldSet(
|
||||
h.Label(h.Text("Username"), h.Attr("for", "username")),
|
||||
h.Input(
|
||||
h.ID("username"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your username"),
|
||||
usernameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
),
|
||||
h.Label(h.Text("Password"), h.Attr("for", "password")),
|
||||
h.Input(
|
||||
h.ID("password"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Enter your password"),
|
||||
passwordBind,
|
||||
h.Attr("required"),
|
||||
loginKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Type("button"),
|
||||
h.Text("Login"),
|
||||
loginClick,
|
||||
),
|
||||
),
|
||||
h.P(
|
||||
h.Text("Don't have an account? "),
|
||||
h.A(h.Href("/register"), h.Text("Register")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func RegisterView(usernameBind, passwordBind, confirmBind, registerKeyDown, registerClick h.H, errorMsg string) h.H {
|
||||
var errorEl h.H
|
||||
if errorMsg != "" {
|
||||
errorEl = h.P(h.Class("error"), h.Text(errorMsg))
|
||||
}
|
||||
|
||||
return h.Main(h.Class("container"),
|
||||
h.Div(h.Class("lobby"),
|
||||
h.H1(h.Text("Register")),
|
||||
h.P(h.Text("Create a new account")),
|
||||
errorEl,
|
||||
h.Form(
|
||||
h.FieldSet(
|
||||
h.Label(h.Text("Username"), h.Attr("for", "username")),
|
||||
h.Input(
|
||||
h.ID("username"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Choose a username"),
|
||||
usernameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
),
|
||||
h.Label(h.Text("Password"), h.Attr("for", "password")),
|
||||
h.Input(
|
||||
h.ID("password"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Choose a password (min 8 chars)"),
|
||||
passwordBind,
|
||||
h.Attr("required"),
|
||||
),
|
||||
h.Label(h.Text("Confirm Password"), h.Attr("for", "confirm")),
|
||||
h.Input(
|
||||
h.ID("confirm"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Confirm your password"),
|
||||
confirmBind,
|
||||
h.Attr("required"),
|
||||
registerKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Type("button"),
|
||||
h.Text("Register"),
|
||||
registerClick,
|
||||
),
|
||||
),
|
||||
h.P(
|
||||
h.Text("Already have an account? "),
|
||||
h.A(h.Href("/login"), h.Text("Login")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func AuthHeader(username string, logoutClick h.H) h.H {
|
||||
return h.Div(h.Class("auth-header"),
|
||||
h.Span(h.Text("Logged in as "), h.Strong(h.Text(username))),
|
||||
h.Button(
|
||||
h.Type("button"),
|
||||
h.Class("secondary outline small"),
|
||||
h.Text("Logout"),
|
||||
logoutClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func GuestBanner() h.H {
|
||||
return h.Div(h.Class("guest-banner"),
|
||||
h.Text("Playing as guest. "),
|
||||
h.A(h.Href("/login"), h.Text("Login")),
|
||||
h.Text(" or "),
|
||||
h.A(h.Href("/register"), h.Text("Register")),
|
||||
h.Text(" to save your games."),
|
||||
)
|
||||
}
|
||||
10
ui/lobby.go
10
ui/lobby.go
@@ -4,9 +4,17 @@ import (
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H) h.H {
|
||||
func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H) h.H {
|
||||
var authSection h.H
|
||||
if isLoggedIn {
|
||||
authSection = AuthHeader(username, logoutClick)
|
||||
} else {
|
||||
authSection = GuestBanner()
|
||||
}
|
||||
|
||||
return h.Main(h.Class("container"),
|
||||
h.Div(h.Class("lobby"),
|
||||
authSection,
|
||||
h.H1(h.Text("Connect 4")),
|
||||
h.P(h.Text("Challenge a friend to a game of Connect 4!")),
|
||||
h.Form(
|
||||
|
||||
Reference in New Issue
Block a user