From 3accc753d0e7337d34f7ab98c86c6aafca0af33e Mon Sep 17 00:00:00 2001 From: gosangam Date: Sun, 14 Jun 2026 01:29:08 +0530 Subject: [PATCH] feat: share collection URL --- app/(app)/c/[id]/collection-content.tsx | 100 ++++++++ app/(app)/c/[id]/page.tsx | 127 ++++------- app/api/og/collection/[id]/route.tsx | 215 ++++++++++++++++++ .../collection/collection-scroll-card.tsx | 21 ++ lib/og.ts | 36 +++ 5 files changed, 414 insertions(+), 85 deletions(-) create mode 100644 app/(app)/c/[id]/collection-content.tsx create mode 100644 app/api/og/collection/[id]/route.tsx diff --git a/app/(app)/c/[id]/collection-content.tsx b/app/(app)/c/[id]/collection-content.tsx new file mode 100644 index 00000000..3cd4b374 --- /dev/null +++ b/app/(app)/c/[id]/collection-content.tsx @@ -0,0 +1,100 @@ +"use client"; + +/** + * Client body for the public collection page (/c/[id]). Split out from + * page.tsx so the page itself can stay a server component that exports + * `generateMetadata` (rich OG card on link shares). Anyone can view a + * public collection; private ones return 403 from the BFF, which we treat + * as "not found" to avoid leaking existence. + */ + +import { use, useEffect, useState } from "react"; +import { notFound } from "next/navigation"; +import { Globe, Lock, FolderPlus } from "lucide-react"; +import { + collections, + SocialError, + type Collection, + type Post, +} from "@/lib/social"; +import { PostViews } from "@/components/social/post/post-views"; + +export function CollectionContent(props: { + params: Promise<{ id: string }>; +}) { + const { id } = use(props.params); + const [c, setC] = useState(null); + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [missing, setMissing] = useState(false); + + useEffect(() => { + let cancelled = false; + collections + .get(id) + .then(({ collection, posts }) => { + if (cancelled) return; + setC(collection); + setPosts(posts ?? []); + }) + .catch((err) => { + if ( + err instanceof SocialError && + (err.status === 404 || err.status === 403) + ) { + setMissing(true); + } + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [id]); + + if (missing) notFound(); + if (loading || !c) + return ( +
+

Loading…

+
+ ); + + return ( +
+
+
+ {c.is_public ? ( + + ) : ( + + )} + {c.is_public ? "Public collection" : "Private collection"} +
+

+ {c.title} +

+ {c.description ? ( +

+ {c.description} +

+ ) : null} +

+ {c.item_count} post{c.item_count === 1 ? "" : "s"} +

+
+ +
+ {posts.length === 0 ? ( +
+ +

+ This collection has no items yet. +

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/app/(app)/c/[id]/page.tsx b/app/(app)/c/[id]/page.tsx index 813db063..7a51360d 100644 --- a/app/(app)/c/[id]/page.tsx +++ b/app/(app)/c/[id]/page.tsx @@ -1,94 +1,51 @@ -"use client"; - /** - * Public collection at /c/[id]. Anyone can view if `is_public=true`. - * Private collections return 403 from the BFF, which we treat as "not - * found" to avoid leaking existence. + * /c/[id] — public collection. + * + * Server shell: exports `generateMetadata` so a shared collection link + * unfurls with a rich OG card (pointing at /api/og/collection/), then + * mounts the client body. The server fetch is unauthenticated, so only + * public collections produce a card — private ones fall back to the default + * (and never leak). */ -import { use, useEffect, useState } from "react"; -import { notFound } from "next/navigation"; -import { Globe, Lock, FolderPlus } from "lucide-react"; -import { - collections, - SocialError, - type Collection, - type Post, -} from "@/lib/social"; -import { PostViews } from "@/components/social/post/post-views"; +import type { Metadata } from "next"; +import { bff } from "@/lib/bff"; +import { ogDefault, ogForCollection } from "@/lib/og"; +import { CollectionContent } from "./collection-content"; -export default function PublicCollectionPage(props: { - params: Promise<{ id: string }>; -}) { - const { id } = use(props.params); - const [c, setC] = useState(null); - const [posts, setPosts] = useState([]); - const [loading, setLoading] = useState(true); - const [missing, setMissing] = useState(false); +type Props = { params: Promise<{ id: string }> }; - useEffect(() => { - let cancelled = false; - collections - .get(id) - .then(({ collection, posts }) => { - if (cancelled) return; - setC(collection); - setPosts(posts ?? []); - }) - .catch((err) => { - if ( - err instanceof SocialError && - (err.status === 404 || err.status === 403) - ) { - setMissing(true); - } - }) - .finally(() => !cancelled && setLoading(false)); - return () => { - cancelled = true; - }; - }, [id]); +type CollectionEnvelope = { + collection: { + id: string; + title: string; + description: string | null; + is_public: boolean; + item_count: number; + }; +}; - if (missing) notFound(); - if (loading || !c) - return ( -
-

Loading…

-
+export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + try { + const data = await bff( + `/collections/${encodeURIComponent(id)}`, + { revalidate: 60 }, ); + const c = data?.collection; + if (!c || !c.is_public) return ogDefault(); + return ogForCollection({ + id: c.id, + title: c.title, + description: c.description, + itemCount: c.item_count, + }); + } catch { + // Private (403) / unreachable → fall back to the site card. + return ogDefault(); + } +} - return ( -
-
-
- {c.is_public ? : } - {c.is_public ? "Public collection" : "Private collection"} -
-

- {c.title} -

- {c.description ? ( -

- {c.description} -

- ) : null} -

- {c.item_count} post{c.item_count === 1 ? "" : "s"} -

-
- -
- {posts.length === 0 ? ( -
- -

- This collection has no items yet. -

-
- ) : ( - - )} -
-
- ); +export default function PublicCollectionPage({ params }: Props) { + return ; } diff --git a/app/api/og/collection/[id]/route.tsx b/app/api/og/collection/[id]/route.tsx new file mode 100644 index 00000000..66160d14 --- /dev/null +++ b/app/api/og/collection/[id]/route.tsx @@ -0,0 +1,215 @@ +/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only + renders raw ; next/image doesn't work inside OG image generation. */ +/** + * OG card for a public collection (`/c/`). + * + * Renders a 1200×630 image with the collection title as the centrepiece, + * an item-count line, and the cover image dimmed behind it when present + * (flat cream otherwise). Matches the post/profile card aesthetic. + * + * Data comes from the public BFF collection endpoint. The server fetch is + * unauthenticated, so private collections return 403 → we 404 here (a + * private collection never unfurls, which is the correct privacy stance). + */ + +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 CollectionEnvelope = { + collection: { + id: string; + title: string; + description: string | null; + cover_url: string | null; + is_public: boolean; + item_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 }; +} + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await context.params; + const data = await bff( + `/collections/${encodeURIComponent(id)}`, + { revalidate: 60 }, + ).catch(() => null); + const c = data?.collection; + if (!c || !c.is_public) { + return new Response("Collection not found", { status: 404 }); + } + + const fonts = await loadFonts(); + const title = c.title.length > 90 ? c.title.slice(0, 87) + "…" : c.title; + const description = c.description?.trim() + ? c.description.length > 160 + ? c.description.slice(0, 157) + "…" + : c.description + : ""; + const count = `${c.item_count} post${c.item_count === 1 ? "" : "s"}`; + const bgImageUrl = c.cover_url; + const hasImage = !!bgImageUrl; + + const textPrimary = hasImage ? "#ffffff" : "#2d2418"; + const textSecondary = hasImage ? "rgba(255,255,255,0.85)" : "#5a4a3a"; + const accent = hasImage ? "#ffd9a8" : "#c47a5a"; + + return new ImageResponse( + ( +
+ {hasImage ? ( + <> + +
+ + ) : null} + + {/* Eyebrow */} +
+ Collection +
+ + {/* Title — the centrepiece */} +
48 ? 60 : 76, + fontWeight: 700, + color: textPrimary, + lineHeight: 1.15, + marginTop: 24, + maxHeight: 320, + overflow: "hidden", + zIndex: 1, + }} + > + {title} +
+ + {description ? ( +
+ {description} +
+ ) : null} + +
+ + {/* Footer: count + branding */} +
+
+ {count} +
+
+ prabhupadasays.org +
+
+
+ ), + { + 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("Collection OG image generation failed:", e); + return new Response("Failed to generate OG image", { status: 500 }); + } +} diff --git a/components/social/collection/collection-scroll-card.tsx b/components/social/collection/collection-scroll-card.tsx index 66fd5fdc..36cb6426 100644 --- a/components/social/collection/collection-scroll-card.tsx +++ b/components/social/collection/collection-scroll-card.tsx @@ -21,15 +21,18 @@ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { + Check, ChevronLeft, ChevronRight, Globe, + Link2, Loader2, Lock, Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { collections, type Collection, type Post } from "@/lib/social"; +import { useCopyFeedback } from "@/components/selection-toolbar/shared"; import { PostGridCard } from "../post/post-grid-card"; import { quoteCardBgStyle } from "../post/post-card-shared"; @@ -201,6 +204,7 @@ function CoverSlide({ href: string; onEdit?: () => void; }) { + const { copied, copy } = useCopyFeedback(); // The slide wrapper hosts the Link AND any owner-action chip as // siblings. Nesting a button inside the Link is an a11y anti-pattern // (interactive nested in interactive), so we keep them peers and let @@ -285,6 +289,23 @@ function CoverSlide({
+ {/* Copy public link — a peer of the Link (no nested interactives). The + public route /c/ carries OG metadata so the pasted link unfurls + with a generated card. */} + + {onEdit ? (