feat: share post to other apps
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user