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 00000000000..9e18ed76062 Binary files /dev/null and b/apps/playground-web/public/insight-hero.avif differ diff --git a/apps/playground-web/src/app/AppSidebar.tsx b/apps/playground-web/src/app/AppSidebar.tsx index 9ad5e739248..dc9bffe5e76 100644 --- a/apps/playground-web/src/app/AppSidebar.tsx +++ b/apps/playground-web/src/app/AppSidebar.tsx @@ -1,12 +1,13 @@ import thirdwebIconSrc from "@/../public/thirdweb.svg"; -import { Sidebar } from "@/components/ui/sidebar"; +import { Sidebar, type SidebarLink } from "@/components/ui/sidebar"; import Image from "next/image"; import Link from "next/link"; import { ScrollShadow } from "../components/ui/ScrollShadow/ScrollShadow"; -import { navLinks } from "./navLinks"; import { otherLinks } from "./otherLinks"; -export function AppSidebar() { +export function AppSidebar(props: { + links: SidebarLink[]; +}) { return (
@@ -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) =>