All files / src/screens Search.tsx

87.5% Statements 14/16
82.14% Branches 23/28
80% Functions 8/10
86.66% Lines 13/15

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175                58x   58x                                           58x             2x                   58x 58x   58x                                                             6x       7x                                                     7x 7x   7x       7x                                                                                                          
import { useState, useCallback } from 'react'
import { Loader2, Search as SearchIcon } from 'lucide-react'
import { type SearchResult } from '../api/client'
import NavHeader from '../components/NavHeader'
import SourceFilterChips from '../components/SourceFilterChips'
import { useSearch } from '../hooks/useSearch'
 
export default function Search() {
  const search = useSearch()
 
  return (
    <div className="flex min-h-screen flex-col bg-background">
      <NavHeader />
      <main className="flex-1 px-4 py-6">
        <div className="mx-auto max-w-4xl">
          <SearchForm
            searchInput={search.searchInput}
            setSearchInput={search.setSearchInput}
            onSubmit={search.handleSearchSubmit}
          />
          <SearchContent {...search} />
        </div>
      </main>
    </div>
  )
}
 
function SearchForm({ searchInput, setSearchInput, onSubmit }: {
  searchInput: string
  setSearchInput: (v: string) => void
  onSubmit: (e: React.FormEvent) => void
}) {
  return (
    <form onSubmit={onSubmit} className="mb-6">
      <div className="relative">
        <SearchIcon className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
        <input
          type="text"
          value={searchInput}
          onChange={(e) => setSearchInput(e.target.value)}
          placeholder="Search recipes..."
          className="w-full rounded-xl border border-border bg-input-background py-3 pl-12 pr-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
        />
      </div>
    </form>
  )
}
 
function SearchContent({ query, results, hasMore, sites, selectedSource, setSelectedSource, loading, loadingMore, importing, handleLoadMore, handleImport }: ReturnType<typeof useSearch>) {
  const allSourcesCount = Object.values(sites).reduce((sum, n) => sum + n, 0)
  const displayCount = selectedSource ? (sites[selectedSource] || 0) : allSourcesCount
 
  return (
    <>
      {!loading && (
        <p className="mb-4 text-sm text-muted-foreground">
          {displayCount} {displayCount === 1 ? 'result' : 'results'} found
        </p>
      )}
      <SourceFilterChips sites={sites} selectedSource={selectedSource} onSelectSource={setSelectedSource} />
      {loading ? (
        <div className="flex items-center justify-center py-12">
          <Loader2 className="h-8 w-8 animate-spin text-primary" />
        </div>
      ) : results.length > 0 ? (
        <SearchResults results={results} hasMore={hasMore} loadingMore={loadingMore} importing={importing} onLoadMore={handleLoadMore} onImport={handleImport} />
      ) : (
        <div className="flex flex-col items-center justify-center py-12">
          <p className="text-muted-foreground">No recipes found for "{query}"</p>
        </div>
      )}
    </>
  )
}
 
function SearchResults({ results, hasMore, loadingMore, importing, onLoadMore, onImport }: {
  results: SearchResult[]
  hasMore: boolean
  loadingMore: boolean
  importing: string | null
  onLoadMore: () => void
  onImport: (url: string) => void
}) {
  return (
    <>
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
        {results.map((result, index) => (
          <SearchResultCard key={`${result.url}-${index}`} result={result} onImport={onImport} importing={importing === result.url} />
        ))}
      </div>
      <div className="mt-8 flex justify-center">
        {hasMore ? (
          <button onClick={onLoadMore} disabled={loadingMore} className="inline-flex items-center gap-2 rounded-lg bg-muted px-6 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted/80 disabled:opacity-50">
            {loadingMore ? (<><Loader2 className="h-4 w-4 animate-spin" />Loading...</>) : 'Load More'}
          </button>
        ) : (
          <p className="text-sm text-muted-foreground">End of results</p>
        )}
      </div>
    </>
  )
}
 
interface SearchResultCardProps {
  result: SearchResult
  onImport: (url: string) => void
  importing: boolean
}
 
function SearchResultCard({
  result,
  onImport,
  importing,
}: SearchResultCardProps) {
  const imageUrl = result.cached_image_url || result.image_url
  const [imgError, setImgError] = useState(false)
 
  const handleImgError = useCallback(() => {
    setImgError(true)
  }, [])
 
  return (
    <div className="group flex flex-col overflow-hidden rounded-lg bg-card shadow-sm transition-all hover:shadow-md">
      {/* Image */}
      <div className="relative aspect-[4/3] overflow-hidden bg-muted">
        {imageUrl && !imgError ? (
          <img
            src={imageUrl}
            alt={result.title}
            loading="lazy"
            onError={handleImgError}
            className="h-full w-full object-cover transition-transform group-hover:scale-105"
          />
        ) : (
          <div className="flex h-full w-full items-center justify-center bg-muted px-3 text-center text-sm font-medium text-muted-foreground">
            {result.title}
          </div>
        )}
      </div>
 
      {/* Content */}
      <div className="flex flex-1 flex-col p-3">
        <h3 className="mb-1 line-clamp-2 text-sm font-medium text-card-foreground">
          {result.title}
        </h3>
        <p className="mb-2 text-xs text-muted-foreground">
          <a href={result.url} target="_blank" rel="noopener noreferrer" className="underline decoration-muted-foreground/50 underline-offset-2">{result.host}</a>
          {result.rating_count && (
            <span> ยท {result.rating_count.toLocaleString()} Ratings</span>
          )}
        </p>
        {result.description && (
          <p className="mb-3 line-clamp-2 text-xs text-muted-foreground">
            {result.description}
          </p>
        )}
        <button
          onClick={() => onImport(result.url)}
          disabled={importing}
          className="mt-auto w-full rounded-md bg-primary/10 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
        >
          {importing ? (
            <span className="inline-flex items-center gap-1">
              <Loader2 className="h-3 w-3 animate-spin" />
              Importing...
            </span>
          ) : (
            'Import'
          )}
        </button>
      </div>
    </div>
  )
}
 
← Back to Dashboard