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
@@ -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"
>