원티드 X 코드스테이츠 프리온보딩 프론트엔드 과정 기업과제 1번
담당했던 부분 : Header 부분 및 Card 컴포넌트 별점 부분 작성 / Local Storaged 데이터 저장 부분 작성
✨ 주요 기능
사용자가 자주 찾는 GitHub의 Public Repository의 Issue들을 모아서 볼 수 있습니다.
검색창에 Repository 명을 입력해서 Repository를 검색할 수 있습니다.
검색 된 Public Repository를 즐겨찾기로 등록할 수 있습니다. 즐겨찾기는 최대 4개까지 가능하며 이를 초과시 모달창이 나타납니다.
즐겨찾기에 등록한 Public Repository를 즐겨찾기에서 삭제할 수 있으며 이는 검색창에서 나타나는 Repository 상태와도 연동됩니다.
등록된 각각의 Public Repository의 Issue를 한 페이지에서 모아 볼 수 있습니다.
각 Issue의 Repository 명이 표시되며 해당 issue를 클릭하면 GitHub의 상세페이지로 이동할 수 있습니다.
검색과 Issue는 페이지네이션으로 구분되어 계속해서 탐색할 수 있습니다.
모바일 반응형으로 제작되어 웹페이지와 모바일 기기에 구현받지 않아 긍정적인 UX에 기여합니다.
이번 프로젝트에서는 Header의 로고 부분을 작성할 때 Githere 뒤의 꺽쇠같이 생긴 부분을 svg로 직접 그려보았다. 딱히 도전할려고 도전했던 건 아니고... 그냥 그리는 게 비슷한 걸 찾는 것보다 시간이 덜 걸려보여서 해 봤다. 직접 찾아보지는 않아서 시간을 비교하는 것이야 당연히 어렵지만 어쨌든 svg를 직접 그리는 것도 이렇게 간단한 건데도 그렇게 간단하지만은... 않았던 것 같다. 그래도 이번에 그린다고 찾아보면서 svg의 구조에 대해서 조금 더 이해하게 되었다. 알아놓으면 언젠가 또 도움이 될 때가 있겠거니!
이번 프로젝트에서는 Local Storaged에 데이터를 저장하는 것도 팀원들의 도움을 받아 해 보았다. 로컬 스토리지에 레포지토리의 정보들을 저장하고 그것에 따라 즐겨찾기에 추가되고 해제되는 기능을 구현하였다. 처음에 프로젝트를 시작했을 때는 다들 간단한 기능밖에 없고 상태도 그렇게까지 많이 연동되지 않을 것 같다고 생각해서 리덕스를 사용하지 않았는데, 만들다보니... 전혀 그렇지 않았다...^^
즐겨찾기를 등록하고 해제할 때에 로컬 스토리지에 저장되는 정보에 대한 상태가 Search와 Repositories와 로컬 스토리지에 각각 다 전달이 되어야 해서 props를 굉장히 많이 내려줘야 하게 되었고 그에 따른 상태도 점점 더 많아지게 되었다... 그러나 시간도 없었고 우리가 이미 셋팅해 놓은 코드들을 다 뜯어고쳐서 리덕스를 도입하기에는 무리가 있어서 그냥 그대로 진행하게 되었고 팀원들 모두 다음에는 별로 안 쓰일 것 같아도 혹시 모르니 그냥 리덕스를 사용하자는 결론을 낸 프로젝트가 되었다.
[자바스크립트] 웹 스토리지 (localStorage, sessionStorage) 사용법
// Card.tsx
import React, { MouseEvent, useState } from 'react'
import styled from 'styled-components'
import { AiFillStar, AiOutlineStar } from 'react-icons/ai'
import { CardProps, ICard } from 'types/interface'
import { VscIssues } from 'react-icons/vsc'
import { Box, Modal } from '@mui/material'
const modalStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 'fit-content',
bgcolor: 'background.paper',
border: 'none',
boxShadow: 24,
borderRadius: 5,
p: 4,
}
export default function Card({
card,
starred,
storageState,
setStorageState,
onClick,
}: CardProps) {
const [modal, setModal] = useState(false)
const handleStar = (e: MouseEvent<HTMLOrSVGElement>) => {
e.stopPropagation()
const index = storageState.findIndex(
(item) => item.full_name === card.full_name
)
if (index >= 0) {
storageState.splice(index, 1)
if (storageState.length === 0) {
setStorageState([])
} else {
setStorageState([...storageState])
}
} else if (storageState.length < 4) {
setStorageState((prev: ICard[]) => [
...prev,
{
full_name: card.full_name,
avatar_url: card.avatar_url,
open_issues: card.open_issues,
stargazers_count: card.stargazers_count,
},
])
} else {
setModal(true)
}
}
return (
<>
<CardWrap onClick={onClick}>
<CardItem>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h3>{card.full_name}</h3>
<span style={{ cursor: 'pointer' }}>
{starred ? (
<AiFillStar size={20} color={'6C84EE'} onClick={handleStar} />
) : (
<AiOutlineStar size={20} onClick={handleStar} />
)}
</span>
</div>
<Dl>
<Bottom>
<dd>
<AiOutlineStar
size={20}
style={{ margin: '4px 0 0 0', color: '#fdcb6e' }}
/>
</dd>
<dt>{card.stargazers_count}</dt>
<dd>
<VscIssues
size={20}
style={{ margin: '4px 0 0 0', color: '#197F37' }}
></VscIssues>
</dd>
<dt>{card.open_issues}</dt>
</Bottom>
<ImgBox src={card.avatar_url} />
</Dl>
</CardItem>
</CardWrap>
<Modal
open={modal}
onClose={() => setModal(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={modalStyle}>
<h4>즐겨찾기는 최대 4개까지만 추가할 수 있습니다.</h4>
</Box>
</Modal>
</>
)
}
export const CardWrap = styled.div`
width: 100%;
margin-top: 1rem;
display: flex;
align-items: center;
padding: 12px;
border-radius: 14px;
box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.5);
`
const CardItem = styled.div`
width: 100%;
h3 {
width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
h5 {
width: 60%;
margin-top: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`
const Dl = styled.dl`
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
dt {
margin-right: 8px;
}
dd {
margin-right: 8px;
display: flex;
align-items: center;
}
`
const Bottom = styled.div`
display: flex;
`
const ImgBox = styled.img`
width: 30px;
height: 30px;
background-color: #6c84ee;
border-radius: 50%;
`
// Search.tsx
import React, { useState } from 'react'
import { BsChevronLeft } from 'react-icons/bs'
import styled from 'styled-components'
import { BackButton } from './Issues'
import SearchBar from './SearchBar'
import { QueryFunctionContext, useQuery } from 'react-query'
import { get } from 'api/get'
import Card, { CardWrap } from './Card'
import PaginationModule from './PaginationModule'
import { ClassesObject, INoItem, IRepo, SearchProps } from 'types/interface'
import { Skeleton } from '@mui/material'
function Search({ storageState, setStorageState, setClasses }: SearchProps) {
const [searchValue, setSearchValue] = useState<string>('')
const [page, setPage] = useState(1)
const fetcher = (ctx: QueryFunctionContext) => {
if (ctx.queryKey[1] === '') {
return { items: [], total_count: -1 }
}
return get('repositories', { q: `${ctx.queryKey[1]} in:name`, page })
}
const { data, isFetching } = useQuery([page, searchValue], fetcher, {
staleTime: 60 * 1000,
keepPreviousData: true,
})
const onPageChange = (e: React.ChangeEvent<unknown>, page: number) => {
setPage(page)
}
const showCards = () => {
if (!data) {
return null
} else if (data.total_count === 0) {
return <NoItem />
}
return data.items.map((d: IRepo, index: number) => {
const starred =
storageState.findIndex((item) => item.full_name === d.full_name) >= 0
const newItems = {
full_name: d.full_name,
avatar_url: d.owner.avatar_url,
stargazers_count: d.stargazers_count,
open_issues: d.open_issues,
}
return (
<Card
starred={starred}
key={index}
card={newItems}
storageState={storageState}
setStorageState={setStorageState}
/>
)
})
}
return (
<SearchWrapper>
<BackButton2
onClick={() =>
setClasses((prev: ClassesObject) => ({ ...prev, sideContainer: '' }))
}
>
<BsChevronLeft strokeWidth="2px"></BsChevronLeft>
</BackButton2>
<SearchBar onSubmit={setSearchValue} />
{isFetching
? new Array(10).fill(0).map((i, idx) => <SkeletonBox key={idx} />)
: showCards()}
{data && data.total_count > 0 && (
<PaginationModule
totalPageCount={
data.total_count > 1000 ? 100 : Math.ceil(data.total_count / 10)
}
page={page}
onChange={onPageChange}
/>
)}
</SearchWrapper>
)
}
export const SkeletonBox = () => (
<SkeletonWrapper>
<Skeleton animation="wave" />
<Skeleton animation="wave" height={18} />
<Skeleton
animation="wave"
variant="circular"
width={25}
height={25}
sx={{ float: 'left', marginRight: 5 }}
/>
<Skeleton
animation="wave"
variant="circular"
width={25}
height={25}
sx={{ float: 'left' }}
/>
<Skeleton
animation="wave"
variant="circular"
width={25}
height={25}
sx={{ float: 'right' }}
/>
</SkeletonWrapper>
)
export const NoItem = ({ content }: INoItem) => (
<NoItemWrapper>
<span>{content || '검색 결과가 없습니다.'}</span>
</NoItemWrapper>
)
const SearchWrapper = styled.section`
width: 100%;
height: 100%;
padding: 3.2rem;
background-color: white;
overflow-y: scroll;
`
const BackButton2 = styled(BackButton)`
margin-bottom: 2rem;
transition: opacity 0s 0.5s;
@media (min-width: 768px) {
transition: opacity 0.5s 0s;
opacity: 0;
}
`
const SkeletonWrapper = styled(CardWrap)`
display: block;
height: 93px;
margin-right: 0;
`
export const NoItemWrapper = styled.div`
display: flex;
justify-content: center;
padding: 100px 0;
`
export default Search
// Repositories.tsx
import React, { MouseEvent } from 'react'
import styled from 'styled-components'
import AddButton from '../assets/addButton.svg'
import Card from './Card'
import { ClassesObject, RepositoriesProps } from 'types/interface'
const Repositories = ({
setClasses,
storageState,
setStorageState,
setClickedRepo,
}: RepositoriesProps) => {
const showCards = () => {
return storageState?.map((data) => {
const starred =
storageState.findIndex((item) => item.full_name === data.full_name) >= 0
return (
<Card
starred={starred}
key={data.full_name}
card={data}
storageState={storageState}
setStorageState={setStorageState}
onClick={() => handleCardClick(data.full_name)}
/>
)
})
}
const handleCardClick = (full_name: string) => {
setClickedRepo(full_name)
const width = window.innerWidth
if (width > 768) {
// desktop
setClasses((prev: ClassesObject) => ({ ...prev, toShow: 'issues' }))
} else {
// mobile
setClasses((prev: ClassesObject) => ({
...prev,
toShow: 'issues',
sideContainer: 'slide-in',
}))
}
}
const handleAddClick = (e: MouseEvent<HTMLDivElement>) => {
const width = window.innerWidth
if (width > 768) {
// desktop
setClasses((prev: ClassesObject) => ({ ...prev, toShow: 'search' }))
} else {
// mobile
setClasses((prev: ClassesObject) => ({
...prev,
toShow: 'search',
sideContainer: 'slide-in',
}))
}
}
return (
<RepositoryWrapper>
<HeaderGit>Git</HeaderGit>
<HeaderHere>here</HeaderHere>
<Svg>
<polyline points="0,20 20,20 20,40" />
</Svg>
<SavedRepo>저장된 Repository</SavedRepo>
{showCards()}
<AddBtn
src={AddButton}
alt="Move to search page"
onClick={handleAddClick}
/>
</RepositoryWrapper>
)
}
const RepositoryWrapper = styled.section`
width: 100%;
height: 100%;
overflow-y: scroll;
`
const HeaderGit = styled.span`
font-size: 6rem;
font-family: 'Jost', sans-serif;
color: #112155;
font-weight: 800;
`
const HeaderHere = styled(HeaderGit)`
color: #6c84ee;
`
const Svg = styled.svg`
width: ${(props) => props.width || '70px'};
height: ${(props) => props.height || '70px'};
fill: ${(props) => props.fill || 'none'};
stroke: ${(props) => props.stroke || '#6C84EE'};
stroke-width: ${(props) => props.strokeWidth || '8'};
`
const SavedRepo = styled.div`
padding: 2rem 1rem 1rem 1rem;
font-size: 2rem;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
@media (max-width: 768px) {
display: grid;
grid-template-columns: 1fr;
}
`
const AddBtn = styled.img`
display: block;
width: 6rem;
height: 6rem;
margin: 2rem auto;
`
export default Repositories