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:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.24.0 — 2026-07-14 08:08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **My Recipes search now matches ingredients and tags too**, not just title/description — same improvement shipped for Explore/Search earlier.
|
||||||
|
- **QR code on printed recipes**: a public or unlisted recipe's printout now includes a scannable code linking back to the online version.
|
||||||
|
|
||||||
## 0.23.0 — 2026-07-13 23:06
|
## 0.23.0 — 2026-07-13 23:06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -55,11 +55,25 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
: undefined;
|
: undefined;
|
||||||
const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : 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(
|
const where = and(
|
||||||
eq(recipes.authorId, session.user.id),
|
eq(recipes.authorId, session.user.id),
|
||||||
batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined,
|
batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined,
|
||||||
query
|
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,
|
: undefined,
|
||||||
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
|
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
|
||||||
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
|
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import QRCode from "qrcode";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, eq, and } from "@epicure/db";
|
import { db, recipes, eq, and } from "@epicure/db";
|
||||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||||
@@ -27,6 +28,13 @@ export default async function RecipePrintPage({ params }: Params) {
|
|||||||
|
|
||||||
if (!recipe) notFound();
|
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 totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
|
|
||||||
const stepGroups: Array<{ label: string; steps: typeof recipe.steps }> = [];
|
const stepGroups: Array<{ label: string; steps: typeof recipe.steps }> = [];
|
||||||
@@ -61,6 +69,10 @@ export default async function RecipePrintPage({ params }: Params) {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
h1 { font-size: 2em; margin: 0 0 8px; line-height: 1.2; }
|
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; }
|
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 { 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; }
|
.meta span { display: flex; align-items: center; gap: 4px; }
|
||||||
@@ -91,7 +103,16 @@ export default async function RecipePrintPage({ params }: Params) {
|
|||||||
<PrintTrigger />
|
<PrintTrigger />
|
||||||
|
|
||||||
<article>
|
<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 && (
|
{recipe.description && (
|
||||||
<p className="description">{recipe.description}</p>
|
<p className="description">{recipe.description}</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.23.0";
|
export const APP_VERSION = "0.24.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.24.0",
|
||||||
|
date: "2026-07-14 08:08",
|
||||||
|
added: [
|
||||||
|
"**My Recipes search now matches ingredients and tags too**, not just title/description — same improvement shipped for Explore/Search earlier.",
|
||||||
|
"**QR code on printed recipes**: a public or unlisted recipe's printout now includes a scannable code linking back to the online version.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.23.0",
|
version: "0.23.0",
|
||||||
date: "2026-07-13 23:06",
|
date: "2026-07-13 23:06",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
"shareCopyFailed": "Failed to copy link",
|
"shareCopyFailed": "Failed to copy link",
|
||||||
"shareNotPublicNotice": "This link will only work once the recipe is set to Public — link copied anyway.",
|
"shareNotPublicNotice": "This link will only work once the recipe is set to Public — link copied anyway.",
|
||||||
"print": "Print",
|
"print": "Print",
|
||||||
|
"qrCodeAlt": "QR code linking to this recipe",
|
||||||
|
"qrCodeCaption": "Scan to view online",
|
||||||
"ingredients": "Ingredients",
|
"ingredients": "Ingredients",
|
||||||
"instructions": "Instructions",
|
"instructions": "Instructions",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
"shareCopyFailed": "Échec de la copie du lien",
|
"shareCopyFailed": "Échec de la copie du lien",
|
||||||
"shareNotPublicNotice": "Ce lien ne fonctionnera qu'une fois la recette définie sur Public — lien copié quand même.",
|
"shareNotPublicNotice": "Ce lien ne fonctionnera qu'une fois la recette définie sur Public — lien copié quand même.",
|
||||||
"print": "Imprimer",
|
"print": "Imprimer",
|
||||||
|
"qrCodeAlt": "Code QR menant à cette recette",
|
||||||
|
"qrCodeCaption": "Scannez pour voir en ligne",
|
||||||
"ingredients": "Ingrédients",
|
"ingredients": "Ingrédients",
|
||||||
"instructions": "Instructions",
|
"instructions": "Instructions",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.23.0",
|
"version": "0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.23.0",
|
"version": "0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user