From 105759257e340f2b5071fa464c20d6e35c699923 Mon Sep 17 00:00:00 2001 From: Brandon R <54774639+b-j-roberts@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:13:19 -0500 Subject: [PATCH] feat: NFT and Templates frontend + backend interactions (#67) * NFTs frontend and devnet interaction, templates upload on frontend and devnet interaction, various patches, ... * Formatting * Fix tests * Go fmt * Fix local integration --- backend/config/backend.go | 12 +- backend/routes/indexer.go | 281 ++++++++++++++- backend/routes/nft.go | 197 +++++++++++ backend/routes/pixel.go | 45 +++ backend/routes/routes.go | 1 + backend/routes/templates.go | 180 +++++++++- configs/backend.config.json | 4 +- configs/docker-backend.config.json | 4 +- docker-compose.yml | 2 + frontend/src/App.js | 77 ++++- frontend/src/canvas/Canvas.css | 27 ++ frontend/src/canvas/Canvas.js | 323 ++++++++++++++---- frontend/src/canvas/ExtraPixelsPanel.css | 123 +++++++ frontend/src/canvas/ExtraPixelsPanel.js | 57 ++++ frontend/src/canvas/PixelSelector.js | 5 +- frontend/src/canvas/TemplateBuilderPanel.css | 103 ++++++ frontend/src/canvas/TemplateBuilderPanel.js | 87 +++++ frontend/src/configs/backend.config.json | 4 +- frontend/src/tabs/ExpandableTab.js | 5 +- frontend/src/tabs/NFTs.css | 50 --- frontend/src/tabs/NFTs.js | 59 ---- frontend/src/tabs/TabPanel.js | 12 +- frontend/src/tabs/Templates.css | 50 --- frontend/src/tabs/Templates.js | 57 ---- frontend/src/tabs/Voting.js | 2 +- frontend/src/tabs/nfts/CollectionItem.css | 56 +++ frontend/src/tabs/nfts/CollectionItem.js | 33 ++ frontend/src/tabs/nfts/NFTItem.css | 84 +++++ frontend/src/tabs/nfts/NFTItem.js | 42 +++ frontend/src/tabs/nfts/NFTs.css | 80 +++++ frontend/src/tabs/nfts/NFTs.js | 89 +++++ frontend/src/tabs/quests/Quests.js | 4 +- frontend/src/tabs/templates/EventItem.css | 107 ++++++ frontend/src/tabs/templates/EventItem.js | 84 +++++ frontend/src/tabs/templates/TemplateItem.css | 118 +++++++ frontend/src/tabs/templates/TemplateItem.js | 54 +++ frontend/src/tabs/templates/Templates.css | 88 +++++ frontend/src/tabs/templates/Templates.js | 217 ++++++++++++ indexer/script.js | 18 +- onchain/Scarb.toml | 2 + onchain/src/art_peace.cairo | 47 ++- onchain/src/nfts/canvas_nft.cairo | 16 +- onchain/src/nfts/component.cairo | 6 +- onchain/src/nfts/interfaces.cairo | 6 +- onchain/src/templates/interfaces.cairo | 1 + onchain/src/tests/art_peace.cairo | 13 +- postgres/init.sql | 15 +- tests/integration/docker/add_template.sh | 36 ++ tests/integration/docker/deploy.sh | 50 ++- tests/integration/docker/initialize.sh | 2 +- tests/integration/docker/mint_nft.sh | 36 ++ .../integration/docker/place_extra_pixels.sh | 36 ++ tests/integration/local/add_template.sh | 36 ++ tests/integration/local/deploy.sh | 52 ++- tests/integration/local/mint_nft.sh | 36 ++ tests/integration/local/place_extra_pixels.sh | 35 ++ tests/integration/local/run.sh | 5 +- 57 files changed, 2888 insertions(+), 383 deletions(-) create mode 100644 backend/routes/nft.go create mode 100644 frontend/src/canvas/ExtraPixelsPanel.css create mode 100644 frontend/src/canvas/ExtraPixelsPanel.js create mode 100644 frontend/src/canvas/TemplateBuilderPanel.css create mode 100644 frontend/src/canvas/TemplateBuilderPanel.js delete mode 100644 frontend/src/tabs/NFTs.css delete mode 100644 frontend/src/tabs/NFTs.js delete mode 100644 frontend/src/tabs/Templates.css delete mode 100644 frontend/src/tabs/Templates.js create mode 100644 frontend/src/tabs/nfts/CollectionItem.css create mode 100644 frontend/src/tabs/nfts/CollectionItem.js create mode 100644 frontend/src/tabs/nfts/NFTItem.css create mode 100644 frontend/src/tabs/nfts/NFTItem.js create mode 100644 frontend/src/tabs/nfts/NFTs.css create mode 100644 frontend/src/tabs/nfts/NFTs.js create mode 100644 frontend/src/tabs/templates/EventItem.css create mode 100644 frontend/src/tabs/templates/EventItem.js create mode 100644 frontend/src/tabs/templates/TemplateItem.css create mode 100644 frontend/src/tabs/templates/TemplateItem.js create mode 100644 frontend/src/tabs/templates/Templates.css create mode 100644 frontend/src/tabs/templates/Templates.js create mode 100755 tests/integration/docker/add_template.sh create mode 100755 tests/integration/docker/mint_nft.sh create mode 100755 tests/integration/docker/place_extra_pixels.sh create mode 100755 tests/integration/local/add_template.sh create mode 100755 tests/integration/local/mint_nft.sh create mode 100755 tests/integration/local/place_extra_pixels.sh diff --git a/backend/config/backend.go b/backend/config/backend.go index 6c4b8b78..61921d11 100644 --- a/backend/config/backend.go +++ b/backend/config/backend.go @@ -6,8 +6,10 @@ import ( ) type BackendScriptsConfig struct { - PlacePixelDevnet string `json:"place_pixel_devnet"` - AddTemplateHashDevnet string `json:"add_template_hash_devnet"` + PlacePixelDevnet string `json:"place_pixel_devnet"` + PlaceExtraPixelsDevnet string `json:"place_extra_pixels_devnet"` + AddTemplateDevnet string `json:"add_template_devnet"` + MintNFTDevnet string `json:"mint_nft_devnet"` } type BackendConfig struct { @@ -21,8 +23,10 @@ var DefaultBackendConfig = BackendConfig{ Host: "localhost", Port: 8080, Scripts: BackendScriptsConfig{ - PlacePixelDevnet: "../scripts/place_pixel.sh", - AddTemplateHashDevnet: "../scripts/add_template_hash.sh", + PlacePixelDevnet: "../scripts/place_pixel.sh", + PlaceExtraPixelsDevnet: "../scripts/place_extra_pixels.sh", + AddTemplateDevnet: "../scripts/add_template.sh", + MintNFTDevnet: "../scripts/mint_nft.sh", }, Production: false, } diff --git a/backend/routes/indexer.go b/backend/routes/indexer.go index 5012075a..77ef66f1 100644 --- a/backend/routes/indexer.go +++ b/backend/routes/indexer.go @@ -2,10 +2,15 @@ package routes import ( "context" + "encoding/hex" "encoding/json" "fmt" + "image" + "image/color" + "image/png" "io" "net/http" + "os" "strconv" "github.com/gorilla/websocket" @@ -55,6 +60,12 @@ func InitIndexerRoutes() { } */ +const ( + pixelPlacedEvent = "0x02d7b50ebf415606d77c7e7842546fc13f8acfbfd16f7bcf2bc2d08f54114c23" + nftMintedEvent = "0x030826e0cd9a517f76e857e3f3100fe5b9098e9f8216d3db283fb4c9a641232f" + templateAddedEvent = "0x03e18ec266fe76a2efce73f91228e6e04456b744fc6984c7a6374e417fb4bf59" +) + // 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) { @@ -74,11 +85,28 @@ func consumeIndexerMsg(w http.ResponseWriter, r *http.Request) { return } - address := reqBody["data"].(map[string]interface{})["batch"].([]interface{})[0].(map[string]interface{})["events"].([]interface{})[0].(map[string]interface{})["event"].(map[string]interface{})["keys"].([]interface{})[1] + events := reqBody["data"].(map[string]interface{})["batch"].([]interface{})[0].(map[string]interface{})["events"].([]interface{}) + + for _, event := range events { + eventKey := event.(map[string]interface{})["event"].(map[string]interface{})["keys"].([]interface{})[0].(string) + if eventKey == pixelPlacedEvent { + processPixelPlacedEvent(event.(map[string]interface{}), w) + } else if eventKey == nftMintedEvent { + processNFTMintedEvent(event.(map[string]interface{}), w) + } else if eventKey == templateAddedEvent { + processTemplateAddedEvent(event.(map[string]interface{}), w) + } else { + fmt.Println("Unknown event key: ", eventKey) + } + } +} + +func processPixelPlacedEvent(event map[string]interface{}, w http.ResponseWriter) { + address := event["event"].(map[string]interface{})["keys"].([]interface{})[1] address = address.(string)[2:] - posHex := reqBody["data"].(map[string]interface{})["batch"].([]interface{})[0].(map[string]interface{})["events"].([]interface{})[0].(map[string]interface{})["event"].(map[string]interface{})["keys"].([]interface{})[2] - dayIdxHex := reqBody["data"].(map[string]interface{})["batch"].([]interface{})[0].(map[string]interface{})["events"].([]interface{})[0].(map[string]interface{})["event"].(map[string]interface{})["keys"].([]interface{})[3] - colorHex := reqBody["data"].(map[string]interface{})["batch"].([]interface{})[0].(map[string]interface{})["events"].([]interface{})[0].(map[string]interface{})["event"].(map[string]interface{})["data"].([]interface{})[0] + posHex := event["event"].(map[string]interface{})["keys"].([]interface{})[2] + dayIdxHex := event["event"].(map[string]interface{})["keys"].([]interface{})[3] + colorHex := event["event"].(map[string]interface{})["data"].([]interface{})[0] // Convert hex to int position, err := strconv.ParseInt(posHex.(string), 0, 64) @@ -141,3 +169,248 @@ func consumeIndexerMsg(w http.ResponseWriter, r *http.Request) { } } } + +/* +indexer-1 | [ +indexer-1 | [Object: null prototype] { +indexer-1 | event: [Object: null prototype] { +indexer-1 | fromAddress: "0x07163dbc0d5dc7e65c8fb9697dbd778e70fa9fef66f12a018a69751eb53fec5a", +indexer-1 | keys: [ +indexer-1 | "0x030826e0cd9a517f76e857e3f3100fe5b9098e9f8216d3db283fb4c9a641232f", +indexer-1 | "0x0000000000000000000000000000000000000000000000000000000000000000", +indexer-1 | "0x0000000000000000000000000000000000000000000000000000000000000000" +indexer-1 | ], +indexer-1 | data: [ +indexer-1 | "0x0000000000000000000000000000000000000000000000000000000000000091", +indexer-1 | "0x000000000000000000000000000000000000000000000000000000000000000e", +indexer-1 | "0x000000000000000000000000000000000000000000000000000000000000000d", +indexer-1 | "0x0000000000000000000000000000000000000000000000000000000000000000", +indexer-1 | "0x0000000000000000000000000000000000000000000000000000000000000006", +indexer-1 | "0x0328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0" +indexer-1 | ], +indexer-1 | index: "1" +indexer-1 | } +indexer-1 | } +indexer-1 | ] +*/ + +func processNFTMintedEvent(event map[string]interface{}, w http.ResponseWriter) { + // TODO: combine high and low token ids + tokenIdLowHex := event["event"].(map[string]interface{})["keys"].([]interface{})[1] + tokenIdHighHex := event["event"].(map[string]interface{})["keys"].([]interface{})[2] + + positionHex := event["event"].(map[string]interface{})["data"].([]interface{})[0] + widthHex := event["event"].(map[string]interface{})["data"].([]interface{})[1] + heightHex := event["event"].(map[string]interface{})["data"].([]interface{})[2] + imageHashHex := event["event"].(map[string]interface{})["data"].([]interface{})[3] + blockNumberHex := event["event"].(map[string]interface{})["data"].([]interface{})[4] + minterHex := event["event"].(map[string]interface{})["data"].([]interface{})[5] + + fmt.Println("NFT minted with token id low: ", tokenIdLowHex, " and token id high: ", tokenIdHighHex) + + tokenId, err := strconv.ParseInt(tokenIdLowHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting token id low hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + position, err := strconv.ParseInt(positionHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting position hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + width, err := strconv.ParseInt(widthHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting width hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + height, err := strconv.ParseInt(heightHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting height hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + blockNumber, err := strconv.ParseInt(blockNumberHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting block number hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + minter := minterHex.(string)[2:] + + fmt.Println("NFT minted with position: ", position, " width: ", width, " height: ", height, " image hash: ", imageHashHex, " block number: ", blockNumber, " minter: ", minter, "tokenId", tokenId) + // Set NFT in postgres + _, err = core.ArtPeaceBackend.Databases.Postgres.Exec(context.Background(), "INSERT INTO NFTs (key, position, width, height, imageHash, blockNumber, minter) VALUES ($1, $2, $3, $4, $5, $6, $7)", tokenId, position, width, height, imageHashHex, blockNumber, minter) + if err != nil { + fmt.Println("Error inserting NFT into postgres: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // TODO: get image from canvas through starknet rpc? What to do about pending transactions? + + // Load image from redis + ctx := context.Background() + // TODO: Better way to get image + canvas, err := core.ArtPeaceBackend.Databases.Redis.Get(ctx, "canvas").Result() + if err != nil { + panic(err) + } + + colorPaletteHex := core.ArtPeaceBackend.CanvasConfig.Colors + colorPalette := make([]color.RGBA, len(colorPaletteHex)) + for idx, colorHex := range colorPaletteHex { + r, err := strconv.ParseInt(colorHex[0:2], 16, 64) + if err != nil { + fmt.Println("Error converting red hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + g, err := strconv.ParseInt(colorHex[2:4], 16, 64) + if err != nil { + fmt.Println("Error converting green hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + b, err := strconv.ParseInt(colorHex[4:6], 16, 64) + if err != nil { + fmt.Println("Error converting blue hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + colorPalette[idx] = color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} + } + bitWidth := int64(core.ArtPeaceBackend.CanvasConfig.ColorsBitWidth) + startX := int64(position % int64(core.ArtPeaceBackend.CanvasConfig.Canvas.Width)) + startY := int64(position / int64(core.ArtPeaceBackend.CanvasConfig.Canvas.Width)) + oneByteBitOffset := int64(8 - bitWidth) + twoByteBitOffset := int64(16 - bitWidth) + generatedImage := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + for y := startY; y < startY+height; y++ { + for x := startX; x < startX+width; x++ { + pos := y*int64(core.ArtPeaceBackend.CanvasConfig.Canvas.Width) + x + bitPos := pos * bitWidth + bytePos := bitPos / 8 + bitOffset := bitPos % 8 + if bitOffset <= oneByteBitOffset { + colorIdx := (canvas[bytePos] >> (oneByteBitOffset - bitOffset)) & 0b11111 + generatedImage.Set(int(x-startX), int(y-startY), colorPalette[colorIdx]) + } else { + colorIdx := (((uint16(canvas[bytePos]) << 8) | uint16(canvas[bytePos+1])) >> (twoByteBitOffset - bitOffset)) & 0b11111 + generatedImage.Set(int(x-startX), int(y-startY), colorPalette[colorIdx]) + } + } + } + + // TODO: Path to save image + // Save image to disk + filename := fmt.Sprintf("nft-%d.png", tokenId) + file, err := os.Create(filename) + if err != nil { + fmt.Println("Error creating file: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer file.Close() + + err = png.Encode(file, generatedImage) + if err != nil { + fmt.Println("Error encoding image: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write([]byte("NFT minted")) +} + +func processTemplateAddedEvent(event map[string]interface{}, w http.ResponseWriter) { + templateIdHex := event["event"].(map[string]interface{})["keys"].([]interface{})[1] + templateHashHex := event["event"].(map[string]interface{})["data"].([]interface{})[0] + templateNameHex := event["event"].(map[string]interface{})["data"].([]interface{})[1] + templatePositionHex := event["event"].(map[string]interface{})["data"].([]interface{})[2] + templateWidthHex := event["event"].(map[string]interface{})["data"].([]interface{})[3] + templateHeightHex := event["event"].(map[string]interface{})["data"].([]interface{})[4] + // TODO: Combine low and high token ids + templateRewardHighHex := event["event"].(map[string]interface{})["data"].([]interface{})[5] + templateRewardLowHex := event["event"].(map[string]interface{})["data"].([]interface{})[6] + templateRewardTokenHex := event["event"].(map[string]interface{})["data"].([]interface{})[7] + + fmt.Println("Template added with template id: ", templateIdHex, " template hash: ", templateHashHex, "reward: ", templateRewardLowHex, templateRewardHighHex, "name:", templateNameHex) + + templateId, err := strconv.ParseInt(templateIdHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting template id hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Parse template name hex as bytes encoded in utf-8 + decodedName, err := hex.DecodeString(templateNameHex.(string)[2:]) + if err != nil { + fmt.Println("Error decoding template name hex: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + // Trim off 0s at the start + trimmedName := []byte{} + trimming := true + for _, b := range decodedName { + if b == 0 && trimming { + continue + } + trimming = false + trimmedName = append(trimmedName, b) + } + templateName := string(trimmedName) + + templatePosition, err := strconv.ParseInt(templatePositionHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting template position hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + templateWidth, err := strconv.ParseInt(templateWidthHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting template width hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + templateHeight, err := strconv.ParseInt(templateHeightHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting template height hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + templateReward, err := strconv.ParseInt(templateRewardLowHex.(string), 0, 64) + if err != nil { + fmt.Println("Error converting template reward hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + templateRewardToken := templateRewardTokenHex.(string)[2:] + + // Add template to postgres + _, err = core.ArtPeaceBackend.Databases.Postgres.Exec(context.Background(), "INSERT INTO Templates (key, name, hash, position, width, height, reward, rewardToken) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", templateId, templateName, templateHashHex, templatePosition, templateWidth, templateHeight, templateReward, templateRewardToken) + if err != nil { + fmt.Println("Error inserting template into postgres: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write([]byte("Template added")) +} diff --git a/backend/routes/nft.go b/backend/routes/nft.go new file mode 100644 index 00000000..d902325e --- /dev/null +++ b/backend/routes/nft.go @@ -0,0 +1,197 @@ +package routes + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strconv" + + "github.com/keep-starknet-strange/art-peace/backend/core" +) + +func InitNFTRoutes() { + http.HandleFunc("/get-nft", getNFT) + http.HandleFunc("/get-nfts", getNFTs) + http.HandleFunc("/get-my-nfts", getMyNFTs) + http.HandleFunc("/mint-nft-devnet", mintNFTDevnet) + // Create a static file server for the nft images + http.Handle("/nft-images/", http.StripPrefix("/nft-images/", http.FileServer(http.Dir(".")))) + //http.HandleFunc("/nft-image", nftImage) +} + +type NFTData struct { + TokenID int `json:"tokenId"` + Position int `json:"position"` + Width int `json:"width"` + Height int `json:"height"` + ImageHash string `json:"imageHash"` + BlockNumber int `json:"blockNumber"` + Minter string `json:"minter"` +} + +func getNFT(w http.ResponseWriter, r *http.Request) { + tokenId := r.URL.Query().Get("tokenId") + + var nftData NFTData + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), "SELECT * FROM nfts WHERE token_id = $1", tokenId) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + defer rows.Close() + + err = rows.Scan(&nftData.TokenID, &nftData.Position, &nftData.Width, &nftData.Height, &nftData.ImageHash, &nftData.BlockNumber, &nftData.Minter) + if err != nil { + 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(nftData) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte(out)) +} + +func getMyNFTs(w http.ResponseWriter, r *http.Request) { + address := r.URL.Query().Get("address") + + var nftDatas []NFTData + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), "SELECT * FROM nfts WHERE minter = $1", address) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + defer rows.Close() + + for rows.Next() { + var nftData NFTData + err = rows.Scan(&nftData.TokenID, &nftData.Position, &nftData.Width, &nftData.Height, &nftData.ImageHash, &nftData.BlockNumber, &nftData.Minter) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + nftDatas = append(nftDatas, nftData) + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + out, err := json.Marshal(nftDatas) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte(out)) +} + +func getNFTs(w http.ResponseWriter, r *http.Request) { + // TODO: Pagination & Likes + var nftDatas []NFTData + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), "SELECT * FROM nfts") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + defer rows.Close() + + for rows.Next() { + var nftData NFTData + err = rows.Scan(&nftData.TokenID, &nftData.Position, &nftData.Width, &nftData.Height, &nftData.ImageHash, &nftData.BlockNumber, &nftData.Minter) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + nftDatas = append(nftDatas, nftData) + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + out, err := json.Marshal(nftDatas) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte(out)) +} + +// func nftImage(w http.ResponseWriter, r *http.Request) { +// // Get the png image at location "nft-{tokenId}.png" +// tokenId := r.URL.Query().Get("tokenId") +// imageLocation := fmt.Sprintf("nft-%s.png", tokenId) +// +// image, err := os.Open(imageLocation) +// if err != nil { +// w.WriteHeader(http.StatusInternalServerError) +// w.Write([]byte(err.Error())) +// return +// } +// defer image.Close() +// +// w.Header().Set("Access-Control-Allow-Origin", "*") +// w.Header().Set("Content-Type", "image/png") +// w.WriteHeader(http.StatusOK) +// +// io.Copy(w, image) +// } + +func mintNFTDevnet(w http.ResponseWriter, r *http.Request) { + reqBody, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + var jsonBody map[string]string + err = json.Unmarshal(reqBody, &jsonBody) + if err != nil { + panic(err) + } + + position, err := strconv.Atoi(jsonBody["position"]) + if err != nil { + panic(err) + } + + width, err := strconv.Atoi(jsonBody["width"]) + if err != nil { + panic(err) + } + + height, err := strconv.Atoi(jsonBody["height"]) + if err != nil { + panic(err) + } + + shellCmd := core.ArtPeaceBackend.BackendConfig.Scripts.MintNFTDevnet + contract := os.Getenv("ART_PEACE_CONTRACT_ADDRESS") + + cmd := exec.Command(shellCmd, contract, "mint_nft", strconv.Itoa(position), strconv.Itoa(width), strconv.Itoa(height)) + _, err = cmd.Output() + if err != nil { + fmt.Println("Error executing shell command: ", err) + panic(err) + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write([]byte("Minted NFT on devnet")) +} diff --git a/backend/routes/pixel.go b/backend/routes/pixel.go index f1279503..5d995014 100644 --- a/backend/routes/pixel.go +++ b/backend/routes/pixel.go @@ -18,6 +18,7 @@ func InitPixelRoutes() { http.HandleFunc("/getPixelInfo", getPixelInfo) if !core.ArtPeaceBackend.BackendConfig.Production { http.HandleFunc("/placePixelDevnet", placePixelDevnet) + http.HandleFunc("/placeExtraPixelsDevnet", placeExtraPixelsDevnet) } http.HandleFunc("/placePixelRedis", placePixelRedis) } @@ -92,6 +93,50 @@ func placePixelDevnet(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Pixel placed")) } +func placeExtraPixelsDevnet(w http.ResponseWriter, r *http.Request) { + // TODO: Disable in production + reqBody, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + // TODO: Pixel position instead of x, y + // Json data format: + /* + { + "extraPixels": [ + { "x": 0, "y": 0, "colorId": 1 }, + { "x": 1, "y": 0, "colorId": 2 }, + ] + } + */ + var jsonBody map[string][]map[string]int + err = json.Unmarshal(reqBody, &jsonBody) + if err != nil { + panic(err) + } + + shellCmd := core.ArtPeaceBackend.BackendConfig.Scripts.PlaceExtraPixelsDevnet + contract := os.Getenv("ART_PEACE_CONTRACT_ADDRESS") + + positions := strconv.Itoa(len(jsonBody["extraPixels"])) + colors := strconv.Itoa(len(jsonBody["extraPixels"])) + for _, pixel := range jsonBody["extraPixels"] { + pos := pixel["x"] + pixel["y"]*int(core.ArtPeaceBackend.CanvasConfig.Canvas.Width) + positions += " " + strconv.Itoa(pos) + colors += " " + strconv.Itoa(pixel["colorId"]) + } + + cmd := exec.Command(shellCmd, contract, "place_extra_pixels", positions, colors) + _, err = cmd.Output() + if err != nil { + fmt.Println("Error executing shell command: ", err) + panic(err) + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write([]byte("Extra pixels placed")) +} + func placePixelRedis(w http.ResponseWriter, r *http.Request) { // TODO: Only allow mods to place pixels on redis instance reqBody, err := io.ReadAll(r.Body) diff --git a/backend/routes/routes.go b/backend/routes/routes.go index 97bb3226..4dedcc93 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -8,6 +8,7 @@ func InitRoutes() { InitTemplateRoutes() InitUserRoutes() InitContractRoutes() + InitNFTRoutes() InitQuestsRoutes() InitColorsRoutes() } diff --git a/backend/routes/templates.go b/backend/routes/templates.go index 88e5a9b8..934cdecf 100644 --- a/backend/routes/templates.go +++ b/backend/routes/templates.go @@ -1,15 +1,19 @@ package routes import ( + "context" "encoding/json" "fmt" "image" - _ "image/png" + "image/color" + "image/png" "io" "io/ioutil" "net/http" "os" "os/exec" + "strconv" + "strings" "github.com/NethermindEth/juno/core/crypto" "github.com/NethermindEth/juno/core/felt" @@ -18,10 +22,12 @@ import ( ) func InitTemplateRoutes() { + http.HandleFunc("/get-templates", getTemplates) http.HandleFunc("/addTemplateImg", addTemplateImg) - http.HandleFunc("/addTemplateData", addTemplateData) + http.HandleFunc("/add-template-data", addTemplateData) + http.Handle("/templates/", http.StripPrefix("/templates/", http.FileServer(http.Dir(".")))) if !core.ArtPeaceBackend.BackendConfig.Production { - http.HandleFunc("/addTemplateHashDevnet", addTemplateHashDevnet) + http.HandleFunc("/add-template-devnet", addTemplateDevnet) } } @@ -43,6 +49,49 @@ func imageToPixelData(imageData []byte) []byte { return []byte{0, 1, 1, 2, 2, 3} } +type TemplateData struct { + Key int `json:"key"` + Name string `json:"name"` + Hash string `json:"hash"` + Width int `json:"width"` + Height int `json:"height"` + Position int `json:"position"` + Reward int `json:"reward"` + RewardToken string `json:"rewardToken"` +} + +func getTemplates(w http.ResponseWriter, r *http.Request) { + var templates []TemplateData + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), "SELECT * FROM templates") + if err != nil { + fmt.Println("Error querying templates: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var template TemplateData + err := rows.Scan(&template.Key, &template.Name, &template.Hash, &template.Width, &template.Height, &template.Position, &template.Reward, &template.RewardToken) + if err != nil { + fmt.Println("Error scanning template: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + templates = append(templates, template) + } + + out, err := json.Marshal(templates) + if err != nil { + fmt.Println("Error marshalling templates: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write(out) +} + func addTemplateImg(w http.ResponseWriter, r *http.Request) { file, _, err := r.FormFile("image") if err != nil { @@ -52,11 +101,11 @@ func addTemplateImg(w http.ResponseWriter, r *http.Request) { // Create a temporary file to store the uploaded file // TODO: change location & determine valid file types - tempFile, err := ioutil.TempFile("temp-images", "upload-*.png") - if err != nil { - panic(err) - } - defer tempFile.Close() + // tempFile, err := ioutil.TempFile("temp-images", "upload-*.png") + // if err != nil { + // panic(err) + // } + // defer tempFile.Close() // Decode the image to check dimensions img, format, err := image.Decode(file) @@ -76,7 +125,7 @@ func addTemplateImg(w http.ResponseWriter, r *http.Request) { if err != nil { panic(err) } - tempFile.Write(fileBytes) + // tempFile.Write(fileBytes) r.Body.Close() @@ -94,20 +143,94 @@ func addTemplateData(w http.ResponseWriter, r *http.Request) { if err != nil { panic(err) } + // Map like {"width": "64", "height": "64", "image": byte array} var jsonBody map[string]string err = json.Unmarshal(reqBody, &jsonBody) if err != nil { panic(err) } - hash := hashTemplateImage([]byte(jsonBody["image"])) + width, err := strconv.Atoi(jsonBody["width"]) + if err != nil { + panic(err) + } + + height, err := strconv.Atoi(jsonBody["height"]) + if err != nil { + panic(err) + } + + imageData := jsonBody["image"] + // Split string by comma + imageSplit := strings.Split(imageData, ",") + imageBytes := make([]byte, len(imageSplit)) + for idx, val := range imageSplit { + valInt, err := strconv.Atoi(val) + if err != nil { + panic(err) + } + imageBytes[idx] = byte(valInt) + } + + hash := hashTemplateImage(imageBytes) // TODO: Store image hash and pixel data in database + colorPaletteHex := core.ArtPeaceBackend.CanvasConfig.Colors + colorPalette := make([]color.RGBA, len(colorPaletteHex)) + for idx, colorHex := range colorPaletteHex { + r, err := strconv.ParseInt(colorHex[0:2], 16, 64) + if err != nil { + fmt.Println("Error converting red hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + g, err := strconv.ParseInt(colorHex[2:4], 16, 64) + if err != nil { + fmt.Println("Error converting green hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + b, err := strconv.ParseInt(colorHex[4:6], 16, 64) + if err != nil { + fmt.Println("Error converting blue hex to int: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + colorPalette[idx] = color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} + } + generatedImage := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + for y := 0; y < int(height); y++ { + for x := 0; x < int(width); x++ { + pos := y*int(width) + x + colorIdx := int(imageBytes[pos]) + if colorIdx < len(colorPalette) { + generatedImage.Set(x, y, colorPalette[colorIdx]) + } + } + } + + // TODO: Path to store generated image + filename := fmt.Sprintf("template-%s.png", hash) + file, err := os.Create(filename) + if err != nil { + fmt.Println("Error creating file: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer file.Close() + + err = png.Encode(file, generatedImage) + if err != nil { + fmt.Println("Error encoding image: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") w.Write([]byte(hash)) } -func addTemplateHashDevnet(w http.ResponseWriter, r *http.Request) { +func addTemplateDevnet(w http.ResponseWriter, r *http.Request) { // Disable this in production if core.ArtPeaceBackend.BackendConfig.Production { http.Error(w, "Not available in production", http.StatusNotImplemented) @@ -124,11 +247,40 @@ func addTemplateHashDevnet(w http.ResponseWriter, r *http.Request) { panic(err) } + hash := jsonBody["hash"] + + // name to hex encoding using utf-8 bytes + name := jsonBody["name"] + nameHex := fmt.Sprintf("0x%x", name) + + position, err := strconv.Atoi(jsonBody["position"]) + if err != nil { + panic(err) + } + + width, err := strconv.Atoi(jsonBody["width"]) + if err != nil { + panic(err) + } + + height, err := strconv.Atoi(jsonBody["height"]) + if err != nil { + panic(err) + } + + // TODO: u256 + reward, err := strconv.Atoi(jsonBody["reward"]) + if err != nil { + panic(err) + } + + rewardToken := jsonBody["rewardToken"] + // TODO: Create this script - shellCmd := core.ArtPeaceBackend.BackendConfig.Scripts.AddTemplateHashDevnet + shellCmd := core.ArtPeaceBackend.BackendConfig.Scripts.AddTemplateDevnet // TODO: remove contract from jsonBody contract := os.Getenv("ART_PEACE_CONTRACT_ADDRESS") - cmd := exec.Command(shellCmd, contract, "add_template", jsonBody["hash"]) + cmd := exec.Command(shellCmd, contract, "add_template", hash, nameHex, strconv.Itoa(position), strconv.Itoa(width), strconv.Itoa(height), strconv.Itoa(reward), rewardToken) _, err = cmd.Output() if err != nil { fmt.Println("Error executing shell command: ", err) @@ -136,5 +288,5 @@ func addTemplateHashDevnet(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Access-Control-Allow-Origin", "*") - w.Write([]byte("Hash added to devnet")) + w.Write([]byte("Template added to devnet")) } diff --git a/configs/backend.config.json b/configs/backend.config.json index 228cf22b..06f5881d 100644 --- a/configs/backend.config.json +++ b/configs/backend.config.json @@ -3,7 +3,9 @@ "port": 8080, "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", - "add_template_hash_devnet": "../tests/integration/local/add_template_hash.sh" + "place_extra_pixels_devnet": "../tests/integration/local/place_extra_pixels.sh", + "add_template_devnet": "../tests/integration/local/add_template.sh", + "mint_nft_devnet": "../tests/integration/local/mint_nft.sh" }, "production": false } diff --git a/configs/docker-backend.config.json b/configs/docker-backend.config.json index 16a2e491..bc35db6b 100644 --- a/configs/docker-backend.config.json +++ b/configs/docker-backend.config.json @@ -3,7 +3,9 @@ "port": 8080, "scripts": { "place_pixel_devnet": "/scripts/place_pixel.sh", - "add_template_hash_devnet": "/scripts/add_template_hash.sh" + "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 25796567..4e9e2252 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,8 @@ services: depends_on: deployer: condition: service_completed_successfully + apibara: + condition: service_started links: - backend - apibara diff --git a/frontend/src/App.js b/frontend/src/App.js index 43656ad4..40a00d8b 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,13 +1,16 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useMediaQuery } from 'react-responsive' import './App.css'; import Canvas from './canvas/Canvas.js'; import PixelSelector from './canvas/PixelSelector.js'; import SelectedPixelPanel from './canvas/SelectedPixelPanel.js'; +import ExtraPixelsPanel from './canvas/ExtraPixelsPanel.js'; +import TemplateBuilderPanel from './canvas/TemplateBuilderPanel.js'; import TabsFooter from './tabs/TabsFooter.js'; import TabPanel from './tabs/TabPanel.js'; import { usePreventZoom } from './utils/Window.js'; import logo from './resources/logo.png'; +import canvasConfig from "./configs/canvas.config.json" function App() { // Window management @@ -33,6 +36,13 @@ function App() { } } + // Canvas + const width = canvasConfig.canvas.width + const height = canvasConfig.canvas.height + + const canvasRef = useRef(null); + const extraCanvasRef = useRef(null); + // Pixel selection data const [selectedColorId, setSelectedColorId] = useState(-1); const [pixelSelectedMode, setPixelSelectedMode] = useState(false); @@ -40,18 +50,71 @@ function App() { const [selectedPositionY, setSelectedPositionY] = useState(null) const [pixelPlacedBy, setPixelPlacedBy] = useState(""); + const [extraPixels, setExtraPixels] = useState(42); // TODO: fetch from server + const [extraPixelsUsed, setExtraPixelsUsed] = useState(0); + const [extraPixelsData, setExtraPixelsData] = useState([]); + const clearPixelSelection = () => { setSelectedPositionX(null); setSelectedPositionY(null); setPixelSelectedMode(false); + setPixelPlacedBy(""); } const setPixelSelection = (x, y) => { setSelectedPositionX(x); setSelectedPositionY(y); setPixelSelectedMode(true); + // TODO: move http fetch for pixel data here? } + const clearExtraPixels = useCallback(() => { + setExtraPixelsUsed(0); + setExtraPixelsData([]); + + const canvas = extraCanvasRef.current + const context = canvas.getContext('2d') + context.clearRect(0, 0, width, height) + }, [width, height]) + + // TODO: thread safety? + const clearExtraPixel = useCallback((index) => { + setExtraPixelsUsed(extraPixelsUsed - 1); + setExtraPixelsData(extraPixelsData.filter((_, i) => i !== index)); + const canvas = extraCanvasRef.current + const context = canvas.getContext('2d') + const pixel = extraPixelsData[index] + const x = pixel.x + const y = pixel.y + context.clearRect(x, y, 1, 1) + }, [extraPixelsData, extraPixelsUsed, setExtraPixelsData, setExtraPixelsUsed]) + + const addExtraPixel = useCallback((x, y) => { + // Overwrite pixel if already placed + const existingPixelIndex = extraPixelsData.findIndex((pixel) => pixel.x === x && pixel.y === y) + if (existingPixelIndex !== -1) { + let newExtraPixelsData = [...extraPixelsData] + newExtraPixelsData[existingPixelIndex].colorId = selectedColorId + setExtraPixelsData(newExtraPixelsData) + } else { + setExtraPixelsUsed(extraPixelsUsed + 1); + setExtraPixelsData([...extraPixelsData, {x: x, y: y, colorId: selectedColorId}]); + } + }, [extraPixelsData, extraPixelsUsed, selectedColorId]) + + // Templates + const [templateCreationMode, setTemplateCreationMode] = useState(false); + const [templatePlacedMode, setTemplatePlacedMode] = useState(false); + const [templateImage, setTemplateImage] = useState(null); + const [templateColorIds, setTemplateColorIds] = useState([]); + const [templateImagePositionX, setTemplateImagePositionX] = useState(0) + const [templateImagePositionY, setTemplateImagePositionY] = useState(0) + const [templateImagePosition, setTemplateImagePosition] = useState(0) + + // NFTs + const [nftSelectionMode, setNftSelectionMode] = useState(false); + + // Timing const [timeLeftInDay, setTimeLeftInDay] = useState(''); const startTime = "15:00"; const [hours, minutes] = startTime.split(":"); @@ -83,7 +146,7 @@ function App() { return (
- + { !isDesktopOrLaptop && ( logo )} @@ -91,10 +154,16 @@ function App() { { (!isPortrait ? pixelSelectedMode : pixelSelectedMode && activeTab === tabs[0]) && ( )} - + { (!isPortrait ? extraPixelsUsed > 0 : extraPixelsUsed > 0 && activeTab === tabs[0]) && ( + + )} + { (templateCreationMode || templatePlacedMode) && ( + + )} +
- +
diff --git a/frontend/src/canvas/Canvas.css b/frontend/src/canvas/Canvas.css index dcaea0fa..19a23e70 100644 --- a/frontend/src/canvas/Canvas.css +++ b/frontend/src/canvas/Canvas.css @@ -25,6 +25,14 @@ box-shadow: 0 0 0.05rem 0.01rem rgba(0, 0, 0, 0.4); } +.Canvas__extras { + position: absolute; + left: 0; + z-index: 3; + background-color: rgba(0, 0, 0, 0); + image-rendering: pixelated; +} + .Canvas__selected { position: absolute; z-index: 4; @@ -44,3 +52,22 @@ pointer-events: none; } + +.Canvas__nftSelection { + position: absolute; + z-index: 5; + + background-color: rgba(0, 0, 0, 0); + outline: 0.1px solid rgba(255, 0, 0, 0.5); +} + +.Canvas__template { + position: absolute; + z-index: 6; + + background-color: rgba(0, 0, 0, 0); + image-rendering: pixelated; + opacity: 0.5; + + outline: 0.1px solid rgba(255, 0, 0, 0.5); +} diff --git a/frontend/src/canvas/Canvas.js b/frontend/src/canvas/Canvas.js index 70f2a0c7..e29ae923 100644 --- a/frontend/src/canvas/Canvas.js +++ b/frontend/src/canvas/Canvas.js @@ -110,7 +110,7 @@ const Canvas = props => { if (setup) { return; } - const canvas = canvasRef.current + const canvas = props.canvasRef.current const context = canvas.getContext('2d') let getCanvasEndpoint = backendUrl + "/getCanvas" @@ -173,67 +173,173 @@ const Canvas = props => { setupColors, ]); + const colorPixel = useCallback((position, color) => { + const canvas = props.canvasRef.current + const context = canvas.getContext('2d') + const x = position % width + const y = Math.floor(position / width) + const colorIdx = color + const colorHex = "#" + colors[colorIdx] + "FF" + context.fillStyle = colorHex + context.fillRect(x, y, 1, 1) + }, [colors, width]) + + const colorExtraPixel = useCallback((position, color) => { + const canvas = props.extraCanvasRef.current + const context = canvas.getContext('2d') + const x = position % width + const y = Math.floor(position / width) + const colorIdx = color + const colorHex = "#" + colors[colorIdx] + "FF" + context.fillStyle = colorHex + context.fillRect(x, y, 1, 1) + }, [colors, width]) + useEffect(() => { if (lastJsonMessage) { - const canvas = canvasRef.current; - const context = canvas.getContext("2d"); - const x = lastJsonMessage.position % width; - const y = Math.floor(lastJsonMessage.position / width); - const colorIdx = lastJsonMessage.color; - const color = "#" + colors[colorIdx] + "FF"; - //const [r, g, b, a] = color.match(/\w\w/g).map(x => parseInt(x, 16)) - context.fillStyle = color; - context.fillRect(x, y, 1, 1); + colorPixel(lastJsonMessage.position, lastJsonMessage.color) } - }, [lastJsonMessage, colors, width]); - - const pixelSelect = useCallback( - (clientX, clientY) => { - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const x = Math.floor( - ((clientX - rect.left) / (rect.right - rect.left)) * width - ); - const y = Math.floor( - ((clientY - rect.top) / (rect.bottom - rect.top)) * height - ); - if ( - props.selectedColorId === -1 && - props.pixelSelectedMode && - props.selectedPositionX === x && - props.selectedPositionY === y - ) { - props.clearPixelSelection(); - return; - } - if (x < 0 || x >= width || y < 0 || y >= height) { - return; - } - props.setPixelSelection(x, y); + }, [lastJsonMessage, colorPixel]) + + const pixelSelect = useCallback((clientX, clientY) => { + const canvas = props.canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = Math.floor((clientX - rect.left) / (rect.right - rect.left) * width) + const y = Math.floor((clientY - rect.top) / (rect.bottom - rect.top) * height) + if (props.selectedColorId === -1 && props.pixelSelectedMode && props.selectedPositionX === x && props.selectedPositionY === y) { + props.clearPixelSelection() + return + } + if (x < 0 || x >= width || y < 0 || y >= height) { + return + } + props.setPixelSelection(x, y) - const position = y * width + x; - let getPixelInfoEndpoint = - backendUrl + "/getPixelInfo?position=" + position.toString(); - fetch(getPixelInfoEndpoint, { - mode: "cors", + const position = y * width + x; + let getPixelInfoEndpoint = + backendUrl + "/getPixelInfo?position=" + position.toString(); + fetch(getPixelInfoEndpoint, { + mode: "cors", + }) + .then((response) => { + return response.text(); }) - .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); - }); + .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) => { - pixelSelect(e.clientX, e.clientY); + if (props.nftSelectionMode) { + if (!nftSelectionStarted) { + const canvas = props.canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = Math.floor((e.clientX - rect.left) / (rect.right - rect.left) * width) + const y = Math.floor((e.clientY - rect.top) / (rect.bottom - rect.top) * height) + if (x < 0 || x >= width || y < 0 || y >= height) { + return + } + setNftSelectionStartX(x) + setNftSelectionStartY(y) + setNftSelectionEndX(x+1) + setNftSelectionEndY(y+1) + setNftSelectionPositionX(x) + setNftSelectionPositionY(y) + setNftSelectionWidth(1) + setNftSelectionHeight(1) + setNftSelectionStarted(true) + return + } else { + const canvas = props.canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = Math.floor((e.clientX - rect.left) / (rect.right - rect.left) * width) + const y = Math.floor((e.clientY - rect.top) / (rect.bottom - rect.top) * height) + if (x < 0 || x >= width || y < 0 || y >= height) { + return + } + setNftSelectionEndX(x+1) + setNftSelectionEndY(y+1) + if (nftSelectionEndX <= nftSelectionStartX) { + setNftSelectionPositionX(nftSelectionEndX - 1) + setNftSelectionWidth(nftSelectionStartX - nftSelectionEndX + 1) + } else { + setNftSelectionPositionX(nftSelectionStartX) + setNftSelectionWidth(nftSelectionEndX - nftSelectionStartX) + } + if (nftSelectionEndY <= nftSelectionStartY) { + setNftSelectionPositionY(nftSelectionEndY - 1) + setNftSelectionHeight(nftSelectionStartY - nftSelectionEndY + 1) + } else { + setNftSelectionPositionY(nftSelectionStartY) + setNftSelectionHeight(nftSelectionEndY - nftSelectionStartY) + } + console.log("Making NFT with position: ", nftSelectionPositionX, nftSelectionPositionY, nftSelectionWidth, nftSelectionHeight) + + // Mint NFT + let mintNFTEndpoint = backendUrl + "/mint-nft-devnet" + let nftPosition = nftSelectionPositionX + nftSelectionPositionY * width + let nftWidth = nftSelectionWidth + let nftHeight = nftSelectionHeight + fetch(mintNFTEndpoint, { + mode: "cors", + method: "POST", + body: JSON.stringify({ + position: nftPosition.toString(), + width: nftWidth.toString(), + height: nftHeight.toString(), + }), + }).then(response => { + return response.text() + }).then(data => { + console.log(data) + }).catch(error => { + console.error("Error minting nft") + console.error(error) + }); + + setNftSelectionStarted(false) + setNftSelectionPositionX(-1) + setNftSelectionPositionY(-1) + setNftSelectionWidth(0) + setNftSelectionHeight(0) + setNftSelectionStartX(0) + setNftSelectionStartY(0) + setNftSelectionEndX(0) + setNftSelectionEndY(0) + props.setNftSelectionMode(false) + + return + } + } + + if (props.templateCreationMode) { + const canvas = props.canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = Math.floor((e.clientX - rect.left) / (rect.right - rect.left) * width) + const y = Math.floor((e.clientY - rect.top) / (rect.bottom - rect.top) * height) + if (x < 0 || x >= width || y < 0 || y >= height) { + return + } + + let templatePosition = x + y * width + // TODO: Template preview + + 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; } @@ -241,7 +347,18 @@ const Canvas = props => { return; } - + if (props.extraPixels > 0) { + // TODO: allow overwrite on all pixels used + if (props.extraPixelsUsed < props.extraPixels) { + props.addExtraPixel(props.selectedPositionX, props.selectedPositionY) + colorExtraPixel(props.selectedPositionX + props.selectedPositionY * width, props.selectedColorId) + return + } else { + // TODO: Notify user of no more extra pixels + return + } + } + const position = props.selectedPositionX + props.selectedPositionY * width const colorIdx = props.selectedColorId let placePixelEndpoint = backendUrl + "/placePixelDevnet" @@ -336,8 +453,86 @@ const Canvas = props => { return "#" + colors[props.selectedColorId] + "FF"; }; + // TODO + //const templateImage = [1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 4, 3] + //const templateWidth = 4 + //const templatePositionX = 13 + //const templatePositionY = 13 + // + + // TODO + // TODO: setup nft selection mode func w/ stop pixel selection + const [nftSelectionPositionX, setNftSelectionPositionX] = useState(-1) + const [nftSelectionPositionY, setNftSelectionPositionY] = useState(-1) + const [nftSelectionWidth, setNftSelectionWidth] = useState(0) + const [nftSelectionHeight, setNftSelectionHeight] = useState(0) + const [nftSelectionStartX, setNftSelectionStartX] = useState(0) + const [nftSelectionStartY, setNftSelectionStartY] = useState(0) + const [nftSelectionEndX, setNftSelectionEndX] = useState(0) + const [nftSelectionEndY, setNftSelectionEndY] = useState(0) + const [nftSelectionStarted, setNftSelectionStarted] = useState(false) + useEffect(() => { const setFromEvent = (e) => { + if (props.nftSelectionMode) { + if (!nftSelectionStarted) { + const canvas = props.canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = Math.floor((e.clientX - rect.left) / (rect.right - rect.left) * width) + const y = Math.floor((e.clientY - rect.top) / (rect.bottom - rect.top) * height) + if (x < 0 || x >= width || y < 0 || y >= height) { + return + } + setNftSelectionStartX(x) + setNftSelectionStartY(y) + setNftSelectionEndX(x+1) + setNftSelectionEndY(y+1) + setNftSelectionPositionX(x) + setNftSelectionPositionY(y) + setNftSelectionWidth(1) + setNftSelectionHeight(1) + return + } else { + const canvas = props.canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = Math.floor((e.clientX - rect.left) / (rect.right - rect.left) * width) + const y = Math.floor((e.clientY - rect.top) / (rect.bottom - rect.top) * height) + if (x < 0 || x >= width || y < 0 || y >= height) { + return + } + setNftSelectionEndX(x+1) + setNftSelectionEndY(y+1) + if (nftSelectionEndX <= nftSelectionStartX) { + setNftSelectionPositionX(nftSelectionEndX - 1) + setNftSelectionWidth(nftSelectionStartX - nftSelectionEndX + 1) + } else { + setNftSelectionPositionX(nftSelectionStartX) + setNftSelectionWidth(nftSelectionEndX - nftSelectionStartX) + } + if (nftSelectionEndY <= nftSelectionStartY) { + setNftSelectionPositionY(nftSelectionEndY - 1) + setNftSelectionHeight(nftSelectionStartY - nftSelectionEndY + 1) + } else { + setNftSelectionPositionY(nftSelectionStartY) + setNftSelectionHeight(nftSelectionEndY - nftSelectionStartY) + } + return + } + } + if (props.templateCreationMode) { + const canvas = props.canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = Math.floor((e.clientX - rect.left) / (rect.right - rect.left) * width) + const y = Math.floor((e.clientY - rect.top) / (rect.bottom - rect.top) * height) + if (x < 0 || x >= width || y < 0 || y >= height) { + return + } + // TODO: Stop template overflows + props.setTemplateImagePositionX(x) + props.setTemplateImagePositionY(y) + return + } + if (props.selectedColorId === -1) { return; } @@ -348,14 +543,7 @@ const Canvas = props => { return () => { window.removeEventListener("mousemove", setFromEvent); }; - }, [props.selectedColorId, pixelSelect]); - - // TODO - //const templateImage = [1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 4, 3] - //const templateWidth = 4 - //const templatePositionX = 13 - //const templatePositionY = 13 - // + }, [props.selectedColorId, pixelSelect, props.nftSelectionMode, nftSelectionStarted, nftSelectionPositionX, nftSelectionPositionY, nftSelectionWidth, nftSelectionHeight, height, width, props.canvasRef, nftSelectionEndX, nftSelectionEndY, nftSelectionStartX, nftSelectionStartY]); // TODO: both place options return ( @@ -369,12 +557,21 @@ const Canvas = props => { )} - + + { props.extraPixels > 0 && ( + + )} + { props.nftSelectionMode && ( +
+
+ )} + { (props.templateCreationMode || props.templatePlacedMode) && ( + Template + )} ); }; - export default Canvas; diff --git a/frontend/src/canvas/ExtraPixelsPanel.css b/frontend/src/canvas/ExtraPixelsPanel.css new file mode 100644 index 00000000..63497b97 --- /dev/null +++ b/frontend/src/canvas/ExtraPixelsPanel.css @@ -0,0 +1,123 @@ +.ExtraPixelsPanel { + position: relative; + width: 100%; + margin-bottom: 0.5rem; + + display: flex; + flex-direction: column; + + background-image: linear-gradient(to bottom right, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.4)); + z-index: 100; + border-radius: 1rem; + box-shadow: 0 0 1rem 0.1rem rgba(0, 0, 0, 0.2); +} + +.ExtraPixelsPanel__header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.2rem 0.2rem; + margin: 0 0.5rem; +} + +.ExtraPixelsPanel__title { + font-size: 1.2rem; +} + +.ExtraPixelsPanel__submit { + margin-right: 2rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + background-color: rgba(255, 255, 255, 0.7); + box-shadow: 0 0 0.7rem rgba(50, 50, 50, 0.4); + cursor: pointer; +} + +.ExtraPixelsPanel__submit:hover { + box-shadow: 0 0 0.7rem rgba(50, 50, 50, 0.6); + transform: translateY(-0.2rem) scale(1.03); +} + +.ExtraPixelsPanel__submit:active { + box-shadow: 0 0 0.7rem rgba(0, 0, 0, 0.8); + transform: translateY(0) scale(1); +} + +.ExtraPixelsPanel__pixels { + display: flex; + flex-direction: row; + align-items: center; + margin: 0 0.5rem; + overflow-x: scroll; + flex-wrap: nowrap; + padding: 0.2rem 0; +} + +.ExtraPixelsPanel__item { + display: flex; + flex-direction: column; + align-items: center; + margin: 0 0.5rem; +} + +.ExtraPixelsPanel__bubble { + position: relative; + width: 3rem; + height: 3rem; + margin: 0; + border-radius: 1rem; + box-shadow: 0 0 0.7rem rgba(50, 50, 50, 0.4); + cursor: pointer; + pointer-events: fill; +} + +.ExtraPixelsPanel__bubble:hover { + box-shadow: 0 0 0.7rem rgba(50, 50, 50, 0.6); + transform: translateY(-0.2rem) scale(1.03); +} + +.ExtraPixelsPanel__bubble:active { + box-shadow: 0 0 0.7rem rgba(0, 0, 0, 0.8); + transform: translateY(0) scale(1); +} + +.ExtraPixelsPanel__bubble__remove { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.2rem; + margin: 0; + padding: 0; + cursor: pointer; + display: none; +} + +.ExtraPixelsPanel__bubble:hover .ExtraPixelsPanel__bubble__remove { + display: block; + color: red; +} + +.ExtraPixelsPanel__id { + font-size: 0.8rem; +} + +.ExtraPixelsPanel__exit { + position: absolute; + top: 0; + right: 0; + font-size: 1.2rem; + margin: 0.6rem; + cursor: pointer; +} + +.ExtraPixelsPanel__exit:hover { + color: red; + transform: scale(1.2, 1.2); +} + +.ExtraPixelsPanel__exit:active { + color: red; + transform: scale(1, 1); +} diff --git a/frontend/src/canvas/ExtraPixelsPanel.js b/frontend/src/canvas/ExtraPixelsPanel.js new file mode 100644 index 00000000..ea43b014 --- /dev/null +++ b/frontend/src/canvas/ExtraPixelsPanel.js @@ -0,0 +1,57 @@ +import React from 'react'; +import './ExtraPixelsPanel.css'; +import canvasConfig from '../configs/canvas.config.json'; +import backendConfig from '../configs/backend.config.json'; + +const ExtraPixelsPanel = props => { + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port + let colors = canvasConfig.colors; + colors = colors.map(color => `#${color}FF`); + + const clearAll = () => { + props.clearPixelSelection(); + props.clearExtraPixels(); + } + + const submit = () => { + let placeExtraPixelsEndpoint = backendUrl + "/placeExtraPixelsDevnet"; + fetch(placeExtraPixelsEndpoint, { + mode: "cors", + method: "POST", + body: JSON.stringify({ + extraPixels: props.extraPixelsData + }), + }).then(response => { + return response.text(); + }).then(data => { + console.log(data); + }).catch(error => { + console.error("Error placing extra pixels: ", error); + }); + clearAll(); + } + + return ( +
+

clearAll()}>X

+
+

Extra Pixels

+
submit()}>Submit
+
+
+ { props.extraPixelsData.map((pixelData, index) => { + return ( +
+
props.clearExtraPixel(index)}> +

X

+
+

({pixelData.x}, {pixelData.y})

+
+ ); + })} +
+
+ ); +} + +export default ExtraPixelsPanel; diff --git a/frontend/src/canvas/PixelSelector.js b/frontend/src/canvas/PixelSelector.js index 47d26a5a..12095b63 100644 --- a/frontend/src/canvas/PixelSelector.js +++ b/frontend/src/canvas/PixelSelector.js @@ -42,7 +42,6 @@ const PixelSelector = (props) => { }, [colors, backendUrl, staticColors, setColors, setIsSetup, isSetup]); // TODO: implement extraPixels feature(s) - const extraPixels = 0; const getTimeTillNextPlacement = useCallback(() => { let timeSinceLastPlacement = Date.now() - placedTime; @@ -109,10 +108,10 @@ const PixelSelector = (props) => { { !selectorMode &&

{timeTillNextPlacement}

- { extraPixels > 0 && + { props.extraPixels > 0 &&

|

-

{extraPixels} XTRA

+

{props.extraPixels - props.extraPixelsUsed} XTRA

} {props.selectedColorId !== -1 && diff --git a/frontend/src/canvas/TemplateBuilderPanel.css b/frontend/src/canvas/TemplateBuilderPanel.css new file mode 100644 index 00000000..5a7337cf --- /dev/null +++ b/frontend/src/canvas/TemplateBuilderPanel.css @@ -0,0 +1,103 @@ +.TemplateBuilderPanel { + position: relative; + padding: 1.2rem 0.5rem 0.5rem 0.5rem; + margin: 0.5rem; + width: 100%; + + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + + background-image: linear-gradient(to bottom right, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.4)); + z-index: 100; + border-radius: 1rem; + box-shadow: 0 0 1rem 0.1rem rgba(0, 0, 0, 0.2); +} + +.TemplateBuilderPanel__exit { + position: absolute; + top: 0; + right: 0; + font-size: 1.2rem; + margin: 0.6rem; + cursor: pointer; +} + +.TemplateBuilderPanel__exit:hover { + color: red; + transform: scale(1.2, 1.2); +} + +.TemplateBuilderPanel__exit:active { + color: red; + transform: scale(1, 1); +} + +.TemplateBuilderPanel__title { + font-size: 1.4rem; + margin: 0.5rem; + text-decoration: underline; +} + +.TemplateBuilderPanel__form { + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + margin: 0.5rem; +} + +.TemplateBuilderPanel__row { + display: flex; + flex-direction: row; + justify-content: center; + align-content: center; + width: 100%; +} + +.TemplateBuilderPanel__label { + font-size: 1.2rem; + margin: 0.5rem 0; + flex: 1; +} + +.TemplateBuilderPanel__input { + font-size: 1.2rem; + margin: 0.5rem 0; + padding: 0.5rem; + border-radius: 0.5rem; + border: 0.1rem solid rgba(0, 0, 0, 0.2); + background-color: rgba(255, 255, 255, 0.9); + box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.2); + flex: 1; + width: 70%; +} + +.TemplateBuilderPanel__input:focus { + outline: none; + border: 0.1rem solid rgba(0, 0, 0, 0.5); +} + +.TemplateBuilderPanel__button { + font-size: 1.2rem; + margin: 0.5rem; + padding: 0.5rem; + border-radius: 0.5rem; + border: 0.1rem solid rgba(0, 0, 0, 0.2); + background-color: rgba(255, 255, 255, 0.9); + cursor: pointer; + box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.2); +} + +.TemplateBuilderPanel__button:hover { + background-color: rgba(255, 255, 255, 0.7); + transform: scale(1.05) translateY(-0.1rem); + box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.5); +} + +.TemplateBuilderPanel__button:active { + background-color: rgba(255, 255, 255, 0.5); + box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.8); + transform: scale(1) translateY(0); +} diff --git a/frontend/src/canvas/TemplateBuilderPanel.js b/frontend/src/canvas/TemplateBuilderPanel.js new file mode 100644 index 00000000..e4063084 --- /dev/null +++ b/frontend/src/canvas/TemplateBuilderPanel.js @@ -0,0 +1,87 @@ +import React from 'react'; +import './TemplateBuilderPanel.css'; +import backendConfig from "../configs/backend.config.json" + +const TemplateBuilderPanel = props => { + const [templateName, setTemplateName] = React.useState(''); + const [templateReward, setTemplateReward] = React.useState(''); + const [templateRewardToken, setTemplateRewardToken] = React.useState(''); + + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port + + const createTemplate = (name, reward, rewardToken) => { + // TODO: Name off chain? + console.log('Creating template with name:', name, 'reward:', reward, 'rewardToken:', rewardToken); + let addTemplateEndpoint = backendUrl + "/add-template-devnet" + let template = new Image() + template.src = props.templateImage + template.onload = function() { + let templateWidth = this.width + let templateHeight = this.height + let addTemplateDataEndpoint = backendUrl + "/add-template-data" + fetch(addTemplateDataEndpoint, { + method: 'POST', + mode: 'cors', + body: JSON.stringify({ + width: templateWidth.toString(), + height: templateHeight.toString(), + image: props.templateColorIds.toString() + }) + }).then(response => { + return response.text() + }).then(data => { + // Load template hash from response + let templateHash = data + + fetch(addTemplateEndpoint, { + method: 'POST', + mode: 'cors', + body: JSON.stringify({ + name: name, + position: props.templateImagePosition.toString(), + width: templateWidth.toString(), + height: templateHeight.toString(), + hash: templateHash, + reward: reward, + rewardToken: rewardToken, + }) + }).then(response => { + return response.text() + }).then(data => { + console.log('Template created:', data) + }).catch(error => { + console.error('Error creating template:', error) + }); + }).catch(error => { + console.error('Error creating template data:', error) + }); + } + } + + return ( +
+

props.setTemplateCreationMode(false)}>X

+

Template Builder

+
+
+ + setTemplateName(e.target.value)} /> +
+ +
+ + setTemplateReward(e.target.value)} placeholder="Optional" /> +
+ +
+ + setTemplateRewardToken(e.target.value)} placeholder="Optional" /> +
+ + +
+
+ ); +} + +export default TemplateBuilderPanel; diff --git a/frontend/src/configs/backend.config.json b/frontend/src/configs/backend.config.json index 228cf22b..06f5881d 100644 --- a/frontend/src/configs/backend.config.json +++ b/frontend/src/configs/backend.config.json @@ -3,7 +3,9 @@ "port": 8080, "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", - "add_template_hash_devnet": "../tests/integration/local/add_template_hash.sh" + "place_extra_pixels_devnet": "../tests/integration/local/place_extra_pixels.sh", + "add_template_devnet": "../tests/integration/local/add_template.sh", + "mint_nft_devnet": "../tests/integration/local/mint_nft.sh" }, "production": false } diff --git a/frontend/src/tabs/ExpandableTab.js b/frontend/src/tabs/ExpandableTab.js index 4baf84ba..63469064 100644 --- a/frontend/src/tabs/ExpandableTab.js +++ b/frontend/src/tabs/ExpandableTab.js @@ -7,6 +7,7 @@ const ExpandableTab = props => { const MainSection = props.mainSection; const ExpandedSection = props.expandedSection; + const { ...rest } = props; // TODO: Add close button that switches to canvas // TODO: Add expand/collapse feature @@ -15,8 +16,8 @@ const ExpandableTab = props => {

{props.title}

- - {expanded && } + + {expanded && }
setExpanded(!expanded)}>
diff --git a/frontend/src/tabs/NFTs.css b/frontend/src/tabs/NFTs.css deleted file mode 100644 index d238c210..00000000 --- a/frontend/src/tabs/NFTs.css +++ /dev/null @@ -1,50 +0,0 @@ -.NFTs__main { - position: relative; - width: min(100%, 30rem); - - transition: all 0.5s; -} - -.NFTs__container { - height: 70vh; - overflow: scroll; -} - -.NFTs__header { - font-size: 1.4rem; - text-align: center; - padding: 0; - padding-bottom: 0.5rem; - margin: 0; - text-decoration: underline; -} - -.NFTs__marketplace { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; -} - -.NFTs__marketplace__grid { - width: 100%; - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - grid-template-rows: min-content; - grid-gap: 1rem; - padding: 1rem; - height: 70vh; - overflow: scroll; -} - -.NFTs__marketplace__item { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - border-radius: 1rem; - background-color: rgba(255, 255, 255, 0.8); - - height: 13rem; -} diff --git a/frontend/src/tabs/NFTs.js b/frontend/src/tabs/NFTs.js deleted file mode 100644 index 6d6176c2..00000000 --- a/frontend/src/tabs/NFTs.js +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' -import './NFTs.css'; -import ExpandableTab from './ExpandableTab.js'; - -const NFTsMainSection = props => { - return ( -
-

My Collection

-
-
-
-
-
-
-
-
-
- ); -} - -const NFTsExpandedSection = props => { - return ( -
-

Marketplace

-
-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
-
- ); -} - -const NFTs = props => { - // TODO: Properties to NFTs main container - // TODO: Properties like : position, template?, ... - return ( - - ); -} - -export default NFTs; diff --git a/frontend/src/tabs/TabPanel.js b/frontend/src/tabs/TabPanel.js index 9d569559..da677cce 100644 --- a/frontend/src/tabs/TabPanel.js +++ b/frontend/src/tabs/TabPanel.js @@ -3,8 +3,8 @@ import './TabPanel.css'; import Quests from './quests/Quests.js'; import Voting from './Voting.js'; -import Templates from './Templates.js'; -import NFTs from './NFTs.js'; +import Templates from './templates/Templates.js'; +import NFTs from './nfts/NFTs.js'; import Account from './Account.js'; const TabPanel = props => { @@ -16,8 +16,12 @@ const TabPanel = props => { {props.activeTab === "Vote" && ( )} - {props.activeTab === "Templates" && } - {props.activeTab === "NFTs" && } + {props.activeTab === "Templates" && ( + + )} + {props.activeTab === "NFTs" && ( + + )} {props.activeTab === "Account" && }
); diff --git a/frontend/src/tabs/Templates.css b/frontend/src/tabs/Templates.css deleted file mode 100644 index 09d32caa..00000000 --- a/frontend/src/tabs/Templates.css +++ /dev/null @@ -1,50 +0,0 @@ -.Templates__main { - position: relative; - width: min(100%, 30rem); - - transition: all 0.5s; -} - -.Templates__container { - height: 70vh; - overflow: scroll; -} - -.Templates__header { - font-size: 1.4rem; - text-align: center; - padding: 0; - padding-bottom: 0.5rem; - margin: 0; - text-decoration: underline; -} - -.Templates__all { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; -} - -.Templates__all__grid { - width: 100%; - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - grid-template-rows: min-content; - grid-gap: 1rem; - padding: 1rem; - height: 70vh; - overflow: scroll; -} - -.Templates__all__item { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - border-radius: 1rem; - background-color: rgba(255, 255, 255, 0.8); - - height: 13rem; -} diff --git a/frontend/src/tabs/Templates.js b/frontend/src/tabs/Templates.js deleted file mode 100644 index 9f3d04da..00000000 --- a/frontend/src/tabs/Templates.js +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react' -import './Templates.css'; -import ExpandableTab from './ExpandableTab.js'; - -const TemplatesMainSection = props => { - return ( -
-

Mine / Events

-
-
-
-
-
-
-
-
-
- ); -} - -const TemplatesExpandedSection = props => { - return ( -
-

All Templates

-
-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
-
- ); -} - -const Templates = props => { - return ( - - ); -} - -export default Templates; diff --git a/frontend/src/tabs/Voting.js b/frontend/src/tabs/Voting.js index 8af9adf9..06f4b422 100644 --- a/frontend/src/tabs/Voting.js +++ b/frontend/src/tabs/Voting.js @@ -23,7 +23,7 @@ const Voting = props => {
Color
Count
-
+
{colors.map((color, index) => (
{ diff --git a/frontend/src/tabs/nfts/CollectionItem.css b/frontend/src/tabs/nfts/CollectionItem.css new file mode 100644 index 00000000..754a0931 --- /dev/null +++ b/frontend/src/tabs/nfts/CollectionItem.css @@ -0,0 +1,56 @@ +.CollectionItem { + display: grid; + grid-template-columns: 2fr 1fr; + padding: 0; + margin: 0.5rem 1.4rem 0.5rem 0.5rem; + border-radius: 0.3rem; + box-shadow: 0 0.1rem 0.6rem rgba(0, 0, 0, 0.5); +} + +.CollectionItem__imagecontainer { + display: flex; + justify-content: center; + padding: 0; + margin: 0; + width: 100%; + height: 20rem; + border: 2px solid rgba(0, 0, 150, 0.1); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +.CollectionItem__image { + width: 100%; + height: auto; + display: block; + object-fit: contain; + margin: 0; + padding: 0; + image-rendering: pixelated; +} + +.CollectionItem__info { + font-size: 1.2rem; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; +} + +.CollectionItem__info__item { + flex-grow: 1; + text-align: center; + border: 2px solid rgba(0, 0, 150, 0.1); + padding: 0.5rem 0; + margin: 0; + overflow: hidden; +} + +/* Style children of .CollectionItem__info__item */ +.CollectionItem__info__item > * { + margin: 0; + padding: 0.3rem; +} + +/* Underline the first child of .CollectionItem__info__item */ +.CollectionItem__info__item > *:first-child { text-decoration: underline; +} diff --git a/frontend/src/tabs/nfts/CollectionItem.js b/frontend/src/tabs/nfts/CollectionItem.js new file mode 100644 index 00000000..6f5b4f86 --- /dev/null +++ b/frontend/src/tabs/nfts/CollectionItem.js @@ -0,0 +1,33 @@ +import React from 'react' +import './CollectionItem.css'; +import canvasConfig from "../../configs/canvas.config.json" + +const CollectionItem = props => { + // TODO: Properties like : position, template?, ... + // TODO: alt text for image + const posx = props.position % canvasConfig.canvas.width; + const posy = Math.floor(props.position / canvasConfig.canvas.width); + return ( +
+
+ nftimg +
+
+
+

Pos

+

({posx}, {posy})

+
+
+

Size

+

{props.width}x{props.height}

+
+
+

Block

+

{props.blockNumber}

+
+
+
+ ); +} + +export default CollectionItem; diff --git a/frontend/src/tabs/nfts/NFTItem.css b/frontend/src/tabs/nfts/NFTItem.css new file mode 100644 index 00000000..2fd5a6d9 --- /dev/null +++ b/frontend/src/tabs/nfts/NFTItem.css @@ -0,0 +1,84 @@ +.NFTItem { + display: grid; + grid-template-columns: 2fr 1fr; + padding: 0; + margin: 0.5rem; + border-radius: 0.3rem; + box-shadow: 0 0.1rem 0.6rem rgba(0, 0, 0, 0.5); + position: relative; +} + +.NFTItem__imagecontainer { + display: flex; + justify-content: center; + padding: 0; + margin: 0; + width: 100%; + height: 20rem; + border: 2px solid rgba(0, 0, 150, 0.1); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +.NFTItem__image { + width: 100%; + height: auto; + display: block; + object-fit: contain; + margin: 0; + padding: 0; + image-rendering: pixelated; +} + +.NFTItem__like { + position: absolute; + bottom: 0; + right: 0; + padding: 0 0.5rem; + min-width: 3rem; + text-align: center; + margin: 0.5rem; + background-color: rgba(255, 255, 255, 0.5); + border-radius: 0.3rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + font-size: 1rem; +} + +.NFTItem__like:hover { + background-color: rgba(255, 255, 255, 0.8); + transform: scale(1.1) translateY(-0.2rem); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + +.NFTItem__like:active { + transform: scale(1) translateY(0); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + +/* TODO: icon */ + +.NFTItem__info { + font-size: 1rem; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; +} + +.NFTItem__info__item { + flex-grow: 1; + text-align: center; + border: 2px solid rgba(0, 0, 150, 0.1); + padding: 0.5rem 0; + margin: 0; + overflow: hidden; +} + +/* Style children of .NFTItem__info__item */ +.NFTItem__info__item > * { + margin: 0; + padding: 0.3rem; +} + +/* Underline the first child of .NFTItem__info__item */ +.NFTItem__info__item > *:first-child { text-decoration: underline; +} diff --git a/frontend/src/tabs/nfts/NFTItem.js b/frontend/src/tabs/nfts/NFTItem.js new file mode 100644 index 00000000..b82500ad --- /dev/null +++ b/frontend/src/tabs/nfts/NFTItem.js @@ -0,0 +1,42 @@ +import React from 'react' +import './NFTItem.css'; +import canvasConfig from "../../configs/canvas.config.json" + +const NFTItem = props => { + // TODO: Properties like : position, template?, ... + // TODO: alt text for image + const posx = props.position % canvasConfig.canvas.width; + const posy = Math.floor(props.position / canvasConfig.canvas.width); + return ( +
+
+
+ nftimg +
+
+

{props.likes}

+
+
+
+
+

Pos

+

({posx}, {posy})

+
+
+

Size

+

{props.width}x{props.height}

+
+
+

Block

+

{props.blockNumber}

+
+
+

Minter

+

{props.minter}

+
+
+
+ ); +} + +export default NFTItem; diff --git a/frontend/src/tabs/nfts/NFTs.css b/frontend/src/tabs/nfts/NFTs.css new file mode 100644 index 00000000..2fbe2fc1 --- /dev/null +++ b/frontend/src/tabs/nfts/NFTs.css @@ -0,0 +1,80 @@ +.NFTs__main { + position: relative; + width: min(100%, 40rem); + + transition: all 0.5s; +} + +.NFTs__container { + height: 55vh; + overflow: scroll; + display: flex; + flex-direction: column; + margin-top: 0.5rem; +} + +.NFTs__header { + display: flex; + flex-direction: row; + justify-content: space-evenly; + align-items: center; +} + +.NFTs__heading { + font-size: 1.4rem; + text-align: center; + padding: 0; + padding-bottom: 0.5rem; + margin: 0; + text-decoration: underline; +} + +.NFTs__mint { + padding: 0.5rem; + font-size: 1.2rem; + text-align: center; + border-radius: 1rem; + background-color: rgba(255, 255, 255, 0.8); + border: 0.1rem solid rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.NFTs__mint:hover { + transform: scale(1.1) translateY(-2px); + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); +} + +.NFTs__mint:active { + transform: scale(1) translateY(2px); + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); +} + +.NFTs__all { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + border-left: 4px solid rgba(0, 0, 0, 0.3); +} + +.NFTs__all__grid { + margin-top: 0.5rem; + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr)); + grid-template-rows: min-content; + height: 55vh; + overflow: scroll; +} + +.NFTs__all__item { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 1rem; + background-color: rgba(255, 255, 255, 0.8); + + height: 13rem; +} diff --git a/frontend/src/tabs/nfts/NFTs.js b/frontend/src/tabs/nfts/NFTs.js new file mode 100644 index 00000000..d09cf77a --- /dev/null +++ b/frontend/src/tabs/nfts/NFTs.js @@ -0,0 +1,89 @@ +import React from 'react' +import './NFTs.css'; +import ExpandableTab from '../ExpandableTab.js'; +import CollectionItem from './CollectionItem.js'; +import NFTItem from './NFTItem.js'; +import backendConfig from "../../configs/backend.config.json" + +const NFTsMainSection = props => { + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port + const imageURL = backendUrl + "/nft-images/"; + return ( +
+
+

My Collection

+
props.setNftSelectionMode(true)}>Mint
+
+
+ {props.nftsCollection.map((nft, index) => { + return + })} +
+
+ ); +} + +const NFTsExpandedSection = props => { + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port + const imageURL = backendUrl + "/nft-images/"; + return ( +
+
+

All NFTs

+
+
+ {props.allNfts.map((nft, index) => { + return + })} +
+
+ ); +} + +const NFTs = props => { + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port + const [setup, setSetup] = React.useState(false); + const [myNFTs, setMyNFTs] = React.useState([]); + const [allNFTs, setAllNFTs] = React.useState([]); + + React.useEffect(() => { + if (!setup) { + setSetup(true); + } else { + return; + } + + // TODO + const addr = '0328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0'; + let getMyNFTsEndpoint = backendUrl + "/get-my-nfts?address=" + addr; + fetch(getMyNFTsEndpoint, {mode: 'cors'}).then(response => { + return response.json(); + }).then(data => { + if (data === null) { + data = []; + } + setMyNFTs(data); + }).catch(err => { + console.error(err); + }); + + let getNFTsEndpoint = backendUrl + "/get-nfts"; + fetch(getNFTsEndpoint, {mode: 'cors'}).then(response => { + return response.json(); + }).then(data => { + if (data === null) { + data = []; + } + setAllNFTs(data); + }).catch(err => { + console.error(err); + }); + + }, [setup, backendUrl, setSetup, setMyNFTs, setAllNFTs]); + + return ( + + ); +} + +export default NFTs; diff --git a/frontend/src/tabs/quests/Quests.js b/frontend/src/tabs/quests/Quests.js index 1af41f32..b59dfc9e 100644 --- a/frontend/src/tabs/quests/Quests.js +++ b/frontend/src/tabs/quests/Quests.js @@ -91,8 +91,8 @@ const Quests = (props) => { // TODO: Icons for each tab? return ( -
-
+
+

Dailys

{props.timeLeftInDay}

diff --git a/frontend/src/tabs/templates/EventItem.css b/frontend/src/tabs/templates/EventItem.css new file mode 100644 index 00000000..955c9be5 --- /dev/null +++ b/frontend/src/tabs/templates/EventItem.css @@ -0,0 +1,107 @@ +.EventItem { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + padding: 0.4rem; + margin: 0.5rem; + border-radius: 0.3rem; + box-shadow: 0 0.3rem 0.7rem rgba(0, 0, 0, 0.5); + position: relative; +} + +.EventItem:hover { + transform: translateY(-0.3rem); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.7); +} + +.EventItem:active { + transform: translateY(0); + box-shadow: 0 0.3rem 0.7rem rgba(0, 0, 0, 0.5); +} + +.EventItem__image { + width: 100%; + height: 100%; + object-fit: cover; + margin: 0; + padding: 0; + display: block; + border: 2px solid rgba(0, 0, 150, 0.1); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.EventItem__desc { + position: absolute; + bottom: 0; + right: 0; + padding: 0 0.5rem; + margin: 0.5rem; + display: flex; + flex-direction: row; + justify-content: right; + width: 95%; +} + +/* TODO: Fix ellipsis */ +.EventItem__name { + font-size: 1rem; + color: rgba(255, 255, 255, 0.8); + background-color: rgba(0, 0, 0, 0.5); + border-radius: 0.3rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + margin-right: 0.5rem; + padding: 0 0.5rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.EventItem__users { + min-width: 3rem; + text-align: center; + background-color: rgba(255, 255, 255, 0.5); + border-radius: 0.3rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + font-size: 1rem; + margin-right: 0.5rem; +} + +.EventItem__time { + text-align: center; + background-color: rgba(255, 255, 255, 0.5); + border-radius: 0.3rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + font-size: 1rem; + padding: 0 0.3rem; +} + +/* TODO: icon */ + +.EventItem__info { + font-size: 1rem; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + background-color: rgba(255, 255, 255, 0.5); +} + +.EventItem__info__item { + flex-grow: 1; + text-align: center; + border: 2px solid rgba(0, 0, 150, 0.1); + padding: 0.5rem 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Style children of .EventItem__info__item */ +.EventItem__info__item > * { + margin: 0; + padding: 0.3rem; +} + +/* Underline the first child of .EventItem__info__item */ +.EventItem__info__item > *:first-child { text-decoration: underline; +} diff --git a/frontend/src/tabs/templates/EventItem.js b/frontend/src/tabs/templates/EventItem.js new file mode 100644 index 00000000..b9e14208 --- /dev/null +++ b/frontend/src/tabs/templates/EventItem.js @@ -0,0 +1,84 @@ +import React, { useCallback, useEffect } from 'react' +import './EventItem.css'; +import canvasConfig from "../../configs/canvas.config.json" + +const EventItem = props => { + // TODO: Reward color + // TODO: alt text for image + const posx = props.template.position % canvasConfig.canvas.width; + const posy = Math.floor(props.template.position / canvasConfig.canvas.width); + + const [formatedEnding, setFormatedEnding] = React.useState(""); + + // Create changing gradient color backgroundColor + const [color, setColor] = React.useState(0); + const btrColorOffset = 1000; + + useEffect(() => { + const interval = setInterval(() => { + setColor((color + 3) % 360); + }, 50); + return () => clearInterval(interval); + }, [color]); + + // Update the time every second + const formatEnding = useCallback((time) => { + const timeDiff = time - Date.now(); + const seconds = Math.floor(timeDiff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const pad = (num) => num < 10 ? '0' + num : num; + return `${pad(hours)}:${pad(minutes % 60)}:${pad(seconds % 60)}`; + }, []); + + useEffect(() => { + const interval = setInterval(() => { + setFormatedEnding(formatEnding(props.template.end_time)); + }, 1000); + return () => clearInterval(interval); + }, [formatEnding, props.template.end_time]); + + return ( +
+
+ nftimg +
+
+

{props.template.name}

+
+
+

{props.template.users}

+
+
+

{formatedEnding}

+
+
+
+
+ { props.template.reward !== undefined && +
+

Reward

+

{props.template.reward} {props.template.reward_token}

+
+ } +
+

Pos

+

({posx}, {posy})

+
+
+

Size

+

{props.template.width}x{props.template.height}

+
+ { props.template.creator !== undefined && +
+

Creator

+

{props.template.creator}

+
+ } +
+
+ ); + // TODO: Handle unknown tokens +} + +export default EventItem; diff --git a/frontend/src/tabs/templates/TemplateItem.css b/frontend/src/tabs/templates/TemplateItem.css new file mode 100644 index 00000000..975d8702 --- /dev/null +++ b/frontend/src/tabs/templates/TemplateItem.css @@ -0,0 +1,118 @@ +.TemplateItem { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + padding: 0; + margin: 0.5rem; + border-radius: 0.3rem; + box-shadow: 0 0.1rem 0.6rem rgba(0, 0, 0, 0.5); + position: relative; +} + +.TemplateItem:hover { + transform: translateY(-0.3rem); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.5); +} + +.TemplateItem:active { + transform: translateY(0); + box-shadow: 0 0.1rem 0.6rem rgba(0, 0, 0, 0.5); +} + +.TemplateItem__image { + width: 100%; + height: 100%; + object-fit: cover; + margin: 0; + padding: 0; + display: block; + border: 2px solid rgba(0, 0, 150, 0.1); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + overflow: hidden; + image-rendering: pixelated; +} + +.TemplateItem__desc { + position: absolute; + bottom: 0; + right: 0; + padding: 0 0.5rem; + margin: 0.5rem; + display: flex; + flex-direction: row; + justify-content: right; + width: 95%; +} + +/* TODO: Fix ellipsis */ +.TemplateItem__name { + font-size: 1rem; + color: rgba(255, 255, 255, 0.8); + background-color: rgba(0, 0, 0, 0.5); + border-radius: 0.3rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + margin-right: 0.5rem; + padding: 0 0.5rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.TemplateItem__users { + min-width: 3rem; + text-align: center; + background-color: rgba(255, 255, 255, 0.5); + border-radius: 0.3rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + font-size: 1rem; + margin-right: 0.5rem; +} + +.TemplateItem__like { + min-width: 3rem; + text-align: center; + background-color: rgba(255, 255, 255, 0.5); + border-radius: 0.3rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + font-size: 1rem; +} + +.TemplateItem__like:hover { + background-color: rgba(255, 255, 255, 0.8); + transform: scale(1.1) translateY(-0.2rem); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + +.TemplateItem__like:active { + transform: scale(1) translateY(0); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + +/* TODO: icon */ + +.TemplateItem__info { + font-size: 1rem; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; +} + +.TemplateItem__info__item { + flex-grow: 1; + text-align: center; + border: 2px solid rgba(0, 0, 150, 0.1); + padding: 0.5rem 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Style children of .TemplateItem__info__item */ +.TemplateItem__info__item > * { + margin: 0; + padding: 0.3rem; +} + +/* Underline the first child of .TemplateItem__info__item */ +.TemplateItem__info__item > *:first-child { text-decoration: underline; +} diff --git a/frontend/src/tabs/templates/TemplateItem.js b/frontend/src/tabs/templates/TemplateItem.js new file mode 100644 index 00000000..bf46c4e9 --- /dev/null +++ b/frontend/src/tabs/templates/TemplateItem.js @@ -0,0 +1,54 @@ +import React from 'react' +import './TemplateItem.css'; +import canvasConfig from "../../configs/canvas.config.json" + +const TemplateItem = props => { + // TODO: Reward color + // TODO: alt text for image + // TODO: Follow button in top right of image + const posx = props.template.position % canvasConfig.canvas.width; + const posy = Math.floor(props.template.position / canvasConfig.canvas.width); + return ( +
+
+ nftimg +
+
+

{props.template.name}

+
+
+

{props.template.users}

+
+
+

{props.template.likes}

+
+
+
+
+ { props.template.reward !== undefined && +
+

Reward

+

{props.template.reward} {props.template.reward_token}

+
+ } +
+

Pos

+

({posx}, {posy})

+
+
+

Size

+

{props.template.width}x{props.template.height}

+
+ { props.template.creator !== undefined && +
+

Creator

+

{props.template.creator}

+
+ } +
+
+ ); + // TODO: Handle unknown tokens +} + +export default TemplateItem; diff --git a/frontend/src/tabs/templates/Templates.css b/frontend/src/tabs/templates/Templates.css new file mode 100644 index 00000000..b62a88c7 --- /dev/null +++ b/frontend/src/tabs/templates/Templates.css @@ -0,0 +1,88 @@ +.Templates__main { + position: relative; + width: min(100%, 40rem); + + transition: all 0.5s; +} + +.Templates__container { + height: 55vh; + overflow-y: scroll; + display: flex; + flex-direction: column; + margin-top: 0.5rem; +} + +.Templates__header { + display: flex; + flex-direction: row; + justify-content: space-evenly; + align-items: center; +} + +.Templates__heading { + font-size: 1.4rem; + text-align: center; + padding: 0; + padding-bottom: 0.5rem; + margin: 0; + text-decoration: underline; +} + +.Templates__subheading { + font-size: 1.4rem; + text-align: center; + padding: 1rem 0; + margin: 0; + text-decoration: underline; +} + +.Templates__create { + padding: 0.5rem; + font-size: 1.2rem; + text-align: center; + border-radius: 1rem; + background-color: rgba(255, 255, 255, 0.8); + border: 0.1rem solid rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.Templates__create:hover { + transform: scale(1.1) translateY(-2px); + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); +} + +.Templates__create:active { + transform: scale(1) translateY(2px); + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); +} + +.Templates__all { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + border-left: 4px solid rgba(0, 0, 0, 0.3); +} + +.Templates__all__grid { + margin-top: 0.5rem; + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr)); + grid-template-rows: min-content; + height: 55vh; + overflow: scroll; +} + +.Templates__all__item { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 1rem; + background-color: rgba(255, 255, 255, 0.8); + + height: 13rem; +} diff --git a/frontend/src/tabs/templates/Templates.js b/frontend/src/tabs/templates/Templates.js new file mode 100644 index 00000000..93a4924f --- /dev/null +++ b/frontend/src/tabs/templates/Templates.js @@ -0,0 +1,217 @@ +import React, { useRef, useState } from 'react' +import './Templates.css'; +import EventItem from './EventItem.js'; +import TemplateItem from './TemplateItem.js'; +import ExpandableTab from '../ExpandableTab.js'; +import canvasConfig from '../../configs/canvas.config.json'; +import backendConfig from "../../configs/backend.config.json" + +const TemplatesMainSection = props => { + // Each color represented as 'RRGGBB' + const colorsPalette = canvasConfig.colors; + + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port + const imageURL = backendUrl + "/templates/" + + const imageToPalette = (image) => { + // Convert image pixels to be within the color palette + + // Get image data + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = image.width; + canvas.height = image.height; + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, image.width, image.height); + const data = imageData.data; + + let imagePalleteIds = []; + // Convert image data to color palette + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] < 128) { + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + data[i + 3] = 0; + imagePalleteIds.push(255); + continue; + } + let minDistance = 1000000; + let minColor = colorsPalette[0]; + let minColorIndex = 0; + for (let j = 0; j < colorsPalette.length; j++) { + const color = colorsPalette[j].match(/[A-Za-z0-9]{2}/g).map(x => parseInt(x, 16)); + const distance = Math.sqrt(Math.pow(data[i] - color[0], 2) + Math.pow(data[i + 1] - color[1], 2) + Math.pow(data[i + 2] - color[2], 2)); + if (distance < minDistance) { + minDistance = distance; + minColor = color; + minColorIndex = j; + } + } + data[i] = minColor[0]; + data[i + 1] = minColor[1]; + data[i + 2] = minColor[2]; + imagePalleteIds.push(minColorIndex); + } + + // Set image data back to canvas + ctx.putImageData(imageData, 0, 0); + return [canvas.toDataURL(), imagePalleteIds]; + } + + const uploadTemplate = () => { + // Open file upload dialog + props.inputFile.current.click(); + } + + const handleFileChange = (event) => { + const file = event.target.files[0]; + if (file === undefined) { + return + } + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = function(e) { + var image = new Image(); + image.src = e.target.result; + + image.onload = function() { + var height = this.height; + var width = this.width; + if (height < 5 || width < 5) { + alert("Image is too small, minimum size is 5x5. Given size is " + width + "x" + height); + return; + } + if (height > 50 || width > 50) { + alert("Image is too large, maximum size is 50x50. Given size is " + width + "x" + height); + return; + } + const [paletteImage, colorIds] = imageToPalette(image); + // TODO: Upload to backend and get template hash back + props.setTemplateImage(paletteImage); + props.setTemplateColorIds(colorIds); + props.setTemplateCreationMode(true); + }; + }; + } + + return ( +
+
+

For you

+ +
Create
+
+
+

Events

+ {props.eventTemplates.map((template, index) => { + return + })} + {props.availableTemplates.map((template, index) => { + let formattedhash = template.hash.replace("0x0", "0x") + template.image = imageURL + "template-" + formattedhash + ".png" + return + })} +

Mine

+ {props.myTemplates.map((template, index) => { + return + })} +

Subscribed

+ {props.subscribedTemplates.map((template, index) => { + return + })} +
+
+ ); +} + +const TemplatesExpandedSection = props => { + return ( +
+
+

All Templates

+
+
+ {props.allTemplates.map((template, index) => { + return + })} +
+
+ ); +} + +const Templates = props => { + const endTime = (minutes) => { + const now = Date.now(); + const endTime = new Date(now + minutes * 60000); + return endTime; + } + + const eventTemplates = [ + { name: "Event Template 1", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 20, width: 20, height: 20, end_time: endTime(10), position: 0, reward: 100, reward_token: "ETH" }, + { name: "Event Template 2", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 12, width: 25, height: 20, end_time: endTime(13), position: 47, reward: 100, reward_token: "STRK" }, + { name: "Event Template 3", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 15, width: 20, height: 20, end_time: endTime(15), position: 0, reward: 0.010, reward_token: "ETH" }, + { name: "Event Template 4", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 10, width: 20, height: 20, end_time: endTime(20), position: 0 } + ]; + + const myTemplates = [ + { name: "My Template 1", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 20, width: 20, height: 20, position: 25, likes: 10, reward: 100, reward_token: "ETH" }, + { name: "My Template With long name 2", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 12, width: 25, height: 20, position: 47, likes: 20, reward: 0.000003, reward_token: "ETH" }, + { name: "My Template 3", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 15, width: 20, height: 20, position: 0, likes: 5, reward: 200, reward_token: "STRK" }, + { name: "My Template 4", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 10, width: 20, height: 20, position: 47, likes: 15, reward: 100, reward_token: "STRK" }, + ]; + + const subscribedTemplates = [ + { name: "Subscribed Template 1", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 20, width: 20, height: 20, position: 0, likes: 12, creator: "hello.stark", reward: 100, reward_token: "ETH" }, + { name: "Subscribed Template 2", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 12, width: 25, height: 20, position: 47, likes: 15, creator: "0x000000000000000000000000", reward: 200, reward_token: "STRK" }, + { name: "Subscribed Template 3", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 15, width: 20, height: 20, position: 0, likes: 20, creator: "good.stark" }, + { name: "Subscribed Template 4", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 10, width: 20, height: 20, position: 47, likes: 25, creator: "Me" } + ]; + + const allTemplates = [ + { name: "All Template 1", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 20, width: 20, height: 20, position: 0, likes: 12, creator: "hello.stark", reward: 100, reward_token: "STRK" }, + { name: "All Template 2", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 12, width: 25, height: 20, position: 47, likes: 15, creator: "0x00000000000000000000000", reward: 100, reward_token: "STRK" }, + { name: "All Template 3", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 15, width: 20, height: 20, position: 0, likes: 20, creator: "good.stark", reward: 100, reward_token: "STRK" }, + { name: "All Template 4", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 10, width: 20, height: 20, position: 47, likes: 25, creator: "Me",reward: 0.00002100023, reward_token: "ETH" }, + { name: "All Template 5", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 20, width: 20, height: 20, position: 0, likes: 12, creator: "hello.stark" }, + { name: "All Template 6", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 12, width: 25, height: 20, position: 47, likes: 15, creator: "You" }, + { name: "All Template 7", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 15, width: 20, height: 20, position: 0, likes: 20, creator: "good.stark" }, + { name: "All Template 8", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 10, width: 20, height: 20, position: 47, likes: 25, creator: "Me" }, + { name: "All Template 9", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 20, width: 20, height: 20, position: 0, likes: 12, creator: "hello.stark" }, + { name: "All Template 10", image: "https://www.w3schools.com/w3images/mountains.jpg", users: 12, width: 25, height: 20, position: 47, likes: 15, creator: ";kjhdflakj" } + ]; + + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port + const [setup, setSetup] = useState(false) + const [availableTemplates, setAvailableTemplates] = useState([]) + const inputFile = useRef() + + React.useEffect(() => { + if (!setup) { + setSetup(true) + } else { + return + } + + let getTemplatesEndpoint = backendUrl + "/get-templates"; + fetch(getTemplatesEndpoint, { + mode: 'cors', + }).then(response => { + return response.json(); + }).then(data => { + if (data === null) { + data = [] + } + setAvailableTemplates(data) + }).catch((error) => { + console.error('Error:', error); + }); + }, [setup, backendUrl]) + + return ( + + ); +} + +export default Templates; diff --git a/indexer/script.js b/indexer/script.js index 0390248a..ce8412c2 100644 --- a/indexer/script.js +++ b/indexer/script.js @@ -7,9 +7,21 @@ export const config = { events: [ { fromAddress: Deno.env.get("ART_PEACE_CONTRACT_ADDRESS"), - keys: [ - "0x2D7B50EBF415606D77C7E7842546FC13F8ACFBFD16F7BCF2BC2D08F54114C23", - ], + 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, diff --git a/onchain/Scarb.toml b/onchain/Scarb.toml index ad672acb..65bcb533 100644 --- a/onchain/Scarb.toml +++ b/onchain/Scarb.toml @@ -10,6 +10,8 @@ snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry", tag = "v openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.11.0" } starknet = "2.6.3" +#[lib] + [scripts] test = "snforge test" diff --git a/onchain/src/art_peace.cairo b/onchain/src/art_peace.cairo index 9d1bd625..e5c13f0c 100644 --- a/onchain/src/art_peace.cairo +++ b/onchain/src/art_peace.cairo @@ -82,7 +82,6 @@ pub mod ArtPeace { pub end_time: u64, pub daily_quests: Span, pub main_quests: Span, - pub nft_contract: ContractAddress, } const DAY_IN_SECONDS: u64 = consteval_int!(60 * 60 * 24); @@ -108,6 +107,12 @@ pub mod ArtPeace { self.end_time.write(init_params.end_time); self.day_index.write(0); + // TODO: Dev only - remove + let test_address = starknet::contract_address_const::< + 0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 + >(); + self.extra_pixels.write(test_address, 1000); + // TODO: To config let daily_quests_count = self.get_daily_quest_count(); let mut i = 0; @@ -127,8 +132,6 @@ pub mod ArtPeace { self.main_quests.write(i, *init_params.main_quests.at(i)); i += 1; }; - - self.nft_contract.write(init_params.nft_contract); } #[abi(embed_v0)] @@ -165,13 +168,16 @@ pub mod ArtPeace { fn place_pixel(ref self: ContractState, pos: u128, color: u8) { let now = starknet::get_block_timestamp(); - assert(now <= self.end_time.read(), ''); - assert(pos < self.total_pixels.read(), ''); - assert(color < self.color_count.read(), ''); + assert(now <= self.end_time.read(), 'ArtPeace game has ended'); + assert(pos < self.total_pixels.read(), 'Position out of bounds'); + assert(color < self.color_count.read(), 'Color out of bounds'); // TODO: Use sender not caller? let caller = starknet::get_caller_address(); // TODO: Only if the user has placed a pixel before? - assert(now - self.last_placed_time.read(caller) >= self.time_between_pixels.read(), ''); + assert( + now - self.last_placed_time.read(caller) >= self.time_between_pixels.read(), + 'Pixel not available' + ); let pixel = Pixel { color, owner: caller }; self.canvas.write(pos, pixel); self.last_placed_time.write(caller, now); @@ -191,12 +197,12 @@ pub mod ArtPeace { fn place_extra_pixels(ref self: ContractState, positions: Array, colors: Array) { let now = starknet::get_block_timestamp(); - assert(now <= self.end_time.read(), ''); + assert(now <= self.end_time.read(), 'ArtPeace game has ended'); let pixel_count = positions.len(); - assert(pixel_count == colors.len(), ''); + assert(pixel_count == colors.len(), 'Positions & Colors must match'); let caller = starknet::get_caller_address(); let extra_pixels = self.extra_pixels.read(caller); - assert(pixel_count <= extra_pixels, ''); + assert(pixel_count <= extra_pixels, 'Not enough extra pixels'); let color_palette_count = self.color_count.read(); let total_pixels = self.total_pixels.read(); let day = self.day_index.read(); @@ -204,8 +210,8 @@ pub mod ArtPeace { while i < pixel_count { let pos = *positions.at(i); let color = *colors.at(i); - assert(pos < total_pixels, ''); - assert(color < color_palette_count, ''); + assert(pos < total_pixels, 'Position out of bounds'); + assert(color < color_palette_count, 'Color out of bounds'); let pixel = Pixel { color, owner: caller }; self.canvas.write(pos, pixel); self @@ -214,6 +220,7 @@ pub mod ArtPeace { (day, caller, color), self.user_pixels_placed.read((day, caller, color)) + 1 ); i += 1; + self.emit(PixelPlaced { placed_by: caller, pos, day, color }); }; self.extra_pixels.write(caller, extra_pixels - pixel_count); //TODO: to extra pixel self.emit(ExtraPixelsPlaced { placed_by: caller, positions, day, colors }); @@ -353,7 +360,7 @@ pub mod ArtPeace { fn claim_today_quest(ref self: ContractState, quest_id: u32, calldata: Span) { let now = starknet::get_block_timestamp(); - assert(now <= self.end_time.read(), ''); + assert(now <= self.end_time.read(), 'ArtPeace game has ended'); let quest = self.daily_quests.read((self.day_index.read(), quest_id)); let user = starknet::get_caller_address(); let reward = IQuestDispatcher { contract_address: quest }.claim(user, calldata); @@ -369,7 +376,7 @@ pub mod ArtPeace { fn claim_main_quest(ref self: ContractState, quest_id: u32, calldata: Span) { let now = starknet::get_block_timestamp(); - assert(now <= self.end_time.read(), ''); + assert(now <= self.end_time.read(), 'ArtPeace game has ended'); let quest = self.main_quests.read(quest_id); let user = starknet::get_caller_address(); let reward = IQuestDispatcher { contract_address: quest }.claim(user, calldata); @@ -440,6 +447,14 @@ pub mod ArtPeace { #[abi(embed_v0)] impl ArtPeaceNFTMinter of IArtPeaceNFTMinter { + fn add_nft_contract(ref self: ContractState, nft_contract: ContractAddress) { + let zero_address = starknet::contract_address_const::<0>(); + assert(self.nft_contract.read() == zero_address, 'NFT contract already set'); + self.nft_contract.write(nft_contract); + ICanvasNFTAdditionalDispatcher { contract_address: nft_contract } + .set_canvas_contract(starknet::get_contract_address()); + } + fn mint_nft(self: @ContractState, mint_params: NFTMintParams) { let metadata = NFTMetadata { position: mint_params.position, @@ -459,8 +474,8 @@ pub mod ArtPeace { impl ArtPeaceTemplateVerifier of ITemplateVerifier { // TODO: Check template function fn complete_template(ref self: ContractState, template_id: u32, template_image: Span) { - assert(template_id < self.get_templates_count(), ''); - assert(!self.is_template_complete(template_id), ''); + assert(template_id < self.get_templates_count(), 'Template ID out of bounds'); + assert(!self.is_template_complete(template_id), 'Template already completed'); // TODO: ensure template_image matches the template size & hash let template_metadata: TemplateMetadata = self.get_template(template_id); let non_zero_width: core::zeroable::NonZero:: = template_metadata diff --git a/onchain/src/nfts/canvas_nft.cairo b/onchain/src/nfts/canvas_nft.cairo index 537d6ed2..5524a792 100644 --- a/onchain/src/nfts/canvas_nft.cairo +++ b/onchain/src/nfts/canvas_nft.cairo @@ -4,6 +4,7 @@ mod CanvasNFT { use openzeppelin::introspection::src5::SRC5Component; use starknet::ContractAddress; use art_peace::nfts::component::CanvasNFTStoreComponent; + use art_peace::nfts::component::CanvasNFTStoreComponent::CanvasNFTMinted; use art_peace::nfts::{ICanvasNFTAdditional, NFTMetadata}; component!(path: ERC721Component, storage: erc721, event: ERC721Event); @@ -45,16 +46,19 @@ mod CanvasNFT { } #[constructor] - fn constructor( - ref self: ContractState, name: ByteArray, symbol: ByteArray, art_peace_addr: ContractAddress - ) { - self.art_peace.write(art_peace_addr); - let base_uri = format!("{:?}", art_peace_addr); + fn constructor(ref self: ContractState, name: ByteArray, symbol: ByteArray) { + let base_uri = "test"; // TODO: change to real base uri self.erc721.initializer(name, symbol, base_uri); } #[abi(embed_v0)] impl CanvasNFTAdditional of ICanvasNFTAdditional { + fn set_canvas_contract(ref self: ContractState, canvas_contract: ContractAddress) { + let zero_address = starknet::contract_address_const::<0>(); + assert(self.art_peace.read() == zero_address, 'ArtPeace contract already set'); + self.art_peace.write(canvas_contract); + } + fn mint(ref self: ContractState, metadata: NFTMetadata, receiver: ContractAddress) { assert( self.art_peace.read() == starknet::get_caller_address(), @@ -64,7 +68,7 @@ mod CanvasNFT { self.nfts.nfts_data.write(token_id, metadata); self.erc721._mint(receiver, token_id); self.nfts.nfts_count.write(token_id + 1); - // TODO: self.emit(Event::NFTEvent::CanvasNFTMinted { token_id, metadata }); + self.nfts.emit(CanvasNFTMinted { token_id, metadata }); } } } diff --git a/onchain/src/nfts/component.cairo b/onchain/src/nfts/component.cairo index e0d8eb70..87b71993 100644 --- a/onchain/src/nfts/component.cairo +++ b/onchain/src/nfts/component.cairo @@ -16,10 +16,10 @@ pub mod CanvasNFTStoreComponent { } #[derive(Drop, starknet::Event)] - struct CanvasNFTMinted { + pub struct CanvasNFTMinted { #[key] - token_id: u256, - metadata: NFTMetadata, + pub token_id: u256, + pub metadata: NFTMetadata, } #[embeddable_as(CanvasNFTStoreImpl)] diff --git a/onchain/src/nfts/interfaces.cairo b/onchain/src/nfts/interfaces.cairo index dccf722d..f5c5c3f4 100644 --- a/onchain/src/nfts/interfaces.cairo +++ b/onchain/src/nfts/interfaces.cairo @@ -5,7 +5,7 @@ pub struct NFTMintParams { pub height: u128, } -#[derive(Drop, Serde, PartialEq, starknet::Store)] +#[derive(Drop, Copy, Serde, PartialEq, starknet::Store)] pub struct NFTMetadata { pub position: u128, pub width: u128, @@ -28,12 +28,16 @@ pub trait ICanvasNFTStore { #[starknet::interface] pub trait ICanvasNFTAdditional { + // Sets up the contract addresses + fn set_canvas_contract(ref self: TContractState, canvas_contract: starknet::ContractAddress); // Mint a new NFT called by the ArtPeaceNFTMinter contract. fn mint(ref self: TContractState, metadata: NFTMetadata, receiver: starknet::ContractAddress); } #[starknet::interface] pub trait IArtPeaceNFTMinter { + // Sets up the contract addresses + fn add_nft_contract(ref self: TContractState, nft_contract: starknet::ContractAddress); // Mints a new NFT from the canvas using init params, and returns the token ID. fn mint_nft(self: @TContractState, mint_params: NFTMintParams); } diff --git a/onchain/src/templates/interfaces.cairo b/onchain/src/templates/interfaces.cairo index 2ecb9962..41f0f967 100644 --- a/onchain/src/templates/interfaces.cairo +++ b/onchain/src/templates/interfaces.cairo @@ -3,6 +3,7 @@ use starknet::ContractAddress; #[derive(Drop, Copy, Serde, starknet::Store)] pub struct TemplateMetadata { pub hash: felt252, + pub name: felt252, pub position: u128, pub width: u128, pub height: u128, diff --git a/onchain/src/tests/art_peace.cairo b/onchain/src/tests/art_peace.cairo index 73c10d43..8b685325 100644 --- a/onchain/src/tests/art_peace.cairo +++ b/onchain/src/tests/art_peace.cairo @@ -82,7 +82,6 @@ fn deploy_contract() -> ContractAddress { end_time: 1000000, daily_quests: array![].span(), main_quests: array![].span(), - nft_contract: NFT_CONTRACT(), } .serialize(ref calldata); let contract_addr = contract.deploy_at(@calldata, ART_PEACE_CONTRACT()).unwrap(); @@ -119,7 +118,6 @@ fn deploy_with_quests_contract( end_time: 1000000, daily_quests: daily_quests, main_quests: main_quests, - nft_contract: NFT_CONTRACT(), } .serialize(ref calldata); let contract_addr = contract.deploy_at(@calldata, ART_PEACE_CONTRACT()).unwrap(); @@ -195,7 +193,6 @@ fn deploy_nft_contract() -> ContractAddress { let symbol: ByteArray = "A/P"; name.serialize(ref calldata); symbol.serialize(ref calldata); - calldata.append(ART_PEACE_CONTRACT().into()); contract.deploy_at(@calldata, NFT_CONTRACT()).unwrap() } @@ -402,7 +399,13 @@ fn template_full_basic_test() { 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: erc20_mock, + name: 'test', + hash: template_hash, + position: 0, + width: 2, + height: 2, + reward: 0, + reward_token: erc20_mock, }; template_store.add_template(template_metadata); @@ -500,6 +503,7 @@ fn nft_mint_test() { let nft_minter = IArtPeaceNFTMinterDispatcher { contract_address: art_peace.contract_address }; let nft_store = ICanvasNFTStoreDispatcher { contract_address: NFT_CONTRACT() }; let nft = IERC721Dispatcher { contract_address: NFT_CONTRACT() }; + nft_minter.add_nft_contract(NFT_CONTRACT()); let mint_params = NFTMintParams { position: 10, width: 16, height: 16, }; snf::start_prank(CheatTarget::One(nft_minter.contract_address), PLAYER1()); @@ -544,6 +548,7 @@ fn deposit_reward_test() { let template_image = array![1, 2, 3, 4]; let template_hash = compute_template_hash(template_image.span()); let template_metadata = TemplateMetadata { + name: 'test', hash: template_hash, position: 0, width: 2, diff --git a/postgres/init.sql b/postgres/init.sql index f3fe8bc5..420640e9 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -109,5 +109,18 @@ CREATE TABLE Templates ( width integer NOT NULL, height integer NOT NULL, position integer NOT NULL, - data bytea NOT NULL + reward integer NOT NULL, + rewardToken char(64) NOT NULL +-- ,data bytea NOT NULL +); + +-- TODO: Owner & change on transfer +CREATE TABLE NFTs ( + key integer NOT NULL PRIMARY KEY, + position integer NOT NULL, + width integer NOT NULL, + height integer NOT NULL, + imageHash text NOT NULL, + blockNumber integer NOT NULL, + minter char(64) NOT NULL ); diff --git a/tests/integration/docker/add_template.sh b/tests/integration/docker/add_template.sh new file mode 100755 index 00000000..aee9031b --- /dev/null +++ b/tests/integration/docker/add_template.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# This script runs the integration tests. + +# TODO: Host? +RPC_HOST="devnet" +RPC_PORT=5050 + +RPC_URL=http://$RPC_HOST:$RPC_PORT + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +WORK_DIR=$SCRIPT_DIR/.. + +#TODO: 2 seperate directories when called from the test script +OUTPUT_DIR=$HOME/.art-peace-tests +TIMESTAMP=$(date +%s) +LOG_DIR=$OUTPUT_DIR/logs/$TIMESTAMP +TMP_DIR=$OUTPUT_DIR/tmp/$TIMESTAMP + +# TODO: Clean option to remove old logs and state +#rm -rf $OUTPUT_DIR/logs/* +#rm -rf $OUTPUT_DIR/tmp/* +mkdir -p $LOG_DIR +mkdir -p $TMP_DIR + +ACCOUNT_NAME=art_peace_acct +ACCOUNT_ADDRESS=0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 +ACCOUNT_PRIVATE_KEY=0x856c96eaa4e7c40c715ccc5dacd8bf6e +ACCOUNT_PROFILE=starknet-devnet +ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json + +/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY + +#TODO: rename script and make more generic +echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME invoke --contract-address $1 --function $2 --calldata $3 $4 $5 $6 $7 0 $8 $9" > $LOG_DIR/cmd.txt +/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $1 --function $2 --calldata $3 $4 $5 $6 $7 0 $8 $9 > $LOG_DIR/output.json diff --git a/tests/integration/docker/deploy.sh b/tests/integration/docker/deploy.sh index 7204e68a..7e455e2f 100755 --- a/tests/integration/docker/deploy.sh +++ b/tests/integration/docker/deploy.sh @@ -30,14 +30,19 @@ ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json /root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY - CONTRACT_DIR=$WORK_DIR/onchain -CLASS_NAME="ArtPeace" +ART_PEACE_CLASS_NAME="ArtPeace" #TODO: Issue if no declare done -CLASS_DECLARE_RESULT=$(cd $CONTRACT_DIR && /root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json declare --contract-name $CLASS_NAME | tail -n 1) -CLASS_HASH=$(echo $CLASS_DECLARE_RESULT | jq -r '.class_hash') -echo "Declared class \"$CLASS_NAME\" with hash $CLASS_HASH" +ART_PEACE_CLASS_DECLARE_RESULT=$(cd $CONTRACT_DIR && /root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json declare --contract-name $ART_PEACE_CLASS_NAME | tail -n 1) +ART_PEACE_CLASS_HASH=$(echo $ART_PEACE_CLASS_DECLARE_RESULT | jq -r '.class_hash') +echo "Declared class \"$ART_PEACE_CLASS_NAME\" with hash $ART_PEACE_CLASS_HASH" + +NFT_CLASS_NAME="CanvasNFT" + +NFT_CLASS_DECLARE_RESULT=$(cd $CONTRACT_DIR && /root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json declare --contract-name $NFT_CLASS_NAME | tail -n 1) +NFT_CLASS_HASH=$(echo $NFT_CLASS_DECLARE_RESULT | jq -r '.class_hash') +echo "Declared class \"$NFT_CLASS_NAME\" with hash $NFT_CLASS_HASH" CANVAS_CONFIG=$WORK_DIR/configs/canvas.config.json WIDTH=$(jq -r '.canvas.width' $CANVAS_CONFIG) @@ -48,18 +53,37 @@ COLORS=$(jq -r '.colors[]' $CANVAS_CONFIG | sed 's/^/0x/') END_TIME=3000000000 # [WIDTH, HEIGHT, TIME_BETWEEN_PIXELS, COLOR_PALLETE_LEN, COLORS, END_TIME, DAILY_QUESTS_LEN, D AILY_QUESTS, DAILY_QUESTS_LEN, MAIN_QUESTS, NFT_CONTRACT] -CALLDATA=$(echo -n $WIDTH $HEIGHT $PLACE_DELAY $COLOR_COUNT $COLORS $END_TIME 0 0 0) +CALLDATA=$(echo -n $WIDTH $HEIGHT $PLACE_DELAY $COLOR_COUNT $COLORS $END_TIME 0 0) + +# Precalculated contract address +# echo "Precalculating contract address..." # TODO: calldata passed as parameters -echo "Deploying contract \"$CLASS_NAME\"..." -echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $CLASS_HASH --constructor-calldata $CALLDATA" -CONTRACT_DEPLOY_RESULT=$(/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $CLASS_HASH --constructor-calldata $CALLDATA | tail -n 1) -CONTRACT_ADDRESS=$(echo $CONTRACT_DEPLOY_RESULT | jq -r '.contract_address') -echo "Deployed contract \"$CLASS_NAME\" with address $CONTRACT_ADDRESS" +echo "Deploying contract \"$ART_PEACE_CLASS_NAME\"..." +echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $ART_PEACE_CLASS_HASH --constructor-calldata $CALLDATA" +ART_PEACE_CONTRACT_DEPLOY_RESULT=$(/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $ART_PEACE_CLASS_HASH --constructor-calldata $CALLDATA | tail -n 1) +ART_PEACE_CONTRACT_ADDRESS=$(echo $ART_PEACE_CONTRACT_DEPLOY_RESULT | jq -r '.contract_address') +echo "Deployed contract \"$ART_PEACE_CLASS_NAME\" with address $ART_PEACE_CONTRACT_ADDRESS" + +NFT_NAME="0 318195848183955342120051 10" +NFT_SYMBOL="0 4271952 3" +CALLDATA=$(echo -n $NFT_NAME $NFT_SYMBOL) + +echo "Deploying contract \"$NFT_CLASS_NAME\"..." +echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $NFT_CLASS_HASH --constructor-calldata $CALLDATA" +NFT_CONTRACT_DEPLOY_RESULT=$(/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $NFT_CLASS_HASH --constructor-calldata $CALLDATA | tail -n 1) +NFT_CONTRACT_ADDRESS=$(echo $NFT_CONTRACT_DEPLOY_RESULT | jq -r '.contract_address') +echo "Deployed contract \"$NFT_CLASS_NAME\" with address $NFT_CONTRACT_ADDRESS" + +echo "Setting up contract \"$ART_PEACE_CLASS_NAME\"..." +echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $ART_PEACE_CONTRACT_ADDRESS --function set_nft_contract --calldata $NFT_CONTRACT_ADDRESS" +/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=$CONTRACT_ADDRESS" > /configs/configs.env -echo "REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$CONTRACT_ADDRESS" >> /configs/configs.env +echo "ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" > /configs/configs.env +echo "REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" >> /configs/configs.env +echo "NFT_CONTRACT_ADDRESS=$NFT_CONTRACT_ADDRESS" >> /configs/configs.env +echo "REACT_APP_NFT_CONTRACT_ADDRESS=$NFT_CONTRACT_ADDRESS" >> /configs/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 8d7a8f7e..5f35eab2 100755 --- a/tests/integration/docker/initialize.sh +++ b/tests/integration/docker/initialize.sh @@ -9,7 +9,7 @@ echo "Initializing the canvas" curl http://backend:8080/initCanvas -X POST echo "Set the contract address" -CONTRACT_ADDRESS=$(cat /configs/configs.env | tail -n 1 | cut -d '=' -f2) +CONTRACT_ADDRESS=$(cat /configs/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" diff --git a/tests/integration/docker/mint_nft.sh b/tests/integration/docker/mint_nft.sh new file mode 100755 index 00000000..b0635f4f --- /dev/null +++ b/tests/integration/docker/mint_nft.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# This script runs the integration tests. + +# TODO: Host? +RPC_HOST="devnet" +RPC_PORT=5050 + +RPC_URL=http://$RPC_HOST:$RPC_PORT + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +WORK_DIR=$SCRIPT_DIR/.. + +#TODO: 2 seperate directories when called from the test script +OUTPUT_DIR=$HOME/.art-peace-tests +TIMESTAMP=$(date +%s) +LOG_DIR=$OUTPUT_DIR/logs/$TIMESTAMP +TMP_DIR=$OUTPUT_DIR/tmp/$TIMESTAMP + +# TODO: Clean option to remove old logs and state +#rm -rf $OUTPUT_DIR/logs/* +#rm -rf $OUTPUT_DIR/tmp/* +mkdir -p $LOG_DIR +mkdir -p $TMP_DIR + +ACCOUNT_NAME=art_peace_acct +ACCOUNT_ADDRESS=0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 +ACCOUNT_PRIVATE_KEY=0x856c96eaa4e7c40c715ccc5dacd8bf6e +ACCOUNT_PROFILE=starknet-devnet +ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json + +/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY + +#TODO: rename script and make more generic +echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME invoke --contract-address $1 --function $2 --calldata $3 $4 $5" > $LOG_DIR/cmd.txt +/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $1 --function $2 --calldata $3 $4 $5 > $LOG_DIR/output.json diff --git a/tests/integration/docker/place_extra_pixels.sh b/tests/integration/docker/place_extra_pixels.sh new file mode 100755 index 00000000..63bd103f --- /dev/null +++ b/tests/integration/docker/place_extra_pixels.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# This script runs the integration tests. + +# TODO: Host? +RPC_HOST="devnet" +RPC_PORT=5050 + +RPC_URL=http://$RPC_HOST:$RPC_PORT + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +WORK_DIR=$SCRIPT_DIR/.. + +#TODO: 2 seperate directories when called from the test script +OUTPUT_DIR=$HOME/.art-peace-tests +TIMESTAMP=$(date +%s) +LOG_DIR=$OUTPUT_DIR/logs/$TIMESTAMP +TMP_DIR=$OUTPUT_DIR/tmp/$TIMESTAMP + +# TODO: Clean option to remove old logs and state +#rm -rf $OUTPUT_DIR/logs/* +#rm -rf $OUTPUT_DIR/tmp/* +mkdir -p $LOG_DIR +mkdir -p $TMP_DIR + +ACCOUNT_NAME=art_peace_acct +ACCOUNT_ADDRESS=0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 +ACCOUNT_PRIVATE_KEY=0x856c96eaa4e7c40c715ccc5dacd8bf6e +ACCOUNT_PROFILE=starknet-devnet +ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json + +/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY + +#TODO: rename script and make more generic +echo "/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME invoke --contract-address $1 --function $2 --calldata $3 $4" > $LOG_DIR/cmd.txt +/root/.local/bin/sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $1 --function $2 --calldata $3 $4 > $LOG_DIR/output.json diff --git a/tests/integration/local/add_template.sh b/tests/integration/local/add_template.sh new file mode 100755 index 00000000..d00b6bb5 --- /dev/null +++ b/tests/integration/local/add_template.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# This script runs the integration tests. + +# TODO: Host? +RPC_HOST="127.0.0.1" +RPC_PORT=5050 + +RPC_URL=http://$RPC_HOST:$RPC_PORT + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +WORK_DIR=$SCRIPT_DIR/.. + +#TODO: 2 seperate directories when called from the test script +OUTPUT_DIR=$HOME/.art-peace-tests +TIMESTAMP=$(date +%s) +LOG_DIR=$OUTPUT_DIR/logs/$TIMESTAMP +TMP_DIR=$OUTPUT_DIR/tmp/$TIMESTAMP + +# TODO: Clean option to remove old logs and state +#rm -rf $OUTPUT_DIR/logs/* +#rm -rf $OUTPUT_DIR/tmp/* +mkdir -p $LOG_DIR +mkdir -p $TMP_DIR + +ACCOUNT_NAME=art_peace_acct +ACCOUNT_ADDRESS=0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 +ACCOUNT_PRIVATE_KEY=0x856c96eaa4e7c40c715ccc5dacd8bf6e +ACCOUNT_PROFILE=starknet-devnet +ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json + +sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY + +#TODO: rename script and make more generic +echo "sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME invoke --contract-address $1 --function $2 --calldata $3 $4 $5 $6 $7 0 $8 $9" > $LOG_DIR/cmd.txt +sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $1 --function $2 --calldata $3 $4 $5 $6 $7 0 $8 $9 > $LOG_DIR/output.json diff --git a/tests/integration/local/deploy.sh b/tests/integration/local/deploy.sh index cf398e4e..7c928bc7 100755 --- a/tests/integration/local/deploy.sh +++ b/tests/integration/local/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# This script runs the integration tests. +# This script deploys the ArtPeace contract to the StarkNet devnet locally RPC_HOST="127.0.0.1" RPC_PORT=5050 @@ -30,14 +30,19 @@ ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY - CONTRACT_DIR=$WORK_DIR/onchain -CLASS_NAME="ArtPeace" +ART_PEACE_CLASS_NAME="ArtPeace" #TODO: Issue if no declare done -CLASS_DECLARE_RESULT=$(cd $CONTRACT_DIR && sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json declare --contract-name $CLASS_NAME | tail -n 1) -CLASS_HASH=$(echo $CLASS_DECLARE_RESULT | jq -r '.class_hash') -echo "Declared class \"$CLASS_NAME\" with hash $CLASS_HASH" +ART_PEACE_CLASS_DECLARE_RESULT=$(cd $CONTRACT_DIR && sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json declare --contract-name $ART_PEACE_CLASS_NAME | tail -n 1) +ART_PEACE_CLASS_HASH=$(echo $ART_PEACE_CLASS_DECLARE_RESULT | jq -r '.class_hash') +echo "Declared class \"$ART_PEACE_CLASS_NAME\" with hash $ART_PEACE_CLASS_HASH" + +NFT_CLASS_NAME="CanvasNFT" + +NFT_CLASS_DECLARE_RESULT=$(cd $CONTRACT_DIR && sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json declare --contract-name $NFT_CLASS_NAME | tail -n 1) +NFT_CLASS_HASH=$(echo $NFT_CLASS_DECLARE_RESULT | jq -r '.class_hash') +echo "Declared class \"$NFT_CLASS_NAME\" with hash $NFT_CLASS_HASH" CANVAS_CONFIG=$WORK_DIR/configs/canvas.config.json WIDTH=$(jq -r '.canvas.width' $CANVAS_CONFIG) @@ -47,15 +52,32 @@ COLOR_COUNT=$(jq -r '.colors[]' $CANVAS_CONFIG | wc -l | tr -d ' ') COLORS=$(jq -r '.colors[]' $CANVAS_CONFIG | sed 's/^/0x/') END_TIME=3000000000 -# [WIDTH, HEIGHT, TIME_BETWEEN_PIXELS, COLOR_PALLETE_LEN, COLORS, END_TIME, DAILY_QUESTS_LEN, DAILY_QUESTS, DAILY_QUESTS_LEN, MAIN_QUESTS] -CALLDATA=$(echo -n $WIDTH $HEIGHT $PLACE_DELAY $COLOR_COUNT $COLORS $END_TIME 0 0 0) -echo "Calldata: $CALLDATA" +# [WIDTH, HEIGHT, TIME_BETWEEN_PIXELS, COLOR_PALLETE_LEN, COLORS, END_TIME, DAILY_QUESTS_LEN, D AILY_QUESTS, DAILY_QUESTS_LEN, MAIN_QUESTS, NFT_CONTRACT] +CALLDATA=$(echo -n $WIDTH $HEIGHT $PLACE_DELAY $COLOR_COUNT $COLORS $END_TIME 0 0) + +# Precalculated contract address +# echo "Precalculating contract address..." + # TODO: calldata passed as parameters -echo "Deploying contract \"$CLASS_NAME\"..." -echo "sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $CLASS_HASH --constructor-calldata $CALLDATA" -CONTRACT_DEPLOY_RESULT=$(sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $CLASS_HASH --constructor-calldata $CALLDATA | tail -n 1) -CONTRACT_ADDRESS=$(echo $CONTRACT_DEPLOY_RESULT | jq -r '.contract_address') -echo "Deployed contract \"$CLASS_NAME\" with address $CONTRACT_ADDRESS" +echo "Deploying contract \"$ART_PEACE_CLASS_NAME\"..." +echo "sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $ART_PEACE_CLASS_HASH --constructor-calldata $CALLDATA" +ART_PEACE_CONTRACT_DEPLOY_RESULT=$(sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $ART_PEACE_CLASS_HASH --constructor-calldata $CALLDATA | tail -n 1) +ART_PEACE_CONTRACT_ADDRESS=$(echo $ART_PEACE_CONTRACT_DEPLOY_RESULT | jq -r '.contract_address') +echo "Deployed contract \"$ART_PEACE_CLASS_NAME\" with address $ART_PEACE_CONTRACT_ADDRESS" + +NFT_NAME="0 318195848183955342120051 10" +NFT_SYMBOL="0 4271952 3" +CALLDATA=$(echo -n $NFT_NAME $NFT_SYMBOL) + +echo "Deploying contract \"$NFT_CLASS_NAME\"..." +echo "sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $NFT_CLASS_HASH --constructor-calldata $CALLDATA" +NFT_CONTRACT_DEPLOY_RESULT=$(sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json deploy --class-hash $NFT_CLASS_HASH --constructor-calldata $CALLDATA | tail -n 1) +NFT_CONTRACT_ADDRESS=$(echo $NFT_CONTRACT_DEPLOY_RESULT | jq -r '.contract_address') +echo "Deployed contract \"$NFT_CLASS_NAME\" with address $NFT_CONTRACT_ADDRESS" + +echo "Setting up contract \"$ART_PEACE_CLASS_NAME\"..." +echo "sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $ART_PEACE_CONTRACT_ADDRESS --function set_nft_contract --calldata $NFT_CONTRACT_ADDRESS" +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 # MULTICALL_TEMPLATE_DIR=$CONTRACT_DIR/tests/multicalls @@ -66,3 +88,5 @@ echo "Deployed contract \"$CLASS_NAME\" with address $CONTRACT_ADDRESS" # sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait multicall run --path $HELLO_STARKNET_MULTI # # sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait call --contract-address $CONTRACT_ADDRESS --function get_balance --block-id latest +# +# TODO: exit 1 on failure diff --git a/tests/integration/local/mint_nft.sh b/tests/integration/local/mint_nft.sh new file mode 100755 index 00000000..a99c1212 --- /dev/null +++ b/tests/integration/local/mint_nft.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# This script runs the integration tests. + +# TODO: Host? +RPC_HOST="127.0.0.1" +RPC_PORT=5050 + +RPC_URL=http://$RPC_HOST:$RPC_PORT + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +WORK_DIR=$SCRIPT_DIR/.. + +#TODO: 2 seperate directories when called from the test script +OUTPUT_DIR=$HOME/.art-peace-tests +TIMESTAMP=$(date +%s) +LOG_DIR=$OUTPUT_DIR/logs/$TIMESTAMP +TMP_DIR=$OUTPUT_DIR/tmp/$TIMESTAMP + +# TODO: Clean option to remove old logs and state +#rm -rf $OUTPUT_DIR/logs/* +#rm -rf $OUTPUT_DIR/tmp/* +mkdir -p $LOG_DIR +mkdir -p $TMP_DIR + +ACCOUNT_NAME=art_peace_acct +ACCOUNT_ADDRESS=0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 +ACCOUNT_PRIVATE_KEY=0x856c96eaa4e7c40c715ccc5dacd8bf6e +ACCOUNT_PROFILE=starknet-devnet +ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json + +sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY + +#TODO: rename script and make more generic +echo "sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME invoke --contract-address $1 --function $2 --calldata $3 $4 $5" > $LOG_DIR/cmd.txt +sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $1 --function $2 --calldata $3 $4 $5 > $LOG_DIR/output.json diff --git a/tests/integration/local/place_extra_pixels.sh b/tests/integration/local/place_extra_pixels.sh new file mode 100755 index 00000000..e0551d10 --- /dev/null +++ b/tests/integration/local/place_extra_pixels.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# +# This script runs the integration tests. + +RPC_HOST="127.0.0.1" +RPC_PORT=5050 + +RPC_URL=http://$RPC_HOST:$RPC_PORT + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +WORK_DIR=$SCRIPT_DIR/../../.. + +#TODO: 2 seperate directories when called from the test script +OUTPUT_DIR=$HOME/.art-peace-tests +TIMESTAMP=$(date +%s) +LOG_DIR=$OUTPUT_DIR/logs/$TIMESTAMP +TMP_DIR=$OUTPUT_DIR/tmp/$TIMESTAMP + +# TODO: Clean option to remove old logs and state +#rm -rf $OUTPUT_DIR/logs/* +#rm -rf $OUTPUT_DIR/tmp/* +mkdir -p $LOG_DIR +mkdir -p $TMP_DIR + +ACCOUNT_NAME=art_peace_acct +ACCOUNT_ADDRESS=0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 +ACCOUNT_PRIVATE_KEY=0x856c96eaa4e7c40c715ccc5dacd8bf6e +ACCOUNT_PROFILE=starknet-devnet +ACCOUNT_FILE=$TMP_DIR/starknet_accounts.json + +sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE account add --name $ACCOUNT_NAME --address $ACCOUNT_ADDRESS --private-key $ACCOUNT_PRIVATE_KEY + +#TODO: rename script and make more generic +echo "sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME invoke --contract-address $1 --function $2 --calldata $3 $4" > $LOG_DIR/cmd.txt +sncast --url $RPC_URL --accounts-file $ACCOUNT_FILE --account $ACCOUNT_NAME --wait --json invoke --contract-address $1 --function $2 --calldata $3 $4 > $LOG_DIR/output.json diff --git a/tests/integration/local/run.sh b/tests/integration/local/run.sh index 6e7d4986..a190d19d 100755 --- a/tests/integration/local/run.sh +++ b/tests/integration/local/run.sh @@ -67,8 +67,10 @@ DEPLOY_LOG_FILE=$LOG_DIR/deploy.log touch $DEPLOY_LOG_FILE $SCRIPT_DIR/deploy.sh 2>&1 > $DEPLOY_LOG_FILE # Read last word of last line of deploy log -ART_PEACE_CONTRACT_ADDRESS=$(cat $DEPLOY_LOG_FILE | tail -n 1 | awk '{print $NF}') +ART_PEACE_CONTRACT_ADDRESS=$(cat $DEPLOY_LOG_FILE | grep Deployed\ contract\ \"ArtPeace\" | tail -n 1 | awk '{print $NF}') +CANVAS_NFT_CONTRACT_ADDRESS=$(cat $DEPLOY_LOG_FILE | grep Deployed\ contract\ \"CanvasNFT\" | tail -n 1 | awk '{print $NF}') echo "Deployed art-peace contract(s) at $ART_PEACE_CONTRACT_ADDRESS" +echo "Deployed canvas NFT contract at $CANVAS_NFT_CONTRACT_ADDRESS" # Start the art-peace place_pixel indexer script echo "Starting art-peace place_pixel indexer script ..." @@ -80,6 +82,7 @@ cd $WORK_DIR/indexer 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 "NFT_CONTRACT_ADDRESS=$CANVAS_NFT_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 &