perf: og image caching v2 (#23189)
This commit is contained in:
@@ -17,21 +17,21 @@ export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
if (!p.success) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const props = await getStaticProps(p.data.slug);
|
||||
const slugFromUrl = p.data.slug;
|
||||
const props = await getStaticProps(slugFromUrl);
|
||||
|
||||
if (!props) {
|
||||
notFound();
|
||||
}
|
||||
const { name, logo, description } = props.data;
|
||||
const { name, logo, dirName: appStoreDirSlug, slug: appSlug, description } = props.data;
|
||||
|
||||
return await generateAppMetadata(
|
||||
{ slug: logo, name, description },
|
||||
{ slug: appStoreDirSlug ?? appSlug, logoUrl: logo, name, description },
|
||||
() => name,
|
||||
() => description,
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/${p.data.slug}`
|
||||
`/apps/${appSlug}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -66,10 +66,10 @@ export const _generateMetadata = async (
|
||||
);
|
||||
const image =
|
||||
SEO_IMG_OGIMG +
|
||||
constructGenericImage({
|
||||
(await constructGenericImage({
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
@@ -80,7 +80,7 @@ export const _generateMetadata = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const _generateMetadataForStaticPage = (
|
||||
export const _generateMetadataForStaticPage = async (
|
||||
title: string,
|
||||
description: string,
|
||||
hideBranding?: boolean,
|
||||
@@ -108,10 +108,10 @@ export const _generateMetadataForStaticPage = (
|
||||
};
|
||||
const image =
|
||||
SEO_IMG_OGIMG +
|
||||
constructGenericImage({
|
||||
(await constructGenericImage({
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
@@ -137,7 +137,7 @@ export const generateMeetingMetadata = async (
|
||||
origin,
|
||||
pathname
|
||||
);
|
||||
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
|
||||
const image = SEO_IMG_OGIMG + (await constructMeetingImage(meeting));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
@@ -164,7 +164,7 @@ export const generateAppMetadata = async (
|
||||
pathname
|
||||
);
|
||||
|
||||
const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description });
|
||||
const image = SEO_IMG_OGIMG + (await constructAppImage({ ...app, description: metadata.description }));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { getOGImageVersion } from "@calcom/lib/OgImages";
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
vi.mock("next/og", () => ({
|
||||
@@ -14,14 +16,19 @@ vi.mock("next/og", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/OgImages", () => ({
|
||||
Meeting: vi.fn(() => null),
|
||||
App: vi.fn(() => null),
|
||||
Generic: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("@calcom/lib/OgImages", async (importOriginal) => {
|
||||
return await importOriginal();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
vi.mock(import("@calcom/lib/constants"), async (importOriginal) => {
|
||||
return await importOriginal();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/web/public/app-store/svg-hashes.json", () => ({
|
||||
default: {
|
||||
huddle01video: "81a0653b",
|
||||
zoomvideo: "d1c78abf",
|
||||
},
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
@@ -82,7 +89,7 @@ describe("GET /api/social/og/image", () => {
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
expect(await response.text()).toBe("Wrong image type");
|
||||
});
|
||||
|
||||
test("returns 404 when invalid type parameter is provided", async () => {
|
||||
@@ -90,7 +97,7 @@ describe("GET /api/social/og/image", () => {
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
expect(await response.text()).toBe("Wrong image type");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,4 +114,33 @@ describe("GET /api/social/og/image", () => {
|
||||
expect(await response.text()).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOGImageVersion with SVG hash", () => {
|
||||
test("app type: ETag changes when SVG hash is provided", async () => {
|
||||
const etagWithoutHash = await getOGImageVersion("app");
|
||||
const etagWithHash = await getOGImageVersion("app", {
|
||||
slug: "huddle01video",
|
||||
svgHash: "81a0653b",
|
||||
});
|
||||
|
||||
expect(etagWithoutHash).toBeTruthy();
|
||||
expect(etagWithHash).toBeTruthy();
|
||||
expect(etagWithHash).not.toBe(etagWithoutHash);
|
||||
});
|
||||
|
||||
test("app type: different SVG hashes produce different ETags", async () => {
|
||||
const etagHash1 = await getOGImageVersion("app", {
|
||||
slug: "huddle01video",
|
||||
svgHash: "81a0653b",
|
||||
});
|
||||
const etagHash2 = await getOGImageVersion("app", {
|
||||
slug: "zoomvideo",
|
||||
svgHash: "d1c78abf",
|
||||
});
|
||||
|
||||
expect(etagHash1).toBeTruthy();
|
||||
expect(etagHash2).toBeTruthy();
|
||||
expect(etagHash1).not.toBe(etagHash2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
|
||||
import type { SatoriOptions } from "satori";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||
import { Meeting, App, Generic, getOGImageVersion } from "@calcom/lib/OgImages";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export const runtime = "edge";
|
||||
@@ -22,6 +22,7 @@ const appSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
slug: z.string(),
|
||||
logoUrl: z.string(),
|
||||
});
|
||||
|
||||
const genericSchema = z.object({
|
||||
@@ -74,6 +75,7 @@ async function handler(req: NextRequest) {
|
||||
imageType,
|
||||
});
|
||||
|
||||
const etag = await getOGImageVersion("meeting");
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
@@ -89,7 +91,9 @@ async function handler(req: NextRequest) {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
"Cache-Control":
|
||||
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
|
||||
ETag: `"${etag}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -111,19 +115,32 @@ async function handler(req: NextRequest) {
|
||||
}
|
||||
case "app": {
|
||||
try {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
const { name, description, slug, logoUrl } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
logoUrl: searchParams.get("logoUrl"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
// Get SVG hash for the app
|
||||
const svgHashesModule = await import("@calcom/web/public/app-store/svg-hashes.json");
|
||||
const SVG_HASHES = svgHashesModule.default ?? {};
|
||||
const svgHash = SVG_HASHES[slug] ?? undefined;
|
||||
|
||||
const etag = await getOGImageVersion("app", { svgHash });
|
||||
const img = new ImageResponse(
|
||||
<App name={name} description={description} slug={slug} logoUrl={logoUrl} />,
|
||||
ogConfig
|
||||
);
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
"Cache-Control":
|
||||
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
|
||||
ETag: `"${etag}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -151,13 +168,16 @@ async function handler(req: NextRequest) {
|
||||
imageType,
|
||||
});
|
||||
|
||||
const etag = await getOGImageVersion("generic");
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
"Cache-Control":
|
||||
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
|
||||
ETag: `"${etag}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -178,9 +198,9 @@ async function handler(req: NextRequest) {
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
return new Response("Wrong image type", { status: 404 });
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare module "@calcom/web/public/app-store/svg-hashes.json" {
|
||||
const value: Record<string, string>;
|
||||
export default value;
|
||||
}
|
||||
@@ -10,13 +10,10 @@ import { lucideIconList } from "../../../../packages/ui/components/icon/icon-lis
|
||||
import { IconGrid } from "./IconGrid";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const metadata: Metadata = _generateMetadataForStaticPage(
|
||||
"Icons Showcase",
|
||||
"",
|
||||
undefined,
|
||||
undefined,
|
||||
"/icons"
|
||||
);
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const glob = require("glob");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const copyAppStoreStatic = () => {
|
||||
// Get all static files from app-store packages
|
||||
const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true });
|
||||
|
||||
// Object to store icon SVG hashes
|
||||
const SVG_HASHES = {};
|
||||
|
||||
staticFiles.forEach((file) => {
|
||||
// Extract app name from path
|
||||
const appNameMatch = file.match(/app-store\/(.*?)\/static/);
|
||||
if (!appNameMatch) return;
|
||||
|
||||
const appName = appNameMatch[1];
|
||||
const appDirName = appNameMatch[1];
|
||||
const fileName = path.basename(file);
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
const destDir = path.join(process.cwd(), "public", "app-store", appName);
|
||||
const destDir = path.join(process.cwd(), "public", "app-store", appDirName);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
@@ -23,8 +26,20 @@ const copyAppStoreStatic = () => {
|
||||
// Copy file to destination (Turborepo caching handles change detection)
|
||||
const destPath = path.join(destDir, fileName);
|
||||
fs.copyFileSync(file, destPath);
|
||||
|
||||
// If it's an icon SVG file, compute hash
|
||||
if (fileName.includes("icon") && fileName.endsWith(".svg")) {
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
|
||||
SVG_HASHES[appDirName] = hash;
|
||||
}
|
||||
|
||||
console.log(`Copied ${file} to ${destPath}`);
|
||||
});
|
||||
|
||||
// Write SVG hashes to a JSON file
|
||||
const hashFilePath = path.join(process.cwd(), "public", "app-store", "svg-hashes.json");
|
||||
fs.writeFileSync(hashFilePath, JSON.stringify(SVG_HASHES, null, 2));
|
||||
};
|
||||
|
||||
// Run the copy function
|
||||
|
||||
+140
-60
@@ -20,6 +20,7 @@ export interface AppImageProps {
|
||||
name: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
logoUrl: string;
|
||||
}
|
||||
|
||||
export interface GenericImageProps {
|
||||
@@ -48,6 +49,48 @@ const joinMultipleNames = (names: string[] = []) => {
|
||||
|
||||
const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CAL_URL}${url}`);
|
||||
|
||||
const OG_ASSETS = {
|
||||
meeting: {
|
||||
id: "meeting-og-image-v1", // Bump version when changing Meeting component structure/styling
|
||||
logo: LOGO,
|
||||
logoWidth: "350",
|
||||
avatarSize: "160",
|
||||
variant: "dark" as const,
|
||||
},
|
||||
app: {
|
||||
id: "app-og-image-v1", // Bump version when changing App component structure/styling
|
||||
logo: LOGO,
|
||||
logoWidth: "150",
|
||||
iconSize: "172",
|
||||
variant: "light" as const,
|
||||
},
|
||||
generic: {
|
||||
id: "generic-og-image-v1", // Bump version when changing Generic component structure/styling
|
||||
logo: "cal-logo-word-black.svg",
|
||||
logoWidth: "350",
|
||||
variant: "light" as const,
|
||||
},
|
||||
};
|
||||
|
||||
export const getOGImageVersion = async (
|
||||
type: keyof typeof OG_ASSETS,
|
||||
additionalInputs?: Record<string, string>
|
||||
) => {
|
||||
const versionInputs: Record<string, unknown> = {
|
||||
...OG_ASSETS[type],
|
||||
...(additionalInputs ?? {}),
|
||||
};
|
||||
|
||||
const content = JSON.stringify(versionInputs, Object.keys(versionInputs).sort());
|
||||
|
||||
// Use Web Crypto API instead of Node.js crypto for Edge Runtime compatibility (`/api/social/og/image` is an Edge Runtime route)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
return hashHex.substring(0, 8);
|
||||
};
|
||||
|
||||
/**
|
||||
* Test urls:
|
||||
* 1. 1 user http://localhost:3000/api/social/og/image?type=meeting&title=super%20long%20event%20title%20for%20testing%20purposes&meetingProfileName=Pro%20Example&meetingImage=http://localhost:3000/pro/avatar.png&names=Pro%20Example&usernames=pro
|
||||
@@ -56,7 +99,11 @@ const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CA
|
||||
* 4. Team event (round robin) http://localhost:3000/api/social/og/image?type=meeting&title=Round%20Robin%20Seeded%20Team%20Event&meetingProfileName=Seeded%20Team
|
||||
* 5. Dynamic collective (2 persons) http://localhost:3000/api/social/og/image?type=meeting&title=15min&meetingProfileName=Team%20Pro%20Example,%20Pro%20Example&names=Team%20Pro%20Example&names=Pro%20Example&usernames=teampro&usernames=pro
|
||||
*/
|
||||
export const constructMeetingImage = ({ title, users = [], profile }: MeetingImageProps): string => {
|
||||
export const constructMeetingImage = async ({
|
||||
title,
|
||||
users = [],
|
||||
profile,
|
||||
}: MeetingImageProps): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
type: "meeting",
|
||||
title,
|
||||
@@ -72,6 +119,8 @@ export const constructMeetingImage = ({ title, users = [], profile }: MeetingIma
|
||||
params.append("usernames", user.username);
|
||||
});
|
||||
|
||||
params.set("v", await getOGImageVersion("meeting"));
|
||||
|
||||
return encodeURIComponent(`/api/social/og/image?${params.toString()}`);
|
||||
};
|
||||
|
||||
@@ -79,22 +128,34 @@ export const constructMeetingImage = ({ title, users = [], profile }: MeetingIma
|
||||
* Test url:
|
||||
* http://localhost:3000/api/social/og/image?type=app&name=Huddle01&slug=/api/app-store/huddle01video/icon.svg&description=Huddle01%20is%20a%20new%20video%20conferencing%20software%20native%20to%20Web3%20and%20is%20comparable%20to%20a%20decentralized%20version%20of%20Zoom.%20It%20supports%20conversations%20for...
|
||||
*/
|
||||
export const constructAppImage = ({ name, slug, description }: AppImageProps): string => {
|
||||
export const constructAppImage = async ({
|
||||
name,
|
||||
slug,
|
||||
logoUrl,
|
||||
description,
|
||||
}: AppImageProps): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
type: "app",
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
logoUrl,
|
||||
});
|
||||
|
||||
params.set("v", await getOGImageVersion("app"));
|
||||
|
||||
return encodeURIComponent(`/api/social/og/image?${params.toString()}`);
|
||||
};
|
||||
|
||||
export const constructGenericImage = ({ title, description }: GenericImageProps) => {
|
||||
export const constructGenericImage = async ({ title, description }: GenericImageProps): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
type: "generic",
|
||||
title,
|
||||
description,
|
||||
});
|
||||
|
||||
params.set("v", await getOGImageVersion("generic"));
|
||||
|
||||
return encodeURIComponent(`/api/social/og/image?${params.toString()}`);
|
||||
};
|
||||
|
||||
@@ -112,7 +173,14 @@ const Wrapper = ({ children, variant = "light", rotateBackground }: WrapperProps
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output,
|
||||
* remember to bump the version in OG_ASSETS.meeting.id (e.g., "meeting-og-image-v1" → "meeting-og-image-v2")
|
||||
* to ensure proper cache invalidation.
|
||||
*/
|
||||
export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
|
||||
const config = OG_ASSETS.meeting;
|
||||
|
||||
// We filter attendees here based on whether they have an image and filter duplicates.
|
||||
// Users ALWAYS have an image (albeit a gray empty person avatar), so this mainly filters out
|
||||
// any non existing images for dynamic collectives, while at the same time removing them from
|
||||
@@ -134,10 +202,10 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
|
||||
const names = attendees.length > 0 ? attendees.map((user) => user.name) : [profile.name];
|
||||
|
||||
return (
|
||||
<Wrapper variant="dark">
|
||||
<Wrapper variant={config.variant}>
|
||||
<div tw="h-full flex flex-col justify-start">
|
||||
<div tw="flex items-center justify-center" style={{ fontFamily: "cal", fontWeight: 300 }}>
|
||||
<img src={`${WEBAPP_URL}/${LOGO}`} width="350" alt="Logo" />
|
||||
<img src={`${WEBAPP_URL}/${config.logo}`} width={config.logoWidth} alt="Logo" />
|
||||
{avatars.length > 0 && (
|
||||
<div style={{ color: "#111827" }} tw="font-bold text-[92px] mx-8 bottom-2">
|
||||
/
|
||||
@@ -150,12 +218,13 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
|
||||
key={avatar}
|
||||
src={avatar}
|
||||
alt="Profile picture"
|
||||
width="160"
|
||||
height="160"
|
||||
width={config.avatarSize}
|
||||
height={config.avatarSize}
|
||||
/>
|
||||
))}
|
||||
{avatars.length > 3 && (
|
||||
<div tw="flex items-center justify-center w-[160px] h-[160px] rounded-full bg-black text-inverted text-[54px] font-bold">
|
||||
<div
|
||||
tw={`flex items-center justify-center w-[${config.avatarSize}px] h-[${config.avatarSize}px] rounded-full bg-black text-inverted text-[54px] font-bold`}>
|
||||
<span tw="flex top-[-5px] left-[-5px]">+{avatars.length - 3}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -178,10 +247,10 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
|
||||
const VisualBlur = ({ logoUrl }: { logoUrl: string }) => {
|
||||
// Making a blur of a dark logo is very ugly. We use the filename to indicate,
|
||||
// when we don't want to render these blurry blob backgrounds.
|
||||
if (visualSlug.indexOf("dark") > -1) return null;
|
||||
if (logoUrl.indexOf("dark") > -1) return null;
|
||||
|
||||
return (
|
||||
<div tw="flex relative">
|
||||
@@ -191,7 +260,7 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
|
||||
style={{
|
||||
filter: "blur(98px)",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.7)",
|
||||
backgroundImage: `url(${WEBAPP_URL}${visualSlug})`,
|
||||
backgroundImage: `url(${WEBAPP_URL}${logoUrl})`,
|
||||
backgroundSize: "400px 400px",
|
||||
}}
|
||||
/>
|
||||
@@ -202,7 +271,7 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
|
||||
style={{
|
||||
filter: "blur(150px)",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.7)",
|
||||
backgroundImage: `url(${WEBAPP_URL}${visualSlug})`,
|
||||
backgroundImage: `url(${WEBAPP_URL}${logoUrl})`,
|
||||
backgroundSize: "630px 630px",
|
||||
}}
|
||||
/>
|
||||
@@ -210,60 +279,71 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const App = ({ name, description, slug }: AppImageProps) => (
|
||||
<Wrapper>
|
||||
<img src={`${WEBAPP_URL}/${LOGO}`} width="150" alt="Logo" tw="absolute right-[48px] top-[48px]" />
|
||||
/**
|
||||
* ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output,
|
||||
* remember to bump the version in OG_ASSETS.app.id (e.g., "app-og-image-v1" → "app-og-image-v2")
|
||||
* to ensure proper cache invalidation.
|
||||
*/
|
||||
export const App = ({ name, description, logoUrl }: AppImageProps) => {
|
||||
const config = OG_ASSETS.app;
|
||||
|
||||
<VisualBlur visualSlug={slug} />
|
||||
return (
|
||||
<Wrapper variant={config.variant}>
|
||||
<img
|
||||
src={`${WEBAPP_URL}/${config.logo}`}
|
||||
width={config.logoWidth}
|
||||
alt="Logo"
|
||||
tw="absolute right-[48px] top-[48px]"
|
||||
/>
|
||||
|
||||
<div tw="flex items-center w-full">
|
||||
<div tw="flex">
|
||||
<img src={`${WEBAPP_URL}${slug}`} alt="App icon" width="172" height="172" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "#111827" }} tw="flex mt-auto w-full flex-col">
|
||||
<div tw="flex text-[64px] mb-7" style={{ fontFamily: "cal", fontWeight: 600 }}>
|
||||
{name}
|
||||
</div>
|
||||
<div tw="flex text-[36px]" style={{ fontFamily: "inter" }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
<VisualBlur logoUrl={logoUrl} />
|
||||
|
||||
export const Generic = ({ title, description }: GenericImageProps) => (
|
||||
<Wrapper>
|
||||
<div tw="h-full flex flex-col justify-start">
|
||||
<div tw="flex items-center justify-center" style={{ fontFamily: "cal", fontWeight: 300 }}>
|
||||
<img src={`${WEBAPP_URL}/cal-logo-word-black.svg`} width="350" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#111827" }} tw="relative flex text-[54px] w-full flex-col mt-auto">
|
||||
<div tw="flex w-[1040px]" style={{ fontFamily: "cal" }}>
|
||||
{title}
|
||||
<div tw="flex items-center w-full">
|
||||
<div tw="flex">
|
||||
<img
|
||||
src={`${WEBAPP_URL}${logoUrl}`}
|
||||
alt="App icon"
|
||||
width={config.iconSize}
|
||||
height={config.iconSize}
|
||||
/>
|
||||
</div>
|
||||
<div tw="flex mt-3 w-[1040px]" style={{ fontFamily: "inter" }}>
|
||||
</div>
|
||||
<div style={{ color: "#111827" }} tw="flex mt-auto w-full flex-col">
|
||||
<div tw="flex text-[64px] mb-7" style={{ fontFamily: "cal", fontWeight: 600 }}>
|
||||
{name}
|
||||
</div>
|
||||
<div tw="flex text-[36px]" style={{ fontFamily: "inter" }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScreenShot = ({ image, fallbackImage }: ScreenshotImageProps) => (
|
||||
<Wrapper rotateBackground>
|
||||
<div tw="relative h-full w-full flex flex-col justify-center items-center">
|
||||
<div tw="relative mt-[140px] flex rounded-2xl" style={{ boxShadow: "0 0 45px -3px rgba(0,0,0,.3)" }}>
|
||||
<img
|
||||
src={fallbackImage}
|
||||
tw="absolute inset-0 rounded-2xl"
|
||||
width="1024"
|
||||
height="576"
|
||||
alt="screenshot"
|
||||
/>
|
||||
<img src={image} width="1024" height="576" tw="rounded-2xl" alt="screenshot" />
|
||||
/**
|
||||
* ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output,
|
||||
* remember to bump the version in OG_ASSETS.generic.id (e.g., "generic-og-image-v1" → "generic-og-image-v2")
|
||||
* to ensure proper cache invalidation.
|
||||
*/
|
||||
export const Generic = ({ title, description }: GenericImageProps) => {
|
||||
const config = OG_ASSETS.generic;
|
||||
|
||||
return (
|
||||
<Wrapper variant={config.variant}>
|
||||
<div tw="h-full flex flex-col justify-start">
|
||||
<div tw="flex items-center justify-center" style={{ fontFamily: "cal", fontWeight: 300 }}>
|
||||
<img src={`${WEBAPP_URL}/${config.logo}`} width={config.logoWidth} alt="Logo" />
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#111827" }} tw="relative flex text-[54px] w-full flex-col mt-auto">
|
||||
<div tw="flex w-[1040px]" style={{ fontFamily: "cal" }}>
|
||||
{title}
|
||||
</div>
|
||||
<div tw="flex mt-3 w-[1040px]" style={{ fontFamily: "inter" }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user