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",