HackerNews.tsx
1 import * as React from 'react' 2 import { RotatingLines } from 'react-loader-spinner' 3 4 const fetchData = async ({ query = '', page = 0, tag = '' }) => { 5 return fetch( 6 `https://hn.algolia.com/api/v1/search?query=${query}&tags=${encodeURIComponent( 7 tag 8 )}&page=${page}` 9 ) 10 .then((response) => response.json()) 11 .then((json) => ({ 12 results: json.hits || [], 13 pages: json.nbPages || 0, 14 resultsPerPage: json.hitsPerPage || 20 15 })) 16 } 17 18 export default function HackerNewsSearch() { 19 const [query, setQuery] = React.useState("") 20 const [results, setResults] = React.useState([]) 21 const [tag, setTag] = React.useState("story") 22 const [page, setPage] = React.useState(0) 23 const [resultsPerPage, setResultsPerPage] = React.useState(0) 24 const [totalPages, setTotalPages] = React.useState(50) 25 const [loading, setLoading] = React.useState(false) 26 27 const handleSearch: React.ChangeEventHandler<HTMLInputElement> = (e) => { 28 setQuery(e.target.value) 29 setPage(0) 30 } 31 32 const handleTag: React.ChangeEventHandler<HTMLSelectElement> = (e) => { 33 setTag(e.target.value) 34 setPage(0) 35 } 36 37 const handleNextPage = () => { 38 setPage(prevPage => prevPage >= totalPages ? totalPages : prevPage + 1) 39 } 40 41 const handlePrevPage = () => { 42 setPage(prevPage => prevPage === 0 ? 0 : page - 1) 43 } 44 45 React.useEffect(() => { 46 let isStale = false 47 setLoading(true) 48 const fetchNews = async (query: string) => { 49 try { 50 const fetchedNews = await fetchData({ query, page, tag }) 51 52 if (isStale) { 53 return 54 } 55 56 if (fetchedNews) { 57 const { results, pages, resultsPerPage } = fetchedNews 58 console.debug({ results, resultsPerPage, pages }) 59 setResults(results) 60 setResultsPerPage(resultsPerPage) 61 setTotalPages(pages) 62 setLoading(false) 63 } else { 64 throw new Error(`Failed to fetch news for ${query} in tag ${tag}`) 65 } 66 } catch (fetchNewsError) { 67 setLoading(false) 68 console.debug({ error: (fetchNewsError as Error).message }) 69 } 70 } 71 72 fetchNews(query) 73 74 return () => { 75 setLoading(false) 76 isStale = true 77 } 78 }, [query, page, tag]) 79 80 return ( 81 <main> 82 <h1>Hacker News Search</h1> 83 <form onSubmit={(e) => e.preventDefault()}> 84 <div> 85 <label htmlFor="query">Search</label> 86 <input 87 type="text" 88 id="query" 89 name="query" 90 value={query} 91 onChange={handleSearch} 92 placeholder="Search Hacker News..." 93 /> 94 </div> 95 <div> 96 <label htmlFor="tag">Tag</label> 97 <select id="tag" name="tag" onChange={handleTag} value={tag}> 98 <option value="story">Story</option> 99 <option value="ask_hn">Ask HN</option> 100 <option value="show_hn">Show HN</option> 101 <option value="poll">Poll</option> 102 </select> 103 </div> 104 </form> 105 <section> 106 <header> 107 <h2> 108 <span>No Results OR Page {page + 1} of {totalPages}</span> 109 <RotatingLines 110 strokeColor="grey" 111 strokeWidth="5" 112 animationDuration="0.75" 113 width="20" 114 visible={loading} 115 /> 116 </h2> 117 <div> 118 <button className="link" onClick={handlePrevPage} disabled={page <= 0}> 119 Previous 120 </button> 121 <button className="link" onClick={handleNextPage} disabled={page >= totalPages - 1}> 122 Next 123 </button> 124 </div> 125 </header> 126 <ul> 127 {results.map(({ url, objectID, title }, index) => { 128 const href = 129 url || `https://news.ycombinator.com/item?id=${objectID}` 130 131 return ( 132 <li key={objectID}> 133 <span>{(index + 1) % resultsPerPage}.</span> 134 <a href={href} target="_blank" rel="noreferrer"> 135 {title} 136 </a> 137 </li> 138 ) 139 })} 140 </ul> 141 </section> 142 </main> 143 ) 144 }