diff --git a/.gitignore b/.gitignore index c65ee56..4370588 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ sqlite.db +.env diff --git a/auth/cookies.go b/auth/cookies.go index fb69fcc..4100aef 100644 --- a/auth/cookies.go +++ b/auth/cookies.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "net/http" "time" ) @@ -28,3 +29,17 @@ func RemoveUserCookie(w http.ResponseWriter) { SameSite: http.SameSiteStrictMode, }) } + +func GetUserIdFromRequest(r *http.Request) (int64, error) { + c, err := r.Cookie("token") + if err != nil { + return -1, fmt.Errorf("no token cookie: %v", err) + } + + userId, err := ValidateUserToken(c.Value) + if err != nil { + return -1, fmt.Errorf("invalid token: %v", err) + } + + return userId, nil +} diff --git a/docs/api/games/Add game.bru b/docs/api/games/Add game.bru new file mode 100644 index 0000000..d7d2013 --- /dev/null +++ b/docs/api/games/Add game.bru @@ -0,0 +1,15 @@ +meta { + name: Add game + type: http + seq: 2 +} + +post { + url: {{base_url}}/api/user/games + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/docs/api/games/Search games.bru b/docs/api/games/Search games.bru new file mode 100644 index 0000000..e8c477d --- /dev/null +++ b/docs/api/games/Search games.bru @@ -0,0 +1,20 @@ +meta { + name: Search games + type: http + seq: 3 +} + +get { + url: {{base_url}}/api/search/games?q=no man's sky + body: none + auth: inherit +} + +params:query { + q: no man's sky +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/docs/api/games/folder.bru b/docs/api/games/folder.bru new file mode 100644 index 0000000..ff46365 --- /dev/null +++ b/docs/api/games/folder.bru @@ -0,0 +1,8 @@ +meta { + name: games + seq: 4 +} + +auth { + mode: inherit +} diff --git a/docs/api/user/folder.bru b/docs/api/user/folder.bru index 353be05..eef733a 100644 --- a/docs/api/user/folder.bru +++ b/docs/api/user/folder.bru @@ -1,6 +1,6 @@ meta { name: user - seq: 2 + seq: 3 } auth { diff --git a/go.mod b/go.mod index ccca291..40ed445 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,19 @@ module game-wishlist go 1.25.7 require ( - github.com/google/uuid v1.6.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.48.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/PuerkitoBio/goquery v1.11.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + golang.org/x/net v0.50.0 // indirect golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index c3b395a..eaf2168 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,88 @@ +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= diff --git a/main.go b/main.go index bc3ec19..2779b5d 100644 --- a/main.go +++ b/main.go @@ -9,8 +9,12 @@ import ( "log" "net/http" "os" + "strconv" + "strings" "time" + "github.com/PuerkitoBio/goquery" + "github.com/joho/godotenv" "golang.org/x/crypto/bcrypt" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -38,6 +42,8 @@ func sendJSON(w http.ResponseWriter, data any, status int) { } func main() { + godotenv.Load() + dbPath := os.Getenv("DB_PATH") if dbPath == "" { dbPath = "./sqlite.db" @@ -48,7 +54,7 @@ func main() { log.Fatalf("failed to connect to db: %v\n", err) } - if err := db.AutoMigrate(model.User{}); err != nil { + if err := db.AutoMigrate(model.User{}, model.Game{}); err != nil { log.Fatalf("failed to automigrate db: %v\n", err) } @@ -152,20 +158,14 @@ func main() { }) mux.HandleFunc("GET /api/user", func(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie("token") - if err != nil { - sendError(w, "no token cookie", err, 401) - return - } - - userId, err := auth.ValidateUserToken(c.Value) + userId, err := auth.GetUserIdFromRequest(r) if err != nil { sendError(w, "invalid token", err, 401) return } user := &model.User{} - if tx := db.First(user, "id = ?", userId); tx.Error != nil { + if tx := db.Preload("Games").First(user, "id = ?", userId); tx.Error != nil { sendError(w, "user not found", err, 404) return } @@ -173,6 +173,115 @@ func main() { sendJSON(w, user, 200) }) + mux.HandleFunc("GET /api/search/games", func(w http.ResponseWriter, r *http.Request) { + req, err := http.NewRequest("GET", "https://store.steampowered.com/search/", nil) + if err != nil { + sendError(w, "failed to construct api request", err, 500) + return + } + + q := req.URL.Query() + q.Set("term", r.URL.Query().Get("q")) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + sendError(w, "failed to query api", err, 500) + return + } + defer resp.Body.Close() + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + sendError(w, "failed to parse html", err, 500) + return + } + + type game struct { + Name string `json:"name"` + SteamAppID int `json:"steamAppId"` + Image string `json:"image"` + ReleaseDate string `json:"releaseDate"` + } + + games := []game{} + doc.Find(`#search_resultsRows > a`).Each(func(i int, s *goquery.Selection) { + name := s.Find(".search_name > .title").Text() + appId := s.AttrOr("data-ds-appid", "") + if appId == "" { + return + } + + id, err := strconv.Atoi(appId) + if err != nil { + return + } + + image := s.Find(".search_capsule > img").AttrOr("src", "") + if image == "" { + return + } + + releaseDate := s.Find(".search_released").Text() + + games = append(games, game{ + Name: name, + SteamAppID: id, + Image: image, + ReleaseDate: strings.TrimSpace(releaseDate), + }) + }) + + sendJSON(w, games, 200) + }) + + mux.HandleFunc("POST /api/user/games", func(w http.ResponseWriter, r *http.Request) { + userId, err := auth.GetUserIdFromRequest(r) + if err != nil { + sendError(w, "unauthorized", err, 401) + return + } + + user := &model.User{} + if tx := db.Preload("Games").First(user, "id = ?", userId); tx.Error != nil { + sendError(w, "user not found", err, 404) + return + } + + var body struct { + SteamAppID int `json:"steamAppId"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sendError(w, "invalid request", err, 400) + return + } + + if body.SteamAppID == 0 { + sendError(w, "steam appid is required", nil, 400) + return + } + + game, err := getSteamGameDetails(body.SteamAppID) + if err != nil { + sendError(w, "steam game not found", err, 404) + return + } + + if tx := db.Create(game); tx.Error != nil { + sendError(w, "failed to create game", err, 400) + return + } + + user.Games = append(user.Games, *game) + + if tx := db.Save(user); tx.Error != nil { + sendError(w, "failed to add game to user object", tx.Error, 500) + return + } + + sendJSON(w, user, 200) + }) + log.Print("starting http server on http://localhost:5000") if err := http.ListenAndServe(":5000", mux); err != nil { log.Fatalf("failed to start http server: %v\n", err) diff --git a/model/game.go b/model/game.go new file mode 100644 index 0000000..405f5e4 --- /dev/null +++ b/model/game.go @@ -0,0 +1,10 @@ +package model + +type Game struct { + Model + + Name string `json:"name"` + SteamAppID int `json:"steamAppId"` + Image string `json:"image"` + ReleaseDate string `json:"releaseDate"` +} diff --git a/model/user.go b/model/user.go index 812eca9..1c0a6b5 100644 --- a/model/user.go +++ b/model/user.go @@ -5,4 +5,5 @@ type User struct { Login string `json:"login" gorm:"unique"` Password string `json:"-"` + Games []Game `json:"games" gorm:"many2many:user_games"` } diff --git a/steam.go b/steam.go new file mode 100644 index 0000000..14c83c7 --- /dev/null +++ b/steam.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "fmt" + "game-wishlist/model" + "net/http" + "net/url" + "strconv" +) + +type SteamAppDetails = map[int]struct { + Success bool `json:"success"` + Data struct { + Type string `json:"type"` + Name string `json:"name"` + SteamAppId int `json:"steam_appid"` + HeaderImage string `json:"header_image"` + ReleaseDate struct { + Date string `json:"date"` + } `json:"release_date"` + } `json:"data"` +} + +func getSteamGameDetails(appId int) (*model.Game, error) { + req, err := http.NewRequest("GET", "https://store.steampowered.com/api/appdetails", nil) + if err != nil { + return nil, fmt.Errorf("failed to construct request for game details: %v", err) + } + + q := make(url.Values) + q.Set("appids", strconv.Itoa(appId)) + + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch game details: %v", err) + } + defer resp.Body.Close() + + appDetails := SteamAppDetails{} + if err := json.NewDecoder(resp.Body).Decode(&appDetails); err != nil { + return nil, fmt.Errorf("failed to decode game details response: %v", err) + } + + gameDetails, ok := appDetails[appId] + if !ok { + return nil, fmt.Errorf("game not found") + } + + return &model.Game{ + Name: gameDetails.Data.Name, + SteamAppID: gameDetails.Data.SteamAppId, + Image: gameDetails.Data.HeaderImage, + ReleaseDate: gameDetails.Data.ReleaseDate.Date, + }, nil +} diff --git a/web/package.json b/web/package.json index 65d745a..11907a3 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "^1.2.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.21", "react": "^19.2.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 69bbb61..02db8de 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@base-ui/react': + specifier: ^1.2.0 + version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -140,6 +143,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -152,6 +159,27 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.2.0': + resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui/utils@0.2.5': + resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -346,6 +374,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1156,6 +1199,9 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1197,6 +1243,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} @@ -1242,6 +1291,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1398,6 +1452,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -1421,6 +1477,30 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.4.0 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@floating-ui/utils': 0.2.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -1545,6 +1625,23 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.10': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2295,6 +2392,8 @@ snapshots: react@19.2.4: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} rollup@4.57.1: @@ -2348,6 +2447,8 @@ snapshots: dependencies: has-flag: 4.0.0 + tabbable@6.4.0: {} + tailwindcss@4.1.18: {} tapable@2.3.0: {} @@ -2390,6 +2491,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.3 diff --git a/web/src/App.tsx b/web/src/App.tsx index b34d820..d937fbd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { AuthModal } from "./components/AuthModal"; import { useUserQuery } from "./api/user"; import { Header } from "./components/Header"; +import { AddGameForm } from "./components/AddGameForm"; export default function App() { const { data: user, isFetched } = useUserQuery(); @@ -34,6 +35,16 @@ export default function App() { )}
+ + +
+ {user?.games.map((game) => ( +
+ + {game.name} +
+ ))} +
); } diff --git a/web/src/api/games.ts b/web/src/api/games.ts new file mode 100644 index 0000000..fe28484 --- /dev/null +++ b/web/src/api/games.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; + +export type SearchGame = { + name: string; + steamAppId: number; + image: string; + releaseDate: string; +}; + +export const useSearchGamesQuery = (params: { search: string }) => { + return useQuery({ + queryKey: ["search-games", params], + queryFn: async () => { + const q = new URLSearchParams(); + q.set("q", params.search); + + const resp = await fetch(`/api/search/games?${q.toString()}`); + return (await resp.json()) as SearchGame[]; + }, + }); +}; + +export const useAddGameMutation = () => { + return useMutation({ + mutationFn: async (data: { steamAppId: number }) => { + const resp = await fetch("/api/user/games", { + method: "POST", + body: JSON.stringify(data), + }); + return await resp.json(); + }, + }); +}; diff --git a/web/src/api/user.ts b/web/src/api/user.ts index 506f92d..ca1ff3b 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,8 +1,20 @@ import { useQuery } from "@tanstack/react-query"; +export type Game = { + id: number; + name: string; + steamAppId: number; + releaseDate: string; + image: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +}; + export type User = { id: number; login: string; + games: Game[]; createdAt: string; updatedAt: string; deletedAt: string | null; diff --git a/web/src/components/AddGameForm.tsx b/web/src/components/AddGameForm.tsx new file mode 100644 index 0000000..7da6ac4 --- /dev/null +++ b/web/src/components/AddGameForm.tsx @@ -0,0 +1,81 @@ +import { Autocomplete } from "@base-ui/react"; +import { Controller, useForm } from "react-hook-form"; +import { + useAddGameMutation, + useSearchGamesQuery, + type SearchGame, +} from "../api/games"; +import { useQueryClient } from "@tanstack/react-query"; + +export const AddGameForm = () => { + const queryClient = useQueryClient(); + const mutation = useAddGameMutation(); + + const form = useForm<{ appId: string; query: string }>({ + defaultValues: { + appId: "", + query: "", + }, + }); + + const query = form.watch("query"); + const { data: games } = useSearchGamesQuery({ search: query }); + + const onSubmit = form.handleSubmit((data) => { + if (!data.appId) return; + mutation.mutate( + { + steamAppId: parseInt(data.appId), + }, + { + onSuccess() { + form.reset(); + queryClient.invalidateQueries({ queryKey: ["user"] }); + }, + }, + ); + }); + + return ( +
+ ( + + form.setValue("query", e.target.value)} + /> + + + + + No games found + + {(game: SearchGame, i) => ( + + {game.name} + + )} + + + + + + )} + /> + + + ); +};