feat: share collection URL
This commit is contained in:
@@ -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<Collection | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
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 (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 py-12">
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6 py-10 space-y-8">
|
||||
<header className="rounded-2xl bg-card ring-1 ring-foreground/10 p-6 sm:p-8">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{c.is_public ? (
|
||||
<Globe className="size-3.5" />
|
||||
) : (
|
||||
<Lock className="size-3.5" />
|
||||
)}
|
||||
{c.is_public ? "Public collection" : "Private collection"}
|
||||
</div>
|
||||
<h1 className="mt-3 font-[family-name:var(--font-title)] font-semibold text-3xl sm:text-4xl text-foreground leading-tight">
|
||||
{c.title}
|
||||
</h1>
|
||||
{c.description ? (
|
||||
<p className="mt-3 text-base text-muted-foreground leading-relaxed">
|
||||
{c.description}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-4 text-xs text-muted-foreground tabular-nums">
|
||||
{c.item_count} post{c.item_count === 1 ? "" : "s"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-3">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<FolderPlus className="size-6 text-muted-foreground mx-auto" />
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This collection has no items yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<PostViews posts={posts} />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+42
-85
@@ -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/<id>), 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<Collection | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
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 (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 py-12">
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
</main>
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const data = await bff<CollectionEnvelope>(
|
||||
`/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 (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6 py-10 space-y-8">
|
||||
<header className="rounded-2xl bg-card ring-1 ring-foreground/10 p-6 sm:p-8">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{c.is_public ? <Globe className="size-3.5" /> : <Lock className="size-3.5" />}
|
||||
{c.is_public ? "Public collection" : "Private collection"}
|
||||
</div>
|
||||
<h1 className="mt-3 font-[family-name:var(--font-title)] font-semibold text-3xl sm:text-4xl text-foreground leading-tight">
|
||||
{c.title}
|
||||
</h1>
|
||||
{c.description ? (
|
||||
<p className="mt-3 text-base text-muted-foreground leading-relaxed">
|
||||
{c.description}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-4 text-xs text-muted-foreground tabular-nums">
|
||||
{c.item_count} post{c.item_count === 1 ? "" : "s"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-3">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<FolderPlus className="size-6 text-muted-foreground mx-auto" />
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This collection has no items yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<PostViews posts={posts} />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
export default function PublicCollectionPage({ params }: Props) {
|
||||
return <CollectionContent params={params} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
|
||||
renders raw <img>; next/image doesn't work inside OG image generation. */
|
||||
/**
|
||||
* OG card for a public collection (`/c/<id>`).
|
||||
*
|
||||
* 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<CollectionEnvelope>(
|
||||
`/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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: hasImage ? "#1a1410" : "#faf5f0",
|
||||
fontFamily: "Noto Sans, sans-serif",
|
||||
position: "relative",
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
{hasImage ? (
|
||||
<>
|
||||
<img
|
||||
alt=""
|
||||
src={bgImageUrl}
|
||||
width={1200}
|
||||
height={630}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
objectFit: "cover",
|
||||
filter: "brightness(0.45)",
|
||||
}}
|
||||
/>
|
||||
<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}
|
||||
|
||||
{/* Eyebrow */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 6,
|
||||
textTransform: "uppercase",
|
||||
color: accent,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Collection
|
||||
</div>
|
||||
|
||||
{/* Title — the centrepiece */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: title.length > 48 ? 60 : 76,
|
||||
fontWeight: 700,
|
||||
color: textPrimary,
|
||||
lineHeight: 1.15,
|
||||
marginTop: 24,
|
||||
maxHeight: 320,
|
||||
overflow: "hidden",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 28,
|
||||
color: textSecondary,
|
||||
lineHeight: 1.4,
|
||||
marginTop: 24,
|
||||
maxHeight: 120,
|
||||
overflow: "hidden",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ flex: 1, display: "flex" }} />
|
||||
|
||||
{/* Footer: count + branding */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 24, color: textSecondary, display: "flex" }}>
|
||||
{count}
|
||||
</div>
|
||||
<div style={{ fontSize: 22, 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("Collection OG image generation failed:", e);
|
||||
return new Response("Failed to generate OG image", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Copy public link — a peer of the Link (no nested interactives). The
|
||||
public route /c/<id> carries OG metadata so the pasted link unfurls
|
||||
with a generated card. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(`${window.location.origin}/c/${c.id}`)}
|
||||
aria-label={copied ? "Link copied" : "Copy collection link"}
|
||||
title={copied ? "Link copied" : "Copy collection link"}
|
||||
className={cn(
|
||||
"absolute top-3 left-3 z-10 inline-flex size-8 items-center justify-center",
|
||||
"rounded-full bg-black/50 backdrop-blur-sm hover:bg-black/70",
|
||||
"text-white transition-colors",
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="size-4" /> : <Link2 className="size-4" />}
|
||||
</button>
|
||||
|
||||
{onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -470,3 +470,39 @@ export function ogForPost(p: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** OG metadata for a public collection (`/c/<id>`). */
|
||||
export function ogForCollection(c: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
itemCount?: number;
|
||||
}) {
|
||||
const imageUrl = new URL(
|
||||
`/api/og/collection/${encodeURIComponent(c.id)}`,
|
||||
SITE_URL,
|
||||
).toString();
|
||||
const title = c.title;
|
||||
const count =
|
||||
typeof c.itemCount === "number"
|
||||
? `${c.itemCount} post${c.itemCount === 1 ? "" : "s"}`
|
||||
: "A collection";
|
||||
const description =
|
||||
c.description?.trim() || `${count} on Prabhupāda Says`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: [{ url: imageUrl, width: 1200, height: 630, alt: title }],
|
||||
siteName: "Prabhupāda Says",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image" as const,
|
||||
title,
|
||||
description,
|
||||
images: [imageUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user