From 44df734f1c2182a3bc89b41e10234943775921f8 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 19 Jul 2026 19:35:41 +0200 Subject: [PATCH] feat: public collections viewable anonymously at /c/{id} (v0.54.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 + apps/web/app/(app)/collections/[id]/page.tsx | 12 +- apps/web/app/c/[id]/page.tsx | 122 +++++++++++++++++++ apps/web/app/print/collection/[id]/page.tsx | 2 +- apps/web/lib/changelog.ts | 9 +- apps/web/messages/en.json | 6 + apps/web/messages/fr.json | 6 + apps/web/package.json | 2 +- apps/web/proxy.ts | 2 +- package.json | 2 +- 10 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 apps/web/app/c/[id]/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef3d35..b09d08a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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 ### Added diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index 316974f..e669821 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { headers } from "next/headers"; 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 { db, collections, eq, and } from "@epicure/db"; import { collectionVisibleToViewer } from "@/lib/visibility"; @@ -105,6 +105,16 @@ export default async function CollectionPage({ params }: Params) { /> )} {isOwner && } + {col.visibility === "public" && ( + + + + + } /> + {m.recipe.viewPublicly} + + )} {!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && ( )} diff --git a/apps/web/app/c/[id]/page.tsx b/apps/web/app/c/[id]/page.tsx new file mode 100644 index 0000000..a9e3fee --- /dev/null +++ b/apps/web/app/c/[id]/page.tsx @@ -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 { + 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 => r !== null); + + return ( +
+
+ + {m.publicCollection.publicCollectionBy} + + {col.user.name} + + {session && ( +
+ + {m.publicCollection.viewInLibrary} + +
+ )} + {!session && ( +
+ {m.publicRecipe.signUpFree} + {m.publicRecipe.logIn} +
+ )} +
+ +
+

{col.name}

+ {col.description &&

{col.description}

} +

+ {visibleRecipes.length} recipe{visibleRecipes.length !== 1 ? "s" : ""} +

+
+ + {visibleRecipes.length > 0 && ( +
+ {visibleRecipes.map((recipe) => ( + + + + ))} +
+ )} + + {!session && ( +
+

{m.publicCollection.wantToSave}

+

{m.publicCollection.wantToSaveDescription}

+ {m.publicRecipe.getStartedFree} +
+ )} +
+ ); +} diff --git a/apps/web/app/print/collection/[id]/page.tsx b/apps/web/app/print/collection/[id]/page.tsx index e6647f1..5193a44 100644 --- a/apps/web/app/print/collection/[id]/page.tsx +++ b/apps/web/app/print/collection/[id]/page.tsx @@ -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 // anonymous scanner could actually resolve. 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; const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null; diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index ef9ec0f..cc00e4a 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // 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 = { version: string; @@ -11,6 +11,13 @@ export type 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", date: "2026-07-19 17:05", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 6c8cbf5..ba27591 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -531,6 +531,12 @@ "wantToSaveDescription": "Create a free account to save recipes, scale servings, and build your own library.", "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": { "fetchError": "Couldn't fetch substitutes.", "titleFor": "Substitute for {ingredient}", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index aec3c07..193dc78 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -531,6 +531,12 @@ "wantToSaveDescription": "Créez un compte gratuit pour sauvegarder des recettes, ajuster les portions et construire votre propre bibliothèque.", "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": { "fetchError": "Impossible de récupérer les substituts.", "titleFor": "Substitut pour {ingredient}", diff --git a/apps/web/package.json b/apps/web/package.json index 54cb95a..b033b7c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.54.0", + "version": "0.54.1", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 879b43c..edb7a67 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; 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 // every path starts with "/" and that would make the whole app public. const PUBLIC_EXACT_PATHS = ["/"]; diff --git a/package.json b/package.json index 2395022..c235a2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.54.0", + "version": "0.54.1", "private": true, "scripts": { "dev": "pnpm --filter web dev",