Files
spsays-training/app/api/og/post/[id]/route.tsx
T

283 lines
8.2 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 single post.
*
* Renders a 1200×630 image with:
*
* • The post caption (the quote) as the visual centrepiece, in
* serif-toned weight so it reads as a passage rather than UI chrome.
* • The source reference below — citation for the underlying scripture.
* • Author byline (avatar + name + @handle) bottom-left.
* • The post's first attached image as a dimmed background when
* available — same content the author sees on the card itself.
*
* Falls back to a flat cream background when the post has no images.
* The light/dark text treatment swaps based on which surface is showing.
*/
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 PostImage = { id: string; url: string; position: number };
type Post = {
id: string;
user_id: string;
caption: string;
source_url: string;
source_title: string;
images: PostImage[];
author_name?: string;
author_handle?: string;
author_avatar_url?: string | null;
};
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() || "?"
);
}
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params;
const post = await bff<Post>(`/posts/${encodeURIComponent(id)}`, {
revalidate: 60,
}).catch(() => null);
if (!post) {
return new Response("Post not found", { status: 404 });
}
const fonts = await loadFonts();
const caption = post.caption || "";
const displayCaption =
caption.length > 280 ? caption.slice(0, 277) + "…" : caption;
const reference = post.source_title || "Source";
const authorName = post.author_name || "Unknown";
const authorHandle = post.author_handle ? `@${post.author_handle}` : "";
const bgImageUrl = post.images?.[0]?.url ?? null;
const hasImage = !!bgImageUrl;
// Colour palette flips for image-backed cards so the caption stays
// legible over the dimmed photo.
const textPrimary = hasImage ? "#ffffff" : "#2d2418";
const textSecondary = hasImage ? "rgba(255,255,255,0.85)" : "#5a4a3a";
const accent = hasImage ? "#ffd9a8" : "#c47a5a";
return new ImageResponse(
(
<div
style={{
width: 1200,
height: 630,
display: "flex",
flexDirection: "column",
backgroundColor: hasImage ? "#1a1410" : "#faf5f0",
fontFamily: "Noto Sans, sans-serif",
position: "relative",
padding: 80,
}}
>
{/* Background image + dimming overlay */}
{hasImage ? (
<>
<img
alt=""
src={bgImageUrl}
width={1200}
height={630}
style={{
position: "absolute",
top: 0,
left: 0,
width: 1200,
height: 630,
objectFit: "cover",
filter: "brightness(0.45)",
}}
/>
{/* Bottom-up gradient anchors the caption against the image */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: 1200,
height: 630,
background:
"linear-gradient(to top, rgba(0,0,0,0.75), rgba(0,0,0,0.1))",
}}
/>
</>
) : null}
{/* Caption — the centrepiece */}
<div
style={{
display: "flex",
fontSize: displayCaption.length > 160 ? 36 : 44,
fontWeight: 700,
color: textPrimary,
lineHeight: 1.35,
marginTop: 20,
maxHeight: 380,
overflow: "hidden",
zIndex: 1,
}}
>
“{displayCaption}”
</div>
{/* Reference (source citation) */}
<div
style={{
display: "flex",
fontSize: 22,
fontWeight: 700,
color: textSecondary,
letterSpacing: 4,
textTransform: "uppercase",
marginTop: 28,
zIndex: 1,
}}
>
{reference}
</div>
{/* Spacer pushes the byline + branding to the bottom */}
<div style={{ flex: 1, display: "flex" }} />
{/* Bottom row: author + branding */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
zIndex: 1,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 16,
}}
>
{post.author_avatar_url ? (
<img
alt=""
src={post.author_avatar_url}
width={64}
height={64}
style={{
width: 64,
height: 64,
borderRadius: 32,
objectFit: "cover",
border: `3px solid ${textPrimary}`,
}}
/>
) : (
<div
style={{
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: accent,
color: hasImage ? "#1a1410" : "#faf5f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 28,
fontWeight: 700,
}}
>
{initialsFor(authorName)}
</div>
)}
<div
style={{ display: "flex", flexDirection: "column", gap: 2 }}
>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: textPrimary,
display: "flex",
}}
>
{authorName}
</div>
{authorHandle ? (
<div
style={{
fontSize: 18,
color: textSecondary,
display: "flex",
}}
>
{authorHandle}
</div>
) : null}
</div>
</div>
<div style={{ fontSize: 18, color: accent, fontWeight: 700 }}>
prabhupadasays.org
</div>
</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("Post OG image generation failed:", e);
return new Response("Failed to generate OG image", { status: 500 });
}
}