283 lines
8.2 KiB
TypeScript
283 lines
8.2 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 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 });
|
||
}
|
||
}
|