Files
spsays-training/app/api/og/profile/[handle]/route.tsx
T

228 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
/**
* OG card for a user profile (`/u/<handle>`).
*
* Renders a 1200×630 image with avatar (or initials fallback), display
* name, @handle, and a stats line (post count + join year). Designed
* to match the chapter-card OG aesthetic so a feed of links from
* different parts of the site looks coherent in WhatsApp / Twitter
* previews.
*
* Data comes from the public BFF profile endpoint — no auth required.
* Failures (missing profile, BFF unreachable) return 404 / 500 instead
* of a generic image so social scrapers know to fall back to a
* higher-level card; the share/[code]'s `ogDefault()` shape handles
* that case.
*/
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { bff } from "@/lib/bff";
export const runtime = "nodejs";
type PublicProfile = {
handle: string;
name: string;
avatar_url: string | null;
joined_at: string;
post_count: number;
};
let notoRegularData: Buffer | null = null;
let notoBoldData: Buffer | null = null;
async function loadFonts() {
const fontsDir = join(process.cwd(), "public/fonts/Noto_Sans/static");
if (!notoRegularData) {
notoRegularData = await readFile(join(fontsDir, "NotoSans-Regular.ttf"));
}
if (!notoBoldData) {
notoBoldData = await readFile(join(fontsDir, "NotoSans-Bold.ttf"));
}
return { regular: notoRegularData, bold: notoBoldData };
}
function initialsFor(name: string): string {
return (
name
.split(/\s+/)
.filter(Boolean)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?"
);
}
function formatJoined(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return `${d.toLocaleString("en-US", { month: "short" })} ${d.getFullYear()}`;
}
export async function GET(
_request: Request,
context: { params: Promise<{ handle: string }> },
) {
try {
const { handle } = await context.params;
const profile = await bff<PublicProfile>(
`/u/${encodeURIComponent(handle)}`,
{ revalidate: 60 },
).catch(() => null);
if (!profile) {
return new Response("Profile not found", { status: 404 });
}
const fonts = await loadFonts();
const displayName =
profile.name.length > 40 ? profile.name.slice(0, 37) + "…" : profile.name;
const stats = [
`${profile.post_count} post${profile.post_count === 1 ? "" : "s"}`,
profile.joined_at ? `Joined ${formatJoined(profile.joined_at)}` : null,
]
.filter(Boolean)
.join(" · ");
return new ImageResponse(
(
<div
style={{
width: 1200,
height: 630,
display: "flex",
flexDirection: "column",
backgroundColor: "#faf5f0",
fontFamily: "Noto Sans, sans-serif",
padding: 80,
}}
>
{/* Top row: avatar + identity block */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 48,
}}
>
{profile.avatar_url ? (
<img
alt=""
src={profile.avatar_url}
width={220}
height={220}
style={{
width: 220,
height: 220,
borderRadius: 110,
objectFit: "cover",
border: "6px solid #2d2418",
flexShrink: 0,
}}
/>
) : (
<div
style={{
width: 220,
height: 220,
borderRadius: 110,
backgroundColor: "#c47a5a",
color: "#faf5f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 96,
fontWeight: 700,
flexShrink: 0,
}}
>
{initialsFor(profile.name)}
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
flex: 1,
minWidth: 0,
}}
>
<div
style={{
fontSize: 60,
fontWeight: 700,
color: "#2d2418",
lineHeight: 1.05,
display: "flex",
overflow: "hidden",
}}
>
{displayName}
</div>
<div style={{ fontSize: 30, color: "#5a4a3a", display: "flex" }}>
@{profile.handle}
</div>
{stats ? (
<div
style={{
fontSize: 22,
color: "#5a4a3a",
marginTop: 12,
display: "flex",
}}
>
{stats}
</div>
) : null}
</div>
</div>
{/* Footer: branding */}
<div
style={{
marginTop: "auto",
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<span style={{ fontSize: 22, color: "#5a4a3a" }}>
Read quotes shared on
</span>
<span style={{ fontSize: 22, color: "#c47a5a", fontWeight: 700 }}>
prabhupadasays.org
</span>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: "Noto Sans",
data: fonts.regular,
style: "normal" as const,
weight: 400,
},
{
name: "Noto Sans",
data: fonts.bold,
style: "normal" as const,
weight: 700,
},
],
},
);
} catch (e) {
console.error("Profile OG image generation failed:", e);
return new Response("Failed to generate OG image", { status: 500 });
}
}