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:
@@ -55,11 +55,25 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
: undefined;
|
||||
const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined;
|
||||
|
||||
// Escape ilike wildcard chars (% and _) so a search like "100%" matches literally.
|
||||
const escapedQuery = query.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
|
||||
const where = and(
|
||||
eq(recipes.authorId, session.user.id),
|
||||
batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined,
|
||||
query
|
||||
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
|
||||
? or(
|
||||
ilike(recipes.title, `%${escapedQuery}%`),
|
||||
ilike(recipes.description, `%${escapedQuery}%`),
|
||||
// Plain SQL identifiers rather than drizzle's column proxies —
|
||||
// inside db.query.recipes.findMany's `where`, the relational query
|
||||
// builder rewrites embedded column refs to the outer query's own
|
||||
// table alias regardless of which table they actually belong to,
|
||||
// producing broken SQL (recipes.raw_name doesn't exist). Using
|
||||
// literal identifiers for the ingredients subquery sidesteps that.
|
||||
sql`exists (select 1 from recipe_ingredients ri where ri.recipe_id = ${recipes.id} and ri.raw_name ilike ${`%${escapedQuery}%`})`,
|
||||
sql`exists (select 1 from unnest(${recipes.tags}) as tag where tag ilike ${`%${escapedQuery}%`})`
|
||||
)
|
||||
: undefined,
|
||||
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
|
||||
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user