diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f13a15..5c5cdcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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 ### Added diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index 3a2db23..e546ed5 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -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, diff --git a/apps/web/app/print/[id]/page.tsx b/apps/web/app/print/[id]/page.tsx index 22670a7..7a6b87e 100644 --- a/apps/web/app/print/[id]/page.tsx +++ b/apps/web/app/print/[id]/page.tsx @@ -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) {
-

{recipe.title}

+
+

{recipe.title}

+ {qrDataUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- a data: URL generated server-side, not an optimizable remote image */} + {m.recipe.qrCodeAlt} + {m.recipe.qrCodeCaption} +
+ )} +
{recipe.description && (

{recipe.description}

diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index b63517b..2b132b3 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // 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 = { version: string; @@ -11,6 +11,14 @@ export type 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", date: "2026-07-13 23:06", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b5b5124..d1e412a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -65,6 +65,8 @@ "shareCopyFailed": "Failed to copy link", "shareNotPublicNotice": "This link will only work once the recipe is set to Public — link copied anyway.", "print": "Print", + "qrCodeAlt": "QR code linking to this recipe", + "qrCodeCaption": "Scan to view online", "ingredients": "Ingredients", "instructions": "Instructions", "photos": "Photos", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index eeaa429..6adcaf9 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -65,6 +65,8 @@ "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.", "print": "Imprimer", + "qrCodeAlt": "Code QR menant à cette recette", + "qrCodeCaption": "Scannez pour voir en ligne", "ingredients": "Ingrédients", "instructions": "Instructions", "photos": "Photos", diff --git a/apps/web/package.json b/apps/web/package.json index 40a1e23..2808c9c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.23.0", + "version": "0.24.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 141fd14..8216214 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.23.0", + "version": "0.24.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",