Compare commits
10 Commits
09fe5a74d8
...
460503ba4a
Author | SHA1 | Date | |
---|---|---|---|
460503ba4a | |||
5b19b72ebf | |||
08de52f5f6 | |||
6b38c706b4 | |||
0662ed0c56 | |||
87edd6f478 | |||
c8d5540b0d | |||
7903bb7830 | |||
a74f722c58 | |||
4d21c77f99 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
data
|
data
|
||||||
main.log
|
main.log
|
||||||
input
|
input
|
||||||
|
frontend/.vscode
|
||||||
|
@@ -23,5 +23,4 @@ create table note (
|
|||||||
content text,
|
content text,
|
||||||
timestamp string,
|
timestamp string,
|
||||||
player integer references player(id)
|
player integer references player(id)
|
||||||
);
|
);
|
||||||
create unique index idx_note_content on note(timestamp, player);
|
|
21
backend/go.mod
Normal file
21
backend/go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module stinkinator
|
||||||
|
|
||||||
|
go 1.23.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gofiber/fiber/v3 v3.0.0-beta.3
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.55.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
golang.org/x/sys v0.21.0 // indirect
|
||||||
|
)
|
35
backend/go.sum
Normal file
35
backend/go.sum
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
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/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
|
||||||
|
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||||
|
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||||
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GuildService struct {
|
type GuildService struct {
|
||||||
@@ -39,6 +40,7 @@ func (gs *GuildService) Query(query GuildServiceQuery) ([]Guild, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (gs *GuildService) Create(name string) (Guild, error) {
|
func (gs *GuildService) Create(name string) (Guild, error) {
|
||||||
|
log.Printf("Creating guild %s", name)
|
||||||
guild := Guild{}
|
guild := Guild{}
|
||||||
res, err := gs.db.writeConn.Exec("insert into guild (name) values (?)", name)
|
res, err := gs.db.writeConn.Exec("insert into guild (name) values (?)", name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,6 +51,7 @@ func (gs *GuildService) Create(name string) (Guild, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return guild, fmt.Errorf("failed to get last insert id: %v", err)
|
return guild, fmt.Errorf("failed to get last insert id: %v", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("Created guild %s with id %d", name, id)
|
||||||
|
|
||||||
qres, err := gs.Query(GuildServiceQuery{ID: &id})
|
qres, err := gs.Query(GuildServiceQuery{ID: &id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,14 +67,17 @@ func (gs *GuildService) Create(name string) (Guild, error) {
|
|||||||
func (gs *GuildService) GetOrCreate(name string) (Guild, error) {
|
func (gs *GuildService) GetOrCreate(name string) (Guild, error) {
|
||||||
guild := Guild{}
|
guild := Guild{}
|
||||||
guilds, err := gs.Query(GuildServiceQuery{Name: &name})
|
guilds, err := gs.Query(GuildServiceQuery{Name: &name})
|
||||||
if err != nil {
|
if len(guilds) > 1 {
|
||||||
if len(guilds) > 1 {
|
return guild, fmt.Errorf("more than one guild found for name %s", name)
|
||||||
return guild, fmt.Errorf("more than one guild found for name %s", name)
|
}
|
||||||
}
|
if err != nil || len(guilds) == 0 {
|
||||||
guild, err = gs.Create(name)
|
guild, err = gs.Create(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return guild, fmt.Errorf("failed creating guild: %v", err)
|
return guild, fmt.Errorf("failed creating guild: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(guilds) == 1 {
|
||||||
|
guild = guilds[0]
|
||||||
|
}
|
||||||
return guild, nil
|
return guild, nil
|
||||||
}
|
}
|
138
backend/main.go
Normal file
138
backend/main.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Error *log.Logger
|
||||||
|
var Warning *log.Logger
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
|
||||||
|
logFile, err := os.Create("main.log")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating log file: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger := io.MultiWriter(os.Stdout, logFile)
|
||||||
|
log.SetOutput(logger)
|
||||||
|
|
||||||
|
Error = log.New(io.MultiWriter(logFile, os.Stderr, os.Stdout),
|
||||||
|
fmt.Sprintf("%sERROR:%s ", "\033[0;101m", "\033[0m"),
|
||||||
|
log.Lmicroseconds|log.Lshortfile)
|
||||||
|
Warning = log.New(io.MultiWriter(logFile, os.Stdout),
|
||||||
|
fmt.Sprintf("%sWarning:%s ", "\033[0;93m", "\033[0m"),
|
||||||
|
log.Lmicroseconds|log.Lshortfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
var db DB
|
||||||
|
var gs GuildService
|
||||||
|
var ps PlayerService
|
||||||
|
var ns NoteService
|
||||||
|
var as AssociationService
|
||||||
|
|
||||||
|
//go:embed selectPlayer.sql
|
||||||
|
var selectPlayer string
|
||||||
|
|
||||||
|
//go:embed selectAssociation.sql
|
||||||
|
var selectAssociation string
|
||||||
|
|
||||||
|
//go:embed selectNotes.sql
|
||||||
|
var selectNotes string
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
db = DB{
|
||||||
|
path: "data/db.db",
|
||||||
|
}
|
||||||
|
err := db.Open()
|
||||||
|
if err != nil {
|
||||||
|
Error.Printf("Failed opening database: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
gs = GuildService{db: &db}
|
||||||
|
ps = PlayerService{db: &db}
|
||||||
|
ns = NoteService{db: &db}
|
||||||
|
as = AssociationService{db: &db}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
|
||||||
|
app.Post("/note/new", CreateNote)
|
||||||
|
app.Get("/player/:name", GetPlayer)
|
||||||
|
app.Get("/player", GetPlayers)
|
||||||
|
|
||||||
|
log.Fatal(app.Listen(":3000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateNote(c fiber.Ctx) error {
|
||||||
|
data := c.Body()
|
||||||
|
res := Response{}
|
||||||
|
|
||||||
|
note, err := ParseNote(string(data))
|
||||||
|
if err != nil {
|
||||||
|
Error.Printf("Failed parsing note: %v", err)
|
||||||
|
res.Success = false
|
||||||
|
res.Message = err.Error()
|
||||||
|
return c.Status(400).JSON(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if note.Player == "" {
|
||||||
|
Error.Printf("No player specified in note: %v", note)
|
||||||
|
res.Success = false
|
||||||
|
res.Message = "No player specified"
|
||||||
|
return c.Status(400).JSON(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbnote, _, err := PersistNoteData(note)
|
||||||
|
if err != nil {
|
||||||
|
Error.Printf("Failed persisting note: %v", err)
|
||||||
|
res.Success = false
|
||||||
|
res.Message = err.Error()
|
||||||
|
return c.Status(500).JSON(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Data = dbnote
|
||||||
|
res.Message = "OK"
|
||||||
|
return c.Status(200).JSON(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPlayer(c fiber.Ctx) error {
|
||||||
|
name := c.Params("name")
|
||||||
|
log.Printf("Getting player %s", name)
|
||||||
|
player, err := ps.GetAllPlayerInfo(name, 10)
|
||||||
|
if err != nil {
|
||||||
|
Error.Printf("Failed getting player: %v", err)
|
||||||
|
return c.Status(500).JSON(Response{
|
||||||
|
Success: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(200).JSON(Response{
|
||||||
|
Success: true,
|
||||||
|
Data: player,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPlayers(c fiber.Ctx) error {
|
||||||
|
players, err := ps.Query(PlayerServiceQuery{})
|
||||||
|
if err != nil {
|
||||||
|
Error.Printf("Failed getting players: %v", err)
|
||||||
|
return c.Status(500).JSON(Response{
|
||||||
|
Success: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(200).JSON(Response{
|
||||||
|
Success: true,
|
||||||
|
Data: players,
|
||||||
|
})
|
||||||
|
}
|
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,10 +31,15 @@ func (ns *NoteService) Query(query NoteServiceQuery) ([]Note, error) {
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
note := Note{}
|
note := Note{}
|
||||||
err := rows.Scan(¬e.ID, ¬e.Content, ¬e.Timestamp, ¬e.Player.ID)
|
var ts string
|
||||||
|
err := rows.Scan(¬e.ID, ¬e.Content, &ts, ¬e.Player.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("failed scanning note: %v", err)
|
return res, fmt.Errorf("failed scanning note: %v", err)
|
||||||
}
|
}
|
||||||
|
note.Timestamp, err = time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed parsing timestamp: %v", err)
|
||||||
|
}
|
||||||
res = append(res, note)
|
res = append(res, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +47,9 @@ func (ns *NoteService) Query(query NoteServiceQuery) ([]Note, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ns *NoteService) Create(content string, timestamp time.Time, player Player) (Note, error) {
|
func (ns *NoteService) Create(content string, timestamp time.Time, player Player) (Note, error) {
|
||||||
|
log.Printf("Creating note %q with timestamp %s and player %d", content, timestamp, player.ID)
|
||||||
note := Note{}
|
note := Note{}
|
||||||
res, err := ns.db.writeConn.Exec("insert into note (content, timestamp, player) values (?, ?, ?)", content, timestamp, player.ID)
|
res, err := ns.db.writeConn.Exec("insert into note (content, timestamp, player) values (?, ?, ?)", content, timestamp.Format(time.RFC3339), player.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return note, fmt.Errorf("failed to insert note: %v", err)
|
return note, fmt.Errorf("failed to insert note: %v", err)
|
||||||
}
|
}
|
||||||
@@ -51,6 +58,7 @@ func (ns *NoteService) Create(content string, timestamp time.Time, player Player
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return note, fmt.Errorf("failed to get last insert id: %v", err)
|
return note, fmt.Errorf("failed to get last insert id: %v", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("Created note %q with timestamp %s and player %d with id %d", content, timestamp, player.ID, id)
|
||||||
|
|
||||||
qres, err := ns.Query(NoteServiceQuery{ID: &id})
|
qres, err := ns.Query(NoteServiceQuery{ID: &id})
|
||||||
if err != nil {
|
if err != nil {
|
149
backend/playerService.go
Normal file
149
backend/playerService.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlayerService struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
type PlayerServiceQuery struct {
|
||||||
|
Name *string `db:"name"`
|
||||||
|
ID *int64 `db:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PlayerService) Query(query PlayerServiceQuery) ([]Player, error) {
|
||||||
|
res := []Player{}
|
||||||
|
sqlQuery := "SELECT id, name, guild FROM player"
|
||||||
|
whereQuery := BuildWhereQuery(query)
|
||||||
|
if whereQuery != "" {
|
||||||
|
sqlQuery += " " + whereQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := ps.db.readConn.Query(sqlQuery)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed getting players for query %q: %v", sqlQuery, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
player := Player{}
|
||||||
|
err := rows.Scan(&player.ID, &player.Name, &player.Guild.ID)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed scanning player: %v", err)
|
||||||
|
}
|
||||||
|
res = append(res, player)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PlayerService) Create(name string, guild Guild) (Player, error) {
|
||||||
|
log.Printf("Creating player %s in guild %s", name, guild.Name)
|
||||||
|
player := Player{}
|
||||||
|
res, err := ps.db.writeConn.Exec("insert into player (name, guild) values (?, ?)", name, guild.ID)
|
||||||
|
if err != nil {
|
||||||
|
return player, fmt.Errorf("failed to insert player: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return player, fmt.Errorf("failed to get last insert id: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Created player %s in guild %s with id %d", name, guild.Name, id)
|
||||||
|
|
||||||
|
qres, err := ps.Query(PlayerServiceQuery{ID: &id})
|
||||||
|
if err != nil {
|
||||||
|
return player, fmt.Errorf("failed getting player for id %d: %v", id, err)
|
||||||
|
}
|
||||||
|
if len(qres) > 1 {
|
||||||
|
return player, fmt.Errorf("more than one player found for id %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return qres[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PlayerService) GetOrCreate(name string, guild Guild) (Player, error) {
|
||||||
|
player := Player{}
|
||||||
|
players, err := ps.Query(PlayerServiceQuery{Name: &name})
|
||||||
|
if len(players) > 1 {
|
||||||
|
return player, fmt.Errorf("more than one player found for name %s", name)
|
||||||
|
}
|
||||||
|
if err != nil || len(players) == 0 {
|
||||||
|
player, err = ps.Create(name, guild)
|
||||||
|
if err != nil {
|
||||||
|
return player, fmt.Errorf("failed creating player: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(players) == 1 {
|
||||||
|
player = players[0]
|
||||||
|
}
|
||||||
|
return player, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
FullPlayer struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Guild struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"guild"`
|
||||||
|
Associations []FullPlayerAssociation `json:"associations"`
|
||||||
|
Notes []FullPlayerNote `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
FullPlayerAssociation struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
FullPlayerNote struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ps *PlayerService) GetAllPlayerInfo(name string, nnotes int) (FullPlayer, error) {
|
||||||
|
res := FullPlayer{}
|
||||||
|
|
||||||
|
err := ps.db.readConn.QueryRow(selectPlayer, name).Scan(&res.ID, &res.Name, &res.Guild.ID, &res.Guild.Name)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed getting player info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := ps.db.readConn.Query(selectAssociation, res.ID)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed getting player associations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
assoc := FullPlayerAssociation{}
|
||||||
|
err := rows.Scan(&assoc.ID, &assoc.Name, &assoc.Note)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed scanning association: %v", err)
|
||||||
|
}
|
||||||
|
res.Associations = append(res.Associations, assoc)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
rows, err = ps.db.readConn.Query(selectNotes, res.ID, nnotes)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed getting player notes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
note := FullPlayerNote{}
|
||||||
|
err := rows.Scan(¬e.ID, ¬e.Content, ¬e.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed scanning note: %v", err)
|
||||||
|
}
|
||||||
|
res.Notes = append(res.Notes, note)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
28
backend/selectAssociation.sql
Normal file
28
backend/selectAssociation.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- 3. Get all associations (N rows)
|
||||||
|
with
|
||||||
|
AllAssociations as (
|
||||||
|
select
|
||||||
|
lhs as PlayerID,
|
||||||
|
rhs as AssociateID,
|
||||||
|
note
|
||||||
|
from
|
||||||
|
association
|
||||||
|
where
|
||||||
|
lhs = $1
|
||||||
|
union
|
||||||
|
select
|
||||||
|
rhs as PlayerID,
|
||||||
|
lhs as AssociateID,
|
||||||
|
note
|
||||||
|
from
|
||||||
|
association
|
||||||
|
where
|
||||||
|
rhs = $1
|
||||||
|
)
|
||||||
|
select
|
||||||
|
p.id as AssociateID,
|
||||||
|
p.name as AssociateName,
|
||||||
|
coalesce(a.note, '') as AssociationNote
|
||||||
|
from
|
||||||
|
AllAssociations a
|
||||||
|
join player p on a.AssociateID = p.id;
|
22
backend/selectNotes.sql
Normal file
22
backend/selectNotes.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- 2. Get N most recent notes (10 rows)
|
||||||
|
select
|
||||||
|
id as NoteID,
|
||||||
|
content as NoteContent,
|
||||||
|
timestamp as NoteTimestamp
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
n.*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
order by
|
||||||
|
timestamp desc
|
||||||
|
) as rn
|
||||||
|
from
|
||||||
|
note n
|
||||||
|
where
|
||||||
|
n.player = $1
|
||||||
|
) ranked
|
||||||
|
where
|
||||||
|
rn <= $2
|
||||||
|
order by
|
||||||
|
timestamp desc;
|
11
backend/selectPlayer.sql
Normal file
11
backend/selectPlayer.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- 1. Get player and guild info (1 row)
|
||||||
|
select
|
||||||
|
p.id as PlayerID,
|
||||||
|
p.name as PlayerName,
|
||||||
|
g.id as GuildID,
|
||||||
|
g.name as GuildName
|
||||||
|
from
|
||||||
|
player p
|
||||||
|
join guild g on p.guild = g.id
|
||||||
|
where
|
||||||
|
p.name = $1;
|
45
backend/types.go
Normal file
45
backend/types.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type (
|
||||||
|
Guild struct {
|
||||||
|
ID int64
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
Player struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Guild Guild `json:"guild,omitempty"`
|
||||||
|
}
|
||||||
|
Association struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
LHS Player `json:"lhs"`
|
||||||
|
RHS Player `json:"rhs"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
Note struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Player Player `json:"player"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type NoteData struct {
|
||||||
|
Player string
|
||||||
|
Date time.Time
|
||||||
|
Guild string
|
||||||
|
Note string
|
||||||
|
Associations []NoteAssociationData
|
||||||
|
}
|
||||||
|
type NoteAssociationData struct {
|
||||||
|
Player string
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
118
backend/utils.go
Normal file
118
backend/utils.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildWhereQuery(params interface{}) string {
|
||||||
|
conditions := []string{}
|
||||||
|
v := reflect.ValueOf(params)
|
||||||
|
t := v.Type()
|
||||||
|
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
field := v.Field(i)
|
||||||
|
if field.Kind() == reflect.Ptr && !field.IsNil() {
|
||||||
|
dbTag := t.Field(i).Tag.Get("db")
|
||||||
|
if dbTag != "" {
|
||||||
|
switch field.Elem().Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
conditions = append(conditions, fmt.Sprintf("%s = '%s'", dbTag, field.Elem().String()))
|
||||||
|
case reflect.Bool:
|
||||||
|
conditions = append(conditions, fmt.Sprintf("%s = %t", dbTag, field.Elem().Bool()))
|
||||||
|
case reflect.Int:
|
||||||
|
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
|
||||||
|
case reflect.Int64:
|
||||||
|
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
return "WHERE " + strings.Join(conditions, " AND ")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var associationRe = regexp.MustCompile(`r:(\w+)(?:\(([^)]+)\))?`)
|
||||||
|
func ParseNote(data string) (NoteData, error) {
|
||||||
|
log.Printf("Parsing note: %q", data)
|
||||||
|
res := NoteData{}
|
||||||
|
lines := strings.Split(data, "\n")
|
||||||
|
note := []string{}
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
switch parts[0] {
|
||||||
|
case "p":
|
||||||
|
res.Player = parts[1]
|
||||||
|
case "d":
|
||||||
|
var err error
|
||||||
|
res.Date, err = time.Parse(time.DateOnly, parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("failed to parse date: %v", err)
|
||||||
|
}
|
||||||
|
case "g":
|
||||||
|
res.Guild = parts[1]
|
||||||
|
default:
|
||||||
|
note = append(note, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Note = strings.Join(note, "\n")
|
||||||
|
|
||||||
|
matches := associationRe.FindAllStringSubmatch(res.Note, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
res.Associations = append(res.Associations, NoteAssociationData{
|
||||||
|
Player: match[1],
|
||||||
|
Note: match[2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PersistNoteData(note NoteData) (Note, []Association, error) {
|
||||||
|
res := Note{}
|
||||||
|
ass := []Association{}
|
||||||
|
|
||||||
|
res.Content = note.Note
|
||||||
|
res.Timestamp = note.Date
|
||||||
|
guild, err := gs.GetOrCreate(note.Guild)
|
||||||
|
if err != nil {
|
||||||
|
return res, ass, fmt.Errorf("failed getting guild for %s: %v", note.Guild, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
player, err := ps.GetOrCreate(note.Player, guild)
|
||||||
|
if err != nil {
|
||||||
|
return res, ass, fmt.Errorf("failed getting player for %s: %v", note.Player, err)
|
||||||
|
}
|
||||||
|
res.Player = player
|
||||||
|
player.Guild = guild
|
||||||
|
|
||||||
|
for _, assoc := range note.Associations {
|
||||||
|
assocPlayer, err := ps.GetOrCreate(assoc.Player, guild)
|
||||||
|
if err != nil {
|
||||||
|
return res, ass, fmt.Errorf("failed getting player for %s: %v", assoc.Player, err)
|
||||||
|
}
|
||||||
|
association, err := as.Create(player, assocPlayer, assoc.Note)
|
||||||
|
if err != nil {
|
||||||
|
return res, ass, fmt.Errorf("failed creating association: %v", err)
|
||||||
|
}
|
||||||
|
ass = append(ass, association)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = ns.Create(res.Content, res.Timestamp, res.Player)
|
||||||
|
if err != nil {
|
||||||
|
return res, ass, fmt.Errorf("failed creating note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, ass, nil
|
||||||
|
}
|
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Astro Starter Kit: Basics
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template basics
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
||||||
|
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
||||||
|
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
│ └── favicon.svg
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── Card.astro
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── Layout.astro
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
7
frontend/astro.config.mjs
Normal file
7
frontend/astro.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
output: "server",
|
||||||
|
});
|
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro check && astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/check": "^0.9.4",
|
||||||
|
"astro": "^4.16.7",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.14"
|
||||||
|
}
|
||||||
|
}
|
4463
frontend/pnpm-lock.yaml
generated
Normal file
4463
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
9
frontend/public/favicon.svg
Normal file
9
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||||
|
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||||
|
<style>
|
||||||
|
path { fill: #000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #FFF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 749 B |
61
frontend/src/components/Card.astro
Normal file
61
frontend/src/components/Card.astro
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { href, title, body } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<li class="link-card">
|
||||||
|
<a href={href}>
|
||||||
|
<h2>
|
||||||
|
{title}
|
||||||
|
<span>→</span>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<style>
|
||||||
|
.link-card {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
padding: 1px;
|
||||||
|
background-color: #23262d;
|
||||||
|
background-image: none;
|
||||||
|
background-size: 400%;
|
||||||
|
border-radius: 7px;
|
||||||
|
background-position: 100%;
|
||||||
|
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.link-card > a {
|
||||||
|
width: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: calc(1.5rem - 1px);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
background-color: #23262d;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.link-card:is(:hover, :focus-within) {
|
||||||
|
background-position: 0;
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
}
|
||||||
|
.link-card:is(:hover, :focus-within) h2 {
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
}
|
||||||
|
</style>
|
1
frontend/src/env.d.ts
vendored
Normal file
1
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
52
frontend/src/layouts/Layout.astro
Normal file
52
frontend/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Astro description" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<style is:global>
|
||||||
|
:root {
|
||||||
|
--accent: 136, 58, 234;
|
||||||
|
--accent-light: 224, 204, 250;
|
||||||
|
--accent-dark: 49, 10, 101;
|
||||||
|
--accent-gradient: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
rgb(var(--accent)),
|
||||||
|
rgb(var(--accent-light)) 30%,
|
||||||
|
white 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #13151a;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family:
|
||||||
|
Menlo,
|
||||||
|
Monaco,
|
||||||
|
Lucida Console,
|
||||||
|
Liberation Mono,
|
||||||
|
DejaVu Sans Mono,
|
||||||
|
Bitstream Vera Sans Mono,
|
||||||
|
Courier New,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
</style>
|
129
frontend/src/pages/index.astro
Normal file
129
frontend/src/pages/index.astro
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import Card from '../components/Card.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Welcome to Astro.">
|
||||||
|
<main>
|
||||||
|
<svg
|
||||||
|
class="astro-a"
|
||||||
|
width="495"
|
||||||
|
height="623"
|
||||||
|
viewBox="0 0 495 623"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M167.19 364.254C83.4786 364.254 0 404.819 0 404.819C0 404.819 141.781 19.4876 142.087 18.7291C146.434 7.33701 153.027 0 162.289 0H332.441C341.703 0 348.574 7.33701 352.643 18.7291C352.92 19.5022 494.716 404.819 494.716 404.819C494.716 404.819 426.67 364.254 327.525 364.254L264.41 169.408C262.047 159.985 255.147 153.581 247.358 153.581C239.569 153.581 232.669 159.985 230.306 169.408L167.19 364.254ZM160.869 530.172C160.877 530.18 160.885 530.187 160.894 530.195L160.867 530.181C160.868 530.178 160.868 530.175 160.869 530.172ZM136.218 411.348C124.476 450.467 132.698 504.458 160.869 530.172C160.997 529.696 161.125 529.242 161.248 528.804C161.502 527.907 161.737 527.073 161.917 526.233C165.446 509.895 178.754 499.52 195.577 500.01C211.969 500.487 220.67 508.765 223.202 527.254C224.141 534.12 224.23 541.131 224.319 548.105C224.328 548.834 224.337 549.563 224.347 550.291C224.563 566.098 228.657 580.707 237.264 593.914C245.413 606.426 256.108 615.943 270.749 622.478C270.593 621.952 270.463 621.508 270.35 621.126C270.045 620.086 269.872 619.499 269.685 618.911C258.909 585.935 266.668 563.266 295.344 543.933C298.254 541.971 301.187 540.041 304.12 538.112C310.591 533.854 317.059 529.599 323.279 525.007C345.88 508.329 360.09 486.327 363.431 457.844C364.805 446.148 363.781 434.657 359.848 423.275C358.176 424.287 356.587 425.295 355.042 426.275C351.744 428.366 348.647 430.33 345.382 431.934C303.466 452.507 259.152 455.053 214.03 448.245C184.802 443.834 156.584 436.019 136.218 411.348Z"
|
||||||
|
fill="url(#paint0_linear_1805_24383)"></path>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="paint0_linear_1805_24383"
|
||||||
|
x1="247.358"
|
||||||
|
y1="0"
|
||||||
|
x2="247.358"
|
||||||
|
y2="622.479"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stop-opacity="0.9"></stop>
|
||||||
|
<stop offset="1" stop-opacity="0.2"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<h1>Welcome to <span class="text-gradient">Astro</span></h1>
|
||||||
|
<p class="instructions">
|
||||||
|
To get started, open the directory <code>src/pages</code> in your project.<br />
|
||||||
|
<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
|
||||||
|
</p>
|
||||||
|
<ul role="list" class="link-card-grid">
|
||||||
|
<Card
|
||||||
|
href="https://docs.astro.build/"
|
||||||
|
title="Documentation"
|
||||||
|
body="Learn how Astro works and explore the official API docs."
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
href="https://astro.build/integrations/"
|
||||||
|
title="Integrations"
|
||||||
|
body="Supercharge your project with new frameworks and libraries."
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
href="https://astro.build/themes/"
|
||||||
|
title="Themes"
|
||||||
|
body="Explore a galaxy of community-built starter themes."
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
href="https://astro.build/chat/"
|
||||||
|
title="Community"
|
||||||
|
body="Come say hi to our amazing Discord community. ❤️"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||||
|
<h1 class="text-4xl font-bold text-blue-600 hover:text-blue-800">
|
||||||
|
Hello Tailwind + Astro!
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
margin: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 800px;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.astro-a {
|
||||||
|
position: absolute;
|
||||||
|
top: -32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translatex(-50%);
|
||||||
|
width: 220px;
|
||||||
|
height: auto;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.text-gradient {
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-size: 400%;
|
||||||
|
background-position: 0%;
|
||||||
|
}
|
||||||
|
.instructions {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(var(--accent-light), 25%);
|
||||||
|
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.instructions code {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
background: rgba(var(--accent-light), 12%);
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3em 0.4em;
|
||||||
|
}
|
||||||
|
.instructions strong {
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
}
|
||||||
|
.link-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
19
frontend/src/pages/player.astro
Normal file
19
frontend/src/pages/player.astro
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
import type { APIResponse, Player } from "../types";
|
||||||
|
|
||||||
|
const res = await fetch("http://localhost:3000/player");
|
||||||
|
const data: APIResponse<Player[]> = await res.json();
|
||||||
|
console.log(data);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
data.success &&
|
||||||
|
data.data.map((player) => (
|
||||||
|
<div>
|
||||||
|
<a href={`/player/${player.name}`}>{player.name}</a>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
{!data.success && <div>{data.message}</div>}
|
||||||
|
</div>
|
7
frontend/src/pages/player/[name].astro
Normal file
7
frontend/src/pages/player/[name].astro
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
const { name } = Astro.params;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Hello {name}!</h1>
|
||||||
|
</div>
|
3
frontend/src/styles/global.css
Normal file
3
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
29
frontend/src/types.ts
Normal file
29
frontend/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export type Player = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
guild: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
associations: Association[];
|
||||||
|
notes: Note[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Association = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Note = {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
player: Player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type APIResponse<T> = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
3
frontend/tsconfig.json
Normal file
3
frontend/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict"
|
||||||
|
}
|
5
go.mod
5
go.mod
@@ -1,5 +0,0 @@
|
|||||||
module stinkinator
|
|
||||||
|
|
||||||
go 1.23.2
|
|
||||||
|
|
||||||
require github.com/mattn/go-sqlite3 v1.14.24
|
|
2
go.sum
2
go.sum
@@ -1,2 +0,0 @@
|
|||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
111
main.go
111
main.go
@@ -1,111 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Error *log.Logger
|
|
||||||
var Warning *log.Logger
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
|
|
||||||
logFile, err := os.Create("main.log")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error creating log file: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
logger := io.MultiWriter(os.Stdout, logFile)
|
|
||||||
log.SetOutput(logger)
|
|
||||||
|
|
||||||
Error = log.New(io.MultiWriter(logFile, os.Stderr, os.Stdout),
|
|
||||||
fmt.Sprintf("%sERROR:%s ", "\033[0;101m", "\033[0m"),
|
|
||||||
log.Lmicroseconds|log.Lshortfile)
|
|
||||||
Warning = log.New(io.MultiWriter(logFile, os.Stdout),
|
|
||||||
fmt.Sprintf("%sWarning:%s ", "\033[0;93m", "\033[0m"),
|
|
||||||
log.Lmicroseconds|log.Lshortfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
var db DB
|
|
||||||
var gs GuildService
|
|
||||||
var ps PlayerService
|
|
||||||
var ns NoteService
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
inputFile := flag.String("if", "input", "Input file")
|
|
||||||
flag.Parse()
|
|
||||||
log.Printf("Input file: %s", *inputFile)
|
|
||||||
|
|
||||||
db = DB{
|
|
||||||
path: "data/db.db",
|
|
||||||
}
|
|
||||||
err := db.Open()
|
|
||||||
if err != nil {
|
|
||||||
Error.Printf("Failed opening database: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
gs = GuildService{db: &db}
|
|
||||||
ps = PlayerService{db: &db}
|
|
||||||
ns = NoteService{db: &db}
|
|
||||||
|
|
||||||
inputData, err := os.ReadFile(*inputFile)
|
|
||||||
if err != nil {
|
|
||||||
Error.Printf("Failed reading input file: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("%#v", string(inputData))
|
|
||||||
|
|
||||||
note, err := ParseNote(string(inputData))
|
|
||||||
if err != nil {
|
|
||||||
Error.Printf("Failed parsing note: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("%#v", note)
|
|
||||||
}
|
|
||||||
|
|
||||||
var associationRe = regexp.MustCompile(`r:(\w+)(?:\(([^)]+)\))?`)
|
|
||||||
func ParseNote(data string) (NoteData, error) {
|
|
||||||
res := NoteData{}
|
|
||||||
lines := strings.Split(data, "\n")
|
|
||||||
note := []string{}
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.Split(line, ":")
|
|
||||||
switch parts[0] {
|
|
||||||
case "p":
|
|
||||||
res.Player = parts[1]
|
|
||||||
case "d":
|
|
||||||
var err error
|
|
||||||
res.Date, err = time.Parse(time.DateOnly, parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("failed to parse date: %v", err)
|
|
||||||
}
|
|
||||||
case "g":
|
|
||||||
res.Guild = parts[1]
|
|
||||||
default:
|
|
||||||
note = append(note, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Note = strings.Join(note, "\n")
|
|
||||||
|
|
||||||
matches := associationRe.FindAllStringSubmatch(res.Note, -1)
|
|
||||||
for _, match := range matches {
|
|
||||||
res.Associations = append(res.Associations, NoteAssociationData{
|
|
||||||
Player: match[1],
|
|
||||||
Note: match[2],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
|
@@ -1,75 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type PlayerService struct {
|
|
||||||
db *DB
|
|
||||||
}
|
|
||||||
type PlayerServiceQuery struct {
|
|
||||||
Name *string `db:"name"`
|
|
||||||
ID *int64 `db:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ps *PlayerService) Query(query PlayerServiceQuery) ([]Player, error) {
|
|
||||||
res := []Player{}
|
|
||||||
sqlQuery := "SELECT id, name, guild FROM player"
|
|
||||||
whereQuery := BuildWhereQuery(query)
|
|
||||||
if whereQuery != "" {
|
|
||||||
sqlQuery += " " + whereQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := ps.db.readConn.Query(sqlQuery)
|
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("failed getting players for query %q: %v", sqlQuery, err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
player := Player{}
|
|
||||||
err := rows.Scan(&player.ID, &player.Name, &player.Guild.ID)
|
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("failed scanning player: %v", err)
|
|
||||||
}
|
|
||||||
res = append(res, player)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ps *PlayerService) Create(name string, guild Guild) (Player, error) {
|
|
||||||
player := Player{}
|
|
||||||
res, err := ps.db.writeConn.Exec("insert into player (name, guild) values (?, ?)", name, guild.ID)
|
|
||||||
if err != nil {
|
|
||||||
return player, fmt.Errorf("failed to insert player: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := res.LastInsertId()
|
|
||||||
if err != nil {
|
|
||||||
return player, fmt.Errorf("failed to get last insert id: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
qres, err := ps.Query(PlayerServiceQuery{ID: &id})
|
|
||||||
if err != nil {
|
|
||||||
return player, fmt.Errorf("failed getting player for id %d: %v", id, err)
|
|
||||||
}
|
|
||||||
if len(qres) > 1 {
|
|
||||||
return player, fmt.Errorf("more than one player found for id %d", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return qres[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ps *PlayerService) GetOrCreate(name string, guild Guild) (Player, error) {
|
|
||||||
player := Player{}
|
|
||||||
players, err := ps.Query(PlayerServiceQuery{Name: &name})
|
|
||||||
if err != nil {
|
|
||||||
if len(players) > 1 {
|
|
||||||
return player, fmt.Errorf("more than one player found for name %s", name)
|
|
||||||
}
|
|
||||||
player, err = ps.Create(name, guild)
|
|
||||||
if err != nil {
|
|
||||||
return player, fmt.Errorf("failed creating player: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return player, nil
|
|
||||||
}
|
|
39
types.go
39
types.go
@@ -1,39 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type (
|
|
||||||
Guild struct {
|
|
||||||
ID int64
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
Player struct {
|
|
||||||
ID int64
|
|
||||||
Name string `json:"name"`
|
|
||||||
Guild Guild `json:"guild"`
|
|
||||||
}
|
|
||||||
Association struct {
|
|
||||||
ID int64
|
|
||||||
LHS Player
|
|
||||||
RHS Player
|
|
||||||
Note string
|
|
||||||
}
|
|
||||||
Note struct {
|
|
||||||
ID int64
|
|
||||||
Content string
|
|
||||||
Timestamp time.Time
|
|
||||||
Player Player
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type NoteData struct {
|
|
||||||
Player string
|
|
||||||
Date time.Time
|
|
||||||
Guild string
|
|
||||||
Note string
|
|
||||||
Associations []NoteAssociationData
|
|
||||||
}
|
|
||||||
type NoteAssociationData struct {
|
|
||||||
Player string
|
|
||||||
Note string
|
|
||||||
}
|
|
37
utils.go
37
utils.go
@@ -1,37 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func BuildWhereQuery(params interface{}) string {
|
|
||||||
conditions := []string{}
|
|
||||||
v := reflect.ValueOf(params)
|
|
||||||
t := v.Type()
|
|
||||||
|
|
||||||
for i := 0; i < v.NumField(); i++ {
|
|
||||||
field := v.Field(i)
|
|
||||||
if field.Kind() == reflect.Ptr && !field.IsNil() {
|
|
||||||
dbTag := t.Field(i).Tag.Get("db")
|
|
||||||
if dbTag != "" {
|
|
||||||
switch field.Elem().Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
conditions = append(conditions, fmt.Sprintf("%s = '%s'", dbTag, field.Elem().String()))
|
|
||||||
case reflect.Bool:
|
|
||||||
conditions = append(conditions, fmt.Sprintf("%s = %t", dbTag, field.Elem().Bool()))
|
|
||||||
case reflect.Int:
|
|
||||||
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
|
|
||||||
case reflect.Int64:
|
|
||||||
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
return "WHERE " + strings.Join(conditions, " AND ")
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
Reference in New Issue
Block a user