diff --git a/apps/web/public/fonts/funnel-display-semibold.ttf b/apps/web/public/fonts/funnel-display-semibold.ttf new file mode 100644 index 0000000..0956c48 Binary files /dev/null and b/apps/web/public/fonts/funnel-display-semibold.ttf differ diff --git a/apps/web/public/fonts/geologica-semibold.ttf b/apps/web/public/fonts/geologica-semibold.ttf new file mode 100644 index 0000000..4b73df8 Binary files /dev/null and b/apps/web/public/fonts/geologica-semibold.ttf differ diff --git a/apps/web/src/app/e/[slug]/opengraph-image.tsx b/apps/web/src/app/e/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..6874b22 --- /dev/null +++ b/apps/web/src/app/e/[slug]/opengraph-image.tsx @@ -0,0 +1,13 @@ +import { createServerCaller } from "@/trpc/server"; +import { renderSocialImage } from "@/server/social-image"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const alt = "Shared event album on Manyangles"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; +export default async function EventImage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const caller = await createServerCaller(); + const event = await caller.event.bySlug(slug).catch(() => null); + return renderSocialImage(event ?? undefined); +} diff --git a/apps/web/src/app/e/[slug]/page.tsx b/apps/web/src/app/e/[slug]/page.tsx index 85aa280..43381b3 100644 --- a/apps/web/src/app/e/[slug]/page.tsx +++ b/apps/web/src/app/e/[slug]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import type { Metadata } from "next"; import { MapPinIcon } from "lucide-react"; import { EventMap } from "@/components/event-map"; import { createServerCaller } from "@/trpc/server"; @@ -8,6 +9,19 @@ import { galleryIsPublic } from "@/lib/publishing"; import { GuestGallery } from "./guest-gallery"; import { GuestUpload } from "./guest-upload"; +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const caller = await createServerCaller(); + const event = await caller.event.bySlug(slug).catch(() => null); + if (!event) return { title: "Shared album", robots: { index: false, follow: false } }; + const description = "Share your photos and memories on Manyangles."; + return { + title: event.title, description, + openGraph: { title: event.title, description, type: "website" }, + twitter: { card: "summary_large_image", title: event.title, description }, + }; +} + export default async function EventPage({ params, }: { diff --git a/apps/web/src/app/opengraph-image.tsx b/apps/web/src/app/opengraph-image.tsx index f603b3f..95ed99f 100644 --- a/apps/web/src/app/opengraph-image.tsx +++ b/apps/web/src/app/opengraph-image.tsx @@ -1,60 +1,6 @@ -import { ImageResponse } from "next/og"; -import { BRAND_NAME, BRAND_TAGLINE } from "@/lib/brand"; - +import { renderSocialImage } from "@/server/social-image"; +export const runtime = "nodejs"; +export const alt = "Manyangles — Every angle. One shared album."; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; - -export default function OpenGraphImage() { - return new ImageResponse( - ( -
- - - - - -
- {BRAND_NAME} -
-
- {BRAND_TAGLINE} -
-
- ), - size, - ); -} +export default function OpenGraphImage() { return renderSocialImage(); } diff --git a/apps/web/src/server/social-image.tsx b/apps/web/src/server/social-image.tsx new file mode 100644 index 0000000..4e1d5c8 --- /dev/null +++ b/apps/web/src/server/social-image.tsx @@ -0,0 +1,45 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { ImageResponse } from "next/og"; +import sharp from "sharp"; +import { BRAND_NAME, BRAND_TAGLINE } from "@/lib/brand"; + +export async function renderSocialImage(event?: { title: string; bannerUrl: string | null }) { + const [funnel, geologica, banner] = await Promise.all([ + readFile(path.join(process.cwd(), "public/fonts/funnel-display-semibold.ttf")), + readFile(path.join(process.cwd(), "public/fonts/geologica-semibold.ttf")), + event?.bannerUrl ? loadBanner(event.bannerUrl) : null, + ]); + const title = event?.title ?? BRAND_TAGLINE; + return new ImageResponse( +
+ {banner ? : null} +
+
+
65 ? 48 : 64, lineHeight: 1.08, maxWidth: 1060, marginBottom: 30 }}>{title.length > 140 ? `${title.slice(0, 137)}…` : title}
+
+ + + + + +
{BRAND_NAME}
+
+
+
, + { width: 1200, height: 630, fonts: [ + { name: "Funnel", data: funnel, weight: 600 }, + { name: "Geologica", data: geologica, weight: 600 }, + ], headers: { "Cache-Control": "no-store" } }, + ); +} + +async function loadBanner(url: string) { + try { + const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + // Use the optimized display variant, never the uploaded original. + const png = await sharp(Buffer.from(await response.arrayBuffer())).resize(1200, 630, { fit: "cover" }).png().toBuffer(); + return `data:image/png;base64,${png.toString("base64")}`; + } catch { return null; } +}