Skip to content

Commit

Permalink
feat(explorer): front page (#3255)
Browse files Browse the repository at this point in the history
Co-authored-by: Kevin Ingersoll <[email protected]>
  • Loading branch information
karooolis and holic authored Oct 9, 2024
1 parent ea18f27 commit 6476dec
Show file tree
Hide file tree
Showing 9 changed files with 239 additions and 95 deletions.
5 changes: 5 additions & 0 deletions .changeset/lucky-bulldogs-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/explorer": patch
---

Each chain's home page now lets you find and pick a world to explore.
18 changes: 7 additions & 11 deletions packages/explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
136 changes: 136 additions & 0 deletions packages/explorer/src/app/(explorer)/[chainName]/worlds/WorldsForm.tsx
Original file line number Diff line number Diff line change
@@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
reValidateMode: "onChange",
});

function onSubmit({ worldAddress }: z.infer<typeof formSchema>) {
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 (
<div className="mx-auto flex min-h-screen w-[450px] flex-col items-center justify-center p-4">
<h1 className="flex items-center gap-6 self-start font-mono text-4xl font-bold uppercase">
<Image src={mudLogo} alt="MUD logo" width={48} height={48} /> Worlds Explorer
</h1>

<Command className="mt-6 overflow-visible bg-transparent">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div>
<FormField
control={form.control}
name="worldAddress"
render={({ field }) => (
<FormItem>
<FormControl>
<CommandPrimitive.Input
asChild
value={field.value}
onValueChange={(value) => {
field.onChange(value);
}}
onBlur={() => {
field.onBlur();
setOpen(false);
}}
onFocus={() => setOpen(true)}
placeholder="Enter world address..."
>
<Input className="h-12" />
</CommandPrimitive.Input>
</FormControl>
<FormMessage className="uppercase" />
</FormItem>
)}
/>

<div className="relative">
<CommandList>
{open ? (
<div className="absolute top-3 z-10 max-h-[200px] w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground outline-none animate-in">
<CommandGroup>
{worlds?.map((world) => {
return (
<CommandItem
key={world}
value={world}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onSelect={(value) => {
form.setValue("worldAddress", value as Address, {
shouldValidate: true,
});
setOpen(false);
}}
className="cursor-pointer font-mono"
>
{world}
</CommandItem>
);
})}
</CommandGroup>
</div>
) : null}
</CommandList>
</div>
</div>

<div className="flex w-full items-center gap-x-2">
<Button type="submit" className="flex-1 uppercase" variant="default">
Explore the world
</Button>
<Button
className="flex-1 uppercase"
variant="secondary"
onClick={onLuckyWorld}
disabled={worlds.length === 0}
>
I&apos;m feeling lucky
</Button>
</div>
</form>
</Form>
</Command>
</div>
);
}
60 changes: 54 additions & 6 deletions packages/explorer/src/app/(explorer)/[chainName]/worlds/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,63 @@
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<Address[]> {
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: {
chainName: string;
};
};

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 <WorldsForm worlds={worlds} />;
}
Original file line number Diff line number Diff line change
@@ -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 });
}
}
4 changes: 3 additions & 1 deletion packages/explorer/src/app/(explorer)/hooks/useWorldUrl.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
2 changes: 1 addition & 1 deletion packages/explorer/src/app/(explorer)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function RootLayout({
<html lang="en">
<body className={`${inter.variable} ${jetbrains.variable} dark`}>
<Theme>
<div className="container pb-8">{children}</div>
<div className="container">{children}</div>
<Toaster richColors closeButton duration={10000} />
</Theme>
</body>
Expand Down
5 changes: 5 additions & 0 deletions packages/explorer/src/app/(explorer)/utils/getWorldUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Address } from "viem";

export function getWorldUrl(chainName: string, worldAddress: Address) {
return `/${chainName}/worlds/${worldAddress}`;
}
Loading

0 comments on commit 6476dec

Please sign in to comment.