diff --git a/explorer/package-lock.json b/explorer/package-lock.json index dcf4f29174..ded50e8ba3 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -9657,6 +9657,22 @@ "prepend-http": "^1.0.0", "query-string": "^4.1.0", "sort-keys": "^1.0.0" + }, + "dependencies": { + "query-string": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", + "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", + "requires": { + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + }, + "strict-uri-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" + } } }, "npm-run-all": { @@ -11446,15 +11462,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, - "query-string": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", - "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", - "requires": { - "object-assign": "^4.1.0", - "strict-uri-encode": "^1.0.0" - } - }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -13175,11 +13182,6 @@ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, - "strict-uri-encode": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", - "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" - }, "string-length": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index a7f46b18fb..a39f3957c2 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -21,7 +21,7 @@ function App() {
- + ({ ...location, pathname: "/" })}> Solana Explorer
diff --git a/explorer/src/components/AccountsCard.tsx b/explorer/src/components/AccountsCard.tsx index 1b35e21214..3b14310a1c 100644 --- a/explorer/src/components/AccountsCard.tsx +++ b/explorer/src/components/AccountsCard.tsx @@ -125,8 +125,8 @@ const renderAccountRow = ( break; case Status.CheckFailed: case Status.HistoryFailed: - statusClass = "danger"; - statusText = "Error"; + statusClass = "dark"; + statusText = "Cluster Error"; break; case Status.Checking: case Status.FetchingHistory: diff --git a/explorer/src/components/ClusterModal.tsx b/explorer/src/components/ClusterModal.tsx index c916015055..5ce8c418fd 100644 --- a/explorer/src/components/ClusterModal.tsx +++ b/explorer/src/components/ClusterModal.tsx @@ -1,11 +1,12 @@ import React from "react"; +import { Link, useLocation, useHistory } from "react-router-dom"; +import { Location } from "history"; import { useCluster, - useClusterDispatch, - updateCluster, ClusterStatus, clusterUrl, clusterName, + clusterSlug, CLUSTERS, Cluster, useClusterModal @@ -45,19 +46,35 @@ function ClusterModal() { type InputProps = { activeSuffix: string; active: boolean }; function CustomClusterInput({ activeSuffix, active }: InputProps) { const { customUrl } = useCluster(); - const dispatch = useClusterDispatch(); const [editing, setEditing] = React.useState(false); + const history = useHistory(); + const location = useLocation(); const customClass = (prefix: string) => active ? `${prefix}-${activeSuffix}` : ""; + const clusterLocation = (location: Location, url: string) => { + const params = new URLSearchParams(location.search); + params.set("clusterUrl", url); + params.delete("cluster"); + return { + ...location, + search: params.toString() + }; + }; + + const updateCustomUrl = React.useCallback( + (url: string) => { + history.push(clusterLocation(location, url)); + }, + [history, location] + ); + const inputTextClass = editing ? "" : "text-muted"; return ( -
clusterLocation(location, customUrl)} className="btn input-group input-group-merge p-0" - onClick={() => - !active && updateCluster(dispatch, Cluster.Custom, customUrl) - } > setEditing(true)} onBlur={() => setEditing(false)} - onInput={e => - updateCluster(dispatch, Cluster.Custom, e.currentTarget.value) - } + onInput={e => updateCustomUrl(e.currentTarget.value)} />
Custom:
-
+ ); } function ClusterToggle() { const { status, cluster, customUrl } = useCluster(); - const dispatch = useClusterDispatch(); let activeSuffix = ""; switch (status) { @@ -116,21 +130,35 @@ function ClusterToggle() { ? `border-${activeSuffix} text-${activeSuffix}` : "btn-white text-dark"; + const clusterLocation = (location: Location) => { + const params = new URLSearchParams(location.search); + const slug = clusterSlug(net); + if (slug && slug !== "mainnet-beta") { + params.set("cluster", slug); + params.delete("clusterUrl"); + } else { + params.delete("cluster"); + if (slug === "mainnet-beta") { + params.delete("clusterUrl"); + } + } + return { + ...location, + search: params.toString() + }; + }; + return ( - + ); })}
diff --git a/explorer/src/components/Copyable.tsx b/explorer/src/components/Copyable.tsx index 594afceb05..74863ee6ac 100644 --- a/explorer/src/components/Copyable.tsx +++ b/explorer/src/components/Copyable.tsx @@ -3,22 +3,23 @@ import React, { useState, ReactNode } from "react"; type CopyableProps = { text: string; children: ReactNode; + bottom?: boolean; }; type State = "hide" | "copy" | "copied"; -function Popover({ state }: { state: State }) { +function Popover({ state, bottom }: { state: State; bottom?: boolean }) { if (state === "hide") return null; const text = state === "copy" ? "Copy" : "Copied!"; return ( -
+
{text}
); } -function Copyable({ text, children }: CopyableProps) { +function Copyable({ bottom, text, children }: CopyableProps) { const [state, setState] = useState("hide"); const copyToClipboard = () => navigator.clipboard.writeText(text); @@ -29,15 +30,14 @@ function Copyable({ text, children }: CopyableProps) { }); return ( -
-
setState("copy")} - onMouseOut={() => state === "copy" && setState("hide")} - > - {children} -
- +
setState("copy")} + onMouseOut={() => state === "copy" && setState("hide")} + > + {children} +
); } diff --git a/explorer/src/components/TabbedPage.tsx b/explorer/src/components/TabbedPage.tsx index 0cc6a72e59..4c1ebba9a3 100644 --- a/explorer/src/components/TabbedPage.tsx +++ b/explorer/src/components/TabbedPage.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Link, useLocation } from "react-router-dom"; +import { Link } from "react-router-dom"; import { useClusterModal } from "providers/cluster"; import ClusterStatusButton from "components/ClusterStatusButton"; @@ -54,14 +54,16 @@ function NavLink({ tab: Tab; current: Tab; }) { - const location = useLocation(); let classes = "nav-link"; if (tab === current) { classes += " active"; } return ( - + ({ ...location, pathname: href })} + className={classes} + > {tab} ); diff --git a/explorer/src/components/TransactionDetails.tsx b/explorer/src/components/TransactionDetails.tsx index 038482a9ee..c482165fe8 100644 --- a/explorer/src/components/TransactionDetails.tsx +++ b/explorer/src/components/TransactionDetails.tsx @@ -1,4 +1,5 @@ import React from "react"; +import bs58 from "bs58"; import { Source, useTransactionStatus, @@ -16,7 +17,8 @@ import { TransactionInstruction, TransferParams, CreateAccountParams, - SystemProgram + SystemProgram, + SignatureResult } from "@solana/web3.js"; import ClusterStatusButton from "components/ClusterStatusButton"; import { lamportsToSolString } from "utils"; @@ -123,73 +125,51 @@ function TransactionStatusCard({ signature }: Props) { let statusClass = "success"; let statusText = "Success"; if (info.result.err) { - statusClass = "danger"; + statusClass = "warning"; statusText = "Error"; } return ( - {statusText} +

+ {statusText} +

); }; const fee = details?.transaction?.meta?.fee; return (
-
-
-
-
Status
-
-
- -
-
+
+

Transaction Status

+
-
-
-
-
-
-
Result
-
-
{renderResult()}
-
-
-
-
-
-
Block
-
-
{info.slot}
-
-
+ + + Result + {renderResult()} + -
-
-
-
Confirmations
-
-
- {info.confirmations} -
-
-
- {fee && ( -
-
-
-
Fee (SOL)
-
-
{lamportsToSolString(fee)}
-
-
- )} -
-
+ + Block + {info.slot} + + + + Confirmations + {info.confirmations} + + + {fee && ( + + Fee (SOL) + {lamportsToSolString(fee)} + + )} +
); } @@ -265,7 +245,7 @@ function TransactionAccountsCard({ signature }: Props) { return (
-

Accounts

+

Transaction Accounts

@@ -284,34 +264,108 @@ function TransactionAccountsCard({ signature }: Props) { ); } +function ixResult(result: SignatureResult, index: number) { + if (result.err) { + const err = result.err as any; + const ixError = err["InstructionError"]; + if (ixError && Array.isArray(ixError)) { + const [errorIndex, error] = ixError; + if (Number.isInteger(errorIndex) && errorIndex === index) { + return ["warning", `"${JSON.stringify(error)}"`]; + } + } + return ["dark"]; + } + return ["success"]; +} + +type InstructionProps = { + title: string; + children: React.ReactNode; + result: SignatureResult; + index: number; +}; + +function InstructionCard({ title, children, result, index }: InstructionProps) { + const [resultClass, errorString] = ixResult(result, index); + return ( +
+
+

+ + #{index + 1} + + {title} Instruction +

+

+ + {errorString} + +

+
+ {children} +
+ ); +} + function TransactionInstructionsCard({ signature }: Props) { + const status = useTransactionStatus(signature); const details = useTransactionDetails(signature); const dispatch = useDetailsDispatch(); const { url } = useCluster(); const refreshDetails = () => fetchDetails(dispatch, signature, url); - if (!details || !details.transaction) return null; + if (!status || !status.info || !details || !details.transaction) return null; const { transaction } = details.transaction; if (transaction.instructions.length === 0) { return ; } + const result = status.info.result; const instructionDetails = transaction.instructions.map((ix, index) => { const transfer = decodeTransfer(ix); - if (transfer) - return ; + if (transfer) { + return ( + + + + ); + } + const create = decodeCreate(ix); - if (create) - return ; - return ; + if (create) { + return ( + + + + ); + } + + return ( + + + + ); }); return ( <>
-
Transaction Instruction(s)
+
+

Transaction Instruction(s)

+
{instructionDetails} @@ -320,140 +374,186 @@ function TransactionInstructionsCard({ signature }: Props) { } function TransferDetails({ - transfer, - index + ix, + transfer }: { + ix: TransactionInstruction; transfer: TransferParams; - index: number; }) { const from = transfer.fromPubkey.toBase58(); const to = transfer.toPubkey.toBase58(); + const [fromMeta, toMeta] = ix.keys; return ( -
-
-

- #{index + 1} - Transfer -

-
-
-
- + <> +
+ + + + + + + + + + + + + + + + + + + ); } function CreateDetails({ - create, - index + ix, + create }: { + ix: TransactionInstruction; create: CreateAccountParams; - index: number; }) { const from = create.fromPubkey.toBase58(); const newKey = create.newAccountPubkey.toBase58(); + const [fromMeta, newMeta] = ix.keys; + return ( -
-
-

- #{index + 1} - Create Account -

-
-
-
- + <> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } -function InstructionDetails({ - ix, - index -}: { - ix: TransactionInstruction; - index: number; -}) { +function InstructionDetails({ ix }: { ix: TransactionInstruction }) { return ( -
-
-

- #{index + 1} -

-
-
-
- + <> +
+ + + -function ListGroupItem({ - label, - children -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
-
-
-
{label}
-
-
{children}
-
-
+ {ix.keys.map(({ pubkey, isSigner, isWritable }, keyIndex) => ( + + + + + ))} + + + + + + ); } @@ -480,3 +580,13 @@ function RetryCard({ retry, text }: { retry: () => void; text: string }) { ); } + +function TableCardBody({ children }: { children: React.ReactNode }) { + return ( +
+
Program + {displayAddress(SystemProgram.programId)} - - - - {from} - - - - - {to} - - - - {lamportsToSolString(transfer.lamports)} - - - - + +
+
From Account
+ {!fromMeta.isWritable && ( + Readonly + )} + {fromMeta.isSigner && ( + Signer + )} +
+ + {from} + +
+
To Account
+ {!toMeta.isWritable && ( + Readonly + )} + {toMeta.isSigner && ( + Signer + )} +
+ + {to} + +
Transfer Amount (SOL){lamportsToSolString(transfer.lamports)}
Program + {displayAddress(SystemProgram.programId)} - - - - {from} - - - - - {newKey} - - - - {lamportsToSolString(create.lamports)} - - {create.space} - + +
+
From Account
+ {!fromMeta.isWritable && ( + Readonly + )} + {fromMeta.isSigner && ( + Signer + )} +
+ + {from} + +
+
New Account
+ {!newMeta.isWritable && ( + Readonly + )} + {newMeta.isSigner && ( + Signer + )} +
+ + {newKey} + +
Transfer Amount (SOL){lamportsToSolString(create.lamports)}
Allocated Space (Bytes){create.space}
Assigned Owner + {displayAddress(create.programId)} - - - - + +
Program + {displayAddress(ix.programId)} - - {ix.keys.map(({ pubkey }, keyIndex) => ( - - - {pubkey.toBase58()} - - - ))} - {ix.data.length} - - - - ); -} + +
+
Account #{keyIndex + 1}
+ {!isWritable && ( + Readonly + )} + {isSigner && ( + Signer + )} +
+ + {pubkey.toBase58()} + +
Raw Data (Base58) + + {bs58.encode(ix.data)} + +
+ {children} +
+
+ ); +} diff --git a/explorer/src/components/TransactionsCard.tsx b/explorer/src/components/TransactionsCard.tsx index cbd7b3de8d..1e3e9fad9e 100644 --- a/explorer/src/components/TransactionsCard.tsx +++ b/explorer/src/components/TransactionsCard.tsx @@ -13,7 +13,6 @@ import bs58 from "bs58"; import { assertUnreachable } from "../utils"; import { useCluster } from "../providers/cluster"; import Copyable from "./Copyable"; -import { useHistory, useLocation } from "react-router-dom"; function TransactionsCard() { const { transactions, idCounter } = useTransactions(); @@ -21,7 +20,6 @@ function TransactionsCard() { const signatureInput = React.useRef(null); const [error, setError] = React.useState(""); const { url } = useCluster(); - const location = useLocation(); const onNew = (signature: string) => { if (signature.length === 0) return; @@ -101,7 +99,7 @@ function TransactionsCard() { {transactions.map(transaction => - renderTransactionRow(transaction, dispatch, location, url) + renderTransactionRow(transaction, dispatch, url) )} @@ -125,7 +123,6 @@ const renderHeader = () => { const renderTransactionRow = ( transactionStatus: TransactionStatus, dispatch: any, - location: any, url: string ) => { const { fetchStatus, info, signature, id } = transactionStatus; @@ -169,7 +166,7 @@ const renderTransactionRow = ( if (info?.confirmations === "max") { return ( ({ ...location, pathname: "/tx/" + signature })} className="btn btn-rounded-circle btn-white btn-sm" > diff --git a/explorer/src/providers/cluster.tsx b/explorer/src/providers/cluster.tsx index 4b6f9af11c..0083f71ec5 100644 --- a/explorer/src/providers/cluster.tsx +++ b/explorer/src/providers/cluster.tsx @@ -1,6 +1,6 @@ import React from "react"; import { clusterApiUrl, Connection } from "@solana/web3.js"; -import { findGetParameter } from "../utils/url"; +import { useQuery } from "../utils/url"; export enum ClusterStatus { Connected, @@ -22,6 +22,19 @@ export const CLUSTERS = [ Cluster.Custom ]; +export function clusterSlug(cluster: Cluster): string | undefined { + switch (cluster) { + case Cluster.MainnetBeta: + return "mainnet-beta"; + case Cluster.Testnet: + return "testnet"; + case Cluster.Devnet: + return "devnet"; + case Cluster.Custom: + return undefined; + } +} + export function clusterName(cluster: Cluster): string { switch (cluster) { case Cluster.MainnetBeta: @@ -77,11 +90,11 @@ function clusterReducer(state: State, action: Action): State { } } -function initState(): State { - const clusterParam = - findGetParameter("cluster") || findGetParameter("network"); - const clusterUrlParam = - findGetParameter("clusterUrl") || findGetParameter("networkUrl"); +function parseQuery( + query: URLSearchParams +): { cluster: Cluster; customUrl: string } { + const clusterParam = query.get("cluster"); + const clusterUrlParam = query.get("clusterUrl"); let cluster; let customUrl = DEFAULT_CUSTOM_URL; @@ -120,8 +133,7 @@ function initState(): State { return { cluster, - customUrl, - status: ClusterStatus.Connecting + customUrl }; } @@ -134,17 +146,18 @@ const DispatchContext = React.createContext(undefined); type ClusterProviderProps = { children: React.ReactNode }; export function ClusterProvider({ children }: ClusterProviderProps) { - const [state, dispatch] = React.useReducer( - clusterReducer, - undefined, - initState - ); + const [state, dispatch] = React.useReducer(clusterReducer, { + cluster: DEFAULT_CLUSTER, + customUrl: DEFAULT_CUSTOM_URL, + status: ClusterStatus.Connecting + }); const [showModal, setShowModal] = React.useState(false); + const { cluster, customUrl } = parseQuery(useQuery()); + // Reconnect to cluster when it changes React.useEffect(() => { - // Connect to cluster immediately - updateCluster(dispatch, state.cluster, state.customUrl); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + updateCluster(dispatch, cluster, customUrl); + }, [cluster, customUrl]); return ( @@ -170,7 +183,7 @@ export function clusterUrl(cluster: Cluster, customUrl: string): string { } } -export async function updateCluster( +async function updateCluster( dispatch: Dispatch, cluster: Cluster, customUrl: string @@ -203,14 +216,6 @@ export function useCluster() { }; } -export function useClusterDispatch() { - const context = React.useContext(DispatchContext); - if (!context) { - throw new Error(`useClusterDispatch must be used within a ClusterProvider`); - } - return context; -} - export function useClusterModal() { const context = React.useContext(ModalContext); if (!context) { diff --git a/explorer/src/providers/transactions/details.tsx b/explorer/src/providers/transactions/details.tsx index f1d1bcdd33..fa218ca319 100644 --- a/explorer/src/providers/transactions/details.tsx +++ b/explorer/src/providers/transactions/details.tsx @@ -4,7 +4,7 @@ import { TransactionSignature, ConfirmedTransaction } from "@solana/web3.js"; -import { useCluster, ClusterStatus } from "../cluster"; +import { useCluster } from "../cluster"; import { useTransactions, FetchStatus } from "./index"; export interface Details { @@ -94,7 +94,7 @@ export function DetailsProvider({ children }: DetailsProviderProps) { const [state, dispatch] = React.useReducer(reducer, {}); const { transactions } = useTransactions(); - const { status, url } = useCluster(); + const { url } = useCluster(); // Filter blocks for current transaction slots React.useEffect(() => { @@ -111,15 +111,13 @@ export function DetailsProvider({ children }: DetailsProviderProps) { removeSignatures.forEach(s => removeList.push(s)); dispatch({ type: ActionType.Remove, signatures: removeList }); - if (status !== ClusterStatus.Connected) return; - const fetchList: string[] = []; fetchSignatures.forEach(s => fetchList.push(s)); dispatch({ type: ActionType.Add, signatures: fetchList }); fetchSignatures.forEach(signature => { fetchDetails(dispatch, signature, url); }); - }, [status, transactions]); // eslint-disable-line react-hooks/exhaustive-deps + }, [transactions]); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/explorer/src/providers/transactions/index.tsx b/explorer/src/providers/transactions/index.tsx index 9dba7edb59..82e549f486 100644 --- a/explorer/src/providers/transactions/index.tsx +++ b/explorer/src/providers/transactions/index.tsx @@ -6,8 +6,8 @@ import { Account, SignatureResult } from "@solana/web3.js"; -import { findGetParameter } from "../../utils/url"; -import { useCluster, ClusterStatus } from "../cluster"; +import { useQuery } from "../../utils/url"; +import { useCluster, Cluster } from "../cluster"; import { DetailsProvider, StateContext as DetailsStateContext, @@ -20,7 +20,6 @@ import { Dispatch as AccountsDispatch, ActionType as AccountsActionType } from "../accounts"; -import { useLocation } from "react-router-dom"; export enum FetchStatus { Fetching, @@ -128,14 +127,12 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) { transactions: {} }); - const { status, url } = useCluster(); + const { cluster, url } = useCluster(); const accountsDispatch = useAccountsDispatch(); - const search = useLocation().search; + const query = useQuery(); // Check transaction statuses whenever cluster updates React.useEffect(() => { - if (status !== ClusterStatus.Connected) return; - Object.keys(state.transactions).forEach(signature => { dispatch({ type: ActionType.FetchSignature, @@ -146,19 +143,18 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) { }); // Create a test transaction - if (findGetParameter("test") !== null) { + if (cluster === Cluster.Devnet && query.get("test") !== null) { createTestTransaction(dispatch, accountsDispatch, url); } - }, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps + }, [query, cluster, url]); // eslint-disable-line react-hooks/exhaustive-deps // Check for transactions in the url params React.useEffect(() => { - TX_ALIASES.flatMap(key => - (findGetParameter(key)?.split(",") || []).concat( - findGetParameter(key + "s")?.split(",") || [] - ) - ) - .flatMap(paramValue => paramValue?.split(",") || []) + TX_ALIASES.flatMap(key => [query.get(key), query.get(key + "s")]) + .filter((value): value is string => value !== null) + .flatMap(value => value.split(",")) + // Remove duplicates + .filter((item, pos, self) => self.indexOf(item) === pos) .filter(signature => !state.transactions[signature]) .forEach(signature => { dispatch({ @@ -168,7 +164,7 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) { }); checkTransactionStatus(dispatch, signature, url); }); - }, [search]); // eslint-disable-line react-hooks/exhaustive-deps + }, [query]); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss index 6d4a27e3be..49a8538032 100644 --- a/explorer/src/scss/_solana.scss +++ b/explorer/src/scss/_solana.scss @@ -12,15 +12,19 @@ code { .copyable { position: relative; - width: fit-content; + display: inline; + cursor: pointer; - & > div:hover { - cursor: pointer; - } + .popover { + &.bs-popover-top { + background-color: $dark; + top: -4rem; + } - .popover.bs-popover-top { - background-color: $dark; - top: -4rem; + &.bs-popover-bottom { + background-color: $dark; + top: 1.5rem; + } .popover-body { color: white; @@ -28,6 +32,7 @@ code { .arrow::after { border-top-color: $dark; + border-bottom-color: $dark; } } } diff --git a/explorer/src/utils/url.ts b/explorer/src/utils/url.ts index 846b7571e3..207d0ce646 100644 --- a/explorer/src/utils/url.ts +++ b/explorer/src/utils/url.ts @@ -1,3 +1,9 @@ +import { useLocation } from "react-router-dom"; + +export function useQuery() { + return new URLSearchParams(useLocation().search); +} + export function findGetParameter(parameterName: string): string | null { let result = null, tmp = [];