feat: share collection URL

This commit is contained in:
gosangam
2026-06-14 01:29:08 +05:30
parent b04ed1d3aa
commit 3accc753d0
5 changed files with 414 additions and 85 deletions
+42 -85
View File
@@ -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} />;
}