Skip to content

Commit

Permalink
👔 Add: 실시간 검색 기본 로직 작성 & 데이터 타입 추가
Browse files Browse the repository at this point in the history
  • Loading branch information
romantech committed Nov 12, 2021
1 parent cf7527e commit 9b7e75d
Show file tree
Hide file tree
Showing 14 changed files with 363 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1 +1 @@
API_KEY = #뉴욕타임즈 API KEY
REACT_APP_API_KEY = #뉴욕타임즈 API KEY
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^4.7.0",
"@types/node": "^12.20.37",
"@types/react": "^17.0.34",
"@types/react-dom": "^17.0.11",
Expand Down
17 changes: 15 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import React from 'react';
import styled from 'styled-components/macro';
import { Tabs } from 'antd';
import SearchPage from './pages/SearchPage';

const { TabPane } = Tabs;

const App = function (): JSX.Element {
return (
<StyledWrapper>
<h1>Hello World</h1>
<Tabs defaultActiveKey="1">
<TabPane tab="SEARCH" key="1">
<SearchPage />
</TabPane>
<TabPane tab="FAVORITE" key="2">
Content of Tab Pane 2
</TabPane>
</Tabs>
</StyledWrapper>
);
};

const StyledWrapper = styled.section``;
const StyledWrapper = styled.section`
padding: 1rem 1.5rem;
`;

export default App;
12 changes: 12 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import axios, { AxiosPromise } from 'axios';

axios.defaults.baseURL = 'https://api.nytimes.com/svc/search/v2';
axios.defaults.params = {
'api-key': process.env.REACT_APP_API_KEY,
};

export default {
searchArticles: (query: string): AxiosPromise => {
return axios.get(`/articlesearch.json?q=${query}`);
},
};
68 changes: 68 additions & 0 deletions src/components/Article.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from 'react';
import styled from 'styled-components/macro';
import useImage from '../hooks/useImage';

import { sliceCharactersUntilNum } from '../utils';

interface ArticleListProps {
article: Article;
imageUrl: string;
}

const Article = function ({
article,
imageUrl,
}: ArticleListProps): JSX.Element {
const Image = useImage(imageUrl);

return (
<ArticleContainer>
<TextWrapper>
<h2>{article.headline.main}</h2>
<p>
{sliceCharactersUntilNum(article.lead_paragraph, 30) + ' '}
<a href={article.web_url} target="_blank" rel="noreferrer">
...more
</a>
</p>
</TextWrapper>
<ImageWrapper>
<Image />
</ImageWrapper>
</ArticleContainer>
);
};

const ArticleContainer = styled.section`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid lightgray;
padding: 1rem 0rem;
gap: 2rem;
`;

const TextWrapper = styled.section`
width: 80%;
overflow: hidden;
h2 {
margin: 0;
padding: 0;
font-size: 1.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;

const ImageWrapper = styled.section`
min-width: 100px;
width: 20%;
display: flex;
align-items: center;
justify-content: center;
`;

export default Article;
35 changes: 35 additions & 0 deletions src/components/ArticleList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import styled from 'styled-components/macro';
import Article from './Article';

interface ArticleListProps {
articles: Article[];
}

const ArticleList = function ({ articles }: ArticleListProps): JSX.Element {
return (
<ArticleListContainer>
{articles.map(article => {
const domain = 'https://nytimes.com/';
const hasImage = article.multimedia[0] !== undefined;
const imageUrl = hasImage
? `${domain}${article.multimedia[0]?.url}`
: 'https://i.ibb.co/0yYnWSn/default-fallback-image.png';
return (
<Article key={article._id} article={article} imageUrl={imageUrl} />
);
})}
</ArticleListContainer>
);
};

const ArticleListContainer = styled.section`
width: 85vw;
max-width: 38rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
`;

export default ArticleList;
48 changes: 48 additions & 0 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { useState, useEffect } from 'react';
import styled from 'styled-components/macro';

interface SearchBarProps {
onSearchSubmit: (term: string) => Promise<void>;
clearResults: () => void;
}

const SearchBar = function ({
onSearchSubmit,
clearResults,
}: SearchBarProps): JSX.Element {
const [term, setTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState(term);

useEffect(() => {
const timer = setTimeout(() => setTerm(debouncedTerm), 1000);
return () => clearTimeout(timer);
}, [debouncedTerm]);

useEffect(() => {
if (term !== '') {
onSearchSubmit(term);
} else {
clearResults();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term]);

return (
<Input
onChange={({ target }) => setDebouncedTerm(target.value)}
type="text"
value={debouncedTerm}
maxLength={100}
/>
);
};

const Input = styled.input`
width: 85vw;
height: 20vh;
max-height: 3.5rem;
max-width: 38rem;
padding: 1rem;
`;

export default SearchBar;
35 changes: 35 additions & 0 deletions src/hooks/useImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { useState } from 'react';
import { Spin } from 'antd';
import styled from 'styled-components/macro';

type SpinSize = 'default' | 'small' | 'large';

const useImage = function (
src: string,
spinSize = 'default',
): () => JSX.Element {
const [isLoading, setIsLoading] = useState(true);
const Image = function () {
return (
<>
{isLoading && <Spin size={spinSize as SpinSize} />}
<StyledImage
isLoading={isLoading}
src={src}
alt="article_image"
onLoad={() => setIsLoading(false)}
/>
</>
);
};

return Image;
};

const StyledImage = styled.img<{ isLoading: boolean }>`
max-width: 100%;
object-fit: cover;
display: ${({ isLoading }) => isLoading && 'none'};
`;

export default useImage;
48 changes: 48 additions & 0 deletions src/pages/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import ArticleList from '../components/ArticleList';
import SearchBar from '../components/SearchBar';
import API from '../api';

const SearchPage = function (): JSX.Element {
const [articles, setArticles] = useState<Article[]>([]);
const [noResults, setNoResults] = useState(false);

const onSearchSubmit = async (term: string) => {
const { data } = await API.searchArticles(term.toLowerCase());
setNoResults(data.response.docs.length === 0);
setArticles(data.response.docs);
};

const clearResults = () => setArticles([]);

return (
<Container>
<h1>SEARCH NY-TIMES</h1>
<section>
<SearchBar
onSearchSubmit={onSearchSubmit}
clearResults={clearResults}
/>
</section>
<section>
{noResults ? (
<h3 className="no-results">No results found.</h3>
) : (
<ArticleList articles={articles} />
)}
</section>
</Container>
);
};

const Container = styled.section`
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
`;

export default SearchPage;
84 changes: 84 additions & 0 deletions src/types/articleSearch.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
type HeadlineKey =
| 'main'
| 'kicker'
| 'content_kicker'
| 'print_headline'
| 'name'
| 'seo'
| 'sub';

type PersonKey =
| 'firstname'
| 'middlename'
| 'lastname'
| 'qualifier'
| 'title'
| 'role'
| 'organization'
| 'rank';

type MetaKey = 'hits' | 'offset' | 'time';

interface Legacy {
xlarge: string;
xlargewidth: integer;
xlargeheight: integer;
}

interface Multimedia {
rank: integer;
subtype: string;
caption: string;
credit: string;
type: string;
url: string;
height: integer;
width: integer;
legacy: Legacy;
crop_name: string;
}

interface Keywords {
name: string;
value: string;
rank: integer;
major: string;
}

interface Byline {
original: string;
person: { [key in PersonKey]: string | integer }[];
organization: string;
}

interface Article {
abstract: string;
byline: Byline;
document_type: string;
headline: Record<HeadlineKey, string>;
keywords: Keywords[];
lead_paragraph: string;
multimedia: Multimedia[];
news_desk: string;
pub_date: string;
section_name: string;
snippet: string;
source: string;
subsection_name: string;
type_of_material: string;
uri: string;
web_url: string;
word_count: integer;
_id: string;
}

interface Response {
docs: Article[];
meta: Record<MetaKey, integer>;
}

interface ArticleSearch {
status: string;
copyright: string;
response: Response;
}
Empty file removed src/types/index.d.ts
Empty file.
Loading

0 comments on commit 9b7e75d

Please sign in to comment.