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:
Jonas Hahn
2025-08-24 12:08:14 +02:00
parent c9a3196ccb
commit 4b4377a24e
19 changed files with 376 additions and 278 deletions

352
src/routes.go Normal file
View 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(&lt).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(&lt).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")
}