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.
|
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
|
## 0.14.0 — 2026-07-13 21:47
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -76,11 +76,26 @@ export default async function ExplorePage({
|
|||||||
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
|
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
|
||||||
const recent: RecipeResult[] = recentRows;
|
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 (
|
return (
|
||||||
<ExplorePageContent
|
<ExplorePageContent
|
||||||
trending={trending}
|
trending={trending}
|
||||||
recent={recent}
|
recent={recent}
|
||||||
initialQuery={q ?? ""}
|
initialQuery={q ?? ""}
|
||||||
|
popularTags={popularTags}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
recipes,
|
recipes,
|
||||||
|
recipeIngredients,
|
||||||
users,
|
users,
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
@@ -69,12 +70,19 @@ export async function GET(req: NextRequest) {
|
|||||||
// Escape ilike wildcard chars (% and _) so user input like "100%" is matched literally.
|
// Escape ilike wildcard chars (% and _) so user input like "100%" is matched literally.
|
||||||
const escapedQ = q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
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 = [
|
const conditions = [
|
||||||
eq(recipes.visibility, "public"),
|
eq(recipes.visibility, "public"),
|
||||||
eq(users.isPrivate, false),
|
eq(users.isPrivate, false),
|
||||||
or(
|
or(
|
||||||
ilike(recipes.title, `%${escapedQ}%`),
|
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`);
|
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);
|
const where = and(...conditions);
|
||||||
|
|
||||||
// --- Main data query ---
|
// --- Main data query ---
|
||||||
|
|||||||
@@ -79,9 +79,10 @@ type Props = {
|
|||||||
trending: ExploreRecipeResult[];
|
trending: ExploreRecipeResult[];
|
||||||
recent: ExploreRecipeResult[];
|
recent: ExploreRecipeResult[];
|
||||||
initialQuery: string;
|
initialQuery: string;
|
||||||
|
popularTags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
export function ExplorePageContent({ trending, recent, initialQuery, popularTags }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations("explore");
|
const t = useTranslations("explore");
|
||||||
@@ -93,6 +94,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
const [difficulty, setDifficulty] = useState("any");
|
const [difficulty, setDifficulty] = useState("any");
|
||||||
const [maxMins, setMaxMins] = useState("");
|
const [maxMins, setMaxMins] = useState("");
|
||||||
|
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||||
const [results, setResults] = useState<SearchRecipeResult[]>([]);
|
const [results, setResults] = useState<SearchRecipeResult[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [offset, setOffset] = 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 debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fetchResults = useCallback(
|
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()) {
|
if (!q.trim()) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
@@ -124,6 +126,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
const params = new URLSearchParams({ q, limit: "20", offset: String(off) });
|
const params = new URLSearchParams({ q, limit: "20", offset: String(off) });
|
||||||
if (diff && diff !== "any") params.set("difficulty", diff);
|
if (diff && diff !== "any") params.set("difficulty", diff);
|
||||||
if (maxM) params.set("maxMins", maxM);
|
if (maxM) params.set("maxMins", maxM);
|
||||||
|
if (tags.length > 0) params.set("tags", tags.join(","));
|
||||||
const res = await fetch(`/api/v1/search?${params}`);
|
const res = await fetch(`/api/v1/search?${params}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const json = await res.json() as SearchResponse;
|
const json = await res.json() as SearchResponse;
|
||||||
@@ -157,7 +160,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialQuery) {
|
if (initialQuery) {
|
||||||
fetchResults(initialQuery, "any", "", 0);
|
fetchResults(initialQuery, "any", "", 0, []);
|
||||||
fetchPeople(initialQuery);
|
fetchPeople(initialQuery);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -213,7 +216,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
updateUrl(value);
|
updateUrl(value);
|
||||||
fetchResults(value, difficulty, maxMins, 0);
|
fetchResults(value, difficulty, maxMins, 0, [...selectedTags]);
|
||||||
fetchPeople(value);
|
fetchPeople(value);
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
@@ -223,24 +226,34 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
setQuery(inputValue);
|
setQuery(inputValue);
|
||||||
updateUrl(inputValue);
|
updateUrl(inputValue);
|
||||||
fetchResults(inputValue, difficulty, maxMins, 0);
|
fetchResults(inputValue, difficulty, maxMins, 0, [...selectedTags]);
|
||||||
fetchPeople(inputValue);
|
fetchPeople(inputValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDifficultyChange = (value: string | null) => {
|
const handleDifficultyChange = (value: string | null) => {
|
||||||
const v = value ?? "any";
|
const v = value ?? "any";
|
||||||
setDifficulty(v);
|
setDifficulty(v);
|
||||||
fetchResults(query, v, maxMins, 0);
|
fetchResults(query, v, maxMins, 0, [...selectedTags]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMaxMinsChange = (value: string) => {
|
const handleMaxMinsChange = (value: string) => {
|
||||||
setMaxMins(value);
|
setMaxMins(value);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
fetchResults(query, difficulty, value, 0);
|
fetchResults(query, difficulty, value, 0, [...selectedTags]);
|
||||||
}, 300);
|
}, 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 hasQuery = query.trim().length > 0;
|
||||||
const hasMore = results.length < total;
|
const hasMore = results.length < total;
|
||||||
const hasNoResults = hasQuery && !loading && !peopleLoading && results.length === 0 && peopleResults.length === 0;
|
const hasNoResults = hasQuery && !loading && !peopleLoading && results.length === 0 && peopleResults.length === 0;
|
||||||
@@ -294,6 +307,18 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* People results */}
|
{/* People results */}
|
||||||
@@ -365,7 +390,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => fetchResults(query, difficulty, maxMins, offset, true)}
|
onClick={() => fetchResults(query, difficulty, maxMins, offset, [...selectedTags], true)}
|
||||||
disabled={loadingMore}
|
disabled={loadingMore}
|
||||||
className="min-w-32"
|
className="min-w-32"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.14.0",
|
||||||
date: "2026-07-13 21:47",
|
date: "2026-07-13 21:47",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user