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

View File

@@ -1,17 +1,14 @@
root = "." root = "."
tmp_dir = "tmp"
[build] [build]
cmd = "go build -o ./tmp/main ./src/main.go" cmd = "nix develop -c go build -o ./tmp/main ./src/..." # for nix users
bin = "tmp/main" include_ext = ["go", "html", "css"]
full_bin = "tmp/main" exclude_dir = ["tmp"]
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
exclude_dir = ["tmp", "vendor"]
[log] [log]
level = "warn"
time = true time = true
[colors] [env]
main = "yellow" GIN_MODE = "debug" # change to "release" for production
watcher = "cyan" APP_PORT = 18765
build = "green"

3
.gitignore vendored
View File

@@ -1 +1,2 @@
qrank.db qrank.db
tmp

View File

@@ -1,9 +1,12 @@
# QRank # 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. 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 ## 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.

View File

@@ -42,12 +42,42 @@ body {
} }
.input { .input {
width: 100%; width: 75%;
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #ddd 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 { .label {
font-size: 12px; font-size: 12px;
color: #444; color: #444;

View File

@@ -12,10 +12,9 @@
devShells = forAllSystems (pkgs: devShells = forAllSystems (pkgs:
let let
# Pick the Go you want. pkgs.go is fine; change to pkgs.go_1_23 if you prefer a fixed version. # 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 { in {
default = pkgs.mkShell { default = pkgs.mkShell {
# Tools required for cgo and sqlite
buildInputs = [ buildInputs = [
go go
pkgs.gcc pkgs.gcc
@@ -23,25 +22,18 @@
pkgs.sqlite pkgs.sqlite
]; ];
# Optional (handy) tools
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.git pkgs.git
]; ];
# Enable CGO so mattn/go-sqlite3 works
CGO_ENABLED = 1; CGO_ENABLED = 1;
# If you plan static linking, uncomment:
# hardeningDisable = [ "fortify" ];
shellHook = '' shellHook = ''
echo "🔧 Nix dev shell ready (CGO_ENABLED=1)." echo 'Inside nix shell.'
echo " Run: go run ."
''; '';
}; };
}); });
# Convenience runner: `nix run` # Used for nix run
apps = forAllSystems (pkgs: { apps = forAllSystems (pkgs: {
default = { default = {
type = "app"; type = "app";
@@ -53,7 +45,7 @@
}; };
}); });
# Optional: formatter for this repo # Nix formatter
formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt); formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt);
}; };
} }

View File

@@ -33,8 +33,8 @@ func requireAuth() gin.HandlerFunc {
} }
func setSessionCookie(c *gin.Context, token string) { func setSessionCookie(c *gin.Context, token string) {
// Very long-lived cookie (~10 years) // Very long-lived cookie for one year
maxAge := 10 * 365 * 24 * 60 * 60 maxAge := 365 * 24 * 60 * 60
httpOnly := true httpOnly := true
secure := strings.HasPrefix(baseURL, "https://") secure := strings.HasPrefix(baseURL, "https://")
sameSite := http.SameSiteLaxMode 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()) 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) h := strings.TrimSpace(handle)
if h == "" { if h == "" {
return nil, sql.ErrNoRows 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 { if u, err := userByEmail(strings.ToLower(h)); err == nil {
return u, nil return u, nil
} }
// create } else {
u := &User{Email: strings.ToLower(h), Username: defaultUsername()} // Find and return the user
if err := ensureUniqueUsernameAndSlug(u); err != nil { if u, err := userByName(h); err == nil {
return nil, err return u, nil
} }
if err := db.Create(u).Error; err != nil {
return nil, err
}
return u, nil
} }
// username
var u User return nil, sql.ErrNoRows
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
} }

View File

@@ -4,8 +4,9 @@ import (
"log" "log"
"os" "os"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -16,46 +17,44 @@ var (
db *gorm.DB db *gorm.DB
baseURL string baseURL string
cookieDomain string cookieDomain string
// Global logger for this package
lg = log.Default()
) )
const ( const (
PORT = "18765" // Default port if APP_PORT is not set
defaultPort = 18765
) )
var logger = log.Default()
// ===================== Main ===================== // ===================== Main =====================
func main() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) 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") baseURL = os.Getenv("APP_BASE_URL")
if baseURL == "" { 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 var err error
dsn := os.Getenv("DATABASE_URL") db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{})
if dsn != "" { if err != nil {
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) lg.Fatal("sqlite connect:", err)
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")
} }
lg.Println("using SQLite qrank.db")
if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GamePlayer{}); err != nil { 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() r := gin.Default()
// Serve static files from the current directory // Serve static files from the current directory
@@ -82,12 +81,10 @@ func main() {
r.GET("/me", requireAuth(), getMe) r.GET("/me", requireAuth(), getMe)
r.POST("/me", requireAuth(), postMe) r.POST("/me", requireAuth(), postMe)
bind := os.Getenv("APP_BIND") // Start application with port
if bind == "" { bind := ":" + port
bind = ":" + PORT lg.Println("listening on", bind, "base:", baseURL)
}
log.Println("listening on", bind, "base:", baseURL)
if err := r.Run(bind); err != nil { if err := r.Run(bind); err != nil {
log.Fatal(err) lg.Fatal(err)
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
) )
// One knows that the user is active when there exists a session for the user
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
CreatedAt time.Time CreatedAt time.Time
@@ -14,13 +15,13 @@ type User struct {
Slug string `gorm:"uniqueIndex;size:80"` Slug string `gorm:"uniqueIndex;size:80"`
} }
// Currently no expiry
type Session struct { type Session struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
CreatedAt time.Time CreatedAt time.Time
// No expiry by default; can add later Token string `gorm:"uniqueIndex;size:128"`
Token string `gorm:"uniqueIndex;size:128"` UserID uint `gorm:"index"`
UserID uint `gorm:"index"` User User
User User
} }
type LoginToken struct { type LoginToken struct {
@@ -46,6 +47,7 @@ type Game struct {
Table *Table Table *Table
ScoreA int ScoreA int
ScoreB int ScoreB int
WinnerIsA bool
} }
type GamePlayer struct { type GamePlayer struct {
@@ -55,3 +57,10 @@ type GamePlayer struct {
Side string `gorm:"size:1;index"` // "A" or "B" Side string `gorm:"size:1;index"` // "A" or "B"
User User User User
} }
// Also house the common models that are not in the database here
type stats struct {
Games,
Wins,
Losses int
}

View File

@@ -2,7 +2,9 @@ package main
import ( import (
"errors" "errors"
"fmt"
"log" "log"
"net/http"
"strings" "strings"
"time" "time"
@@ -10,125 +12,86 @@ import (
"gorm.io/gorm" "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(&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")
}
func getEnter(c *gin.Context) { func getEnter(c *gin.Context) {
var table *Table // Simple render for now
if slug := c.Param("tslug"); slug != "" { tm.Render(c, "enter", gin.H{})
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})
} }
func postEnter(c *gin.Context) { func postEnter(c *gin.Context) {
current := getSessionUser(c) current := getSessionUser(c)
if current == nil { if current == nil {
c.Redirect(302, "/login") c.Redirect(http.StatusFound, "/login")
return return
} }
a1h := c.PostForm("a1") // Parse form
a2h := c.PostForm("a2") a1h := strings.TrimSpace(c.PostForm("a1"))
b1h := c.PostForm("b1") a2h := strings.TrimSpace(c.PostForm("a2"))
b2h := c.PostForm("b2") b1h := strings.TrimSpace(c.PostForm("b1"))
b2h := strings.TrimSpace(c.PostForm("b2"))
scoreA := atoiSafe(c.PostForm("scoreA")) scoreA := atoiSafe(c.PostForm("scoreA"))
scoreB := atoiSafe(c.PostForm("scoreB")) scoreB := atoiSafe(c.PostForm("scoreB"))
// Resolve players (default A1 to current if empty) // Require a winner
if strings.TrimSpace(a1h) == "" { if scoreA == scoreB {
a1h = current.Username 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})
a1, _ := findOrCreateUserByHandle(a1h) return
var a2, b1, b2 *User
if a2h != "" {
a2, _ = findOrCreateUserByHandle(a2h)
}
if b1h != "" {
b1, _ = findOrCreateUserByHandle(b1h)
}
if b2h != "" {
b2, _ = findOrCreateUserByHandle(b2h)
} }
// 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 var tableID *uint
if slug := c.Param("tslug"); slug != "" { if slug := c.Param("tslug"); slug != "" {
var t Table 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 { if err := db.Create(&g).Error; err != nil {
c.String(500, "game create error") c.String(http.StatusInternalServerError, "game create error")
return return
} }
// Collect players by side
players := []struct { players := []struct {
U *User u *User
S string side string
}{{a1, "A"}} }{
if a2 != nil { {a1, "A"},
players = append(players, struct { {a2, "A"},
U *User {b1, "B"},
S string {b2, "B"},
}{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"})
} }
for _, p := range players { for _, p := range players {
if p.U != nil { if p.u != nil {
db.Create(&GamePlayer{GameID: g.ID, UserID: p.U.ID, Side: p.S}) 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) { func getHistory(c *gin.Context) {
// Load recent games with players // Load recent games with players
var games []Game var games []Game
@@ -274,53 +244,21 @@ func getUserView(c *gin.Context) {
return 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 own := false
if cu := getSessionUser(c); cu != nil && cu.ID == u.ID { if cu := getSessionUser(c); cu != nil && cu.ID == u.ID {
own = true 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) { func getMe(c *gin.Context) {
cu := getSessionUser(c) u := getSessionUser(c)
if cu == nil { if u == nil {
c.Redirect(302, "/login") c.Redirect(302, "/login")
return 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) { func postMe(c *gin.Context) {
@@ -330,10 +268,12 @@ func postMe(c *gin.Context) {
return return
} }
newU := strings.TrimSpace(c.PostForm("username")) newU := strings.TrimSpace(c.PostForm("username"))
if newU == "" { if newU == "" || newU == cu.Username {
c.Redirect(302, "/me") c.Redirect(302, "/me")
return return
} }
// Update username and slug
cu.Username = newU cu.Username = newU
cu.Slug = slugify(newU) cu.Slug = slugify(newU)
if err := ensureUniqueUsernameAndSlug(cu); err != nil { if err := ensureUniqueUsernameAndSlug(cu); err != nil {
@@ -342,5 +282,71 @@ func postMe(c *gin.Context) {
if err := db.Save(cu).Error; err != nil { if err := db.Save(cu).Error; err != nil {
log.Println("save user:", err) 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(&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")
} }

View File

@@ -30,14 +30,20 @@ func NewTemplateManager(dir string, base string, ext string) *TemplateManager {
tm := &TemplateManager{ tm := &TemplateManager{
templates: make(map[string]*template.Template), templates: make(map[string]*template.Template),
funcs: template.FuncMap{ funcs: template.FuncMap{
// Attach functions to the templates here
"fmtTime": func(t time.Time) string { "fmtTime": func(t time.Time) string {
return t.Local().Format("2006-01-02 15:04") // Gos reference time return t.Local().Format("2006-01-02 15:04") // Gos reference time
}, },
"cmpTwo": func(a, b int) bool {
return a == b
},
}, },
base: base, base: base,
dir: dir, dir: dir,
ext: ext, ext: ext,
} }
// Populate template manager
tm.LoadTemplates() tm.LoadTemplates()
return tm return tm
@@ -58,7 +64,7 @@ func (tm *TemplateManager) LoadTemplates() {
} }
name := stripAfterDot(filepath.Base(file)) name := stripAfterDot(filepath.Base(file))
logger.Println(name) lg.Println(name)
// Parse base + view template together // Parse base + view template together
tpl, err := template.New(name). tpl, err := template.New(name).

View File

@@ -16,12 +16,15 @@ func mustRandToken(n int) string {
return hex.EncodeToString(b) 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 { func defaultUsername() string {
// io-game style: adjective-animal-xxxx // Inspired from IO games: adjective-animal-xx
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"}
a := adjectives[int(time.Now().UnixNano())%len(adjectives)] a := adjectives[int(time.Now().UnixNano())%len(adjectives)]
an := animals[int(time.Now().UnixNano()/17)%len(animals)] an := animals[int(time.Now().UnixNano()/17)%len(animals)]
return a + "-" + an + "-" + strings.ToLower(mustRandToken(2)) return a + "-" + an + "-" + strings.ToLower(mustRandToken(2))
@@ -72,8 +75,18 @@ func ensureUniqueUsernameAndSlug(u *User) error {
} }
func userByEmail(email string) (*User, error) { func userByEmail(email string) (*User, error) {
// Email must be lowercased and trimmed
var u User 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 { if tx.Error != nil {
return nil, tx.Error return nil, tx.Error
} }
@@ -121,3 +134,36 @@ func stripAfterDot(s string) string {
} }
return s 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}
}

View File

@@ -5,12 +5,14 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}QRank{{end}}</title> <title>QRank</title>
<!-- Also apple support -->
<link rel="icon" type="image/png" href="/assets/favicon.ico"> <link rel="icon" type="image/png" href="/assets/favicon.ico">
<link rel="apple-touch-icon" href="/assets/favicon.ico"> <link rel="apple-touch-icon" href="/assets/favicon.ico">
<link rel="shortcut icon" href="/assets/favicon.ico"> <link rel="shortcut icon" href="/assets/favicon.ico">
<!-- Only one stylesheet for now -->
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
@@ -18,12 +20,14 @@
<div class="header"> <div class="header">
<div class="container"> <div class="container">
<div class="row" style="justify-content:space-between;align-items:center"> <div class="row" style="justify-content:space-between;align-items:center">
<div><strong><a href="/" style="text-decoration:none;color:#111">QRank</a></strong></div> <div><strong><a href="/" style="text-decoration:none;color:#111">{{block "title" .}}QRank{{end}}</a></strong></div>
<!-- Navigation menu -->
<div class="nav"> <div class="nav">
{{if .CurrentUser}}
<a href="/enter">Enter scores</a> <a href="/enter">Enter scores</a>
<a href="/history">History</a> <a href="/history">History</a>
<a href="/leaderboard">Leaderboard</a> <a href="/leaderboard">Leaderboard</a>
{{if .CurrentUser}}
<a href="/me">@{{.CurrentUser.Username}}</a> <a href="/me">@{{.CurrentUser.Username}}</a>
{{else}} {{else}}
<a href="/login">Log in</a> <a href="/login">Log in</a>
@@ -33,6 +37,17 @@
</div> </div>
</div> </div>
<div class="container"> <div class="container">
<!-- Information messages for the user -->
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
{{if .Success}}
<div class="alert alert-success">{{.Success}}</div>
{{end}}
{{if .Message}}
<div class="alert alert-info">{{.Message}}</div>
{{end}}
<!-- This is shorthand for a define along with a template --> <!-- This is shorthand for a define along with a template -->
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</div> </div>

View File

@@ -1,31 +1,41 @@
{{define "title"}}Enter scores - QRank{{end}} {{define "title"}}QRank{{end}}
{{define "content"}} {{define "content"}}
<div class="card"> <div class="card">
<h2>Enter a game</h2> <h2>Enter a game</h2>
{{if .Table}}<p class="small">Table: <span class="badge">{{.Table.Name}}</span></p>{{end}}
<form method="POST" action="{{.PostAction}}"> {{if .Table}}
<p class="small">Table: <span class="badge">{{.Table.Name}}</span></p>
{{end}}
<form method="POST" action="/enter">
<div class="row"> <div class="row">
<div style="flex:1;min-width:280px"> <div style="flex:1;min-width:180px">
<h3>Team A</h3> <h3>Team A</h3>
<label class="label">Player A1</label> <label class="label">Player A1</label>
<input class="input" name="a1" value="{{.CurrentUser.Username}}"> <input class="input" name="a1" {{if .a1}}value="{{.a1}}"{{else}}value="{{.CurrentUser.Username}}"{{end}} placeholder="username or email">
<div style="height:6px"></div>
<label class="label">Player A2 (optional)</label> <label class="label">Player A2 (optional)</label>
<input class="input" name="a2" placeholder="username or email"> <input class="input" name="a2" {{if .a2}}value="{{.a2}}"{{end}} placeholder="username or email">
<div style="height:12px"></div>
<label class="label">Score A</label> <label class="label">Score A</label>
<input class="input" type="number" name="scoreA" value="10" min="0" required> <input class="input" type="number" name="scoreA" {{if .scoreA}}value="{{.scoreA}}"{{else}}value="0"{{end}} min="0" max="10" required>
</div> </div>
<div style="flex:1;min-width:280px">
<div style="flex:1;min-width:180px">
<h3>Team B</h3> <h3>Team B</h3>
<label class="label">Player B1</label> <label class="label">Player B1</label>
<input class="input" name="b1" placeholder="username or email"> <input class="input" name="b1" {{if .b1}}value="{{.b1}}"{{end}} placeholder="username or email">
<div style="height:6px"></div>
<label class="label">Player B2 (optional)</label> <label class="label">Player B2 (optional)</label>
<input class="input" name="b2" placeholder="username or email"> <input class="input" name="b2" {{if .b2}}value="{{.b2}}"{{end}} placeholder="username or email">
<div style="height:12px"></div>
<label class="label">Score B</label> <label class="label">Score B</label>
<input class="input" type="number" name="scoreB" value="8" min="0" required> <input class="input" type="number" name="scoreB" {{if .scoreB}}value="{{.scoreB}}"{{else}}value="0"{{end}} min="0" max="10" required>
</div> </div>
</div> </div>
<div style="height:12px"></div> <div style="height:12px"></div>
<button class="btn btn-primary" type="submit">Submit result</button> <button class="btn btn-primary" type="submit">Submit result</button>
</form> </form>
</div> </div>
{{end}} {{end}}

View File

@@ -7,7 +7,7 @@
<div class="small">{{fmtTime .CreatedAt}}</div> <div class="small">{{fmtTime .CreatedAt}}</div>
<div> <div>
{{if .Table}}<span class="badge">{{.Table.Name}}</span> {{end}} {{if .Table}}<span class="badge">{{.Table.Name}}</span> {{end}}
Team A {{.ScoreA}} \u2013 {{.ScoreB}} Team B {{if .WinnerIsA}}{{range .PlayersA}}{{.Username}} {{end}}{{.ScoreA}}/{{.ScoreB}}{{else}}{{range .PlayersB}}{{.Username}} {{end}}{{.ScoreB}}/{{.ScoreA}}{{end}}
</div> </div>
<div class="small"> <div class="small">
{{range .PlayersA}}@{{.Username}} {{end}}vs {{range .PlayersB}}@{{.Username}} {{end}} {{range .PlayersA}}@{{.Username}} {{end}}vs {{range .PlayersB}}@{{.Username}} {{end}}

View File

@@ -3,7 +3,7 @@
<h2>Leaderboard (by wins)</h2> <h2>Leaderboard (by wins)</h2>
<ol> <ol>
{{range .Rows}} {{range .Rows}}
<li>@<a href="/u/{{.Slug}}">{{.Username}}</a> \u2014 {{.Wins}} wins ({{.Games}} games)</li> <li>@<a href="/u/{{.Slug}}">{{.Username}}</a> - {{.Wins}} wins ({{.Games}} games)</li>
{{else}} {{else}}
<li class="small">No players yet.</li> <li class="small">No players yet.</li>
{{end}} {{end}}

View File

@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<div class="card"> <div class="card">
<h2>Check the server logs</h2> <h2>Check your email inbox</h2>
<p class="small">We just printed a magic link for <strong>{{.Email}}</strong>. Open it here to sign in. <p class="small">We just printed a magic link for <strong>{{.Email}}</strong>. Open it to sign in.
Token expires in 30 days.</p> Token expires in 30 days.</p>
<a class="btn" href="/login">Back</a> <a class="btn" href="/login">Back</a>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div class="card"> <div class="card">
<h2>@{{.Viewed.Username}}</h2> <h2>@{{.Viewed.Username}}</h2>
<p class="small">Joined {{fmtTime .Viewed.CreatedAt}}</p> <p class="small">Joined {{fmtTime .Viewed.CreatedAt}}</p>
<p>Games played: {{.Stats.Games}} Wins: {{.Stats.Wins}} Losses: {{.Stats.Losses}}</p> <p>Game score {{.Stats.Games}}/{{.Stats.Wins}}/{{.Stats.Losses}}</p>
{{if .Own}} {{if .Own}}
<hr> <hr>
<h3>Update your profile</h3> <h3>Update your profile</h3>

View File

@@ -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

BIN
tmp/main
View File

Binary file not shown.