diff --git a/.all-contributorsrc b/.all-contributorsrc index 01de750d..51c74d2d 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -54,6 +54,33 @@ "contributions": [ "code" ] + }, + { + "login": "ptisserand", + "name": "ptisserand", + "avatar_url": "https://avatars.githubusercontent.com/u/544314?v=4", + "profile": "https://github.com/ptisserand", + "contributions": [ + "code" + ] + }, + { + "login": "mubarak23", + "name": "Mubarak Muhammad Aminu", + "avatar_url": "https://avatars.githubusercontent.com/u/7858376?v=4", + "profile": "http://mubarak23.github.io/", + "contributions": [ + "code" + ] + }, + { + "login": "thomas192", + "name": "0xK2", + "avatar_url": "https://avatars.githubusercontent.com/u/65908739?v=4", + "profile": "https://github.com/thomas192", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 023cbd3f..727a898e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ docker compose build ```bash # Must install all the dependencies first +# Use npm install inside the `frontend` directory # Change the user on `configs/database.config.json` for postgres make integration-test-local ``` @@ -112,6 +113,11 @@ Thanks goes to these wonderful people. Follow the [contributors guide](https://g Tristan
Tristan

💻 Abdulhakeem Abdulazeez Ayodeji
Abdulhakeem Abdulazeez Ayodeji

💻 Trunks @ Carbonable
Trunks @ Carbonable

💻 + ptisserand
ptisserand

💻 + Mubarak Muhammad Aminu
Mubarak Muhammad Aminu

💻 + + + 0xK2
0xK2

💻 diff --git a/backend/config/backend.go b/backend/config/backend.go index 4b9d8954..61921d11 100644 --- a/backend/config/backend.go +++ b/backend/config/backend.go @@ -13,9 +13,10 @@ type BackendScriptsConfig struct { } type BackendConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Scripts BackendScriptsConfig `json:"scripts"` + Host string `json:"host"` + Port int `json:"port"` + Scripts BackendScriptsConfig `json:"scripts"` + Production bool `json:"production"` } var DefaultBackendConfig = BackendConfig{ @@ -27,6 +28,7 @@ var DefaultBackendConfig = BackendConfig{ AddTemplateDevnet: "../scripts/add_template.sh", MintNFTDevnet: "../scripts/mint_nft.sh", }, + Production: false, } var DefaultBackendConfigPath = "../configs/backend.config.json" diff --git a/backend/core/backend.go b/backend/core/backend.go index 9625ded8..d0072b9f 100644 --- a/backend/core/backend.go +++ b/backend/core/backend.go @@ -10,7 +10,8 @@ import ( ) type Backend struct { - Databases *Databases + Databases *Databases + // TODO: Is this thread safe? WSConnections []*websocket.Conn CanvasConfig *config.CanvasConfig diff --git a/backend/core/databases.go b/backend/core/databases.go index 2f224066..5f248dea 100644 --- a/backend/core/databases.go +++ b/backend/core/databases.go @@ -31,6 +31,7 @@ func NewDatabases(databaseConfig *config.DatabaseConfig) *Databases { // Connect to Postgres postgresConnString := "postgresql://" + databaseConfig.Postgres.User + ":" + os.Getenv("POSTGRES_PASSWORD") + "@" + databaseConfig.Postgres.Host + ":" + strconv.Itoa(databaseConfig.Postgres.Port) + "/" + databaseConfig.Postgres.Database + // TODO: crd_audit?sslmode=disable pgPool, err := pgxpool.New(context.Background(), postgresConnString) if err != nil { panic(err) diff --git a/backend/main.go b/backend/main.go index 1b03b991..8be1a9cd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -8,10 +8,22 @@ import ( "github.com/keep-starknet-strange/art-peace/backend/routes" ) +func isFlagSet(name string) bool { + found := false + flag.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + func main() { canvasConfigFilename := flag.String("canvas-config", config.DefaultCanvasConfigPath, "Canvas config file") databaseConfigFilename := flag.String("database-config", config.DefaultDatabaseConfigPath, "Database config file") backendConfigFilename := flag.String("backend-config", config.DefaultBackendConfigPath, "Backend config file") + production := flag.Bool("production", false, "Production mode") + flag.Parse() canvasConfig, err := config.LoadCanvasConfig(*canvasConfigFilename) @@ -29,11 +41,16 @@ func main() { panic(err) } + if isFlagSet("production") { + backendConfig.Production = *production + } + databases := core.NewDatabases(databaseConfig) defer databases.Close() + core.ArtPeaceBackend = core.NewBackend(databases, canvasConfig, backendConfig) + routes.InitRoutes() - core.ArtPeaceBackend = core.NewBackend(databases, canvasConfig, backendConfig) core.ArtPeaceBackend.Start() } diff --git a/backend/routes/canvas.go b/backend/routes/canvas.go index 22e8a2f0..68604970 100644 --- a/backend/routes/canvas.go +++ b/backend/routes/canvas.go @@ -14,22 +14,26 @@ func InitCanvasRoutes() { } func initCanvas(w http.ResponseWriter, r *http.Request) { - // TODO: Check if canvas already exists - totalBitSize := core.ArtPeaceBackend.CanvasConfig.Canvas.Width * core.ArtPeaceBackend.CanvasConfig.Canvas.Height * core.ArtPeaceBackend.CanvasConfig.ColorsBitWidth - totalByteSize := (totalBitSize / 8) - if totalBitSize%8 != 0 { - // Round up to nearest byte - totalByteSize += 1 + if core.ArtPeaceBackend.Databases.Redis.Exists(context.Background(), "canvas").Val() == 0 { + totalBitSize := core.ArtPeaceBackend.CanvasConfig.Canvas.Width * core.ArtPeaceBackend.CanvasConfig.Canvas.Height * core.ArtPeaceBackend.CanvasConfig.ColorsBitWidth + totalByteSize := (totalBitSize / 8) + if totalBitSize%8 != 0 { + // Round up to nearest byte + totalByteSize += 1 + } + + // Create canvas + canvas := make([]byte, totalByteSize) + ctx := context.Background() + err := core.ArtPeaceBackend.Databases.Redis.Set(ctx, "canvas", canvas, 0).Err() + if err != nil { + panic(err) + } + + fmt.Println("Canvas initialized") + } else { + fmt.Println("Canvas already initialized") } - - canvas := make([]byte, totalByteSize) - ctx := context.Background() - err := core.ArtPeaceBackend.Databases.Redis.Set(ctx, "canvas", canvas, 0).Err() - if err != nil { - panic(err) - } - - fmt.Println("Canvas initialized") } func getCanvas(w http.ResponseWriter, r *http.Request) { diff --git a/backend/routes/colors.go b/backend/routes/colors.go new file mode 100644 index 00000000..3fc0df20 --- /dev/null +++ b/backend/routes/colors.go @@ -0,0 +1,127 @@ +package routes + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/jackc/pgx/v5" + "github.com/keep-starknet-strange/art-peace/backend/core" +) + +type Colors struct { + Hex string `json:"hex"` +} + +func InitColorsRoutes() { + http.HandleFunc("/get-colors", GetAllColors) + http.HandleFunc("/get-color", GetSingleColor) + http.HandleFunc("/init-colors", InitColors) +} + +func GetAllColors(w http.ResponseWriter, r *http.Request) { + + var colors []Colors + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), "SELECT hex FROM colors") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + defer rows.Close() + + for rows.Next() { + var c Colors + err := rows.Scan(&c.Hex) + if err != nil { + log.Fatalf("Scan failed: %v\n", err) + } + colors = append(colors, c) + } + if err := rows.Err(); err != nil { + log.Fatalf("Error retrieving data: %v\n", err) + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + out, err := json.Marshal(colors) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte(out)) +} + +func GetSingleColor(w http.ResponseWriter, r *http.Request) { + + colorKey := r.URL.Query().Get("id") + if colorKey == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("ID not provided")) + return + } + + var c Colors + row := core.ArtPeaceBackend.Databases.Postgres.QueryRow(context.Background(), "SELECT hex FROM colors WHERE key = $1", colorKey) + err := row.Scan(&c.Hex) + if err != nil { + if err == pgx.ErrNoRows { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Color not found")) + } else { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + } + return + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + out, err := json.Marshal(c) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte(out)) +} + +func InitColors(w http.ResponseWriter, r *http.Request) { + // TODO: Add authentication and/or check if colors already exist + reqBody, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return + } + + var colors []string + err = json.Unmarshal(reqBody, &colors) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return + } + + for _, color := range colors { + _, err = core.ArtPeaceBackend.Databases.Postgres.Exec(context.Background(), "INSERT INTO colors (hex) VALUES ($1)", color) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Colors initialized")) + fmt.Println("Colors initialized") +} diff --git a/backend/routes/indexer.go b/backend/routes/indexer.go index 824e8be2..77ef66f1 100644 --- a/backend/routes/indexer.go +++ b/backend/routes/indexer.go @@ -67,6 +67,7 @@ const ( ) // TODO: User might miss some messages between loading canvas and connecting to websocket? +// TODO: Check thread safety of these things func consumeIndexerMsg(w http.ResponseWriter, r *http.Request) { requestBody, err := io.ReadAll(r.Body) if err != nil { diff --git a/backend/routes/pixel.go b/backend/routes/pixel.go index 4a7ec789..74584de7 100644 --- a/backend/routes/pixel.go +++ b/backend/routes/pixel.go @@ -16,8 +16,10 @@ import ( func InitPixelRoutes() { http.HandleFunc("/getPixel", getPixel) http.HandleFunc("/getPixelInfo", getPixelInfo) - http.HandleFunc("/placePixelDevnet", placePixelDevnet) - http.HandleFunc("/placeExtraPixelsDevnet", placeExtraPixelsDevnet) + if !core.ArtPeaceBackend.BackendConfig.Production { + http.HandleFunc("/placePixelDevnet", placePixelDevnet) + http.HandleFunc("/placeExtraPixelsDevnet", placeExtraPixelsDevnet) + } http.HandleFunc("/placePixelRedis", placePixelRedis) } @@ -56,6 +58,12 @@ func getPixelInfo(w http.ResponseWriter, r *http.Request) { } func placePixelDevnet(w http.ResponseWriter, r *http.Request) { + // Disable this in production + if core.ArtPeaceBackend.BackendConfig.Production { + http.Error(w, "Not available in production", http.StatusNotImplemented) + return + } + reqBody, err := io.ReadAll(r.Body) if err != nil { panic(err) diff --git a/backend/routes/quests.go b/backend/routes/quests.go new file mode 100644 index 00000000..50afea33 --- /dev/null +++ b/backend/routes/quests.go @@ -0,0 +1,76 @@ +package routes + +import ( + "context" + "encoding/json" + "log" + "net/http" + + "github.com/keep-starknet-strange/art-peace/backend/core" +) + +// the Quest struct will represent the structure for both Daily and Main Quests data +type Quest struct { + Key int `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Reward int `json:"reward"` + DayIndex int `json:"dayIndex,omitempty"` // Only for daily quests +} + +func InitQuestsRoutes() { + http.HandleFunc("/getDailyQuests", GetDailyQuests) + http.HandleFunc("/getMainQuests", GetMainQuests) +} + +// Query dailyQuests +func GetDailyQuests(w http.ResponseWriter, r *http.Request) { + query := `SELECT key, name, description, reward, dayIndex FROM DailyQuests ORDER BY dayIndex ASC` + handleQuestQuery(w, r, query) +} + +// Query mainQuest +func GetMainQuests(w http.ResponseWriter, r *http.Request) { + query := `SELECT key, name, description, reward FROM MainQuests` + handleQuestQuery(w, r, query) +} + +func handleQuestQuery(w http.ResponseWriter, r *http.Request, query string) { + var quests []Quest + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), query) + if err != nil { + http.Error(w, "Database query failed: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var q Quest + if err := rows.Scan(&q.Key, &q.Name, &q.Description, &q.Reward, &q.DayIndex); err != nil { + log.Printf("Error scanning row: %v", err) + continue // Log and continue to process other rows + } + quests = append(quests, q) + } + if err := rows.Err(); err != nil { + log.Printf("Error during rows iteration: %v", err) + http.Error(w, "Error processing data: "+err.Error(), http.StatusInternalServerError) + return + } + + setupCORS(&w, r) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(quests); err != nil { + http.Error(w, "Error encoding response: "+err.Error(), http.StatusInternalServerError) + } +} + +// CORS setup +func setupCORS(w *http.ResponseWriter, r *http.Request) { + (*w).Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == "OPTIONS" { + (*w).Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + (*w).Header().Set("Access-Control-Allow-Headers", "Content-Type") + (*w).WriteHeader(http.StatusOK) + } +} diff --git a/backend/routes/routes.go b/backend/routes/routes.go index a45dfd71..4dedcc93 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -9,4 +9,6 @@ func InitRoutes() { InitUserRoutes() InitContractRoutes() InitNFTRoutes() + InitQuestsRoutes() + InitColorsRoutes() } diff --git a/backend/routes/templates.go b/backend/routes/templates.go index 80a86fac..1b0031e7 100644 --- a/backend/routes/templates.go +++ b/backend/routes/templates.go @@ -25,8 +25,10 @@ func InitTemplateRoutes() { http.HandleFunc("/get-templates", getTemplates) http.HandleFunc("/addTemplateImg", addTemplateImg) http.HandleFunc("/add-template-data", addTemplateData) - http.HandleFunc("/add-template-devnet", addTemplateDevnet) http.Handle("/templates/", http.StripPrefix("/templates/", http.FileServer(http.Dir(".")))) + if !core.ArtPeaceBackend.BackendConfig.Production { + http.HandleFunc("/add-template-devnet", addTemplateDevnet) + } } // TODO: Add specific location for template images @@ -91,8 +93,6 @@ func getTemplates(w http.ResponseWriter, r *http.Request) { } func addTemplateImg(w http.ResponseWriter, r *http.Request) { - // TODO: Limit file size / proportions between 5x5 and 64x64 - // Passed like this curl -F "image=@art-peace-low-res-goose.jpg" http://localhost:8080/addTemplateImg file, _, err := r.FormFile("image") if err != nil { panic(err) @@ -107,6 +107,19 @@ func addTemplateImg(w http.ResponseWriter, r *http.Request) { // } // defer tempFile.Close() + // Decode the image to check dimensions + img, format, err := image.Decode(file) + if err != nil { + http.Error(w, "Failed to decode the image: "+err.Error()+" - format: "+format, http.StatusBadRequest) + return + } + bounds := img.Bounds() + width, height := bounds.Max.X-bounds.Min.X, bounds.Max.Y-bounds.Min.Y + if width < 5 || width > 50 || height < 5 || height > 50 { + http.Error(w, fmt.Sprintf("Image dimensions out of allowed range (5x5 to 50x50). Uploaded image size: %dx%d", width, height), http.StatusBadRequest) + return + } + // Read all data from the uploaded file and write it to the temporary file fileBytes, err := ioutil.ReadAll(file) if err != nil { @@ -218,7 +231,12 @@ func addTemplateData(w http.ResponseWriter, r *http.Request) { } func addTemplateDevnet(w http.ResponseWriter, r *http.Request) { - // TODO: Disable this in production + // Disable this in production + if core.ArtPeaceBackend.BackendConfig.Production { + http.Error(w, "Not available in production", http.StatusNotImplemented) + return + } + reqBody, err := io.ReadAll(r.Body) if err != nil { panic(err) diff --git a/backend/routes/user.go b/backend/routes/user.go index f03d018a..6ff302a4 100644 --- a/backend/routes/user.go +++ b/backend/routes/user.go @@ -9,6 +9,7 @@ import ( func InitUserRoutes() { http.HandleFunc("/getExtraPixels", getExtraPixels) + http.HandleFunc("/getUsername", getUsername) } func getExtraPixels(w http.ResponseWriter, r *http.Request) { @@ -26,3 +27,19 @@ func getExtraPixels(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(available)) } + +func getUsername(w http.ResponseWriter, r *http.Request) { + address := r.URL.Query().Get("address") + + var name string + err := core.ArtPeaceBackend.Databases.Postgres.QueryRow(context.Background(), "SELECT name FROM Users WHERE address = $1", address).Scan(&name) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + w.Write([]byte(name)) +} diff --git a/configs/backend.config.json b/configs/backend.config.json index da6dbf6e..2273a20a 100644 --- a/configs/backend.config.json +++ b/configs/backend.config.json @@ -5,5 +5,6 @@ "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", "place_extra_pixels_devnet": "../tests/integration/local/place_extra_pixels.sh", "add_template_hash_devnet": "../tests/integration/local/add_template_hash.sh" - } + }, + "production": false } diff --git a/configs/docker-backend.config.json b/configs/docker-backend.config.json index 8b3877f8..bc35db6b 100644 --- a/configs/docker-backend.config.json +++ b/configs/docker-backend.config.json @@ -6,5 +6,6 @@ "place_extra_pixels_devnet": "/scripts/place_extra_pixels.sh", "add_template_devnet": "/scripts/add_template.sh", "mint_nft_devnet": "/scripts/mint_nft.sh" - } + }, + "production": false } diff --git a/docker-compose.yml b/docker-compose.yml index 0cfecfed..4e9e2252 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,7 +54,7 @@ services: environment: - SCARB=/root/.local/bin/scarb volumes: - - deployment:/deployment + - configs:/configs apibara: image: quay.io/apibara/starknet:1.5.0 command: @@ -88,8 +88,11 @@ services: links: - backend - apibara + environment: + - APIBARA_STREAM_URL=http://art-peace-apibara-1:7171 + - BACKEND_TARGET_URL=http://art-peace-backend-1:8080/consumeIndexerMsg volumes: - - deployment:/deployment + - configs:/configs restart: on-failure frontend: build: @@ -104,7 +107,7 @@ services: - backend - devnet volumes: - - deployment:/deployment + - configs:/configs - ./frontend/package.json:/app/package.json - ./frontend/package-lock.json:/app/package-lock.json - ./frontend/public/:/app/public @@ -115,4 +118,4 @@ volumes: postgres: devnet: apibara: - deployment: + configs: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7634e38..f8ea36ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "d3": "^7.9.0", "get-starknet": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -3854,22 +3855,31 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz", + "integrity": "sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==", "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", + "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/@testing-library/jest-dom": { @@ -6639,6 +6649,384 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6826,6 +7214,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -9304,6 +9700,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", @@ -15131,6 +15535,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, "node_modules/rollup": { "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", @@ -15203,6 +15612,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6b85ccc2..16758294 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "d3": "^7.9.0", "get-starknet": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/App.js b/frontend/src/App.js index 913412d8..40a00d8b 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useMediaQuery } from 'react-responsive' import './App.css'; import Canvas from './canvas/Canvas.js'; @@ -114,6 +114,32 @@ function App() { // NFTs const [nftSelectionMode, setNftSelectionMode] = useState(false); + // Timing + const [timeLeftInDay, setTimeLeftInDay] = useState(''); + const startTime = "15:00"; + const [hours, minutes] = startTime.split(":"); + + + useEffect(() => { + const calculateTimeLeft = () => { + const now = new Date(); + const nextDayStart = new Date(now); + nextDayStart.setDate(now.getDate() + 1); + nextDayStart.setUTCHours(parseInt(hours), parseInt(minutes), 0, 0); + + const difference = nextDayStart - now; + const hoursFinal = Math.floor((difference / (1000 * 60 * 60)) % 24); + const minutesFinal = Math.floor((difference / 1000 / 60) % 60); + const secondsFinal = Math.floor((difference / 1000) % 60); + + const formattedTimeLeft = `${hoursFinal.toString().padStart(2, '0')}:${minutesFinal.toString().padStart(2, '0')}:${secondsFinal.toString().padStart(2, '0')}`; + setTimeLeftInDay(formattedTimeLeft); + }; + + const interval = setInterval(calculateTimeLeft, 1000); + return () => clearInterval(interval); + }) + // Tabs const tabs = ['Canvas', 'Quests', 'Vote', 'Templates', 'NFTs', 'Account']; const [activeTab, setActiveTab] = useState(tabs[0]); @@ -134,7 +160,7 @@ function App() { { (templateCreationMode || templatePlacedMode) && ( )} - +
diff --git a/frontend/src/canvas/Canvas.js b/frontend/src/canvas/Canvas.js index b8133f9d..e29ae923 100644 --- a/frontend/src/canvas/Canvas.js +++ b/frontend/src/canvas/Canvas.js @@ -1,108 +1,120 @@ + import React, { useCallback, useRef, useEffect, useState } from 'react' +import { select, zoom, zoomIdentity } from "d3" import useWebSocket, { ReadyState } from 'react-use-websocket' import './Canvas.css'; // import TemplateOverlay from './TemplateOverlay.js'; -import canvasConfig from "../configs/canvas.config.json" -import backendConfig from "../configs/backend.config.json" +import canvasConfig from "../configs/canvas.config.json"; +import backendConfig from "../configs/backend.config.json"; + const Canvas = props => { const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port - // TODO: Pressing "Canvas" resets the view / positioning + //TODO: Pressing "Canvas" resets the view / positioning + //TODO: Way to configure tick rates to give smooth xp for all users + + //Todo: Make this dynamic + const minScale = 1; + const maxScale = 40; + + + const canvasRef = useRef(null) + const canvasPositionRef = useRef(null) + const canvasScaleRef = useRef(null) - const [canvasPositionX, setCanvasPositionX] = useState(0) - const [canvasPositionY, setCanvasPositionY] = useState(0) - const [isDragging, setIsDragging] = useState(false) - const [dragStartX, setDragStartX] = useState(0) - const [dragStartY, setDragStartY] = useState(0) - const [canvasScale, setCanvasScale] = useState(6) - const minScale = 1 // TODO: To config - const maxScale = 40 - //TODO: Way to configure tick rates to give smooth xp for all users - // Read canvas config from environment variable file json - const width = canvasConfig.canvas.width - const height = canvasConfig.canvas.height - const colors = canvasConfig.colors + const width = canvasConfig.canvas.width; + const height = canvasConfig.canvas.height; + const staticColors = canvasConfig.colors; + + const [colors, setColors] = useState([]); + const [setupColors, setSetupColors] = useState(false); - const WS_URL = "ws://" + backendConfig.host + ":" + backendConfig.port + "/ws" + useEffect(() => { + if (setupColors) { + return; + } + let getColorsEndpoint = backendUrl + "/get-colors"; + fetch(getColorsEndpoint, { mode: "cors" }).then((response) => { + response.json().then((data) => { + let colors = []; + for (let i = 0; i < data.length; i++) { + colors.push(data[i].hex); + } + setColors(colors); + setSetupColors(true); + }).catch((error) => { + setColors(staticColors); + setSetupColors(true); + console.error(error); + }); + }); + // TODO: Return a cleanup function to close the websocket / ... + }, [colors, backendUrl, staticColors, setupColors, setColors]); + + const WS_URL = + "ws://" + backendConfig.host + ":" + backendConfig.port + "/ws"; const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket( WS_URL, { share: false, shouldReconnect: () => true, + }, ) - - // TODO: Weird positioning behavior when clicking into devtools - - // Handle wheel event for zooming - const handleWheel = (e) => { - let newScale = canvasScale - if (e.deltaY < 0) { - newScale = Math.min(maxScale, newScale + 0.2) - } else { - newScale = Math.max(minScale, newScale - 0.2) - } - // TODO: Smart positioning of canvas zoom ( zoom to center of mouse pointer ) - //let newCanvasPositionX = canvasPositionX - //let newCanvasPositionY = canvasPositionY - //const canvasOriginX = canvasPositionX + width / 2 - //const canvasOriginY = canvasPositionY + height / 2 - //setCanvasPositionX(newCanvasPositionX) - //setCanvasPositionY(newCanvasPositionY) - - setCanvasScale(newScale) - } - - const handlePointerDown = (e) => { - setIsDragging(true) - setDragStartX(e.clientX) - setDragStartY(e.clientY) - } - - const handlePointerUp = () => { - setIsDragging(false) - setDragStartX(0) - setDragStartY(0) - } - - const handlePointerMove = (e) => { - if (isDragging) { - // TODO: Prevent dragging outside of canvas container - setCanvasPositionX(canvasPositionX + e.clientX - dragStartX) - setCanvasPositionY(canvasPositionY + e.clientY - dragStartY) - setDragStartX(e.clientX) - setDragStartY(e.clientY) - } - } + // TODO: Weird positioning behavior when clicking into devtools useEffect(() => { - document.addEventListener('pointerup', handlePointerUp) + const canvas = select(canvasPositionRef.current) + const Dzoom = zoom().scaleExtent([minScale, maxScale]).on("zoom", zoomHandler) + + // Set default zoom level and center the canvas + canvas + .call(Dzoom) + .call(Dzoom.transform, zoomIdentity.translate(0, 0).scale(4)) return () => { - document.removeEventListener('pointerup', handlePointerUp) - } - }, []) + canvas.on(".zoom", null); // Clean up zoom event listeners + }; + }, []); + + const zoomHandler = (event) => { + const ele = canvasScaleRef.current + const { + k: newScale, + x: newCanvasPositionX, + y: newCanvasPositionY, + } = event.transform; + const transformValue = `translate(${newCanvasPositionX}px, ${newCanvasPositionY}px) scale(${newScale})` + ele.style.transform = transformValue + } const [setup, setSetup] = useState(false) - const draw = useCallback((ctx, imageData) => { - ctx.canvas.width = width - ctx.canvas.height = height - ctx.putImageData(imageData, 0, 0) - // TODO: Use image-rendering for supported browsers? - }, [width, height]) + + const draw = useCallback( + (ctx, imageData) => { + ctx.canvas.width = width; + ctx.canvas.height = height; + ctx.putImageData(imageData, 0, 0); + // TODO: Use image-rendering for supported browsers? + }, + [width, height] + ); useEffect(() => { + if (!setupColors) { + return; + } if (setup) { - return + return; } const canvas = props.canvasRef.current const context = canvas.getContext('2d') let getCanvasEndpoint = backendUrl + "/getCanvas" - fetch(getCanvasEndpoint, {mode: 'cors'}).then(response => { + fetch(getCanvasEndpoint, { mode: 'cors' }).then(response => { return response.arrayBuffer() }).then(data => { let colorData = new Uint8Array(data, 0, data.byteLength) @@ -124,32 +136,42 @@ const Canvas = props => { dataArray.push(value) } } - let imageDataArray = [] + let imageDataArray = []; for (let i = 0; i < dataArray.length; i++) { - const color = "#" + colors[dataArray[i]] + "FF" - const [r, g, b, a] = color.match(/\w\w/g).map(x => parseInt(x, 16)) - imageDataArray.push(r, g, b, a) + const color = "#" + colors[dataArray[i]] + "FF"; + const [r, g, b, a] = color.match(/\w\w/g).map((x) => parseInt(x, 16)); + imageDataArray.push(r, g, b, a); } - const uint8ClampedArray = new Uint8ClampedArray(imageDataArray) - const imageData = new ImageData(uint8ClampedArray, width, height) - draw(context, imageData) - setSetup(true) - }).catch(error => { + const uint8ClampedArray = new Uint8ClampedArray(imageDataArray); + const imageData = new ImageData(uint8ClampedArray, width, height); + draw(context, imageData); + setSetup(true); + }).catch((error) => { //TODO: Notifiy user of error - console.error(error) + console.error(error); }); - console.log("Connect to websocket") + console.log("Connect to websocket"); if (readyState === ReadyState.OPEN) { sendJsonMessage({ event: "subscribe", data: { channel: "general", }, - }) + }); } // TODO: Return a cleanup function to close the websocket / ... - }, [readyState, sendJsonMessage, setup, colors, width, height, backendUrl, draw]) + }, [ + readyState, + sendJsonMessage, + setup, + colors, + width, + height, + backendUrl, + draw, + setupColors, + ]); const colorPixel = useCallback((position, color) => { const canvas = props.canvasRef.current @@ -193,21 +215,26 @@ const Canvas = props => { } props.setPixelSelection(x, y) - const position = y * width + x - let getPixelInfoEndpoint = backendUrl + "/getPixelInfo?position=" + position.toString() + const position = y * width + x; + let getPixelInfoEndpoint = + backendUrl + "/getPixelInfo?position=" + position.toString(); fetch(getPixelInfoEndpoint, { - mode: 'cors' - }).then(response => { - return response.text() - }).then(data => { - // TODO: Cache pixel info & clear cache on update from websocket - // TODO: Dont query if hover select ( until 1s after hover? ) - props.setPixelPlacedBy(data) - }).catch(error => { - console.error(error) - }); - - }, [props, width, height, backendUrl]) + mode: "cors", + }) + .then((response) => { + return response.text(); + }) + .then((data) => { + // TODO: Cache pixel info & clear cache on update from websocket + // TODO: Dont query if hover select ( until 1s after hover? ) + props.setPixelPlacedBy(data); + }) + .catch((error) => { + console.error(error); + }); + }, + [props, width, height, backendUrl] + ); const pixelClicked = (e) => { if (props.nftSelectionMode) { @@ -303,53 +330,21 @@ const Canvas = props => { let templatePosition = x + y * width // TODO: Template preview - // TODO: Upload template / ask for template name, reward, ... - //let addTemplateEndpoint = backendUrl + "/add-template-devnet" - //let template = new Image() - //// Read templateImage as data url - //template.src = props.templateImage - //template.onload = function() { - // // TODO: Refactor this - // let templateWidth = this.width - // let templateHeight = this.height - // let templateHash = "0" // TODO - // let templateReward = "0" // TODO - // let templateRewardToken = "0" // TODO - // fetch(addTemplateEndpoint, { - // mode: "cors", - // method: "POST", - // body: JSON.stringify({ - // position: templatePosition.toString(), - // width: templateWidth.toString(), - // height: templateHeight.toString(), - // hash: templateHash, - // reward: templateReward, - // rewardToken: templateRewardToken, - // }), - // }).then(response => { - // return response.text() - // }).then(data => { - // console.log(data) - // }).catch(error => { - // console.error("Error adding template") - // console.error(error) - // }); - props.setTemplateImagePositionX(x) - props.setTemplateImagePositionY(y) - props.setTemplateImagePosition(templatePosition) - props.setTemplatePlacedMode(true) - props.setTemplateCreationMode(false) - // } + props.setTemplateImagePositionX(x) + props.setTemplateImagePositionY(y) + props.setTemplateImagePosition(templatePosition) + props.setTemplatePlacedMode(true) + props.setTemplateCreationMode(false) return } pixelSelect(e.clientX, e.clientY) if (props.selectedColorId === -1) { - return + return; } if (props.selectedPositionX === null || props.selectedPositionY === null) { - return + return; } if (props.extraPixels > 0) { @@ -367,6 +362,7 @@ const Canvas = props => { const position = props.selectedPositionX + props.selectedPositionY * width const colorIdx = props.selectedColorId let placePixelEndpoint = backendUrl + "/placePixelDevnet" + fetch(placePixelEndpoint, { mode: "cors", method: "POST", @@ -374,53 +370,88 @@ const Canvas = props => { position: position.toString(), color: colorIdx.toString(), }), - }).then(response => { - return response.text() - }).then(data => { - console.log(data) - }).catch(error => { - console.error("Error placing pixel") - console.error(error) - }); - props.clearPixelSelection() - props.setSelectedColorId(-1) + }) + .then((response) => { + return response.text(); + }) + .then((data) => { + console.log(data); + }) + .catch((error) => { + console.error("Error placing pixel"); + console.error(error); + }); + props.clearPixelSelection(); + props.setSelectedColorId(-1); // TODO: Optimistic update + + } - + // TODO: Deselect pixel when clicking outside of color palette or pixel // TODO: Show small position vec in bottom right corner of canvas const getSelectedColor = () => { - console.log(props.selectedColorId, props.selectedPositionX, props.selectedPositionY) + console.log( + props.selectedColorId, + props.selectedPositionX, + props.selectedPositionY + ); if (props.selectedPositionX === null || props.selectedPositionY === null) { - return null + return null; } if (props.selectedColorId === -1) { - return null + return null; } - return "#" + colors[props.selectedColorId] + "FF" - } + return "#" + colors[props.selectedColorId] + "FF"; + }; const getSelectorsColor = () => { if (props.selectedPositionX === null || props.selectedPositionY === null) { - return null + return null; } if (props.selectedColorId === -1) { - let color = props.canvasRef.current.getContext('2d').getImageData(props.selectedPositionX, props.selectedPositionY, 1, 1).data - return "#" + color[0].toString(16).padStart(2, '0') + color[1].toString(16).padStart(2, '0') + color[2].toString(16).padStart(2, '0') + color[3].toString(16).padStart(2, '0') + let color = canvasRef.current + .getContext("2d") + .getImageData( + props.selectedPositionX, + props.selectedPositionY, + 1, + 1 + ).data; + return ( + "#" + + color[0].toString(16).padStart(2, "0") + + color[1].toString(16).padStart(2, "0") + + color[2].toString(16).padStart(2, "0") + + color[3].toString(16).padStart(2, "0") + ); } - return "#" + colors[props.selectedColorId] + "FF" - } + return "#" + colors[props.selectedColorId] + "FF"; + }; const getSelectorsColorInverse = () => { if (props.selectedPositionX === null || props.selectedPositionY === null) { - return null + return null; } if (props.selectedColorId === -1) { - let color = props.canvasRef.current.getContext('2d').getImageData(props.selectedPositionX, props.selectedPositionY, 1, 1).data - return "#" + (255 - color[0]).toString(16).padStart(2, '0') + (255 - color[1]).toString(16).padStart(2, '0') + (255 - color[2]).toString(16).padStart(2, '0') + color[3].toString(16).padStart(2, '0') + let color = canvasRef.current + .getContext("2d") + .getImageData( + props.selectedPositionX, + props.selectedPositionY, + 1, + 1 + ).data; + return ( + "#" + + (255 - color[0]).toString(16).padStart(2, "0") + + (255 - color[1]).toString(16).padStart(2, "0") + + (255 - color[2]).toString(16).padStart(2, "0") + + color[3].toString(16).padStart(2, "0") + ); } - return "#" + colors[props.selectedColorId] + "FF" - } + return "#" + colors[props.selectedColorId] + "FF"; + }; // TODO //const templateImage = [1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 4, 3] @@ -503,9 +534,9 @@ const Canvas = props => { } if (props.selectedColorId === -1) { - return + return; } - pixelSelect(e.clientX, e.clientY) + pixelSelect(e.clientX, e.clientY); }; window.addEventListener("mousemove", setFromEvent); @@ -516,12 +547,14 @@ const Canvas = props => { // TODO: both place options return ( -
-
-
- { props.pixelSelectedMode && ( -
-
+ +
+
+
+ {props.pixelSelectedMode && ( +
+
+
)} @@ -539,6 +572,6 @@ const Canvas = props => {
); -} +}; -export default Canvas +export default Canvas; diff --git a/frontend/src/canvas/PixelSelector.js b/frontend/src/canvas/PixelSelector.js index 00d00de7..12095b63 100644 --- a/frontend/src/canvas/PixelSelector.js +++ b/frontend/src/canvas/PixelSelector.js @@ -1,9 +1,10 @@ import React, {useCallback, useEffect, useState} from 'react'; import './PixelSelector.css'; import canvasConfig from '../configs/canvas.config.json'; +import backendConfig from '../configs/backend.config.json'; const PixelSelector = (props) => { - + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port; const [placedTime, setPlacedTime] = useState(0); const [timeTillNextPlacement, setTimeTillNextPlacement] = useState("XX:XX"); // TODO: get from server on init // TODO: Animation for swapping selectorMode @@ -11,8 +12,34 @@ const PixelSelector = (props) => { const timeBetweenPlacements = 5000; // 5 seconds TODO: make this a config const updateInterval = 200; // 200ms - let colors = canvasConfig.colors; - colors = colors.map(color => `#${color}FF`); + let staticColors = canvasConfig.colors; + staticColors = staticColors.map(color => `#${color}FF`); + + const [colors, setColors] = useState([]); + const [isSetup, setIsSetup] = useState(false); + + useEffect(() => { + if (isSetup) { + return; + } + let getColorsEndpoint = backendUrl + "/get-colors"; + fetch(getColorsEndpoint, { mode: "cors" }).then((response) => { + response.json().then((data) => { + let colors = []; + for (let i = 0; i < data.length; i++) { + colors.push(data[i].hex); + } + colors = colors.map(color => `#${color}FF`); + setColors(colors); + setIsSetup(true); + }).catch((error) => { + setColors(staticColors); + setIsSetup(true); + console.error(error); + }); + }); + // TODO: Return a cleanup function to close the websocket / ... + }, [colors, backendUrl, staticColors, setColors, setIsSetup, isSetup]); // TODO: implement extraPixels feature(s) diff --git a/frontend/src/configs/backend.config.json b/frontend/src/configs/backend.config.json index b8f73c23..228cf22b 100644 --- a/frontend/src/configs/backend.config.json +++ b/frontend/src/configs/backend.config.json @@ -4,5 +4,6 @@ "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", "add_template_hash_devnet": "../tests/integration/local/add_template_hash.sh" - } + }, + "production": false } diff --git a/frontend/src/tabs/Account.css b/frontend/src/tabs/Account.css index e69de29b..5c3a638b 100644 --- a/frontend/src/tabs/Account.css +++ b/frontend/src/tabs/Account.css @@ -0,0 +1,64 @@ +.Account__flex { + display: flex; + margin: 8px 4px; +} + +.Account__flex--center { + align-items: center; +} + +.Account__wrap { + text-overflow: ellipsis; + overflow: hidden; +} + +.Account__list { + padding-left: 20px; + list-style-type: none; +} + +.Account__list li { + margin-bottom: 10px; + text-indent: -8px; +} + +.Account__list li:before { + content: "-"; + text-indent: -8px; +} + +.Account__input { + width: 100%; + padding: 6px 10px; +} + +.Account__input:focus { + border: 1px solid black; + outline: black; +} + +.Account__button { + background-color: black; + color: #efefef; + border: 1px solid black; + text-transform: uppercase; + cursor: pointer; + border-radius: 6px; +} + +.Account__button--edit { + padding: 2px 8px; + font-size: 8px; +} + +.Account__button--submit { + padding: 8px 16px; + font-size: 10px; +} + +.Account__user { + display: flex; + gap: 10px; + justify-content: space-between; + width: 100%; +} diff --git a/frontend/src/tabs/Account.js b/frontend/src/tabs/Account.js index 4d846979..f4e3d88b 100644 --- a/frontend/src/tabs/Account.js +++ b/frontend/src/tabs/Account.js @@ -1,13 +1,81 @@ -import React from 'react' -import './Account.css'; -import BasicTab from './BasicTab.js'; +import React, { useState, useEffect } from "react"; +import "./Account.css"; +import BasicTab from "./BasicTab.js"; -const Account = props => { +const Account = (props) => { // TODO: Create the account tab w/ wallet address, username, pixel info, top X % users ( pixels placed? ), ... + const [username, setUsername] = useState(""); + const [pixelCount, setPixelCount] = useState(2572); + const [accountRank, setAccountRank] = useState(""); + const [isUsernameSaved, saveUsername] = useState(false); + + const handleSubmit = (event) => { + event.preventDefault(); + setUsername(username); + saveUsername(true); + }; + + const editUsername = (e) => { + saveUsername(false); + } + + useEffect(() => { + if (pixelCount >= 5000) { + setAccountRank("Champion"); + } else if (pixelCount >= 3000) { + setAccountRank("Platinum"); + } else if (pixelCount >= 2000) { + setAccountRank("Gold"); + } else if (pixelCount >= 1000) { + setAccountRank("Silver"); + } else { + setAccountRank("Bronze"); + } + }); return ( +
+

Address:

+

+ 0x0000000000000000000000000000000000000000000000000000000000000000 +

+
+
+

Username:

+ {isUsernameSaved ? ( +
+

{username}

+ +
+ ) : ( +
+ + +
+ )} +
+
+

Pixel count:

+

{pixelCount}

+
+
+

Current Rank:

+

{accountRank}

+
); -} +}; export default Account; diff --git a/frontend/src/tabs/TabPanel.js b/frontend/src/tabs/TabPanel.js index c13535be..da677cce 100644 --- a/frontend/src/tabs/TabPanel.js +++ b/frontend/src/tabs/TabPanel.js @@ -9,12 +9,20 @@ import Account from './Account.js'; const TabPanel = props => { return ( -
- { props.activeTab === 'Quests' && } - { props.activeTab === 'Vote' && } - { props.activeTab === 'Templates' && } - { props.activeTab === 'NFTs' && } - { props.activeTab === 'Account' && } +
+ {props.activeTab === "Quests" && ( + + )} + {props.activeTab === "Vote" && ( + + )} + {props.activeTab === "Templates" && ( + + )} + {props.activeTab === "NFTs" && ( + + )} + {props.activeTab === "Account" && }
); } diff --git a/frontend/src/tabs/Voting.js b/frontend/src/tabs/Voting.js index bc6db763..06f4b422 100644 --- a/frontend/src/tabs/Voting.js +++ b/frontend/src/tabs/Voting.js @@ -11,42 +11,12 @@ const Voting = props => { const [votes, setVotes] = useState(colorVotes); const [userVote, setUserVote] = useState(-1); // TODO: Pull from API - const timeTillVote = '05:14:23'; - const [time, setTime] = useState(timeTillVote); - - useEffect(() => { - const interval = setInterval(() => { - setTime(time => { - let timeSplit = time.split(':'); - let hours = parseInt(timeSplit[0]); - let minutes = parseInt(timeSplit[1]); - let seconds = parseInt(timeSplit[2]); - if (seconds === 0) { - if (minutes === 0) { - if (hours === 0) { - return '00:00:00'; - } - hours--; - minutes = 59; - seconds = 59; - } else { - minutes--; - seconds = 59; - } - } else { - seconds--; - } - return `${hours < 10 ? '0' + hours : hours}:${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds}`; - }); - }, 1000); - return () => clearInterval(interval); - }, [time]); return (

Color Vote

Vote for a new palette color.

-

Vote closes: {time}

+

Vote closes: {props.timeLeftInDay}

Vote
diff --git a/frontend/src/tabs/quests/Quests.js b/frontend/src/tabs/quests/Quests.js index 3b024eb1..b59dfc9e 100644 --- a/frontend/src/tabs/quests/Quests.js +++ b/frontend/src/tabs/quests/Quests.js @@ -1,54 +1,92 @@ -import React from 'react' -import './Quests.css'; -import BasicTab from '../BasicTab.js'; -import QuestItem from './QuestItem.js'; +import React, { useState, useEffect } from "react"; +import "./Quests.css"; +import BasicTab from "../BasicTab.js"; +import QuestItem from "./QuestItem.js"; -const Quests = props => { +const Quests = (props) => { + const [dailyQuests, setDailyQuests] = useState([]); + const [mainQuests, setMainQuests] = useState([]); + + useEffect(() => { + const fetchQuests = async () => { + try { + // Fetching daily quests from backend + const dailyResponse = await fetch('http://localhost:8080/getDailyQuests'); + const dailyData = await dailyResponse.json(); + setDailyQuests(dailyData); + + // Fetching main quests from backend + const mainResponse = await fetch('http://localhost:8080/getMainQuests'); + const mainData = await mainResponse.json(); + setMainQuests(mainData); + } catch (error) { + console.error('Failed to fetch quests', error); + } + }; + + fetchQuests(); + }, []); // TODO: Main quests should be scrollable // TODO: Main quests should be moved to the bottom on complete // TODO: Pull quests from backend // TODO: Links in descriptions - const dailyQuests = [ + + + + const localDailyQuests = [ { title: "Place 10 pixels", description: "Add 10 pixels on the canvas", reward: "3", - status: "completed" + status: "completed", }, { title: "Build a template", description: "Create a template for the community to use", reward: "3", - status: "claim" + status: "claim", }, { title: "Deploy a Memecoin", description: "Create an Unruggable memecoin", reward: "10", - status: "completed" - } - ] + status: "completed", + }, + ]; - const mainQuests = [ + const localMainQuests = [ { title: "Tweet #art/peace", description: "Tweet about art/peace using the hashtag & addr", reward: "10", - status: "incomplete" + status: "incomplete", }, { title: "Place 100 pixels", description: "Add 100 pixels on the canvas", reward: "10", - status: "completed" + status: "completed", }, { title: "Mint an art/peace NFT", description: "Mint an NFT using the art/peace theme", reward: "5", - status: "incomplete" + status: "incomplete", + }, + ]; + + const sortByCompleted = (arr) => { + if (!arr) return []; + const newArray = []; + for (let i = 0; i < arr.length; i++) { + if (arr[i].status == "completed") { + newArray.push(arr[i]); + } else { + newArray.unshift(arr[i]); + } } - ] + return newArray; + }; // TODO: Icons for each tab? return ( @@ -56,19 +94,49 @@ const Quests = props => {

Dailys

-

XX:XX:XX

+

{props.timeLeftInDay}

- {dailyQuests.map((quest, index) => ( - + {sortByCompleted(dailyQuests).map((quest, index) => ( + ))} - + {sortByCompleted(localDailyQuests).map((quest, index) => ( + + ))} +

Main

- {mainQuests.map((quest, index) => ( - + {sortByCompleted(mainQuests).map((quest, index) => ( + + ))} + {sortByCompleted(localMainQuests).map((quest, index) => ( + ))}
); -} +}; export default Quests; diff --git a/indexer/Dockerfile b/indexer/Dockerfile index 993bcb38..69dad95e 100644 --- a/indexer/Dockerfile +++ b/indexer/Dockerfile @@ -1,6 +1,6 @@ FROM quay.io/apibara/sink-webhook:0.6.0 as sink-webhook WORKDIR /indexer -COPY ./indexer/docker-script.js . +COPY ./indexer/script.js . -CMD ["run", "docker-script.js", "--allow-env", "/deployment/.env"] +CMD ["run", "script.js", "--allow-env", "/configs/configs.env", "--allow-env-from-env", "BACKEND_TARGET_URL,APIBARA_STREAM_URL"] diff --git a/indexer/README.md b/indexer/README.md index 9380a3d1..ebfff6f2 100644 --- a/indexer/README.md +++ b/indexer/README.md @@ -6,5 +6,9 @@ This directory contains the Apibara indexer setup for `art/peace`, which indexes ``` # Setup Indexer/DNA w/ docker compose or other options -apibara run scripts.js +# Create an indexer.env file with the following : +# ART_PEACE_CONTRACT_ADDRESS=... # Example: 0x78223f7ab13216727ed426380079c169578cafad83a3178c7b33ba7ca307713 +# APIBARA_STREAM_URL=... # Example: http://localhost:7171 +# BACKEND_TARGET_URL=... # Example: http://localhost:8080/consumeIndexerMsg +apibara run scripts.js --allow-env indexer.env ``` diff --git a/indexer/docker-script.js b/indexer/docker-script.js deleted file mode 100644 index f5b12fc6..00000000 --- a/indexer/docker-script.js +++ /dev/null @@ -1,40 +0,0 @@ -export const config = { - streamUrl: "http://art-peace-apibara-1:7171", - startingBlock: 0, - network: "starknet", - finality: "DATA_STATUS_PENDING", - filter: { - events: [ - { - fromAddress: Deno.env.get("ART_PEACE_CONTRACT_ADDRESS"), - keys: ["0x2D7B50EBF415606D77C7E7842546FC13F8ACFBFD16F7BCF2BC2D08F54114C23"], - includeReverted: false, - includeTransaction: false, - includeReceipt: false, - }, - { - fromAddress: Deno.env.get("NFT_CONTRACT_ADDRESS"), - keys: ["0x30826E0CD9A517F76E857E3F3100FE5B9098E9F8216D3DB283FB4C9A641232F"], - includeReverted: false, - includeTransaction: false, - includeReceipt: false, - }, - { - fromAddress: Deno.env.get("ART_PEACE_CONTRACT_ADDRESS"), - keys: ["0x3E18EC266FE76A2EFCE73F91228E6E04456B744FC6984C7A6374E417FB4BF59"], - includeReverted: false, - includeTransaction: false, - includeReceipt: false, - }, - ], - }, - sinkType: "webhook", - sinkOptions: { - targetUrl: "http://art-peace-backend-1:8080/consumeIndexerMsg" - }, -}; - -// This transform does nothing. -export default function transform(block) { - return block; -} diff --git a/indexer/script.js b/indexer/script.js index c0c0b29b..ce8412c2 100644 --- a/indexer/script.js +++ b/indexer/script.js @@ -1,5 +1,5 @@ export const config = { - streamUrl: "http://localhost:7171", + streamUrl: Deno.env.get("APIBARA_STREAM_URL"), startingBlock: 0, network: "starknet", finality: "DATA_STATUS_PENDING", @@ -12,11 +12,25 @@ export const config = { includeTransaction: false, includeReceipt: false, }, + { + fromAddress: Deno.env.get("NFT_CONTRACT_ADDRESS"), + keys: ["0x30826E0CD9A517F76E857E3F3100FE5B9098E9F8216D3DB283FB4C9A641232F"], + includeReverted: false, + includeTransaction: false, + includeReceipt: false, + }, + { + fromAddress: Deno.env.get("ART_PEACE_CONTRACT_ADDRESS"), + keys: ["0x3E18EC266FE76A2EFCE73F91228E6E04456B744FC6984C7A6374E417FB4BF59"], + includeReverted: false, + includeTransaction: false, + includeReceipt: false, + }, ], }, sinkType: "webhook", sinkOptions: { - targetUrl: "http://localhost:8080/consumeIndexerMsg" + targetUrl: Deno.env.get("BACKEND_TARGET_URL"), }, }; diff --git a/onchain/src/lib.cairo b/onchain/src/lib.cairo index 4097103e..5c6a3325 100644 --- a/onchain/src/lib.cairo +++ b/onchain/src/lib.cairo @@ -3,6 +3,7 @@ pub mod interfaces; use art_peace::ArtPeace; use interfaces::{IArtPeace, IArtPeaceDispatcher, IArtPeaceDispatcherTrait, Pixel}; + mod quests { pub mod interfaces; pub mod pixel_quest; @@ -33,7 +34,22 @@ mod nfts { }; } +mod username_store { + pub mod interfaces; + pub mod username_store; + + use interfaces::{IUsernameStore, IUsernameStoreDispatcher, IUsernameStoreDispatcherTrait}; + use username_store::UsernameStore; +} + +mod mocks { + pub mod erc20_mock; +} + #[cfg(test)] mod tests { mod art_peace; + mod username_store; + pub(crate) mod utils; } + diff --git a/onchain/src/mocks/erc20_mock.cairo b/onchain/src/mocks/erc20_mock.cairo new file mode 100644 index 00000000..8bd72de4 --- /dev/null +++ b/onchain/src/mocks/erc20_mock.cairo @@ -0,0 +1,42 @@ +// +// https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/tests/mocks/erc20_mocks.cairo +// + +#[starknet::contract] +pub mod SnakeERC20Mock { + use openzeppelin::token::erc20::ERC20Component; + use starknet::ContractAddress; + + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + + #[abi(embed_v0)] + impl ERC20Impl = ERC20Component::ERC20Impl; + #[abi(embed_v0)] + impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl; + impl InternalImpl = ERC20Component::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + erc20: ERC20Component::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC20Event: ERC20Component::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + initial_supply: u256, + recipient: ContractAddress + ) { + self.erc20.initializer(name, symbol); + self.erc20._mint(recipient, initial_supply); + } +} diff --git a/onchain/src/templates/component.cairo b/onchain/src/templates/component.cairo index e159b069..7fb27ce8 100644 --- a/onchain/src/templates/component.cairo +++ b/onchain/src/templates/component.cairo @@ -1,6 +1,9 @@ #[starknet::component] pub mod TemplateStoreComponent { use art_peace::templates::interfaces::{ITemplateStore, TemplateMetadata}; + use core::num::traits::Zero; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{ContractAddress, get_caller_address}; #[storage] struct Storage { @@ -59,6 +62,11 @@ pub mod TemplateStoreComponent { let template_id = self.templates_count.read(); self.templates.write(template_id, template_metadata); self.templates_count.write(template_id + 1); + + if !template_metadata.reward_token.is_zero() && template_metadata.reward != 0 { + self.deposit(template_metadata.reward_token, template_metadata.reward); + } + self.emit(TemplateAdded { id: template_id, metadata: template_metadata }); } @@ -66,4 +74,27 @@ pub mod TemplateStoreComponent { self.completed_templates.read(template_id) } } + + #[generate_trait] + impl InternalImpl< + TContractState, +HasComponent + > of InternalTrait { + fn deposit( + ref self: ComponentState, + reward_token: ContractAddress, + reward_amount: u256 + ) { + let caller_address = get_caller_address(); + let contract_address = starknet::get_contract_address(); + assert(!get_caller_address().is_zero(), 'Invalid caller'); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: reward_token }; + let allowance = erc20_dispatcher.allowance(caller_address, contract_address); + assert(allowance >= reward_amount, 'Insufficient allowance'); + + let success = erc20_dispatcher + .transfer_from(caller_address, contract_address, reward_amount); + assert(success, 'Transfer failed'); + } + } } diff --git a/onchain/src/templates/interfaces.cairo b/onchain/src/templates/interfaces.cairo index 4aeb4a7a..41f0f967 100644 --- a/onchain/src/templates/interfaces.cairo +++ b/onchain/src/templates/interfaces.cairo @@ -1,3 +1,5 @@ +use starknet::ContractAddress; + #[derive(Drop, Copy, Serde, starknet::Store)] pub struct TemplateMetadata { pub hash: felt252, @@ -6,7 +8,7 @@ pub struct TemplateMetadata { pub width: u128, pub height: u128, pub reward: u256, - pub reward_token: starknet::ContractAddress + pub reward_token: ContractAddress } #[starknet::interface] diff --git a/onchain/src/tests/art_peace.cairo b/onchain/src/tests/art_peace.cairo index 17e39a78..616c607f 100644 --- a/onchain/src/tests/art_peace.cairo +++ b/onchain/src/tests/art_peace.cairo @@ -1,6 +1,8 @@ use art_peace::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; use art_peace::ArtPeace::InitParams; use art_peace::quests::pixel_quest::PixelQuest::PixelQuestInitParams; +use art_peace::mocks::erc20_mock::SnakeERC20Mock; +use art_peace::tests::utils; use art_peace::nfts::interfaces::{ IArtPeaceNFTMinterDispatcher, IArtPeaceNFTMinterDispatcherTrait, ICanvasNFTStoreDispatcher, ICanvasNFTStoreDispatcherTrait, NFTMintParams, NFTMetadata @@ -13,10 +15,13 @@ use art_peace::templates::interfaces::{ use core::poseidon::PoseidonTrait; use core::hash::{HashStateTrait, HashStateExTrait}; +use openzeppelin::token::erc20::interface::{IERC20, IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin::token::erc721::interface::{IERC721Dispatcher, IERC721DispatcherTrait}; + use snforge_std as snf; use snforge_std::{CheatTarget, ContractClassTrait}; -use starknet::{ContractAddress, contract_address_const}; + +use starknet::{ContractAddress, contract_address_const, get_contract_address}; const DAY_IN_SECONDS: u64 = consteval_int!(60 * 60 * 24); const WIDTH: u128 = 100; @@ -27,6 +32,10 @@ fn ART_PEACE_CONTRACT() -> ContractAddress { contract_address_const::<'ArtPeace'>() } +fn ERC20_MOCK_CONTRACT() -> ContractAddress { + contract_address_const::<'erc20mock'>() +} + fn EMPTY_CALLDATA() -> Span { array![].span() } @@ -187,6 +196,25 @@ fn deploy_nft_contract() -> ContractAddress { contract.deploy_at(@calldata, NFT_CONTRACT()).unwrap() } + +fn deploy_erc20_mock() -> ContractAddress { + let contract = snf::declare("SnakeERC20Mock"); + let name: ByteArray = "erc20 mock"; + let symbol: ByteArray = "ERC20MOCK"; + let initial_supply: u256 = 10 * utils::pow_256(10, 18); + let recipient: ContractAddress = get_contract_address(); + + let mut calldata: Array = array![]; + Serde::serialize(@name, ref calldata); + Serde::serialize(@symbol, ref calldata); + Serde::serialize(@initial_supply, ref calldata); + Serde::serialize(@recipient, ref calldata); + + let contract_addr = contract.deploy_at(@calldata, ERC20_MOCK_CONTRACT()).unwrap(); + + contract_addr +} + fn warp_to_next_available_time(art_peace: IArtPeaceDispatcher) { let last_time = art_peace.get_last_placed_time(); snf::start_warp(CheatTarget::One(art_peace.contract_address), last_time + TIME_BETWEEN_PIXELS); @@ -365,16 +393,13 @@ fn template_full_basic_test() { assert!(template_store.get_templates_count() == 0, "Templates count is not 0"); + let erc20_mock: ContractAddress = deploy_erc20_mock(); + // 2x2 template image let template_image = array![1, 2, 3, 4]; let template_hash = compute_template_hash(template_image.span()); let template_metadata = TemplateMetadata { - hash: template_hash, - position: 0, - width: 2, - height: 2, - reward: 0, - reward_token: contract_address_const::<0>(), + hash: template_hash, position: 0, width: 2, height: 2, reward: 0, reward_token: erc20_mock, }; template_store.add_template(template_metadata); @@ -503,3 +528,36 @@ fn nft_mint_test() { assert!(nft.balance_of(PLAYER1()) == 0, "NFT balance is not correct after transfer"); assert!(nft.balance_of(PLAYER2()) == 1, "NFT balance is not correct after transfer"); } + +#[test] +fn deposit_reward_test() { + let art_peace_address = deploy_contract(); + let art_peace = IArtPeaceDispatcher { contract_address: art_peace_address }; + let template_store = ITemplateStoreDispatcher { contract_address: art_peace.contract_address }; + + let erc20_mock: ContractAddress = deploy_erc20_mock(); + let reward_amount: u256 = 1 * utils::pow_256(10, 18); + + // 2x2 template image + let template_image = array![1, 2, 3, 4]; + let template_hash = compute_template_hash(template_image.span()); + let template_metadata = TemplateMetadata { + hash: template_hash, + position: 0, + width: 2, + height: 2, + reward: reward_amount, + reward_token: erc20_mock, + }; + + IERC20Dispatcher { contract_address: erc20_mock }.approve(art_peace_address, reward_amount); + + template_store.add_template(template_metadata); + + let art_peace_token_balance = IERC20Dispatcher { contract_address: erc20_mock } + .balance_of(art_peace_address); + + assert!( + art_peace_token_balance == reward_amount, "reward wrongly distributed when adding template" + ); +} diff --git a/onchain/src/tests/username_store.cairo b/onchain/src/tests/username_store.cairo new file mode 100644 index 00000000..f4f4ee38 --- /dev/null +++ b/onchain/src/tests/username_store.cairo @@ -0,0 +1,36 @@ +use snforge_std::{declare, ContractClassTrait}; +use art_peace::username_store::interfaces::{ + IUsernameStoreDispatcher, IUsernameStoreDispatcherTrait +}; +use starknet::{ContractAddress, get_caller_address, get_contract_address, contract_address_const}; + +fn deploy_contract() -> ContractAddress { + let contract = declare("UsernameStore"); + + return contract.deploy(@ArrayTrait::new()).unwrap(); +} + +#[test] +fn test_claim_username() { + let contract_address = deploy_contract(); + let dispatcher = IUsernameStoreDispatcher { contract_address }; + dispatcher.claim_username('deal'); + + let username_address = dispatcher.get_username('deal'); + + assert(contract_address != username_address, 'Username not claimed'); +} +#[test] +fn test_transfer_username() { + let contract_address = deploy_contract(); + let dispatcher = IUsernameStoreDispatcher { contract_address }; + dispatcher.claim_username('devsweet'); + + let second_contract_address = contract_address_const::<1>(); + + dispatcher.transfer_username('devsweet', second_contract_address); + + let username_address = dispatcher.get_username('devsweet'); + + assert(username_address == second_contract_address, 'Username not Transferred'); +} diff --git a/onchain/src/tests/utils.cairo b/onchain/src/tests/utils.cairo new file mode 100644 index 00000000..1d9a66bc --- /dev/null +++ b/onchain/src/tests/utils.cairo @@ -0,0 +1,23 @@ +use core::num::traits::Zero; + +// Math +pub(crate) fn pow_256(self: u256, mut exponent: u8) -> u256 { + if self.is_zero() { + return 0; + } + let mut result = 1; + let mut base = self; + + loop { + if exponent & 1 == 1 { + result = result * base; + } + + exponent = exponent / 2; + if exponent == 0 { + break result; + } + + base = base * base; + } +} diff --git a/onchain/src/username_store/interfaces.cairo b/onchain/src/username_store/interfaces.cairo new file mode 100644 index 00000000..04ea5850 --- /dev/null +++ b/onchain/src/username_store/interfaces.cairo @@ -0,0 +1,8 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IUsernameStore { + fn claim_username(ref self: TContractState, key: felt252); + fn transfer_username(ref self: TContractState, key: felt252, new_Address: ContractAddress); + fn get_username(ref self: TContractState, key: felt252) -> ContractAddress; +} diff --git a/onchain/src/username_store/username_store.cairo b/onchain/src/username_store/username_store.cairo new file mode 100644 index 00000000..309a5fe2 --- /dev/null +++ b/onchain/src/username_store/username_store.cairo @@ -0,0 +1,79 @@ +pub mod UserNameClaimErrors { + pub const USERNAME_CLAIMED: felt252 = 'username_claimed'; + pub const USERNAME_CANNOT_BE_TRANSFER: felt252 = 'username_cannot_be_transferred'; +} + +#[starknet::contract] +pub mod UsernameStore { + use starknet::{get_caller_address, ContractAddress, contract_address_const}; + use art_peace::username_store::IUsernameStore; + use super::UserNameClaimErrors; + + #[storage] + struct Storage { + usernames: LegacyMap:: + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + UserNameClaimed: UserNameClaimed, + UserNameTransferred: UserNameTransferred + } + + #[derive(Drop, starknet::Event)] + struct UserNameClaimed { + #[key] + username: felt252, + address: ContractAddress + } + + #[derive(Drop, starknet::Event)] + struct UserNameTransferred { + #[key] + username: felt252, + address: ContractAddress + } + + #[abi(embed_v0)] + pub impl UsernameStore of IUsernameStore { + fn claim_username(ref self: ContractState, key: felt252) { + let mut username_address = self.usernames.read(key); + + assert( + username_address == contract_address_const::<0>(), + UserNameClaimErrors::USERNAME_CLAIMED + ); + + self.usernames.write(key, get_caller_address()); + + self + .emit( + Event::UserNameClaimed( + UserNameClaimed { username: key, address: get_caller_address() } + ) + ) + } + + fn transfer_username(ref self: ContractState, key: felt252, new_Address: ContractAddress) { + let username_address = self.usernames.read(key); + + if username_address != get_caller_address() { + core::panic_with_felt252(UserNameClaimErrors::USERNAME_CANNOT_BE_TRANSFER); + } + + self.usernames.write(key, new_Address); + + self + .emit( + Event::UserNameTransferred( + UserNameTransferred { username: key, address: new_Address } + ) + ) + } + + fn get_username(ref self: ContractState, key: felt252) -> ContractAddress { + self.usernames.read(key) + } + } +} diff --git a/postgres/init.sql b/postgres/init.sql index f591448e..420640e9 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -41,31 +41,49 @@ CREATE TABLE Days ( ); CREATE INDEX days_dayIndex_index ON Days (dayIndex); --- TODO: Remove completedStatus & status from Quests? -CREATE TABLE Quests ( +CREATE TABLE DailyQuests ( key integer NOT NULL PRIMARY KEY, name text NOT NULL, description text NOT NULL, reward integer NOT NULL, - dayIndex integer NOT NULL, - completedStatus integer NOT NULL + dayIndex integer NOT NULL ); -CREATE INDEX quests_dayIndex_index ON Quests (dayIndex); +CREATE INDEX dailyQuests_dayIndex_index ON DailyQuests (dayIndex); -- TODO: Add calldata field -CREATE TABLE UserQuests ( +-- Table for storing the daily quests that the user has completed +CREATE TABLE UserDailyQuests ( key integer NOT NULL PRIMARY KEY, userAddress char(64) NOT NULL, questKey integer NOT NULL, - status integer NOT NULL, completed boolean NOT NULL, completedAt timestamp ); -CREATE INDEX userQuests_userAddress_index ON UserQuests (userAddress); -CREATE INDEX userQuests_questKey_index ON UserQuests (questKey); +CREATE INDEX userDailyQuests_userAddress_index ON UserDailyQuests (userAddress); +CREATE INDEX userDailyQuests_questKey_index ON UserDailyQuests (questKey); -CREATE TABLE Colors ( +CREATE TABLE MainQuests ( + key integer NOT NULL PRIMARY KEY, + name text NOT NULL, + description text NOT NULL, + reward integer NOT NULL +); + +-- Table for storing the main quests that the user has completed +CREATE TABLE UserMainQuests ( key integer NOT NULL PRIMARY KEY, + userAddress char(64) NOT NULL, + questKey integer NOT NULL, + completed boolean NOT NULL, + completedAt timestamp +); +CREATE INDEX userMainQuests_userAddress_index ON UserMainQuests (userAddress); +CREATE INDEX userMainQuests_questKey_index ON UserMainQuests (questKey); + +-- TODO: key to color_idx +CREATE TABLE Colors ( + -- Postgres auto-incrementing primary key + key int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, hex text NOT NULL ); diff --git a/tests/integration/docker/deploy.sh b/tests/integration/docker/deploy.sh index f6c42029..dc825a95 100755 --- a/tests/integration/docker/deploy.sh +++ b/tests/integration/docker/deploy.sh @@ -80,10 +80,10 @@ echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --acc /root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $ART_PEACE_CONTRACT_ADDRESS --function add_nft_contract --calldata $NFT_CONTRACT_ADDRESS # TODO: Remove these lines? -echo "ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" > /deployment/.env -echo "REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" >> /deployment/.env -echo "NFT_CONTRACT_ADDRESS=$NFT_CONTRACT_ADDRESS" >> /deployment/.env -echo "REACT_APP_NFT_CONTRACT_ADDRESS=$NFT_CONTRACT_ADDRESS" >> /deployment/.env +echo "ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" > /configs/.env +echo "REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" >> /configs/.env +echo "NFT_CONTRACT_ADDRESS=$NFT_CONTRACT_ADDRESS" >> /configs/.env +echo "REACT_APP_NFT_CONTRACT_ADDRESS=$NFT_CONTRACT_ADDRESS" >> /configs/.env # TODO # MULTICALL_TEMPLATE_DIR=$CONTRACT_DIR/tests/multicalls diff --git a/tests/integration/docker/initialize.sh b/tests/integration/docker/initialize.sh index 98b8c6d7..cdf1c5d7 100755 --- a/tests/integration/docker/initialize.sh +++ b/tests/integration/docker/initialize.sh @@ -9,5 +9,10 @@ echo "Initializing the canvas" curl http://backend:8080/initCanvas -X POST echo "Set the contract address" -CONTRACT_ADDRESS=$(cat /deployment/.env | grep "^ART_PEACE_CONTRACT_ADDRESS" | cut -d '=' -f2) +CONTRACT_ADDRESS=$(cat /configs/.env | grep "^ART_PEACE_CONTRACT_ADDRESS" | cut -d '=' -f2) curl http://backend:8080/setContractAddress -X POST -d "$CONTRACT_ADDRESS" + +echo "Setup the colors from the color config" +# flatten colors with quotes and join them with comma and wrap in [] +COLORS=$(cat /configs/canvas.config.json | jq -r '.colors | map("\"\(.)\"") | join(",")') +curl http://backend:8080/init-colors -X POST -d "[$COLORS]" diff --git a/tests/integration/local/run.sh b/tests/integration/local/run.sh index 318fc40f..6e7d4986 100755 --- a/tests/integration/local/run.sh +++ b/tests/integration/local/run.sh @@ -77,7 +77,12 @@ INDEXER_SCRIPT_LOG_FILE=$LOG_DIR/indexer_script.log touch $INDEXER_SCRIPT_LOG_FILE cd $WORK_DIR/indexer #TODO: apibara -> postgres automatically? -ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS apibara run script.js --allow-env-from-env ART_PEACE_CONTRACT_ADDRESS 2>&1 > $INDEXER_SCRIPT_LOG_FILE & +rm -f $TMP_DIR/indexer.env +touch $TMP_DIR/indexer.env +echo "ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" >> $TMP_DIR/indexer.env +echo "APIBARA_STREAM_URL=http://localhost:7171" >> $TMP_DIR/indexer.env +echo "BACKEND_TARGET_URL=http://localhost:8080/consumeIndexerMsg" >> $TMP_DIR/indexer.env +apibara run script.js --allow-env $TMP_DIR/indexer.env 2>&1 > $INDEXER_SCRIPT_LOG_FILE & INDEXER_SCRIPT_PID=$! sleep 2 # Wait for indexer script to start; TODO: Check if indexer script is actually running @@ -85,6 +90,8 @@ sleep 2 # Wait for indexer script to start; TODO: Check if indexer script is act echo "Initializing art-peace canvas ..." curl http://localhost:8080/initCanvas -X POST curl http://localhost:8080/setContractAddress -X POST -d "$ART_PEACE_CONTRACT_ADDRESS" +COLORS=$(cat $CANVAS_CONFIG_FILE | jq -r '.colors | map("\"\(.)\"") | join(",")') +curl http://localhost:8080/init-colors -X POST -d "[$COLORS]" # Start the art-peace frontend echo "Starting art-peace frontend ..." @@ -96,7 +103,7 @@ REACT_CANVAS_CONFIG_FILE=$WORK_DIR/frontend/src/configs/canvas.config.json REACT_BACKEND_CONFIG_FILE=$WORK_DIR/frontend/src/configs/backend.config.json cp $CANVAS_CONFIG_FILE $REACT_CANVAS_CONFIG_FILE #TODO: Use a symlink instead? cp $BACKEND_CONFIG_FILE $REACT_BACKEND_CONFIG_FILE -REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS REACT_APP_CANVAS_CONFIG_FILE=$REACT_CANVAS_CONFIG_FILE REACT_APP_BACKEND_CONFIG_FILE=$REACT_BACKEND_CONFIG_FILE npm start 2>&1 > $FRONTEND_LOG_FILE & +npm start 2>&1 > $FRONTEND_LOG_FILE & FRONTEND_PID=$! sleep 2 # Wait for frontend to start; TODO: Check if frontend is actually running