From 6efabeab8a6615ed824d3fdd83e53f226cfce662 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 21:55:17 +0200 Subject: [PATCH] feat: search matches ingredients/tags, add tag filter chips to Explore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search previously only matched title/description. Now also matches ingredient rawName (EXISTS subquery) and tags (unnest+ILIKE) via sequential scan — fine at current scale, flagged for a trigram/GIN index if it gets slow. Explore also shows the 12 most-used public tags as clickable chips that AND-filter results via a new `tags` query param (array containment). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 +++ apps/web/app/(app)/explore/page.tsx | 15 +++++++ apps/web/app/api/v1/search/route.ts | 20 ++++++++- .../search/explore-page-content.tsx | 41 +++++++++++++++---- apps/web/lib/changelog.ts | 10 ++++- apps/web/package.json | 2 +- package.json | 2 +- 7 files changed, 84 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abd50c8..c004b1d 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.15.0 — 2026-07-13 21:54 + +### Added +- **Search now matches ingredients and tags**, not just title/description — "zaatar" now finds a recipe that uses it even if the title doesn't mention it. +- **Tag filter chips on Explore**: click a popular tag to narrow results instead of typing it. + ## 0.14.0 — 2026-07-13 21:47 ### Added diff --git a/apps/web/app/(app)/explore/page.tsx b/apps/web/app/(app)/explore/page.tsx index e525cf0..1857298 100644 --- a/apps/web/app/(app)/explore/page.tsx +++ b/apps/web/app/(app)/explore/page.tsx @@ -76,11 +76,26 @@ export default async function ExplorePage({ const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r); const recent: RecipeResult[] = recentRows; + // Most-used tags across public recipes — shown as filter chips on the + // search bar so browsing by tag doesn't require typing the exact word. + const popularTagsResult = await db.execute<{ tag: string }>(sql` + select tag, count(*) as usage_count + from recipes r + inner join users u on r.author_id = u.id + cross join lateral unnest(r.tags) as tag + where r.visibility = 'public' and u.is_private = false + group by tag + order by usage_count desc + limit 12 + `); + const popularTags = popularTagsResult.map((r) => r.tag); + return ( ); } diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts index 5ad9d1d..6c7489d 100644 --- a/apps/web/app/api/v1/search/route.ts +++ b/apps/web/app/api/v1/search/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, recipes, + recipeIngredients, users, eq, and, @@ -69,12 +70,19 @@ export async function GET(req: NextRequest) { // Escape ilike wildcard chars (% and _) so user input like "100%" is matched literally. const escapedQ = q.replace(/[\\%_]/g, (c) => `\\${c}`); + // A query also matches if it's a substring of an ingredient's raw name or + // one of the recipe's free-form tags — not just title/description. Neither + // recipeIngredients.rawName nor recipes.tags has a supporting index today, + // so this is a sequential scan; fine at this scale, worth a trigram/GIN + // index if search ever gets slow. const conditions = [ eq(recipes.visibility, "public"), eq(users.isPrivate, false), or( ilike(recipes.title, `%${escapedQ}%`), - ilike(recipes.description, `%${escapedQ}%`) + ilike(recipes.description, `%${escapedQ}%`), + sql`exists (select 1 from ${recipeIngredients} where ${recipeIngredients.recipeId} = ${recipes.id} and ${recipeIngredients.rawName} ilike ${`%${escapedQ}%`})`, + sql`exists (select 1 from unnest(${recipes.tags}) as tag where tag ilike ${`%${escapedQ}%`})` )!, ]; @@ -93,6 +101,16 @@ export async function GET(req: NextRequest) { conditions.push(sql`${recipes.dietaryTags} @> ${JSON.stringify({ [tag]: true })}::jsonb`); } + // Exact-tag refinement (filter chips) — distinct from the free-text match + // above, which only checks whether the query substring appears in a tag. + const tagsRaw = searchParams.get("tags"); + const tagFilters = tagsRaw + ? tagsRaw.split(",").map((s) => s.trim()).filter(Boolean).slice(0, 5) + : []; + for (const tag of tagFilters) { + conditions.push(sql`${recipes.tags} @> ARRAY[${tag}]::text[]`); + } + const where = and(...conditions); // --- Main data query --- diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index a518a7f..35da3f4 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -79,9 +79,10 @@ type Props = { trending: ExploreRecipeResult[]; recent: ExploreRecipeResult[]; initialQuery: string; + popularTags: string[]; }; -export function ExplorePageContent({ trending, recent, initialQuery }: Props) { +export function ExplorePageContent({ trending, recent, initialQuery, popularTags }: Props) { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations("explore"); @@ -93,6 +94,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { const [query, setQuery] = useState(initialQuery); const [difficulty, setDifficulty] = useState("any"); const [maxMins, setMaxMins] = useState(""); + const [selectedTags, setSelectedTags] = useState>(new Set()); const [results, setResults] = useState([]); const [total, setTotal] = useState(0); const [offset, setOffset] = useState(0); @@ -111,7 +113,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { const debounceRef = useRef | null>(null); const fetchResults = useCallback( - async (q: string, diff: string, maxM: string, off: number, append = false) => { + async (q: string, diff: string, maxM: string, off: number, tags: string[], append = false) => { if (!q.trim()) { setResults([]); setTotal(0); @@ -124,6 +126,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { const params = new URLSearchParams({ q, limit: "20", offset: String(off) }); if (diff && diff !== "any") params.set("difficulty", diff); if (maxM) params.set("maxMins", maxM); + if (tags.length > 0) params.set("tags", tags.join(",")); const res = await fetch(`/api/v1/search?${params}`); if (!res.ok) return; const json = await res.json() as SearchResponse; @@ -157,7 +160,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { useEffect(() => { if (initialQuery) { - fetchResults(initialQuery, "any", "", 0); + fetchResults(initialQuery, "any", "", 0, []); fetchPeople(initialQuery); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -213,7 +216,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { debounceRef.current = setTimeout(() => { setQuery(value); updateUrl(value); - fetchResults(value, difficulty, maxMins, 0); + fetchResults(value, difficulty, maxMins, 0, [...selectedTags]); fetchPeople(value); }, 300); }; @@ -223,24 +226,34 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { if (debounceRef.current) clearTimeout(debounceRef.current); setQuery(inputValue); updateUrl(inputValue); - fetchResults(inputValue, difficulty, maxMins, 0); + fetchResults(inputValue, difficulty, maxMins, 0, [...selectedTags]); fetchPeople(inputValue); }; const handleDifficultyChange = (value: string | null) => { const v = value ?? "any"; setDifficulty(v); - fetchResults(query, v, maxMins, 0); + fetchResults(query, v, maxMins, 0, [...selectedTags]); }; const handleMaxMinsChange = (value: string) => { setMaxMins(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - fetchResults(query, difficulty, value, 0); + fetchResults(query, difficulty, value, 0, [...selectedTags]); }, 300); }; + const toggleTag = (tag: string) => { + setSelectedTags((prev) => { + const next = new Set(prev); + if (next.has(tag)) next.delete(tag); + else next.add(tag); + fetchResults(query, difficulty, maxMins, 0, [...next]); + return next; + }); + }; + const hasQuery = query.trim().length > 0; const hasMore = results.length < total; const hasNoResults = hasQuery && !loading && !peopleLoading && results.length === 0 && peopleResults.length === 0; @@ -294,6 +307,18 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { )} )} + + {hasQuery && popularTags.length > 0 && ( +
+ {popularTags.map((tag) => ( + + ))} +
+ )} {/* People results */} @@ -365,7 +390,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {