From e04b0ebf1ecd4944a033cbaaa3c7c895d4051490 Mon Sep 17 00:00:00 2001 From: Tristan <122918260+TAdev0@users.noreply.github.com> Date: Mon, 15 Apr 2024 18:38:00 +0200 Subject: [PATCH 01/17] [feat] Deposit reward when adding a new template (#41) * deposit_reward * fix * idiomatic imports * remove underscore for internal fn * address_comments --- onchain/src/lib.cairo | 5 ++ onchain/src/mocks/erc20_mock.cairo | 42 +++++++++++++++ onchain/src/templates/component.cairo | 31 +++++++++++ onchain/src/templates/interfaces.cairo | 4 +- onchain/src/tests/art_peace.cairo | 72 +++++++++++++++++++++++--- onchain/src/tests/utils.cairo | 23 ++++++++ 6 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 onchain/src/mocks/erc20_mock.cairo create mode 100644 onchain/src/tests/utils.cairo diff --git a/onchain/src/lib.cairo b/onchain/src/lib.cairo index 4097103e..158e7378 100644 --- a/onchain/src/lib.cairo +++ b/onchain/src/lib.cairo @@ -33,7 +33,12 @@ mod nfts { }; } +mod mocks { + pub mod erc20_mock; +} + #[cfg(test)] mod tests { mod art_peace; + pub(crate) mod utils; } diff --git a/onchain/src/mocks/erc20_mock.cairo b/onchain/src/mocks/erc20_mock.cairo new file mode 100644 index 00000000..8bd72de4 --- /dev/null +++ b/onchain/src/mocks/erc20_mock.cairo @@ -0,0 +1,42 @@ +// +// https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/tests/mocks/erc20_mocks.cairo +// + +#[starknet::contract] +pub mod SnakeERC20Mock { + use openzeppelin::token::erc20::ERC20Component; + use starknet::ContractAddress; + + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + + #[abi(embed_v0)] + impl ERC20Impl = ERC20Component::ERC20Impl; + #[abi(embed_v0)] + impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl; + impl InternalImpl = ERC20Component::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + erc20: ERC20Component::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC20Event: ERC20Component::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + initial_supply: u256, + recipient: ContractAddress + ) { + self.erc20.initializer(name, symbol); + self.erc20._mint(recipient, initial_supply); + } +} diff --git a/onchain/src/templates/component.cairo b/onchain/src/templates/component.cairo index e159b069..7fb27ce8 100644 --- a/onchain/src/templates/component.cairo +++ b/onchain/src/templates/component.cairo @@ -1,6 +1,9 @@ #[starknet::component] pub mod TemplateStoreComponent { use art_peace::templates::interfaces::{ITemplateStore, TemplateMetadata}; + use core::num::traits::Zero; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{ContractAddress, get_caller_address}; #[storage] struct Storage { @@ -59,6 +62,11 @@ pub mod TemplateStoreComponent { let template_id = self.templates_count.read(); self.templates.write(template_id, template_metadata); self.templates_count.write(template_id + 1); + + if !template_metadata.reward_token.is_zero() && template_metadata.reward != 0 { + self.deposit(template_metadata.reward_token, template_metadata.reward); + } + self.emit(TemplateAdded { id: template_id, metadata: template_metadata }); } @@ -66,4 +74,27 @@ pub mod TemplateStoreComponent { self.completed_templates.read(template_id) } } + + #[generate_trait] + impl InternalImpl< + TContractState, +HasComponent + > of InternalTrait { + fn deposit( + ref self: ComponentState, + reward_token: ContractAddress, + reward_amount: u256 + ) { + let caller_address = get_caller_address(); + let contract_address = starknet::get_contract_address(); + assert(!get_caller_address().is_zero(), 'Invalid caller'); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: reward_token }; + let allowance = erc20_dispatcher.allowance(caller_address, contract_address); + assert(allowance >= reward_amount, 'Insufficient allowance'); + + let success = erc20_dispatcher + .transfer_from(caller_address, contract_address, reward_amount); + assert(success, 'Transfer failed'); + } + } } diff --git a/onchain/src/templates/interfaces.cairo b/onchain/src/templates/interfaces.cairo index 44b9d7d1..2ecb9962 100644 --- a/onchain/src/templates/interfaces.cairo +++ b/onchain/src/templates/interfaces.cairo @@ -1,3 +1,5 @@ +use starknet::ContractAddress; + #[derive(Drop, Copy, Serde, starknet::Store)] pub struct TemplateMetadata { pub hash: felt252, @@ -5,7 +7,7 @@ pub struct TemplateMetadata { pub width: u128, pub height: u128, pub reward: u256, - pub reward_token: starknet::ContractAddress + pub reward_token: ContractAddress } #[starknet::interface] diff --git a/onchain/src/tests/art_peace.cairo b/onchain/src/tests/art_peace.cairo index c679dd01..73c10d43 100644 --- a/onchain/src/tests/art_peace.cairo +++ b/onchain/src/tests/art_peace.cairo @@ -1,6 +1,8 @@ use art_peace::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; use art_peace::ArtPeace::InitParams; use art_peace::quests::pixel_quest::PixelQuest::PixelQuestInitParams; +use art_peace::mocks::erc20_mock::SnakeERC20Mock; +use art_peace::tests::utils; use art_peace::nfts::interfaces::{ IArtPeaceNFTMinterDispatcher, IArtPeaceNFTMinterDispatcherTrait, ICanvasNFTStoreDispatcher, ICanvasNFTStoreDispatcherTrait, NFTMintParams, NFTMetadata @@ -13,10 +15,13 @@ use art_peace::templates::interfaces::{ use core::poseidon::PoseidonTrait; use core::hash::{HashStateTrait, HashStateExTrait}; +use openzeppelin::token::erc20::interface::{IERC20, IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin::token::erc721::interface::{IERC721Dispatcher, IERC721DispatcherTrait}; + use snforge_std as snf; use snforge_std::{CheatTarget, ContractClassTrait}; -use starknet::{ContractAddress, contract_address_const}; + +use starknet::{ContractAddress, contract_address_const, get_contract_address}; const DAY_IN_SECONDS: u64 = consteval_int!(60 * 60 * 24); const WIDTH: u128 = 100; @@ -27,6 +32,10 @@ fn ART_PEACE_CONTRACT() -> ContractAddress { contract_address_const::<'ArtPeace'>() } +fn ERC20_MOCK_CONTRACT() -> ContractAddress { + contract_address_const::<'erc20mock'>() +} + fn EMPTY_CALLDATA() -> Span { array![].span() } @@ -190,6 +199,25 @@ fn deploy_nft_contract() -> ContractAddress { contract.deploy_at(@calldata, NFT_CONTRACT()).unwrap() } + +fn deploy_erc20_mock() -> ContractAddress { + let contract = snf::declare("SnakeERC20Mock"); + let name: ByteArray = "erc20 mock"; + let symbol: ByteArray = "ERC20MOCK"; + let initial_supply: u256 = 10 * utils::pow_256(10, 18); + let recipient: ContractAddress = get_contract_address(); + + let mut calldata: Array = array![]; + Serde::serialize(@name, ref calldata); + Serde::serialize(@symbol, ref calldata); + Serde::serialize(@initial_supply, ref calldata); + Serde::serialize(@recipient, ref calldata); + + let contract_addr = contract.deploy_at(@calldata, ERC20_MOCK_CONTRACT()).unwrap(); + + contract_addr +} + fn warp_to_next_available_time(art_peace: IArtPeaceDispatcher) { let last_time = art_peace.get_last_placed_time(); snf::start_warp(CheatTarget::One(art_peace.contract_address), last_time + TIME_BETWEEN_PIXELS); @@ -368,16 +396,13 @@ fn template_full_basic_test() { assert!(template_store.get_templates_count() == 0, "Templates count is not 0"); + let erc20_mock: ContractAddress = deploy_erc20_mock(); + // 2x2 template image let template_image = array![1, 2, 3, 4]; let template_hash = compute_template_hash(template_image.span()); let template_metadata = TemplateMetadata { - hash: template_hash, - position: 0, - width: 2, - height: 2, - reward: 0, - reward_token: contract_address_const::<0>(), + hash: template_hash, position: 0, width: 2, height: 2, reward: 0, reward_token: erc20_mock, }; template_store.add_template(template_metadata); @@ -505,3 +530,36 @@ fn nft_mint_test() { assert!(nft.balance_of(PLAYER1()) == 0, "NFT balance is not correct after transfer"); assert!(nft.balance_of(PLAYER2()) == 1, "NFT balance is not correct after transfer"); } + +#[test] +fn deposit_reward_test() { + let art_peace_address = deploy_contract(); + let art_peace = IArtPeaceDispatcher { contract_address: art_peace_address }; + let template_store = ITemplateStoreDispatcher { contract_address: art_peace.contract_address }; + + let erc20_mock: ContractAddress = deploy_erc20_mock(); + let reward_amount: u256 = 1 * utils::pow_256(10, 18); + + // 2x2 template image + let template_image = array![1, 2, 3, 4]; + let template_hash = compute_template_hash(template_image.span()); + let template_metadata = TemplateMetadata { + hash: template_hash, + position: 0, + width: 2, + height: 2, + reward: reward_amount, + reward_token: erc20_mock, + }; + + IERC20Dispatcher { contract_address: erc20_mock }.approve(art_peace_address, reward_amount); + + template_store.add_template(template_metadata); + + let art_peace_token_balance = IERC20Dispatcher { contract_address: erc20_mock } + .balance_of(art_peace_address); + + assert!( + art_peace_token_balance == reward_amount, "reward wrongly distributed when adding template" + ); +} diff --git a/onchain/src/tests/utils.cairo b/onchain/src/tests/utils.cairo new file mode 100644 index 00000000..1d9a66bc --- /dev/null +++ b/onchain/src/tests/utils.cairo @@ -0,0 +1,23 @@ +use core::num::traits::Zero; + +// Math +pub(crate) fn pow_256(self: u256, mut exponent: u8) -> u256 { + if self.is_zero() { + return 0; + } + let mut result = 1; + let mut base = self; + + loop { + if exponent & 1 == 1 { + result = result * base; + } + + exponent = exponent / 2; + if exponent == 0 { + break result; + } + + base = base * base; + } +} From 96d36f6adad54398a464370de093f623207cf878 Mon Sep 17 00:00:00 2001 From: ptisserand Date: Tue, 16 Apr 2024 03:20:09 +0200 Subject: [PATCH 02/17] [feat] Create production flag on the backend (#35) * [feat] backend production flag (#3) * backend: disable `devnet` routes when production mode is enabled * Add production to config defaults --------- Co-authored-by: Brandon Roberts --- backend/config/backend.go | 8 +++++--- backend/main.go | 19 ++++++++++++++++++- backend/routes/pixel.go | 10 +++++++++- backend/routes/templates.go | 11 +++++++++-- configs/backend.config.json | 3 ++- configs/docker-backend.config.json | 3 ++- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/backend/config/backend.go b/backend/config/backend.go index d14c7975..788d00ee 100644 --- a/backend/config/backend.go +++ b/backend/config/backend.go @@ -11,9 +11,10 @@ type BackendScriptsConfig struct { } type BackendConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Scripts BackendScriptsConfig `json:"scripts"` + Host string `json:"host"` + Port int `json:"port"` + Scripts BackendScriptsConfig `json:"scripts"` + Production bool `json:"production"` } var DefaultBackendConfig = BackendConfig{ @@ -23,6 +24,7 @@ var DefaultBackendConfig = BackendConfig{ PlacePixelDevnet: "../scripts/place_pixel.sh", AddTemplateHashDevnet: "../scripts/add_template_hash.sh", }, + Production: false, } var DefaultBackendConfigPath = "../configs/backend.config.json" diff --git a/backend/main.go b/backend/main.go index 1b03b991..8be1a9cd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -8,10 +8,22 @@ import ( "github.com/keep-starknet-strange/art-peace/backend/routes" ) +func isFlagSet(name string) bool { + found := false + flag.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + func main() { canvasConfigFilename := flag.String("canvas-config", config.DefaultCanvasConfigPath, "Canvas config file") databaseConfigFilename := flag.String("database-config", config.DefaultDatabaseConfigPath, "Database config file") backendConfigFilename := flag.String("backend-config", config.DefaultBackendConfigPath, "Backend config file") + production := flag.Bool("production", false, "Production mode") + flag.Parse() canvasConfig, err := config.LoadCanvasConfig(*canvasConfigFilename) @@ -29,11 +41,16 @@ func main() { panic(err) } + if isFlagSet("production") { + backendConfig.Production = *production + } + databases := core.NewDatabases(databaseConfig) defer databases.Close() + core.ArtPeaceBackend = core.NewBackend(databases, canvasConfig, backendConfig) + routes.InitRoutes() - core.ArtPeaceBackend = core.NewBackend(databases, canvasConfig, backendConfig) core.ArtPeaceBackend.Start() } diff --git a/backend/routes/pixel.go b/backend/routes/pixel.go index 59656372..f1279503 100644 --- a/backend/routes/pixel.go +++ b/backend/routes/pixel.go @@ -16,7 +16,9 @@ import ( func InitPixelRoutes() { http.HandleFunc("/getPixel", getPixel) http.HandleFunc("/getPixelInfo", getPixelInfo) - http.HandleFunc("/placePixelDevnet", placePixelDevnet) + if !core.ArtPeaceBackend.BackendConfig.Production { + http.HandleFunc("/placePixelDevnet", placePixelDevnet) + } http.HandleFunc("/placePixelRedis", placePixelRedis) } @@ -55,6 +57,12 @@ func getPixelInfo(w http.ResponseWriter, r *http.Request) { } func placePixelDevnet(w http.ResponseWriter, r *http.Request) { + // Disable this in production + if core.ArtPeaceBackend.BackendConfig.Production { + http.Error(w, "Not available in production", http.StatusNotImplemented) + return + } + reqBody, err := io.ReadAll(r.Body) if err != nil { panic(err) diff --git a/backend/routes/templates.go b/backend/routes/templates.go index d1717970..090e58e1 100644 --- a/backend/routes/templates.go +++ b/backend/routes/templates.go @@ -18,7 +18,9 @@ import ( func InitTemplateRoutes() { http.HandleFunc("/addTemplateImg", addTemplateImg) http.HandleFunc("/addTemplateData", addTemplateData) - http.HandleFunc("/addTemplateHashDevnet", addTemplateHashDevnet) + if !core.ArtPeaceBackend.BackendConfig.Production { + http.HandleFunc("/addTemplateHashDevnet", addTemplateHashDevnet) + } } // TODO: Add specific location for template images @@ -93,7 +95,12 @@ func addTemplateData(w http.ResponseWriter, r *http.Request) { } func addTemplateHashDevnet(w http.ResponseWriter, r *http.Request) { - // TODO: Disable this in production + // Disable this in production + if core.ArtPeaceBackend.BackendConfig.Production { + http.Error(w, "Not available in production", http.StatusNotImplemented) + return + } + reqBody, err := io.ReadAll(r.Body) if err != nil { panic(err) diff --git a/configs/backend.config.json b/configs/backend.config.json index b8f73c23..228cf22b 100644 --- a/configs/backend.config.json +++ b/configs/backend.config.json @@ -4,5 +4,6 @@ "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", "add_template_hash_devnet": "../tests/integration/local/add_template_hash.sh" - } + }, + "production": false } diff --git a/configs/docker-backend.config.json b/configs/docker-backend.config.json index 1141700d..16a2e491 100644 --- a/configs/docker-backend.config.json +++ b/configs/docker-backend.config.json @@ -4,5 +4,6 @@ "scripts": { "place_pixel_devnet": "/scripts/place_pixel.sh", "add_template_hash_devnet": "/scripts/add_template_hash.sh" - } + }, + "production": false } From f031054e5409626546ea7e0bff5863c498e10e9e Mon Sep 17 00:00:00 2001 From: Brandon R <54774639+b-j-roberts@users.noreply.github.com> Date: Mon, 15 Apr 2024 20:24:29 -0500 Subject: [PATCH 03/17] Contrib: Add ptisserand (#42) * :busts_in_silhouette: Add @ptisserand as a contributor * Go fmt --- .all-contributorsrc | 9 +++++++++ README.md | 1 + backend/config/backend.go | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 01de750d..7a465d22 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -54,6 +54,15 @@ "contributions": [ "code" ] + }, + { + "login": "ptisserand", + "name": "ptisserand", + "avatar_url": "https://avatars.githubusercontent.com/u/544314?v=4", + "profile": "https://github.com/ptisserand", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 023cbd3f..11f9907d 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Thanks goes to these wonderful people. Follow the [contributors guide](https://g Tristan
Tristan

💻 Abdulhakeem Abdulazeez Ayodeji
Abdulhakeem Abdulazeez Ayodeji

💻 Trunks @ Carbonable
Trunks @ Carbonable

💻 + ptisserand
ptisserand

💻 diff --git a/backend/config/backend.go b/backend/config/backend.go index 788d00ee..6c4b8b78 100644 --- a/backend/config/backend.go +++ b/backend/config/backend.go @@ -24,7 +24,7 @@ var DefaultBackendConfig = BackendConfig{ PlacePixelDevnet: "../scripts/place_pixel.sh", AddTemplateHashDevnet: "../scripts/add_template_hash.sh", }, - Production: false, + Production: false, } var DefaultBackendConfigPath = "../configs/backend.config.json" From d21aa1c0e01a6abcefbdbb0ab518161fb62faffa Mon Sep 17 00:00:00 2001 From: Tristan <122918260+TAdev0@users.noreply.github.com> Date: Tue, 16 Apr 2024 04:09:20 +0200 Subject: [PATCH 04/17] [feat] Load Apibara Script Variables from Environment (#25) * load from env * update * Environment setup to use a single script.js file for indexing --------- Co-authored-by: Brandon Roberts --- README.md | 1 + docker-compose.yml | 11 +++++++---- indexer/Dockerfile | 4 ++-- indexer/README.md | 6 +++++- indexer/docker-script.js | 26 -------------------------- indexer/script.js | 8 +++++--- tests/integration/docker/deploy.sh | 4 ++-- tests/integration/docker/initialize.sh | 2 +- tests/integration/local/run.sh | 9 +++++++-- 9 files changed, 30 insertions(+), 41 deletions(-) delete mode 100644 indexer/docker-script.js diff --git a/README.md b/README.md index 11f9907d..b42059f4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ docker compose build ```bash # Must install all the dependencies first +# Use npm install inside the `frontend` directory # Change the user on `configs/database.config.json` for postgres make integration-test-local ``` diff --git a/docker-compose.yml b/docker-compose.yml index 78ebcc0e..25796567 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,7 +54,7 @@ services: environment: - SCARB=/root/.local/bin/scarb volumes: - - deployment:/deployment + - configs:/configs apibara: image: quay.io/apibara/starknet:1.5.0 command: @@ -86,8 +86,11 @@ services: links: - backend - apibara + environment: + - APIBARA_STREAM_URL=http://art-peace-apibara-1:7171 + - BACKEND_TARGET_URL=http://art-peace-backend-1:8080/consumeIndexerMsg volumes: - - deployment:/deployment + - configs:/configs restart: on-failure frontend: build: @@ -102,7 +105,7 @@ services: - backend - devnet volumes: - - deployment:/deployment + - configs:/configs - ./frontend/package.json:/app/package.json - ./frontend/package-lock.json:/app/package-lock.json - ./frontend/public/:/app/public @@ -113,4 +116,4 @@ volumes: postgres: devnet: apibara: - deployment: + configs: diff --git a/indexer/Dockerfile b/indexer/Dockerfile index 993bcb38..69dad95e 100644 --- a/indexer/Dockerfile +++ b/indexer/Dockerfile @@ -1,6 +1,6 @@ FROM quay.io/apibara/sink-webhook:0.6.0 as sink-webhook WORKDIR /indexer -COPY ./indexer/docker-script.js . +COPY ./indexer/script.js . -CMD ["run", "docker-script.js", "--allow-env", "/deployment/.env"] +CMD ["run", "script.js", "--allow-env", "/configs/configs.env", "--allow-env-from-env", "BACKEND_TARGET_URL,APIBARA_STREAM_URL"] diff --git a/indexer/README.md b/indexer/README.md index 9380a3d1..ebfff6f2 100644 --- a/indexer/README.md +++ b/indexer/README.md @@ -6,5 +6,9 @@ This directory contains the Apibara indexer setup for `art/peace`, which indexes ``` # Setup Indexer/DNA w/ docker compose or other options -apibara run scripts.js +# Create an indexer.env file with the following : +# ART_PEACE_CONTRACT_ADDRESS=... # Example: 0x78223f7ab13216727ed426380079c169578cafad83a3178c7b33ba7ca307713 +# APIBARA_STREAM_URL=... # Example: http://localhost:7171 +# BACKEND_TARGET_URL=... # Example: http://localhost:8080/consumeIndexerMsg +apibara run scripts.js --allow-env indexer.env ``` diff --git a/indexer/docker-script.js b/indexer/docker-script.js deleted file mode 100644 index 017ac4c4..00000000 --- a/indexer/docker-script.js +++ /dev/null @@ -1,26 +0,0 @@ -export const config = { - streamUrl: "http://art-peace-apibara-1:7171", - startingBlock: 0, - network: "starknet", - finality: "DATA_STATUS_PENDING", - filter: { - events: [ - { - fromAddress: Deno.env.get("ART_PEACE_CONTRACT_ADDRESS"), - keys: ["0x2D7B50EBF415606D77C7E7842546FC13F8ACFBFD16F7BCF2BC2D08F54114C23"], - includeReverted: false, - includeTransaction: false, - includeReceipt: false, - }, - ], - }, - sinkType: "webhook", - sinkOptions: { - targetUrl: "http://art-peace-backend-1:8080/consumeIndexerMsg" - }, -}; - -// This transform does nothing. -export default function transform(block) { - return block; -} diff --git a/indexer/script.js b/indexer/script.js index c0c0b29b..0390248a 100644 --- a/indexer/script.js +++ b/indexer/script.js @@ -1,5 +1,5 @@ export const config = { - streamUrl: "http://localhost:7171", + streamUrl: Deno.env.get("APIBARA_STREAM_URL"), startingBlock: 0, network: "starknet", finality: "DATA_STATUS_PENDING", @@ -7,7 +7,9 @@ export const config = { events: [ { fromAddress: Deno.env.get("ART_PEACE_CONTRACT_ADDRESS"), - keys: ["0x2D7B50EBF415606D77C7E7842546FC13F8ACFBFD16F7BCF2BC2D08F54114C23"], + keys: [ + "0x2D7B50EBF415606D77C7E7842546FC13F8ACFBFD16F7BCF2BC2D08F54114C23", + ], includeReverted: false, includeTransaction: false, includeReceipt: false, @@ -16,7 +18,7 @@ export const config = { }, sinkType: "webhook", sinkOptions: { - targetUrl: "http://localhost:8080/consumeIndexerMsg" + targetUrl: Deno.env.get("BACKEND_TARGET_URL"), }, }; diff --git a/tests/integration/docker/deploy.sh b/tests/integration/docker/deploy.sh index f517daa5..7204e68a 100755 --- a/tests/integration/docker/deploy.sh +++ b/tests/integration/docker/deploy.sh @@ -58,8 +58,8 @@ CONTRACT_ADDRESS=$(echo $CONTRACT_DEPLOY_RESULT | jq -r '.contract_address') echo "Deployed contract \"$CLASS_NAME\" with address $CONTRACT_ADDRESS" # TODO: Remove these lines? -echo "ART_PEACE_CONTRACT_ADDRESS=$CONTRACT_ADDRESS" > /deployment/.env -echo "REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$CONTRACT_ADDRESS" >> /deployment/.env +echo "ART_PEACE_CONTRACT_ADDRESS=$CONTRACT_ADDRESS" > /configs/configs.env +echo "REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$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 a12e888c..d21279b2 100755 --- a/tests/integration/docker/initialize.sh +++ b/tests/integration/docker/initialize.sh @@ -9,5 +9,5 @@ echo "Initializing the canvas" curl http://backend:8080/initCanvas -X POST echo "Set the contract address" -CONTRACT_ADDRESS=$(cat /deployment/.env | tail -n 1 | cut -d '=' -f2) +CONTRACT_ADDRESS=$(cat /configs/configs.env | tail -n 1 | cut -d '=' -f2) curl http://backend:8080/setContractAddress -X POST -d "$CONTRACT_ADDRESS" diff --git a/tests/integration/local/run.sh b/tests/integration/local/run.sh index 318fc40f..8e06d47b 100755 --- a/tests/integration/local/run.sh +++ b/tests/integration/local/run.sh @@ -77,7 +77,12 @@ INDEXER_SCRIPT_LOG_FILE=$LOG_DIR/indexer_script.log touch $INDEXER_SCRIPT_LOG_FILE cd $WORK_DIR/indexer #TODO: apibara -> postgres automatically? -ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS apibara run script.js --allow-env-from-env ART_PEACE_CONTRACT_ADDRESS 2>&1 > $INDEXER_SCRIPT_LOG_FILE & +rm -f $TMP_DIR/indexer.env +touch $TMP_DIR/indexer.env +echo "ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS" >> $TMP_DIR/indexer.env +echo "APIBARA_STREAM_URL=http://localhost:7171" >> $TMP_DIR/indexer.env +echo "BACKEND_TARGET_URL=http://localhost:8080/consumeIndexerMsg" >> $TMP_DIR/indexer.env +apibara run script.js --allow-env $TMP_DIR/indexer.env 2>&1 > $INDEXER_SCRIPT_LOG_FILE & INDEXER_SCRIPT_PID=$! sleep 2 # Wait for indexer script to start; TODO: Check if indexer script is actually running @@ -96,7 +101,7 @@ REACT_CANVAS_CONFIG_FILE=$WORK_DIR/frontend/src/configs/canvas.config.json REACT_BACKEND_CONFIG_FILE=$WORK_DIR/frontend/src/configs/backend.config.json cp $CANVAS_CONFIG_FILE $REACT_CANVAS_CONFIG_FILE #TODO: Use a symlink instead? cp $BACKEND_CONFIG_FILE $REACT_BACKEND_CONFIG_FILE -REACT_APP_ART_PEACE_CONTRACT_ADDRESS=$ART_PEACE_CONTRACT_ADDRESS REACT_APP_CANVAS_CONFIG_FILE=$REACT_CANVAS_CONFIG_FILE REACT_APP_BACKEND_CONFIG_FILE=$REACT_BACKEND_CONFIG_FILE npm start 2>&1 > $FRONTEND_LOG_FILE & +npm start 2>&1 > $FRONTEND_LOG_FILE & FRONTEND_PID=$! sleep 2 # Wait for frontend to start; TODO: Check if frontend is actually running From 3795e979ab0852ae3f6cccddc308060862a2289b Mon Sep 17 00:00:00 2001 From: Brandon R <54774639+b-j-roberts@users.noreply.github.com> Date: Mon, 15 Apr 2024 21:26:01 -0500 Subject: [PATCH 05/17] bug: Backend Postgres parallel access (#43) * Fix bug with concurrent access to postgres by switching to a pool * Go fmt --- backend/core/backend.go | 3 ++- backend/core/databases.go | 11 ++++++----- backend/go.mod | 2 ++ backend/routes/indexer.go | 1 + 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/core/backend.go b/backend/core/backend.go index 9625ded8..d0072b9f 100644 --- a/backend/core/backend.go +++ b/backend/core/backend.go @@ -10,7 +10,8 @@ import ( ) type Backend struct { - Databases *Databases + Databases *Databases + // TODO: Is this thread safe? WSConnections []*websocket.Conn CanvasConfig *config.CanvasConfig diff --git a/backend/core/databases.go b/backend/core/databases.go index a53d59b0..5f248dea 100644 --- a/backend/core/databases.go +++ b/backend/core/databases.go @@ -5,7 +5,7 @@ import ( "os" "strconv" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" "github.com/keep-starknet-strange/art-peace/backend/config" @@ -15,7 +15,7 @@ type Databases struct { DatabaseConfig *config.DatabaseConfig Redis *redis.Client - Postgres *pgx.Conn + Postgres *pgxpool.Pool } func NewDatabases(databaseConfig *config.DatabaseConfig) *Databases { @@ -31,16 +31,17 @@ func NewDatabases(databaseConfig *config.DatabaseConfig) *Databases { // Connect to Postgres postgresConnString := "postgresql://" + databaseConfig.Postgres.User + ":" + os.Getenv("POSTGRES_PASSWORD") + "@" + databaseConfig.Postgres.Host + ":" + strconv.Itoa(databaseConfig.Postgres.Port) + "/" + databaseConfig.Postgres.Database - pgConn, err := pgx.Connect(context.Background(), postgresConnString) + // TODO: crd_audit?sslmode=disable + pgPool, err := pgxpool.New(context.Background(), postgresConnString) if err != nil { panic(err) } - d.Postgres = pgConn + d.Postgres = pgPool return d } func (d *Databases) Close() { d.Redis.Close() - d.Postgres.Close(context.Background()) + d.Postgres.Close() } diff --git a/backend/go.mod b/backend/go.mod index b25549f6..475dc2cc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -20,10 +20,12 @@ require ( github.com/fxamacker/cbor/v2 v2.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/crypto v0.18.0 // indirect golang.org/x/net v0.20.0 // indirect + golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect rsc.io/tmplfunc v0.0.3 // indirect diff --git a/backend/routes/indexer.go b/backend/routes/indexer.go index a137412f..5012075a 100644 --- a/backend/routes/indexer.go +++ b/backend/routes/indexer.go @@ -56,6 +56,7 @@ func InitIndexerRoutes() { */ // TODO: User might miss some messages between loading canvas and connecting to websocket? +// TODO: Check thread safety of these things func consumeIndexerMsg(w http.ResponseWriter, r *http.Request) { requestBody, err := io.ReadAll(r.Body) if err != nil { From 1019dd6365a0c9ab6ff3a9ffcda41375d8e5f87a Mon Sep 17 00:00:00 2001 From: Mubarak Muhammad Aminu Date: Tue, 16 Apr 2024 20:46:19 +0100 Subject: [PATCH 06/17] Ft username store contract (#32) * username store contract * write test for username store contract * implement changes base on the review recieved on the PR * fix unit test for claim_username and tranfer_username * fix merge conflict * Refactor --------- Co-authored-by: Brandon Roberts --- onchain/src/lib.cairo | 11 +++ onchain/src/tests/username_store.cairo | 36 +++++++++ onchain/src/username_store/interfaces.cairo | 8 ++ .../src/username_store/username_store.cairo | 79 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 onchain/src/tests/username_store.cairo create mode 100644 onchain/src/username_store/interfaces.cairo create mode 100644 onchain/src/username_store/username_store.cairo diff --git a/onchain/src/lib.cairo b/onchain/src/lib.cairo index 158e7378..5c6a3325 100644 --- a/onchain/src/lib.cairo +++ b/onchain/src/lib.cairo @@ -3,6 +3,7 @@ pub mod interfaces; use art_peace::ArtPeace; use interfaces::{IArtPeace, IArtPeaceDispatcher, IArtPeaceDispatcherTrait, Pixel}; + mod quests { pub mod interfaces; pub mod pixel_quest; @@ -33,6 +34,14 @@ mod nfts { }; } +mod username_store { + pub mod interfaces; + pub mod username_store; + + use interfaces::{IUsernameStore, IUsernameStoreDispatcher, IUsernameStoreDispatcherTrait}; + use username_store::UsernameStore; +} + mod mocks { pub mod erc20_mock; } @@ -40,5 +49,7 @@ mod mocks { #[cfg(test)] mod tests { mod art_peace; + mod username_store; pub(crate) mod utils; } + diff --git a/onchain/src/tests/username_store.cairo b/onchain/src/tests/username_store.cairo new file mode 100644 index 00000000..f4f4ee38 --- /dev/null +++ b/onchain/src/tests/username_store.cairo @@ -0,0 +1,36 @@ +use snforge_std::{declare, ContractClassTrait}; +use art_peace::username_store::interfaces::{ + IUsernameStoreDispatcher, IUsernameStoreDispatcherTrait +}; +use starknet::{ContractAddress, get_caller_address, get_contract_address, contract_address_const}; + +fn deploy_contract() -> ContractAddress { + let contract = declare("UsernameStore"); + + return contract.deploy(@ArrayTrait::new()).unwrap(); +} + +#[test] +fn test_claim_username() { + let contract_address = deploy_contract(); + let dispatcher = IUsernameStoreDispatcher { contract_address }; + dispatcher.claim_username('deal'); + + let username_address = dispatcher.get_username('deal'); + + assert(contract_address != username_address, 'Username not claimed'); +} +#[test] +fn test_transfer_username() { + let contract_address = deploy_contract(); + let dispatcher = IUsernameStoreDispatcher { contract_address }; + dispatcher.claim_username('devsweet'); + + let second_contract_address = contract_address_const::<1>(); + + dispatcher.transfer_username('devsweet', second_contract_address); + + let username_address = dispatcher.get_username('devsweet'); + + assert(username_address == second_contract_address, 'Username not Transferred'); +} diff --git a/onchain/src/username_store/interfaces.cairo b/onchain/src/username_store/interfaces.cairo new file mode 100644 index 00000000..04ea5850 --- /dev/null +++ b/onchain/src/username_store/interfaces.cairo @@ -0,0 +1,8 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IUsernameStore { + fn claim_username(ref self: TContractState, key: felt252); + fn transfer_username(ref self: TContractState, key: felt252, new_Address: ContractAddress); + fn get_username(ref self: TContractState, key: felt252) -> ContractAddress; +} diff --git a/onchain/src/username_store/username_store.cairo b/onchain/src/username_store/username_store.cairo new file mode 100644 index 00000000..309a5fe2 --- /dev/null +++ b/onchain/src/username_store/username_store.cairo @@ -0,0 +1,79 @@ +pub mod UserNameClaimErrors { + pub const USERNAME_CLAIMED: felt252 = 'username_claimed'; + pub const USERNAME_CANNOT_BE_TRANSFER: felt252 = 'username_cannot_be_transferred'; +} + +#[starknet::contract] +pub mod UsernameStore { + use starknet::{get_caller_address, ContractAddress, contract_address_const}; + use art_peace::username_store::IUsernameStore; + use super::UserNameClaimErrors; + + #[storage] + struct Storage { + usernames: LegacyMap:: + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + UserNameClaimed: UserNameClaimed, + UserNameTransferred: UserNameTransferred + } + + #[derive(Drop, starknet::Event)] + struct UserNameClaimed { + #[key] + username: felt252, + address: ContractAddress + } + + #[derive(Drop, starknet::Event)] + struct UserNameTransferred { + #[key] + username: felt252, + address: ContractAddress + } + + #[abi(embed_v0)] + pub impl UsernameStore of IUsernameStore { + fn claim_username(ref self: ContractState, key: felt252) { + let mut username_address = self.usernames.read(key); + + assert( + username_address == contract_address_const::<0>(), + UserNameClaimErrors::USERNAME_CLAIMED + ); + + self.usernames.write(key, get_caller_address()); + + self + .emit( + Event::UserNameClaimed( + UserNameClaimed { username: key, address: get_caller_address() } + ) + ) + } + + fn transfer_username(ref self: ContractState, key: felt252, new_Address: ContractAddress) { + let username_address = self.usernames.read(key); + + if username_address != get_caller_address() { + core::panic_with_felt252(UserNameClaimErrors::USERNAME_CANNOT_BE_TRANSFER); + } + + self.usernames.write(key, new_Address); + + self + .emit( + Event::UserNameTransferred( + UserNameTransferred { username: key, address: new_Address } + ) + ) + } + + fn get_username(ref self: ContractState, key: felt252) -> ContractAddress { + self.usernames.read(key) + } + } +} From 54b5d5fdf05d55fbd2ce2d24cfcae6317f3d2d6f Mon Sep 17 00:00:00 2001 From: Abdulhakeem Abdulazeez Ayodeji <44169294+Ayoazeez26@users.noreply.github.com> Date: Wed, 17 Apr 2024 14:47:25 +0100 Subject: [PATCH 07/17] feat: Accounts Tab (#50) * feat: added data for accounts tab * feat: fixed PR comments --- frontend/src/tabs/Account.css | 64 ++++++++++++++++++++++++++++ frontend/src/tabs/Account.js | 78 ++++++++++++++++++++++++++++++++--- 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/frontend/src/tabs/Account.css b/frontend/src/tabs/Account.css index e69de29b..5c3a638b 100644 --- a/frontend/src/tabs/Account.css +++ b/frontend/src/tabs/Account.css @@ -0,0 +1,64 @@ +.Account__flex { + display: flex; + margin: 8px 4px; +} + +.Account__flex--center { + align-items: center; +} + +.Account__wrap { + text-overflow: ellipsis; + overflow: hidden; +} + +.Account__list { + padding-left: 20px; + list-style-type: none; +} + +.Account__list li { + margin-bottom: 10px; + text-indent: -8px; +} + +.Account__list li:before { + content: "-"; + text-indent: -8px; +} + +.Account__input { + width: 100%; + padding: 6px 10px; +} + +.Account__input:focus { + border: 1px solid black; + outline: black; +} + +.Account__button { + background-color: black; + color: #efefef; + border: 1px solid black; + text-transform: uppercase; + cursor: pointer; + border-radius: 6px; +} + +.Account__button--edit { + padding: 2px 8px; + font-size: 8px; +} + +.Account__button--submit { + padding: 8px 16px; + font-size: 10px; +} + +.Account__user { + display: flex; + gap: 10px; + justify-content: space-between; + width: 100%; +} diff --git a/frontend/src/tabs/Account.js b/frontend/src/tabs/Account.js index 4d846979..f4e3d88b 100644 --- a/frontend/src/tabs/Account.js +++ b/frontend/src/tabs/Account.js @@ -1,13 +1,81 @@ -import React from 'react' -import './Account.css'; -import BasicTab from './BasicTab.js'; +import React, { useState, useEffect } from "react"; +import "./Account.css"; +import BasicTab from "./BasicTab.js"; -const Account = props => { +const Account = (props) => { // TODO: Create the account tab w/ wallet address, username, pixel info, top X % users ( pixels placed? ), ... + const [username, setUsername] = useState(""); + const [pixelCount, setPixelCount] = useState(2572); + const [accountRank, setAccountRank] = useState(""); + const [isUsernameSaved, saveUsername] = useState(false); + + const handleSubmit = (event) => { + event.preventDefault(); + setUsername(username); + saveUsername(true); + }; + + const editUsername = (e) => { + saveUsername(false); + } + + useEffect(() => { + if (pixelCount >= 5000) { + setAccountRank("Champion"); + } else if (pixelCount >= 3000) { + setAccountRank("Platinum"); + } else if (pixelCount >= 2000) { + setAccountRank("Gold"); + } else if (pixelCount >= 1000) { + setAccountRank("Silver"); + } else { + setAccountRank("Bronze"); + } + }); return ( +
+

Address:

+

+ 0x0000000000000000000000000000000000000000000000000000000000000000 +

+
+
+

Username:

+ {isUsernameSaved ? ( +
+

{username}

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

Pixel count:

+

{pixelCount}

+
+
+

Current Rank:

+

{accountRank}

+
); -} +}; export default Account; From 422d8125979035bc530d1f3f63b8b5b5e7c55bda Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Wed, 17 Apr 2024 13:06:59 -0500 Subject: [PATCH 08/17] :busts_in_silhouette: Add @mubarak23 as a contributor --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 7a465d22..8c7caac1 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -63,6 +63,15 @@ "contributions": [ "code" ] + }, + { + "login": "mubarak23", + "name": "Mubarak Muhammad Aminu", + "avatar_url": "https://avatars.githubusercontent.com/u/7858376?v=4", + "profile": "http://mubarak23.github.io/", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index b42059f4..b14f42ff 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Thanks goes to these wonderful people. Follow the [contributors guide](https://g Abdulhakeem Abdulazeez Ayodeji
Abdulhakeem Abdulazeez Ayodeji

💻 Trunks @ Carbonable
Trunks @ Carbonable

💻 ptisserand
ptisserand

💻 + Mubarak Muhammad Aminu
Mubarak Muhammad Aminu

💻 From 3125497b8e9879fe63ddb7b96c2c1818489bc0c6 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Thu, 18 Apr 2024 11:28:34 -0500 Subject: [PATCH 09/17] Update quests postgres tables --- postgres/init.sql | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index 369ec13d..5044b904 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -41,28 +41,44 @@ CREATE TABLE Days ( ); CREATE INDEX days_dayIndex_index ON Days (dayIndex); --- TODO: Remove completedStatus & status from Quests? -CREATE TABLE Quests ( +CREATE TABLE DailyQuests ( key integer NOT NULL PRIMARY KEY, name text NOT NULL, description text NOT NULL, reward integer NOT NULL, - dayIndex integer NOT NULL, - completedStatus integer NOT NULL + dayIndex integer NOT NULL ); -CREATE INDEX quests_dayIndex_index ON Quests (dayIndex); +CREATE INDEX dailyQuests_dayIndex_index ON DailyQuests (dayIndex); -- TODO: Add calldata field -CREATE TABLE UserQuests ( +-- Table for storing the daily quests that the user has completed +CREATE TABLE UserDailyQuests ( + key integer NOT NULL PRIMARY KEY, + userAddress char(64) NOT NULL, + questKey integer NOT NULL, + completed boolean NOT NULL, + completedAt timestamp +); +CREATE INDEX userDailyQuests_userAddress_index ON UserDailyQuests (userAddress); +CREATE INDEX userDailyQuests_questKey_index ON UserDailyQuests (questKey); + +CREATE TABLE MainQuests ( + key integer NOT NULL PRIMARY KEY, + name text NOT NULL, + description text NOT NULL, + reward integer NOT NULL +); + +-- Table for storing the main quests that the user has completed +CREATE TABLE UserMainQuests ( key integer NOT NULL PRIMARY KEY, userAddress char(64) NOT NULL, questKey integer NOT NULL, - status integer NOT NULL, completed boolean NOT NULL, completedAt timestamp ); -CREATE INDEX userQuests_userAddress_index ON UserQuests (userAddress); -CREATE INDEX userQuests_questKey_index ON UserQuests (questKey); +CREATE INDEX userMainQuests_userAddress_index ON UserMainQuests (userAddress); +CREATE INDEX userMainQuests_questKey_index ON UserMainQuests (questKey); CREATE TABLE Colors ( key integer NOT NULL PRIMARY KEY, From fe28b4c3d34fcc22c41c8a5ed08ff8dc3525e9b6 Mon Sep 17 00:00:00 2001 From: 0xK2 <65908739+thomas192@users.noreply.github.com> Date: Thu, 18 Apr 2024 19:50:10 +0200 Subject: [PATCH 10/17] Updated addTemplateImg(): dimension check (#59) --- backend/routes/templates.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/routes/templates.go b/backend/routes/templates.go index 090e58e1..88e5a9b8 100644 --- a/backend/routes/templates.go +++ b/backend/routes/templates.go @@ -3,6 +3,8 @@ package routes import ( "encoding/json" "fmt" + "image" + _ "image/png" "io" "io/ioutil" "net/http" @@ -42,8 +44,6 @@ func imageToPixelData(imageData []byte) []byte { } func addTemplateImg(w http.ResponseWriter, r *http.Request) { - // TODO: Limit file size / proportions between 5x5 and 64x64 - // Passed like this curl -F "image=@art-peace-low-res-goose.jpg" http://localhost:8080/addTemplateImg file, _, err := r.FormFile("image") if err != nil { panic(err) @@ -58,6 +58,19 @@ func addTemplateImg(w http.ResponseWriter, r *http.Request) { } defer tempFile.Close() + // Decode the image to check dimensions + img, format, err := image.Decode(file) + if err != nil { + http.Error(w, "Failed to decode the image: "+err.Error()+" - format: "+format, http.StatusBadRequest) + return + } + bounds := img.Bounds() + width, height := bounds.Max.X-bounds.Min.X, bounds.Max.Y-bounds.Min.Y + if width < 5 || width > 50 || height < 5 || height > 50 { + http.Error(w, fmt.Sprintf("Image dimensions out of allowed range (5x5 to 50x50). Uploaded image size: %dx%d", width, height), http.StatusBadRequest) + return + } + // Read all data from the uploaded file and write it to the temporary file fileBytes, err := ioutil.ReadAll(file) if err != nil { From 584b13f80878aeef8ccd14b57bdf70827b8e97f7 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Thu, 18 Apr 2024 12:54:40 -0500 Subject: [PATCH 11/17] :busts_in_silhouette: Add @thomas192 as a contributor --- .all-contributorsrc | 9 +++++++++ README.md | 3 +++ 2 files changed, 12 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 8c7caac1..51c74d2d 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -72,6 +72,15 @@ "contributions": [ "code" ] + }, + { + "login": "thomas192", + "name": "0xK2", + "avatar_url": "https://avatars.githubusercontent.com/u/65908739?v=4", + "profile": "https://github.com/thomas192", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index b14f42ff..727a898e 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ Thanks goes to these wonderful people. Follow the [contributors guide](https://g ptisserand
ptisserand

💻 Mubarak Muhammad Aminu
Mubarak Muhammad Aminu

💻 + + 0xK2
0xK2

💻 + From 3ce4077e7d7b49ba69f3992a7f49b5290f75e511 Mon Sep 17 00:00:00 2001 From: Abdulhakeem Abdulazeez Ayodeji <44169294+Ayoazeez26@users.noreply.github.com> Date: Fri, 19 Apr 2024 05:56:43 +0100 Subject: [PATCH 12/17] Feat/prevent reinit (#60) * feat: added check to prevent canvas re-init * feat: format code * feat: formatted file * feat: added extra check to func * nits and patch build --------- Co-authored-by: Brandon Roberts --- backend/routes/canvas.go | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/backend/routes/canvas.go b/backend/routes/canvas.go index 22e8a2f0..68604970 100644 --- a/backend/routes/canvas.go +++ b/backend/routes/canvas.go @@ -14,22 +14,26 @@ func InitCanvasRoutes() { } func initCanvas(w http.ResponseWriter, r *http.Request) { - // TODO: Check if canvas already exists - totalBitSize := core.ArtPeaceBackend.CanvasConfig.Canvas.Width * core.ArtPeaceBackend.CanvasConfig.Canvas.Height * core.ArtPeaceBackend.CanvasConfig.ColorsBitWidth - totalByteSize := (totalBitSize / 8) - if totalBitSize%8 != 0 { - // Round up to nearest byte - totalByteSize += 1 + if core.ArtPeaceBackend.Databases.Redis.Exists(context.Background(), "canvas").Val() == 0 { + totalBitSize := core.ArtPeaceBackend.CanvasConfig.Canvas.Width * core.ArtPeaceBackend.CanvasConfig.Canvas.Height * core.ArtPeaceBackend.CanvasConfig.ColorsBitWidth + totalByteSize := (totalBitSize / 8) + if totalBitSize%8 != 0 { + // Round up to nearest byte + totalByteSize += 1 + } + + // Create canvas + canvas := make([]byte, totalByteSize) + ctx := context.Background() + err := core.ArtPeaceBackend.Databases.Redis.Set(ctx, "canvas", canvas, 0).Err() + if err != nil { + panic(err) + } + + fmt.Println("Canvas initialized") + } else { + fmt.Println("Canvas already initialized") } - - canvas := make([]byte, totalByteSize) - ctx := context.Background() - err := core.ArtPeaceBackend.Databases.Redis.Set(ctx, "canvas", canvas, 0).Err() - if err != nil { - panic(err) - } - - fmt.Println("Canvas initialized") } func getCanvas(w http.ResponseWriter, r *http.Request) { From 425e38a53a309b112f9d55179671f9c2614b0287 Mon Sep 17 00:00:00 2001 From: Adeyemi Gbenga Date: Fri, 19 Apr 2024 06:10:30 +0100 Subject: [PATCH 13/17] [feat] Improve canvas zoom #44 (#57) * feat: improved canvas zoom * feat: improved canvas and fix lint format * feat: improved canvas and fix lint format * feat: added min and max scale variable --- frontend/package.json | 1 + frontend/src/canvas/Canvas.js | 109 +++++++++++++--------------------- 2 files changed, 42 insertions(+), 68 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 6b85ccc2..16758294 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "d3": "^7.9.0", "get-starknet": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/canvas/Canvas.js b/frontend/src/canvas/Canvas.js index 43302efb..1e0f88eb 100644 --- a/frontend/src/canvas/Canvas.js +++ b/frontend/src/canvas/Canvas.js @@ -1,4 +1,5 @@ import React, { useCallback, useRef, useEffect, useState } from 'react' +import { select, zoom, zoomIdentity } from "d3" import useWebSocket, { ReadyState } from 'react-use-websocket' import './Canvas.css'; // import TemplateOverlay from './TemplateOverlay.js'; @@ -7,20 +8,17 @@ import backendConfig from "../configs/backend.config.json" const Canvas = props => { const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port - // TODO: Pressing "Canvas" resets the view / positioning + //TODO: Pressing "Canvas" resets the view / positioning + //TODO: Way to configure tick rates to give smooth xp for all users + + //Todo: Make this dynamic + const minScale = 1; + const maxScale = 40; - const [canvasPositionX, setCanvasPositionX] = useState(0) - const [canvasPositionY, setCanvasPositionY] = useState(0) - const [isDragging, setIsDragging] = useState(false) - const [dragStartX, setDragStartX] = useState(0) - const [dragStartY, setDragStartY] = useState(0) - const [canvasScale, setCanvasScale] = useState(6) - const minScale = 1 // TODO: To config - const maxScale = 40 - //TODO: Way to configure tick rates to give smooth xp for all users - const canvasRef = useRef(null) + const canvasPositionRef = useRef(null) + const canvasScaleRef = useRef(null) // Read canvas config from environment variable file json const width = canvasConfig.canvas.width @@ -35,58 +33,33 @@ const Canvas = props => { shouldReconnect: () => true, }, ) - - // TODO: Weird positioning behavior when clicking into devtools - - // Handle wheel event for zooming - const handleWheel = (e) => { - let newScale = canvasScale - if (e.deltaY < 0) { - newScale = Math.min(maxScale, newScale + 0.2) - } else { - newScale = Math.max(minScale, newScale - 0.2) - } - // TODO: Smart positioning of canvas zoom ( zoom to center of mouse pointer ) - //let newCanvasPositionX = canvasPositionX - //let newCanvasPositionY = canvasPositionY - //const canvasOriginX = canvasPositionX + width / 2 - //const canvasOriginY = canvasPositionY + height / 2 - //setCanvasPositionX(newCanvasPositionX) - //setCanvasPositionY(newCanvasPositionY) - setCanvasScale(newScale) - } + // TODO: Weird positioning behavior when clicking into devtools + useEffect(() => { + const canvas = select(canvasPositionRef.current) + const Dzoom = zoom().scaleExtent([minScale, maxScale]).on("zoom", zoomHandler) - const handlePointerDown = (e) => { - setIsDragging(true) - setDragStartX(e.clientX) - setDragStartY(e.clientY) - } + // Set default zoom level and center the canvas + canvas + .call(Dzoom) + .call(Dzoom.transform, zoomIdentity.translate(0, 0).scale(4)) - const handlePointerUp = () => { - setIsDragging(false) - setDragStartX(0) - setDragStartY(0) - } + return () => { + canvas.on(".zoom", null); // Clean up zoom event listeners + }; + }, []); - const handlePointerMove = (e) => { - if (isDragging) { - // TODO: Prevent dragging outside of canvas container - setCanvasPositionX(canvasPositionX + e.clientX - dragStartX) - setCanvasPositionY(canvasPositionY + e.clientY - dragStartY) - setDragStartX(e.clientX) - setDragStartY(e.clientY) - } + const zoomHandler = (event) => { + const ele = canvasScaleRef.current + const { + k: newScale, + x: newCanvasPositionX, + y: newCanvasPositionY, + } = event.transform; + const transformValue = `translate(${newCanvasPositionX}px, ${newCanvasPositionY}px) scale(${newScale})` + ele.style.transform = transformValue } - useEffect(() => { - document.addEventListener('pointerup', handlePointerUp) - - return () => { - document.removeEventListener('pointerup', handlePointerUp) - } - }, []) - const [setup, setSetup] = useState(false) const draw = useCallback((ctx, imageData) => { @@ -104,7 +77,7 @@ const Canvas = props => { const context = canvas.getContext('2d') let getCanvasEndpoint = backendUrl + "/getCanvas" - fetch(getCanvasEndpoint, {mode: 'cors'}).then(response => { + fetch(getCanvasEndpoint, { mode: 'cors' }).then(response => { return response.arrayBuffer() }).then(data => { let colorData = new Uint8Array(data, 0, data.byteLength) @@ -205,7 +178,7 @@ const Canvas = props => { if (props.selectedPositionX === null || props.selectedPositionY === null) { return } - + const position = props.selectedPositionX + props.selectedPositionY * width const colorIdx = props.selectedColorId let placePixelEndpoint = backendUrl + "/placePixelDevnet" @@ -228,7 +201,7 @@ const Canvas = props => { props.setSelectedColorId(-1) // TODO: Optimistic update } - + // TODO: Deselect pixel when clicking outside of color palette or pixel // TODO: Show small position vec in bottom right corner of canvas const getSelectedColor = () => { @@ -287,19 +260,19 @@ const Canvas = props => { // TODO: both place options return ( -
-
-
- { props.pixelSelectedMode && ( -
-
+
+
+
+ {props.pixelSelectedMode && ( +
+
)} - +
); } -export default Canvas \ No newline at end of file +export default Canvas From 9695b3316f53b9ec9f1b1f3af4f544c002006231 Mon Sep 17 00:00:00 2001 From: "Mano.dev" <100848212+manoahLinks@users.noreply.github.com> Date: Fri, 19 Apr 2024 06:21:20 +0100 Subject: [PATCH 14/17] feat: getUsername backend route fixes #49 (#62) * feat: getUsername backend route fies #49 * go format --------- Co-authored-by: Mano.dev Co-authored-by: Brandon Roberts --- backend/routes/user.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/routes/user.go b/backend/routes/user.go index f03d018a..6ff302a4 100644 --- a/backend/routes/user.go +++ b/backend/routes/user.go @@ -9,6 +9,7 @@ import ( func InitUserRoutes() { http.HandleFunc("/getExtraPixels", getExtraPixels) + http.HandleFunc("/getUsername", getUsername) } func getExtraPixels(w http.ResponseWriter, r *http.Request) { @@ -26,3 +27,19 @@ func getExtraPixels(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(available)) } + +func getUsername(w http.ResponseWriter, r *http.Request) { + address := r.URL.Query().Get("address") + + var name string + err := core.ArtPeaceBackend.Databases.Postgres.QueryRow(context.Background(), "SELECT name FROM Users WHERE address = $1", address).Scan(&name) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + w.Write([]byte(name)) +} From 065e9dc2edb1fb1eddf7ac38e043d15b4223f801 Mon Sep 17 00:00:00 2001 From: Xaxxoo <51526246+Xaxxoo@users.noreply.github.com> Date: Mon, 22 Apr 2024 15:41:40 +0100 Subject: [PATCH 15/17] Color backend routes created (#55) * Color backend routes created * get-colors route fetched * setting the colors array * setting the colors array * Add init-colors route and setup in integration tests * Git fmt --------- Co-authored-by: Brandon Roberts --- backend/routes/colors.go | 127 +++++++ backend/routes/routes.go | 1 + frontend/package-lock.json | 424 ++++++++++++++++++++++- frontend/src/canvas/Canvas.js | 292 +++++++++++----- frontend/src/canvas/PixelSelector.js | 33 +- frontend/src/configs/backend.config.json | 3 +- postgres/init.sql | 4 +- tests/integration/docker/initialize.sh | 5 + tests/integration/local/run.sh | 2 + 9 files changed, 786 insertions(+), 105 deletions(-) create mode 100644 backend/routes/colors.go diff --git a/backend/routes/colors.go b/backend/routes/colors.go new file mode 100644 index 00000000..3fc0df20 --- /dev/null +++ b/backend/routes/colors.go @@ -0,0 +1,127 @@ +package routes + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/jackc/pgx/v5" + "github.com/keep-starknet-strange/art-peace/backend/core" +) + +type Colors struct { + Hex string `json:"hex"` +} + +func InitColorsRoutes() { + http.HandleFunc("/get-colors", GetAllColors) + http.HandleFunc("/get-color", GetSingleColor) + http.HandleFunc("/init-colors", InitColors) +} + +func GetAllColors(w http.ResponseWriter, r *http.Request) { + + var colors []Colors + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), "SELECT hex FROM colors") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + defer rows.Close() + + for rows.Next() { + var c Colors + err := rows.Scan(&c.Hex) + if err != nil { + log.Fatalf("Scan failed: %v\n", err) + } + colors = append(colors, c) + } + if err := rows.Err(); err != nil { + log.Fatalf("Error retrieving data: %v\n", err) + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + out, err := json.Marshal(colors) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte(out)) +} + +func GetSingleColor(w http.ResponseWriter, r *http.Request) { + + colorKey := r.URL.Query().Get("id") + if colorKey == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("ID not provided")) + return + } + + var c Colors + row := core.ArtPeaceBackend.Databases.Postgres.QueryRow(context.Background(), "SELECT hex FROM colors WHERE key = $1", colorKey) + err := row.Scan(&c.Hex) + if err != nil { + if err == pgx.ErrNoRows { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Color not found")) + } else { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + } + return + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + out, err := json.Marshal(c) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte(out)) +} + +func InitColors(w http.ResponseWriter, r *http.Request) { + // TODO: Add authentication and/or check if colors already exist + reqBody, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return + } + + var colors []string + err = json.Unmarshal(reqBody, &colors) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return + } + + for _, color := range colors { + _, err = core.ArtPeaceBackend.Databases.Postgres.Exec(context.Background(), "INSERT INTO colors (hex) VALUES ($1)", color) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Colors initialized")) + fmt.Println("Colors initialized") +} diff --git a/backend/routes/routes.go b/backend/routes/routes.go index 67bdf249..31dfb92e 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -8,4 +8,5 @@ func InitRoutes() { InitTemplateRoutes() InitUserRoutes() InitContractRoutes() + InitColorsRoutes() } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7634e38..f8ea36ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "d3": "^7.9.0", "get-starknet": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -3854,22 +3855,31 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz", + "integrity": "sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==", "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", + "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/@testing-library/jest-dom": { @@ -6639,6 +6649,384 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6826,6 +7214,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -9304,6 +9700,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", @@ -15131,6 +15535,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, "node_modules/rollup": { "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", @@ -15203,6 +15612,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", diff --git a/frontend/src/canvas/Canvas.js b/frontend/src/canvas/Canvas.js index 1e0f88eb..70f2a0c7 100644 --- a/frontend/src/canvas/Canvas.js +++ b/frontend/src/canvas/Canvas.js @@ -1,10 +1,12 @@ + import React, { useCallback, useRef, useEffect, useState } from 'react' import { select, zoom, zoomIdentity } from "d3" import useWebSocket, { ReadyState } from 'react-use-websocket' import './Canvas.css'; // import TemplateOverlay from './TemplateOverlay.js'; -import canvasConfig from "../configs/canvas.config.json" -import backendConfig from "../configs/backend.config.json" +import canvasConfig from "../configs/canvas.config.json"; +import backendConfig from "../configs/backend.config.json"; + const Canvas = props => { const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port @@ -20,17 +22,45 @@ const Canvas = props => { const canvasPositionRef = useRef(null) const canvasScaleRef = useRef(null) + // Read canvas config from environment variable file json - const width = canvasConfig.canvas.width - const height = canvasConfig.canvas.height - const colors = canvasConfig.colors + const width = canvasConfig.canvas.width; + const height = canvasConfig.canvas.height; + const staticColors = canvasConfig.colors; + + const [colors, setColors] = useState([]); + const [setupColors, setSetupColors] = useState(false); + + useEffect(() => { + if (setupColors) { + return; + } + let getColorsEndpoint = backendUrl + "/get-colors"; + fetch(getColorsEndpoint, { mode: "cors" }).then((response) => { + response.json().then((data) => { + let colors = []; + for (let i = 0; i < data.length; i++) { + colors.push(data[i].hex); + } + setColors(colors); + setSetupColors(true); + }).catch((error) => { + setColors(staticColors); + setSetupColors(true); + console.error(error); + }); + }); + // TODO: Return a cleanup function to close the websocket / ... + }, [colors, backendUrl, staticColors, setupColors, setColors]); - const WS_URL = "ws://" + backendConfig.host + ":" + backendConfig.port + "/ws" + const WS_URL = + "ws://" + backendConfig.host + ":" + backendConfig.port + "/ws"; const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket( WS_URL, { share: false, shouldReconnect: () => true, + }, ) @@ -62,16 +92,23 @@ const Canvas = props => { const [setup, setSetup] = useState(false) - const draw = useCallback((ctx, imageData) => { - ctx.canvas.width = width - ctx.canvas.height = height - ctx.putImageData(imageData, 0, 0) - // TODO: Use image-rendering for supported browsers? - }, [width, height]) + + const draw = useCallback( + (ctx, imageData) => { + ctx.canvas.width = width; + ctx.canvas.height = height; + ctx.putImageData(imageData, 0, 0); + // TODO: Use image-rendering for supported browsers? + }, + [width, height] + ); useEffect(() => { + if (!setupColors) { + return; + } if (setup) { - return + return; } const canvas = canvasRef.current const context = canvas.getContext('2d') @@ -99,89 +136,116 @@ const Canvas = props => { dataArray.push(value) } } - let imageDataArray = [] + let imageDataArray = []; for (let i = 0; i < dataArray.length; i++) { - const color = "#" + colors[dataArray[i]] + "FF" - const [r, g, b, a] = color.match(/\w\w/g).map(x => parseInt(x, 16)) - imageDataArray.push(r, g, b, a) + const color = "#" + colors[dataArray[i]] + "FF"; + const [r, g, b, a] = color.match(/\w\w/g).map((x) => parseInt(x, 16)); + imageDataArray.push(r, g, b, a); } - const uint8ClampedArray = new Uint8ClampedArray(imageDataArray) - const imageData = new ImageData(uint8ClampedArray, width, height) - draw(context, imageData) - setSetup(true) - }).catch(error => { + const uint8ClampedArray = new Uint8ClampedArray(imageDataArray); + const imageData = new ImageData(uint8ClampedArray, width, height); + draw(context, imageData); + setSetup(true); + }).catch((error) => { //TODO: Notifiy user of error - console.error(error) + console.error(error); }); - console.log("Connect to websocket") + console.log("Connect to websocket"); if (readyState === ReadyState.OPEN) { sendJsonMessage({ event: "subscribe", data: { channel: "general", }, - }) + }); } // TODO: Return a cleanup function to close the websocket / ... - }, [readyState, sendJsonMessage, setup, colors, width, height, backendUrl, draw]) + }, [ + readyState, + sendJsonMessage, + setup, + colors, + width, + height, + backendUrl, + draw, + setupColors, + ]); 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 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) + context.fillStyle = color; + context.fillRect(x, y, 1, 1); } - }, [lastJsonMessage, colors, width]) + }, [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) - - const position = y * width + x - let getPixelInfoEndpoint = backendUrl + "/getPixelInfo?position=" + position.toString() - fetch(getPixelInfoEndpoint, { - mode: 'cors' - }).then(response => { - return response.text() - }).then(data => { - // TODO: Cache pixel info & clear cache on update from websocket - // TODO: Dont query if hover select ( until 1s after hover? ) - props.setPixelPlacedBy(data) - }).catch(error => { - console.error(error) - }); + 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); - }, [props, width, height, backendUrl]) + const position = y * width + x; + let getPixelInfoEndpoint = + backendUrl + "/getPixelInfo?position=" + position.toString(); + fetch(getPixelInfoEndpoint, { + mode: "cors", + }) + .then((response) => { + return response.text(); + }) + .then((data) => { + // TODO: Cache pixel info & clear cache on update from websocket + // TODO: Dont query if hover select ( until 1s after hover? ) + props.setPixelPlacedBy(data); + }) + .catch((error) => { + console.error(error); + }); + }, + [props, width, height, backendUrl] + ); const pixelClicked = (e) => { - pixelSelect(e.clientX, e.clientY) + pixelSelect(e.clientX, e.clientY); if (props.selectedColorId === -1) { - return + return; } if (props.selectedPositionX === null || props.selectedPositionY === null) { - return + return; } + const position = props.selectedPositionX + props.selectedPositionY * width const colorIdx = props.selectedColorId let placePixelEndpoint = backendUrl + "/placePixelDevnet" + fetch(placePixelEndpoint, { mode: "cors", method: "POST", @@ -189,60 +253,95 @@ const Canvas = props => { position: position.toString(), color: colorIdx.toString(), }), - }).then(response => { - return response.text() - }).then(data => { - console.log(data) - }).catch(error => { - console.error("Error placing pixel") - console.error(error) - }); - props.clearPixelSelection() - props.setSelectedColorId(-1) + }) + .then((response) => { + return response.text(); + }) + .then((data) => { + console.log(data); + }) + .catch((error) => { + console.error("Error placing pixel"); + console.error(error); + }); + props.clearPixelSelection(); + props.setSelectedColorId(-1); // TODO: Optimistic update + + } // TODO: Deselect pixel when clicking outside of color palette or pixel // TODO: Show small position vec in bottom right corner of canvas const getSelectedColor = () => { - console.log(props.selectedColorId, props.selectedPositionX, props.selectedPositionY) + console.log( + props.selectedColorId, + props.selectedPositionX, + props.selectedPositionY + ); if (props.selectedPositionX === null || props.selectedPositionY === null) { - return null + return null; } if (props.selectedColorId === -1) { - return null + return null; } - return "#" + colors[props.selectedColorId] + "FF" - } + return "#" + colors[props.selectedColorId] + "FF"; + }; const getSelectorsColor = () => { if (props.selectedPositionX === null || props.selectedPositionY === null) { - return null + return null; } if (props.selectedColorId === -1) { - let color = canvasRef.current.getContext('2d').getImageData(props.selectedPositionX, props.selectedPositionY, 1, 1).data - return "#" + color[0].toString(16).padStart(2, '0') + color[1].toString(16).padStart(2, '0') + color[2].toString(16).padStart(2, '0') + color[3].toString(16).padStart(2, '0') + let color = canvasRef.current + .getContext("2d") + .getImageData( + props.selectedPositionX, + props.selectedPositionY, + 1, + 1 + ).data; + return ( + "#" + + color[0].toString(16).padStart(2, "0") + + color[1].toString(16).padStart(2, "0") + + color[2].toString(16).padStart(2, "0") + + color[3].toString(16).padStart(2, "0") + ); } - return "#" + colors[props.selectedColorId] + "FF" - } + return "#" + colors[props.selectedColorId] + "FF"; + }; const getSelectorsColorInverse = () => { if (props.selectedPositionX === null || props.selectedPositionY === null) { - return null + return null; } if (props.selectedColorId === -1) { - let color = canvasRef.current.getContext('2d').getImageData(props.selectedPositionX, props.selectedPositionY, 1, 1).data - return "#" + (255 - color[0]).toString(16).padStart(2, '0') + (255 - color[1]).toString(16).padStart(2, '0') + (255 - color[2]).toString(16).padStart(2, '0') + color[3].toString(16).padStart(2, '0') + let color = canvasRef.current + .getContext("2d") + .getImageData( + props.selectedPositionX, + props.selectedPositionY, + 1, + 1 + ).data; + return ( + "#" + + (255 - color[0]).toString(16).padStart(2, "0") + + (255 - color[1]).toString(16).padStart(2, "0") + + (255 - color[2]).toString(16).padStart(2, "0") + + color[3].toString(16).padStart(2, "0") + ); } - return "#" + colors[props.selectedColorId] + "FF" - } + return "#" + colors[props.selectedColorId] + "FF"; + }; useEffect(() => { const setFromEvent = (e) => { if (props.selectedColorId === -1) { - return + return; } - pixelSelect(e.clientX, e.clientY) + pixelSelect(e.clientX, e.clientY); }; window.addEventListener("mousemove", setFromEvent); @@ -260,12 +359,14 @@ const Canvas = props => { // TODO: both place options return ( +
{props.pixelSelectedMode && (
+
)} @@ -273,6 +374,7 @@ const Canvas = props => {
); -} +}; + -export default Canvas +export default Canvas; diff --git a/frontend/src/canvas/PixelSelector.js b/frontend/src/canvas/PixelSelector.js index 6856d32f..47d26a5a 100644 --- a/frontend/src/canvas/PixelSelector.js +++ b/frontend/src/canvas/PixelSelector.js @@ -1,9 +1,10 @@ import React, {useCallback, useEffect, useState} from 'react'; import './PixelSelector.css'; import canvasConfig from '../configs/canvas.config.json'; +import backendConfig from '../configs/backend.config.json'; const PixelSelector = (props) => { - + const backendUrl = "http://" + backendConfig.host + ":" + backendConfig.port; const [placedTime, setPlacedTime] = useState(0); const [timeTillNextPlacement, setTimeTillNextPlacement] = useState("XX:XX"); // TODO: get from server on init // TODO: Animation for swapping selectorMode @@ -11,8 +12,34 @@ const PixelSelector = (props) => { const timeBetweenPlacements = 5000; // 5 seconds TODO: make this a config const updateInterval = 200; // 200ms - let colors = canvasConfig.colors; - colors = colors.map(color => `#${color}FF`); + let staticColors = canvasConfig.colors; + staticColors = staticColors.map(color => `#${color}FF`); + + const [colors, setColors] = useState([]); + const [isSetup, setIsSetup] = useState(false); + + useEffect(() => { + if (isSetup) { + return; + } + let getColorsEndpoint = backendUrl + "/get-colors"; + fetch(getColorsEndpoint, { mode: "cors" }).then((response) => { + response.json().then((data) => { + let colors = []; + for (let i = 0; i < data.length; i++) { + colors.push(data[i].hex); + } + colors = colors.map(color => `#${color}FF`); + setColors(colors); + setIsSetup(true); + }).catch((error) => { + setColors(staticColors); + setIsSetup(true); + console.error(error); + }); + }); + // TODO: Return a cleanup function to close the websocket / ... + }, [colors, backendUrl, staticColors, setColors, setIsSetup, isSetup]); // TODO: implement extraPixels feature(s) const extraPixels = 0; diff --git a/frontend/src/configs/backend.config.json b/frontend/src/configs/backend.config.json index b8f73c23..228cf22b 100644 --- a/frontend/src/configs/backend.config.json +++ b/frontend/src/configs/backend.config.json @@ -4,5 +4,6 @@ "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", "add_template_hash_devnet": "../tests/integration/local/add_template_hash.sh" - } + }, + "production": false } diff --git a/postgres/init.sql b/postgres/init.sql index 5044b904..f3fe8bc5 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -80,8 +80,10 @@ CREATE TABLE UserMainQuests ( CREATE INDEX userMainQuests_userAddress_index ON UserMainQuests (userAddress); CREATE INDEX userMainQuests_questKey_index ON UserMainQuests (questKey); +-- TODO: key to color_idx CREATE TABLE Colors ( - key integer NOT NULL PRIMARY KEY, + -- Postgres auto-incrementing primary key + key int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, hex text NOT NULL ); diff --git a/tests/integration/docker/initialize.sh b/tests/integration/docker/initialize.sh index d21279b2..8d7a8f7e 100755 --- a/tests/integration/docker/initialize.sh +++ b/tests/integration/docker/initialize.sh @@ -11,3 +11,8 @@ curl http://backend:8080/initCanvas -X POST echo "Set the contract address" CONTRACT_ADDRESS=$(cat /configs/configs.env | tail -n 1 | cut -d '=' -f2) curl http://backend:8080/setContractAddress -X POST -d "$CONTRACT_ADDRESS" + +echo "Setup the colors from the color config" +# flatten colors with quotes and join them with comma and wrap in [] +COLORS=$(cat /configs/canvas.config.json | jq -r '.colors | map("\"\(.)\"") | join(",")') +curl http://backend:8080/init-colors -X POST -d "[$COLORS]" diff --git a/tests/integration/local/run.sh b/tests/integration/local/run.sh index 8e06d47b..6e7d4986 100755 --- a/tests/integration/local/run.sh +++ b/tests/integration/local/run.sh @@ -90,6 +90,8 @@ sleep 2 # Wait for indexer script to start; TODO: Check if indexer script is act echo "Initializing art-peace canvas ..." curl http://localhost:8080/initCanvas -X POST curl http://localhost:8080/setContractAddress -X POST -d "$ART_PEACE_CONTRACT_ADDRESS" +COLORS=$(cat $CANVAS_CONFIG_FILE | jq -r '.colors | map("\"\(.)\"") | join(",")') +curl http://localhost:8080/init-colors -X POST -d "[$COLORS]" # Start the art-peace frontend echo "Starting art-peace frontend ..." From c8c6edc50fc9974a5098ff74f9a1653c4bf2bb54 Mon Sep 17 00:00:00 2001 From: Abdulhakeem Abdulazeez Ayodeji <44169294+Ayoazeez26@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:21:11 +0100 Subject: [PATCH 16/17] feat: track time left in day (#65) * feat: track time left in day * Display time --------- Co-authored-by: Brandon Roberts --- frontend/src/App.js | 29 +++++++++++++++++++++++++-- frontend/src/tabs/TabPanel.js | 16 +++++++++------ frontend/src/tabs/Voting.js | 32 +----------------------------- frontend/src/tabs/quests/Quests.js | 2 +- 4 files changed, 39 insertions(+), 40 deletions(-) diff --git a/frontend/src/App.js b/frontend/src/App.js index ce387b3e..43656ad4 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useMediaQuery } from 'react-responsive' import './App.css'; import Canvas from './canvas/Canvas.js'; @@ -52,6 +52,31 @@ function App() { setPixelSelectedMode(true); } + const [timeLeftInDay, setTimeLeftInDay] = useState(''); + const startTime = "15:00"; + const [hours, minutes] = startTime.split(":"); + + + useEffect(() => { + const calculateTimeLeft = () => { + const now = new Date(); + const nextDayStart = new Date(now); + nextDayStart.setDate(now.getDate() + 1); + nextDayStart.setUTCHours(parseInt(hours), parseInt(minutes), 0, 0); + + const difference = nextDayStart - now; + const hoursFinal = Math.floor((difference / (1000 * 60 * 60)) % 24); + const minutesFinal = Math.floor((difference / 1000 / 60) % 60); + const secondsFinal = Math.floor((difference / 1000) % 60); + + const formattedTimeLeft = `${hoursFinal.toString().padStart(2, '0')}:${minutesFinal.toString().padStart(2, '0')}:${secondsFinal.toString().padStart(2, '0')}`; + setTimeLeftInDay(formattedTimeLeft); + }; + + const interval = setInterval(calculateTimeLeft, 1000); + return () => clearInterval(interval); + }) + // Tabs const tabs = ['Canvas', 'Quests', 'Vote', 'Templates', 'NFTs', 'Account']; const [activeTab, setActiveTab] = useState(tabs[0]); @@ -66,7 +91,7 @@ function App() { { (!isPortrait ? pixelSelectedMode : pixelSelectedMode && activeTab === tabs[0]) && ( )} - +
diff --git a/frontend/src/tabs/TabPanel.js b/frontend/src/tabs/TabPanel.js index b68f49a1..9d569559 100644 --- a/frontend/src/tabs/TabPanel.js +++ b/frontend/src/tabs/TabPanel.js @@ -9,12 +9,16 @@ import Account from './Account.js'; const TabPanel = props => { return ( -
- { props.activeTab === 'Quests' && } - { props.activeTab === 'Vote' && } - { props.activeTab === 'Templates' && } - { props.activeTab === 'NFTs' && } - { props.activeTab === 'Account' && } +
+ {props.activeTab === "Quests" && ( + + )} + {props.activeTab === "Vote" && ( + + )} + {props.activeTab === "Templates" && } + {props.activeTab === "NFTs" && } + {props.activeTab === "Account" && }
); } diff --git a/frontend/src/tabs/Voting.js b/frontend/src/tabs/Voting.js index 96842f59..8af9adf9 100644 --- a/frontend/src/tabs/Voting.js +++ b/frontend/src/tabs/Voting.js @@ -11,42 +11,12 @@ const Voting = props => { const [votes, setVotes] = useState(colorVotes); const [userVote, setUserVote] = useState(-1); // TODO: Pull from API - const timeTillVote = '05:14:23'; - const [time, setTime] = useState(timeTillVote); - - useEffect(() => { - const interval = setInterval(() => { - setTime(time => { - let timeSplit = time.split(':'); - let hours = parseInt(timeSplit[0]); - let minutes = parseInt(timeSplit[1]); - let seconds = parseInt(timeSplit[2]); - if (seconds === 0) { - if (minutes === 0) { - if (hours === 0) { - return '00:00:00'; - } - hours--; - minutes = 59; - seconds = 59; - } else { - minutes--; - seconds = 59; - } - } else { - seconds--; - } - return `${hours < 10 ? '0' + hours : hours}:${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds}`; - }); - }, 1000); - return () => clearInterval(interval); - }, [time]); return (

Color Vote

Vote for a new palette color.

-

Vote closes: {time}

+

Vote closes: {props.timeLeftInDay}

Vote
diff --git a/frontend/src/tabs/quests/Quests.js b/frontend/src/tabs/quests/Quests.js index ca9a1bc6..de29e9ca 100644 --- a/frontend/src/tabs/quests/Quests.js +++ b/frontend/src/tabs/quests/Quests.js @@ -56,7 +56,7 @@ const Quests = props => {

Dailys

-

XX:XX:XX

+

{props.timeLeftInDay}

{dailyQuests.map((quest, index) => ( From 3fa4ac8ecb26cf3a8582965514a61b8a69bda44b Mon Sep 17 00:00:00 2001 From: Fishon Amos <43862685+fishonamos@users.noreply.github.com> Date: Mon, 22 Apr 2024 08:34:22 -0700 Subject: [PATCH 17/17] feat: sorted completed quests and created daily and main quest routes #1 (#54) * add sorting for completed * Create quest.go To store Quest Structs * Create Quests.go * Update routes.go * Update routes.go * Update and rename Quests.go to quests.go * Update quest.go * Delete quest.go * Update quests.go Updates to quests route * Update quests.go Changed route name to follow other routes pattern. * Update quests.go * Update Quests.js to consume mainQuests and dailyQuests routes * Update Quests.js * Display local quests for now --------- Co-authored-by: Otaiki1 Co-authored-by: Brandon Roberts Co-authored-by: Brandon R <54774639+b-j-roberts@users.noreply.github.com> --- backend/routes/quests.go | 76 +++++++++++++++++++ backend/routes/routes.go | 1 + frontend/src/tabs/quests/Quests.js | 116 +++++++++++++++++++++++------ 3 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 backend/routes/quests.go diff --git a/backend/routes/quests.go b/backend/routes/quests.go new file mode 100644 index 00000000..50afea33 --- /dev/null +++ b/backend/routes/quests.go @@ -0,0 +1,76 @@ +package routes + +import ( + "context" + "encoding/json" + "log" + "net/http" + + "github.com/keep-starknet-strange/art-peace/backend/core" +) + +// the Quest struct will represent the structure for both Daily and Main Quests data +type Quest struct { + Key int `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Reward int `json:"reward"` + DayIndex int `json:"dayIndex,omitempty"` // Only for daily quests +} + +func InitQuestsRoutes() { + http.HandleFunc("/getDailyQuests", GetDailyQuests) + http.HandleFunc("/getMainQuests", GetMainQuests) +} + +// Query dailyQuests +func GetDailyQuests(w http.ResponseWriter, r *http.Request) { + query := `SELECT key, name, description, reward, dayIndex FROM DailyQuests ORDER BY dayIndex ASC` + handleQuestQuery(w, r, query) +} + +// Query mainQuest +func GetMainQuests(w http.ResponseWriter, r *http.Request) { + query := `SELECT key, name, description, reward FROM MainQuests` + handleQuestQuery(w, r, query) +} + +func handleQuestQuery(w http.ResponseWriter, r *http.Request, query string) { + var quests []Quest + rows, err := core.ArtPeaceBackend.Databases.Postgres.Query(context.Background(), query) + if err != nil { + http.Error(w, "Database query failed: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var q Quest + if err := rows.Scan(&q.Key, &q.Name, &q.Description, &q.Reward, &q.DayIndex); err != nil { + log.Printf("Error scanning row: %v", err) + continue // Log and continue to process other rows + } + quests = append(quests, q) + } + if err := rows.Err(); err != nil { + log.Printf("Error during rows iteration: %v", err) + http.Error(w, "Error processing data: "+err.Error(), http.StatusInternalServerError) + return + } + + setupCORS(&w, r) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(quests); err != nil { + http.Error(w, "Error encoding response: "+err.Error(), http.StatusInternalServerError) + } +} + +// CORS setup +func setupCORS(w *http.ResponseWriter, r *http.Request) { + (*w).Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == "OPTIONS" { + (*w).Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + (*w).Header().Set("Access-Control-Allow-Headers", "Content-Type") + (*w).WriteHeader(http.StatusOK) + } +} diff --git a/backend/routes/routes.go b/backend/routes/routes.go index 31dfb92e..97bb3226 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -8,5 +8,6 @@ func InitRoutes() { InitTemplateRoutes() InitUserRoutes() InitContractRoutes() + InitQuestsRoutes() InitColorsRoutes() } diff --git a/frontend/src/tabs/quests/Quests.js b/frontend/src/tabs/quests/Quests.js index de29e9ca..1af41f32 100644 --- a/frontend/src/tabs/quests/Quests.js +++ b/frontend/src/tabs/quests/Quests.js @@ -1,74 +1,142 @@ -import React from 'react' -import './Quests.css'; -import BasicTab from '../BasicTab.js'; -import QuestItem from './QuestItem.js'; +import React, { useState, useEffect } from "react"; +import "./Quests.css"; +import BasicTab from "../BasicTab.js"; +import QuestItem from "./QuestItem.js"; -const Quests = props => { +const Quests = (props) => { + const [dailyQuests, setDailyQuests] = useState([]); + const [mainQuests, setMainQuests] = useState([]); + + useEffect(() => { + const fetchQuests = async () => { + try { + // Fetching daily quests from backend + const dailyResponse = await fetch('http://localhost:8080/getDailyQuests'); + const dailyData = await dailyResponse.json(); + setDailyQuests(dailyData); + + // Fetching main quests from backend + const mainResponse = await fetch('http://localhost:8080/getMainQuests'); + const mainData = await mainResponse.json(); + setMainQuests(mainData); + } catch (error) { + console.error('Failed to fetch quests', error); + } + }; + + fetchQuests(); + }, []); // TODO: Main quests should be scrollable // TODO: Main quests should be moved to the bottom on complete // TODO: Pull quests from backend // TODO: Links in descriptions - const dailyQuests = [ + + + + const localDailyQuests = [ { title: "Place 10 pixels", description: "Add 10 pixels on the canvas", reward: "3", - status: "completed" + status: "completed", }, { title: "Build a template", description: "Create a template for the community to use", reward: "3", - status: "claim" + status: "claim", }, { title: "Deploy a Memecoin", description: "Create an Unruggable memecoin", reward: "10", - status: "completed" - } - ] + status: "completed", + }, + ]; - const mainQuests = [ + const localMainQuests = [ { title: "Tweet #art/peace", description: "Tweet about art/peace using the hashtag & addr", reward: "10", - status: "incomplete" + status: "incomplete", }, { title: "Place 100 pixels", description: "Add 100 pixels on the canvas", reward: "10", - status: "completed" + status: "completed", }, { title: "Mint an art/peace NFT", description: "Mint an NFT using the art/peace theme", reward: "5", - status: "incomplete" + status: "incomplete", + }, + ]; + + const sortByCompleted = (arr) => { + if (!arr) return []; + const newArray = []; + for (let i = 0; i < arr.length; i++) { + if (arr[i].status == "completed") { + newArray.push(arr[i]); + } else { + newArray.unshift(arr[i]); + } } - ] + return newArray; + }; // TODO: Icons for each tab? return ( -
-
+
+

Dailys

{props.timeLeftInDay}

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

Main

- {mainQuests.map((quest, index) => ( - + {sortByCompleted(mainQuests).map((quest, index) => ( + + ))} + {sortByCompleted(localMainQuests).map((quest, index) => ( + ))}
); -} +}; export default Quests;