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:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user