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:
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user