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>
This commit is contained in:
Arnaud
2026-07-19 19:35:41 +02:00
parent 9da57dd1d0
commit 44df734f1c
10 changed files with 162 additions and 6 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.54.1 — 2026-07-19 17:25
### Added
- Public collections are now viewable by logged-out visitors at /c/{id} (no account needed), same as public recipes at /r/{id} — the collection's QR code/print link and the new "view publicly" icon on the collection page both point here. Followers-only collections check the visitor's follow status the same way followers-only recipes do.
## 0.54.0 — 2026-07-19 17:05 ## 0.54.0 — 2026-07-19 17:05
### Added ### Added
+11 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { Printer, UtensilsCrossed, StickyNote } from "lucide-react"; import { Printer, UtensilsCrossed, StickyNote, ExternalLink } from "lucide-react";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, collections, eq, and } from "@epicure/db"; import { db, collections, eq, and } from "@epicure/db";
import { collectionVisibleToViewer } from "@/lib/visibility"; import { collectionVisibleToViewer } from "@/lib/visibility";
@@ -105,6 +105,16 @@ export default async function CollectionPage({ params }: Params) {
/> />
)} )}
{isOwner && <DeleteCollectionDialog collectionId={id} />} {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") && ( {!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && (
<ForkCollectionButton collectionId={id} /> <ForkCollectionButton collectionId={id} />
)} )}
+122
View File
@@ -0,0 +1,122 @@
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>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export default async function CollectionPrintPage({ params }: Params) {
// Same rationale as the recipe print page's QR: only link a URL an // Same rationale as the recipe print page's QR: only link a URL an
// anonymous scanner could actually resolve. // anonymous scanner could actually resolve.
const shareUrl = col.visibility === "public" || col.visibility === "unlisted" const shareUrl = col.visibility === "public" || col.visibility === "unlisted"
? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/collections/${id}` ? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/c/${id}`
: null; : null;
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null; const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.54.0"; export const APP_VERSION = "0.54.1";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.54.1",
date: "2026-07-19 17:25",
added: [
"Public collections are now viewable by logged-out visitors at /c/{id} (no account needed), same as public recipes at /r/{id} — the collection's QR code/print link and the new \"view publicly\" icon on the collection page both point here. Followers-only collections check the visitor's follow status the same way followers-only recipes do.",
],
},
{ {
version: "0.54.0", version: "0.54.0",
date: "2026-07-19 17:05", date: "2026-07-19 17:05",
+6
View File
@@ -531,6 +531,12 @@
"wantToSaveDescription": "Create a free account to save recipes, scale servings, and build your own library.", "wantToSaveDescription": "Create a free account to save recipes, scale servings, and build your own library.",
"getStartedFree": "Get started free" "getStartedFree": "Get started free"
}, },
"publicCollection": {
"publicCollectionBy": "Public collection by",
"viewInLibrary": "View in your library",
"wantToSave": "Want to save this collection?",
"wantToSaveDescription": "Create a free account to fork collections, save recipes, and build your own library."
},
"substitute": { "substitute": {
"fetchError": "Couldn't fetch substitutes.", "fetchError": "Couldn't fetch substitutes.",
"titleFor": "Substitute for {ingredient}", "titleFor": "Substitute for {ingredient}",
+6
View File
@@ -531,6 +531,12 @@
"wantToSaveDescription": "Créez un compte gratuit pour sauvegarder des recettes, ajuster les portions et construire votre propre bibliothèque.", "wantToSaveDescription": "Créez un compte gratuit pour sauvegarder des recettes, ajuster les portions et construire votre propre bibliothèque.",
"getStartedFree": "Commencer gratuitement" "getStartedFree": "Commencer gratuitement"
}, },
"publicCollection": {
"publicCollectionBy": "Collection publique par",
"viewInLibrary": "Voir dans votre bibliothèque",
"wantToSave": "Envie de sauvegarder cette collection ?",
"wantToSaveDescription": "Créez un compte gratuit pour forker des collections, sauvegarder des recettes et construire votre propre bibliothèque."
},
"substitute": { "substitute": {
"fetchError": "Impossible de récupérer les substituts.", "fetchError": "Impossible de récupérer les substituts.",
"titleFor": "Substitut pour {ingredient}", "titleFor": "Substitut pour {ingredient}",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.54.0", "version": "0.54.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies"; import { getSessionCookie } from "better-auth/cookies";
import { applyRateLimit } from "@/lib/rate-limit"; import { applyRateLimit } from "@/lib/rate-limit";
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/verify-2fa", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/", "/features", "/pricing", "/about", "/privacy", "/terms", "/contact"]; const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/verify-2fa", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/c/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/", "/features", "/pricing", "/about", "/privacy", "/terms", "/contact"];
// Exact-match only — "/" can't go in PUBLIC_PATHS's startsWith list, since // Exact-match only — "/" can't go in PUBLIC_PATHS's startsWith list, since
// every path starts with "/" and that would make the whole app public. // every path starts with "/" and that would make the whole app public.
const PUBLIC_EXACT_PATHS = ["/"]; const PUBLIC_EXACT_PATHS = ["/"];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.54.0", "version": "0.54.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",