diff --git a/.air.conf b/.air.conf index 6d298ee..76974d2 100644 --- a/.air.conf +++ b/.air.conf @@ -1,17 +1,14 @@ root = "." -tmp_dir = "tmp" [build] - cmd = "go build -o ./tmp/main ./src/main.go" - bin = "tmp/main" - full_bin = "tmp/main" - include_ext = ["go", "tpl", "tmpl", "html", "css", "js"] - exclude_dir = ["tmp", "vendor"] + cmd = "nix develop -c go build -o ./tmp/main ./src/..." # for nix users + include_ext = ["go", "html", "css"] + exclude_dir = ["tmp"] [log] + level = "warn" time = true -[colors] - main = "yellow" - watcher = "cyan" - build = "green" +[env] + GIN_MODE = "debug" # change to "release" for production + APP_PORT = 18765 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d0be2e5..d839785 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -qrank.db \ No newline at end of file +qrank.db +tmp \ No newline at end of file diff --git a/README.md b/README.md index 5d0b1ab..25d173b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ # QRank -The *easy* tracker for tabletop soccers with QR code integration. - +The **easy** tracker for tabletop soccers with QR code integration. Everything is still in development. Feel free to open issues and or pull requests. +This application is build with [go](https://go.dev/) and [gin](https://gin-gonic.com/). Other languages like HTML and CSS are used. + ## Getting started -To run the application go to the root directory and run `make run` in the terminal. On a nix system it is possible to run with all dependencies via `make nix`. The listening address is `https://localhost:18765`. +To run the application go to the root directory and run `make run` in the terminal. On a nix system it is possible to run with all dependencies via `make nix-run`. The default listening address is `http://localhost:18765`. + +For a development environment install [air](https://github.com/air-verse/air) and run the development server with `air` from the root directory. diff --git a/assets/styles.css b/assets/styles.css index da5af47..c61934b 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -42,12 +42,42 @@ body { } .input { - width: 100%; + width: 75%; padding: 10px; border-radius: 10px; border: 1px solid #ddd } +a, a:link, a:visited, a:hover, a:active { + color: darkblue; /* always dark blue */ + text-decoration: none; /* remove underline */ +} + +.alert { + padding: 10px 14px; + border-radius: 6px; + margin: 10px 0; + font-size: 0.9rem; +} + +.alert-error { + background: #ffe5e5; + border: 1px solid #e74c3c; + color: #c0392b; +} + +.alert-success { + background: #e6ffed; + border: 1px solid #2ecc71; + color: #27ae60; +} + +.alert-info { + background: #eaf4ff; + border: 1px solid #3498db; + color: #2980b9; +} + .label { font-size: 12px; color: #444; diff --git a/flake.nix b/flake.nix index 66b9d08..4cb09db 100644 --- a/flake.nix +++ b/flake.nix @@ -12,10 +12,9 @@ devShells = forAllSystems (pkgs: let # Pick the Go you want. pkgs.go is fine; change to pkgs.go_1_23 if you prefer a fixed version. - go = pkgs.go; + go = pkgs.go_1_24; in { default = pkgs.mkShell { - # Tools required for cgo and sqlite buildInputs = [ go pkgs.gcc @@ -23,25 +22,18 @@ pkgs.sqlite ]; - # Optional (handy) tools nativeBuildInputs = [ pkgs.git ]; - # Enable CGO so mattn/go-sqlite3 works CGO_ENABLED = 1; - - # If you plan static linking, uncomment: - # hardeningDisable = [ "fortify" ]; - shellHook = '' - echo "🔧 Nix dev shell ready (CGO_ENABLED=1)." - echo "▶ Run: go run ." + echo 'Inside nix shell.' ''; }; }); - # Convenience runner: `nix run` + # Used for nix run apps = forAllSystems (pkgs: { default = { type = "app"; @@ -53,7 +45,7 @@ }; }); - # Optional: formatter for this repo + # Nix formatter formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt); }; } diff --git a/src/auth.go b/src/auth.go index 9561dc3..2b78bce 100644 --- a/src/auth.go +++ b/src/auth.go @@ -33,8 +33,8 @@ func requireAuth() gin.HandlerFunc { } func setSessionCookie(c *gin.Context, token string) { - // Very long-lived cookie (~10 years) - maxAge := 10 * 365 * 24 * 60 * 60 + // Very long-lived cookie for one year + maxAge := 365 * 24 * 60 * 60 httpOnly := true secure := strings.HasPrefix(baseURL, "https://") sameSite := http.SameSiteLaxMode @@ -43,36 +43,23 @@ func setSessionCookie(c *gin.Context, token string) { c.Header("Set-Cookie", (&http.Cookie{Name: "session", Value: token, Path: "/", Domain: cookieDomain, MaxAge: maxAge, Secure: secure, HttpOnly: httpOnly, SameSite: sameSite}).String()) } -func findOrCreateUserByHandle(handle string) (*User, error) { +func findUserByHandle(handle string) (*User, error) { h := strings.TrimSpace(handle) if h == "" { return nil, sql.ErrNoRows } - if strings.Contains(h, "@") { // email + + // Return user by email or username + if strings.Contains(h, "@") { if u, err := userByEmail(strings.ToLower(h)); err == nil { return u, nil } - // create - u := &User{Email: strings.ToLower(h), Username: defaultUsername()} - if err := ensureUniqueUsernameAndSlug(u); err != nil { - return nil, err + } else { + // Find and return the user + if u, err := userByName(h); err == nil { + return u, nil } - if err := db.Create(u).Error; err != nil { - return nil, err - } - return u, nil } - // username - var u User - if err := db.Where("username = ?", h).First(&u).Error; err == nil { - return &u, nil - } - // If not found, create placeholder user with random email-like token - u = User{Email: mustRandToken(3) + "+placeholder@local", Username: h, Slug: slugify(h)} - if err := ensureUniqueUsernameAndSlug(&u); err != nil { /* best-effort */ - } - if err := db.Create(&u).Error; err != nil { - return nil, err - } - return &u, nil + + return nil, sql.ErrNoRows } diff --git a/src/main.go b/src/main.go index 34dc630..136ecb5 100644 --- a/src/main.go +++ b/src/main.go @@ -4,8 +4,9 @@ import ( "log" "os" + "strconv" + "github.com/gin-gonic/gin" - "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -16,46 +17,44 @@ var ( db *gorm.DB baseURL string cookieDomain string + + // Global logger for this package + lg = log.Default() ) const ( - PORT = "18765" + // Default port if APP_PORT is not set + defaultPort = 18765 ) -var logger = log.Default() - // ===================== Main ===================== func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) + // Get the listening port + port := os.Getenv("APP_PORT") + port = strconv.Itoa(defaultPort) + + // Set the base URL baseURL = os.Getenv("APP_BASE_URL") if baseURL == "" { - baseURL = "http://localhost:" + PORT + baseURL = "http://localhost:" + port } - cookieDomain = os.Getenv("APP_COOKIE_DOMAIN") - // DB connect + // Open connection to SQLite var err error - dsn := os.Getenv("DATABASE_URL") - if dsn != "" { - db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - log.Fatal("postgres connect:", err) - } - log.Println("using Postgres") - } else { - db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{}) - if err != nil { - log.Fatal("sqlite connect:", err) - } - log.Println("using SQLite qrank.db") + db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{}) + if err != nil { + lg.Fatal("sqlite connect:", err) } + lg.Println("using SQLite qrank.db") if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GamePlayer{}); err != nil { - log.Fatal("migrate:", err) + lg.Fatal("migrate:", err) } + // Create engine r := gin.Default() // Serve static files from the current directory @@ -82,12 +81,10 @@ func main() { r.GET("/me", requireAuth(), getMe) r.POST("/me", requireAuth(), postMe) - bind := os.Getenv("APP_BIND") - if bind == "" { - bind = ":" + PORT - } - log.Println("listening on", bind, "base:", baseURL) + // Start application with port + bind := ":" + port + lg.Println("listening on", bind, "base:", baseURL) if err := r.Run(bind); err != nil { - log.Fatal(err) + lg.Fatal(err) } } diff --git a/src/models.go b/src/models.go index d97053f..1cf0c75 100644 --- a/src/models.go +++ b/src/models.go @@ -4,6 +4,7 @@ import ( "time" ) +// One knows that the user is active when there exists a session for the user type User struct { ID uint `gorm:"primaryKey"` CreatedAt time.Time @@ -14,13 +15,13 @@ type User struct { Slug string `gorm:"uniqueIndex;size:80"` } +// Currently no expiry type Session struct { ID uint `gorm:"primaryKey"` CreatedAt time.Time - // No expiry by default; can add later - Token string `gorm:"uniqueIndex;size:128"` - UserID uint `gorm:"index"` - User User + Token string `gorm:"uniqueIndex;size:128"` + UserID uint `gorm:"index"` + User User } type LoginToken struct { @@ -46,6 +47,7 @@ type Game struct { Table *Table ScoreA int ScoreB int + WinnerIsA bool } type GamePlayer struct { @@ -55,3 +57,10 @@ type GamePlayer struct { Side string `gorm:"size:1;index"` // "A" or "B" User User } + +// Also house the common models that are not in the database here +type stats struct { + Games, + Wins, + Losses int +} diff --git a/src/handlers.go b/src/routes.go similarity index 66% rename from src/handlers.go rename to src/routes.go index ec303ca..8f00e4c 100644 --- a/src/handlers.go +++ b/src/routes.go @@ -2,7 +2,9 @@ package main import ( "errors" + "fmt" "log" + "net/http" "strings" "time" @@ -10,125 +12,86 @@ import ( "gorm.io/gorm" ) -func getIndex(c *gin.Context) { - if getSessionUser(c) != nil { - c.Redirect(302, "/enter") - return - } - getLogin(c) -} - -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") -} - func getEnter(c *gin.Context) { - var table *Table - if slug := c.Param("tslug"); slug != "" { - var t Table - if err := db.Where("slug = ?", slug).First(&t).Error; err == nil { - table = &t - } - } - post := "/enter" - if table != nil { - post = "/t/" + table.Slug + "/enter" - } - tm.Render(c, "enter", gin.H{"PostAction": post, "Table": table}) + // Simple render for now + tm.Render(c, "enter", gin.H{}) } func postEnter(c *gin.Context) { current := getSessionUser(c) if current == nil { - c.Redirect(302, "/login") + c.Redirect(http.StatusFound, "/login") return } - a1h := c.PostForm("a1") - a2h := c.PostForm("a2") - b1h := c.PostForm("b1") - b2h := c.PostForm("b2") + // 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")) - // Resolve players (default A1 to current if empty) - if strings.TrimSpace(a1h) == "" { - a1h = current.Username - } - a1, _ := findOrCreateUserByHandle(a1h) - var a2, b1, b2 *User - if a2h != "" { - a2, _ = findOrCreateUserByHandle(a2h) - } - if b1h != "" { - b1, _ = findOrCreateUserByHandle(b1h) - } - if b2h != "" { - b2, _ = findOrCreateUserByHandle(b2h) + // 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 @@ -137,42 +100,49 @@ func postEnter(c *gin.Context) { } } - g := Game{ScoreA: scoreA, ScoreB: scoreB, TableID: tableID} + // Create game + g := Game{ + ScoreA: scoreA, + ScoreB: scoreB, + TableID: tableID, + WinnerIsA: scoreA > scoreB, + } if err := db.Create(&g).Error; err != nil { - c.String(500, "game create error") + c.String(http.StatusInternalServerError, "game create error") return } + // Collect players by side players := []struct { - U *User - S string - }{{a1, "A"}} - if a2 != nil { - players = append(players, struct { - U *User - S string - }{a2, "A"}) - } - if b1 != nil { - players = append(players, struct { - U *User - S string - }{b1, "B"}) - } - if b2 != nil { - players = append(players, struct { - U *User - S string - }{b2, "B"}) + u *User + side string + }{ + {a1, "A"}, + {a2, "A"}, + {b1, "B"}, + {b2, "B"}, } + for _, p := range players { - if p.U != nil { - db.Create(&GamePlayer{GameID: g.ID, UserID: p.U.ID, Side: p.S}) + 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(302, "/history") + 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 @@ -274,53 +244,21 @@ func getUserView(c *gin.Context) { return } - // Compute stats - type Stats struct{ Games, Wins, Losses int } - st := Stats{} - var gps []GamePlayer - db.Where("user_id = ?", u.ID).Find(&gps) - gameMap := map[uint]string{} - for _, gp := range gps { - gameMap[gp.GameID] = gp.Side - } - - if len(gameMap) > 0 { - var games []Game - ids := make([]uint, 0, len(gameMap)) - for gid := range gameMap { - ids = append(ids, gid) - } - db.Find(&games, ids) - for _, g := range games { - st.Games++ - if g.ScoreA == g.ScoreB { - continue - } - if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" { - st.Wins++ - } else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" { - st.Wins++ - } else { - st.Losses++ - } - } - } - own := false if cu := getSessionUser(c); cu != nil && cu.ID == u.ID { own = true } - tm.Render(c, "user", gin.H{"Viewed": u, "Stats": st, "Own": own}) + tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": own}) } func getMe(c *gin.Context) { - cu := getSessionUser(c) - if cu == nil { + u := getSessionUser(c) + if u == nil { c.Redirect(302, "/login") return } - tm.Render(c, "user", gin.H{"Viewed": cu, "Stats": struct{ Games, Wins, Losses int }{0, 0, 0}, "Own": true}) + tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": true}) } func postMe(c *gin.Context) { @@ -330,10 +268,12 @@ func postMe(c *gin.Context) { return } newU := strings.TrimSpace(c.PostForm("username")) - if newU == "" { + 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 { @@ -342,5 +282,71 @@ func postMe(c *gin.Context) { if err := db.Save(cu).Error; err != nil { log.Println("save user:", err) } - c.Redirect(302, "/u/"+cu.Slug) + 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") } diff --git a/src/templates.go b/src/templates.go index 5d1fc95..8dc607d 100644 --- a/src/templates.go +++ b/src/templates.go @@ -30,14 +30,20 @@ func NewTemplateManager(dir string, base string, ext string) *TemplateManager { tm := &TemplateManager{ templates: make(map[string]*template.Template), funcs: template.FuncMap{ + // Attach functions to the templates here "fmtTime": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") // Go’s reference time }, + "cmpTwo": func(a, b int) bool { + return a == b + }, }, base: base, dir: dir, ext: ext, } + + // Populate template manager tm.LoadTemplates() return tm @@ -58,7 +64,7 @@ func (tm *TemplateManager) LoadTemplates() { } name := stripAfterDot(filepath.Base(file)) - logger.Println(name) + lg.Println(name) // Parse base + view template together tpl, err := template.New(name). diff --git a/src/utils.go b/src/utils.go index d8ad17c..7e4f6ee 100644 --- a/src/utils.go +++ b/src/utils.go @@ -16,12 +16,15 @@ func mustRandToken(n int) string { return hex.EncodeToString(b) } -func now() time.Time { return time.Now().UTC() } +func now() time.Time { + return time.Now().UTC() +} + +var adjectives = []string{"swift", "brave", "mighty", "cheeky", "sneaky", "zippy", "bouncy", "crispy", "fuzzy", "spicy", "snappy", "jazzy", "spry", "bold", "witty"} +var animals = []string{"otter", "panda", "falcon", "lynx", "badger", "tiger", "koala", "yak", "gecko", "eagle", "mamba", "fox", "yak", "whale", "rhino"} func defaultUsername() string { - // io-game style: adjective-animal-xxxx - adjectives := []string{"swift", "brave", "mighty", "cheeky", "sneaky", "zippy", "bouncy", "crispy", "fuzzy", "spicy", "snappy", "jazzy", "spry", "bold", "witty"} - animals := []string{"otter", "panda", "falcon", "lynx", "badger", "tiger", "koala", "yak", "gecko", "eagle", "mamba", "fox", "yak", "whale", "rhino"} + // Inspired from IO games: adjective-animal-xx a := adjectives[int(time.Now().UnixNano())%len(adjectives)] an := animals[int(time.Now().UnixNano()/17)%len(animals)] return a + "-" + an + "-" + strings.ToLower(mustRandToken(2)) @@ -72,8 +75,18 @@ func ensureUniqueUsernameAndSlug(u *User) error { } func userByEmail(email string) (*User, error) { + // Email must be lowercased and trimmed var u User - tx := db.Where("email = ?", strings.ToLower(email)).First(&u) + tx := db.Where("email = ?", email).First(&u) + if tx.Error != nil { + return nil, tx.Error + } + return &u, nil +} + +func userByName(name string) (*User, error) { + var u User + tx := db.Where("username = ?", name).First(&u) if tx.Error != nil { return nil, tx.Error } @@ -121,3 +134,36 @@ func stripAfterDot(s string) string { } return s } + +func getStatsFromUser(u *User) stats { + var gps []GamePlayer + var games, wins, losses int + db.Where("user_id = ?", u.ID).Find(&gps) + gameMap := map[uint]string{} + for _, gp := range gps { + gameMap[gp.GameID] = gp.Side + } + + if len(gameMap) > 0 { + var gamesL []Game + ids := make([]uint, 0, len(gameMap)) + for gid := range gameMap { + ids = append(ids, gid) + } + db.Find(&gamesL, ids) + for _, g := range gamesL { + games++ + if g.ScoreA == g.ScoreB { + continue + } + if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" { + wins++ + } else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" { + wins++ + } else { + losses++ + } + } + } + return stats{Games: games, Wins: wins, Losses: losses} +} diff --git a/templates/base.html b/templates/base.html index 26e764f..74482c0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,12 +5,14 @@ - {{block "title" .}}QRank{{end}} + QRank + + @@ -18,12 +20,14 @@
-
QRank
+
{{block "title" .}}QRank{{end}}
+ +
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + {{if .Success}} +
{{.Success}}
+ {{end}} + {{if .Message}} +
{{.Message}}
+ {{end}} + {{block "content" .}}{{end}}
diff --git a/templates/enter.html b/templates/enter.html index a5f558f..1a8ffa7 100644 --- a/templates/enter.html +++ b/templates/enter.html @@ -1,31 +1,41 @@ -{{define "title"}}Enter scores - QRank{{end}} +{{define "title"}}QRank{{end}} {{define "content"}}

Enter a game

- {{if .Table}}

Table: {{.Table.Name}}

{{end}} -
+ + {{if .Table}} +

Table: {{.Table.Name}}

+ {{end}} + +
-
+

Team A

- + +
- + +
- +
-
+ +

Team B

- + +
- + +
- +
+
-{{end}} \ No newline at end of file +{{end}} diff --git a/templates/history.html b/templates/history.html index 664552c..cd2d4f8 100644 --- a/templates/history.html +++ b/templates/history.html @@ -7,7 +7,7 @@
{{fmtTime .CreatedAt}}
{{if .Table}}{{.Table.Name}} {{end}} - Team A {{.ScoreA}} \u2013 {{.ScoreB}} Team B + {{if .WinnerIsA}}{{range .PlayersA}}{{.Username}} {{end}}{{.ScoreA}}/{{.ScoreB}}{{else}}{{range .PlayersB}}{{.Username}} {{end}}{{.ScoreB}}/{{.ScoreA}}{{end}}
{{range .PlayersA}}@{{.Username}} {{end}}vs {{range .PlayersB}}@{{.Username}} {{end}} diff --git a/templates/leaderboard.html b/templates/leaderboard.html index a9c8311..8d5bf74 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -3,7 +3,7 @@

Leaderboard (by wins)

    {{range .Rows}} -
  1. @{{.Username}} \u2014 {{.Wins}} wins ({{.Games}} games)
  2. +
  3. @{{.Username}} - {{.Wins}} wins ({{.Games}} games)
  4. {{else}}
  5. No players yet.
  6. {{end}} diff --git a/templates/sent.html b/templates/sent.html index e57cf8a..cf4677a 100644 --- a/templates/sent.html +++ b/templates/sent.html @@ -1,7 +1,7 @@ {{define "content"}}
    -

    Check the server logs

    -

    We just printed a magic link for {{.Email}}. Open it here to sign in. +

    Check your email inbox

    +

    We just printed a magic link for {{.Email}}. Open it to sign in. Token expires in 30 days.

    Back
    diff --git a/templates/user.html b/templates/user.html index b1ff19a..d9bc5b4 100644 --- a/templates/user.html +++ b/templates/user.html @@ -2,7 +2,7 @@

    @{{.Viewed.Username}}

    Joined {{fmtTime .Viewed.CreatedAt}}

    -

    Games played: {{.Stats.Games}} Wins: {{.Stats.Wins}} Losses: {{.Stats.Losses}}

    +

    Game score {{.Stats.Games}}/{{.Stats.Wins}}/{{.Stats.Losses}}

    {{if .Own}}

    Update your profile

    diff --git a/tmp/build-errors.log b/tmp/build-errors.log deleted file mode 100644 index a2a130a..0000000 --- a/tmp/build-errors.log +++ /dev/null @@ -1 +0,0 @@ -exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main deleted file mode 100755 index cf24bf0..0000000 Binary files a/tmp/main and /dev/null differ