Refactor project structure and update configurations. Now first working version
- Updated `.air.conf` for Nix compatibility and simplified build commands. - Enhanced `.gitignore` to include `tmp` directory. - Improved `README.md` with clearer instructions and added language details. - Refined CSS styles for better UI consistency and added alert styles. - Upgraded `flake.nix` to use Go 1.24 and improved shell environment setup. - Modified authentication logic in `auth.go` for better user handling. - Updated `main.go` to dynamically set the listening port and improved logging. - Added new `routes.go` file for handling game entry and history. - Enhanced user models and added statistics tracking in `models.go`. - Improved template rendering and added user feedback messages in HTML templates. - Removed obsolete build error logs and binaries.
This commit is contained in:
352
src/routes.go
Normal file
352
src/routes.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func getEnter(c *gin.Context) {
|
||||
// Simple render for now
|
||||
tm.Render(c, "enter", gin.H{})
|
||||
}
|
||||
|
||||
func postEnter(c *gin.Context) {
|
||||
current := getSessionUser(c)
|
||||
if current == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form
|
||||
a1h := strings.TrimSpace(c.PostForm("a1"))
|
||||
a2h := strings.TrimSpace(c.PostForm("a2"))
|
||||
b1h := strings.TrimSpace(c.PostForm("b1"))
|
||||
b2h := strings.TrimSpace(c.PostForm("b2"))
|
||||
scoreA := atoiSafe(c.PostForm("scoreA"))
|
||||
scoreB := atoiSafe(c.PostForm("scoreB"))
|
||||
|
||||
// Require a winner
|
||||
if scoreA == scoreB {
|
||||
tm.Render(c, "enter", gin.H{"Error": "Score A score must be different from score B",
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve players
|
||||
resolveUser := func(handle string) (*User, error) {
|
||||
if handle == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := findUserByHandle(handle)
|
||||
if err != nil {
|
||||
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q not found", handle),
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
var a1, a2, b1, b2 *User
|
||||
|
||||
// Always ensure A1 exists, fallback to current if empty
|
||||
var err error
|
||||
if a1, err = resolveUser(a1h); err != nil {
|
||||
return
|
||||
}
|
||||
if a2, err = resolveUser(a2h); err != nil {
|
||||
return
|
||||
}
|
||||
if b1, err = resolveUser(b1h); err != nil {
|
||||
return
|
||||
}
|
||||
if b2, err = resolveUser(b2h); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for at least one player on each side
|
||||
if b1 == nil && b2 == nil || a1 == nil && a2 == nil {
|
||||
tm.Render(c, "enter", gin.H{"Error": "At least one player required on each side",
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate users
|
||||
seen := map[uint]bool{}
|
||||
users := []*User{a1, a2, b1, b2}
|
||||
for i, u := range users {
|
||||
if u != nil {
|
||||
if seen[u.ID] {
|
||||
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q specified multiple times", users[i].Username),
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return
|
||||
}
|
||||
seen[u.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Look up table if provided
|
||||
var tableID *uint
|
||||
if slug := c.Param("tslug"); slug != "" {
|
||||
var t Table
|
||||
if err := db.Where("slug = ?", slug).First(&t).Error; err == nil {
|
||||
tableID = &t.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Create game
|
||||
g := Game{
|
||||
ScoreA: scoreA,
|
||||
ScoreB: scoreB,
|
||||
TableID: tableID,
|
||||
WinnerIsA: scoreA > scoreB,
|
||||
}
|
||||
if err := db.Create(&g).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "game create error")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect players by side
|
||||
players := []struct {
|
||||
u *User
|
||||
side string
|
||||
}{
|
||||
{a1, "A"},
|
||||
{a2, "A"},
|
||||
{b1, "B"},
|
||||
{b2, "B"},
|
||||
}
|
||||
|
||||
for _, p := range players {
|
||||
if p.u != nil {
|
||||
if err := db.Create(&GamePlayer{GameID: g.ID, UserID: p.u.ID, Side: p.side}).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "failed to assign player")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/history")
|
||||
}
|
||||
|
||||
func getIndex(c *gin.Context) {
|
||||
if u := getSessionUser(c); u != nil {
|
||||
c.Redirect(302, "/enter")
|
||||
return
|
||||
}
|
||||
getLogin(c)
|
||||
}
|
||||
|
||||
func getHistory(c *gin.Context) {
|
||||
// Load recent games with players
|
||||
var games []Game
|
||||
db.Order("created_at desc").Limit(100).Preload("Table").Find(&games)
|
||||
|
||||
type GRow struct {
|
||||
Game
|
||||
PlayersA []User
|
||||
PlayersB []User
|
||||
}
|
||||
var rows []GRow
|
||||
for _, g := range games {
|
||||
var gps []GamePlayer
|
||||
db.Preload("User").Where("game_id = ?", g.ID).Find(&gps)
|
||||
var a, b []User
|
||||
for _, gp := range gps {
|
||||
if gp.Side == "A" {
|
||||
a = append(a, gp.User)
|
||||
} else {
|
||||
b = append(b, gp.User)
|
||||
}
|
||||
}
|
||||
rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b})
|
||||
}
|
||||
tm.Render(c, "history", gin.H{"Games": rows})
|
||||
}
|
||||
|
||||
func getLeaderboard(c *gin.Context) {
|
||||
// Simple metric: wins = games where player's side has higher score
|
||||
type Row struct {
|
||||
Username, Slug string
|
||||
Wins, Games int
|
||||
}
|
||||
|
||||
// Load last 1000 games for performance (simple MVP)
|
||||
var games []Game
|
||||
db.Order("created_at desc").Limit(1000).Find(&games)
|
||||
|
||||
// Build map of gameID->winnerSide
|
||||
winner := map[uint]string{}
|
||||
for _, g := range games {
|
||||
side := ""
|
||||
if g.ScoreA > g.ScoreB {
|
||||
side = "A"
|
||||
} else if g.ScoreB > g.ScoreA {
|
||||
side = "B"
|
||||
}
|
||||
winner[g.ID] = side
|
||||
}
|
||||
|
||||
// Count per user
|
||||
type Cnt struct{ Wins, Games int }
|
||||
counts := map[uint]*Cnt{}
|
||||
users := map[uint]User{}
|
||||
|
||||
for _, g := range games {
|
||||
var gps []GamePlayer
|
||||
db.Preload("User").Where("game_id = ?", g.ID).Find(&gps)
|
||||
for _, gp := range gps {
|
||||
if counts[gp.UserID] == nil {
|
||||
counts[gp.UserID] = &Cnt{}
|
||||
users[gp.UserID] = gp.User
|
||||
}
|
||||
counts[gp.UserID].Games++
|
||||
if winner[g.ID] != "" && winner[g.ID] == gp.Side {
|
||||
counts[gp.UserID].Wins++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows := []Row{}
|
||||
for uid, cnt := range counts {
|
||||
u := users[uid]
|
||||
rows = append(rows, Row{Username: u.Username, Slug: u.Slug, Wins: cnt.Wins, Games: cnt.Games})
|
||||
}
|
||||
|
||||
// Sort by wins desc, then games desc, then username
|
||||
// Simple insertion sort to avoid extra imports
|
||||
for i := 1; i < len(rows); i++ {
|
||||
j := i
|
||||
for j > 0 && (rows[j-1].Wins < rows[j].Wins || (rows[j-1].Wins == rows[j].Wins && (rows[j-1].Games < rows[j].Games || (rows[j-1].Games == rows[j].Games && rows[j-1].Username > rows[j].Username)))) {
|
||||
rows[j-1], rows[j] = rows[j], rows[j-1]
|
||||
j--
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) > 100 {
|
||||
rows = rows[:100]
|
||||
}
|
||||
|
||||
tm.Render(c, "leaderboard", gin.H{"Rows": rows})
|
||||
}
|
||||
|
||||
func getUserView(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
u, err := userBySlug(slug)
|
||||
if err != nil {
|
||||
c.String(404, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
own := false
|
||||
if cu := getSessionUser(c); cu != nil && cu.ID == u.ID {
|
||||
own = true
|
||||
}
|
||||
|
||||
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": own})
|
||||
}
|
||||
|
||||
func getMe(c *gin.Context) {
|
||||
u := getSessionUser(c)
|
||||
if u == nil {
|
||||
c.Redirect(302, "/login")
|
||||
return
|
||||
}
|
||||
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": true})
|
||||
}
|
||||
|
||||
func postMe(c *gin.Context) {
|
||||
cu := getSessionUser(c)
|
||||
if cu == nil {
|
||||
c.Redirect(302, "/login")
|
||||
return
|
||||
}
|
||||
newU := strings.TrimSpace(c.PostForm("username"))
|
||||
if newU == "" || newU == cu.Username {
|
||||
c.Redirect(302, "/me")
|
||||
return
|
||||
}
|
||||
|
||||
// Update username and slug
|
||||
cu.Username = newU
|
||||
cu.Slug = slugify(newU)
|
||||
if err := ensureUniqueUsernameAndSlug(cu); err != nil {
|
||||
log.Println("unique username error:", err)
|
||||
}
|
||||
if err := db.Save(cu).Error; err != nil {
|
||||
log.Println("save user:", err)
|
||||
}
|
||||
c.Redirect(302, "/me")
|
||||
}
|
||||
|
||||
func getLogin(c *gin.Context) {
|
||||
tm.Render(c, "login", gin.H{})
|
||||
}
|
||||
|
||||
func postLogin(c *gin.Context) {
|
||||
email := strings.ToLower(strings.TrimSpace(c.PostForm("email")))
|
||||
if email == "" {
|
||||
c.Redirect(302, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// ensure user exists
|
||||
var u User
|
||||
tx := db.Where("email = ?", email).First(&u)
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
u = User{Email: email, Username: defaultUsername()}
|
||||
if err := ensureUniqueUsernameAndSlug(&u); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
log.Println("create user:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// create token valid 30 days
|
||||
token := mustRandToken(24)
|
||||
lt := LoginToken{Token: token, Email: email, ExpiresAt: now().Add(30 * 24 * time.Hour)}
|
||||
if err := db.Create(<).Error; err != nil {
|
||||
log.Println("create token:", err)
|
||||
}
|
||||
|
||||
link := strings.TrimRight(baseURL, "/") + "/magic?token=" + token
|
||||
log.Printf("[MAGIC LINK] %s for %s (valid until %s)\n", link, email, lt.ExpiresAt.Format(time.RFC3339))
|
||||
|
||||
tm.Render(c, "sent", gin.H{"Email": email})
|
||||
}
|
||||
|
||||
func getMagic(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.Redirect(302, "/login")
|
||||
return
|
||||
}
|
||||
var lt LoginToken
|
||||
if err := db.Where("token = ? AND expires_at > ?", token, now()).First(<).Error; err != nil {
|
||||
c.String(400, "Invalid or expired token")
|
||||
return
|
||||
}
|
||||
// find or create user by email again (in case)
|
||||
u, err := userByEmail(lt.Email)
|
||||
if err != nil {
|
||||
c.String(500, "user lookup error")
|
||||
return
|
||||
}
|
||||
|
||||
// create session
|
||||
sessTok := mustRandToken(24)
|
||||
if err := db.Create(&Session{Token: sessTok, UserID: u.ID}).Error; err != nil {
|
||||
c.String(500, "session error")
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, sessTok)
|
||||
|
||||
c.Redirect(302, "/enter")
|
||||
}
|
||||
Reference in New Issue
Block a user