diff --git a/apps/dashboard/src/@/components/blocks/Avatars/GradientAvatar.tsx b/apps/dashboard/src/@/components/blocks/Avatars/GradientAvatar.tsx index 1d70a27b13f..e0c411d6070 100644 --- a/apps/dashboard/src/@/components/blocks/Avatars/GradientAvatar.tsx +++ b/apps/dashboard/src/@/components/blocks/Avatars/GradientAvatar.tsx @@ -1,34 +1,8 @@ import { Img } from "@/components/blocks/Img"; -import { useMemo } from "react"; import type { ThirdwebClient } from "thirdweb"; import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler"; import { cn } from "../../../lib/utils"; - -const gradients = [ - ["#fca5a5", "#b91c1c"], - ["#fdba74", "#c2410c"], - ["#fcd34d", "#b45309"], - ["#fde047", "#a16207"], - ["#a3e635", "#4d7c0f"], - ["#86efac", "#15803d"], - ["#67e8f9", "#0e7490"], - ["#7dd3fc", "#0369a1"], - ["#93c5fd", "#1d4ed8"], - ["#a5b4fc", "#4338ca"], - ["#c4b5fd", "#6d28d9"], - ["#d8b4fe", "#7e22ce"], - ["#f0abfc", "#a21caf"], - ["#f9a8d4", "#be185d"], - ["#fda4af", "#be123c"], -]; - -function getGradientForString(str: string) { - const number = Math.abs( - str.split("").reduce((acc, b, i) => acc + b.charCodeAt(0) * (i + 1), 0), - ); - const index = number % gradients.length; - return gradients[index]; -} +import { GradientBlobbie } from "./GradientBlobbie"; export function GradientAvatar(props: { src: string | undefined; @@ -36,13 +10,6 @@ export function GradientAvatar(props: { className: string; client: ThirdwebClient; }) { - const gradient = useMemo(() => { - if (!props.id) { - return undefined; - } - return getGradientForString(props.id); - }, [props.id]); - const resolvedSrc = props.src ? resolveSchemeWithErrorHandler({ client: props.client, @@ -54,15 +21,7 @@ export function GradientAvatar(props: { - ) : undefined - } + fallback={props.id ? : undefined} /> ); } diff --git a/apps/dashboard/src/@/components/blocks/Avatars/GradientBlobbie.tsx b/apps/dashboard/src/@/components/blocks/Avatars/GradientBlobbie.tsx new file mode 100644 index 00000000000..d6446c6b147 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/Avatars/GradientBlobbie.tsx @@ -0,0 +1,45 @@ +// Note: This is also used in opengraph-image.tsx +// don't use any hooks or client side stuff here + +const gradients = [ + ["#fca5a5", "#b91c1c"], + ["#fdba74", "#c2410c"], + ["#fcd34d", "#b45309"], + ["#fde047", "#a16207"], + ["#a3e635", "#4d7c0f"], + ["#86efac", "#15803d"], + ["#67e8f9", "#0e7490"], + ["#7dd3fc", "#0369a1"], + ["#93c5fd", "#1d4ed8"], + ["#a5b4fc", "#4338ca"], + ["#c4b5fd", "#6d28d9"], + ["#d8b4fe", "#7e22ce"], + ["#f0abfc", "#a21caf"], + ["#f9a8d4", "#be185d"], + ["#fda4af", "#be123c"], +]; + +function getGradientForString(str: string) { + const number = Math.abs( + str.split("").reduce((acc, b, i) => acc + b.charCodeAt(0) * (i + 1), 0), + ); + const index = number % gradients.length; + return gradients[index]; +} + +export function GradientBlobbie(props: { + id: string; + style?: React.CSSProperties; +}) { + const gradient = getGradientForString(props.id); + return ( +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx new file mode 100644 index 00000000000..8cc4ea9c115 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx @@ -0,0 +1,129 @@ +import { GradientBlobbie } from "@/components/blocks/Avatars/GradientBlobbie"; +/* eslint-disable @next/next/no-img-element */ +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler"; +import { notFound } from "next/navigation"; +import { ImageResponse } from "next/og"; +import { resolveAvatar } from "thirdweb/extensions/ens"; +import { shortenIfAddress } from "../../../../utils/usedapp-external"; +import { resolveAddressAndEns } from "./resolveAddressAndEns"; + +export const runtime = "edge"; + +export const size = { + width: 1200, + height: 630, +}; + +type PageProps = { + params: Promise<{ + addressOrEns: string; + }>; +}; + +export default async function Image(props: PageProps) { + const client = getThirdwebClient(); + const params = await props.params; + const resolvedInfo = await resolveAddressAndEns(params.addressOrEns); + + if (!resolvedInfo) { + notFound(); + } + + const [inter700, profileBackground] = await Promise.all([ + fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) => + res.arrayBuffer(), + ), + fetch( + new URL("og-lib/assets/profile/background.png", import.meta.url), + ).then((res) => res.arrayBuffer()), + ]); + + const ensImage = resolvedInfo.ensName + ? await resolveAvatar({ + client: client, + name: resolvedInfo.ensName, + }) + : null; + + const resolvedENSImageSrc = ensImage + ? resolveSchemeWithErrorHandler({ + client: client, + uri: ensImage, + }) + : null; + + return new ImageResponse( +
+ + +
+
+ {resolvedENSImageSrc ? ( + + ) : ( + + )} + +

+ {resolvedInfo.ensName || shortenIfAddress(resolvedInfo.address)} +

+
+
+
+ +
+
+
+
, + { + ...size, + fonts: [ + { + data: inter700, + name: "Inter", + weight: 700, + }, + ], + }, + ); +} + +const OgBrandIcon: React.FC = () => ( + // biome-ignore lint/a11y/noSvgWithoutTitle: not needed + + + +); diff --git a/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/page.tsx b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/page.tsx index 173bdc95b93..3cdcd5ff828 100644 --- a/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/page.tsx @@ -39,8 +39,15 @@ export async function generateMetadata(props: PageProps): Promise { replaceDeployerAddress(resolvedInfo.ensName || resolvedInfo.address), ); + const title = displayName; + const description = `Visit ${displayName}'s profile. See their published contracts and deploy them in one click.`; + return { - title: displayName, - description: `Visit ${displayName}'s profile. See their published contracts and deploy them in one click.`, + title, + description, + openGraph: { + title, + description, + }, }; } diff --git a/apps/dashboard/src/og-lib/url-utils.ts b/apps/dashboard/src/og-lib/url-utils.ts deleted file mode 100644 index 8d000d70f37..00000000000 --- a/apps/dashboard/src/og-lib/url-utils.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getAbsoluteUrl } from "lib/vercel-utils"; - -interface OgImageProfile { - displayName: string; - bio?: string; - avatar?: string; - publishedCnt?: string; -} - -const OG_VERSION = "0.1.0"; - -type OgProps = { - profile: OgImageProfile; -}; - -function toUrl( - type: TOgType, - props: OgProps[TOgType], -): URL { - const url = new URL(`${getAbsoluteUrl()}/api/og/${type}`); - // biome-ignore lint/complexity/noForEach: FIXME - Object.entries(props).forEach(([key, value]) => { - if (value !== undefined) { - if (Array.isArray(value)) { - // biome-ignore lint/complexity/noForEach: FIXME - value.forEach((item) => - url.searchParams.append(key.toLowerCase(), item), - ); - } else { - url.searchParams.append(key.toLowerCase(), value); - } - } - }); - url.searchParams.sort(); - url.searchParams.append("og_version", OG_VERSION); - return url; -} - -function fromUrl(type: keyof OgProps, url: URL): OgProps[typeof type] { - switch (type) { - case "profile": - return { - displayName: url.searchParams.get("displayname") || "", - bio: url.searchParams.get("bio") || undefined, - avatar: url.searchParams.get("avatar") || undefined, - publishedCnt: url.searchParams.get("publishedcnt") || undefined, - } as OgProps["profile"]; - default: - throw new Error(`Unknown OG type: ${type}`); - } -} - -export const ProfileOG = { - toUrl: (props: OgProps["profile"]) => toUrl("profile", props), - fromUrl: (url: URL) => fromUrl("profile", url) as OgProps["profile"], -}; diff --git a/apps/dashboard/src/pages/api/og/profile.tsx b/apps/dashboard/src/pages/api/og/profile.tsx deleted file mode 100644 index 525bb451446..00000000000 --- a/apps/dashboard/src/pages/api/og/profile.tsx +++ /dev/null @@ -1,239 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import { isProd } from "@/constants/env"; -import { ImageResponse } from "@vercel/og"; -import type { NextRequest } from "next/server"; -import { ProfileOG } from "og-lib/url-utils"; - -// Make sure the font exists in the specified path: -export const config = { - runtime: "edge", -}; - -const image = fetch( - new URL("og-lib/assets/profile/background.png", import.meta.url), -).then((res) => res.arrayBuffer()); - -const inter400_ = fetch( - new URL("og-lib/fonts/inter/400.ttf", import.meta.url), -).then((res) => res.arrayBuffer()); -const inter500_ = fetch( - new URL("og-lib/fonts/inter/500.ttf", import.meta.url), -).then((res) => res.arrayBuffer()); -const inter700_ = fetch( - new URL("og-lib/fonts/inter/700.ttf", import.meta.url), -).then((res) => res.arrayBuffer()); - -const ibmPlexMono400_ = fetch( - new URL("og-lib/fonts/ibm-plex-mono/400.ttf", import.meta.url), -).then((res) => res.arrayBuffer()); -const ibmPlexMono500_ = fetch( - new URL("og-lib/fonts/ibm-plex-mono/500.ttf", import.meta.url), -).then((res) => res.arrayBuffer()); -const ibmPlexMono700_ = fetch( - new URL("og-lib/fonts/ibm-plex-mono/700.ttf", import.meta.url), -).then((res) => res.arrayBuffer()); - -const OgBrandIcon: React.FC = () => ( - // biome-ignore lint/a11y/noSvgWithoutTitle: not needed - - - -); - -const PackageIcon: React.FC = () => ( - // biome-ignore lint/a11y/noSvgWithoutTitle: not needed - - - - - - -); - -const MAX_LENGTH = 320; - -function descriptionShortener(description: string) { - let words = []; - let currentLength = 0; - let shortened = false; - for (const word of description.split(" ")) { - // +1 for the space - if (currentLength + word.length + 1 > MAX_LENGTH) { - shortened = true; - break; - } - words.push(word); - currentLength += word.length + 1; - } - - const lastWord = words[words.length - 1]; - if (lastWord && lastWord.length < 4) { - words = words.slice(0, -1); - } - if (lastWord?.endsWith(".")) { - return words.join(" "); - } - if (!shortened) { - return words.join(" "); - } - return `${words.join(" ")} ...`; -} - -const IPFS_GATEWAY = process.env.API_ROUTES_CLIENT_ID - ? `https://${process.env.API_ROUTES_CLIENT_ID}.${isProd ? "ipfscdn.io/ipfs/" : "thirdwebstorage-dev.com/ipfs/"}` - : "https://ipfs.io/ipfs/"; - -function replaceAnyIpfsUrlWithGateway(url: string) { - if (url.startsWith("ipfs://")) { - return `${IPFS_GATEWAY}${url.slice(7)}`; - } - if (url.includes("/ipfs/")) { - const [, after] = url.split("/ipfs/"); - return `${IPFS_GATEWAY}${after}`; - } - return url; -} - -export default async function handler(req: NextRequest) { - if (req.method !== "GET") { - return new Response("Method not allowed", { status: 405 }); - } - - const profileData = ProfileOG.fromUrl(new URL(req.url)); - - const [ - inter400, - inter500, - inter700, - ibmPlexMono400, - ibmPlexMono500, - ibmPlexMono700, - imageData, - ] = await Promise.all([ - inter400_, - inter500_, - inter700_, - ibmPlexMono400_, - ibmPlexMono500_, - ibmPlexMono700_, - image, - ]); - - return new ImageResponse( -
- - {/* the actual component starts here */} - -
- {/* title description and profile image */} -
- -

- {profileData.displayName} -

- {profileData.bio && ( -

- {descriptionShortener(profileData.bio)} -

- )} -
-
-
- - - {profileData.publishedCnt || 0} published contract - {profileData.publishedCnt === "1" ? "" : "s"} - -
-
- -
-
-
-
, - { - width: 1200, - height: 630, - fonts: [ - { - data: inter400, - name: "Inter", - weight: 400, - }, - { - data: inter500, - name: "Inter", - weight: 500, - }, - { - data: inter700, - name: "Inter", - weight: 700, - }, - { - data: ibmPlexMono400, - name: "IBM Plex Mono", - weight: 400, - }, - { - data: ibmPlexMono500, - name: "IBM Plex Mono", - weight: 500, - }, - { - data: ibmPlexMono700, - name: "IBM Plex Mono", - weight: 700, - }, - ], - }, - ); -}