Files
Epicure/apps/web/app/print/collection/[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

166 lines
7.1 KiB
TypeScript

import { notFound } from "next/navigation";
import { headers } from "next/headers";
import QRCode from "qrcode";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, and } from "@epicure/db";
import { collectionVisibleToViewer } from "@/lib/visibility";
import { PrintTrigger } from "@/components/recipe/print-trigger";
import { formatIngredientQuantity } from "@/lib/unit-conversion";
import { getMessages, formatMessage } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
export default async function CollectionPrintPage({ 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 unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
with: {
recipes: {
with: {
recipe: {
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
},
},
},
},
});
if (!col) notFound();
const recipeEntries = col.recipes.filter((r) => r.recipe !== null);
// 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"}/c/${id}`
: null;
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
return (
<>
<style>{`
@media print {
body { margin: 0; }
.no-print { display: none !important; }
article { page-break-after: always; }
article:last-child { page-break-after: auto; }
}
body {
font-family: Georgia, 'Times New Roman', serif;
color: #1a1a1a;
max-width: 680px;
margin: 40px auto;
padding: 0 24px;
font-size: 14px;
line-height: 1.6;
}
h1 { font-size: 2em; margin: 0 0 8px; line-height: 1.2; }
h2 { font-size: 1.1em; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin: 24px 0 12px; }
.meta { display: flex; gap: 24px; font-size: 0.85em; color: #555; margin: 12px 0 16px; flex-wrap: wrap; }
.meta span { display: flex; align-items: center; gap: 4px; }
.description { color: #444; font-style: italic; margin-bottom: 16px; }
ul.ingredients { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
ul.ingredients li { padding: 4px 0; border-bottom: 1px dotted #e0e0e0; font-family: system-ui, sans-serif; font-size: 0.9em; }
ul.ingredients li .qty { color: #666; min-width: 60px; display: inline-block; }
ol.steps { padding-left: 20px; margin: 0; }
ol.steps li { padding: 6px 0 6px 4px; border-bottom: 1px dotted #e0e0e0; font-size: 0.95em; }
ol.steps li:last-child { border-bottom: none; }
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
.cookbook-cover { text-align: center; margin-bottom: 40px; }
.cookbook-cover h1 { font-size: 2.6em; }
.qr-block { text-align: center; margin: 0 auto; font-family: system-ui, sans-serif; }
.qr-block img { width: 84px; height: 84px; display: block; margin: 0 auto; }
.qr-block span { display: block; font-size: 0.65em; color: #999; margin-top: 4px; }
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
.print-btn {
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
background: #18181b; color: white; border: none; border-radius: 6px;
cursor: pointer; font-size: 13px; font-family: system-ui, sans-serif;
}
.print-btn:hover { background: #3f3f46; }
`}</style>
<PrintTrigger />
<div className="cookbook-cover">
<h1>{col.name}</h1>
{col.description && <p className="description">{col.description}</p>}
<p style={{ fontFamily: "system-ui, sans-serif", fontSize: "0.85em", color: "#888" }}>
{recipeEntries.length} recipe{recipeEntries.length !== 1 ? "s" : ""}
</p>
{qrDataUrl && (
<div className="qr-block">
{/* eslint-disable-next-line @next/next/no-img-element -- data: URI, next/image can't optimize it anyway */}
<img src={qrDataUrl} alt={m.collections.qrCodeAlt} />
<span>{m.collections.qrCodeCaption}</span>
</div>
)}
</div>
{recipeEntries.map(({ recipe }) => {
if (!recipe) return null;
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
return (
<article key={recipe.id}>
<h1>{recipe.title}</h1>
{recipe.description && <p className="description">{recipe.description}</p>}
<div className="meta">
{recipe.baseServings && <span>{formatMessage(m.recipe.servings, { count: recipe.baseServings })}</span>}
{recipe.prepMins && <span>{formatMessage(m.recipe.prep, { mins: recipe.prepMins })}</span>}
{recipe.cookMins && <span>{formatMessage(m.recipe.cook, { mins: recipe.cookMins })}</span>}
{totalMins > 0 && <span>{formatMessage(m.recipe.total, { mins: totalMins })}</span>}
{recipe.difficulty && <span>{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}</span>}
</div>
{recipe.ingredients.length > 0 && (
<>
<h2>{m.recipe.ingredients}</h2>
<ul className="ingredients">
{recipe.ingredients.map((ing) => (
<li key={ing.id}>
<span className="qty">
{formatIngredientQuantity(ing.quantity, ing.unit, unitPref)}
</span>
{ing.rawName}
{ing.note && <span style={{ color: "#888" }}> ({ing.note})</span>}
</li>
))}
</ul>
</>
)}
{recipe.steps.length > 0 && (
<>
<h2>{m.recipe.instructions}</h2>
<ol className="steps">
{recipe.steps.map((step) => (
<li key={step.id}>
{step.instruction}
{!!step.timerSeconds && (
<span className="timer"> {Math.floor(step.timerSeconds / 60)} min</span>
)}
</li>
))}
</ol>
</>
)}
</article>
);
})}
<footer>{m.print.footer}</footer>
</>
);
}