feat: search matches ingredients/tags, add tag filter chips to Explore

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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 21:55:17 +02:00
parent f0397740d7
commit 6efabeab8a
7 changed files with 84 additions and 12 deletions
+15
View File
@@ -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 (
<ExplorePageContent
trending={trending}
recent={recent}
initialQuery={q ?? ""}
popularTags={popularTags}
/>
);
}
+19 -1
View File
@@ -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 ---