From c5dfad8dbba67edb36c4be4eb9d6bb9ebd4a6a73 Mon Sep 17 00:00:00 2001 From: MananTank Date: Fri, 7 Feb 2025 15:29:20 +0000 Subject: [PATCH] [TOOL-3298] Playground: Add Insight playground (#6185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview This PR introduces several enhancements and new features to the `playground-web` application, including a new `Spinner` component, improved layout for the `APIHeader`, and various updates to forms, UI components, and hooks for better functionality and usability. ### Detailed summary - Added `isProd` export in `env.ts`. - Introduced `Spinner` component in `Spinner.tsx` with CSS animations. - Updated `Layout` to include `APIHeader` with title and links. - Enhanced `RenderCode` to support new scrollable container class. - Added `Anchor` component with improved link handling. - Modified `CodeClient` to accept new props for scrollable container. - Implemented `useShowMore` hook for dynamic item display. - Updated `tailwind.config.ts` for new color schemes. - Refactored `AppSidebar` to accept dynamic links. - Introduced `fetchAllBlueprints` function for sidebar link generation. - Added `Alert`, `Tooltip`, and `Table` components with improved styling. - Enhanced `SelectWithSearch` for better option handling and search functionality. - Updated various components to use client-side rendering (`"use client"`). - Improved error handling and loading states in image components. - Added new utility functions for better link and chain handling. > The following files were skipped due to too many changes: `apps/playground-web/src/components/ui/select.tsx`, `apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx`, `pnpm-lock.yaml` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/playground-web/package.json | 6 +- apps/playground-web/public/insight-hero.avif | Bin 0 -> 52325 bytes apps/playground-web/src/app/AppSidebar.tsx | 9 +- apps/playground-web/src/app/MobileHeader.tsx | 81 +- apps/playground-web/src/app/globals.css | 13 +- apps/playground-web/src/app/hooks/chains.ts | 39 + .../src/app/hooks/useShowMore.ts | 37 + .../blueprint-playground.client.tsx | 886 ++++++++++++++++++ .../src/app/insight/[blueprint_slug]/page.tsx | 77 ++ .../playground-web/src/app/insight/layout.tsx | 21 + apps/playground-web/src/app/insight/page.tsx | 86 ++ apps/playground-web/src/app/insight/utils.ts | 71 ++ apps/playground-web/src/app/layout.tsx | 12 +- apps/playground-web/src/app/navLinks.ts | 85 +- .../src/components/blocks/APIHeader.tsx | 4 +- .../src/components/blocks/ChainIcon.tsx | 48 + .../components/blocks/NetworkSelectors.tsx | 112 +++ .../components/blocks/select-with-search.tsx | 213 +++++ .../src/components/code/RenderCode.tsx | 8 +- .../src/components/code/code.client.tsx | 3 + .../src/components/code/getCodeHtml.tsx | 29 +- .../src/components/ui/Anchor.tsx | 33 + .../src/components/ui/CustomAccordion.tsx | 85 +- apps/playground-web/src/components/ui/Img.tsx | 78 +- .../components/ui/Spinner/Spinner.module.css | 39 + .../src/components/ui/Spinner/Spinner.tsx | 21 + .../src/components/ui/alert.tsx | 63 ++ .../src/components/ui/badge.tsx | 13 +- .../src/components/ui/breadcrumb.tsx | 116 +++ .../src/components/ui/button.tsx | 30 +- .../playground-web/src/components/ui/form.tsx | 204 ++++ .../src/components/ui/select.tsx | 88 +- .../src/components/ui/sidebar.tsx | 90 +- .../src/components/ui/table.tsx | 73 +- .../src/components/ui/tooltip.tsx | 15 +- apps/playground-web/src/lib/env.ts | 3 + apps/playground-web/tailwind.config.ts | 15 + pnpm-lock.yaml | 122 +-- 38 files changed, 2614 insertions(+), 314 deletions(-) create mode 100644 apps/playground-web/public/insight-hero.avif create mode 100644 apps/playground-web/src/app/hooks/chains.ts create mode 100644 apps/playground-web/src/app/hooks/useShowMore.ts create mode 100644 apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx create mode 100644 apps/playground-web/src/app/insight/[blueprint_slug]/page.tsx create mode 100644 apps/playground-web/src/app/insight/layout.tsx create mode 100644 apps/playground-web/src/app/insight/page.tsx create mode 100644 apps/playground-web/src/app/insight/utils.ts create mode 100644 apps/playground-web/src/components/blocks/ChainIcon.tsx create mode 100644 apps/playground-web/src/components/blocks/NetworkSelectors.tsx create mode 100644 apps/playground-web/src/components/blocks/select-with-search.tsx create mode 100644 apps/playground-web/src/components/ui/Anchor.tsx create mode 100644 apps/playground-web/src/components/ui/Spinner/Spinner.module.css create mode 100644 apps/playground-web/src/components/ui/Spinner/Spinner.tsx create mode 100644 apps/playground-web/src/components/ui/alert.tsx create mode 100644 apps/playground-web/src/components/ui/breadcrumb.tsx create mode 100644 apps/playground-web/src/components/ui/form.tsx create mode 100644 apps/playground-web/src/lib/env.ts diff --git a/apps/playground-web/package.json b/apps/playground-web/package.json index a03ffc173ba..d32a6afc9a7 100644 --- a/apps/playground-web/package.json +++ b/apps/playground-web/package.json @@ -16,6 +16,7 @@ "dependencies": { "@abstract-foundation/agw-client": "^1.4.0", "@abstract-foundation/agw-react": "^1.5.0", + "@hookform/resolvers": "^3.9.1", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-dialog": "1.1.5", @@ -38,16 +39,19 @@ "next": "15.1.6", "next-themes": "^0.4.4", "nextjs-toploader": "^1.6.12", + "openapi-types": "^12.1.3", "prettier": "3.3.3", "react": "19.0.0", "react-dom": "19.0.0", + "react-hook-form": "7.54.2", "react-pick-color": "^2.0.0", "server-only": "^0.0.1", "shiki": "1.27.0", "tailwind-merge": "^2.6.0", "thirdweb": "workspace:*", "timeago.js": "^4.0.2", - "use-debounce": "^10.0.4" + "use-debounce": "^10.0.4", + "zod": "3.24.1" }, "devDependencies": { "@types/node": "22.13.0", diff --git a/apps/playground-web/public/insight-hero.avif b/apps/playground-web/public/insight-hero.avif new file mode 100644 index 0000000000000000000000000000000000000000..9e18ed760629e8d5c3596d18e534b23e4bc1d94c GIT binary patch literal 52325 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⩔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
@@ -23,7 +24,7 @@ export function AppSidebar() { className="grow pr-4 pl-6" scrollableClassName="max-h-full pt-6" > - +
diff --git a/apps/playground-web/src/app/MobileHeader.tsx b/apps/playground-web/src/app/MobileHeader.tsx index 6ddfe8b23db..b533bb5c5e0 100644 --- a/apps/playground-web/src/app/MobileHeader.tsx +++ b/apps/playground-web/src/app/MobileHeader.tsx @@ -7,11 +7,12 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { ScrollShadow } from "../components/ui/ScrollShadow/ScrollShadow"; import { Button } from "../components/ui/button"; -import { Sidebar } from "../components/ui/sidebar"; -import { navLinks } from "./navLinks"; +import { Sidebar, type SidebarLink } from "../components/ui/sidebar"; import { otherLinks } from "./otherLinks"; -export function MobileHeader() { +export function MobileHeader(props: { + links: SidebarLink[]; +}) { const [isOpen, setIsOpen] = useState(false); useEffect(() => { @@ -27,47 +28,49 @@ export function MobileHeader() { }, [isOpen]); return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: -
{ - if (e.target instanceof HTMLAnchorElement) { - setIsOpen(false); - } - }} - > - - - - Playground - - - + <> +
+ + + + Playground + + + +
+ {isOpen && ( -
+
{ + if (e.target instanceof HTMLElement && e.target.closest("a")) { + setIsOpen(false); + } + }} + >
- +
@@ -87,6 +90,6 @@ export function MobileHeader() {
)} -
+ ); } diff --git a/apps/playground-web/src/app/globals.css b/apps/playground-web/src/app/globals.css index 388375a6e4d..5e9b8ee9a85 100644 --- a/apps/playground-web/src/app/globals.css +++ b/apps/playground-web/src/app/globals.css @@ -20,6 +20,11 @@ --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; + --destructive-text: 357.15deg 100% 68.72%; + --success-text: 142.09 70.56% 35.29%; + --warning-text: 38 92% 40%; + --inverted-foreground: 0 0% 100%; + --inverted: 0 0% 4%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 5.9% 10%; @@ -35,7 +40,7 @@ .dark { --background: 240deg 2% 11%; --foreground: 0 0% 98%; - --card: 240deg 2% 11%; + --card: 240deg 2% 13%; --card-foreground: 0 0% 98%; --popover: 240deg 2% 11%; --popover-foreground: 0 0% 98%; @@ -49,7 +54,13 @@ --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; + --destructive-text: 360 72% 55%; + --warning-text: 38 92% 50%; + --success-text: 142 75% 50%; --border: 240deg 2% 20%; + --active-border: 240deg 2% 25%; + --inverted-foreground: 0 0% 0%; + --inverted: 0 0% 100%; --input: 240deg 2% 20%; --ring: 240deg 2% 30%; --chart-1: 220 70% 50%; diff --git a/apps/playground-web/src/app/hooks/chains.ts b/apps/playground-web/src/app/hooks/chains.ts new file mode 100644 index 00000000000..45de3cc93fc --- /dev/null +++ b/apps/playground-web/src/app/hooks/chains.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { ChainMetadata } from "thirdweb/chains"; + +async function fetchChainsFromApi() { + const res = await fetch("https://api.thirdweb.com/v1/chains"); + const json = await res.json(); + + if (json.error) { + throw new Error(json.error.message); + } + + return json.data as ChainMetadata[]; +} + +export function useAllChainsData() { + const query = useQuery({ + queryKey: ["all-chains"], + queryFn: async () => { + const idToChain = new Map(); + const chains = await fetchChainsFromApi(); + + for (const c of chains) { + idToChain.set(c.chainId, c); + } + + return { + allChains: chains, + idToChain, + }; + }, + }); + + return { + isPending: query.isLoading, + data: query.data || { allChains: [], idToChain: new Map() }, + }; +} diff --git a/apps/playground-web/src/app/hooks/useShowMore.ts b/apps/playground-web/src/app/hooks/useShowMore.ts new file mode 100644 index 00000000000..052037fb172 --- /dev/null +++ b/apps/playground-web/src/app/hooks/useShowMore.ts @@ -0,0 +1,37 @@ +"use client"; + +import { useCallback, useState } from "react"; + +/** + * + * @internal + */ +export function useShowMore( + initialItemsToShow: number, + itemsToAdd: number, +) { + // start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more + const [itemsToShow, setItemsToShow] = useState(initialItemsToShow); + const lastItemRef = useCallback( + (node: T) => { + if (!node) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items + } + }, + { threshold: 1 }, + ); + + observer.observe(node); + // when the node is removed from the DOM, observer will be disconnected automatically by the browser + }, + [itemsToAdd], + ); + + return { itemsToShow, lastItemRef }; +} diff --git a/apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx b/apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx new file mode 100644 index 00000000000..6e697d3442a --- /dev/null +++ b/apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx @@ -0,0 +1,886 @@ +"use client"; + +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { CodeClient, CodeLoading } from "@/components/code/code.client"; +import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { + ArrowDownLeftIcon, + ArrowUpRightIcon, + CheckIcon, + CopyIcon, + InfoIcon, + PlayIcon, +} from "lucide-react"; +import type { OpenAPIV3 } from "openapi-types"; +import { useEffect, useMemo, useState } from "react"; +import { + type ControllerRenderProps, + type UseFormReturn, + useForm, +} from "react-hook-form"; +import { z } from "zod"; +import { isProd } from "../../../lib/env"; +import type { BlueprintParameter, BlueprintPathMetadata } from "../utils"; + +export function BlueprintPlayground(props: { + metadata: BlueprintPathMetadata; + backLink: string; + clientId: string; + path: string; + supportedChainIds: number[]; +}) { + const [abortController, setAbortController] = + useState(null); + + const requestMutation = useMutation({ + mutationFn: async (url: string) => { + const controller = new AbortController(); + setAbortController(controller); + const start = performance.now(); + try { + const res = await fetch(url, { + signal: controller.signal, + }); + return { + status: res.status, + data: await res.text(), + time: performance.now() - start, + }; + } catch (e) { + const time = performance.now() - start; + if (e instanceof Error) { + return { + data: e.message, + time: time, + }; + } + return { + data: "Failed to fetch", + time: time, + }; + } + }, + }); + + const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb"; + + return ( + { + requestMutation.mutate(url); + }} + response={ + abortController?.signal.aborted ? undefined : requestMutation.data + } + abortRequest={() => { + if (abortController) { + // just abort it - don't set a new controller + abortController.abort(); + } + }} + domain={`https://insight.${thirdwebDomain}.com`} + path={props.path} + supportedChainIds={props.supportedChainIds} + /> + ); +} + +function modifyParametersForPlayground(_parameters: BlueprintParameter[]) { + const parameters = [..._parameters]; + + // make chain query param required - its not required in open api spec - because it either has to be set in subdomain or as a query param + const chainIdParameter = parameters.find((p) => p.name === "chain"); + if (chainIdParameter) { + chainIdParameter.required = true; + } + + // remove the client id parameter if it is present - we will always replace the parameter with project's client id + const clientIdParameterIndex = parameters.findIndex( + (p) => p.name === "clientId", + ); + if (clientIdParameterIndex !== -1) { + parameters.splice(clientIdParameterIndex, 1); + } + + return parameters; +} + +export function BlueprintPlaygroundUI(props: { + backLink: string; + isPending: boolean; + onRun: (url: string) => void; + response: + | { + time: number; + data: undefined | string; + status?: number; + } + | undefined; + clientId: string; + abortRequest: () => void; + domain: string; + path: string; + metadata: BlueprintPathMetadata; + supportedChainIds: number[]; +}) { + const parameters = useMemo(() => { + const filteredParams = props.metadata.parameters?.filter( + isOpenAPIV3ParameterObject, + ); + return modifyParametersForPlayground(filteredParams || []); + }, [props.metadata.parameters]); + + const formSchema = useMemo(() => { + return createParametersFormSchema(parameters); + }, [parameters]); + + const defaultValues = useMemo(() => { + const values: Record = {}; + for (const param of parameters) { + if (param.name === "chain") { + values.chain = "1"; + } else if ( + param.schema && + "type" in param.schema && + param.schema.default + ) { + values[param.name] = param.schema.default; + } else { + values[param.name] = ""; + } + } + return values; + }, [parameters]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: defaultValues, + }); + + function onSubmit(values: z.infer) { + const url = createBlueprintUrl({ + parameters: parameters, + values: values, + clientId: props.clientId, + domain: props.domain, + path: props.path, + intent: "run", + }); + + props.onRun(url); + } + + return ( +
+ +
+
+
+ form.getValues()} + clientId={props.clientId} + /> +
+
+ +
+ +
+ +
+
+
+
+
+
+ + ); +} + +function PlaygroundHeader(props: { + parameters: BlueprintParameter[]; + isPending: boolean; + getFormValues: () => Record; + clientId: string; + domain: string; + path: string; +}) { + const [hasCopied, setHasCopied] = useState(false); + return ( +
+
+
+ {/* copy url */} + + + {/* vertical line */} +
+ + {/* domain + path */} +
+
+ {props.domain} + ... +
+
+ {props.path} +
+
+ + {/* Run */} + +
+ + +
+
+ ); +} + +function RequestConfigSection(props: { + parameters: BlueprintParameter[]; + form: ParametersForm; + domain: string; + path: string; + supportedChainIds: number[]; +}) { + const { pathVariables, queryParams, filterQueryParams } = useMemo(() => { + const pathVariables: OpenAPIV3.ParameterObject[] = []; + const queryParams: OpenAPIV3.ParameterObject[] = []; + const filterQueryParams: OpenAPIV3.ParameterObject[] = []; + + for (const param of props.parameters) { + if (param.in === "path") { + pathVariables.push(param); + } + + if (param.in === "query") { + if (param.name.startsWith("filter_")) { + filterQueryParams.push(param); + } else { + queryParams.push(param); + } + } + } + + return { + pathVariables, + queryParams, + filterQueryParams, + }; + }, [props.parameters]); + + const showError = + !props.form.formState.isValid && + props.form.formState.isDirty && + props.form.formState.isSubmitted; + + return ( +
+
+
+ + Request +
+ {showError && Invalid Request} +
+ + + {pathVariables.length > 0 && ( + + )} + + {queryParams.length > 0 && ( + + )} + + {filterQueryParams.length > 0 && ( + + )} + +
+ ); +} + +type ParametersForm = UseFormReturn<{ + [x: string]: string | number; +}>; + +function ParameterSection(props: { + parameters: BlueprintParameter[]; + title: string; + form: ParametersForm; + domain: string; + path: string; + supportedChainIds: number[]; + className?: string; +}) { + const url = `${props.domain}${props.path}`; + return ( +
+

{props.title}

+
+ {props.parameters.map((param, i) => { + const description = + param.schema && "type" in param.schema + ? param.schema.description + : undefined; + + const example = + param.schema && "type" in param.schema + ? param.schema.example + : undefined; + const exampleToShow = + typeof example === "string" || typeof example === "number" + ? example + : undefined; + + const showTip = description !== undefined || example !== undefined; + + const hasError = !!props.form.formState.errors[param.name]; + + const placeholder = url.includes(`{${param.name}}`) + ? `{${param.name}}` + : url.includes(`:${param.name}`) + ? `:${param.name}` + : "Value"; + + return ( + ( + +
+
+
+ {param.name} +
+ {param.required && ( + + Required + + )} +
+
+ {param.name === "chain" ? ( + { + field.onChange({ + target: { value: chainId.toString() }, + }); + }} + className="rounded-none border-0 border-t lg:border-none" + popoverContentClassName="min-w-[calc(100vw-20px)] lg:min-w-[500px]" + chainIds={ + props.supportedChainIds.length > 0 + ? props.supportedChainIds + : undefined + } + /> + ) : ( + <> + + + {showTip && ( + + {description && ( +

+ {description} +

+ )} + + {exampleToShow !== undefined && ( +
+

+ Example:{" "} + + {exampleToShow} + +

+
+ )} +
+ } + > + + + )} + + )} +
+
+ + + )} + /> + ); + })} +
+
+ ); +} + +function ParameterInput(props: { + param: OpenAPIV3.ParameterObject; + field: ControllerRenderProps< + { + [x: string]: string | number; + }, + string + >; + showTip: boolean; + hasError: boolean; + placeholder: string; +}) { + const { param, field, showTip, hasError, placeholder } = props; + + if (param.schema && "type" in param.schema && param.schema.enum) { + const { value, onChange, ...restField } = field; + return ( + + ); + } + + return ( + + ); +} + +function formatMilliseconds(ms: number) { + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +function ResponseSection(props: { + isPending: boolean; + response: + | { data: undefined | string; status?: number; time: number } + | undefined; + abortRequest: () => void; +}) { + const formattedData = useMemo(() => { + if (!props.response?.data) return undefined; + try { + return JSON.stringify(JSON.parse(props.response.data), null, 2); + } catch { + return props.response.data; + } + }, [props.response]); + + return ( +
+
+
+ + Response + {props.isPending && } + {props.response?.time && !props.isPending && ( + + {formatMilliseconds(props.response.time)} + + )} +
+ {!props.isPending && props.response?.status && ( + = 200 && props.response.status < 300 + ? "success" + : "destructive" + } + > + {props.response.status} + + )} +
+ + {props.isPending && ( +
+ + +
+ )} + + {!props.isPending && !props.response && ( +
+
+
+
+
+ +
+
+
+

Click Run to start a request

+
+
+ )} + + {!props.isPending && props.response && ( + } + lang="json" + code={formattedData || ""} + className="rounded-none border-none bg-transparent" + scrollableContainerClassName="h-full" + scrollableClassName="h-full" + // shadowColor="hsl(var(--muted)/50%)" + /> + )} +
+ ); +} + +function openAPIV3ParamToZodFormSchema( + schema: BlueprintParameter["schema"], + isRequired: boolean, +): z.ZodTypeAny | undefined { + if (!schema) { + return; + } + + if ("anyOf" in schema) { + const anyOf = schema.anyOf; + if (!anyOf) { + return; + } + const anySchemas = anyOf + .map((s) => openAPIV3ParamToZodFormSchema(s, isRequired)) + .filter((x) => !!x); + // @ts-expect-error - Its ok, z.union is expecting tuple type but we have array + return z.union(anySchemas); + } + + if (!("type" in schema)) { + return; + } + + // if enum values + const enumValues = schema.enum; + if (enumValues) { + const enumSchema = z.enum( + // @ts-expect-error - Its correct + enumValues, + ); + + if (isRequired) { + return enumSchema; + } + + return enumSchema.or(z.literal("")); + } + + switch (schema.type) { + case "integer": { + const intSchema = z.coerce + .number({ + message: "Must be an integer", + }) + .int({ + message: "Must be an integer", + }); + return isRequired + ? intSchema.min(1, { + message: "Required", + }) + : intSchema.optional(); + } + + case "number": { + const numberSchema = z.coerce.number(); + return isRequired + ? numberSchema.min(1, { + message: "Required", + }) + : numberSchema.optional(); + } + + case "boolean": { + const booleanSchema = z.coerce.boolean(); + return isRequired ? booleanSchema : booleanSchema.optional(); + } + + // everything else - just accept it as a string; + default: { + const stringSchema = z.string(); + return isRequired + ? stringSchema.min(1, { + message: "Required", + }) + : stringSchema.optional(); + } + } +} + +function createParametersFormSchema(parameters: BlueprintParameter[]) { + const shape: z.ZodRawShape = {}; + for (const param of parameters) { + const paramSchema = openAPIV3ParamToZodFormSchema( + param.schema, + !!param.required, + ); + if (paramSchema) { + shape[param.name] = paramSchema; + } else { + shape[param.name] = param.required + ? z.string().min(1, { message: "Required" }) + : z.string(); + } + } + + return z.object(shape); +} + +function createBlueprintUrl(options: { + parameters: BlueprintParameter[]; + values: Record; + clientId: string; + domain: string; + path: string; + intent: "copy" | "run"; +}) { + const { parameters, domain, path, values, clientId } = options; + + let url = `${domain}${path}`; + // loop over the values and replace {x} or :x with the actual values for paths + // and add query parameters + const pathVariables = parameters.filter((param) => param.in === "path"); + + const queryParams = parameters.filter((param) => param.in === "query"); + + for (const parameter of pathVariables) { + const value = values[parameter.name]; + if (value) { + url = url.replace(`{${parameter.name}}`, value); + url = url.replace(`:${parameter.name}`, value); + } + } + + const searchParams = new URLSearchParams(); + for (const parameter of queryParams) { + const value = values[parameter.name]; + if (value) { + searchParams.append(parameter.name, value); + } + } + + // add client Id search param + if (options.intent === "run") { + searchParams.append("clientId", clientId); + } else { + searchParams.append("clientId", "YOUR_THIRDWEB_CLIENT_ID"); + } + + if (searchParams.toString()) { + url = `${url}?${searchParams.toString()}`; + } + + return url; +} + +function ElapsedTimeCounter() { + const [ms, setMs] = useState(0); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const internal = 100; + const id = setInterval(() => { + setMs((prev) => prev + internal); + }, internal); + + return () => clearInterval(id); + }, []); + + return ( + + {formatMilliseconds(ms)} + + ); +} + +function isOpenAPIV3ParameterObject( + x: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject, +): x is OpenAPIV3.ParameterObject { + return !("$ref" in x); +} diff --git a/apps/playground-web/src/app/insight/[blueprint_slug]/page.tsx b/apps/playground-web/src/app/insight/[blueprint_slug]/page.tsx new file mode 100644 index 00000000000..e5fd2979d3a --- /dev/null +++ b/apps/playground-web/src/app/insight/[blueprint_slug]/page.tsx @@ -0,0 +1,77 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { redirect } from "next/navigation"; +import { THIRDWEB_CLIENT } from "../../../lib/client"; +import { fetchBlueprintSpec } from "../utils"; +import { BlueprintPlayground } from "./blueprint-playground.client"; + +export default async function Page(props: { + params: Promise<{ + blueprint_slug: string; + }>; + searchParams: Promise<{ path: string }>; +}) { + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]); + + // invalid url + if (!searchParams.path) { + redirect("/insight"); + } + + const [blueprintSpec] = await Promise.all([ + fetchBlueprintSpec({ + blueprintId: params.blueprint_slug, + }), + ]); + + const pathMetadata = blueprintSpec.openapiJson.paths[searchParams.path]?.get; + + // invalid url + if (!pathMetadata) { + redirect("/insight"); + } + + const supportedChainIds = + blueprintSpec.openapiJson.servers?.[0]?.variables?.chainId?.enum?.map( + Number, + ) || []; + + const title = pathMetadata.summary || ""; + return ( +
+ +

+ {title} +

+ +
+ ); +} + +function Breadcrumbs() { + return ( + + + + Insight Blueprints + + + + + ); +} diff --git a/apps/playground-web/src/app/insight/layout.tsx b/apps/playground-web/src/app/insight/layout.tsx new file mode 100644 index 00000000000..9760ee429f4 --- /dev/null +++ b/apps/playground-web/src/app/insight/layout.tsx @@ -0,0 +1,21 @@ +import type React from "react"; +import { APIHeader } from "../../components/blocks/APIHeader"; + +export default function Layout(props: { + children: React.ReactNode; +}) { + return ( +
+ Simple & customizable endpoints for querying rich blockchain data + } + docsLink="https://portal.thirdweb.com/insight" + heroLink="/insight-hero.avif" + /> + + {props.children} +
+ ); +} diff --git a/apps/playground-web/src/app/insight/page.tsx b/apps/playground-web/src/app/insight/page.tsx new file mode 100644 index 00000000000..47e5f96dc87 --- /dev/null +++ b/apps/playground-web/src/app/insight/page.tsx @@ -0,0 +1,86 @@ +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import {} from "lucide-react"; +import Link from "next/link"; +import { fetchAllBlueprints } from "./utils"; + +export default async function Page() { + const blueprints = await fetchAllBlueprints(); + + return ( +
+

Blueprints

+

+ A blueprint is an API that provides access to on-chain data in a + user-friendly format.
There is no need for ABIs, decoding, RPC, + or web3 knowledge to fetch blockchain data.{" "} + + Learn more about Insight Blueprints{" "} + +

+ +
+ {blueprints.map((blueprint) => { + const paths = Object.keys(blueprint.openapiJson.paths); + + return ( + { + const pathObj = blueprint.openapiJson.paths[pathName]; + if (!pathObj) { + throw new Error(`Path not found: ${pathName}`); + } + return { + name: pathObj.get?.summary || "Unknown", + link: `/insight/${blueprint.id}?path=${pathName}`, + }; + })} + /> + ); + })} +
+
+ ); +} + +function BlueprintSection(props: { + title: string; + blueprintId: string; + blueprints: { name: string; link: string }[]; +}) { + return ( +
+
+

{props.title}

+
+ + + {props.blueprints.map((item) => ( + + + + + {item.name} + + + + + ))} + +
+
+ ); +} diff --git a/apps/playground-web/src/app/insight/utils.ts b/apps/playground-web/src/app/insight/utils.ts new file mode 100644 index 00000000000..360e8f9a267 --- /dev/null +++ b/apps/playground-web/src/app/insight/utils.ts @@ -0,0 +1,71 @@ +import "server-only"; + +import type { OpenAPIV3 } from "openapi-types"; +import { isProd } from "../../lib/env"; + +type BlueprintListItem = { + id: string; + name: string; + description: string; + slug: string; +}; + +const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb"; + +async function fetchBlueprintList() { + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/blueprints`, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to fetch blueprints: ${text}`); + } + + const json = (await res.json()) as { data: BlueprintListItem[] }; + + return json.data; +} + +export type BlueprintParameter = OpenAPIV3.ParameterObject; +export type BlueprintPathMetadata = OpenAPIV3.PathItemObject; + +type BlueprintSpec = { + id: string; + name: string; + description: string; + openapiJson: OpenAPIV3.Document; +}; + +export async function fetchBlueprintSpec(params: { + blueprintId: string; +}) { + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/blueprints/${params.blueprintId}`, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to fetch blueprint: ${text}`); + } + + const json = (await res.json()) as { data: BlueprintSpec }; + + return json.data; +} + +export async function fetchAllBlueprints() { + // fetch list + const blueprintSpecs = await fetchBlueprintList(); + + // fetch all blueprints + const blueprints = await Promise.all( + blueprintSpecs.map((spec) => + fetchBlueprintSpec({ + blueprintId: spec.id, + }), + ), + ); + + return blueprints; +} diff --git a/apps/playground-web/src/app/layout.tsx b/apps/playground-web/src/app/layout.tsx index 848f06e279b..dcfbe94c1a1 100644 --- a/apps/playground-web/src/app/layout.tsx +++ b/apps/playground-web/src/app/layout.tsx @@ -8,6 +8,7 @@ import { Providers } from "./providers"; import "./globals.css"; import NextTopLoader from "nextjs-toploader"; import { MobileHeader } from "./MobileHeader"; +import { getSidebarLinks } from "./navLinks"; const sansFont = Inter({ subsets: ["latin"], @@ -27,11 +28,12 @@ export const metadata: Metadata = { description: "thirdweb playground", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const sidebarLinks = await getSidebarLinks(); return ( @@ -56,10 +58,10 @@ export default function RootLayout({ shadow={false} showSpinner={false} /> - -
- -
+ +
+ +
{children}
diff --git a/apps/playground-web/src/app/navLinks.ts b/apps/playground-web/src/app/navLinks.ts index 04fc209cb79..182a6945a87 100644 --- a/apps/playground-web/src/app/navLinks.ts +++ b/apps/playground-web/src/app/navLinks.ts @@ -1,6 +1,7 @@ import type { SidebarLink } from "../components/ui/sidebar"; +import { fetchAllBlueprints } from "./insight/utils"; -export const navLinks: SidebarLink[] = [ +export const staticSidebarLinks: SidebarLink[] = [ { name: "Connect", isCollapsible: false, @@ -117,31 +118,59 @@ export const navLinks: SidebarLink[] = [ }, ], }, - { - name: "Engine", - isCollapsible: false, - expanded: false, - links: [ - { - name: "Airdrop", - href: "/engine/airdrop", - }, - { - name: "Minting", - href: "/engine/minting", - }, - { - name: "Webhooks", - href: "/engine/webhooks", - }, - // { - // name: "Session Keys", - // href: "/engine/account-abstraction/session-keys", - // }, - // { - // name: "Smart Backend Wallets", - // href: "/engine/account-abstraction/smart-backend-wallets", - // }, - ], - }, ]; + +const engineSidebarLinks: SidebarLink = { + name: "Engine", + isCollapsible: false, + expanded: false, + links: [ + { + name: "Airdrop", + href: "/engine/airdrop", + }, + { + name: "Minting", + href: "/engine/minting", + }, + { + name: "Webhooks", + href: "/engine/webhooks", + }, + ], +}; + +export async function getSidebarLinks() { + const insightBlueprints = await fetchAllBlueprints(); + + const insightLinks: SidebarLink[] = insightBlueprints.map((blueprint) => { + const paths = Object.keys(blueprint.openapiJson.paths); + return { + name: blueprint.name, + expanded: false, + links: paths.map((pathName) => { + const pathObj = blueprint.openapiJson.paths[pathName]; + if (!pathObj) { + throw new Error(`Path not found: ${pathName}`); + } + return { + name: pathObj.get?.summary || pathName, + href: `/insight/${blueprint.id}?path=${pathName}`, + }; + }), + }; + }); + + const sidebarLinks: SidebarLink[] = [ + ...staticSidebarLinks, + { + name: "Insight", + isCollapsible: false, + expanded: false, + links: insightLinks, + }, + engineSidebarLinks, + ]; + + return sidebarLinks; +} diff --git a/apps/playground-web/src/components/blocks/APIHeader.tsx b/apps/playground-web/src/components/blocks/APIHeader.tsx index 0794b48ea23..62b447df958 100644 --- a/apps/playground-web/src/components/blocks/APIHeader.tsx +++ b/apps/playground-web/src/components/blocks/APIHeader.tsx @@ -10,7 +10,7 @@ export function APIHeader(props: { }) { return (
{props.title} -

+

{props.description}

diff --git a/apps/playground-web/src/components/blocks/ChainIcon.tsx b/apps/playground-web/src/components/blocks/ChainIcon.tsx new file mode 100644 index 00000000000..f601ccc6b76 --- /dev/null +++ b/apps/playground-web/src/components/blocks/ChainIcon.tsx @@ -0,0 +1,48 @@ +"use client"; + +/* eslint-disable @next/next/no-img-element */ +import { cn } from "@/lib/utils"; +import { Img } from "../ui/Img"; + +const fallbackChainIcon = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHZpZXdCb3g9IjAgMCA5NiA5NiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTY4LjE1MTkgNzUuNzM3MkM2Mi4yOTQzIDc5Ljk5MyA1NS4yMzk3IDgyLjI4NTIgNDcuOTk5MyA4Mi4yODUyQzQwLjc1ODkgODIuMjg1MiAzMy43MDQzIDc5Ljk5MyAyNy44NDY2IDc1LjczNzJNNjMuMDI5MSAxNy4xODM3QzY5LjUzNjggMjAuMzU3NyA3NC44NzI2IDI1LjUxMDQgNzguMjcxOCAzMS45MDMzQzgxLjY3MDkgMzguMjk2MiA4Mi45NTkgNDUuNjAxMiA4MS45NTEzIDUyLjc3MTFNMTQuMDQ3NiA1Mi43NzA4QzEzLjAzOTkgNDUuNjAwOCAxNC4zMjggMzguMjk1OSAxNy43MjcxIDMxLjkwM0MyMS4xMjYzIDI1LjUxMDEgMjYuNDYyMSAyMC4zNTczIDMyLjk2OTggMTcuMTgzM000Ni4wNTk4IDI5LjM2NzVMMjkuMzY3MyA0Ni4wNkMyOC42ODg1IDQ2LjczODkgMjguMzQ5IDQ3LjA3ODMgMjguMjIxOCA0Ny40Njk3QzI4LjExIDQ3LjgxNCAyOC4xMSA0OC4xODQ5IDI4LjIyMTggNDguNTI5MkMyOC4zNDkgNDguOTIwNiAyOC42ODg1IDQ5LjI2MDEgMjkuMzY3MyA0OS45MzlMNDYuMDU5OCA2Ni42MzE0QzQ2LjczODcgNjcuMzEwMyA0Ny4wNzgxIDY3LjY0OTcgNDcuNDY5NSA2Ny43NzY5QzQ3LjgxMzggNjcuODg4OCA0OC4xODQ3IDY3Ljg4ODggNDguNTI5IDY3Ljc3NjlDNDguOTIwNCA2Ny42NDk3IDQ5LjI1OTkgNjcuMzEwMyA0OS45Mzg4IDY2LjYzMTRMNjYuNjMxMiA0OS45MzlDNjcuMzEwMSA0OS4yNjAxIDY3LjY0OTUgNDguOTIwNiA2Ny43NzY3IDQ4LjUyOTJDNjcuODg4NiA0OC4xODQ5IDY3Ljg4ODYgNDcuODE0IDY3Ljc3NjcgNDcuNDY5N0M2Ny42NDk1IDQ3LjA3ODMgNjcuMzEwMSA0Ni43Mzg5IDY2LjYzMTIgNDYuMDZMNDkuOTM4OCAyOS4zNjc1QzQ5LjI1OTkgMjguNjg4NyA0OC45MjA0IDI4LjM0OTIgNDguNTI5IDI4LjIyMkM0OC4xODQ3IDI4LjExMDIgNDcuODEzOCAyOC4xMTAyIDQ3LjQ2OTUgMjguMjIyQzQ3LjA3ODEgMjguMzQ5MiA0Ni43Mzg3IDI4LjY4ODcgNDYuMDU5OCAyOS4zNjc1WiIgc3Ryb2tlPSIjNDA0MDQwIiBzdHJva2Utd2lkdGg9IjYuODU3MTQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K"; + +import { resolveScheme } from "thirdweb/storage"; +import { THIRDWEB_CLIENT } from "../../lib/client"; + +type ImageProps = React.ComponentProps<"img">; + +type ChainIconProps = ImageProps & { + ipfsSrc?: string; +}; + +export const ChainIcon = ({ ipfsSrc, ...restProps }: ChainIconProps) => { + const src = ipfsSrc ? replaceIpfsUrl(ipfsSrc) : fallbackChainIcon; + + return ( + } + skeleton={
} + /> + ); +}; + +function replaceIpfsUrl(uri: string) { + try { + // eslint-disable-next-line no-restricted-syntax + return resolveScheme({ + uri, + client: THIRDWEB_CLIENT, + }); + } catch (err) { + console.error("error resolving ipfs url", uri, err); + return uri; + } +} diff --git a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx new file mode 100644 index 00000000000..3f7e620e703 --- /dev/null +++ b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { useCallback, useMemo } from "react"; +import { useAllChainsData } from "../../app/hooks/chains"; +import { ChainIcon } from "./ChainIcon"; +import { SelectWithSearch } from "./select-with-search"; + +function cleanChainName(chainName: string) { + return chainName.replace("Mainnet", ""); +} + +type Option = { label: string; value: string }; + +export function SingleNetworkSelector(props: { + chainId: number | undefined; + onChange: (chainId: number) => void; + className?: string; + popoverContentClassName?: string; + // if specified - only these chains will be shown + chainIds?: number[]; + side?: "left" | "right" | "top" | "bottom"; + disableChainId?: boolean; + align?: "center" | "start" | "end"; +}) { + const { data } = useAllChainsData(); + const { allChains, idToChain } = data; + + const chainsToShow = useMemo(() => { + if (!props.chainIds) { + return allChains; + } + const chainIdSet = new Set(props.chainIds); + return allChains.filter((chain) => chainIdSet.has(chain.chainId)); + }, [allChains, props.chainIds]); + + const options = useMemo(() => { + return chainsToShow.map((chain) => { + return { + label: chain.name, + value: String(chain.chainId), + }; + }); + }, [chainsToShow]); + + const searchFn = useCallback( + (option: Option, searchValue: string) => { + const chain = idToChain.get(Number(option.value)); + if (!chain) { + return false; + } + + if (Number.isInteger(Number.parseInt(searchValue))) { + return String(chain.chainId).startsWith(searchValue); + } + return chain.name.toLowerCase().includes(searchValue.toLowerCase()); + }, + [idToChain], + ); + + const renderOption = useCallback( + (option: Option) => { + const chain = idToChain.get(Number(option.value)); + if (!chain) { + return option.label; + } + + return ( +
+ + + {cleanChainName(chain.name)} + + + {!props.disableChainId && ( + + Chain ID + {chain.chainId} + + )} +
+ ); + }, + [idToChain, props.disableChainId], + ); + + const isLoadingChains = allChains.length === 0; + + return ( + { + props.onChange(Number(chainId)); + }} + closeOnSelect={true} + placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"} + overrideSearchFn={searchFn} + renderOption={renderOption} + className={props.className} + popoverContentClassName={props.popoverContentClassName} + disabled={isLoadingChains} + side={props.side} + align={props.align} + /> + ); +} diff --git a/apps/playground-web/src/components/blocks/select-with-search.tsx b/apps/playground-web/src/components/blocks/select-with-search.tsx new file mode 100644 index 00000000000..e4bfe5e936f --- /dev/null +++ b/apps/playground-web/src/components/blocks/select-with-search.tsx @@ -0,0 +1,213 @@ +/* eslint-disable no-restricted-syntax */ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { CheckIcon, ChevronDown, SearchIcon } from "lucide-react"; +import React, { useRef, useMemo, useEffect } from "react"; +import { useShowMore } from "../../app/hooks/useShowMore"; +import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow"; +import { Input } from "../ui/input"; + +interface SelectWithSearchProps + extends React.ButtonHTMLAttributes { + options: { + label: string; + value: string; + }[]; + value: string | undefined; + onValueChange: (value: string) => void; + placeholder: string; + searchPlaceholder?: string; + className?: string; + overrideSearchFn?: ( + option: { value: string; label: string }, + searchTerm: string, + ) => boolean; + renderOption?: (option: { value: string; label: string }) => React.ReactNode; + popoverContentClassName?: string; + side?: "left" | "right" | "top" | "bottom"; + align?: "center" | "start" | "end"; + closeOnSelect?: boolean; +} + +export const SelectWithSearch = React.forwardRef< + HTMLButtonElement, + SelectWithSearchProps +>( + ( + { + options, + onValueChange, + placeholder, + className, + value, + renderOption, + overrideSearchFn, + popoverContentClassName, + searchPlaceholder, + closeOnSelect, + ...props + }, + ref, + ) => { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(""); + const selectedOption = useMemo( + () => options.find((option) => option.value === value), + [options, value], + ); + + // show 50 initially and then 20 more when reaching the end + const { itemsToShow, lastItemRef } = useShowMore(50, 20); + + const optionsToShow = useMemo(() => { + const filteredOptions: { + label: string; + value: string; + }[] = []; + + const searchValLowercase = searchValue.toLowerCase(); + + for (let i = 0; i <= options.length - 1; i++) { + if (filteredOptions.length >= itemsToShow) { + break; + } + const option = options[i]; + if (!option) { + continue; + } + + if (overrideSearchFn) { + if (overrideSearchFn(option, searchValLowercase)) { + filteredOptions.push(option); + } + } else { + if (option.label.toLowerCase().includes(searchValLowercase)) { + filteredOptions.push(option); + } + } + } + + return filteredOptions; + }, [options, searchValue, itemsToShow, overrideSearchFn]); + + // scroll to top when options change + const popoverElRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + const scrollContainer = + popoverElRef.current?.querySelector("[data-scrollable]"); + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + }); + } + }, [searchValue]); + + return ( + + + + + + setIsPopoverOpen(false)} + style={{ + width: "var(--radix-popover-trigger-width)", + maxHeight: "var(--radix-popover-content-available-height)", + }} + ref={popoverElRef} + > +
+ {/* Search */} +
+ setSearchValue(e.target.value)} + className="!h-auto rounded-b-none border-0 border-border border-b py-3 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0" + /> + +
+ + + {/* List */} +
+ {optionsToShow.length === 0 && ( +
+ No results found +
+ )} + + {optionsToShow.map((option, i) => { + const isSelected = value === option.value; + return ( + + ); + })} +
+
+
+
+
+ ); + }, +); + +SelectWithSearch.displayName = "SelectWithSearch"; diff --git a/apps/playground-web/src/components/code/RenderCode.tsx b/apps/playground-web/src/components/code/RenderCode.tsx index 3c5a4157aba..e24165ea2bf 100644 --- a/apps/playground-web/src/components/code/RenderCode.tsx +++ b/apps/playground-web/src/components/code/RenderCode.tsx @@ -7,17 +7,21 @@ export function RenderCode(props: { html: string; className?: string; scrollableClassName?: string; + scrollableContainerClassName?: string; }) { return (
= ({ loader, className, scrollableClassName, + scrollableContainerClassName, }) => { const codeQuery = useQuery({ queryKey: ["html", code], @@ -46,6 +48,7 @@ export const CodeClient: React.FC = ({ html={codeQuery.data.html} className={className} scrollableClassName={scrollableClassName} + scrollableContainerClassName={scrollableContainerClassName} /> ); }; diff --git a/apps/playground-web/src/components/code/getCodeHtml.tsx b/apps/playground-web/src/components/code/getCodeHtml.tsx index f670101b3e7..d3cb902aadf 100644 --- a/apps/playground-web/src/components/code/getCodeHtml.tsx +++ b/apps/playground-web/src/components/code/getCodeHtml.tsx @@ -1,14 +1,29 @@ import * as parserBabel from "prettier/plugins/babel"; import * as estree from "prettier/plugins/estree"; import { format } from "prettier/standalone"; -import { codeToHtml } from "shiki"; +import { type BundledLanguage, codeToHtml } from "shiki"; -export async function getCodeHtml(code: string, lang: string) { - const formattedCode = await format(code, { - parser: "babel-ts", - plugins: [parserBabel, estree], - printWidth: 60, - }); +function isPrettierSupportedLang(lang: BundledLanguage) { + return ( + lang === "js" || + lang === "jsx" || + lang === "ts" || + lang === "tsx" || + lang === "javascript" || + lang === "typescript" + ); +} + +export async function getCodeHtml(code: string, lang: BundledLanguage) { + const formattedCode = isPrettierSupportedLang(lang) + ? await format(code, { + parser: "babel-ts", + plugins: [parserBabel, estree], + printWidth: 60, + }).catch(() => { + return code; + }) + : code; const html = await codeToHtml(formattedCode, { lang: lang, diff --git a/apps/playground-web/src/components/ui/Anchor.tsx b/apps/playground-web/src/components/ui/Anchor.tsx new file mode 100644 index 00000000000..f3c04ade91f --- /dev/null +++ b/apps/playground-web/src/components/ui/Anchor.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Link as LinkIcon } from "lucide-react"; + +export function Anchor(props: { + id: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {props.children} + {props.id && ( + { + e.stopPropagation(); + }} + > + + + )} +
+ ); +} diff --git a/apps/playground-web/src/components/ui/CustomAccordion.tsx b/apps/playground-web/src/components/ui/CustomAccordion.tsx index bd5e6c6c031..6cdc8320680 100644 --- a/apps/playground-web/src/components/ui/CustomAccordion.tsx +++ b/apps/playground-web/src/components/ui/CustomAccordion.tsx @@ -3,9 +3,11 @@ import { cn } from "@/lib/utils"; import { ChevronDown } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; +import { Anchor } from "./Anchor"; import { DynamicHeight } from "./DynamicHeight"; type CustomAccordionProps = { + id?: string; chevronPosition?: "left" | "right"; trigger: React.ReactNode; children: React.ReactNode; @@ -18,52 +20,73 @@ type CustomAccordionProps = { export function CustomAccordion(props: CustomAccordionProps) { const [isOpen, setIsOpen] = useState(props.defaultOpen || false); - const elRef = useRef(null); const contentId = useId(); const buttonId = useId(); + const accordionContentRef = useRef(null); - // when another accordion is opened, close this one + const accordionAnchorId = props.id; + + // if window hash matches current accordion's id, open it + const accordionIdMatchChecked = useRef(false); useEffect(() => { - if (!isOpen) { + if (accordionIdMatchChecked.current) { return; } + accordionIdMatchChecked.current = true; + const hash = window.location.hash; + if (hash && hash === `#${accordionAnchorId}`) { + setTimeout(() => { + setIsOpen(true); + setTimeout(() => { + accordionContentRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, 500); + }, 500); + } + }, [accordionAnchorId]); - const selfEl = elRef.current; - - if (!selfEl) { + // if window hash matches any child accordion's id, open it + const accordionContentChecked = useRef(false); + useEffect(() => { + const accordionContentEl = accordionContentRef.current; + if (!accordionContentEl || accordionContentChecked.current) { return; } - const allAccordions = selfEl.parentElement?.querySelectorAll( - "[data-custom-accordion]", - ); + accordionContentChecked.current = true; - if (!allAccordions) { + const hash = window.location.hash; + if (!hash) { return; } - for (const accordion of allAccordions) { - if (!(accordion instanceof HTMLElement)) { - continue; - } - - if (accordion === selfEl) { - continue; - } + // if any child element has an anchor tag with href that matches the hash, open the accordion + const containsMatchingAnchor = Array.from( + accordionContentEl.querySelectorAll("a[href^='#']"), + ).find((childAccordion) => { + const href = childAccordion.getAttribute("href"); + return href === hash; + }); - accordion.addEventListener("click", () => { - if (isOpen) { - setIsOpen(false); - } - }); + if (containsMatchingAnchor) { + setTimeout(() => { + setIsOpen(true); + setTimeout(() => { + containsMatchingAnchor.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, 500); + }, 500); } - }, [isOpen]); + }, []); return (
@@ -107,7 +134,9 @@ export function CustomAccordion(props: CustomAccordionProps) { !isOpen && "hidden", )} > -
{props.children}
+
+ {props.children} +
diff --git a/apps/playground-web/src/components/ui/Img.tsx b/apps/playground-web/src/components/ui/Img.tsx index b61d6b16d35..98910e168c0 100644 --- a/apps/playground-web/src/components/ui/Img.tsx +++ b/apps/playground-web/src/components/ui/Img.tsx @@ -1,30 +1,84 @@ -"use client"; /* eslint-disable @next/next/no-img-element */ -import { useState } from "react"; +"use client"; +import { useLayoutEffect, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; type imgElementProps = React.DetailedHTMLProps< React.ImgHTMLAttributes, HTMLImageElement ->; +> & { + skeleton?: React.ReactNode; + fallback?: React.ReactNode; + src: string | undefined; +}; export function Img(props: imgElementProps) { - const [isLoading, setIsLoading] = useState(true); + const [_status, setStatus] = useState<"pending" | "fallback" | "loaded">( + "pending", + ); + const status = + props.src === undefined + ? "pending" + : props.src === "" + ? "fallback" + : _status; + const { className, fallback, skeleton, ...restProps } = props; + const defaultSkeleton =
; + const defaultFallback =
; + const imgRef = useRef(null); + + useLayoutEffect(() => { + const imgEl = imgRef.current; + if (!imgEl) { + return; + } + if (imgEl.complete) { + setStatus("loaded"); + } else { + function handleLoad() { + setStatus("loaded"); + } + imgEl.addEventListener("load", handleLoad); + return () => { + imgEl.removeEventListener("load", handleLoad); + }; + } + }, []); + return (
- {/* biome-ignore lint/a11y/useAltText: */} { - setIsLoading(false); + {...restProps} + // avoid setting empty src string to prevent request to the entire page + src={restProps.src || undefined} + ref={imgRef} + onError={() => { + setStatus("fallback"); }} style={{ - opacity: isLoading ? 0 : 1, + opacity: status === "loaded" ? 1 : 0, + ...restProps.style, }} + alt={restProps.alt || ""} + className={cn( + "fade-in-0 object-cover transition-opacity duration-300", + className, + )} + decoding="async" /> - {isLoading && ( -
+ + {status !== "loaded" && ( +
*]:h-full [&>*]:w-full", + className, + )} + > + {status === "pending" && (skeleton || defaultSkeleton)} + {status === "fallback" && (fallback || defaultFallback)} +
)}
); diff --git a/apps/playground-web/src/components/ui/Spinner/Spinner.module.css b/apps/playground-web/src/components/ui/Spinner/Spinner.module.css new file mode 100644 index 00000000000..0adc57ba75e --- /dev/null +++ b/apps/playground-web/src/components/ui/Spinner/Spinner.module.css @@ -0,0 +1,39 @@ +.loader { + border-radius: 50%; + position: relative; + animation: rotate 1s linear infinite; + animation: rotate 2s linear infinite; +} + +.loader circle { + content: ""; + box-sizing: border-box; + position: absolute; + inset: 0px; + border-radius: 50%; + border: 4px solid #fff; + animation: prixClipFix 2s linear infinite; + stroke-linecap: round; + animation: dash 1.5s ease-in-out infinite; +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + 0% { + stroke-dasharray: 1, 150; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -35; + } + 100% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -124; + } +} diff --git a/apps/playground-web/src/components/ui/Spinner/Spinner.tsx b/apps/playground-web/src/components/ui/Spinner/Spinner.tsx new file mode 100644 index 00000000000..c1f7d59f39d --- /dev/null +++ b/apps/playground-web/src/components/ui/Spinner/Spinner.tsx @@ -0,0 +1,21 @@ +import { cn } from "../../../lib/utils"; +import style from "./Spinner.module.css"; + +export function Spinner(props: { className?: string }) { + return ( + + loading + + + ); +} diff --git a/apps/playground-web/src/components/ui/alert.tsx b/apps/playground-web/src/components/ui/alert.tsx new file mode 100644 index 00000000000..7994c5cfbd8 --- /dev/null +++ b/apps/playground-web/src/components/ui/alert.tsx @@ -0,0 +1,63 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "bg-card relative w-full rounded-lg border border-border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "text-foreground", + destructive: "text-destructive-text [&>svg]:text-destructive-text", + info: "text-link-foreground [&>svg]:text-link-foreground", + warning: "text-warning-text [&>svg]:text-warning-text", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/playground-web/src/components/ui/badge.tsx b/apps/playground-web/src/components/ui/badge.tsx index 6d5a5c0a5d2..078708826de 100644 --- a/apps/playground-web/src/components/ui/badge.tsx +++ b/apps/playground-web/src/components/ui/badge.tsx @@ -4,17 +4,20 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-full border border-border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 leading-4", { variants: { variant: { - default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + default: "border-transparent bg-primary/20 text-primary", secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + "border-transparent bg-accent text-accent-foreground hover:bg-accent/80", destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + "border-transparent dark:bg-red-950 dark:text-red-400 bg-red-500/20 text-red-800", + warning: + "border-transparent dark:bg-yellow-600/20 dark:text-yellow-500 bg-yellow-500/20 text-yellow-900", outline: "text-foreground", + success: + "border-transparent dark:bg-green-950/50 dark:text-green-400 bg-green-200 text-green-950", }, }, defaultVariants: { diff --git a/apps/playground-web/src/components/ui/breadcrumb.tsx b/apps/playground-web/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000000..e8f9f5cbf1c --- /dev/null +++ b/apps/playground-web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,116 @@ +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>