diff --git a/.changeset/blue-starfishes-fry.md b/.changeset/blue-starfishes-fry.md new file mode 100644 index 0000000000..dd09e1fce1 --- /dev/null +++ b/.changeset/blue-starfishes-fry.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/explorer": patch +--- + +Observe tab is now populated by transactions flowing through the world, in addition to local transactions when using the `observer` transport wrapper. diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/explore/TablesViewer.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/explore/TablesViewer.tsx index 638aa52911..ec2d8d47ea 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/explore/TablesViewer.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/explore/TablesViewer.tsx @@ -116,38 +116,42 @@ export function TablesViewer({ )} {!isLoading && ( - - - {reactTable.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {reactTable.getRowModel().rows?.length ? ( - reactTable.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} +
+
+ + {reactTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} - )) - ) : ( - - - No results. - - - )} - -
+ ))} + + + {reactTable.getRowModel().rows?.length ? ( + reactTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + + + )} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/Form.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/Form.tsx index 615b090e97..a65dba8646 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/Form.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/Form.tsx @@ -16,8 +16,8 @@ export function Form() { const { data, isFetched } = useWorldAbiQuery(); const [filterValue, setFilterValue] = useState(""); const deferredFilterValue = useDeferredValue(filterValue); - const filteredFunctions = data?.abi?.filter((item) => - item.name.toLowerCase().includes(deferredFilterValue.toLowerCase()), + const filteredFunctions = data?.abi?.filter( + (item) => item.type === "function" && item.name.toLowerCase().includes(deferredFilterValue.toLowerCase()), ); return ( diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/BlockExplorerLink.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/BlockExplorerLink.tsx new file mode 100644 index 0000000000..b19896d3fd --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/BlockExplorerLink.tsx @@ -0,0 +1,15 @@ +import { Hex } from "viem"; +import { useChain } from "../../../../hooks/useChain"; +import { blockExplorerTransactionUrl } from "../../../../utils/blockExplorerTransactionUrl"; + +export function BlockExplorerLink({ hash, children }: { hash?: Hex; children: React.ReactNode }) { + const { id: chainId } = useChain(); + const url = blockExplorerTransactionUrl({ chainId, hash }); + + if (!url) return children; + return ( + + {children} + + ); +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/Confirmations.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/Confirmations.tsx new file mode 100644 index 0000000000..a3a102e06c --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/Confirmations.tsx @@ -0,0 +1,23 @@ +import { Hex } from "viem"; +import { useTransactionConfirmations } from "wagmi"; +import { Skeleton } from "../../../../../../components/ui/Skeleton"; +import { useChain } from "../../../../hooks/useChain"; + +export function Confirmations({ hash }: { hash?: Hex }) { + const { id: chainId } = useChain(); + const { data: confirmations } = useTransactionConfirmations({ + hash, + chainId, + query: { + refetchInterval: 1000, + }, + }); + + if (!confirmations) return ; + return ( + + + {confirmations.toString()} + + ); +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TimeAgo.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TimeAgo.tsx new file mode 100644 index 0000000000..ac6babbfc5 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TimeAgo.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { timeAgo } from "../../../../utils/timeAgo"; + +export function TimeAgo({ timestamp }: { timestamp: bigint }) { + const [ago, setAgo] = useState(() => timeAgo(timestamp)); + + useEffect(() => { + const timer = setInterval(() => { + setAgo(timeAgo(timestamp)); + }, 1000); + + return () => clearInterval(timer); + }, [timestamp]); + + return ( + + {ago} + + ); +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx new file mode 100644 index 0000000000..945dbff048 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx @@ -0,0 +1,140 @@ +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { formatEther } from "viem"; +import { Row, flexRender } from "@tanstack/react-table"; +import { Separator } from "../../../../../../components/ui/Separator"; +import { Skeleton } from "../../../../../../components/ui/Skeleton"; +import { TableCell, TableRow } from "../../../../../../components/ui/Table"; +import { cn } from "../../../../../../utils"; +import { Confirmations } from "./Confirmations"; +import { columns } from "./TransactionsTable"; +import { WatchedTransaction } from "./useTransactionWatcher"; + +function TranctionTableRowDataCell({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

{label}

+

{children ?? }

+
+ ); +} + +export function TransactionTableRow({ row }: { row: Row }) { + const data = row?.original; + const logs = data?.logs; + const receipt = data?.receipt; + + return ( + <> + row.toggleExpanded()} + > + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + {row.getIsExpanded() ? ( + + ) : ( + + )} + + + {row.getIsExpanded() && ( + + + {data && ( + <> +
+ + + + + {data.transaction?.value !== undefined ? `${formatEther(data.transaction.value)} ETH` : null} + + {receipt?.gasUsed.toString()} + + {receipt?.effectiveGasPrice.toString()} + + + {receipt ? `${formatEther(receipt.gasUsed * receipt.effectiveGasPrice)} ETH` : null} + +
+ + + +
+

Inputs

+ {Array.isArray(data.functionData?.args) && data.functionData?.args.length > 0 ? ( +
+ {data.functionData?.args?.map((arg, idx) => ( +
+ arg {idx + 1}: + {String(arg)} +
+ ))} +
+ ) : ( +

No inputs

+ )} +
+ + {data.error ? ( + <> + +
+

Error

+ {data.error ? ( +
+ {data.error.message} +
+ ) : ( +

No error

+ )} +
+ + ) : null} + + + +
+

Logs

+ {Array.isArray(logs) && logs.length > 0 ? ( +
+
    + {logs.map((log, idx) => { + const eventName = "eventName" in log ? log.eventName : null; + const args = "args" in log ? (log.args as Record) : null; + return ( +
  • + {Boolean(eventName) && {eventName?.toString()}:} + {args && ( +
      + {Object.entries(args).map(([key, value]) => ( +
    • + {key}: + {value as never} +
    • + ))} +
    + )} + {idx < logs.length - 1 && } +
  • + ); + })} +
+
+ ) : ( +

No logs

+ )} +
+ + )} +
+
+ )} + + ); +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx new file mode 100644 index 0000000000..a4319762ff --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { BoxIcon, CheckCheckIcon, ReceiptTextIcon, UserPenIcon, XIcon } from "lucide-react"; +import React, { useState } from "react"; +import { ExpandedState, flexRender, getCoreRowModel, getExpandedRowModel, useReactTable } from "@tanstack/react-table"; +import { createColumnHelper } from "@tanstack/react-table"; +import { Badge } from "../../../../../../components/ui/Badge"; +import { Skeleton } from "../../../../../../components/ui/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../../components/ui/Table"; +import { TruncatedHex } from "../../../../../../components/ui/TruncatedHex"; +import { BlockExplorerLink } from "./BlockExplorerLink"; +import { TimeAgo } from "./TimeAgo"; +import { TransactionTableRow } from "./TransactionTableRow"; +import { WatchedTransaction, useTransactionWatcher } from "./useTransactionWatcher"; + +const columnHelper = createColumnHelper(); +export const columns = [ + columnHelper.accessor("receipt.blockNumber", { + header: "Block", + cell: (row) => { + const blockNumber = row.getValue(); + if (!blockNumber) return ; + return ( +
+ + {blockNumber.toString()} +
+ ); + }, + }), + columnHelper.accessor("transaction.from", { + header: "From", + cell: (row) => { + const from = row.getValue(); + if (!from) return ; + return ( +
+ + +
+ ); + }, + }), + columnHelper.accessor("functionData.functionName", { + header: "Function", + cell: (row) => { + const functionName = row.getValue(); + const status = row.row.original.status; + return ( +
+ {functionName} + {status === "pending" && } + {status === "success" && } + {status === "reverted" && } +
+ ); + }, + }), + columnHelper.accessor("hash", { + header: "Tx hash", + cell: (row) => { + const hash = row.getValue(); + if (!hash) return ; + return ( +
+ + + + +
+ ); + }, + }), + columnHelper.accessor("timestamp", { + header: "Time", + cell: (row) => { + const timestamp = row.getValue(); + if (!timestamp) return ; + return ; + }, + }), +]; + +export function TransactionsTable() { + const transactions = useTransactionWatcher(); + const [expanded, setExpanded] = useState({}); + + const table = useReactTable({ + data: transactions, + columns, + state: { + expanded, + }, + onExpandedChange: setExpanded, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ) + ) : ( + + +

+ Waiting for + transactions… +

+
+
+ )} +
+
+ ); +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/page.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/page.tsx index 1cd2e007d7..9ca0089678 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/page.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/page.tsx @@ -1,5 +1,5 @@ -import { Writes } from "./Writes"; +import { TransactionsTable } from "./TransactionsTable"; export default function ObservePage() { - return ; + return ; } diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useTransactionWatcher.ts b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useTransactionWatcher.ts new file mode 100644 index 0000000000..57b8407103 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useTransactionWatcher.ts @@ -0,0 +1,180 @@ +import { useParams } from "next/navigation"; +import { + AbiFunction, + BaseError, + DecodeFunctionDataReturnType, + Hex, + Log, + Transaction, + TransactionReceipt, + decodeFunctionData, + parseAbiItem, + parseEventLogs, +} from "viem"; +import { useConfig, useWatchBlocks } from "wagmi"; +import { useStore } from "zustand"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getTransaction, simulateContract, waitForTransactionReceipt } from "@wagmi/core"; +import { Write, store } from "../../../../../../observer/store"; +import { useChain } from "../../../../hooks/useChain"; +import { useWorldAbiQuery } from "../../../../queries/useWorldAbiQuery"; + +export type WatchedTransaction = { + hash?: Hex; + timestamp?: bigint; + transaction?: Transaction; + functionData?: DecodeFunctionDataReturnType; + receipt?: TransactionReceipt; + status: "pending" | "success" | "reverted" | "unknown"; + write?: Write; + logs?: Log[]; + error?: BaseError; +}; + +export function useTransactionWatcher() { + const { id: chainId } = useChain(); + const { worldAddress } = useParams(); + const wagmiConfig = useConfig(); + const { data: worldAbiData } = useWorldAbiQuery(); + const abi = worldAbiData?.abi; + const [transactions, setTransactions] = useState([]); + const observerWrites = useStore(store, (state) => state.writes); + + const handleTransaction = useCallback( + async (hash: Hex, timestamp: bigint) => { + if (!abi) return; + + const transaction = await getTransaction(wagmiConfig, { hash }); + if (transaction.to !== worldAddress) return; + + let functionName: string | undefined; + let args: readonly unknown[] | undefined; + let transactionError: BaseError | undefined; + + try { + const functionData = decodeFunctionData({ abi, data: transaction.input }); + functionName = functionData.functionName; + args = functionData.args; + } catch (error) { + transactionError = error as BaseError; + functionName = transaction.input.length > 10 ? transaction.input.slice(0, 10) : "unknown"; + } + + setTransactions((prevTransactions) => [ + { + hash, + timestamp, + transaction, + status: "pending", + functionData: { + functionName, + args, + }, + }, + ...prevTransactions, + ]); + + let receipt: TransactionReceipt | undefined; + try { + receipt = await waitForTransactionReceipt(wagmiConfig, { hash }); + } catch { + console.error(`Failed to fetch transaction receipt. Transaction hash: ${hash}`); + } + + if (receipt && receipt.status === "reverted" && functionName) { + try { + // Simulate the failed transaction to retrieve the revert reason + // Note, it only works for functions that are declared in the ABI + // See: https://github.com/wevm/viem/discussions/462 + await simulateContract(wagmiConfig, { + account: transaction.from, + address: worldAddress, + abi, + value: transaction.value, + blockNumber: receipt.blockNumber, + functionName, + args, + }); + } catch (error) { + transactionError = error as BaseError; + } + } + + const status = receipt ? receipt.status : "unknown"; + const logs = parseEventLogs({ + abi, + logs: receipt?.logs || [], + }); + + setTransactions((prevTransactions) => + prevTransactions.map((transaction) => + transaction.hash === hash + ? { + ...transaction, + receipt, + logs, + status, + error: transactionError, + } + : transaction, + ), + ); + }, + [abi, wagmiConfig, worldAddress], + ); + + useEffect(() => { + for (const write of Object.values(observerWrites)) { + const hash = write.hash; + if (write.type === "waitForTransactionReceipt" && hash) { + const transaction = transactions.find((transaction) => transaction.hash === hash); + if (!transaction) { + handleTransaction(hash, BigInt(write.time) / 1000n); + } + } + } + }, [handleTransaction, observerWrites, transactions]); + + useWatchBlocks({ + onBlock(block) { + for (const hash of block.transactions) { + if (transactions.find((transaction) => transaction.hash === hash)) continue; + handleTransaction(hash, block.timestamp); + } + }, + chainId, + pollingInterval: 500, + }); + + const mergedTransactions = useMemo((): WatchedTransaction[] => { + const mergedMap = new Map(); + + for (const write of Object.values(observerWrites)) { + const parsedAbiItem = parseAbiItem(`function ${write.functionSignature}`) as AbiFunction; + const functionData = { + functionName: parsedAbiItem.name, + args: write.args, + }; + + mergedMap.set(write.hash, { + status: "pending", + timestamp: BigInt(write.time) / 1000n, + functionData, + write, + }); + } + + for (const transaction of transactions) { + const existing = mergedMap.get(transaction.hash); + if (existing) { + mergedMap.set(transaction.hash, { ...transaction, write: existing.write }); + } else { + mergedMap.set(transaction.hash, { ...transaction }); + } + } + + return Array.from(mergedMap.values()).sort((a, b) => Number(b.timestamp ?? 0n) - Number(a.timestamp ?? 0n)); + }, [transactions, observerWrites]); + + return mergedTransactions; +} diff --git a/packages/explorer/src/app/(explorer)/api/world-abi/route.ts b/packages/explorer/src/app/(explorer)/api/world-abi/route.ts index 12ab9df73b..e0e27f8f22 100644 --- a/packages/explorer/src/app/(explorer)/api/world-abi/route.ts +++ b/packages/explorer/src/app/(explorer)/api/world-abi/route.ts @@ -1,4 +1,4 @@ -import { AbiFunction, Address, Hex, createWalletClient, http, parseAbi } from "viem"; +import { Address, Hex, createWalletClient, http, parseAbi } from "viem"; import { getBlockNumber, getLogs } from "viem/actions"; import { helloStoreEvent } from "@latticexyz/store"; import { helloWorldEvent } from "@latticexyz/world"; @@ -47,15 +47,12 @@ export async function GET(req: Request) { try { const client = await getClient(chainId); const { fromBlock, toBlock, isWorldDeployed } = await getParameters(chainId, worldAddress); - const worldAbiResponse = await getWorldAbi({ + const abi = await getWorldAbi({ client, worldAddress, fromBlock, toBlock, }); - const abi = worldAbiResponse - .filter((entry): entry is AbiFunction => entry.type === "function") - .sort((a, b) => a.name.localeCompare(b.name)); return Response.json({ abi, isWorldDeployed }); } catch (error: unknown) { diff --git a/packages/explorer/src/app/(explorer)/globals.css b/packages/explorer/src/app/(explorer)/globals.css index e3adb31bcb..a05fa92845 100644 --- a/packages/explorer/src/app/(explorer)/globals.css +++ b/packages/explorer/src/app/(explorer)/globals.css @@ -2,6 +2,12 @@ @tailwind components; @tailwind utilities; +html, +body, +.container { + font-family: var(--font-jetbrains-mono); +} + @layer base { :root { --background: 0 0% 100%; diff --git a/packages/explorer/src/app/(explorer)/layout.tsx b/packages/explorer/src/app/(explorer)/layout.tsx index e37e033e76..97595f9cf9 100644 --- a/packages/explorer/src/app/(explorer)/layout.tsx +++ b/packages/explorer/src/app/(explorer)/layout.tsx @@ -30,14 +30,7 @@ export default function RootLayout({ -
- {children} -
+
{children}
diff --git a/packages/explorer/src/app/(explorer)/utils/blockExplorerTransactionUrl.ts b/packages/explorer/src/app/(explorer)/utils/blockExplorerTransactionUrl.ts new file mode 100644 index 0000000000..a7e8ee8b36 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/utils/blockExplorerTransactionUrl.ts @@ -0,0 +1,19 @@ +import { Hex } from "viem"; +import { chainIdToName, supportedChains, validateChainId } from "../../../common"; + +export function blockExplorerTransactionUrl({ + hash, + chainId, +}: { + hash: Hex | undefined; + chainId: number; +}): string | undefined { + if (!hash) return undefined; + validateChainId(chainId); + + const chainName = chainIdToName[chainId]; + const chain = supportedChains[chainName]; + const explorerUrl = chain.blockExplorers?.default.url; + if (!explorerUrl) return undefined; + return `${explorerUrl}/tx/${hash}`; +} diff --git a/packages/explorer/src/app/(explorer)/utils/timeAgo.ts b/packages/explorer/src/app/(explorer)/utils/timeAgo.ts new file mode 100644 index 0000000000..1ed9496828 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/utils/timeAgo.ts @@ -0,0 +1,26 @@ +export function timeAgo(timestamp: bigint) { + const units = [ + { name: "y", limit: 365 * 24 * 60 * 60, inSeconds: 365 * 24 * 60 * 60 }, + { name: "mth", limit: 30 * 24 * 60 * 60, inSeconds: 30 * 24 * 60 * 60 }, + { name: "d", limit: 24 * 60 * 60, inSeconds: 24 * 60 * 60 }, + { name: "h", limit: 60 * 60, inSeconds: 60 * 60 }, + { name: "m", limit: 60, inSeconds: 60 }, + { name: "s", limit: 1, inSeconds: 1 }, + ]; + + const currentTimestampSeconds = Math.floor(Date.now() / 1000); + const diff = currentTimestampSeconds - Number(timestamp); + + if (diff < 0) { + return "in the future"; + } + + for (const unit of units) { + if (diff >= unit.limit) { + const unitsAgo = Math.floor(diff / unit.inSeconds); + return `${unitsAgo}${unit.name} ago`; + } + } + + return "just now"; +} diff --git a/packages/explorer/src/app/api/utils/getDatabase.ts b/packages/explorer/src/app/api/utils/getDatabase.ts deleted file mode 100644 index a27cf23d3e..0000000000 --- a/packages/explorer/src/app/api/utils/getDatabase.ts +++ /dev/null @@ -1,18 +0,0 @@ -import sqliteDB, { Database } from "better-sqlite3"; -import fs from "fs"; - -export function getDatabase(): Database | null { - const dbPath = process.env.INDEXER_DATABASE as string; - if (!fs.existsSync(dbPath)) { - throw new Error( - "Database cannot be found. Make sure --indexerDatabase flag or INDEXER_DATABASE environment variable are set, and the indexer is running.", - ); - } - - const db = new sqliteDB(dbPath); - if (!db) { - throw new Error("Database path found but failed to initialize."); - } - - return db; -} diff --git a/packages/explorer/src/components/LatestBlock.tsx b/packages/explorer/src/components/LatestBlock.tsx index 8e8712ed4c..b493022b82 100644 --- a/packages/explorer/src/components/LatestBlock.tsx +++ b/packages/explorer/src/components/LatestBlock.tsx @@ -7,18 +7,16 @@ export function LatestBlock() { const { data: block } = useBlockNumber({ watch: true, chainId, + query: { + refetchInterval: 1000, + }, }); return (
{block ? ( -
- +
+ {block.toString()}
) : ( diff --git a/packages/explorer/src/components/Navigation.tsx b/packages/explorer/src/components/Navigation.tsx index fe116eb34e..ab0f89a94c 100644 --- a/packages/explorer/src/components/Navigation.tsx +++ b/packages/explorer/src/components/Navigation.tsx @@ -10,41 +10,30 @@ import { Separator } from "../components/ui/Separator"; import { cn } from "../utils"; import { ConnectButton } from "./ConnectButton"; -export function Navigation() { +function NavigationLink({ href, children }: { href: string; children: React.ReactNode }) { const pathname = usePathname(); const getLinkUrl = useWorldUrl(); - const { data, isFetched } = useWorldAbiQuery(); + return ( + + {children} + + ); +} +export function Navigation() { + const { data, isFetched } = useWorldAbiQuery(); return (
- - Explore - - - - Interact - - - - Observe - + Explore + Interact + Observe
{isFetched && !data?.isWorldDeployed && ( diff --git a/packages/explorer/src/components/ui/Badge.tsx b/packages/explorer/src/components/ui/Badge.tsx new file mode 100644 index 0000000000..c264a23a12 --- /dev/null +++ b/packages/explorer/src/components/ui/Badge.tsx @@ -0,0 +1,34 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "../../utils"; + +const badgeVariants = cva( + cn( + "inline-flex items-center px-2.5 py-0.5", + "rounded-md border", + "text-xs font-semibold transition-colors", + "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + ), + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + tertiary: "border-transparent bg-tertiary text-tertiary-foreground hover:bg-tertiary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/packages/explorer/src/components/ui/Table.tsx b/packages/explorer/src/components/ui/Table.tsx index 4e90da239b..82addda6b0 100644 --- a/packages/explorer/src/components/ui/Table.tsx +++ b/packages/explorer/src/components/ui/Table.tsx @@ -3,9 +3,7 @@ import { cn } from "../../utils"; const Table = React.forwardRef>( ({ className, ...props }, ref) => ( -
- - +
), ); Table.displayName = "Table"; diff --git a/packages/explorer/src/observer/decorator.ts b/packages/explorer/src/observer/decorator.ts index 43077b4ada..ab4c109bf4 100644 --- a/packages/explorer/src/observer/decorator.ts +++ b/packages/explorer/src/observer/decorator.ts @@ -46,7 +46,7 @@ export function observer({ explorerUrl = "http://localhost:13690", waitForTransa write.then((hash) => { const receipt = getAction(client, waitForTransactionReceipt, "waitForTransactionReceipt")({ hash }); - emit("waitForTransactionReceipt", { writeId }); + emit("waitForTransactionReceipt", { writeId, hash }); Promise.allSettled([receipt]).then(([result]) => { emit("waitForTransactionReceipt:result", { ...result, writeId }); }); diff --git a/packages/explorer/src/observer/messages.ts b/packages/explorer/src/observer/messages.ts index a906c9f615..d2d5f5db0f 100644 --- a/packages/explorer/src/observer/messages.ts +++ b/packages/explorer/src/observer/messages.ts @@ -14,6 +14,7 @@ export type Messages = { }; waitForTransactionReceipt: { writeId: string; + hash: Hash; }; "waitForTransactionReceipt:result": PromiseSettledResult & { writeId: string; diff --git a/packages/explorer/src/observer/store.ts b/packages/explorer/src/observer/store.ts index b0f065ff74..5c4e78b414 100644 --- a/packages/explorer/src/observer/store.ts +++ b/packages/explorer/src/observer/store.ts @@ -1,6 +1,6 @@ "use client"; -import { Address } from "viem"; +import { Address, Hex } from "viem"; import { createStore } from "zustand/vanilla"; import { relayChannelName } from "./common"; import { debug } from "./debug"; @@ -8,6 +8,8 @@ import { Message, MessageType } from "./messages"; export type Write = { writeId: string; + type: MessageType; + hash?: Hex; address: Address; functionSignature: string; args: unknown[]; @@ -36,6 +38,8 @@ channel.addEventListener("message", ({ data }: MessageEvent) => { ...state.writes, [data.writeId]: { ...write, + type: data.type, + hash: data.type === "waitForTransactionReceipt" ? data.hash : write.hash, events: [...write.events, data], }, }, diff --git a/packages/explorer/tailwind.config.ts b/packages/explorer/tailwind.config.ts index 4e7d355442..4ea11a90ca 100644 --- a/packages/explorer/tailwind.config.ts +++ b/packages/explorer/tailwind.config.ts @@ -14,10 +14,12 @@ const config = { }, extend: { fontFamily: { - sans: ["var(--font-jetbrains-mono)"], + sans: ["var(--font-inter)"], mono: ["var(--font-jetbrains-mono)"], }, - + fontSize: { + "2xs": ["0.625rem", { lineHeight: "1rem" }], + }, colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", @@ -52,6 +54,7 @@ const config = { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, + success: "#34d399", }, borderRadius: { lg: "var(--radius)",