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
+6
View File
@@ -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
+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 ---
@@ -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<Set<string>>(new Set());
const [results, setResults] = useState<SearchRecipeResult[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
@@ -111,7 +113,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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) {
)}
</div>
)}
{hasQuery && popularTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{popularTags.map((tag) => (
<button key={tag} onClick={() => toggleTag(tag)}>
<Badge variant={selectedTags.has(tag) ? "default" : "outline"} className="cursor-pointer">
{tag}
</Badge>
</button>
))}
</div>
)}
</div>
{/* People results */}
@@ -365,7 +390,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={() => fetchResults(query, difficulty, maxMins, offset, true)}
onClick={() => fetchResults(query, difficulty, maxMins, offset, [...selectedTags], true)}
disabled={loadingMore}
className="min-w-32"
>
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.14.0";
export const APP_VERSION = "0.15.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.15.0",
date: "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.",
],
},
{
version: "0.14.0",
date: "2026-07-13 21:47",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.14.0",
"version": "0.15.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.14.0",
"version": "0.15.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",