feat: share post to other apps

This commit is contained in:
gosangam
2026-05-31 08:04:46 +05:30
parent 03b8f85e06
commit 336f478f8b
13 changed files with 1290 additions and 82 deletions
+278
View File
@@ -0,0 +1,278 @@
/**
* 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
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
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 });
}
}
+224
View File
@@ -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 });
}
}
+9 -5
View File
@@ -14,7 +14,7 @@
*/
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
AtSign,
Bookmark,
@@ -102,9 +102,13 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
function ConditionalTabs({ tabs }: { tabs: ProfileTab[] }) {
const searchParams = useSearchParams();
// /u/me?edit=1 swaps the Posts grid for the inline edit form (see
// app/u/me/page.tsx). The tab strip would just be in the way while the
// user is editing — hide it for that one URL state.
const pathname = usePathname();
// Hide tabs when the user is in a focused subview:
// • /u/me?edit=1 — the inline profile-edit form
// • /u/me/settings — the reading-prefs page
// In either case the strip is visual noise that distracts from the
// task they navigated in to do.
const editMode = searchParams.get("edit") === "1";
return editMode ? null : <ProfileTabs tabs={tabs} />;
const settingsMode = pathname.startsWith("/u/me/settings");
return editMode || settingsMode ? null : <ProfileTabs tabs={tabs} />;
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
/**
* /u/me/settings — dedicated reading-preferences screen for auth users.
*
* Mirrors the Edit Profile pattern: a focused subview of /u/me that
* trades the tab strip for a back link + the settings form. We send
* users here instead of opening the popover because the popover, when
* triggered from ProfileHeader's centred action row, can spill off the
* left edge of narrow viewports. Anonymous users still get the popover
* in SiteHeader — that trigger sits at right-2 so it doesn't overflow.
*
* The /u/me layout's ConditionalTabs detects this path and hides the
* Posts/Collections/Bookmarks/Mentions strip while the user is editing
* settings.
*/
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { ReadingSettings } from "@/components/reading-settings";
export default function ReadingSettingsPage() {
return (
<div className="space-y-6">
<Link
href="/u/me"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Back to profile
</Link>
<div>
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground">
Reading settings
</h1>
<p className="text-sm text-muted-foreground mt-1">
Theme, palette, font, and size — synced across your devices.
</p>
</div>
<ReadingSettings variant="inline" />
</div>
);
}