feat: share post to other apps
This commit is contained in:
@@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user