feat: collections visibility enum (matches recipes) + QR on collection PDF (v0.54.0)

Replaces collections.isPublic (boolean) with collections.visibility
(private/unlisted/public/followers — same enum recipes use). Two-step
migration (0050 adds+backfills, 0051 drops isPublic) since drizzle-kit's
add+drop-in-one-diff rename heuristic needs an interactive prompt we
can't satisfy here.

New collectionVisibleToViewer(viewerId) in lib/visibility.ts mirrors the
existing recipe helper (author always sees own; public/unlisted visible
to anyone; followers-only via the same user_follows EXISTS pattern) —
used by the collection detail page, its print view, fork, and favorite,
replacing their old `or(isPublic, own)` checks.

Create/edit collection dialogs get the same 4-option visibility select
as the recipe form instead of a public/private checkbox.

Collection PDF export now generates a QR code (qrcode, same as the
recipe PDF) linking to /collections/{id}, shown only when visibility is
public/unlisted — same "would an anonymous scanner actually resolve
this" rule as the recipe QR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-19 19:26:34 +02:00
parent b1f745da66
commit 9da57dd1d0
25 changed files with 11217 additions and 56 deletions
+21 -5
View File
@@ -1,7 +1,9 @@
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, or } from "@epicure/db";
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";
@@ -17,10 +19,7 @@ export default async function CollectionPrintPage({ params }: Params) {
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const col = await db.query.collections.findFirst({
where: and(
eq(collections.id, id),
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
),
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
with: {
recipes: {
with: {
@@ -39,6 +38,13 @@ export default async function CollectionPrintPage({ params }: Params) {
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"}/collections/${id}`
: null;
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
return (
<>
<style>{`
@@ -71,6 +77,9 @@ export default async function CollectionPrintPage({ params }: Params) {
.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;
@@ -88,6 +97,13 @@ export default async function CollectionPrintPage({ params }: Params) {
<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 }) => {