diff --git a/.gitignore b/.gitignore index 5c942cc4d..73c280ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ yarn-error.log* dist/ cache/ standalone/ + +*.sqlite +pgdata/ \ No newline at end of file diff --git a/apps/desktop/index.js b/apps/desktop/index.js index 08b83d6cb..f069438bc 100644 --- a/apps/desktop/index.js +++ b/apps/desktop/index.js @@ -1,8 +1,16 @@ /* eslint-disable turbo/no-undeclared-env-vars */ // @ts-check -const { app, BrowserWindow, dialog } = require("electron"); +const { + app, + Menu, + BrowserWindow, + dialog, + MenuItem, + ipcMain, +} = require("electron"); const todesktop = require("@todesktop/runtime"); const log = require("electron-log/main"); +const path = require("path"); log.initialize(); log.transports.ipc.level = "verbose"; @@ -37,6 +45,7 @@ function getRandomPort() { let hasServerStarted = false; let totalAttempsLeft = 10; +process.env.ROOT_USER_PATH = app.getPath("userData"); while (!hasServerStarted && totalAttempsLeft > 0) { try { process.env.PORT = getRandomPort().toString(); @@ -63,9 +72,56 @@ function createWindow() { webPreferences: { nodeIntegration: true, devTools: true, + preload: path.join(__dirname, "preload.js"), }, }); + /** @type any */ + const template = [ + { + label: "File", + submenu: [{ label: "Exit", role: "quit" }], + }, + { + label: "Edit", + submenu: [ + { label: "Undo", role: "undo" }, + { label: "Redo", role: "redo" }, + { type: "separator" }, + { label: "Cut", role: "cut" }, + { label: "Copy", role: "copy" }, + { label: "Paste", role: "paste" }, + ], + }, + { + label: "View", + submenu: [ + { label: "Reload", role: "reload" }, + { label: "Toggle Developer Tools", role: "toggleDevTools" }, + ], + }, + { + label: "Develop", + submenu: [ + { + label: "Add Local Chain", + click: () => { + mainWindow.webContents.send("navigate", "/register/local-chain"); + }, + }, + { + label: "Go home", + click: () => { + mainWindow.webContents.send("navigate", "/"); + }, + }, + ], + }, + ]; + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); + // this is to override the title set by `./apps/web/server.js` // so that the window will show `Explorer` instead of `next-server` process.title = mainWindow.title; @@ -75,6 +131,9 @@ function createWindow() { mainWindow.on("closed", () => { app.quit(); }); + ipcMain.on("navigate", (event, route) => { + mainWindow.webContents.send("navigate", route); + }); } app.whenReady().then(createWindow); diff --git a/apps/desktop/preload.js b/apps/desktop/preload.js new file mode 100644 index 000000000..5e018bf88 --- /dev/null +++ b/apps/desktop/preload.js @@ -0,0 +1,7 @@ +const { ipcRenderer } = require("electron"); + +window.addEventListener("DOMContentLoaded", () => { + ipcRenderer.on("navigate", (event, route) => { + window.location.href = route; + }); +}); diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 000000000..5a478ddec --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,2 @@ +public/images/local-chains/* +!public/images/local-chains/.gitkeep \ No newline at end of file diff --git a/apps/web/app/(register)/layout.tsx b/apps/web/app/(register)/layout.tsx new file mode 100644 index 000000000..b16822704 --- /dev/null +++ b/apps/web/app/(register)/layout.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { HomeBg } from "~/ui/home-bg"; +import { HomeBgMobile } from "~/ui/home-bg/mobile"; + +export default function RegisterLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ ); +} diff --git a/apps/web/app/(register)/register/local-chain/local-chain-schema.ts b/apps/web/app/(register)/register/local-chain/local-chain-schema.ts new file mode 100644 index 000000000..e3f5766ea --- /dev/null +++ b/apps/web/app/(register)/register/local-chain/local-chain-schema.ts @@ -0,0 +1,19 @@ +import { z, preprocess } from "zod"; + +export const localChainFormSchema = z.object({ + chainName: z.string().trim().min(1), + namespace: z.string().trim().optional(), + startHeight: z.coerce.number().int().optional(), + daLayer: z.string().trim().optional(), + logo: z + .string() + .regex(/^data:image\/([a-zA-Z]+);base64,/, "Please upload a valid image") + .nullish(), + rpcUrl: z.string().trim().url(), + rpcPlatform: preprocess( + (arg) => (typeof arg === "string" ? arg.toLowerCase() : arg), + z.enum(["cosmos"]).default("cosmos"), + ), + tokenDecimals: z.coerce.number().int().positive(), + tokenName: z.string().trim().min(1), +}); diff --git a/apps/web/app/(register)/register/local-chain/page.tsx b/apps/web/app/(register)/register/local-chain/page.tsx new file mode 100644 index 000000000..c84075e78 --- /dev/null +++ b/apps/web/app/(register)/register/local-chain/page.tsx @@ -0,0 +1,28 @@ +/* eslint-disable @next/next/no-img-element */ +import * as React from "react"; + +import { redirect } from "next/navigation"; +import { env } from "~/env"; +import { RegisterLocalChainForm } from "./register-local-chain-form"; + +import type { Metadata } from "next"; + +export interface PageProps {} + +export const metadata: Metadata = { + title: "Register a Local Chain", +}; + +export default function Page(props: PageProps) { + if (env.NEXT_PUBLIC_TARGET !== "electron") { + redirect("/"); + } + + return ( + <> +
+ +
+ + ); +} diff --git a/apps/web/app/(register)/register/local-chain/register-local-chain-form.tsx b/apps/web/app/(register)/register/local-chain/register-local-chain-form.tsx new file mode 100644 index 000000000..96c6406d8 --- /dev/null +++ b/apps/web/app/(register)/register/local-chain/register-local-chain-form.tsx @@ -0,0 +1,449 @@ +/* eslint-disable @next/next/no-img-element */ +"use client"; + +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { useFormState, useFormStatus } from "react-dom"; +import { toast } from "sonner"; +import { z } from "zod"; +import { localChainFormSchema } from "./local-chain-schema"; +import type { SingleNetwork } from "~/lib/network"; +import { jsonFetch } from "~/lib/shared-utils"; +import { EventFor } from "~/lib/types"; +import { Button } from "~/ui/button"; +import { + Camera, + CheckCircleOutline, + Home2, + Link as LinkIcon, + Loader, + Warning, +} from "~/ui/icons"; +import { Input } from "~/ui/input"; +import { Select, SelectContent, SelectItem } from "~/ui/select"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "~/ui/shadcn/components/ui/alert"; +import { cn } from "~/ui/shadcn/utils"; + +const rpcStatusResponseSchema = z.object({ + result: z.object({ + sync_info: z.object({ + catching_up: z.boolean(), + earliest_block_height: z.coerce.number(), + latest_block_height: z.coerce.number(), + }), + }), +}); + +async function readFileAsDataURL(file: File) { + return await new Promise((resolve, reject) => { + let fileReader = new FileReader(); + fileReader.onload = (e) => { + if (fileReader.result?.toString().indexOf("data:image/") === 0) { + resolve(fileReader.result.toString()); + } else { + toast.error("Error", { + description: "The file you tried to upload is not an image", + }); + reject(new Error("The file you tried to upload is not an image")); + } + }; + fileReader.readAsDataURL(file); + }); +} + +export function RegisterLocalChainForm() { + const [logoFileDataURL, setLogoFileDataURL] = React.useState( + null, + ); + const [status, action] = useFormState(formAction, null); + const formRef = React.useRef>(null); + const [isCheckingNetworkStatus, startNetworkStatusTransition] = + React.useTransition(); + const [networkStatus, setNetworkStatus] = React.useState<{ + [rpcURL: string]: "HEALTHY" | "UNHEALTHY"; + } | null>(null); + const [rpcUrl, setRPCUrl] = React.useState(""); + const router = useRouter(); + + async function formAction(_: any, formData: FormData) { + const parseResult = localChainFormSchema.safeParse( + Object.fromEntries(formData.entries()), + ); + if (parseResult.success) { + const data = parseResult.data; + const res = await jsonFetch<{ + success: boolean; + data: SingleNetwork; + }>("/api/local-chains", { + body: data, + method: "POST", + }); + + router.refresh(); + return res; + } else { + return parseResult.error.flatten(); + } + } + + function checkForNetworkStatusBeforeSubmission( + event: EventFor<"button", "onClick">, + ) { + const form = event.currentTarget.form!; + const formData = new FormData(form); + const rpcUrl = formData.get("rpcUrl")?.toString().trim(); + if (rpcUrl && networkStatus?.[rpcUrl] !== "HEALTHY") { + event.preventDefault(); + startNetworkStatusTransition( + async () => + await fetchNetworkStatus(rpcUrl).then(() => form.requestSubmit()), + ); + } + } + + async function fetchNetworkStatus(rpcURL: string) { + try { + await jsonFetch(new URL(`${rpcURL}/status`), { + credentials: undefined, + }) + .then((response) => rpcStatusResponseSchema.parse(response)) + .then(() => { + setNetworkStatus({ + [rpcURL]: "HEALTHY", + }); + }); + } catch (error) { + setNetworkStatus({ + [rpcURL]: "UNHEALTHY", + }); + } + } + + if (status && "data" in status && status?.data) { + return ; + } + + const fieldErrors = + status && "fieldErrors" in status ? status?.fieldErrors : null; + const formErrors = + status && "formErrors" in status ? status?.formErrors : null; + console.log("RENDER", { fieldErrors, formErrors }); + return ( +
+
+ Modular Cloud logo +

Register a Local chain

+
+ + {(formErrors && formErrors?.length > 0) || + (fieldErrors?.logo && ( + + + ))} + +
+ + +
+ +
+
+ +
+
+ + + + + + + +
+ + ( +
+ + {isCheckingNetworkStatus ? ( + + + Connecting + + + ) : ( + networkStatus && + (networkStatus[rpcUrl] === "HEALTHY" ? ( + +
+
+ + + Connection successful. This node is healthy. + +
+ ) : ( + +
+
+ + + Connection unsuccessful. + Please ensure your node is online. + +
+ )) + )} + +
+ + + +
+ + + + ); +} + +function SuccessStep({ network }: { network: SingleNetwork }) { + const router = useRouter(); + const [isNavigating, startTransition] = React.useTransition(); + + // Prefetch the route to make it faster to navigate to it + React.useEffect(() => { + router.prefetch(`/${network.slug}`); + }, [router, network.slug]); + + return ( +
+
+
+ +
+ 🎈 +
+

+ You have successfully registered Local Blockchain ! +

+
+
+
+
+ +
+
+ + + +
+ ); +} + +function SubmitButton({ + onClick, +}: { + onClick: (e: EventFor<"button", "onClick">) => void; +}) { + const { pending } = useFormStatus(); + return ( + + ); +} diff --git a/apps/web/app/(register)/register/page.tsx b/apps/web/app/(register)/register/page.tsx index d577ea6ac..06c850ae4 100644 --- a/apps/web/app/(register)/register/page.tsx +++ b/apps/web/app/(register)/register/page.tsx @@ -1,8 +1,6 @@ import * as React from "react"; import type { Metadata } from "next"; -import { HomeBg } from "~/ui/home-bg"; -import { HomeBgMobile } from "~/ui/home-bg/mobile"; import { RegisterForm } from "~/ui/register-form"; export const metadata: Metadata = { @@ -10,23 +8,5 @@ export const metadata: Metadata = { }; export default function RegisterPage() { - return ( -
-
- ); + return ; } diff --git a/apps/web/app/api/local-chains/[slug]/route.ts b/apps/web/app/api/local-chains/[slug]/route.ts new file mode 100644 index 000000000..302b7b791 --- /dev/null +++ b/apps/web/app/api/local-chains/[slug]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from "next/server"; +import { env } from "~/env"; +import { FileSystemCacheDEV } from "~/lib/fs-cache-dev"; +import path from "path"; +import { CACHE_KEYS } from "~/lib/cache-keys"; +import { LOCAL_CHAIN_CACHE_DIR } from "~/lib/constants"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + ctx: { params: { slug: string } }, +) { + if (env.NEXT_PUBLIC_TARGET !== "electron") { + return Response.json( + { + errors: { + root: ["this feature is only available for the electron target"], + }, + }, + { status: 403 }, + ); + } + + const slug = ctx.params.slug; + const fsCache = new FileSystemCacheDEV( + path.join(env.ROOT_USER_PATH, LOCAL_CHAIN_CACHE_DIR), + ); + + return Response.json(await fsCache.get(CACHE_KEYS.networks.single(slug))); +} diff --git a/apps/web/app/api/local-chains/route.ts b/apps/web/app/api/local-chains/route.ts new file mode 100644 index 000000000..bc9a8eabd --- /dev/null +++ b/apps/web/app/api/local-chains/route.ts @@ -0,0 +1,121 @@ +import { NextRequest } from "next/server"; +import { env } from "~/env"; +import slugify from "@sindresorhus/slugify"; +import type { SingleNetwork } from "~/lib/network"; +import { generateRandomString } from "~/lib/shared-utils"; +import crypto from "crypto"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { CACHE_KEYS } from "~/lib/cache-keys"; +import { FileSystemCacheDEV } from "~/lib/fs-cache-dev"; +import path from "path"; +import { LOCAL_CHAIN_CACHE_DIR } from "~/lib/constants"; +import { localChainFormSchema } from "~/app/(register)/register/local-chain/local-chain-schema"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + if (env.NEXT_PUBLIC_TARGET !== "electron") { + return Response.json( + { + errors: { + root: ["this feature is only available for the electron target"], + }, + }, + { status: 403 }, + ); + } + + const fsCache = new FileSystemCacheDEV( + path.join(env.ROOT_USER_PATH, LOCAL_CHAIN_CACHE_DIR), + ); + + const chainKeys = await fsCache.search(CACHE_KEYS.networks.single("local")); + + const chains = await Promise.all( + chainKeys.map((key) => fsCache.get(key)), + ); + + return Response.json(chains.filter((chain) => chain !== null)); +} + +export async function POST(request: NextRequest) { + if (env.NEXT_PUBLIC_TARGET !== "electron") { + return Response.json( + { + formErrors: ["this feature is only available for the electron target"], + }, + { status: 403 }, + ); + } + + const result = localChainFormSchema.safeParse(await request.json()); + if (!result.success) { + return Response.json(result.error.flatten(), { status: 422 }); + } + + const { logo, ...body } = result.data; + + let logoUrl = logo ?? `/images/rollkit-logo.svg`; + const fsCache = new FileSystemCacheDEV( + path.join(env.ROOT_USER_PATH, LOCAL_CHAIN_CACHE_DIR), + ); + const items = await fsCache.search(CACHE_KEYS.networks.single("local")); + + const networkSlug = `local-${slugify(body.chainName)}`; + + const chainData = { + chainName: body.chainName, + brand: "local", + slug: networkSlug, + config: { + logoUrl, + rpcUrls: { + [body.rpcPlatform]: body.rpcUrl, + }, + token: { + name: body.tokenName, + decimals: body.tokenDecimals, + }, + ecosystems: [], + cssGradient: `linear-gradient(97deg, #000 -5.89%, #1E1E1E 83.12%, #000 103.23%)`, // ecplise's default + primaryColor: "236 15% 18%", // ecplise's default + }, + namespace: body.namespace, + startHeight: body.startHeight, + daLayer: body.daLayer, + paidVersion: false, + accountId: generateRandomString(20), + internalId: items.length + 1, + integrationId: crypto.randomUUID(), + createdTime: new Date(), + } satisfies SingleNetwork; + + const { + config: { logoUrl: _, ...restConfig }, + ...rest + } = chainData; + console.log({ + CONFIG_SAVED: { + ...rest, + config: { ...restConfig }, + }, + }); + + await fsCache.set(CACHE_KEYS.networks.single(networkSlug), { + ...chainData, + createdTime: chainData.createdTime.getTime(), + }); + + const [__, localTag] = CACHE_KEYS.networks.local(); + const [allTag] = CACHE_KEYS.networks.all(); + revalidateTag(allTag); + revalidateTag(localTag); + + revalidatePath("/", "layout"); + + return Response.json({ + success: true, + data: chainData, + }); +} diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 000000000..a287b4914 --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,11 @@ +import type { Config } from "drizzle-kit"; + +export default { + schema: "./lib/db/schema/*.sql.ts", + out: "./lib/db/drizzle", + driver: "pg", + dbCredentials: { + connectionString: "postgresql://./pgdata", + // url: "db.sqlite", + }, +} satisfies Config; diff --git a/apps/web/env.js b/apps/web/env.js index 766ef6138..7d3c5074b 100644 --- a/apps/web/env.js +++ b/apps/web/env.js @@ -6,6 +6,7 @@ const { createEnv } = require("@t3-oss/env-nextjs"); const env = createEnv({ server: { DEPLOYMENT_WEBHOOK_SECRET: z.string().optional(), + ROOT_USER_PATH: z.string().optional().default(""), GITHUB_ACTION_TRIGGER_PERSONAL_ACCESS_TOKEN: z.string().optional(), NAMESPACE_ENDPOINT: z.string().url().optional(), BLOB_READ_WRITE_TOKEN: z.string().optional(), @@ -74,6 +75,7 @@ const env = createEnv({ DEPLOYMENT_WEBHOOK_SECRET: process.env.DEPLOYMENT_WEBHOOK_SECRET, GITHUB_ACTION_TRIGGER_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_ACTION_TRIGGER_PERSONAL_ACCESS_TOKEN, + ROOT_USER_PATH: process.env.ROOT_USER_PATH, }, }); diff --git a/apps/web/lib/cache-keys.ts b/apps/web/lib/cache-keys.ts index 6847ef447..b3defe39c 100644 --- a/apps/web/lib/cache-keys.ts +++ b/apps/web/lib/cache-keys.ts @@ -6,23 +6,16 @@ import type { HeadlessRoute } from "./headless-utils"; */ export const CACHE_KEYS = { networks: { - all: () => ["INTEGRATION"], + all: () => ["INTEGRATION_LIST"], + local: () => [...CACHE_KEYS.networks.all(), "INTEGRATION_LOCAL"], summary: (nexToken: string | null = null) => [ ...CACHE_KEYS.networks.all(), "INTEGRATION_SUMMARY", "INTEGRATION_SUMMARY_NEXT_TOKEN", nexToken?.slice(0, 20) ?? "null", ], - single: (slug: string) => [ - ...CACHE_KEYS.networks.all(), - "INTEGRATION_SINGLE", - slug, - ], - platform: (platform: string) => [ - ...CACHE_KEYS.networks.all(), - "platform", - platform, - ], + single: (slug: string) => ["INTEGRATION_SINGLE", slug], + platform: (platform: string) => ["platform", platform], status: (slug: string) => [ ...CACHE_KEYS.networks.single(slug), "INTEGRATION_STATUS", @@ -59,10 +52,13 @@ export const CACHE_KEYS = { txHash, msgIndex, ], - ibcResolve: (resolverId: string, input: any) => [ - "IBC_RESOLVE", - resolverId, - input, - ], + ibcResolve: ( + resolverId: string, + input: { + hash: string; + step: number; + slug: string; + }, + ) => ["IBC_RESOLVE", resolverId, input] as const, blob: (url: string) => ["BLOB", url], } as const; diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index d2d5a6bcf..936eed746 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -15,3 +15,4 @@ export const OG_SIZE = { }; export const ALWAYS_ONLINE_NETWORKS = ["celestia", "eclipse"]; +export const LOCAL_CHAIN_CACHE_DIR = ".next/cache/local-chains"; diff --git a/apps/web/lib/db/index.ts b/apps/web/lib/db/index.ts new file mode 100644 index 000000000..13bd76fb2 --- /dev/null +++ b/apps/web/lib/db/index.ts @@ -0,0 +1,31 @@ +import "server-only"; +import { localChains } from "./schema/local-chains.sql"; +import { drizzle } from "drizzle-orm/pglite"; +import { PGlite } from "@electric-sql/pglite"; +import { sql } from "drizzle-orm"; + +export async function getDbClient() { + const client = new PGlite("./pgdata"); + const db = drizzle(client, { + schema: { + localChains, + }, + }); + await db.execute(sql`CREATE TABLE IF NOT EXISTS "local_chains" ( + id SERIAL PRIMARY KEY, + internalid INTEGER, + brand TEXT NOT NULL, + chainname TEXT NOT NULL, + config JSON NOT NULL, + paidversion BOOLEAN NOT NULL, + slug TEXT NOT NULL UNIQUE, + accountid TEXT NOT NULL, + integrationid TEXT NOT NULL, + namespace TEXT, + daLayer TEXT, + startheight INTEGER, + createdtime TIMESTAMP NOT NULL +); +`); + return db; +} diff --git a/apps/web/lib/db/schema/local-chains.sql.ts b/apps/web/lib/db/schema/local-chains.sql.ts new file mode 100644 index 000000000..c75ea2eb9 --- /dev/null +++ b/apps/web/lib/db/schema/local-chains.sql.ts @@ -0,0 +1,28 @@ +import { + text, + integer, + pgTable, + serial, + json, + boolean, + timestamp, +} from "drizzle-orm/pg-core"; +import type { SingleNetwork } from "~/lib/network"; + +export const localChains = pgTable("local_chains", { + id: serial("id").primaryKey(), + internalId: integer("internalid"), + brand: text("brand").notNull(), + chainName: text("chainname").notNull(), + config: json("config").notNull().$type(), + paidVersion: boolean("paidversion").notNull(), + slug: text("slug").notNull().unique(), + accountId: text("accountid").notNull(), + integrationId: text("integrationid").notNull(), + namespace: text("namespace"), + daLayer: text("dalayer"), + startHeight: integer("startheight"), + createdTime: timestamp("createdtime").notNull(), +}); + +export type LocalChain = typeof localChains.$inferSelect; diff --git a/apps/web/lib/fs-cache-dev.ts b/apps/web/lib/fs-cache-dev.ts index 7d3b5a8c3..9ca61214b 100644 --- a/apps/web/lib/fs-cache-dev.ts +++ b/apps/web/lib/fs-cache-dev.ts @@ -11,13 +11,21 @@ export class FileSystemCacheDEV { private async initCacheDir(): Promise { try { - await fs.mkdir(this.cacheDir, { recursive: true }); + const stats = await fs.stat(this.cacheDir); + if (!stats.isDirectory()) { + await fs.mkdir(this.cacheDir, { recursive: true }); + } } catch (error) { + if ((error as any).code === "ENOENT") { + await fs.mkdir(this.cacheDir, { recursive: true }); + return; + } + console.error("Error creating cache directory:", error); } } - private async computeCacheKey(id: CacheId, updatedAt?: Date | number) { + private computeCacheKey(id: CacheId, updatedAt?: Date | number) { let fullKey = Array.isArray(id) ? id.join("-") : id.toString(); if (updatedAt) { fullKey += `-${new Date(updatedAt).getTime()}`; @@ -26,16 +34,18 @@ export class FileSystemCacheDEV { } async set(key: CacheId, value: T, ttl?: number): Promise { + await this.initCacheDir(); const cacheEntry: CacheEntry = { value, expiry: ttl ? Date.now() + ttl * 1000 : null, }; - const filePath = this.getFilePath(await this.computeCacheKey(key)); + const filePath = this.getFilePath(this.computeCacheKey(key)); await fs.writeFile(filePath, JSON.stringify(cacheEntry), "utf-8"); } - async get(key: string): Promise { - const filePath = this.getFilePath(key); + async get(key: CacheId): Promise { + await this.initCacheDir(); + const filePath = this.getFilePath(this.computeCacheKey(key)); try { const data = await fs.readFile(filePath, "utf-8"); const cacheEntry: CacheEntry = JSON.parse(data); @@ -52,6 +62,21 @@ export class FileSystemCacheDEV { } } + /** + * Search for keys in the cache, return all keys that starts with with the key passed in argument + * @param key The key to look up for + * @returns the list of keys it found + */ + async search(key: CacheId): Promise { + await this.initCacheDir(); + const files = await fs.readdir(this.cacheDir); + return Promise.all( + files + .filter((fileName) => fileName.startsWith(this.computeCacheKey(key))) + .map((file) => file.replaceAll(".json", "")), + ); + } + private getFilePath(key: string): string { return path.join(this.cacheDir, `${key}.json`); } diff --git a/apps/web/lib/network.ts b/apps/web/lib/network.ts index 84d406f33..195f02ce8 100644 --- a/apps/web/lib/network.ts +++ b/apps/web/lib/network.ts @@ -7,7 +7,7 @@ import { integrations, integrationList } from "~/lib/cache"; export const singleNetworkSchema = z.object({ config: z.object({ - logoUrl: z.string().url(), + logoUrl: z.string(), rpcUrls: z.record( z.enum(["evm", "cosmos", "svm", "celestia"]), z.string().url(), @@ -38,22 +38,56 @@ export const singleNetworkSchema = z.object({ chainName: z.string(), brand: z.string(), accountId: z.string(), - internalId: z.string(), + internalId: z.coerce.number(), integrationId: z.string().uuid(), createdTime: z.coerce.date(), + namespace: z.string().nullish(), + startHeight: z.coerce.number().nullish(), + daLayer: z.string().nullish(), }); export type SingleNetwork = z.infer; const allNetworkSchema = z.array(singleNetworkSchema); -export const getAllNetworks = cache(function getAllNetworks() { - return allNetworkSchema.parse(integrationList.value); +export const getAllNetworks = cache(async function getAllNetworks() { + let localChains: SingleNetwork[] = []; + if (env.NEXT_PUBLIC_TARGET === "electron") { + try { + localChains = await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `http://127.0.0.1:${process.env.PORT ?? 3000}/api/local-chains/`, + { + next: { tags: CACHE_KEYS.networks.local() }, + }, + ) + .then((r) => r.json()) + .then(allNetworkSchema.parse); + } catch (error) { + //... do absolutely nothing + } + } + return allNetworkSchema.parse( + localChains.concat(integrationList.value as unknown as SingleNetwork[]), + ); }); export const getSingleNetwork = cache(async function getSingleNetwork( slug: string, ) { + if (env.NEXT_PUBLIC_TARGET === "electron" && slug.startsWith("local")) { + try { + return await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `http://127.0.0.1:${process.env.PORT ?? 3000}/api/local-chains/${slug}`, + ) + .then((r) => r.json()) + .then(singleNetworkSchema.parse); + } catch (error) { + return null; + } + } + try { // @ts-expect-error const found = integrations[slug].value; @@ -122,6 +156,6 @@ async function getSingleNetworkFetch(slug: string) { } export const getAllPaidNetworks = cache(async function getAllPaidNetworks() { - const allNetworks = getAllNetworks(); + const allNetworks = await getAllNetworks(); return allNetworks.filter((network) => network.paidVersion).slice(0, 30); }); diff --git a/apps/web/lib/shared-utils.ts b/apps/web/lib/shared-utils.ts index 698e13cea..d115f554f 100644 --- a/apps/web/lib/shared-utils.ts +++ b/apps/web/lib/shared-utils.ts @@ -156,7 +156,7 @@ export async function jsonFetch( ...options, body: options.body ? JSON.stringify(options.body) : undefined, headers, - credentials: "include", + credentials: "credentials" in options ? options.credentials : "include", }) .then(async (response) => { const text = await response.text(); @@ -288,3 +288,9 @@ export function getMetadata( )} ${capitalize(network.chainName)}, brought to you by Modular Cloud.`, }; } + +export function generateRandomString(length: number): string { + return Array.from({ length }, () => + Math.floor(Math.random() * 36).toString(36), + ).join(""); +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 2d80e17b5..27d8bd235 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -13,6 +13,10 @@ const config = { }, ]; }, + experimental: { + serverComponentsExternalPackages: + process.env.NODE_ENV === "production" ? ["@electric-sql/pglite"] : [], + }, reactStrictMode: true, transpilePackages: ["service-manager"], logging: { @@ -48,6 +52,10 @@ const config = { protocol: "https", hostname: "raw.githubusercontent.com", }, + { + protocol: "http", + hostname: "127.0.0.1", + }, ], }, }; diff --git a/apps/web/package.json b/apps/web/package.json index a400763b6..fe06d7eee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,8 @@ "description": "A block exporer for modular blockchains.", "scripts": { "dev": "next dev --turbo", - "clean-dev": "rm -rf .next/cache/fs-cache && next dev --turbo", + "clean": "rm -rf .next && rm -rf lib/cache && rm db.sqlite && npm run predev && rm -r ./pgdata", + "clean-dev": "npm run clean && next dev --turbo", "build": "next build", "clean-build": "rm -rf .next/cache/fetch-cache && next build", "start": "next start", @@ -24,6 +25,7 @@ }, "main": "electron/index.js", "dependencies": { + "@electric-sql/pglite": "^0.1.2", "@headlessui/react": "^1.7.18", "@modularcloud/headless": "*", "@monaco-editor/react": "^4.5.1", @@ -39,6 +41,7 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.6", + "@sindresorhus/slugify": "^2.2.1", "@solana/web3.js": "^1.78.4", "@t3-oss/env-nextjs": "^0.6.1", "@tanstack/react-query": "^5.0.0", @@ -55,8 +58,9 @@ "date-fns": "^3.3.1", "dayjs": "^1.11.7", "decimal.js": "^10.4.3", + "drizzle-orm": "^0.30.7", "lucide-react": "^0.259.0", - "next": "^14.2.0-canary.39", + "next": "^14.2.0", "react": "^18.2.0", "react-day-picker": "^8.10.0", "react-dom": "^18.2.0", @@ -72,16 +76,19 @@ "tsx": "^4.7.1", "web3": "^1.8.2", "zod": "^3.20.6", + "zod-form-data": "^2.0.2", "zustand": "^4.4.6" }, "devDependencies": { "@babel/core": "^7.0.0", "@svgr/cli": "^8.1.0", "@todesktop/tailwind-variants": "^1.0.1", + "@types/better-sqlite3": "^7.6.9", "@types/node": "^17.0.12", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "autoprefixer": "^10.4.13", + "drizzle-kit": "^0.20.14", "eslint": "7.32.0", "eslint-config-custom": "*", "postcss": "^8.4.21", diff --git a/apps/web/public/images/local-chains/.gitkeep b/apps/web/public/images/local-chains/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/web/public/images/mc-logo.svg b/apps/web/public/images/mc-logo.svg index ba4d5f489..d2e813352 100644 --- a/apps/web/public/images/mc-logo.svg +++ b/apps/web/public/images/mc-logo.svg @@ -1,9 +1,45 @@ - - + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/ui/header/index.tsx b/apps/web/ui/header/index.tsx index 187512d7a..e56410bde 100644 --- a/apps/web/ui/header/index.tsx +++ b/apps/web/ui/header/index.tsx @@ -33,7 +33,7 @@ export async function Header({ networkSlug }: Props) { ModularCloud Logo

Modular Cloud

diff --git a/apps/web/ui/icon-svgs/Camera.svg b/apps/web/ui/icon-svgs/Camera.svg new file mode 100644 index 000000000..9db8d85bc --- /dev/null +++ b/apps/web/ui/icon-svgs/Camera.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/web/ui/icon-svgs/CheckCircleOutline.svg b/apps/web/ui/icon-svgs/CheckCircleOutline.svg new file mode 100644 index 000000000..13f978165 --- /dev/null +++ b/apps/web/ui/icon-svgs/CheckCircleOutline.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/web/ui/icon-svgs/Link.svg b/apps/web/ui/icon-svgs/Link.svg new file mode 100644 index 000000000..d467644c4 --- /dev/null +++ b/apps/web/ui/icon-svgs/Link.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/web/ui/icon-svgs/Loader.svg b/apps/web/ui/icon-svgs/Loader.svg new file mode 100644 index 000000000..21b4c3e9c --- /dev/null +++ b/apps/web/ui/icon-svgs/Loader.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/web/ui/icon-svgs/Wifi.svg b/apps/web/ui/icon-svgs/Wifi.svg new file mode 100644 index 000000000..67b22b703 --- /dev/null +++ b/apps/web/ui/icon-svgs/Wifi.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/web/ui/icons/Camera.tsx b/apps/web/ui/icons/Camera.tsx new file mode 100644 index 000000000..be0ef9911 --- /dev/null +++ b/apps/web/ui/icons/Camera.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import type { SVGProps } from "react"; +const SvgCamera = (props: SVGProps) => ( + + + + +); +export default SvgCamera; diff --git a/apps/web/ui/icons/CheckCircleOutline.tsx b/apps/web/ui/icons/CheckCircleOutline.tsx new file mode 100644 index 000000000..b5ed55d5f --- /dev/null +++ b/apps/web/ui/icons/CheckCircleOutline.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import type { SVGProps } from "react"; +const SvgCheckCircleOutline = (props: SVGProps) => ( + + + +); +export default SvgCheckCircleOutline; diff --git a/apps/web/ui/icons/Link.tsx b/apps/web/ui/icons/Link.tsx new file mode 100644 index 000000000..429abc615 --- /dev/null +++ b/apps/web/ui/icons/Link.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import type { SVGProps } from "react"; +const SvgLink = (props: SVGProps) => ( + + + +); +export default SvgLink; diff --git a/apps/web/ui/icons/Loader.tsx b/apps/web/ui/icons/Loader.tsx new file mode 100644 index 000000000..f00ae6ce4 --- /dev/null +++ b/apps/web/ui/icons/Loader.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import type { SVGProps } from "react"; +const SvgLoader = (props: SVGProps) => ( + + + +); +export default SvgLoader; diff --git a/apps/web/ui/icons/Wifi.tsx b/apps/web/ui/icons/Wifi.tsx new file mode 100644 index 000000000..22e947c0a --- /dev/null +++ b/apps/web/ui/icons/Wifi.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import type { SVGProps } from "react"; +const SvgWifi = (props: SVGProps) => ( + + + +); +export default SvgWifi; diff --git a/apps/web/ui/icons/index.ts b/apps/web/ui/icons/index.ts index bf37d6dfc..6f5e361e4 100644 --- a/apps/web/ui/icons/index.ts +++ b/apps/web/ui/icons/index.ts @@ -12,6 +12,7 @@ export { default as BellOn } from "./BellOn"; export { default as Building } from "./Building"; export { default as Calendar } from "./Calendar"; export { default as CalendarTime } from "./CalendarTime"; +export { default as Camera } from "./Camera"; export { default as CardOff } from "./CardOff"; export { default as CardOn } from "./CardOn"; export { default as CardView } from "./CardView"; @@ -19,6 +20,7 @@ export { default as ChartOff } from "./ChartOff"; export { default as ChartOn } from "./ChartOn"; export { default as Check } from "./Check"; export { default as CheckCircle } from "./CheckCircle"; +export { default as CheckCircleOutline } from "./CheckCircleOutline"; export { default as CheckOff } from "./CheckOff"; export { default as CheckOn } from "./CheckOn"; export { default as Checkmark } from "./Checkmark"; @@ -62,12 +64,14 @@ export { default as KeyboardArrowDown } from "./KeyboardArrowDown"; export { default as KeyboardArrowLeft } from "./KeyboardArrowLeft"; export { default as KeyboardArrowRight } from "./KeyboardArrowRight"; export { default as KeyboardArrowUp } from "./KeyboardArrowUp"; +export { default as Link } from "./Link"; export { default as LinkOut } from "./LinkOut"; export { default as ListView } from "./ListView"; export { default as List } from "./List"; export { default as ListViewOff } from "./ListViewOff"; export { default as ListViewOn } from "./ListViewOn"; export { default as Live } from "./Live"; +export { default as Loader } from "./Loader"; export { default as Loading } from "./Loading"; export { default as MenuHorizontal } from "./MenuHorizontal"; export { default as MessageOff } from "./MessageOff"; @@ -92,4 +96,5 @@ export { default as Undo } from "./Undo"; export { default as UserOff } from "./UserOff"; export { default as UserOn } from "./UserOn"; export { default as Warning } from "./Warning"; +export { default as Wifi } from "./Wifi"; export { default as XCircle } from "./XCircle"; diff --git a/apps/web/ui/input/index.tsx b/apps/web/ui/input/index.tsx index 23cee3d97..7cdc556a5 100644 --- a/apps/web/ui/input/index.tsx +++ b/apps/web/ui/input/index.tsx @@ -6,6 +6,7 @@ export type InputProps = Omit< "size" | "defaultValue" > & { inputClassName?: string; + labelClassName?: string; helpText?: string; renderLeadingIcon?: (classNames: string) => React.ReactNode; renderTrailingIcon?: (classNames: string) => React.ReactNode; @@ -22,6 +23,7 @@ export const Input = React.forwardRef, InputProps>( className, helpText, inputClassName, + labelClassName, label, renderLeadingIcon, renderTrailingIcon, @@ -44,7 +46,10 @@ export const Input = React.forwardRef, InputProps>( return (
-