Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(explorer): front page #3255

Merged
merged 15 commits into from
Oct 9, 2024
Merged
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
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>
);
}
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
Loading