diff --git a/govtool/backend/app/Main.hs b/govtool/backend/app/Main.hs index d82821424..4ba525ad0 100644 --- a/govtool/backend/app/Main.hs +++ b/govtool/backend/app/Main.hs @@ -16,6 +16,7 @@ import Control.Monad.Trans.Except import Control.Monad.Trans.Reader import Data.Aeson hiding (Error) +import Data.Aeson (encode) import qualified Data.ByteString as BS import Data.ByteString.Char8 (unpack) import qualified Data.Cache as Cache @@ -35,7 +36,7 @@ import Data.Text.Encoding (encodeUtf8) import qualified Data.Text.IO as Text import qualified Data.Text.Lazy as LazyText import qualified Data.Text.Lazy.Encoding as LazyText - +import qualified Data.ByteString.Lazy.Char8 as BS8 import Database.PostgreSQL.Simple (close, connectPostgreSQL, Connection) import Network.Wai @@ -62,8 +63,10 @@ import VVA.API.Types import VVA.CommandLine import VVA.Config import VVA.Types (AppEnv (..), - AppError (CriticalError, InternalError, NotFoundError, ValidationError), + AppError (..), CacheEnv (..)) +import VVA.Ipfs (IpfsError(..)) + -- Function to create a connection pool with optimized settings createOptimizedConnectionPool :: BS.ByteString -> IO (Pool Connection) @@ -288,10 +291,15 @@ liftServer appEnv = where handleErrors :: Either AppError a -> Handler a handleErrors (Right x) = pure x - handleErrors (Left (ValidationError msg)) = throwError $ err400 { errBody = BS.fromStrict $ encodeUtf8 msg } - handleErrors (Left (NotFoundError msg)) = throwError $ err404 { errBody = BS.fromStrict $ encodeUtf8 msg } - handleErrors (Left (CriticalError msg)) = throwError $ err500 { errBody = BS.fromStrict $ encodeUtf8 msg } - handleErrors (Left (InternalError msg)) = throwError $ err500 { errBody = BS.fromStrict $ encodeUtf8 msg } + handleErrors (Left appError) = do + let status = case appError of + ValidationError _ -> err400 + NotFoundError _ -> err404 + CriticalError _ -> err500 + InternalError _ -> err500 + AppIpfsError (OtherIpfsError _) -> err400 + AppIpfsError _ -> err503 + throwError $ status { errBody = encode appError, errHeaders = [("Content-Type", "application/json")] } -- * Swagger type SwaggerAPI = SwaggerSchemaUI "swagger-ui" "swagger.json" diff --git a/govtool/backend/example-config.json b/govtool/backend/example-config.json index fe6a47420..800f7dde1 100644 --- a/govtool/backend/example-config.json +++ b/govtool/backend/example-config.json @@ -6,6 +6,7 @@ "password" : "postgres", "port" : 5432 }, + "pinataapijwt": "", "port" : 9999, "host" : "localhost", "cachedurationseconds": 20, diff --git a/govtool/backend/src/VVA/API.hs b/govtool/backend/src/VVA/API.hs index ca7660358..6ab25a806 100644 --- a/govtool/backend/src/VVA/API.hs +++ b/govtool/backend/src/VVA/API.hs @@ -1,10 +1,10 @@ -{-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE ViewPatterns #-} +{-# LANGUAGE DataKinds #-} module VVA.API where @@ -13,7 +13,7 @@ import Control.Exception (throw, throwIO) import Control.Monad.Except (runExceptT, throwError) import Control.Monad.Reader -import Data.Aeson (Value(..), Array, decode, encode, ToJSON, toJSON) +import Data.Aeson (Value(..), Array, decode, ToJSON, toJSON) import Data.Bool (Bool) import Data.List (sortOn, sort) import qualified Data.Map as Map @@ -21,6 +21,8 @@ import Data.Maybe (Maybe (Nothing), catMaybes, fromMaybe import Data.Ord (Down (..)) import Data.Text hiding (any, drop, elem, filter, length, map, null, take) import qualified Data.Text as Text +import qualified Data.Text.Lazy as TL +import qualified Data.Text.Lazy.Encoding as TL import qualified Data.Vector as V import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) @@ -29,6 +31,7 @@ import Numeric.Natural (Natural) import Servant.API import Servant.Server +import Servant.Exception (Throws) import System.Random (randomRIO) import Text.Read (readMaybe) @@ -45,12 +48,18 @@ import qualified VVA.Proposal as Proposal import qualified VVA.Transaction as Transaction import qualified VVA.Types as Types import VVA.Types (App, AppEnv (..), - AppError (CriticalError, InternalError, ValidationError), + AppError (CriticalError, InternalError, ValidationError, AppIpfsError), CacheEnv (..)) import Data.Time (TimeZone, localTimeToUTC) +import qualified VVA.Ipfs as Ipfs +import Data.ByteString.Lazy (ByteString) +import qualified Data.ByteString.Lazy as BSL +import Servant.Exception (Throws) type VVAApi = - "drep" :> "list" + "ipfs" + :> "upload" :> QueryParam "fileName" Text :> ReqBody '[PlainText] Text :> Post '[JSON] UploadResponse + :<|> "drep" :> "list" :> QueryParam "search" Text :> QueryParams "status" DRepStatus :> QueryParam "sort" DRepSortMode @@ -89,7 +98,8 @@ type VVAApi = :<|> "account" :> Capture "stakeKey" HexText :> Get '[JSON] GetAccountInfoResponse server :: App m => ServerT VVAApi m -server = drepList +server = upload + :<|> drepList :<|> getVotingPower :<|> getVotes :<|> drepInfo @@ -107,6 +117,19 @@ server = drepList :<|> getNetworkTotalStake :<|> getAccountInfo +upload :: App m => Maybe Text -> Text -> m UploadResponse +upload mFileName fileContentText = do + AppEnv {vvaConfig} <- ask + let fileContent = TL.encodeUtf8 $ TL.fromStrict fileContentText + vvaPinataJwt = pinataApiJwt vvaConfig + fileName = fromMaybe "data.txt" mFileName -- Default to data.txt if no filename is provided + when (BSL.length fileContent > 1024 * 512) $ + throwError $ ValidationError "The uploaded file is larger than 500Kb" + eIpfsHash <- liftIO $ Ipfs.ipfsUpload vvaPinataJwt fileName fileContent + case eIpfsHash of + Left err -> throwError $ AppIpfsError err + Right ipfsHash -> return $ UploadResponse ipfsHash + mapDRepType :: Types.DRepType -> DRepType mapDRepType Types.DRep = NormalDRep mapDRepType Types.SoleVoter = SoleVoter diff --git a/govtool/backend/src/VVA/API/Types.hs b/govtool/backend/src/VVA/API/Types.hs index d14fd54cb..360f9be64 100644 --- a/govtool/backend/src/VVA/API/Types.hs +++ b/govtool/backend/src/VVA/API/Types.hs @@ -1112,6 +1112,14 @@ data GetAccountInfoResponse } deriving (Generic, Show) deriveJSON (jsonOptions "getAccountInfoResponse") ''GetAccountInfoResponse + +data UploadResponse + = UploadResponse + { uploadResponseIpfsCid :: Text + } + deriving (Generic, Show) +deriveJSON (jsonOptions "uploadResponse") ''UploadResponse + exampleGetAccountInfoResponse :: Text exampleGetAccountInfoResponse = "{\"stakeKey\": \"stake1u9\"," @@ -1125,3 +1133,14 @@ instance ToSchema GetAccountInfoResponse where & description ?~ "GetAccountInfoResponse" & example ?~ toJSON exampleGetAccountInfoResponse + +exampleUploadResponse :: Text +exampleUploadResponse = + "{\"ipfsHash\": \"QmZKLGf2D3Z3F2J2K5J2L5J2L5J2L5J2L5J2L5J2L5J2L5\"}" + +instance ToSchema UploadResponse where + declareNamedSchema _ = pure $ NamedSchema (Just "UploadResponse") $ mempty + & type_ ?~ OpenApiObject + & description ?~ "UploadResponse" + & example + ?~ toJSON exampleUploadResponse diff --git a/govtool/backend/src/VVA/Config.hs b/govtool/backend/src/VVA/Config.hs index cea9e3eb8..49151d630 100644 --- a/govtool/backend/src/VVA/Config.hs +++ b/govtool/backend/src/VVA/Config.hs @@ -32,7 +32,7 @@ import qualified Conferer.Source.Env as Env import Control.Monad.Reader -import Data.Aeson +import Data.Aeson as Aeson import qualified Data.Aeson.Encode.Pretty as AP import Data.ByteString (ByteString, toStrict) import Data.Has (Has, getter) @@ -58,7 +58,7 @@ data DBConfig -- | Port , dBConfigPort :: Int } - deriving (FromConfig, Generic, Show) + deriving (FromConfig, FromJSON, Generic, Show) instance DefaultConfig DBConfig where configDef = DBConfig "localhost" "cexplorer" "postgres" "test" 9903 @@ -79,9 +79,12 @@ data VVAConfigInternal , vVAConfigInternalSentrydsn :: String -- | Sentry environment , vVAConfigInternalSentryEnv :: String + -- | Pinata API JWT + , vVAConfigInternalPinataApiJwt :: Maybe Text } deriving (FromConfig, Generic, Show) + instance DefaultConfig VVAConfigInternal where configDef = VVAConfigInternal @@ -90,7 +93,8 @@ instance DefaultConfig VVAConfigInternal where vVAConfigInternalHost = "localhost", vVaConfigInternalCacheDurationSeconds = 20, vVAConfigInternalSentrydsn = "https://username:password@senty.host/id", - vVAConfigInternalSentryEnv = "development" + vVAConfigInternalSentryEnv = "development", + vVAConfigInternalPinataApiJwt = Nothing } -- | DEX configuration. @@ -108,6 +112,8 @@ data VVAConfig , sentryDSN :: String -- | Sentry environment , sentryEnv :: String + -- | Pinata API JWT + , pinataApiJwt :: Maybe Text } deriving (Generic, Show, ToJSON) @@ -148,7 +154,8 @@ convertConfig VVAConfigInternal {..} = serverHost = vVAConfigInternalHost, cacheDurationSeconds = vVaConfigInternalCacheDurationSeconds, sentryDSN = vVAConfigInternalSentrydsn, - sentryEnv = vVAConfigInternalSentryEnv + sentryEnv = vVAConfigInternalSentryEnv, + pinataApiJwt = vVAConfigInternalPinataApiJwt } -- | Load configuration from a file specified on the command line. Load from @@ -163,7 +170,7 @@ loadVVAConfig configFile = do where buildConfig :: IO Config buildConfig = - Conferer.mkConfig' + mkConfig' [] [ Env.fromConfig "vva", JSON.fromFilePath (fromMaybe "example-config.json" configFile) @@ -185,4 +192,4 @@ getServerPort = asks (serverPort . getter) getServerHost :: (Has VVAConfig r, MonadReader r m) => m Text -getServerHost = asks (serverHost . getter) \ No newline at end of file +getServerHost = asks (serverHost . getter) diff --git a/govtool/backend/src/VVA/Ipfs.hs b/govtool/backend/src/VVA/Ipfs.hs new file mode 100644 index 000000000..86202f298 --- /dev/null +++ b/govtool/backend/src/VVA/Ipfs.hs @@ -0,0 +1,133 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module VVA.Ipfs (ipfsUpload, IpfsError(..)) where + +import Control.Exception (SomeException, try) +import Control.Monad.IO.Class (liftIO) +import qualified Data.Aeson as A +import Data.Aeson (FromJSON(parseJSON), withObject, (.:), eitherDecode, ToJSON(..), encode,(.=),object) +import qualified Data.ByteString.Lazy as LBS +import Data.Text (Text) +import qualified Data.Text.Encoding as TE +import GHC.Generics (Generic) +import Network.HTTP.Client (newManager, parseRequest, httpLbs, method, requestHeaders, RequestBody(..), Request, responseBody, responseStatus) +import Network.HTTP.Client.TLS (tlsManagerSettings) +import Network.HTTP.Client.MultipartFormData (formDataBody, partBS, partFileRequestBody) +import Network.HTTP.Types.Status (statusIsSuccessful, Status, status503, status400) +import qualified Data.ByteString.Lazy.Char8 as LBS8 +import qualified Data.Text.Lazy as TL +import qualified Data.Text.Lazy.Encoding as TL +import qualified Data.Text as T +import Servant.Server (ServerError (errBody)) +import Servant.Exception (ToServantErr(..), Exception(..)) + + +data PinataData = PinataData + { cid :: Text + , size :: Int + , created_at :: Text + , isDuplicate :: Maybe Bool + } deriving (Show, Generic) + +instance FromJSON PinataData + +data PinataSuccessResponse = PinataSuccessResponse + { pinataData :: PinataData + } deriving (Show) + +instance FromJSON PinataSuccessResponse where + parseJSON = withObject "PinataSuccessResponse" $ \v -> PinataSuccessResponse + <$> v .: "data" + +data IpfsError + = PinataConnectionError String + | PinataAPIError Status LBS.ByteString + | PinataDecodingError String LBS.ByteString + | IpfsUnconfiguredError + | OtherIpfsError String + deriving (Show, Generic) + +instance ToJSON IpfsError where + toJSON (PinataConnectionError msg) = + object ["errorType" .= A.String "PinataConnectionError", "message" .= msg] + + toJSON (PinataAPIError status body) = + object + [ "errorType" .= A.String "PinataAPIError" + , "message" .= ("Pinata API returned error status : " ++ show status) + , "pinataResponse" .= object + [ "status" .= show status + , "body" .= TL.unpack (TL.decodeUtf8 body) + ] + ] + + toJSON (PinataDecodingError msg body) = + object + [ "errorType" .= A.String "PinataDecodingError" + , "message" .= msg + , "pinataResponse" .= object + [ "status" .= ("unknown" :: String) + , "body" .= TL.unpack (TL.decodeUtf8 body) + ] + ] + + toJSON IpfsUnconfiguredError = + object ["errorType" .= A.String "IpfsUnconfiguredError", "message" .= ("Backend is not configured for upfs upload" :: String)] + + toJSON (OtherIpfsError msg) = + object ["errorType" .= A.String "OtherIpfsError", "message" .= msg] + + +instance Exception IpfsError + + + +instance ToServantErr IpfsError where + status (OtherIpfsError _) = status400 + status _ = status503 + + message (PinataConnectionError msg) = T.pack ("Pinata service connection error: " <> msg) + message (PinataAPIError status body) = T.pack ("Pinata API error: " <> show status <> " - " <> LBS8.unpack body) + message (PinataDecodingError msg body) = T.pack ("Pinata decoding error: " <> msg <> " - " <> LBS8.unpack body) + message IpfsUnconfiguredError = T.pack ("Backend is not configured to support ipfs upload") + message (OtherIpfsError msg) = T.pack msg + +ipfsUpload :: Maybe Text -> Text -> LBS.ByteString -> IO (Either IpfsError Text) +ipfsUpload maybeJwt fileName fileContent = + case maybeJwt of + Nothing -> pure $ Left $ IpfsUnconfiguredError + Just "" -> pure $ Left $ IpfsUnconfiguredError + Just jwt -> do + manager <- newManager tlsManagerSettings + initialRequest <- parseRequest "https://uploads.pinata.cloud/v3/files" + let req = initialRequest + { method = "POST" + , requestHeaders = [("Authorization", "Bearer " <> TE.encodeUtf8 jwt)] + } + result <- try $ flip httpLbs manager =<< formDataBody + [ partBS "network" "public" + , partFileRequestBody "file" (T.unpack fileName) $ RequestBodyLBS fileContent + ] + req + + case result of + Left (e :: SomeException) -> do + let errMsg = show e + liftIO $ putStrLn errMsg + pure $ Left $ PinataConnectionError errMsg + Right response -> do + let body = responseBody response + let status = responseStatus response + if statusIsSuccessful status + then case eitherDecode body of + Left err -> do + let errMsg = "Failed to decode Pinata API reponse: " <> err + liftIO $ putStrLn errMsg + pure $ Left $ PinataDecodingError errMsg body + Right (res :: PinataSuccessResponse) -> pure $ Right $ cid $ pinataData res + else do + let errMsg = "Pinata API request failed with status: " <> show status + liftIO $ putStrLn errMsg + pure $ Left $ PinataAPIError status body diff --git a/govtool/backend/src/VVA/Types.hs b/govtool/backend/src/VVA/Types.hs index 25c248bd3..d5b0f2cf2 100644 --- a/govtool/backend/src/VVA/Types.hs +++ b/govtool/backend/src/VVA/Types.hs @@ -15,7 +15,7 @@ import Control.Monad.Except (MonadError) import Control.Monad.Fail (MonadFail) import Control.Monad.IO.Class (MonadIO) import Control.Monad.Reader (MonadReader) - +import qualified Data.Aeson as A import Data.Aeson (Value, ToJSON (..), object, (.=)) import qualified Data.Cache as Cache import Data.Has @@ -30,6 +30,7 @@ import Database.PostgreSQL.Simple.FromField (FromField(..), returnErr import VVA.Cache import VVA.Config +import VVA.Ipfs (IpfsError) type App m = (MonadReader AppEnv m, MonadIO m, MonadFail m, MonadError AppError m) @@ -57,10 +58,18 @@ data AppError | NotFoundError Text | CriticalError Text | InternalError Text + | AppIpfsError IpfsError deriving (Show) instance Exception AppError +instance ToJSON AppError where + toJSON (ValidationError msg) = object ["errorType" .= A.String "ValidationError", "message" .= msg] + toJSON (NotFoundError msg) = object ["errorType" .= A.String "NotFoundError", "message" .= msg] + toJSON (CriticalError msg) = object ["errorType" .= A.String "CriticalError", "message" .= msg] + toJSON (InternalError msg) = object ["errorType" .= A.String "InternalError", "message" .= msg] + toJSON (AppIpfsError err) = toJSON err + data Vote = Vote { voteProposalId :: Integer diff --git a/govtool/backend/stack.yaml b/govtool/backend/stack.yaml index b6238c401..8de57aa42 100644 --- a/govtool/backend/stack.yaml +++ b/govtool/backend/stack.yaml @@ -4,3 +4,4 @@ packages: extra-deps: - raven-haskell-0.1.4.1@sha256:9187272adc064197528645b5ad9b89163b668f386f34016d97fa646d5c790784 +- http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554 \ No newline at end of file diff --git a/govtool/backend/stack.yaml.lock b/govtool/backend/stack.yaml.lock index 985ab39a4..d03add71d 100644 --- a/govtool/backend/stack.yaml.lock +++ b/govtool/backend/stack.yaml.lock @@ -1,7 +1,7 @@ # This file was autogenerated by Stack. # You should not edit this file by hand. # For more information, please see the documentation at: -# https://docs.haskellstack.org/en/stable/lock_files +# https://docs.haskellstack.org/en/stable/topics/lock_files packages: - completed: @@ -11,6 +11,13 @@ packages: size: 632 original: hackage: raven-haskell-0.1.4.1@sha256:9187272adc064197528645b5ad9b89163b668f386f34016d97fa646d5c790784 +- completed: + hackage: http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554 + pantry-tree: + sha256: a35e249bf5a162c18e5fa2309c5cfcdaaead1d8fc914be029f3f1239102bd648 + size: 164 + original: + hackage: http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554 snapshots: - completed: sha256: e019cd29e3f7f9dbad500225829a3f7a50f73c674614f2f452e21bb8bf5d99ea diff --git a/govtool/backend/vva-be.cabal b/govtool/backend/vva-be.cabal index b644dad9a..8eb0de86d 100644 --- a/govtool/backend/vva-be.cabal +++ b/govtool/backend/vva-be.cabal @@ -45,13 +45,14 @@ executable vva-be -- other-modules: -- LANGUAGE extensions used by modules in this package. - -- other-extensions: - build-depends: base >=4.16 && <4.18 + -- other-extensions9 + build-depends: base >=4.16 && <4.19 , vva-be , optparse-applicative , text , servant-swagger-ui , servant-server + , servant-exceptions , servant-openapi3 , servant , wai @@ -80,7 +81,7 @@ executable vva-be library hs-source-dirs: src - build-depends: base >=4.16 && <4.18 + build-depends: base >=4.16 && <4.19 , servant-server , conferer , mtl @@ -91,6 +92,7 @@ library , bytestring , optparse-applicative , servant + , servant-exceptions , openapi3 , lens , postgresql-simple @@ -107,9 +109,11 @@ library , swagger2 , http-client , http-client-tls + , http-client-multipart , vector , async , random + , http-types exposed-modules: VVA.Config , VVA.CommandLine @@ -126,4 +130,5 @@ library , VVA.Types , VVA.Network , VVA.Account + , VVA.Ipfs ghc-options: -threaded diff --git a/govtool/frontend/src/components/molecules/VoteActionForm.tsx b/govtool/frontend/src/components/molecules/VoteActionForm.tsx index 9bc79a797..25c1807d2 100644 --- a/govtool/frontend/src/components/molecules/VoteActionForm.tsx +++ b/govtool/frontend/src/components/molecules/VoteActionForm.tsx @@ -3,7 +3,6 @@ import { Box } from "@mui/material"; import { Trans } from "react-i18next"; import { Button, Radio, Typography } from "@atoms"; -import { orange } from "@consts"; import { useModal } from "@context"; import { useScreenDimension, @@ -13,7 +12,8 @@ import { useGetVoteContextTextFromFile, } from "@hooks"; import { formatDisplayDate } from "@utils"; -import { ProposalData, ProposalVote } from "@/models"; +import { errorRed, fadedPurple } from "@/consts"; +import { ProposalData, ProposalVote, Vote } from "@/models"; import { VoteContextModalState, SubmittedVotesModalState } from "../organisms"; type VoteActionFormProps = { @@ -36,10 +36,16 @@ export const VoteActionForm = ({ useState(false); const { voter } = useGetVoterInfo(); - const { voteContextText } = useGetVoteContextTextFromFile(voteContextUrl); + const { voteContextText, valid: voteContextValid = true } = + useGetVoteContextTextFromFile(voteContextUrl, voteContextHash) || {}; - const { isMobile, screenWidth } = useScreenDimension(); - const { openModal } = useModal(); + const finalVoteContextText = + ((previousVote != null || undefined) && !voteContextUrl && !voteContextHash) + ? "" + : voteContextText; + + const { isMobile } = useScreenDimension(); + const { openModal, closeModal } = useModal(); const { t } = useTranslation(); const { @@ -50,7 +56,24 @@ export const VoteActionForm = ({ setValue, vote, canVote, - } = useVoteActionForm({ previousVote, voteContextHash, voteContextUrl }); + } = useVoteActionForm({ previousVote, voteContextHash, voteContextUrl, closeModal }); + + const handleVoteClick = (isVoteChanged:boolean) => { + openModal({ + type: "voteContext", + state: { + onSubmit: (url, hash) => { + setVoteContextUrl(url); + setVoteContextHash(hash ?? undefined); + confirmVote(vote as Vote, url, hash); + setVoteContextData(url, hash); + }, + vote: vote as Vote, + confirmVote, + previousRationale: isVoteChanged ? undefined : finalVoteContextText + } satisfies VoteContextModalState, + }); + }; const setVoteContextData = (url: string, hash: string | null) => { setVoteContextUrl(url); @@ -68,6 +91,9 @@ export const VoteActionForm = ({ if (previousVote?.url) { setVoteContextUrl(previousVote.url); } + if (previousVote?.metadataHash) { + setVoteContextHash(previousVote.metadataHash); + } }, [previousVote?.url, setVoteContextUrl]); const renderCancelButton = useMemo( @@ -91,7 +117,7 @@ export const VoteActionForm = ({ () => ( )} - - {t("optional")} - - - {voteContextText - ? t("govActions.contextAboutYourVote") - : t("govActions.youCanProvideContext")} - - {voteContextText && ( - + {t("govActions.invalidVoteContext")} + + } + {finalVoteContextText && ( + <> + {t("govActions.yourVoteRationale")} + - + {finalVoteContextText && ( + + - {voteContextText} - - - + > + {t("showMore")} + + + + )} + + )} + + + )} - + + - - {t("govActions.selectDifferentOption")} - {previousVote?.vote && previousVote?.vote !== vote ? ( ) : ( + // this button appears on gov action detail page to change vote or rationale. )} diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx index 2e5ab3def..e75f63bda 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx @@ -48,7 +48,10 @@ export const VoteContextCheckResult = ({ {errorMessage ? "Data validation failed" : "Success"} - {errorMessage ?? "Data check has been successful"} + {errorMessage ?? "GovTool has processed has your rationale"} + + + {errorMessage ?? "You can now proceed to vote."} {!errorMessage ? ( ) : ( + + + + ); +}; diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextGovTool.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextGovTool.tsx new file mode 100644 index 000000000..152464a09 --- /dev/null +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextGovTool.tsx @@ -0,0 +1,144 @@ +import { useEffect, Dispatch, SetStateAction, useState } from "react"; +import { Box, Button, CircularProgress, Link, Typography } from "@mui/material"; +import { useMutation } from "react-query"; + +import { VoteContextWrapper } from "@organisms"; +import { postIpfs } from "@services"; +import { downloadTextFile, openInNewTab } from "@utils"; +import { NodeObject } from "jsonld"; +import { UseFormSetValue } from "react-hook-form"; +import { VoteContextFormValues, useTranslation } from "@hooks"; +import { LINKS } from "@/consts/links"; +import { ICONS } from "@/consts/icons"; +import { primaryBlue } from "@/consts"; + +interface PostIpfsResponse { + ipfsCid: string; +} + +type VoteContextGovToolProps = { + setStep: Dispatch>; + setSavedHash: Dispatch>; + jsonldContent: NodeObject | null; + metadataHash: string | null; + setValue: UseFormSetValue; +}; + +export const VoteContextGovTool = ({ + setStep, + setSavedHash, + jsonldContent, + metadataHash, + setValue, +}: VoteContextGovToolProps) => { + const [apiResponse, setApiResponse] = useState(null); + const [uploadInitiated, setUploadInitiated] = useState(false); // New state to track upload + const { t } = useTranslation(); + + const openLink = () => openInNewTab(LINKS.STORING_INFORMATION_OFFLINE); + + const { mutate, isLoading, isError } = useMutation({ + mutationFn: postIpfs, + onSuccess: (data) => { + const ipfsUrl = `ipfs://${data.ipfsCid}`; + setValue("storingURL", ipfsUrl); + setSavedHash(metadataHash); // Set savedHash to metadataHash + setApiResponse(data); + }, + }); + + useEffect(() => { + if (jsonldContent && !uploadInitiated) { + mutate({ content: JSON.stringify(jsonldContent, null, 2) }); + setUploadInitiated(true); // Set flag after initiating upload + } + }, [jsonldContent, mutate, uploadInitiated]); + + const handleDownload = () => { + if (jsonldContent) { + downloadTextFile(JSON.stringify(jsonldContent, null, 2), "voteContext.jsonld"); + } + }; + + return ( + { setStep(2); }} + onContinue={() => { setStep(5); }} + useBackLabel + isContinueDisabled={!apiResponse || isError} + isVoteWithMetadata + > + + {t("createGovernanceAction.rationalePinnedToIPFS")} + + + {t("createGovernanceAction.learnMore")} + + {isError ? ( + + {t("createGovernanceAction.uploadToIPFSError")} + + ) : isLoading ? ( + + + + ) : apiResponse ? ( + <> + + {t("createGovernanceAction.optionalDownloadAndStoreMetadataFile")} + + + + {t("createGovernanceAction.rePinYourFileToIPFS")} + + + + {apiResponse.ipfsCid ? ( + + IPFS URI: {`https://ipfs.io/ipfs/${apiResponse.ipfsCid}`} + + ) : ( + "[URI]" + )} + + + + ) : ( + + {t("createGovernanceAction.uploadingToIPFS")} + + )} + + ); +}; diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx index 4ceca049a..8bd2582bf 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useForm, FormProvider } from "react-hook-form"; +import { Dispatch, SetStateAction, useState } from "react"; +import { useForm, FormProvider, UseFormReturn } from "react-hook-form"; import { ModalWrapper } from "@atoms"; import { useModal } from "@context"; @@ -8,27 +8,47 @@ import { VoteContextCheckResult, VoteContextTerms, VoteContextText, + VoteContextChoice, + VoteContextGovTool, } from "@organisms"; -import { VoteContextFormValues } from "@hooks"; +import { NodeObject } from "jsonld"; +import { VoteContextFormValues, useVoteContextForm } from "@hooks"; +import { Vote } from "@/models"; export type VoteContextModalState = { onSubmit: (url: string, hash: string | null, voteContextText: string) => void; + vote?: Vote; + previousRationale?: string + confirmVote: ( + vote?: Vote, + url?: string, + hash?: string | null, + ) => void; + // onRationaleChange : () }; export const VoteContextModal = () => { const [step, setStep] = useState(1); + const [storeDataYourself, setStoreDataYourself] = useState(true); const [savedHash, setSavedHash] = useState(""); const [errorMessage, setErrorMessage] = useState( undefined, ); + const [jsonldContent, setJsonldContent] = useState(null); + const [metadataHash, setMetadataHash] = useState(null); const { state, closeModal } = useModal(); - const methods = useForm({ mode: "onChange" }); + const methods = useForm({ + mode: "onChange", + defaultValues: { + voteContextText: state?.previousRationale || "", + }, + }); const { getValues } = methods; const submitVoteContext = () => { - if (state && savedHash) { + if (state) { state.onSubmit( getValues("storingURL"), savedHash, @@ -47,29 +67,112 @@ export const VoteContextModal = () => { }} > - {step === 1 && ( - - )} - {step === 2 && ( - - )} - {step === 3 && ( - )} - {step === 4 && ( - {})} + vote={state?.vote} + previousRationale={state?.previousRationale} /> )} ); }; + +// New component to encapsulate the flow that uses useVoteContextForm +const VoteContextFlow = ({ + step, + setStep, + storeDataYourself, + setStoreDataYourself, + setSavedHash, + setErrorMessage, + jsonldContent, + setJsonldContent, + metadataHash, + setMetadataHash, + submitVoteContext, + onCancel, + errorMessage, + methods, // Accept methods +}: { + step: number; + setStep: Dispatch>; + storeDataYourself: boolean; + setStoreDataYourself: Dispatch>; + setSavedHash: Dispatch>; + setErrorMessage: Dispatch>; + jsonldContent: NodeObject | null; + setJsonldContent: Dispatch>; + metadataHash: string | null; + setMetadataHash: Dispatch>; + submitVoteContext: () => void; + onCancel: () => void; + errorMessage: string | undefined; + methods: UseFormReturn; // Type for methods +}) => { + const { generateMetadata } = useVoteContextForm(); + + return ( + <> + {step === 2 && ( + + )} + {step === 3 && storeDataYourself && ( + + )} + {step === 3 && !storeDataYourself && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} + + ); +}; diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx index 953528fba..47f3b86e9 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx @@ -49,6 +49,8 @@ export const VoteContextStoringInformation = ({ onContinue={validateURL} isContinueDisabled={isContinueDisabled} onCancel={onCancel} + isVoteWithMetadata + useSubmitLabel > {t("createGovernanceAction.storingInformationTitle")} diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx index d59d1af38..beadd03ca 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx @@ -23,11 +23,12 @@ export const VoteContextTerms = ({ setStep, onCancel }: StoreDataInfoProps) => { return ( setStep(3)} + onContinue={() => setStep(4)} isContinueDisabled={isContinueDisabled} onCancel={onCancel} + isVoteWithMetadata > - + {t("createGovernanceAction.storeDataTitle")} >; onCancel: () => void; + confirmVote: ( + vote?: Vote, + url?: string, + hash?: string | null, + ) => void; + vote?: Vote; + previousRationale? : string }; const MAX_LENGTH = 10000; export const VoteContextText = ({ setStep, onCancel, + confirmVote, + vote, + previousRationale }: VoteContextTextProps) => { const { t } = useTranslation(); const { control, errors, watch } = useVoteContextForm(); - const isContinueDisabled = !watch("voteContextText"); + const currentRationale = watch("voteContextText"); + + const isRationaleChanged = useMemo(() => currentRationale !== previousRationale, + [currentRationale, previousRationale]); + + const buttonLabel = useMemo(() => { + if (currentRationale === "") { + return t("govActions.voting.voteWithoutMetadata"); + } + return t("govActions.voting.continue"); + }, [currentRationale, t]); const fieldProps = { layoutStyles: { mb: 3 }, name: "voteContextText", placeholder: t("govActions.provideContext"), rules: { - required: { - value: true, - message: t("createGovernanceAction.fields.validations.required"), - }, maxLength: { value: MAX_LENGTH, message: t("createGovernanceAction.fields.validations.maxLength", { @@ -38,12 +55,15 @@ export const VoteContextText = ({ }, }, }; - return ( setStep(2)} - isContinueDisabled={isContinueDisabled} + isVoteWithMetadata={currentRationale !== ""} onCancel={onCancel} + onSkip={() => confirmVote(vote)} + continueLabel={buttonLabel} + isContinueDisabled={(previousRationale !== undefined && previousRationale !== null) + && !isRationaleChanged} > ); diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx index 91ceb8ed5..6f56dda1f 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx @@ -5,17 +5,33 @@ import { useScreenDimension, useTranslation } from "@hooks"; import { Button } from "@atoms"; type VoteContextWrapperProps = { - onContinue: () => void; - isContinueDisabled?: boolean; - onCancel: () => void; + onContinue?: () => void; + isVoteWithMetadata?: boolean; + onCancel?: () => void; + hideAllBtn?: boolean; + useBackLabel?: boolean; + useSubmitLabel?: boolean; + onSkip?: () => void; + continueLabel?: string; + isContinueDisabled?:boolean }; export const VoteContextWrapper: FC< PropsWithChildren -> = ({ onContinue, isContinueDisabled, onCancel, children }) => { +> = ({ + onContinue, + isVoteWithMetadata, + onCancel, + children, + hideAllBtn = false, + useBackLabel = false, + useSubmitLabel = false, + onSkip, + continueLabel, + isContinueDisabled +}) => { const { isMobile } = useScreenDimension(); const { t } = useTranslation(); - return ( <> {children} - - - - + { + !hideAllBtn && + + + + + } ); }; diff --git a/govtool/frontend/src/components/organisms/index.ts b/govtool/frontend/src/components/organisms/index.ts index add7263b4..9ef017f4e 100644 --- a/govtool/frontend/src/components/organisms/index.ts +++ b/govtool/frontend/src/components/organisms/index.ts @@ -28,5 +28,7 @@ export * from "./UncontrolledImageInput"; export * from "./ValidatedGovernanceActionCard"; export * from "./ValidatedGovernanceVotedOnCard"; export * from "./VoteContext"; +export * from "./VoteContext/VoteContextChoice"; +export * from "./VoteContext/VoteContextGovTool"; export * from "./WrongRouteInfo"; export * from "./MaintenanceEndingBanner"; diff --git a/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx b/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx index 343ff146f..8d25e7804 100644 --- a/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx +++ b/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx @@ -7,7 +7,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { PATHS } from "@consts"; import { useCardano, useSnackbar } from "@context"; import { useWalletErrorModal } from "@hooks"; -import { ProposalVote } from "@/models"; +import { ProposalVote, Vote } from "@/models"; export interface VoteActionFormValues { vote: string; @@ -33,12 +33,12 @@ type Props = { previousVote?: ProposalVote | null; voteContextHash?: string; voteContextUrl?: string; + closeModal: () => void; }; export const useVoteActionForm = ({ previousVote, - voteContextHash, - voteContextUrl, + closeModal, }: Props) => { const [isLoading, setIsLoading] = useState(false); const { buildSignSubmitConwayCertTx, buildVote, isPendingTransaction } = @@ -52,7 +52,6 @@ export const useVoteActionForm = ({ const { control, - handleSubmit, formState: { errors, isDirty }, setValue, register: registerInput, @@ -71,52 +70,60 @@ export const useVoteActionForm = ({ index !== null && !areFormErrors; - const confirmVote = useCallback( - async (values: VoteActionFormValues) => { - if (!canVote) return; - - setIsLoading(true); - - const urlSubmitValue = voteContextUrl ?? ""; - const hashSubmitValue = voteContextHash ?? ""; - - try { - const isPendingTx = isPendingTransaction(); - if (isPendingTx) return; - const votingBuilder = await buildVote( - values.vote, - txHash, - index, - urlSubmitValue, - hashSubmitValue, - ); - const result = await buildSignSubmitConwayCertTx({ - votingBuilder, - type: "vote", - resourceId: txHash + index, - }); - if (result) { - addSuccessAlert("Vote submitted"); - navigate(PATHS.dashboardGovernanceActions, { - state: { - isVotedListOnLoad: !!previousVote?.vote, - }, - }); - } - } catch (error) { - openWalletErrorModal({ - error, - dataTestId: "vote-transaction-error-modal", + const confirmVote = useCallback( + async ( + voteValue?: Vote, + url?: string, + hashValue?: string | null, + ) => { + if (!canVote || !voteValue) return; + + setIsLoading(true); + + const urlSubmitValue = url ?? ""; + const hashSubmitValue = hashValue ?? ""; + + try { + const isPendingTx = isPendingTransaction(); + if (isPendingTx) return; + + const votingBuilder = await buildVote( + voteValue, + txHash, + index, + urlSubmitValue, + hashSubmitValue, + ); + + const result = await buildSignSubmitConwayCertTx({ + votingBuilder, + type: "vote", + resourceId: txHash + index, + }); + + if (result) { + addSuccessAlert("Vote submitted"); + navigate(PATHS.dashboardGovernanceActions, { + state: { + isVotedListOnLoad: !!previousVote?.vote, + }, }); - } finally { - setIsLoading(false); + closeModal(); } - }, + } catch (error) { + openWalletErrorModal({ + error, + dataTestId: "vote-transaction-error-modal", + }); + } finally { + setIsLoading(false); + } + }, [buildVote, buildSignSubmitConwayCertTx, txHash, index, canVote], ); return { - confirmVote: handleSubmit(confirmVote), + confirmVote, setValue, vote, registerInput, diff --git a/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx b/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx index e7f8c0144..e53fb168c 100644 --- a/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx +++ b/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx @@ -51,7 +51,7 @@ export const useVoteContextForm = ( setHash(jsonHash); setJson(jsonld); - return jsonld; + return { jsonld, jsonHash }; }, [getValues]); const onClickDownloadJson = () => { @@ -79,11 +79,11 @@ export const useVoteContextForm = ( } catch (error: any) { if (Object.values(MetadataValidationStatus).includes(error)) { if (setErrorMessage) setErrorMessage(error); - if (setStep) setStep(4); + if (setStep) setStep(5); } } finally { if (setSavedHash) setSavedHash(hash); - if (setStep) setStep(4); + if (setStep) setStep(5); } }, [hash], @@ -102,5 +102,6 @@ export const useVoteContextForm = ( setValue, watch, hash, + json, }; }; diff --git a/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts b/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts index cab05659c..7fc7f8f5c 100644 --- a/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts +++ b/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts @@ -5,20 +5,43 @@ import { QUERY_KEYS } from "@/consts"; import { useCardano } from "@/context"; import { useGetVoterInfo } from "."; -export const useGetVoteContextTextFromFile = (url: string | undefined) => { +export const useGetVoteContextTextFromFile = (url: string | undefined, + contextHash : string | undefined) => { const { dRepID } = useCardano(); const { voter } = useGetVoterInfo(); const { data, isLoading } = useQuery( - [QUERY_KEYS.useGetVoteContextFromFile, url], - () => getVoteContextTextFromFile(url), - { - enabled: - !!url && - !!dRepID && - (!!voter?.isRegisteredAsDRep || !!voter?.isRegisteredAsSoleVoter), - }, + [QUERY_KEYS.useGetVoteContextFromFile, url], + () => getVoteContextTextFromFile(url, contextHash), + { + enabled: + !!url && + !!dRepID && + (!!voter?.isRegisteredAsDRep || !!voter?.isRegisteredAsSoleVoter), + }, ); - return { voteContextText: data, isLoading }; + const voteContextText = (data?.metadata as { comment?: string })?.comment || ""; + + if (url === undefined || contextHash === undefined) { + return { + voteContextText: undefined, + isLoading: false, + valid: true + }; + } + if (data) { + if (data?.valid) { + return { + voteContextText, + isLoading, + valid: true + }; + } + return { + voteContextText: undefined, + isLoading, + valid: false + }; + } }; diff --git a/govtool/frontend/src/i18n/locales/en.json b/govtool/frontend/src/i18n/locales/en.json index 73836a465..a60dfb632 100644 --- a/govtool/frontend/src/i18n/locales/en.json +++ b/govtool/frontend/src/i18n/locales/en.json @@ -243,15 +243,33 @@ "storingInformationStep2Link": "Read full guide", "storingInformationStep3Label": "Paste the URL here", "storingInformationTitle": "Information Storage Steps", + "uploadToIPFSError" : "An Error occured while trying to upload to IPFS", "storingInformationURLPlaceholder": "URL", "supportingLinks": "Supporting links", "title": "Create a Governance Action", + "storeAndMaintainDataTitle": "Store and Maintain the Data Yourself", + "learnMoreAboutStoringInformation": "Learn more about storing information", + "govToolProvidesOptions": "GovTool currently provides two options for storing your rationale:", + "govToolCanPinToIPFS": "GovTool can pin it to IPFS", + "storeYourselfInRepo": "Store it yourself in a repository such as GitHub", + "storingOptionsForYourVoterRationale" : "Storing options for your voter rationale", + "chooseDataStorageOption": "Choose a data storage option:", + "govToolPinsDataToIPFS": "GovTool pins data to IPFS", + "downloadAndStoreYourself": "Download and store yourself", + "uploadingToIPFS": "Uploading to IPFS...", + "rationalePinnedToIPFS": "GovTool has pinned your rationale to IPFS", + "learnMore" : "Learn more", + "optionalDownloadAndStoreMetadataFile": "Optional: Download and store a backup copy of your metadata file", + "rePinYourFileToIPFS": "Re-pin your file to IPFS", + "oneMomentPlease" : "One Moment please", "modals": { "submitTransactionSuccess": { "message": "Your Governance Action may take a little time to submit to the chain.", "title": "Governance Action submitted!" } - } + }, + "back" : "Back", + "continue" : "Continue" }, "delegation": { "description": "You can delegate your voting power to a DRep or to a pre-defined voting option.", @@ -434,9 +452,11 @@ "castVote": "<0>You voted {{vote}} on this proposal\non {{date}} (Epoch {{epoch}})", "castVoteDeadline": "You can change your vote up to {{date}} UTC (Epoch {{epoch}})", "changeVote": "Change vote", + "changeRationale": "Change Rationale", "changeYourVote": "Change your vote", "chooseHowToVote": "Choose how you want to vote:", - "contextAboutYourVote": "Context about your vote", + "yourVoteRationale": "Your Vote Rationale", + "invalidVoteContext": "Invalid Vote Context", "dataMissing": "Data Missing", "dataMissingTooltipExplanation": "Please click “View Details” for more information.", "details": "Governance Details:", @@ -450,6 +470,11 @@ "governanceActionId": "Legacy Governance Action ID (CIP-105):", "cip129GovernanceActionId": "Governance Action ID:", "governanceActionType": "Governance Action Type:", + "voting":{ + "submitVote": "Submit Vote", + "continue": "Continue", + "voteWithoutMetadata": "Submit Vote" + }, "goToVote": "Go to Vote", "membersToBeRemovedFromTheCommittee": "Members to be removed from the Committee", "membersToBeAddedToTheCommittee": "Members to be added to the Committee", diff --git a/govtool/frontend/src/models/metadataValidation.ts b/govtool/frontend/src/models/metadataValidation.ts index cbd6356ee..ea5d7bb79 100644 --- a/govtool/frontend/src/models/metadataValidation.ts +++ b/govtool/frontend/src/models/metadataValidation.ts @@ -9,6 +9,7 @@ export enum MetadataValidationStatus { export enum MetadataStandard { CIP108 = "CIP108", CIP119 = "CIP119", + CIP100 = "CIP100" } export type ValidateMetadataResult = { diff --git a/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts b/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts index 0690e03b7..32778cf8b 100644 --- a/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts +++ b/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts @@ -1,11 +1,16 @@ -import axios from "axios"; +import { postValidate } from "./metadataValidation"; +import { MetadataStandard } from "@/models"; -export const getVoteContextTextFromFile = async (url: string | undefined) => { - if (!url) { - throw new Error("URL is undefined"); +export const getVoteContextTextFromFile = async (url: string | undefined, + contextHash : string | undefined) => { + if (!url || !contextHash) { + throw new Error("Missing Vote Context values"); } + const response = await postValidate({ + standard: MetadataStandard.CIP100, + url, + hash: contextHash + }); - const response = await axios.get(url); - - return response.data.body?.body?.comment ?? ""; + return { valid: response.valid, metadata: response.metadata }; }; diff --git a/govtool/frontend/src/services/requests/index.ts b/govtool/frontend/src/services/requests/index.ts index 598fe76d3..5887c0ee9 100644 --- a/govtool/frontend/src/services/requests/index.ts +++ b/govtool/frontend/src/services/requests/index.ts @@ -21,5 +21,6 @@ export * from "./postDRepRegister"; export * from "./postDRepRemoveVote"; export * from "./postDRepRetire"; export * from "./postDRepVote"; +export * from "./postIpfs"; export * from "./getDRepVotingPowerList"; export * from "./getAccount"; diff --git a/govtool/frontend/src/services/requests/postIpfs.ts b/govtool/frontend/src/services/requests/postIpfs.ts new file mode 100644 index 000000000..346dff3c4 --- /dev/null +++ b/govtool/frontend/src/services/requests/postIpfs.ts @@ -0,0 +1,10 @@ +import { API } from "../API"; + +export const postIpfs = async ({ content }: { content: string }) => { + const response = await API.post("/ipfs/upload", content, { + headers: { + "Content-Type": "text/plain;charset=utf-8" + } + }); + return response.data; +}; diff --git a/govtool/frontend/src/utils/clipboard.ts b/govtool/frontend/src/utils/clipboard.ts new file mode 100644 index 000000000..63e04ad3d --- /dev/null +++ b/govtool/frontend/src/utils/clipboard.ts @@ -0,0 +1,3 @@ +export const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); +}; diff --git a/govtool/frontend/src/utils/index.ts b/govtool/frontend/src/utils/index.ts index f331202b3..9ab32b8f0 100644 --- a/govtool/frontend/src/utils/index.ts +++ b/govtool/frontend/src/utils/index.ts @@ -39,3 +39,4 @@ export * from "./getBase64ImageDetails"; export * from "./parseBoolean"; export * from "./validateSignature"; export * from "./cip8verification"; +export * from "./clipboard";