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 = "."
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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -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);
};
}

View File

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

View File

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

View File

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

View File

@@ -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(&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) {
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(&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{
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") // Gos 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).

View File

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

View File

@@ -5,12 +5,14 @@
<head>
<meta charset="utf-8">
<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="apple-touch-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">
</head>
@@ -18,12 +20,14 @@
<div class="header">
<div class="container">
<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">
{{if .CurrentUser}}
<a href="/enter">Enter scores</a>
<a href="/history">History</a>
<a href="/leaderboard">Leaderboard</a>
{{if .CurrentUser}}
<a href="/me">@{{.CurrentUser.Username}}</a>
{{else}}
<a href="/login">Log in</a>
@@ -33,6 +37,17 @@
</div>
</div>
<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 -->
{{block "content" .}}{{end}}
</div>

View File

@@ -1,31 +1,41 @@
{{define "title"}}Enter scores - QRank{{end}}
{{define "title"}}QRank{{end}}
{{define "content"}}
<div class="card">
<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 style="flex:1;min-width:280px">
<div style="flex:1;min-width:180px">
<h3>Team A</h3>
<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>
<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>
<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 style="flex:1;min-width:280px">
<div style="flex:1;min-width:180px">
<h3>Team B</h3>
<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>
<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>
<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 style="height:12px"></div>
<button class="btn btn-primary" type="submit">Submit result</button>
</form>
</div>
{{end}}
{{end}}

View File

@@ -7,7 +7,7 @@
<div class="small">{{fmtTime .CreatedAt}}</div>
<div>
{{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 class="small">
{{range .PlayersA}}@{{.Username}} {{end}}vs {{range .PlayersB}}@{{.Username}} {{end}}

View File

@@ -3,7 +3,7 @@
<h2>Leaderboard (by wins)</h2>
<ol>
{{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}}
<li class="small">No players yet.</li>
{{end}}

View File

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

View File

@@ -2,7 +2,7 @@
<div class="card">
<h2>@{{.Viewed.Username}}</h2>
<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}}
<hr>
<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.