From 6476dec94cf32275631d49c7e8fe8fe5a0708040 Mon Sep 17 00:00:00 2001 From: Karolis Ramanauskas Date: Wed, 9 Oct 2024 14:43:22 +0300 Subject: [PATCH] feat(explorer): front page (#3255) Co-authored-by: Kevin Ingersoll --- .changeset/lucky-bulldogs-taste.md | 5 + packages/explorer/README.md | 18 +-- .../[chainName]/worlds/WorldsForm.tsx | 136 ++++++++++++++++++ .../(explorer)/[chainName]/worlds/page.tsx | 60 +++++++- .../api/sqlite-indexer/worlds/route.ts | 26 ++++ .../src/app/(explorer)/hooks/useWorldUrl.ts | 4 +- .../explorer/src/app/(explorer)/layout.tsx | 2 +- .../src/app/(explorer)/utils/getWorldUrl.ts | 5 + packages/explorer/src/bin/explorer.ts | 78 +--------- 9 files changed, 239 insertions(+), 95 deletions(-) create mode 100644 .changeset/lucky-bulldogs-taste.md create mode 100644 packages/explorer/src/app/(explorer)/[chainName]/worlds/WorldsForm.tsx create mode 100644 packages/explorer/src/app/(explorer)/api/sqlite-indexer/worlds/route.ts create mode 100644 packages/explorer/src/app/(explorer)/utils/getWorldUrl.ts diff --git a/.changeset/lucky-bulldogs-taste.md b/.changeset/lucky-bulldogs-taste.md new file mode 100644 index 0000000000..a2f01c8e94 --- /dev/null +++ b/.changeset/lucky-bulldogs-taste.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/explorer": patch +--- + +Each chain's home page now lets you find and pick a world to explore. diff --git a/packages/explorer/README.md b/packages/explorer/README.md index fa5377abe4..46df799fc3 100644 --- a/packages/explorer/README.md +++ b/packages/explorer/README.md @@ -26,8 +26,6 @@ Or, can be executed with a package bin directly: npx @latticexyz/explorer ``` -**Note:** `worlds.json` is the default file used to configure the world. If you're using a different file or if the file is located in a different path than where you're running the command, you can specify it with the `--worldsFile` flag, or use `--worldAddress` to point to the world address directly. Accordingly, `indexer.db` is the default database file used to index the world state. If you're using a different database file or if the file is located in a different path than where you're running the command, you can specify it with the `--indexerDatabase` flag. - ### Example setup For a full working setup, check out the [local-explorer](https://github.com/latticexyz/mud/tree/main/examples/local-explorer) example. @@ -38,15 +36,13 @@ You may also want to check out the MUD [Quickstart guide](https://mud.dev/quicks The World Explorer accepts the following CLI arguments: -| Argument | Description | Default value | -| ----------------- | ------------------------------------------------------------------- | ------------- | -| `worldAddress` | The address of the world to explore | None | -| `worldsFile` | Path to a worlds configuration file (used to resolve world address) | "worlds.json" | -| `indexerDatabase` | Path to your SQLite indexer database | "indexer.db" | -| `chainId` | The chain ID of the network | 31337 | -| `port` | The port on which to run the World Explorer | 13690 | -| `hostname` | The host on which to run the World Explorer | 0.0.0.0 | -| `dev` | Run the World Explorer in development mode | false | +| Argument | Description | Default value | +| ----------------- | ------------------------------------------- | ------------- | +| `indexerDatabase` | Path to your SQLite indexer database | "indexer.db" | +| `chainId` | The chain ID of the network | 31337 | +| `port` | The port on which to run the World Explorer | 13690 | +| `hostname` | The host on which to run the World Explorer | 0.0.0.0 | +| `dev` | Run the World Explorer in development mode | false | ## Contributing diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/WorldsForm.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/WorldsForm.tsx new file mode 100644 index 0000000000..a17b413515 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/WorldsForm.tsx @@ -0,0 +1,136 @@ +"use client"; + +import Image from "next/image"; +import { useParams, useRouter } from "next/navigation"; +import { Address, isAddress } from "viem"; +import * as z from "zod"; +import { Command as CommandPrimitive } from "cmdk"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "../../../../components/ui/Button"; +import { Command, CommandGroup, CommandItem, CommandList } from "../../../../components/ui/Command"; +import { Form, FormControl, FormField, FormItem, FormMessage } from "../../../../components/ui/Form"; +import { Input } from "../../../../components/ui/Input"; +import mudLogo from "../../icon.svg"; +import { getWorldUrl } from "../../utils/getWorldUrl"; + +const formSchema = z.object({ + worldAddress: z + .string() + .refine((value) => isAddress(value), { + message: "Invalid world address", + }) + .transform((value): Address => value as Address), +}); + +export function WorldsForm({ worlds }: { worlds: Address[] }) { + const router = useRouter(); + const { chainName } = useParams(); + const [open, setOpen] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + reValidateMode: "onChange", + }); + + function onSubmit({ worldAddress }: z.infer) { + router.push(getWorldUrl(chainName as string, worldAddress)); + } + + function onLuckyWorld() { + if (worlds.length > 0) { + const luckyAddress = worlds[Math.floor(Math.random() * worlds.length)]; + router.push(getWorldUrl(chainName as string, luckyAddress)); + } + } + + return ( +
+

+ MUD logo Worlds Explorer +

+ + +
+ +
+ ( + + + { + field.onChange(value); + }} + onBlur={() => { + field.onBlur(); + setOpen(false); + }} + onFocus={() => setOpen(true)} + placeholder="Enter world address..." + > + + + + + + )} + /> + +
+ + {open ? ( +
+ + {worlds?.map((world) => { + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + onSelect={(value) => { + form.setValue("worldAddress", value as Address, { + shouldValidate: true, + }); + setOpen(false); + }} + className="cursor-pointer font-mono" + > + {world} + + ); + })} + +
+ ) : null} +
+
+
+ +
+ + +
+
+ +
+
+ ); +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/page.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/page.tsx index 4358639446..9656b5459a 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/page.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/page.tsx @@ -1,6 +1,51 @@ -import { notFound, redirect } from "next/navigation"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { Address } from "viem"; +import { supportedChains, validateChainName } from "../../../../common"; +import { indexerForChainId } from "../../utils/indexerForChainId"; +import { WorldsForm } from "./WorldsForm"; -export const dynamic = "force-dynamic"; +type ApiResponse = { + items: { + address: { + hash: Address; + }; + }[]; +}; + +async function fetchWorlds(chainName: string): Promise { + validateChainName(chainName); + + const chain = supportedChains[chainName]; + const indexer = indexerForChainId(chain.id); + let worldsApiUrl: string | null = null; + + if (indexer.type === "sqlite") { + const headersList = headers(); + const host = headersList.get("host") || ""; + const protocol = headersList.get("x-forwarded-proto") || "http"; + const baseUrl = `${protocol}://${host}`; + worldsApiUrl = `${baseUrl}/api/sqlite-indexer/worlds`; + } else { + const blockExplorerUrl = chain.blockExplorers?.default.url; + if (blockExplorerUrl) { + worldsApiUrl = `${blockExplorerUrl}/api/v2/mud/worlds`; + } + } + + if (!worldsApiUrl) { + return []; + } + + try { + const response = await fetch(worldsApiUrl); + const data: ApiResponse = await response.json(); + return data.items.map((world) => world.address.hash); + } catch (error) { + console.error(error); + return []; + } +} type Props = { params: { @@ -8,8 +53,11 @@ type Props = { }; }; -export default function WorldsPage({ params }: Props) { - const worldAddress = process.env.WORLD_ADDRESS; - if (worldAddress) return redirect(`/${params.chainName}/worlds/${worldAddress}`); - return notFound(); +export default async function WorldsPage({ params }: Props) { + const worlds = await fetchWorlds(params.chainName); + if (worlds.length === 1) { + return redirect(`/${params.chainName}/worlds/${worlds[0]}`); + } + + return ; } diff --git a/packages/explorer/src/app/(explorer)/api/sqlite-indexer/worlds/route.ts b/packages/explorer/src/app/(explorer)/api/sqlite-indexer/worlds/route.ts new file mode 100644 index 0000000000..dc72e34012 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/api/sqlite-indexer/worlds/route.ts @@ -0,0 +1,26 @@ +import { Address } from "viem"; +import { getDatabase } from "../../utils/getDatabase"; + +export const dynamic = "force-dynamic"; + +type Row = { + address: Address; +}; + +type SqliteTable = Row[] | undefined; + +export async function GET() { + try { + const db = getDatabase(); + const data = (await db?.prepare("SELECT DISTINCT address FROM __mudStoreTables").all()) as SqliteTable; + const items = data?.map((row) => ({ + address: { + hash: row.address, + }, + })); + return Response.json({ items: items || [] }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; + return Response.json({ error: errorMessage }, { status: 400 }); + } +} diff --git a/packages/explorer/src/app/(explorer)/hooks/useWorldUrl.ts b/packages/explorer/src/app/(explorer)/hooks/useWorldUrl.ts index 2225b79585..35a2b0af00 100644 --- a/packages/explorer/src/app/(explorer)/hooks/useWorldUrl.ts +++ b/packages/explorer/src/app/(explorer)/hooks/useWorldUrl.ts @@ -1,7 +1,9 @@ import { useParams } from "next/navigation"; +import { Address } from "viem"; +import { getWorldUrl } from "../utils/getWorldUrl"; export function useWorldUrl() { const params = useParams(); const { chainName, worldAddress } = params; - return (page: string) => `/${chainName}/worlds/${worldAddress}/${page}`; + return (page: string) => `${getWorldUrl(chainName as string, worldAddress as Address)}/${page}`; } diff --git a/packages/explorer/src/app/(explorer)/layout.tsx b/packages/explorer/src/app/(explorer)/layout.tsx index 4467a22c8c..3ee4268150 100644 --- a/packages/explorer/src/app/(explorer)/layout.tsx +++ b/packages/explorer/src/app/(explorer)/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout({ -
{children}
+
{children}
diff --git a/packages/explorer/src/app/(explorer)/utils/getWorldUrl.ts b/packages/explorer/src/app/(explorer)/utils/getWorldUrl.ts new file mode 100644 index 0000000000..f4f0a26879 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/utils/getWorldUrl.ts @@ -0,0 +1,5 @@ +import { Address } from "viem"; + +export function getWorldUrl(chainName: string, worldAddress: Address) { + return `/${chainName}/worlds/${worldAddress}`; +} diff --git a/packages/explorer/src/bin/explorer.ts b/packages/explorer/src/bin/explorer.ts index 1e2f5d7956..218018aaba 100755 --- a/packages/explorer/src/bin/explorer.ts +++ b/packages/explorer/src/bin/explorer.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { watchFile } from "fs"; -import { readFile, rm } from "fs/promises"; +import { rm } from "fs/promises"; import path from "path"; import process from "process"; import { fileURLToPath } from "url"; @@ -38,24 +37,12 @@ const argv = yargs(process.argv.slice(2)) type: "string", default: process.env.INDEXER_DATABASE || "indexer.db", }, - worldsFile: { - alias: "w", - description: "Path to the worlds.json file", - type: "string", - default: process.env.WORLDS_FILE || "worlds.json", - }, dev: { alias: "D", description: "Run in development mode", type: "boolean", default: false, }, - worldAddress: { - alias: "a", - description: "World address", - type: "string", - default: process.env.WORLD_ADDRESS, - }, }) .check((argv) => { validateChainId(Number(argv.chainId)); @@ -63,10 +50,9 @@ const argv = yargs(process.argv.slice(2)) }) .parseSync(); -const { port, hostname, chainId, indexerDatabase, worldsFile, dev } = argv; +const { port, hostname, chainId, indexerDatabase, dev } = argv; const indexerDatabasePath = path.join(packageRoot, indexerDatabase); -let worldAddress = argv.worldAddress; let explorerProcess: ChildProcess; let indexerProcess: ChildProcess; @@ -74,7 +60,6 @@ async function startExplorer() { const env = { ...process.env, CHAIN_ID: chainId.toString(), - WORLD_ADDRESS: worldAddress?.toString(), INDEXER_DATABASE: indexerDatabasePath, }; @@ -118,76 +103,17 @@ async function startStoreIndexer() { RPC_HTTP_URL: "http://127.0.0.1:8545", FOLLOW_BLOCK_TAG: "latest", SQLITE_FILENAME: indexerDatabase, - STORE_ADDRESS: worldAddress, ...process.env, }, }); } -async function readWorldsJson() { - try { - const data = await readFile(worldsFile, "utf8"); - if (data) { - const worlds = JSON.parse(data); - const world = worlds[chainId]; - if (world) { - return world.address; - } else { - console.error(`World not found for chain ID ${chainId}`); - return null; - } - } - } catch (error) { - console.error("Error reading worlds.json:", error); - return null; - } -} - -async function restartExplorer() { - indexerProcess?.kill(); - explorerProcess?.kill(); - - await startStoreIndexer(); - await startExplorer(); -} - -function watchWorldsJson() { - if (!worldsFile) { - return; - } - - watchFile(worldsFile, async () => { - const newWorldAddress = await readWorldsJson(); - if (worldAddress && worldAddress !== newWorldAddress) { - console.log("\nWorld address changed, restarting explorer..."); - - worldAddress = newWorldAddress; - await restartExplorer(); - } - }); -} - process.on("exit", () => { indexerProcess?.kill(); explorerProcess?.kill(); }); async function main() { - // If world address is not provided, try to read it from worlds.json - if (!worldAddress) { - worldAddress = await readWorldsJson(); - - // If world address is still not found, throw an error - if (!worldAddress) { - throw new Error( - `No world address found in "${worldsFile}" file. Either run \`mud deploy\` to create one or provide one with \`--worldAddress\`.`, - ); - } - - // only watch worlds.json if world address was not provided with --worldAddress - watchWorldsJson(); - } - await startStoreIndexer(); await startExplorer(); }