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
+85
View File
@@ -385,3 +385,88 @@ export function ogDefault() {
quote: SITE_DESCRIPTION,
});
}
// ---------------------------------------------------------------------------
// Social: profile + post OG cards
// ---------------------------------------------------------------------------
//
// Profiles and posts get their own image-rendering routes (see
// app/api/og/profile/[handle] and app/api/og/post/[id]) so the card
// can carry avatars, author bylines, and (for posts) the attached
// image. The helpers below just produce the Metadata object pointing
// at those routes. They live here rather than inline in share-og.ts
// so non-share entrypoints (e.g. a profile/post page that adds its
// own `generateMetadata`) can reuse the same builder.
/** OG metadata for a public user profile (`/u/<handle>`). */
export function ogForProfile(p: {
handle: string;
name: string;
postCount?: number;
}) {
const imageUrl = new URL(
`/api/og/profile/${encodeURIComponent(p.handle)}`,
SITE_URL,
).toString();
const title = `${p.name} (@${p.handle})`;
const stats =
typeof p.postCount === "number"
? `${p.postCount} post${p.postCount === 1 ? "" : "s"} on Prabhupāda Says`
: "Reading shared on Prabhupāda Says";
return {
title,
description: stats,
openGraph: {
title,
description: stats,
images: [{ url: imageUrl, width: 1200, height: 630, alt: title }],
siteName: "Prabhupāda Says",
},
twitter: {
card: "summary_large_image" as const,
title,
description: stats,
images: [imageUrl],
},
};
}
/** OG metadata for a single post (`/u/<handle>/posts#post-<id>` shares). */
export function ogForPost(p: {
id: string;
caption: string;
source_title?: string | null;
author_name?: string | null;
author_handle?: string | null;
}) {
const imageUrl = new URL(
`/api/og/post/${encodeURIComponent(p.id)}`,
SITE_URL,
).toString();
const byline = p.author_handle
? `${p.author_name ?? p.author_handle} (@${p.author_handle})`
: p.author_name ?? "Prabhupāda Says";
const reference = p.source_title?.trim() || "Prabhupāda Says";
// Description is shown in some unfurlers (Slack, Discord) when the
// image fails to load — keep the caption visible there too.
const truncatedCaption =
p.caption.length > 200 ? p.caption.slice(0, 197) + "…" : p.caption;
const description = `“${truncatedCaption}” — ${reference}`;
const title = `${reference} · shared by ${byline}`;
return {
title,
description,
openGraph: {
title,
description,
images: [{ url: imageUrl, width: 1200, height: 630, alt: reference }],
siteName: "Prabhupāda Says",
},
twitter: {
card: "summary_large_image" as const,
title,
description,
images: [imageUrl],
},
};
}
+74
View File
@@ -23,6 +23,8 @@ import {
ogForLetter,
ogForMagazine,
ogForOtherDocument,
ogForPost,
ogForProfile,
ogForTranscription,
ogForVerse,
} from "@/lib/og";
@@ -115,6 +117,10 @@ export async function buildShareOgMeta(fullPath: string): Promise<Metadata> {
return parts.length >= 3
? await ogForOtherDocPath(parts[1], parts[2], sp)
: ogDefault();
case "u":
// /u/<handle> → profile share
// /u/<handle>/posts#post-<id> → post share (post id in u.hash)
return await ogForProfilePath(parts, u.hash);
default:
return ogWithFallbackQuote("Prabhupāda Says", sp);
}
@@ -365,6 +371,74 @@ async function ogForOtherDocPath(
// Fallback
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Profile + post share dispatch
// ---------------------------------------------------------------------------
type PublicProfileForOg = {
handle: string;
name: string;
post_count?: number;
};
type PostForOg = {
id: string;
caption: string;
source_title?: string | null;
author_name?: string | null;
author_handle?: string | null;
};
/**
* Resolve `/u/<handle>` and `/u/<handle>/posts#post-<id>` share targets
* to the correct OG card. The hash carries the post id when the share
* is a single-post link (built by SharePostSheet); without a hash we
* fall back to a profile card for the handle.
*/
async function ogForProfilePath(
parts: string[],
hash: string,
): Promise<Metadata> {
if (parts.length < 2) return ogDefault();
const handle = parts[1];
// Post share: /u/<handle>/posts with `#post-<id>` fragment.
if (parts.length >= 3 && parts[2] === "posts") {
const m = hash.match(/^#post-(.+)$/);
if (m) {
const postID = m[1];
try {
const post = await bff<PostForOg>(
`/posts/${encodeURIComponent(postID)}`,
{ revalidate: 60 },
);
if (post) return ogForPost(post);
} catch {
// fall through to profile-level card
}
}
// No post id → treat as a feed share for the profile.
}
// Profile share: /u/<handle>
try {
const profile = await bff<PublicProfileForOg>(
`/u/${encodeURIComponent(handle)}`,
{ revalidate: 60 },
);
if (profile) {
return ogForProfile({
handle: profile.handle,
name: profile.name,
postCount: profile.post_count,
});
}
} catch {
// fall through
}
return ogDefault();
}
function ogWithFallbackQuote(
title: string,
sp: URLSearchParams,
+16
View File
@@ -62,6 +62,11 @@ export type Post = {
author_name?: string;
author_handle?: string;
author_avatar_url?: string | null;
/** True when the calling viewer has reshared this post. Populated by
* endpoints that go through OptionalAuth (single post, profile feed,
* collection contents). Always false / absent for anonymous viewers.
* Drives the post-card "Reshare ↔ Send" swap. */
viewer_has_reshared?: boolean;
};
export type Bookmark = {
@@ -130,6 +135,17 @@ export const posts = {
mine() {
return call<Post[]>("/posts/mine");
},
/** Record a reshare. Idempotent — calling twice is a no-op server
* side. Resharing your own post is rejected with 400 (not currently
* surfaced as a distinct error type because the UI only ever offers
* Reshare on non-owned posts). */
reshare(id: string) {
return call<void>(`/posts/${id}/reshare`, { method: "POST" });
},
/** Remove a previously recorded reshare. Idempotent. */
unreshare(id: string) {
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
},
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
async uploadImage(file: File): Promise<string> {
const { upload_url, public_url } = await call<{