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:
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user