feat: ingredient/tag search on My Recipes, QR code on printed recipes

My Recipes search previously only matched title/description; now also
matches ingredient rawName and tags, mirroring the public search
improvement from earlier. Hit and fixed a real bug along the way:
embedding drizzle column proxies from a foreign table inside a raw
sql`` fragment passed to db.query.recipes.findMany's `where` gets
rewritten to the wrong table alias by the relational query builder,
producing broken SQL — worked fine in the plain query builder used by
/api/v1/search, but not here. Fixed by using literal SQL identifiers
for the ingredients subquery instead of column proxies.

Recipe print pages get the same QR-code treatment shopping lists got
earlier, gated on visibility !== "private" (both public and unlisted
resolve at /r/[id]).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 08:09:52 +02:00
parent acc93de708
commit c16342a9b9
8 changed files with 58 additions and 5 deletions
+22 -1
View File
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import QRCode from "qrcode";
import { auth } from "@/lib/auth/server";
import { db, recipes, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
@@ -27,6 +28,13 @@ export default async function RecipePrintPage({ params }: Params) {
if (!recipe) notFound();
// /r/[id] resolves for both "public" and "unlisted" (see app/r/[id]/page.tsx)
// — only "private" has no scannable link to offer.
const shareUrl = recipe.visibility !== "private"
? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/r/${id}`
: null;
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const stepGroups: Array<{ label: string; steps: typeof recipe.steps }> = [];
@@ -61,6 +69,10 @@ export default async function RecipePrintPage({ params }: Params) {
line-height: 1.6;
}
h1 { font-size: 2em; margin: 0 0 8px; line-height: 1.2; }
.title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.qr-block { text-align: center; flex-shrink: 0; font-family: system-ui, sans-serif; }
.qr-block img { width: 84px; height: 84px; display: block; }
.qr-block span { display: block; font-size: 0.65em; color: #999; margin-top: 4px; max-width: 100px; }
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; }
@@ -91,7 +103,16 @@ export default async function RecipePrintPage({ params }: Params) {
<PrintTrigger />
<article>
<h1>{recipe.title}</h1>
<div className="title-row">
<h1>{recipe.title}</h1>
{qrDataUrl && (
<div className="qr-block">
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL generated server-side, not an optimizable remote image */}
<img src={qrDataUrl} alt={m.recipe.qrCodeAlt} />
<span>{m.recipe.qrCodeCaption}</span>
</div>
)}
</div>
{recipe.description && (
<p className="description">{recipe.description}</p>