228 lines
6.3 KiB
TypeScript
228 lines
6.3 KiB
TypeScript
/* 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 });
|
||
}
|
||
}
|