Files
Epicure/apps/web/app/(app)/collections/[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

141 lines
6.0 KiB
TypeScript

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Printer, UtensilsCrossed, StickyNote, ExternalLink } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, and } from "@epicure/db";
import { collectionVisibleToViewer } from "@/lib/visibility";
import { RecipeGridCard } from "@/components/recipe/recipe-grid-card";
import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid";
import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
import { ShareCollectionButton } from "@/components/collections/share-collection-button";
import { GenerateMealDialog } from "@/components/collections/generate-meal-dialog";
import { EditCollectionDialog } from "@/components/collections/edit-collection-dialog";
import { DeleteCollectionDialog } from "@/components/collections/delete-collection-dialog";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { EmptyState } from "@/components/shared/empty-state";
import { collectionToMarkdown } from "@/lib/markdown/collection";
import { getMessages } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
export const metadata: Metadata = {};
export default async function CollectionPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
with: {
recipes: {
orderBy: (t, { asc }) => asc(t.position),
with: { recipe: { with: { photos: true } } },
},
},
});
if (!col) notFound();
const isOwner = col.userId === session.user.id;
const recipeList = col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : []));
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{col.name}</h1>
{col.description && <p className="text-muted-foreground mt-1">{col.description}</p>}
{col.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{col.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
<p className="text-sm text-muted-foreground mt-1">
{recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {m.recipe.visibility[col.visibility]}
</p>
{isOwner && col.notes && (
<div className="mt-2 flex items-start gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm text-muted-foreground max-w-2xl">
<StickyNote className="h-4 w-4 shrink-0 mt-0.5" />
<p className="whitespace-pre-wrap">{col.notes}</p>
</div>
)}
</div>
<TooltipProvider>
<div className="flex flex-wrap items-center gap-1">
{recipeList.length > 0 && (
<>
<Tooltip>
<TooltipTrigger render={
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Printer className="h-4 w-4" />
</Link>
} />
<TooltipContent>{m.collections.exportPdf}</TooltipContent>
</Tooltip>
<ExportMarkdownButton
markdown={collectionToMarkdown({
name: col.name,
description: col.description,
recipes: recipeList,
})}
filename={col.name}
/>
</>
)}
{isOwner && <GenerateMealDialog collectionId={id} />}
{isOwner && <ShareCollectionButton collectionId={id} />}
{isOwner && (
<EditCollectionDialog
collectionId={id}
initialName={col.name}
initialDescription={col.description}
initialNotes={col.notes}
initialTags={col.tags}
initialVisibility={col.visibility}
/>
)}
{isOwner && <DeleteCollectionDialog collectionId={id} />}
{col.visibility === "public" && (
<Tooltip>
<TooltipTrigger render={
<Link href={`/c/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<ExternalLink className="h-4 w-4" />
</Link>
} />
<TooltipContent>{m.recipe.viewPublicly}</TooltipContent>
</Tooltip>
)}
{!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && (
<ForkCollectionButton collectionId={id} />
)}
</div>
</TooltipProvider>
</div>
{recipeList.length === 0 ? (
<EmptyState icon={UtensilsCrossed} title={m.collections.emptyCollection} compact />
) : isOwner ? (
<CollectionRecipesGrid collectionId={id} recipes={recipeList} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{recipeList.map((recipe) => (
<Link key={recipe.id} href={`/recipes/${recipe.id}`}>
<RecipeGridCard recipe={recipe} />
</Link>
))}
</div>
)}
</div>
);
}