From e9142502f779bf5b2a2d27a6868ec5854a193aa0 Mon Sep 17 00:00:00 2001 From: Adeyemi Gbenga Date: Fri, 24 Jan 2025 07:59:11 +0100 Subject: [PATCH] Pixel Shield (#428) * feat: shield ui flow * feat: added shield UI and multicall to register shield * fix: avoid empty pixel call * feat: improve call * fix: allow only one pixel selection * fix: remove unsed import --- backend/cmd/backend/backend.go | 1 + backend/postgres/init.sql | 3 +- backend/routes/indexer/pixel.go | 118 ++++++- backend/routes/pixel.go | 78 ++++- packages/afk_sdk/src/usePlacePixel.ts | 85 ++--- packages/pixel_ui/src/App.tsx | 101 +++++- .../pixel_ui/src/canvas/CanvasContainer.css | 15 + .../pixel_ui/src/canvas/CanvasContainer.js | 249 +++++++++++--- .../pixel_ui/src/canvas/metadata/Metadata.tsx | 35 +- packages/pixel_ui/src/footer/PixelSelector.js | 304 ++++++++++++------ 10 files changed, 783 insertions(+), 206 deletions(-) diff --git a/backend/cmd/backend/backend.go b/backend/cmd/backend/backend.go index 527620799..f0eb736c8 100644 --- a/backend/cmd/backend/backend.go +++ b/backend/cmd/backend/backend.go @@ -78,6 +78,7 @@ func main() { indexer.InitIndexerRoutes() routes.InitWebsocketRoutes() routes.InitNFTStaticRoutes() + indexer.StartMessageProcessor() core.AFKBackend.Start(core.AFKBackend.BackendConfig.Port) } diff --git a/backend/postgres/init.sql b/backend/postgres/init.sql index 4d93d6b57..e4e0de2fb 100644 --- a/backend/postgres/init.sql +++ b/backend/postgres/init.sql @@ -5,7 +5,8 @@ CREATE TABLE Pixels ( position integer NOT NULL, day integer NOT NULL, color integer NOT NULL, - time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata JSONB DEFAULT NULL ); CREATE INDEX pixels_address_index ON Pixels (address); CREATE INDEX pixels_position_index ON Pixels (position); diff --git a/backend/routes/indexer/pixel.go b/backend/routes/indexer/pixel.go index 424ebf622..6330fd99c 100644 --- a/backend/routes/indexer/pixel.go +++ b/backend/routes/indexer/pixel.go @@ -2,6 +2,8 @@ package indexer import ( "context" + "encoding/json" + "fmt" "strconv" "github.com/AFK-AlignedFamKernel/afk_monorepo/backend/core" @@ -52,6 +54,7 @@ func processPixelPlacedEvent(event IndexerEvent) { return } + fmt.Printf(address, position, dayIdx, color, "print") // Set pixel in postgres _, err = core.AFKBackend.Databases.Postgres.Exec(context.Background(), "INSERT INTO Pixels (address, position, day, color) VALUES ($1, $2, $3, $4)", address, position, dayIdx, color) if err != nil { @@ -116,7 +119,6 @@ func revertPixelPlacedEvent(event IndexerEvent) { func processBasicPixelPlacedEvent(event IndexerEvent) { address := event.Event.Keys[1][2:] // Remove 0x prefix timestampHex := event.Event.Data[0] - timestamp, err := strconv.ParseInt(timestampHex, 0, 64) if err != nil { PrintIndexerError("processBasicPixelPlacedEvent", "Error converting timestamp hex to int", address, timestampHex) @@ -234,3 +236,117 @@ func revertExtraPixelsPlacedEvent(event IndexerEvent) { return } } + +func processBasicPixelPlacedEventWithMetadata(event IndexerEvent) { + address := event.Event.Keys[1][2:] // Remove 0x prefix + timestampHex := event.Event.Data[0] + timestamp, err := strconv.ParseInt(timestampHex, 0, 64) + if err != nil { + PrintIndexerError("processBasicPixelPlacedEventWithMetadata", "Error converting timestamp hex to int", address, timestampHex) + return + } + + // Extract position and color from the event (position is Keys[2], color is in Data[1]) + positionHex := event.Event.Keys[2] + position, err := strconv.Atoi(positionHex) + if err != nil { + PrintIndexerError("processBasicPixelPlacedEventWithMetadata", "Error converting position hex to int", address, positionHex) + return + } + + colorHex := event.Event.Data[1] + color, err := strconv.Atoi(colorHex) + if err != nil { + PrintIndexerError("processBasicPixelPlacedEventWithMetadata", "Error converting color hex to int", address, colorHex) + return + } + + // Extract metadata from the last index in Data (metadata is in Data[n]) + metadata := event.Event.Data[len(event.Event.Data)-1] + + // Unmarshal metadata (if it exists) + var metadataMap map[string]interface{} + if len(metadata) > 0 { + err = json.Unmarshal([]byte(metadata), &metadataMap) + if err != nil { + PrintIndexerError("processBasicPixelPlacedEventWithMetadata", "Error parsing metadata", address, string(metadata)) + return + } + } + + // Prepare SQL statement for inserting pixel info and metadata together + metadataJson, err := json.Marshal(metadataMap) + if err != nil { + PrintIndexerError("processBasicPixelPlacedEventWithMetadata", "Error serializing metadata", address, string(metadata)) + return + } + + // Use a single query to insert the pixel information and metadata into the database + _, err = core.AFKBackend.Databases.Postgres.Exec(context.Background(), + `INSERT INTO Pixels (address, position, color, time) + VALUES ($1, $2, $3, TO_TIMESTAMP($4)) + ON CONFLICT (address, position) + DO UPDATE SET color = $3, time = TO_TIMESTAMP($4), + metadata = COALESCE(metadata, $5)`, + address, position, color, timestamp, metadataJson) + if err != nil { + PrintIndexerError("processBasicPixelPlacedEventWithMetadata", "Error inserting/updating pixel and metadata", address, string(metadataJson)) + return + } + + // Insert or update the last placed time in the LastPlacedTime table + _, err = core.AFKBackend.Databases.Postgres.Exec(context.Background(), + "INSERT INTO LastPlacedTime (address, time) VALUES ($1, TO_TIMESTAMP($2)) ON CONFLICT (address) DO UPDATE SET time = TO_TIMESTAMP($2)", + address, timestamp) + if err != nil { + PrintIndexerError("processBasicPixelPlacedEventWithMetadata", "Error inserting last placed time into postgres", address, timestampHex) + return + } +} + +func revertBasicPixelPlacedEventWithMetadata(event IndexerEvent) { + address := event.Event.Keys[1][2:] // Remove 0x prefix + posHex := event.Event.Keys[2] + + // Convert hex to int for position + position, err := strconv.ParseInt(posHex, 0, 64) + if err != nil { + PrintIndexerError("revertPixelPlacedEvent", "Error converting position hex to int", address, posHex) + return + } + + // We can also retrieve the metadata from the event if needed + metadata := event.Event.Data[len(event.Event.Data)-1] + var metadataMap map[string]interface{} + if len(metadata) > 0 { + err = json.Unmarshal([]byte(metadata), &metadataMap) // Unmarshal from metadata (which is a string) to map + if err != nil { + PrintIndexerError("revertPixelPlacedEvent", "Error parsing metadata", address, string(metadata)) + return + } + } + + // Delete the pixel entry (including metadata) from the PostgreSQL database + _, err = core.AFKBackend.Databases.Postgres.Exec(context.Background(), ` + DELETE FROM Pixels + WHERE address = $1 AND position = $2 + ORDER BY time LIMIT 1`, address, position) + if err != nil { + PrintIndexerError("revertPixelPlacedEvent", "Error deleting pixel from postgres", address, posHex) + return + } + + // Optionally, you can also delete the metadata from the database, + // but usually deleting the pixel entry will automatically take care of it since metadata is part of the same row. + + // Delete the pixel's associated last placed time entry from the LastPlacedTime table + _, err = core.AFKBackend.Databases.Postgres.Exec(context.Background(), + "DELETE FROM LastPlacedTime WHERE address = $1", address) + if err != nil { + PrintIndexerError("revertPixelPlacedEvent", "Error deleting last placed time from postgres", address, posHex) + return + } + + // Optionally log the event if needed + fmt.Printf("Pixel at position %d for address %s has been reverted.\n", position, address) +} diff --git a/backend/routes/pixel.go b/backend/routes/pixel.go index 91f03661c..3b30e84ff 100644 --- a/backend/routes/pixel.go +++ b/backend/routes/pixel.go @@ -2,16 +2,27 @@ package routes import ( "context" + "encoding/json" "fmt" "net/http" "os" "os/exec" "strconv" + "time" "github.com/AFK-AlignedFamKernel/afk_monorepo/backend/core" routeutils "github.com/AFK-AlignedFamKernel/afk_monorepo/backend/routes/utils" ) +// Define a struct to represent a Pixel record +type Pixel struct { + Address string `json:"address"` + Position int `json:"position"` + Day int `json:"day"` + Color int `json:"color"` + Time time.Time `json:"time"` +} + func InitPixelRoutes() { http.HandleFunc("/get-pixel", getPixel) http.HandleFunc("/get-pixel-info", getPixelInfo) @@ -53,10 +64,35 @@ func getPixel(w http.ResponseWriter, r *http.Request) { } type PixelInfo struct { - Address string `json:"address"` - Name string `json:"username"` + Address string `json:"address"` + Name string `json:"username"` + Metadata json.RawMessage `json:"metadata,omitempty"` } +// func getPixelInfo(w http.ResponseWriter, r *http.Request) { +// position, err := strconv.Atoi(r.URL.Query().Get("position")) +// if err != nil { +// routeutils.WriteErrorJson(w, http.StatusBadRequest, "Invalid query position") +// return +// } + +// queryRes, err := core.PostgresQueryOne[PixelInfo](` +// SELECT p.address, COALESCE(u.name, '') as name FROM Pixels p +// LEFT JOIN Users u ON p.address = u.address WHERE p.position = $1 +// ORDER BY p.time DESC LIMIT 1`, position) +// if err != nil { +// routeutils.WriteDataJson(w, "\"0x0000000000000000000000000000000000000000000000000000000000000000\"") +// return +// } + +// if queryRes.Name == "" { +// routeutils.WriteDataJson(w, "\"0x"+queryRes.Address+"\"") +// } else { +// routeutils.WriteDataJson(w, "\""+queryRes.Name+"\"") +// } +// } + +// New implmentation with metadata func getPixelInfo(w http.ResponseWriter, r *http.Request) { position, err := strconv.Atoi(r.URL.Query().Get("position")) if err != nil { @@ -64,21 +100,42 @@ func getPixelInfo(w http.ResponseWriter, r *http.Request) { return } + // Update the query to include metadata queryRes, err := core.PostgresQueryOne[PixelInfo](` - SELECT p.address, COALESCE(u.name, '') as name FROM Pixels p - LEFT JOIN Users u ON p.address = u.address WHERE p.position = $1 - ORDER BY p.time DESC LIMIT 1`, position) + SELECT + p.address, + COALESCE(u.name, '') as name, + p.metadata -- Fetch the metadata column as well + FROM Pixels p + LEFT JOIN Users u ON p.address = u.address + WHERE p.position = $1 + ORDER BY p.time DESC + LIMIT 1`, position) if err != nil { routeutils.WriteDataJson(w, "\"0x0000000000000000000000000000000000000000000000000000000000000000\"") return } + // If queryRes.Name is empty, return the address, else return the name if queryRes.Name == "" { - routeutils.WriteDataJson(w, "\"0x"+queryRes.Address+"\"") + response := "\"0x" + queryRes.Address + "\"" + if queryRes.Metadata != nil { + // If metadata exists, include it in the response + metadataJson, _ := json.Marshal(queryRes.Metadata) + response = fmt.Sprintf("{\"address\": \"%s\", \"metadata\": %s}", queryRes.Address, string(metadataJson)) + } + routeutils.WriteDataJson(w, response) } else { - routeutils.WriteDataJson(w, "\""+queryRes.Name+"\"") + response := "\"" + queryRes.Name + "\"" + if queryRes.Metadata != nil { + // If metadata exists, include it in the response + metadataJson, _ := json.Marshal(queryRes.Metadata) + response = fmt.Sprintf("{\"name\": \"%s\", \"metadata\": %s}", queryRes.Name, string(metadataJson)) + } + routeutils.WriteDataJson(w, response) } } + func getPixelMetadata(w http.ResponseWriter, r *http.Request) { position, err := strconv.Atoi(r.URL.Query().Get("position")) if err != nil { @@ -102,7 +159,6 @@ func getPixelMetadata(w http.ResponseWriter, r *http.Request) { } } - func placePixelDevnet(w http.ResponseWriter, r *http.Request) { // Disable this in production if routeutils.NonProductionMiddleware(w, r) { @@ -208,7 +264,7 @@ func placePixelRedis(w http.ResponseWriter, r *http.Request) { } jsonBody, err := routeutils.ReadJsonBody[map[string]uint](r) - fmt.Println("jsonBody", jsonBody) + fmt.Println("jsonBody", r.Body) if err != nil { routeutils.WriteErrorJson(w, http.StatusBadRequest, "Invalid JSON request body") @@ -218,6 +274,8 @@ func placePixelRedis(w http.ResponseWriter, r *http.Request) { position := (*jsonBody)["position"] color := (*jsonBody)["color"] + fmt.Println("jsonBody", position) + canvasWidth := core.AFKBackend.CanvasConfig.Canvas.Width canvasHeight := core.AFKBackend.CanvasConfig.Canvas.Height @@ -250,7 +308,5 @@ func placePixelRedis(w http.ResponseWriter, r *http.Request) { return } - fmt.Println("Error redis insert pixel", err.Error()) - routeutils.WriteResultJson(w, "Pixel placed on redis") } diff --git a/packages/afk_sdk/src/usePlacePixel.ts b/packages/afk_sdk/src/usePlacePixel.ts index 84e6e2ce9..6f9cb4207 100644 --- a/packages/afk_sdk/src/usePlacePixel.ts +++ b/packages/afk_sdk/src/usePlacePixel.ts @@ -1,6 +1,22 @@ import { useMutation } from '@tanstack/react-query'; import { Account, AccountInterface, num, RPC } from 'starknet'; +const transformKeys = (array: any, keyMap: any) => { + return array.map((obj: any) => { + return Object.entries(obj).reduce((newObj, [key, value]) => { + const newKey = keyMap[key] || key; + // @ts-ignore + newObj[newKey] = value; + return newObj; + }, {}); + }); +}; + +const keyMap = { + contract_address: 'contractAddress', + entry_point: 'entrypoint', +}; + // Detect Telegram context const isTelegramContext = () => typeof window !== 'undefined' && @@ -50,57 +66,54 @@ export const executeContractAction = async ({ options: ExecuteContractActionOptions; }): Promise => { const { version = 3, argentTMA } = options; - // Automatically determine the context type const contextType = isTelegramContext() ? ContractActionContextType.Telegram : ContractActionContextType.Expo; try { - // Estimate fees (same for both contexts) - const estimatedFee = await account.estimateInvokeFee([callProps], { version }); + console.log('CallData', callProps); + // // Estimate fees (same for both contexts) + // const estimatedFee = await account.estimateInvokeFee(transformKeys(callProps, keyMap), { + // version, + // }); + // console.log(estimatedFee, 'estimated fee'); + // // Apply fee multiplier (default to 1.5x if not specified) + // const feeMultiplier = callProps[0]?.feeMultiplier || 1.5; + // const maxFee = + // (estimatedFee.suggestedMaxFee * BigInt(Math.round(feeMultiplier * 10))) / BigInt(10); - // Apply fee multiplier (default to 1.5x if not specified) - const feeMultiplier = callProps.feeMultiplier || 1.5; - const maxFee = - (estimatedFee.suggestedMaxFee * BigInt(Math.round(feeMultiplier * 10))) / BigInt(10); + // // Prepare transaction options based on context + // const transactionOptions = + // contextType === ContractActionContextType.Telegram + // ? { + // version, + // maxFee, + // feeDataAvailabilityMode: RPC.EDataAvailabilityMode.L1, + // resourceBounds: { + // ...estimatedFee.resourceBounds, + // l1_gas: { + // ...estimatedFee.resourceBounds.l1_gas, + // max_amount: num.toHex( + // BigInt(parseInt(estimatedFee.resourceBounds.l1_gas.max_amount, 16) * 2), + // ), + // }, + // }, + // } + // : { + // version, + // maxFee, + // }; - // Prepare transaction options based on context - const transactionOptions = - contextType === ContractActionContextType.Telegram - ? { - version, - maxFee, - feeDataAvailabilityMode: RPC.EDataAvailabilityMode.L1, - resourceBounds: { - ...estimatedFee.resourceBounds, - l1_gas: { - ...estimatedFee.resourceBounds.l1_gas, - max_amount: num.toHex( - BigInt(parseInt(estimatedFee.resourceBounds.l1_gas.max_amount, 16) * 2), - ), - }, - }, - } - : { - version, - maxFee, - }; // Execute the transaction using account.execute() or invoke with wallet since we using sessions const { transaction_hash } = wallet ? await wallet.request({ type: 'wallet_addInvokeTransaction', params: { - calls: [ - { - calldata: callProps.calldata, - contract_address: callProps.contractAddress, - entry_point: callProps.entrypoint, - }, - ], + calls: callProps, }, }) - : await account.execute([callProps], transactionOptions); + : await account.execute(callProps); // Wait for transaction receipt let receipt; if (contextType === ContractActionContextType.Telegram) { diff --git a/packages/pixel_ui/src/App.tsx b/packages/pixel_ui/src/App.tsx index 3dcfdf5d8..c6a065147 100644 --- a/packages/pixel_ui/src/App.tsx +++ b/packages/pixel_ui/src/App.tsx @@ -20,7 +20,7 @@ import canvas_nft_abi from './contracts/canvas_nft.abi.json'; import NotificationPanel from './tabs/NotificationPanel.js'; import ModalPanel from './ui/ModalPanel.js'; import useMediaQuery from './hooks/useMediaQuery'; -import { useAutoConnect, useQueryAddressEffect, useWalletStore, useConnectArgent } from 'afk_react_sdk'; +import { useQueryAddressEffect, useWalletStore, useConnectArgent, useAutoConnect, } from 'afk_react_sdk'; const logoUrl = './assets/pepe-logo.png' const HamburgerUrl = './resources/icons/Hamburger.png'; @@ -87,6 +87,9 @@ function App({ contractAddress, usernameAddress, nftCanvasAddress }: IApp) { } = useWalletStore() + + + const { chain } = useNetwork(); // const [queryAddress, setQueryAddress] = useState('0'); @@ -281,6 +284,81 @@ function App({ contractAddress, usernameAddress, nftCanvasAddress }: IApp) { } }; + + // Shield-related state + const [isShieldMode, setIsShieldMode] = useState(false); + const [shieldSelectionStart, setShieldSelectionStart] = useState({ x: null, y: null }); + const [shieldSelectionEnd, setShieldSelectionEnd] = useState({ x: null, y: null }); + const [isShieldSelecting, setIsShieldSelecting] = useState(false); + const [shieldedAreas, setShieldedAreas] = useState([]); + const [selectedShieldPixels, setSelectedShieldPixels] = useState([]); + + // Shield Fn + const toggleShieldMode = () => { + setIsShieldMode(!isShieldMode); + if (isShieldMode) { + // Reset selection when exiting shield mode + setShieldSelectionStart({ x: null, y: null }); + setShieldSelectionEnd({ x: null, y: null }); + setSelectedShieldPixels([]); + setShieldedAreas([]); + } + }; + + // const updateSelectedShieldPixels = (start, end) => { + // const startX = Math.min(start.x, end.x); + // const startY = Math.min(start.y, end.y); + // const endX = Math.max(start.x, end.x); + // const endY = Math.max(start.y, end.y); + + // const newSelectedPixels = []; + // for (let y = startY; y <= endY; y++) { + // for (let x = startX; x <= endX; x++) { + // const position = y * width + x; + // newSelectedPixels.push(position); + // } + // } + // setSelectedShieldPixels(newSelectedPixels); + // }; + + const updateSelectedShieldPixels = (position, maxPixels) => { + setSelectedShieldPixels((prevPixels) => { + // Check if the position already exists in the array + if (prevPixels.includes(position)) { + return prevPixels // Position already selected, no change + } + + // Check if adding this pixel would exceed the maximum + if (prevPixels.length >= maxPixels) { + console.log(`Maximum number of pixels (${maxPixels}) reached. Cannot add more.`) + return prevPixels // Return unchanged array + } + + // If the position doesn't exist and we're under the limit, add it + return [...prevPixels, position] + }) + } + + + + + const registerShieldArea = () => { + if (shieldSelectionStart.x !== null && shieldSelectionEnd.x !== null) { + const newShieldedArea = { + x: Math.min(shieldSelectionStart.x, shieldSelectionEnd.x), + y: Math.min(shieldSelectionStart.y, shieldSelectionEnd.y), + width: Math.abs(shieldSelectionEnd.x - shieldSelectionStart.x) + 1, + height: Math.abs(shieldSelectionEnd.y - shieldSelectionStart.y) + 1, + }; + setShieldedAreas(prev => [...prev, newShieldedArea]); + console.log("Registered shield area:", newShieldedArea); + // Reset selection after registering + setShieldSelectionStart({ x: null, y: null }); + setShieldSelectionEnd({ x: null, y: null }); + } + }; + + // Pixel selection data const [selectedColorId, setSelectedColorId] = useState(-1); const [pixelSelectedMode, setPixelSelectedMode] = useState(false); @@ -719,6 +797,7 @@ function App({ contractAddress, usernameAddress, nftCanvasAddress }: IApp) { setPixelSelection={setPixelSelection} clearPixelSelection={clearPixelSelection} setPixelPlacedBy={setPixelPlacedBy} + pixelPlacedBy={pixelPlacedBy} basePixelUp={basePixelUp} availablePixelsUsed={availablePixelsUsed} addExtraPixel={addExtraPixel} @@ -748,6 +827,20 @@ function App({ contractAddress, usernameAddress, nftCanvasAddress }: IApp) { setIsEraserMode={setIsEraserMode} clearExtraPixel={clearExtraPixel} setLastPlacedTime={setLastPlacedTime} + selectorMode={selectorMode} + + shieldSelectionStart={shieldSelectionStart} + setShieldSelectionStart={setShieldSelectionStart} + shieldSelectionEnd={shieldSelectionEnd} + setShieldSelectionEnd={setShieldSelectionEnd} + isShieldMode={isShieldMode} + setIsShieldMode={setIsShieldMode} + isShieldSelecting={isShieldSelecting} + setIsShieldSelecting={setIsShieldSelecting} + shieldedAreas={shieldedAreas} + setShieldedAreas={setShieldedAreas} + updateSelectedShieldPixels={updateSelectedShieldPixels} + selectedShieldPixels={selectedShieldPixels} /> {(!isMobile || activeTab === tabs[0]) && (
@@ -893,6 +986,7 @@ function App({ contractAddress, usernameAddress, nftCanvasAddress }: IApp) { lastPlacedTime={lastPlacedTime} basePixelTimer={basePixelTimer} queryAddress={queryAddress} + address={address} setActiveTab={setActiveTab} isEraserMode={isEraserMode} setIsEraseMode={setIsEraserMode} @@ -901,7 +995,10 @@ function App({ contractAddress, usernameAddress, nftCanvasAddress }: IApp) { clearAll={clearAll} account={account} wallet={wallet} - + toggleShieldMode={toggleShieldMode} + isShieldMode={isShieldMode} + registerShieldArea={registerShieldArea} + selectedShieldPixels={selectedShieldPixels} /> )} {isFooterSplit && !footerExpanded && ( diff --git a/packages/pixel_ui/src/canvas/CanvasContainer.css b/packages/pixel_ui/src/canvas/CanvasContainer.css index bb49fa84d..464eb89e4 100644 --- a/packages/pixel_ui/src/canvas/CanvasContainer.css +++ b/packages/pixel_ui/src/canvas/CanvasContainer.css @@ -48,3 +48,18 @@ background-color: rgba(0, 0, 0, 0); outline: 0.1px solid rgba(255, 0, 0, 0.5); } + + + +.shield-selection-box { + position: absolute; + border: 2px solid #3b82f6; + background-color: rgba(59, 130, 246, 0.2); + pointer-events: none; +} + +.shielded-area { + position: absolute; + background-color: rgba(196, 196, 196, 0.2); + pointer-events: none; +} diff --git a/packages/pixel_ui/src/canvas/CanvasContainer.js b/packages/pixel_ui/src/canvas/CanvasContainer.js index 514114dcc..53b3daf3a 100644 --- a/packages/pixel_ui/src/canvas/CanvasContainer.js +++ b/packages/pixel_ui/src/canvas/CanvasContainer.js @@ -2,7 +2,7 @@ import './CanvasContainer.css'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import canvasConfig from '../configs/canvas.config.json'; import { fetchWrapper } from '../services/apiService.js'; import Canvas from './Canvas'; @@ -11,8 +11,9 @@ import NFTSelector from './NFTSelector.js'; import TemplateCreationOverlay from './TemplateCreationOverlay.js'; import TemplateOverlay from './TemplateOverlay.js'; import { useContractAction } from "afk_sdk"; -import { ART_PEACE_ADDRESS } from "common" +import { ART_PEACE_ADDRESS} from "common" import MetadataView from './metadata/Metadata'; +import { byteArray,CallData } from 'starknet'; const CanvasContainer = (props) => { @@ -36,11 +37,97 @@ const CanvasContainer = (props) => { const [isErasing, setIsErasing] = useState(false); - const [showMetadataForm, setShowMetaDataForm] = useState(false) + // Metadata states + const [showMetadataForm, setShowMetaDataForm] = useState(false); + const [metaData, setMetadata] = useState({ + twitter: '', + nostr: '', + ips: '' + }) + + const clampToCanvas = useCallback((x, y) => { + return { + x: Math.max(0, Math.min(width - 1, x)), + y: Math.max(0, Math.min(height - 1, y)) + }; + }, [width, height]); + + + + // const handleSelectionStarts = useCallback( async (e) => { + // if (props.nftMintingMode || props.templateCreationMode || !props.isShieldMode) return; + + // const canvas = props.canvasRef.current + // if (!canvas) return + + // const rect = canvas.getBoundingClientRect() + // const x = Math.floor(((e.clientX - rect.left) / (rect.right - rect.left)) * props.width) + // const y = Math.floor(((e.clientY - rect.top) / (rect.bottom - rect.top)) * props.height) + + // const clampedPosition = clampToCanvas(x, y); + // props.setShieldSelectionStart(clampedPosition) + // props.setShieldSelectionEnd(clampedPosition) + // props.setIsShieldSelecting(true) + // }, [props.nftMintingMode, props.templateCreationMode, width, height, clampToCanvas, props.isShieldMode]); + + + const handleSelectionStart = async (e) => { + if (props.nftMintingMode || props.templateCreationMode || !props.isShieldMode) return; + + //Only one pixel can be shield for now. + const maxPixels = 1; + const canvas = props.canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const x = Math.floor(((e.clientX - rect.left) / (rect.right - rect.left)) * width); + const y = Math.floor(((e.clientY - rect.top) / (rect.bottom - rect.top)) * height); + + + const clampedPosition = clampToCanvas(x, y); + props.setShieldSelectionStart(clampedPosition); + props.setShieldSelectionEnd(clampedPosition); + props.setIsShieldSelecting(true); + + const position = clampedPosition.y * width + clampedPosition.x; + + const getPixelInfoEndpoint = await fetchWrapper( + `get-pixel-info?position=${position.toString()}`, + ); + + if (!getPixelInfoEndpoint.data) { + return; + } + const paddedAddress = '0x0' + props.address.slice(2); + // Check if the pixel address matches the logged-in user's address + if (getPixelInfoEndpoint.data === paddedAddress ) { + props.updateSelectedShieldPixels(position, maxPixels); + } else { + console.log("This pixel doesn't belong to the logged-in user") + } + }; + + + // const handleSelectionMove = useCallback((e) => { + // if (!props.isShieldSelecting) return; + + // const canvas = props.canvasRef.current; + // const rect = canvas.getBoundingClientRect(); + // const x = Math.floor(((e.clientX - rect.left) / (rect.right - rect.left)) * width); + // const y = Math.floor(((e.clientY - rect.top) / (rect.bottom - rect.top)) * height); + + // const clampedPosition = clampToCanvas(x, y); + // props.setShieldSelectionEnd(clampedPosition); + // props.updateSelectedShieldPixels(props.shieldSelectionStart, clampedPosition); + // }, [props.isShieldSelecting, width, height, clampToCanvas]); + + const handleSelectionEnd = useCallback(() => { + props.setIsShieldSelecting(false); + }, []); const handlePointerDown = (e) => { // TODO: Require over canvas? - if (!props.isEraserMode) { + if (props.isShieldMode) { + handleSelectionStart(e); + } else if (!props.isEraserMode) { setIsDragging(true); setDragStartX(e.clientX); setDragStartY(e.clientY); @@ -49,27 +136,35 @@ const CanvasContainer = (props) => { } }; - const handlePointerUp = () => { - setIsErasing(false); - setIsDragging(false); - setDragStartX(0); - setDragStartY(0); - }; + + + const handlePointerUp = useCallback(() => { + if (props.isShieldMode) { + handleSelectionEnd(); + } else { + setIsErasing(false); + setIsDragging(false); + setDragStartX(0); + setDragStartY(0); + } + }, [props.isShieldMode, handleSelectionEnd]); const handlePointerMove = (e) => { - if (props.nftMintingMode && !props.nftSelected) return; - if (props.templateCreationMode && !props.templateCreationSelected) return; - if (isDragging) { + if (props.isShieldMode) { + // handleSelectionMove(e); + } else if ((props.nftMintingMode && !props.nftSelected) || (props.templateCreationMode && !props.templateCreationSelected)) { + return; + } else if (isDragging) { setCanvasX(canvasX + e.clientX - dragStartX); setCanvasY(canvasY + e.clientY - dragStartY); setDragStartX(e.clientX); setDragStartY(e.clientY); - } - if (props.isEraserMode && isErasing) { + } else if (props.isEraserMode && isErasing) { pixelClicked(e); } }; + useEffect(() => { window?.addEventListener('pointerup', handlePointerUp); return () => { @@ -177,6 +272,14 @@ const CanvasContainer = (props) => { } }; + + useEffect(() => { + window.addEventListener('mouseup', handleSelectionEnd); + return () => { + window.removeEventListener('mouseup', handleSelectionEnd); + }; + }, [handleSelectionEnd]); + useEffect(() => { canvasContainerRef?.current.addEventListener('wheel', zoom); canvasContainerRef?.current.addEventListener('touchstart', handleTouchStart); @@ -245,23 +348,62 @@ const CanvasContainer = (props) => { // if (devnetMode) return; // if (!props.address || !props.artPeaceContract) return; - console.log("try placePixelCall") if (!props.address || !props.artPeaceContract || !props.account) return; - console.log("mutatePlacePixel") + + //Check for wallet or account + const callProps = (data, entry) => props.wallet ? + + [{ + // calldata:data, + calldata: data, + contract_address: ART_PEACE_ADDRESS?.['0x534e5f5345504f4c4941'], + entry_point: entry + }] + : + [{ + calldata:data, + contractAddress: ART_PEACE_ADDRESS?.['0x534e5f5345504f4c4941'], + entrypoint: entry + }] + + + + + //Check if the user adds a metadata. + if (metaData.twitter || metaData.nostr || metaData.ips) { + + const metadata = { + pos: position, + ipfs: byteArray.byteArrayFromString(metaData.ips), + nostr_event_id: metaData.nostr, + owner: props.account.address, + contract: ART_PEACE_ADDRESS?.['0x534e5f5345504f4c4941'] || "" // Contract address + }; + + return mutatePlacePixel({ + account: props.account, + wallet: props.wallet, + callProps: callProps(CallData.compile({position, color, now, metaPos:metadata.pos, ipfs:metadata.ipfs, nostr:metadata.nostr_event_id, owner: metadata.owner, contract: metadata.contract}), "place_pixel_with_metadata") + }, { + onError(err) { + console.log(err); + setShowMetaDataForm(false); + }, + onSuccess(data) { + console.log(data, "Success") + } + }) + } mutatePlacePixel({ account: props.account, wallet: props.wallet, - callProps: { - calldata: [position, color, now], - contractAddress: ART_PEACE_ADDRESS?.['0x534e5f5345504f4c4941'], - entrypoint: "place_pixel" - } + callProps: callProps([position, color, now],"place_pixel") }, { onError(err) { console.log(err) setShowMetaDataForm(false) }, - onSuccess(data){ + onSuccess(data) { console.log(data, "Success") } }) @@ -272,12 +414,6 @@ const CanvasContainer = (props) => { if (props.nftMintingMode || props.templateCreationMode) { return; } - - //Show Metadata Form on Pixel Clicked and pixel selectMode - if (props.selectedColorId !== -1) { - setShowMetaDataForm(true); - } - const canvas = props.canvasRef.current; const rect = canvas.getBoundingClientRect(); const x = Math.floor(((e.clientX - rect.left) / (rect.right - rect.left)) * width); @@ -287,7 +423,6 @@ const CanvasContainer = (props) => { if (x < 0 || x >= width || y < 0 || y >= height) { return; } - // Erase Extra Pixel if (props.isEraserMode) { const pixelIndex = props.extraPixelsData.findIndex((pixelData) => { @@ -326,7 +461,8 @@ const CanvasContainer = (props) => { // if (!devnetMode) { props.setSelectedColorId(-1); props.colorPixel(position, colorId); - await placePixelCall(position,colorId,timestamp); + await placePixelCall(position, colorId, timestamp); + props.clearPixelSelection(); props.setLastPlacedTime(timestamp * 1000); // return; @@ -345,10 +481,10 @@ const CanvasContainer = (props) => { mode: 'cors', method: 'POST', body: JSON.stringify({ - position: position.toString(), - color: colorId.toString(), - timestamp: timestamp.toString(), - }), + position: Number(position), + color: Number(colorId), + timestamp: Number(timestamp) + }) }); if (response.result) { console.log(response.result); @@ -473,9 +609,45 @@ const CanvasContainer = (props) => { props.isExtraDeleteMode, ]); + const renderSelectionBox = () => { + if (props.shieldSelectionStart.x === null || props.shieldSelectionEnd.x === null) return null; + + const left = Math.min(props.shieldSelectionStart.x, props.shieldSelectionEnd.x) * canvasScale; + const top = Math.min(props.shieldSelectionStart.y, props.shieldSelectionEnd.y) * canvasScale; + const width = (Math.abs(props.shieldSelectionEnd.x - props.shieldSelectionStart.x) + 1) * canvasScale; + const height = (Math.abs(props.shieldSelectionEnd.y - props.shieldSelectionStart.y) + 1) * canvasScale; + + return ( +
+ ); + }; + + const renderShieldedAreas = () => { + return props.shieldedAreas.map((area, index) => ( +
+ )); + }; + return ( <> - setShowMetaDataForm(false)} showMeta={showMetadataForm} /> + setShowMetaDataForm(true)} closeMeta={() => [setShowMetaDataForm(false), setMetadata({ ips:"", nostr:"", twitter:""})]} showMeta={showMetadataForm} />
{ transform: `translate(${canvasX}px, ${canvasY}px)`, }} > + {props.isShieldMode && renderSelectionBox()} + {renderShieldedAreas()} {props.pixelSelectedMode && (
{ >
)} + { ); }; -export default CanvasContainer; +export default CanvasContainer; \ No newline at end of file diff --git a/packages/pixel_ui/src/canvas/metadata/Metadata.tsx b/packages/pixel_ui/src/canvas/metadata/Metadata.tsx index b2eab9eba..0599f0916 100644 --- a/packages/pixel_ui/src/canvas/metadata/Metadata.tsx +++ b/packages/pixel_ui/src/canvas/metadata/Metadata.tsx @@ -1,21 +1,14 @@ -import { useState } from 'react'; import './metadataForm.css' type IProps = { showMeta: boolean; closeMeta: () => void; -} -type FormData = { - twitter: string; - nostr: string; - ips: string; + handleOpen:()=>void; + selectorMode:boolean; + setFormData:any; + formData } -export default function MetadataForm({ showMeta, closeMeta }: IProps) { - const [formData, setFormData] = useState({ - twitter: '', - nostr: '', - ips: '' - }) +export default function MetadataForm({ showMeta, closeMeta, handleOpen, selectorMode,formData, setFormData }: IProps) { const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target @@ -25,17 +18,25 @@ export default function MetadataForm({ showMeta, closeMeta }: IProps) { })) } - const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - // Handle form submission logic here - console.log('Form submitted'); - setFormData({ twitter: '', nostr: '', ips: '' }) + console.log('Form submitted', formData); closeMeta() } return (
+ {selectorMode && +
+ +
+ }
diff --git a/packages/pixel_ui/src/footer/PixelSelector.js b/packages/pixel_ui/src/footer/PixelSelector.js index 4ae533c1a..021dc7480 100644 --- a/packages/pixel_ui/src/footer/PixelSelector.js +++ b/packages/pixel_ui/src/footer/PixelSelector.js @@ -1,18 +1,92 @@ import './PixelSelector.css'; import '../utils/Styles.css'; -import React, {useEffect, useState} from 'react'; +import React, { useEffect, useState } from 'react'; +import { useContractAction } from "afk_sdk"; +import { ART_PEACE_ADDRESS } from "common" +import { CallData, uint256, constants } from 'starknet'; import { - // useAccount, - // useContract, - // useNetwork, - // useConnect -} from '@starknet-react/core'; + useContract, + useNetwork, +} from "@starknet-react/core"; +import { formatFloatToUint256 } from "common" +import ercAbi from "../contracts/erc20.json" + import EraserIcon from '../resources/icons/Eraser.png'; const PixelSelector = (props) => { + const { chain } = useNetwork() + const { contract } = useContract({ + abi: ercAbi, + // address: ART_PEACE_ADDRESS[constants.StarknetChainId.SN_SEPOLIA], + address: chain.nativeCurrency.address + }) + //Pixel Call Hook + const { mutate: mutatePlaceShield } = useContractAction(); + const shieldPixelFn = async () => { + //Add a default 1Sec Shield time. + const timestamp = props.shieldTime || Math.floor(Date.now() / 1000); + const defaultAmount = 1; + let amountUint256 = formatFloatToUint256(defaultAmount); + amountUint256 = uint256.bnToUint256(BigInt('0x' + defaultAmount)); + + const approveCall = props.wallet ? { + contract_address: contract.address, + entry_point: 'approve', + calldata: CallData.compile({ + address: ART_PEACE_ADDRESS[constants.StarknetChainId.SN_SEPOLIA], + amount: amountUint256 + }), + } : + { + contract_address: contract.address, + entrypoint: 'approve', + calldata: CallData.compile({ + address: ART_PEACE_ADDRESS[constants.StarknetChainId.SN_SEPOLIA], + amount: amountUint256, + }), + } + + + + if (!props.address || !props.account || props.selectedShieldPixels.length === 0) return; + //Check for wallet or account + const callProps = (entry) => props.wallet ? + props.selectedShieldPixels.map((item) => { + return { + calldata: CallData.compile({ item, timestamp }), + contract_address: ART_PEACE_ADDRESS[constants.StarknetChainId.SN_SEPOLIA], + entry_point: entry + } + }) + : + props.selectedShieldPixels.map((item) => { + return { + calldata: CallData.compile(item, timestamp), + // calldata: [item, timestamp], + contractAddress: ART_PEACE_ADDRESS[constants.StarknetChainId.SN_SEPOLIA], + entrypoint: entry + } + }) + + // Combine the calls with approve first + const allCalls = [approveCall, ...callProps("place_pixel_shield")]; + // const allCalls = [approveCall]; + mutatePlaceShield({ + account: props.account, + wallet: props.wallet, + callProps: allCalls + }, { + onError(err) { + console.log(err) + }, + onSuccess(data) { + console.log(data, "Success") + } + }) + }; // Track when a placement is available @@ -89,116 +163,144 @@ const PixelSelector = (props) => { return (
- {(props.selectorMode || ended) && ( -
-
- {props.colors.map((color, idx) => { - return ( + {(props.selectorMode || ended) && ( +
+
+
+ {props.colors.map((color, idx) => { + return ( +
selectColor(idx)} + >
+ ); + })} +
+
cancelSelector()}> + x +
+
+
+
+

{props.isShieldMode ? "Exit Shield Mode" : "Enter Shield Mode"}

+
+ + { + props.isShieldMode ? + + props.selectedShieldPixels.length !== 0 ? + +
[props.registerShieldArea(), shieldPixelFn()]} className='Button__primary Text__large'> +

Shield Pixel for (1) seconds

+
+ : +
+

No Pixel Selected

+
+ + : "" + + } + +
+
+ )} + {!props.selectorMode && !ended && ( +
props.availablePixelsUsed + ? '' + : 'PixelSelector__button--invalid') + } + onClick={toSelectorMode} + > +

{placementTimer}

+ {props.availablePixels > (props.basePixelUp ? 1 : 0) && ( +
selectColor(idx)} + style={{ + margin: '0 1rem', + height: '2.4rem', + width: '0.5rem', + borderRadius: '0.25rem', + backgroundColor: 'rgba(0, 0, 0, 0.3)' + }} >
- ); - })} -
-
cancelSelector()}> - x -
-
- )} - {!props.selectorMode && !ended && ( -
props.availablePixelsUsed - ? '' - : 'PixelSelector__button--invalid') - } - onClick={toSelectorMode} - > -

{placementTimer}

- {props.availablePixels > (props.basePixelUp ? 1 : 0) && ( -
-
-

- {props.availablePixels - props.availablePixelsUsed} left -

-
- )} - {props.selectedColorId !== -1 && ( -
+

+ {props.availablePixels - props.availablePixelsUsed} left +

+
+ )} + {props.selectedColorId !== -1 && (
-
cancelSelector()} > - x +
+
cancelSelector()} + > + x +
-
- )} - {props.isEraserMode && ( -
+ )} + {props.isEraserMode && (
- Eraser -
-
cancelSelector()} - > - x + > + Eraser +
+
cancelSelector()} + > + x +
-
- )} -
- )} - -
+ )} +
+ )} + + + +
); }; export default PixelSelector; +