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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user