원티드 X 코드스테이츠 프리온보딩 프론트엔드 과정 기업과제 1번

H Kim·2022년 3월 11일
0

기업과제

목록 보기
6/8
post-thumbnail

원티드 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의 구조에 대해서 조금 더 이해하게 되었다. 알아놓으면 언젠가 또 도움이 될 때가 있겠거니!

SVG 기본 도형 그리기


이번 프로젝트에서는 Local Storaged에 데이터를 저장하는 것도 팀원들의 도움을 받아 해 보았다. 로컬 스토리지에 레포지토리의 정보들을 저장하고 그것에 따라 즐겨찾기에 추가되고 해제되는 기능을 구현하였다. 처음에 프로젝트를 시작했을 때는 다들 간단한 기능밖에 없고 상태도 그렇게까지 많이 연동되지 않을 것 같다고 생각해서 리덕스를 사용하지 않았는데, 만들다보니... 전혀 그렇지 않았다...^^

즐겨찾기를 등록하고 해제할 때에 로컬 스토리지에 저장되는 정보에 대한 상태가 Search와 Repositories와 로컬 스토리지에 각각 다 전달이 되어야 해서 props를 굉장히 많이 내려줘야 하게 되었고 그에 따른 상태도 점점 더 많아지게 되었다... 그러나 시간도 없었고 우리가 이미 셋팅해 놓은 코드들을 다 뜯어고쳐서 리덕스를 도입하기에는 무리가 있어서 그냥 그대로 진행하게 되었고 팀원들 모두 다음에는 별로 안 쓰일 것 같아도 혹시 모르니 그냥 리덕스를 사용하자는 결론을 낸 프로젝트가 되었다.


[자바스크립트] 웹 스토리지 (localStorage, sessionStorage) 사용법

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

0개의 댓글