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:
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user