From 29fa64ee2574744c7a157af4bc95fdaba2c14cf1 Mon Sep 17 00:00:00 2001 From: yanhongxing Date: Tue, 12 May 2026 21:34:59 +0800 Subject: [PATCH] feat: add search bar to bounties page - Add debounced search input with clear button - Filter bounties client-side by title, description, and skills - Works alongside existing status and language filters - Real-time filtering with 300ms debounce for performance - Shows contextual empty state when no search results found Closes #823 --- frontend/src/components/bounty/BountyGrid.tsx | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..c10a58770 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,16 +1,29 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { ChevronDown, Loader2, Plus } from 'lucide-react'; +import { ChevronDown, Loader2, Plus, Search, X } from 'lucide-react'; import { BountyCard } from './BountyCard'; import { useInfiniteBounties } from '../../hooks/useBounties'; import { staggerContainer, staggerItem } from '../../lib/animations'; const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript']; +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + return debouncedValue; +} + export function BountyGrid() { const [activeSkill, setActiveSkill] = useState('All'); const [statusFilter, setStatusFilter] = useState('open'); + const [searchQuery, setSearchQuery] = useState(''); + const searchInputRef = useRef(null); + + const debouncedSearch = useDebounce(searchQuery, 300); const params = { status: statusFilter, @@ -20,7 +33,23 @@ export function BountyGrid() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = useInfiniteBounties(params); - const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + const allBountiesRaw = data?.pages.flatMap((p) => p.items) ?? []; + + const allBounties = useMemo(() => { + if (!debouncedSearch.trim()) return allBountiesRaw; + const query = debouncedSearch.toLowerCase().trim(); + return allBountiesRaw.filter( + (bounty) => + bounty.title.toLowerCase().includes(query) || + bounty.description.toLowerCase().includes(query) || + bounty.skills.some((s) => s.toLowerCase().includes(query)) + ); + }, [allBountiesRaw, debouncedSearch]); + + const handleClearSearch = useCallback(() => { + setSearchQuery(''); + searchInputRef.current?.focus(); + }, []); return (
@@ -53,6 +82,28 @@ export function BountyGrid() { + {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search bounties by title, description, or skill..." + className="w-full pl-10 pr-10 py-2.5 rounded-lg bg-forge-800 border border-border text-sm text-text-primary placeholder:text-text-muted focus:border-emerald focus:ring-1 focus:ring-emerald outline-none transition-all duration-150" + /> + {searchQuery && ( + + )} +
+ {/* Filter pills */}
{FILTER_SKILLS.map((skill) => ( @@ -97,7 +148,11 @@ export function BountyGrid() {

No bounties found

- {activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'} + {debouncedSearch + ? `No results for "${debouncedSearch}". Try a different search term.` + : activeSkill !== 'All' + ? `Try a different language filter.` + : 'Check back soon for new bounties.'}

)}