initial commit

This commit is contained in:
Niklas Kapelle 2025-02-02 15:11:35 +01:00
commit 3640116dd5
Signed by: niklas
GPG Key ID: 4EB651B36D841D16
39 changed files with 10298 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
/build

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/build
/internal/web/static

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM golang:1.23-alpine AS build
ADD . /app
WORKDIR /app
ARG TARGETARCH
ARG TARGETOS
RUN apk add --no-cache make npm
RUN make deps
RUN make BUILD_ARCH="$TARGETARCH" BUILD_OS="$TARGETOS" build
FROM alpine:latest
RUN apk add --no-cache ca-certificates
WORKDIR /data
COPY --from=build /app/build/beerpong-elo /app/beerpong-elo
EXPOSE 3000
CMD [ "/app/beerpong-elo" ]

44
Makefile Normal file
View File

@ -0,0 +1,44 @@
BINARY = beerpong-elo
BUILD_DIR = build
BUILD_ARCH = $(shell go env GOARCH)
BUILD_OS = $(shell go env GOOS)
DOCKER_BUILD_FLAGS =
DOCKER_BUILD_PLATFORM = linux/amd64,linux/arm64,linux/arm32v6,linux/arm32v7
DOCKER_BUILD_TAG = djeeberjr/beerpong-elo
DIST_DIR = static
EMBED_DIR = internal/web/static
.PHONY: all
all: clean build
.PHONY:build
build: $(BUILD_DIR)/$(BINARY)
$(BUILD_DIR)/$(BINARY): $(EMBED_DIR) $(shell find . -name '*.go')
GOARCH=$(BUILD_ARCH) GOOS=$(BUILD_OS) go build -o $(BUILD_DIR)/$(BINARY) cmd/beerpong-elo.go
$(EMBED_DIR): $(BUILD_DIR)/$(DIST_DIR)
cp -r $(BUILD_DIR)/$(DIST_DIR) $(EMBED_DIR)
$(BUILD_DIR)/$(DIST_DIR):
cd web/ && npm run build && mkdir ../$(BUILD_DIR) && cp -r dist/ ../$(BUILD_DIR)/$(DIST_DIR)
.PHONY: deps
deps:
cd web/ && npm ci
.PHONY:clean
clean:
rm -rf $(BUILD_DIR) $(EMBED_DIR)
.PHONY: test
test:
go test ./...
.PHONY: docker-push
docker-push:
docker buildx build $(DOCKER_BUILD_FLAGS) --platform $(DOCKER_BUILD_PLATFORM) -t $(DOCKER_BUILD_TAG) . --push
.PHONY: docker-local
docker-local:
docker buildx build $(DOCKER_BUILD_FLAGS) -t $(DOCKER_BUILD_TAG) . --load

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# Beerpong Elo
An ELO system for 2v2 beerpong with web interface and graphql api. Using Svelte 5 for the frontend.
# Building
Requires go
`make build`
## Docker
Building the docker image: `make docker` and `make docker-local`.

39
cmd/beerpong-elo.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
beerpongelo "git.kapelle.org/niklas/beerpong-elo/internal"
"github.com/alexflint/go-arg"
)
type args struct {
DBHost string `arg:"--db-endpoint,required,env:DB_HOST" help:"IP or hostname of the DB" placeholder:"DB_HOST"`
DBUsername string `arg:"--db-username,required,env:DB_USERNAME" help:"Username for the DB" placeholder:"DB_USERNAME"`
DBPassword string `arg:"--db-password,required,env:DB_PASSWORD" help:"Password for the DB" placeholder:"DB_PASSWORD"`
DBName string `arg:"--db-name,required,env:DB_NAME" help:"Name of the DB" placeholder:"DB_NAME"`
Address string `arg:"--address,env:ADDRESS" default:":3000" help:"What address to listen on" placeholder:"ADDRESS"`
Import string `arg:"--import" help:"Import a json file and exit" placeholder:"data.json"`
}
func (args) Version() string {
return "beerpong-elo v0.1"
}
func main() {
var args args
arg.MustParse(&args)
config := beerpongelo.Config{
DBHost: args.DBHost,
DBUsername: args.DBUsername,
DBPassword: args.DBPassword,
DBName: args.DBName,
Address: args.Address,
}
if args.Import != "" {
beerpongelo.Import(config,args.Import)
return
}
beerpongelo.Start(config)
}

17
go.mod Normal file
View File

@ -0,0 +1,17 @@
module git.kapelle.org/niklas/beerpong-elo
go 1.22.6
require github.com/alexflint/go-arg v1.5.1
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/graphql-go/graphql v0.8.1 // indirect
github.com/graphql-go/handler v0.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

26
go.sum Normal file
View File

@ -0,0 +1,26 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y=
github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
github.com/graphql-go/handler v0.2.4 h1:gz9q11TUHPNUpqzV8LMa+rkqM5NUuH/nkE3oF2LS3rI=
github.com/graphql-go/handler v0.2.4/go.mod h1:gsQlb4gDvURR0bgN8vWQEh+s5vJALM2lYL3n3cf6OxQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

71
internal/beerpong-elo.go Normal file
View File

@ -0,0 +1,71 @@
package beerpongelo
import (
"encoding/json"
"log"
"net/http"
"os"
"git.kapelle.org/niklas/beerpong-elo/internal/model"
"git.kapelle.org/niklas/beerpong-elo/internal/repo"
"git.kapelle.org/niklas/beerpong-elo/internal/web"
)
type Config struct {
DBHost string
DBUsername string
DBPassword string
DBName string
Address string
}
func Start(config Config) {
repo, err := repo.NewSQLRepo(config.DBHost, config.DBUsername, config.DBPassword, config.DBName)
if err != nil {
log.Fatalf("Failed to start server: %s", err)
}
mux := web.CreateWebserver(repo)
http.ListenAndServe(config.Address, mux)
}
func Import(config Config, file string) {
repo, err := repo.NewSQLRepo(config.DBHost, config.DBUsername, config.DBPassword, config.DBName)
if err != nil {
log.Fatalf("Failed to import file %s: %s", file, err)
}
controller := newController(repo)
err = loadFromFile(controller, file)
if err != nil {
log.Fatalf("Failed to import file %s: %s", file, err)
}
}
func loadFromFile(c Controller, path string) error {
content, err := os.ReadFile(path)
if err != nil {
return err
}
var payload []model.Game
err = json.Unmarshal(content, &payload)
if err != nil {
return err
}
for _, game := range payload {
_, err = c.AddGame(game)
if err != nil {
return err
}
}
log.Printf("Imported %d games", len(payload))
return nil
}

142
internal/controller.go Normal file
View File

@ -0,0 +1,142 @@
package beerpongelo
import (
"math"
"git.kapelle.org/niklas/beerpong-elo/internal/model"
"git.kapelle.org/niklas/beerpong-elo/internal/repo"
)
type Controller struct {
repo repo.Repo
}
func newController(repo repo.Repo) Controller {
return Controller{
repo: repo,
}
}
func (c *Controller) AddGame(game model.Game) (model.GameID, error) {
parsedGame := model.Game{
Added: game.Added,
Score: game.Score,
Overtime: game.Overtime,
}
author, err := c.repo.GetOrCreatePlayerID(string(game.Author))
if err != nil {
return "", err
}
parsedGame.Author = author
t0p0, err := c.repo.GetOrCreatePlayerID(string(game.Team0Player0))
if err != nil {
return "", err
}
parsedGame.Team0Player0 = t0p0
t0p1, err := c.repo.GetOrCreatePlayerID(string(game.Team0Player1))
if err != nil {
return "", err
}
parsedGame.Team0Player1 = t0p1
t1p0, err := c.repo.GetOrCreatePlayerID(string(game.Team1Player0))
if err != nil {
return "", err
}
parsedGame.Team1Player0 = t1p0
t1p1, err := c.repo.GetOrCreatePlayerID(string(game.Team1Player1))
if err != nil {
return "", err
}
parsedGame.Team1Player1 = t1p1
id, err := c.repo.AddGame(parsedGame)
if err != nil {
return "", err
}
parsedGame.ID = id
err = c.createGameResult(parsedGame)
if err != nil {
return "", err
}
return id, nil
}
func (c *Controller) createGameResult(game model.Game) error {
t0p0, err := c.repo.GetPlayer(game.Team0Player0)
if err != nil {
return err
}
t0p1, err := c.repo.GetPlayer(game.Team0Player1)
if err != nil {
return err
}
t1p0, err := c.repo.GetPlayer(game.Team1Player0)
if err != nil {
return err
}
t1p1, err := c.repo.GetPlayer(game.Team1Player1)
if err != nil {
return err
}
newT0p0, newT0p1, newT1p0, newT1p1 := NewRating(float64(t0p0.Elo), float64(t0p1.Elo), float64(t1p0.Elo), float64(t1p1.Elo), game.Score)
t0p0Result := model.GameResult{
Game: game.ID,
Player: t0p0.ID,
StartElo: t0p0.Elo,
EndElo: int(math.Round(newT0p0)),
}
t0p1Result := model.GameResult{
Game: game.ID,
Player: t0p1.ID,
StartElo: t0p1.Elo,
EndElo: int(math.Round(newT0p1)),
}
t1p0Result := model.GameResult{
Game: game.ID,
Player: t1p0.ID,
StartElo: t1p0.Elo,
EndElo: int(math.Round(newT1p0)),
}
t1p1Result := model.GameResult{
Game: game.ID,
Player: t1p1.ID,
StartElo: t1p1.Elo,
EndElo: int(math.Round(newT1p1)),
}
_,err = c.repo.AddGameResult(t0p0Result)
if err != nil{
return err
}
_,err = c.repo.AddGameResult(t0p1Result)
if err != nil{
return err
}
_,err = c.repo.AddGameResult(t1p0Result)
if err != nil{
return err
}
_,err = c.repo.AddGameResult(t1p1Result)
if err != nil{
return err
}
return nil
}

52
internal/elo.go Normal file
View File

@ -0,0 +1,52 @@
package beerpongelo
import (
"math"
)
func winProbPlayer(p, e1, e2 float64) float64 {
const MAGIC float64 = 500
lhs := 1 / (1 + math.Pow(10., (p-e1)/MAGIC))
rhs := 1 / (1 + math.Pow(10., (p-e2)/MAGIC))
return (lhs + rhs) / 2.
}
func NewRating(t0p0, t0p1, t1p0, t1p1 float64, score int) (float64, float64, float64, float64) {
const K float64 = 50
const SCORE_BIAS float64 = 1
const SCORE_SCALE float64 = 0.4
const MAX_SCORE float64 = 10
et0p0 := winProbPlayer(t0p0, t1p0, t1p1)
et0p1 := winProbPlayer(t0p1, t1p0, t1p1)
et1p0 := winProbPlayer(t1p0, t0p0, t0p1)
et1p1 := winProbPlayer(t1p1, t0p0, t0p1)
et0 := (et0p0 + et0p1) / 2.
et1 := (et1p0 + et1p1) / 2.
ct0p0 := t0p0 / (t0p0 + t0p1)
ct0p1 := t0p1 / (t0p0 + t0p1)
ct1p0 := t1p0 / (t1p0 + t1p1)
ct1p1 := t1p1 / (t1p0 + t1p1)
var st0 float64
var st1 float64
if score > 0 {
st0 = 1
st1 = 0
} else {
st0 = 0
st1 = 1
}
scoreMod := SCORE_SCALE * math.Log(math.Abs(float64(score))+SCORE_BIAS) / (math.Log(MAX_SCORE) + SCORE_BIAS)
newt0p0 := t0p0 + K*scoreMod*((st0*ct0p0)-(et0*ct0p0))
newt0p1 := t0p1 + K*scoreMod*((st0*ct0p1)-(et0*ct0p1))
newt1p0 := t1p0 + K*scoreMod*((st1*ct1p0)-(et1*ct1p0))
newt1p1 := t1p1 + K*scoreMod*((st1*ct1p1)-(et1*ct1p1))
return newt0p0, newt0p1, newt1p0, newt1p1
}

33
internal/model/game.go Normal file
View File

@ -0,0 +1,33 @@
package model
import (
"fmt"
"time"
)
type GameID string
type Game struct {
ID GameID `json:"id"`
Added time.Time `json:"added"`
Author PlayerID `json:"author"`
Team0Player0 PlayerID `json:"t0p0"`
Team0Player1 PlayerID `json:"t0p1"`
Team1Player0 PlayerID `json:"t1p0"`
Team1Player1 PlayerID `json:"t1p1"`
Score int `json:"score"`
Overtime bool `jsone:"ot"`
}
func (id *GameID) Scan(src any) error {
switch v := src.(type) {
case int64:
*id = GameID(fmt.Sprintf("%d", v))
return nil
case string:
*id = GameID(v)
return nil
default:
return fmt.Errorf("unsupported type %T for GameID", src)
}
}

View File

@ -0,0 +1,26 @@
package model
import "fmt"
type GameResultID string
type GameResult struct {
ID GameResultID
Game GameID
Player PlayerID
StartElo int
EndElo int
}
func (id *GameResultID) Scan(src any) error {
switch v := src.(type) {
case int64:
*id = GameResultID(fmt.Sprintf("%d", v)) // Convert int64 to string and assign it
return nil
case string:
*id = GameResultID(v)
return nil
default:
return fmt.Errorf("unsupported type %T for GameResultID", src)
}
}

31
internal/model/player.go Normal file
View File

@ -0,0 +1,31 @@
package model
import "fmt"
type PlayerID string
type Player struct {
ID PlayerID
Name string
Elo int
}
func NewPlayer(name string) Player {
return Player{
Name: name,
Elo: 1000,
}
}
func (id *PlayerID) Scan(src any) error {
switch v := src.(type) {
case int64:
*id = PlayerID(fmt.Sprintf("%d", v)) // Convert int64 to string and assign it
return nil
case string:
*id = PlayerID(v)
return nil
default:
return fmt.Errorf("unsupported type %T for PlayerID", src)
}
}

40
internal/repo/repo.go Normal file
View File

@ -0,0 +1,40 @@
package repo
import (
model "git.kapelle.org/niklas/beerpong-elo/internal/model"
)
type Repo interface {
// Add a new game to the repo. ID needs to be nil on argument. PlayerID are fetched beforehand.
AddGame(model.Game) (model.GameID, error)
// Get a game by ID. Returns nil if not found.
GetGame(model.GameID) (*model.Game, error)
// Get ID of a player. Creates one if not found.
GetOrCreatePlayerID(string) (model.PlayerID, error)
// Get player by id. Returns nil if not found.
GetPlayer(model.PlayerID) (*model.Player, error)
// Adds a game result. ID needs to be nil on argument.
AddGameResult(model.GameResult) (model.GameResultID, error)
// Get a game result by ID. Returns nil if not found.
GetGameResult(model.GameResultID) (*model.GameResult, error)
// Get a game result for a player in a game
GetGameResultForPlayerAndGame(model.GameID, model.PlayerID) (*model.GameResult, error)
// Get all game results for a player
GetGameResultsForPlayer(model.PlayerID) ([]*model.GameResult, error)
// Get games for a player
GetGamesForPlayer(model.PlayerID) ([]*model.Game, error)
// Get all players
GetPlayers() ([]*model.Player, error)
// Get all games
GetGames() ([]*model.Game, error)
}

279
internal/repo/sqlRepo.go Normal file
View File

@ -0,0 +1,279 @@
package repo
import (
"database/sql"
"fmt"
"time"
"git.kapelle.org/niklas/beerpong-elo/internal/model"
_ "github.com/go-sql-driver/mysql"
)
type SQLRepo struct {
db *sql.DB
}
func NewSQLRepo(host, username, password, dbName string) (Repo, error) {
db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", username, password, host, dbName))
if err != nil {
return nil, err
}
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
return &SQLRepo{
db: db,
}, nil
}
func (s *SQLRepo) AddGame(game model.Game) (model.GameID, error) {
stmt, err := s.db.Prepare("INSERT INTO Games(added,score,overtime,author,team0player0,team0player1,team1player0,team1player1) VALUES (?,?,?,?,?,?,?,?)")
if err != nil {
return "", err
}
res, err := stmt.Exec(game.Added, game.Score, game.Overtime, game.Author, game.Team0Player0, game.Team0Player1, game.Team1Player0, game.Team1Player1)
if err != nil {
return "", err
}
id, err := res.LastInsertId()
if err != nil {
return "", err
}
var gameID model.GameID
gameID.Scan(id)
return gameID, nil
}
func (s *SQLRepo) AddGameResult(gameResult model.GameResult) (model.GameResultID, error) {
stms, err := s.db.Prepare("INSERT INTO GameResults(game,player,startElo,endElo) VALUES (?,?,?,?)")
if err != nil {
return "", err
}
res, err := stms.Exec(gameResult.Game, gameResult.Player, gameResult.StartElo, gameResult.EndElo)
if err != nil {
return "", err
}
id, err := res.LastInsertId()
if err != nil {
return "", err
}
var gameResultID model.GameResultID
gameResultID.Scan(id)
return gameResultID, nil
}
func (s *SQLRepo) GetGame(id model.GameID) (*model.Game, error) {
rows := s.db.QueryRow("SELECT id,added, score,overtime,author,team0player0,team0player1,team1player0,team1player1 FROM Games WHERE id = ? ", id)
var game model.Game
if err := rows.Scan(&game.ID, &game.Added, &game.Score, &game.Overtime, &game.Author, &game.Team0Player0, &game.Team0Player1, &game.Team1Player0, &game.Team1Player1); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &game, nil
}
func (s *SQLRepo) GetGameResult(id model.GameResultID) (*model.GameResult, error) {
rows := s.db.QueryRow("SELECT game,player,startElo,endElo FROM GameResults WHERE id = ? ", id)
var gameResult model.GameResult
gameResult.ID = id
if err := rows.Scan(&gameResult.Game, &gameResult.Player, &gameResult.StartElo, &gameResult.EndElo); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &gameResult, nil
}
func (s *SQLRepo) playerWithNameExists(name string) (*model.PlayerID, error) {
rows := s.db.QueryRow("SELECT id FROM Players WHERE name = ?", name)
var id model.PlayerID
if err := rows.Scan(&id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &id, nil
}
func (s *SQLRepo) GetOrCreatePlayerID(name string) (model.PlayerID, error) {
foundID, err := s.playerWithNameExists(name)
if err != nil {
return "", err
}
if foundID != nil {
return *foundID, nil
}
stmt, err := s.db.Prepare("INSERT INTO Players(name,elo) VALUES (?,?)")
if err != nil {
return "", err
}
newPlayer := model.NewPlayer(name)
res, err := stmt.Exec(newPlayer.Name, newPlayer.Elo)
if err != nil {
return "", err
}
id, err := res.LastInsertId()
if err != nil {
return "", err
}
var playerID model.PlayerID
playerID.Scan(id)
return playerID, nil
}
func (s *SQLRepo) GetPlayer(id model.PlayerID) (*model.Player, error) {
rows := s.db.QueryRow("SELECT name,elo from Players WHERE id = ?", id)
var player model.Player
player.ID = id
if err := rows.Scan(&player.Name, &player.Elo); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &player, nil
}
func (s *SQLRepo) GetGamesForPlayer(id model.PlayerID) ([]*model.Game, error) {
rows, err := s.db.Query(`
SELECT id,added, score,overtime,author,team0player0,team0player1,team1player0,team1player1
FROM Games
WHERE team0player0 = ? OR team0player1 = ? OR team1player0 = ? OR team1player1 = ?
`, id, id, id, id)
if err != nil {
return nil, err
}
defer rows.Close()
rtn := []*model.Game{}
for rows.Next() {
var game model.Game
err = rows.Scan(&game.ID, &game.Added, &game.Score, &game.Overtime, &game.Author, &game.Team0Player0, &game.Team0Player1, &game.Team1Player0, &game.Team1Player1)
if err != nil {
return nil, err
}
rtn = append(rtn, &game)
}
return rtn, nil
}
func (s *SQLRepo) GetGameResultForPlayerAndGame(gameID model.GameID, playerID model.PlayerID) (*model.GameResult, error) {
rows := s.db.QueryRow("SELECT id,game,player,startElo,endElo FROM GameResults WHERE game = ? AND player = ? ", gameID, playerID)
var gameResult model.GameResult
if err := rows.Scan(&gameResult.ID, &gameResult.Game, &gameResult.Player, &gameResult.StartElo, &gameResult.EndElo); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &gameResult, nil
}
func (s *SQLRepo) GetGameResultsForPlayer(id model.PlayerID) ([]*model.GameResult, error) {
rows, err := s.db.Query("SELECT id,game,player,startElo,endElo FROM GameResults WHERE player = ? ", id)
if err != nil {
return nil, err
}
rtn := []*model.GameResult{}
for rows.Next() {
var gameResult model.GameResult
err = rows.Scan(&gameResult.ID, &gameResult.Game, &gameResult.Player, &gameResult.StartElo, &gameResult.EndElo)
if err != nil {
return nil, err
}
rtn = append(rtn, &gameResult)
}
return rtn, nil
}
func (s *SQLRepo) GetPlayers() ([]*model.Player, error) {
rows, err := s.db.Query("SELECT id, name, elo FROM Players")
if err != nil {
return nil, err
}
rtn := []*model.Player{}
for rows.Next() {
var player model.Player
err = rows.Scan(&player.ID, &player.Name, &player.Elo)
if err != nil {
return nil, err
}
rtn = append(rtn, &player)
}
return rtn, nil
}
func (s *SQLRepo) GetGames() ([]*model.Game, error) {
rows, err := s.db.Query("SELECT id,added, score,overtime,author,team0player0,team0player1,team1player0,team1player1 FROM Games")
if err != nil {
return nil, err
}
rtn := []*model.Game{}
for rows.Next() {
var game model.Game
err = rows.Scan(&game.ID, &game.Added, &game.Score, &game.Overtime, &game.Author, &game.Team0Player0, &game.Team0Player1, &game.Team1Player0, &game.Team1Player1)
if err != nil {
return nil, err
}
rtn = append(rtn, &game)
}
return rtn, nil
}

View File

@ -0,0 +1,44 @@
CREATE TABLE Players (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
elo INT
);
CREATE TABLE Games (
id INT AUTO_INCREMENT PRIMARY KEY,
added DATETIME NOT NULL,
score INT NOT NULL,
overtime BOOL NOT NULL,
author INT,
team0player0 INT,
team0player1 INT,
team1player0 INT,
team1player1 INT,
FOREIGN KEY (author) REFERENCES Players(id) on DELETE SET NULL,
FOREIGN KEY (team0player0) REFERENCES Players(id) ON DELETE SET NULL,
FOREIGN KEY (team0player1) REFERENCES Players(id) ON DELETE SET NULL,
FOREIGN KEY (team1player0) REFERENCES Players(id) ON DELETE SET NULL,
FOREIGN KEY (team1player1) REFERENCES Players(id) ON DELETE SET NULL
);
CREATE TABLE GameResults (
id INT AUTO_INCREMENT PRIMARY KEY,
game INT,
player INT,
startElo INT,
endElo INT,
FOREIGN KEY (game) REFERENCES Games(id) on DELETE SET NULL,
FOREIGN KEY (player) REFERENCES Players(id) on DELETE SET NULL
)
DELIMITER $$
CREATE TRIGGER UpdateCurrentELO
AFTER INSERT ON GameResults
FOR EACH ROW
BEGIN
UPDATE Players
SET elo = NEW.endElo
WHERE id = NEW.player;
END$$
DELIMITER ;

369
internal/web/gqlSchema.go Normal file
View File

@ -0,0 +1,369 @@
package web
import (
"log"
"git.kapelle.org/niklas/beerpong-elo/internal/model"
"git.kapelle.org/niklas/beerpong-elo/internal/repo"
"github.com/graphql-go/graphql"
)
func createShema(repo repo.Repo) graphql.Schema {
player := graphql.NewObject(graphql.ObjectConfig{
Name: "Player",
Description: "A player. Can also be authors of games",
Fields: graphql.Fields{
"ID": &graphql.Field{
Type: graphql.NewNonNull(graphql.ID),
},
"name": &graphql.Field{
Type: graphql.NewNonNull(graphql.String),
},
"elo": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
},
// Field "games" added below
// Field "history" added below
},
})
game := graphql.NewObject(graphql.ObjectConfig{
Name: "Game",
Description: "A game played",
Fields: graphql.Fields{
"id": &graphql.Field{
Type: graphql.NewNonNull(graphql.ID),
},
"added": &graphql.Field{
Type: graphql.NewNonNull(graphql.DateTime),
},
"overtime": &graphql.Field{
Type: graphql.NewNonNull(graphql.Boolean),
},
"score": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
},
"author": &graphql.Field{
Type: graphql.NewNonNull(player),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
game, ok := p.Source.(*model.Game)
if !ok {
log.Printf("Can't get source for field 'author' on 'Game' is %v", p.Source)
return nil, nil
}
player, err := repo.GetPlayer(game.Author)
if err != nil {
return nil, err
}
return player, nil
},
},
"team0player0": &graphql.Field{
Type: graphql.NewNonNull(player),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
game, ok := p.Source.(*model.Game)
if !ok {
log.Printf("Can't get source for field 'team0player0' on 'Game' is %v", p.Source)
return nil, nil
}
player, err := repo.GetPlayer(game.Team0Player0)
if err != nil {
return nil, err
}
return player, nil
},
},
"team0player1": &graphql.Field{
Type: graphql.NewNonNull(player),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
game, ok := p.Source.(*model.Game)
if !ok {
log.Printf("Can't get source for field 'team0player1' on 'Game' is %v", p.Source)
return nil, nil
}
player, err := repo.GetPlayer(game.Team0Player1)
if err != nil {
return nil, err
}
return player, nil
},
},
"team1player0": &graphql.Field{
Type: graphql.NewNonNull(player),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
game, ok := p.Source.(*model.Game)
if !ok {
log.Printf("Can't get source for field 'team1player0' on 'Game' is %v", p.Source)
return nil, nil
}
player, err := repo.GetPlayer(game.Team1Player0)
if err != nil {
return nil, err
}
return player, nil
},
},
"team1player1": &graphql.Field{
Type: graphql.NewNonNull(player),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
game, ok := p.Source.(*model.Game)
if !ok {
log.Printf("Can't get source for field 'team1player1' on 'Game' is %v", p.Source)
return nil, nil
}
player, err := repo.GetPlayer(game.Team1Player1)
if err != nil {
return nil, err
}
return player, nil
},
},
},
})
player.AddFieldConfig("games", &graphql.Field{
Type: graphql.NewNonNull(graphql.NewList(game)),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
player, ok := p.Source.(*model.Player)
if !ok {
log.Printf("Can't get source for field 'games' on 'Player' is %v", p.Source)
return nil, nil
}
games, err := repo.GetGamesForPlayer(player.ID)
if err != nil {
return nil, err
}
return games, nil
},
})
gameResult := graphql.NewObject(graphql.ObjectConfig{
Name: "GameResult",
Description: "The ELO change for a player in a game",
Fields: graphql.Fields{
"id": &graphql.Field{
Type: graphql.NewNonNull(graphql.ID),
},
"player": &graphql.Field{
Type: player,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
gameResult, ok := p.Source.(*model.GameResult)
if !ok {
log.Printf("Can't get source for field 'player' on 'GameResult' is %v", p.Source)
return nil, nil
}
player, err := repo.GetPlayer(gameResult.Player)
if err != nil {
return nil, err
}
return player, nil
},
},
"game": &graphql.Field{
Type: graphql.NewNonNull(game),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
gameResult, ok := p.Source.(*model.GameResult)
if !ok {
log.Printf("Can't get source for field 'game' on 'GameResult' is %v", p.Source)
return nil, nil
}
game, err := repo.GetGame(gameResult.Game)
if err != nil {
return nil, err
}
return game, nil
},
},
"startElo": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
},
"endElo": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
},
"delta": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
gameResult, ok := p.Source.(*model.GameResult)
if !ok {
log.Printf("Can't get source for field 'delta' on 'GameResult' is %v", p.Source)
return nil, nil
}
delta := gameResult.EndElo - gameResult.StartElo
return delta, nil
},
},
},
})
player.AddFieldConfig("history", &graphql.Field{
Type: graphql.NewNonNull(graphql.NewList(gameResult)),
Args: graphql.FieldConfigArgument{
"game": &graphql.ArgumentConfig{
Type: graphql.ID,
Description: "Filter by Game ID",
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
player, ok := p.Source.(*model.Player)
if !ok {
log.Printf("Can't get source from field 'history' on 'player' is %v", p.Source)
return nil, nil
}
if p.Args["game"] != nil {
// Find result for single game
gameID, ok := p.Args["game"].(string)
if !ok {
log.Printf("Failed to parse 'game' at field 'history' on 'player' is %v", p.Args["game"])
return nil, nil
}
gameResult, err := repo.GetGameResultForPlayerAndGame(model.GameID(gameID), player.ID)
if err != nil {
return nil, err
}
return []*model.GameResult{gameResult}, nil
}
// Find all gameResults for a player
gameResults, err := repo.GetGameResultsForPlayer(player.ID)
if err != nil {
return nil, err
}
return gameResults, nil
},
})
queryFields := graphql.Fields{
"player": &graphql.Field{
Type: player,
Description: "Get player by ID",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.ID),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
id, ok := p.Args["id"].(string)
if !ok {
log.Printf("Failed to parse ID at player: %v", p.Args["id"])
return nil, nil
}
player, err := repo.GetPlayer(model.PlayerID(id))
if err != nil {
return nil, err
}
return player, nil
},
},
"game": &graphql.Field{
Type: game,
Description: "Get game by ID",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.ID),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
id, ok := p.Args["id"].(string)
if !ok {
log.Printf("Failed to parse ID at game: %v", p.Args["id"])
return nil, nil
}
game, err := repo.GetGame(model.GameID(id))
if err != nil {
return nil, err
}
return game, nil
},
},
"gameResult": &graphql.Field{
Type: gameResult,
Description: "Result of a game on a players ELO",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.ID),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
id, ok := p.Args["id"].(string)
if !ok {
log.Printf("Failed to parse ID at gameResult: %v", p.Args["id"])
return nil, nil
}
gameResult, err := repo.GetGameResult(model.GameResultID(id))
if err != nil {
return nil, err
}
return gameResult, nil
},
},
"players": &graphql.Field{
Type: graphql.NewNonNull(graphql.NewList(player)),
Description: "Get all players",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
players, err := repo.GetPlayers()
if err != nil {
return nil, err
}
return players, nil
},
},
"games": &graphql.Field{
Type: graphql.NewNonNull(graphql.NewList(game)),
Description: "Get all games",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
games, err := repo.GetGames()
if err != nil {
return nil, err
}
return games, nil
},
},
}
querySchema := graphql.ObjectConfig{
Name: "Query",
Fields: queryFields,
}
schemaConfig := graphql.SchemaConfig{Query: graphql.NewObject(querySchema)}
schema, err := graphql.NewSchema(schemaConfig)
if err != nil {
log.Fatalf("failed to create new schema, error: %v", err)
}
return schema
}

View File

@ -0,0 +1,28 @@
package web
import (
"embed"
"io/fs"
"net/http"
"os"
)
//go:embed static/*
var staticFiles embed.FS
type spaFileSystem struct {
root http.FileSystem
}
func (spa *spaFileSystem) Open(name string) (http.File, error) {
f, err := spa.root.Open(name)
if os.IsNotExist(err) {
return spa.root.Open("index.html")
}
return f, err
}
func initStatic(mux *http.ServeMux) {
staticFS, _ := fs.Sub(staticFiles, "static")
mux.Handle("/", http.FileServer(&spaFileSystem{http.FS(staticFS)}))
}

24
internal/web/web.go Normal file
View File

@ -0,0 +1,24 @@
package web
import (
"net/http"
"git.kapelle.org/niklas/beerpong-elo/internal/repo"
"github.com/graphql-go/handler"
)
func CreateWebserver(repo repo.Repo) *http.ServeMux {
router := http.NewServeMux()
schema := createShema(repo)
gqlHandler := handler.New(&handler.Config{
Schema: &schema,
Pretty: true,
GraphiQL: true,
})
router.Handle("/graphql", gqlHandler)
initStatic(router)
return router
}

24
web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

19
web/codegen.ts Normal file
View File

@ -0,0 +1,19 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
overwrite: true,
schema: "http://localhost:8080/graphql",
documents: "./src/**/*.svelte",
generates: {
"src/gql/": {
preset: "client",
plugins: [],
config:{
useTypeImports: true,
},
}
}
};
export default config;

13
web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Beerpong</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

8483
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
web/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "beerpong-elo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
"codegen": "graphql-codegen --config codegen.ts"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.3",
"@graphql-codegen/client-preset": "4.5.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"autoprefixer": "^10.4.20",
"graphql": "^16.10.0",
"graphql-request": "^7.1.2",
"postcss": "^8.4.49",
"svelte": "^5.15.0",
"svelte-check": "^4.1.1",
"svelte2tsx": "^0.7.33",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.5"
},
"dependencies": {
"@dvcol/svelte-simple-router": "^1.9.1"
}
}

6
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

33
web/src/App.svelte Normal file
View File

@ -0,0 +1,33 @@
<script lang="ts">
import Game from "./pages/Game.svelte";
import Player from "./pages/Player.svelte";
import { RouterView } from "@dvcol/svelte-simple-router/components";
import type {
Route,
RouterOptions,
} from "@dvcol/svelte-simple-router/models";
const routes: Readonly<Route[]> = [
{
name: "game",
path: "/game/:{string}:id",
component: Game,
},
{
name: "player",
path: "/player/:{string}:id",
component: Player,
},
];
const options: RouterOptions = {
routes,
};
</script>
<main>
<RouterView {options} />
</main>
<style>
</style>

3
web/src/app.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

67
web/src/lib/Graph.svelte Normal file
View File

@ -0,0 +1,67 @@
<script lang="ts">
let {
data,
width,
height,
}: { data: Array<number>; width: number; height: number } = $props();
const lineStep = 1;
const labelStep = 2;
let minValue = $derived(Math.min(...data));
let maxValue = $derived(Math.max(...data));
const xScale = (i: number) => (i / (data.length - 1)) * width;
const yScale = (d: number) =>
height - ((d - minValue) / (maxValue - minValue)) * height;
let dPath = $derived(
data
.map((d, i) => `${i === 0 ? "M" : "L"}${xScale(i)},${yScale(d)}`)
.join(" "),
);
let lines: Array<number> = $derived(
Array.from(
{ length: Math.ceil((maxValue - minValue) / lineStep) + 1 },
(_, i) => minValue + i * lineStep,
),
);
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
{width}
{height}
viewBox={`0 0 ${width} ${height}`}
class="border-solid border-black border-2"
>
<g>
{#each lines as line}
<line
x1="0"
y1={yScale(line)}
x2={width}
y2={yScale(line)}
stroke="#e0e0e0"
stroke-width="1"
data-foo={line}
/>
{#if line % labelStep === 0}
<text
x="30"
y={yScale(line) - 2}
font-size="10"
text-anchor="end"
fill="black"
data-foo={line}
>
{line}
</text>
{/if}
{/each}
<!-- Data line -->
<path d={dPath} fill="none" stroke="orange" stroke-width="2" />
</g>
</svg>

9
web/src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app

160
web/src/pages/Game.svelte Normal file
View File

@ -0,0 +1,160 @@
<script lang="ts">
import { request } from "graphql-request";
import { graphql } from "./../gql";
import { onMount } from "svelte";
import type { GetGameQuery } from "../gql/graphql";
import { useRoute, link } from "@dvcol/svelte-simple-router/router";
const doc = graphql(`
query getGame($gameID: ID!) {
game(id: $gameID) {
added
overtime
score
author {
ID
name
}
team0player0 {
ID
name
history(game: $gameID) {
startElo
delta
}
}
team0player1 {
ID
name
history(game: $gameID) {
startElo
delta
}
}
team1player0 {
ID
name
history(game: $gameID) {
startElo
delta
}
}
team1player1 {
ID
name
history(game: $gameID) {
startElo
delta
}
}
}
}
`);
interface Player {
name: string;
id: string;
startElo: number;
delta: number;
}
interface Team {
name: string;
p0: Player;
p1: Player;
}
let loading = $state(true);
let errorState = $state(false);
let data: GetGameQuery | undefined = $state();
onMount(() => {
const idParam = useRoute().location?.params.id;
if (typeof idParam == "string") {
request(window.location.origin + "/graphql", doc, {
gameID: idParam,
})
.then((e) => {
data = e;
loading = false;
})
.catch((e) => {
console.error(e);
errorState = true;
loading = false;
});
} else {
loading = false;
}
});
</script>
<h1 class="text-4xl">Game</h1>
{#if loading}
Loading...
{:else if errorState}
An error occurred
{:else if data == null || data.game == null}
Game not found.
{:else}
{#snippet player(p: Player)}
<a use:link href="/player/{p.id}"
>{p.name} ({p.startElo} <span class="{p.delta<0?"text-red-700":"text-green-700"}" >{(p.delta<0?"":"+")+p.delta}</span>)
</a>
{/snippet}
{#snippet team(team: Team)}
<div class="mx-3">
<h2 class="text-xl">{team.name}</h2>
<div>
{@render player(team.p0)}
</div>
<div>
{@render player(team.p1)}
</div>
</div>
{/snippet}
Added by: {data.game.author.name} on {data.game.added}
<div class="flex justify-between">
{@render team({
name: "Team 1",
p0: {
id: data.game.team0player0.ID,
name: data.game.team0player0.name,
startElo: data.game.team0player0.history[0]!.startElo,
delta: data.game.team0player0.history[0]!.delta,
},
p1: {
id: data.game.team0player1.ID,
name: data.game.team0player1.name,
startElo: data.game.team0player1.history[0]!.startElo,
delta: data.game.team0player1.history[0]!.delta,
},
})}
<div class="text-2xl flex items-center">
<div>
<span>{data.game.score > 0 ? data.game.score : 0}</span>
-
<span>{data.game.score < 0 ? -data.game.score : 0}</span>
</div>
</div>
{@render team({
name: "Team 1",
p0: {
id: data.game.team1player0.ID,
name: data.game.team1player0.name,
startElo: data.game.team1player0.history[0]!.startElo,
delta: data.game.team1player0.history[0]!.delta,
},
p1: {
id: data.game.team1player1.ID,
name: data.game.team1player1.name,
startElo: data.game.team1player1.history[0]!.startElo,
delta: data.game.team1player1.history[0]!.delta,
},
})}
</div>
{/if}

View File

@ -0,0 +1,62 @@
<script lang="ts">
import { request } from "graphql-request";
import { graphql } from "./../gql";
import { onMount } from "svelte";
import type { GetPlayerQuery } from "../gql/graphql";
import { useRoute } from "@dvcol/svelte-simple-router/router";
import Graph from "../lib/Graph.svelte";
const doc = graphql(`
query getPlayer($playerID: ID!) {
player(id: $playerID) {
ID
name
elo
history {
delta
endElo
game {
id
}
}
}
}
`);
let loading = $state(true);
let errorState = $state(false);
let data: GetPlayerQuery | undefined = $state();
onMount(() => {
const idParam = useRoute().location?.params.id;
if (typeof idParam == "string") {
request(window.location.origin + "/graphql", doc, {
playerID: idParam,
})
.then((e) => {
data = e;
loading = false;
})
.catch((e) => {
console.error(e);
errorState = true;
loading = false;
});
} else {
loading = false;
}
});
</script>
{#if loading}
Loading...
{:else if errorState}
An error occurred
{:else if data == null || data.player == null}
Player not found.
{:else}
<div class="m-2">
<h1 class="text-2xl">{data.player.name} ({data.player.elo})</h1>
<Graph height={500} width={800} data={data.player.history.map((e) => e!.endElo)} />
</div>
{/if}

2
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

7
web/svelte.config.js Normal file
View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

12
web/tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{svelte,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

20
web/tsconfig.app.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

7
web/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
web/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
server:{
proxy:{
"/graphql": "http://localhost:8080",
},
},
})