Files
Epicure/apps/web/app/c/[id]/page.tsx
T
Arnaud 44df734f1c feat: public collections viewable anonymously at /c/{id} (v0.54.1)
New /c/[id] route mirrors /r/[id]'s pattern for recipes: no session
required, gated on collections.visibility != 'private', with an explicit
followers-only check (viewer must be signed in and follow the owner)
since this route has no other access control. Added to proxy.ts's
PUBLIC_PATHS.

Only shows recipes inside the collection that the anonymous/signed-in
viewer could actually see on their own (public/unlisted always, followers
recipes only if following that recipe's author) — the owner's own private
recipes stay hidden from everyone else even inside their own public
collection.

Collection PDF export's QR code and the collection page's new "view
publicly" icon (public visibility only, matching the recipe page's same
rule) both now point here instead of the auth-gated /collections/{id}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:35:41 +02:00

123 lines
4.9 KiB
TypeScript

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Globe } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, and, ne } from "@epicure/db";
import { isFollowing } from "@/lib/social-follows";
import { RecipeGridCard } from "@/components/recipe/recipe-grid-card";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getMessages } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { id } = await params;
const col = await db.query.collections.findFirst({
where: (c, { and, eq }) => and(eq(c.id, id), eq(c.visibility, "public")),
});
if (!col) return {};
return {
title: col.name,
description: col.description ?? undefined,
openGraph: { title: col.name, description: col.description ?? undefined, type: "article" },
};
}
export default async function PublicCollectionPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() }).catch(() => null);
const m = getMessages((session?.user as { locale?: string } | undefined)?.locale);
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), ne(collections.visibility, "private")),
with: {
user: true,
recipes: {
orderBy: (t, { asc }) => asc(t.position),
with: { recipe: { with: { photos: true } } },
},
},
});
if (!col) notFound();
// Same rationale as /r/[id]: this route has no session-based access
// control otherwise, so a "followers"-only collection needs an explicit
// gate — the owner, or a signed-in follower of the owner.
if (col.visibility === "followers") {
const viewerId = session?.user?.id;
const allowed = !!viewerId && (viewerId === col.userId || await isFollowing(viewerId, col.userId));
if (!allowed) notFound();
}
const viewerId = session?.user?.id;
const recipeEntries = await Promise.all(
col.recipes.map(async (r) => {
const recipe = r.recipe;
if (!recipe) return null;
if (recipe.visibility === "public" || recipe.visibility === "unlisted") return recipe;
if (recipe.visibility === "followers" && viewerId) {
if (viewerId === recipe.authorId || await isFollowing(viewerId, recipe.authorId)) return recipe;
}
// Owner's own private recipes (allowed in their own collection) aren't
// shown to anyone else here — this route has no owner-only branch.
return null;
})
);
const visibleRecipes = recipeEntries.filter((r): r is NonNullable<typeof r> => r !== null);
return (
<div className="container mx-auto max-w-5xl px-4 py-8 space-y-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-3.5 w-3.5" />
<span>{m.publicCollection.publicCollectionBy}</span>
<Link href={`/u/${col.user.username ?? col.user.id}`} className="hover:text-foreground font-medium">
{col.user.name}
</Link>
{session && (
<div className="ml-auto">
<Link href={`/collections/${id}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{m.publicCollection.viewInLibrary}
</Link>
</div>
)}
{!session && (
<div className="ml-auto flex items-center gap-2">
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>{m.publicRecipe.signUpFree}</Link>
<Link href="/login" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>{m.publicRecipe.logIn}</Link>
</div>
)}
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{col.name}</h1>
{col.description && <p className="text-muted-foreground leading-relaxed">{col.description}</p>}
<p className="text-sm text-muted-foreground">
{visibleRecipes.length} recipe{visibleRecipes.length !== 1 ? "s" : ""}
</p>
</div>
{visibleRecipes.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{visibleRecipes.map((recipe) => (
<Link key={recipe.id} href={`/r/${recipe.id}`}>
<RecipeGridCard recipe={recipe} />
</Link>
))}
</div>
)}
{!session && (
<div className="rounded-xl border bg-muted/40 p-6 text-center space-y-3">
<p className="font-semibold">{m.publicCollection.wantToSave}</p>
<p className="text-sm text-muted-foreground">{m.publicCollection.wantToSaveDescription}</p>
<Link href="/signup" className={cn(buttonVariants())}>{m.publicRecipe.getStartedFree}</Link>
</div>
)}
</div>
);
}