PM Project Day 01

thisisyjin·2022년 5월 27일
0

Dev Log 🐥

목록 보기
18/23
post-thumbnail

PM (Put on a Mask) Project

  • PM = 미세먼지 를 의미한다. 동시에 이 프로젝트의 이름인 Put on a Mask를 의미!

📌 기획안 보기
🎨 디자인 보기


📝 Day 01 - 220526

  • 환경 setting
  • 기본 라우팅 (페이지 구현)
  • 컴포넌트 작성 (with 가상 데이터)
  • styled-components 스타일링

환경 setting

create-react-app

$ yarn create react-app mise

파비콘 적용

이번 PM 프로젝트에 맞는 파비콘을 제작하여 적용함.

패키지 설치

$ yarn add axios redux react-redux redux-actions styled-components

기본 라우팅

App.js

import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import Pm from './pages/Pm';

const App = () => {
  return (
    <>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/pm/:city" element={<Pm />} />
      </Routes>
    </>
  );
};

export default App;

라우트 설정

PathComponent
/<Home />
/pm/:city<Pm />

-> useParams ({city})


기본 컴포넌트 구성

Pages

1) Home

import Footer from '../components/Footer';
import Header from '../components/Header';
import ContentDefault from '../components/ContentDefault';

const Home = () => {
  return (
    <div>
      <Header />
      <ContentDefault />
      <Footer />
    </div>
  );
};

export default Home;

2) Pm

import { useParams } from 'react-router-dom';
import Header from '../components/Header';
import Content from '../components/Content';
import Footer from '../components/Footer';

// /pm/:city

const Pm = () => {
  const { city } = useParams();

  console.log(city);
  return (
    <div>
      <Header />
      <Content />
      <Footer />
    </div>
  );
};

export default Pm;

components

CitySelect.jsx
Content.jsx
ContentDefault.jsx
Footer.jsx
Header.jsx
LevelInfo.jsx
Modal.jsx
ModalButton.jsx
PmDefault.jsx
PmDetail.jsx
PmDetailImage.jsx
PmDetailText.jsx
SeoulMap.jsx

Header.jsx

import styled from 'styled-components';
import colors from '../lib/styles/colors';
import { ReactComponent as MaskICO } from '../assets/mask.svg';
import { Link } from 'react-router-dom';

const HeaderBlock = styled.div`
  width: 100%;
  height: 80px;
  background: ${colors.green[2]};
  color: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  justify-content: center;

  svg {
    width: 40px;
    fill: #fff;
  }

  .header-logo {
    font-size: 32px;
    font-weight: 400;
    letter-spacing: 0.07em;
    line-height: 40px;

    color: #fff;

    &:hover {
      color: ${colors.trans[1]};
      .header-point {
        color: #fff;
      }
    }

    .header-point {
      font-weight: 700;
      font-size: 48px;
    }
  }
`;

const Header = () => {
  return (
    <HeaderBlock>
      <MaskICO />
      <Link to="/">
        <h1 className="header-logo">
          <span className="header-point">P</span>ut on a{' '}
          <span className="header-point">M</span>ask!
        </h1>
      </Link>
    </HeaderBlock>
  );
};

export default Header;

ContentDefault.jsx

import styled from 'styled-components';

import CitySelect from './CitySelect';
import SeoulMap from './SeoulMap';
import LevelInfo from './LevelInfo';
import PmDefault from './PmDefault';
import ModalButton from './ModalButton';

const ContentDefaultBlock = styled.div`
  display: flex;
  padding: 20px 100px;
  justify-content: center;
`;

const CityBlock = styled.div`
  display: flex;
  flex-direction: column;
  margin-right: 120px;
`;

const InfoBlock = styled.div`
  display: flex;
  flex-direction: column;
`;

const ContentDefault = () => {
  return (
    <ContentDefaultBlock>
      <CityBlock>
        <CitySelect />
        <SeoulMap />
      </CityBlock>

      <InfoBlock>
        <LevelInfo />
        <PmDefault />
        <ModalButton />
      </InfoBlock>
    </ContentDefaultBlock>
  );
};

export default ContentDefault;

Footer.jsx

import styled from 'styled-components';
import colors from '../lib/styles/colors';

const FooterBlock = styled.div`
  width: 100%;
  height: 40px;
  background-color: ${colors.gray[0]};
  color: ${colors.gray[2]};
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 100px;
  .links {
    display: flex;

    li {
      margin-right: 60px;
    }
  }
`;

const Ancher = styled.a`
  font-size: 15px;
  font-weight: 700;
  color: ${colors.green[1]};
  &:hover {
    color: ${colors.green[0]};
  }
`;

const Footer = () => {
  return (
    <FooterBlock>
      <h3>© thisisyjin</h3>
      <ul className="links">
        <li>
          <Ancher href="https://velog.io/@thisisyjin">blog</Ancher>
        </li>
        <li>
          <Ancher href="https://github.com/thisisyjin">github</Ancher>
        </li>
        <li>
          <Ancher href="mailto:thisisyjin@naver.com">contact</Ancher>
        </li>
      </ul>
    </FooterBlock>
  );
};

export default Footer;

Content.jsx

import styled from 'styled-components';

import CitySelect from './CitySelect';
import SeoulMap from './SeoulMap';
import LevelInfo from './LevelInfo';
import ModalButton from './ModalButton';
import PmDetail from './PmDetail';

const ContentBlock = styled.div`
  display: flex;
  padding: 20px 100px;
  justify-content: center;
`;

const CityBlock = styled.div`
  display: flex;
  flex-direction: column;
  margin-right: 120px;
`;

const InfoBlock = styled.div`
  display: flex;
  flex-direction: column;
`;

const Content = () => {
  return (
    <ContentBlock>
      <CityBlock>
        <CitySelect />
        <SeoulMap />
      </CityBlock>

      <InfoBlock>
        <LevelInfo />
        <PmDetail />
        <ModalButton />
      </InfoBlock>
    </ContentBlock>
  );
};

export default Content;

CitySelect

import styled from 'styled-components';
import colors from '../lib/styles/colors';
import { ReactComponent as GeoICO } from '../assets/geo.svg';
import { Link } from 'react-router-dom';

// 임시 데이터
export const cities = [
  '강남구',
  '강동구',
  '강서구',
  '강북구',
  '관악구',
  '광진구',
  '구로구',
  '금천구',
  '노원구',
  '동대문구',
  '도봉구',
  '동작구',
  '마포구',
  '서대문구',
  '성동구',
  '성북구',
  '서초구',
  '송파구',
  '영등포구',
  '용산구',
  '양천구',
  '은평구',
  '종로구',
  '중구',
  '중랑구',
];

const CitySelectBlock = styled.div`
  height: 50px;
  width: 100%;
  display: flex;
`;

const GeoWrapper = styled.div`
  display: flex;
  margin-right: 80px;
  align-items: center;
  svg {
    width: 20px;
    fill: ${colors.gray[2]};
    margin-right: 8px;
  }
  h3 {
    color: ${colors.gray[2]};
    font-weight: 400;
    font-size: 24px;
    letter-spacing: 0.02em;
  }
`;

const Select = styled.select`
  cursor: pointer;
  width: 135px;
  height: 50px;
  border-radius: 8px;
  font-size: 18px;
  text-align: center;
  border: none;
  border-bottom: 2px solid ${colors.gray[2]};
  margin-right: 20px;
  &:active,
  &:hover {
    background-color: ${colors.gray[0]};
  }
`;

const StyledForm = styled.form`
  display: flex;
`;

const StyledLink = styled(Link)`
  cursor: pointer;
  background-color: ${colors.green[2]};
  color: #fff;
  padding: 10px 20px;
  border-radius: 10px;
  font-size: 17px;
  &:hover {
    background-color: ${colors.green[1]};
  }
`;

const CitySelect = () => {
  return (
    <CitySelectBlock>
      <GeoWrapper>
        <GeoICO />
        <h3>Seoul</h3>
      </GeoWrapper>
      <StyledForm>
        <Select className="hello">
          {cities.map((city, i) => (
            <option key={city} value={i}>
              {city}
            </option>
          ))}
        </Select>
        <StyledLink
          // select의 value대로 이동 (인덱스)
          to={`/pm/0`}
        >
          조회
        </StyledLink>
      </StyledForm>
    </CitySelectBlock>
  );
};

export default CitySelect;

SeoulMap

import styled from 'styled-components';
import map from '../assets/map.png';
import colors from '../lib/styles/colors';

// 지도 좌표 예시
const mapPosition = [
  { index: 0, city: '강남구', left: 455, top: 509, width: 83, height: 49 },
  { index: 1, city: '강동구', left: 578, top: 368, width: 95, height: 39 },
  { index: 2, city: '강서구', left: 38, top: 311, width: 93, height: 75 },
  { index: 3, city: '강북구', left: 381, top: 173, width: 70, height: 49 },
  { index: 4, city: '관악구', left: 264, top: 550, width: 78, height: 65 },
  { index: 5, city: '광진구', left: 505, top: 356, width: 44, height: 72 },
  { index: 6, city: '구로구', left: 97, top: 485, width: 98, height: 41 },
  { index: 7, city: '금천구', left: 194, top: 543, width: 42, height: 79 },
  { index: 8, city: '노원구', left: 487, top: 104, width: 60, height: 113 },
  { index: 9, city: '동대문구', left: 454, top: 295, width: 56, height: 57 },
  { index: 10, city: '도봉구', left: 410, top: 103, width: 60, height: 58 },
  { index: 11, city: '동작구', left: 270, top: 470, width: 66, height: 33 },
  { index: 12, city: '마포구', left: 201, top: 342, width: 60, height: 47 },
  { index: 13, city: '서대문구', left: 256, top: 299, width: 60, height: 34 },
  { index: 14, city: '성동구', left: 419, top: 367, width: 68, height: 47 },
  { index: 15, city: '성북구', left: 387, top: 251, width: 76, height: 49 },
  { index: 16, city: '서초구', left: 369, top: 491, width: 59, height: 75 },
  { index: 17, city: '송파구', left: 522, top: 452, width: 71, height: 44 },
  { index: 18, city: '영등포구', left: 213, top: 421, width: 42, height: 71 },
  { index: 19, city: '용산구', left: 314, top: 398, width: 76, height: 47 },
  { index: 20, city: '양천구', left: 106, top: 428, width: 79, height: 34 },
  { index: 21, city: '은평구', left: 245, top: 172, width: 68, height: 94 },
  { index: 22, city: '종로구', left: 326, top: 243, width: 42, height: 90 },
  { index: 23, city: '중구', left: 343, top: 348, width: 66, height: 35 },
  { index: 24, city: '중랑구', left: 515, top: 254, width: 56, height: 65 },
];

const SeoulMapBlock = styled.div`
  display: flex;
  align-items: center;
  .map-wrap {
    position: relative;
  }
`;

const CityArea = styled.div`
  cursor: pointer;
  position: absolute;
  top: ${(props) => props.top}px;
  left: ${(props) => props.left}px;
  width: ${(props) => props.width}px;
  height: ${(props) => props.height}px;
  &::after {
    content: '';
    display: block;
    width: 25px;
    height: 25px;
    background-color: ${colors.gray[2]};
    border-radius: 50%;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
  &:hover {
    &::after {
      background-color: ${colors.level[0]};
    }
  }
`;

const SeoulImg = styled.img`
  display: block;
  width: 700px;
  height: 700px;
  position: relative;
`;

const SeoulMap = () => {
  return (
    <SeoulMapBlock>
      <div className="map-wrap">
        <SeoulImg src={map} alt="seoul map" />
        {mapPosition.map((p) => (
          <CityArea
            key={p.left}
            top={p.top}
            left={p.left}
            width={p.width}
            height={p.height}
            city={p.city}
          />
        ))}
      </div>
    </SeoulMapBlock>
  );
};

export default SeoulMap;

LevelInfo

import styled from 'styled-components';
import { ReactComponent as PmLevelICO } from '../assets/pmLevel.svg';
import colors from '../lib/styles/colors';

const LevelInfoBlock = styled.div`
  width: 400px;
  border-radius: 15px;
  border: 2.5px solid #000;
  text-align: center;
  margin-bottom: 20px;
  .level-title {
    padding: 8px 0;
    font-size: 20px;
    border-bottom: 2.5px solid #000;
    margin-bottom: 14px;
  }
`;

const LevelIcons = styled.div`
  width: 280px;
  height: 150px;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  margin: 0 auto;
  margin-bottom: 14px;
  .very-bad {
    stroke: ${colors.level[0]};
  }
  .bad {
    stroke: ${colors.level[1]};
  }
  .soso {
    stroke: ${colors.level[2]};
  }
  .good {
    stroke: ${colors.level[3]};
  }
  .desc {
    width: 60px;
    font-size: 14px;
    letter-spacing: -0.03em;
    margin-right: 10px;
    margin-left: 6px;
    text-align: left;
  }
`;

const LevelInfo = () => {
  return (
    <LevelInfoBlock>
      <h3 className="level-title">미세먼지 Level</h3>
      <LevelIcons>
        <PmLevelICO className="very-bad" />
        <span className="desc">매우 나쁨</span>
        <PmLevelICO className="bad" />
        <span className="desc">나쁨</span>
        <PmLevelICO className="soso" />
        <span className="desc">보통</span>
        <PmLevelICO className="good" />
        <span className="desc">좋음</span>
      </LevelIcons>
    </LevelInfoBlock>
  );
};

export default LevelInfo;

PmDetail

import styled from 'styled-components';
import PmDetailImage from './PmDetailImage';
import PmDetailText from './PmDetailText';

// 임시 데이터

// API 데이터 요청
const city = 0; // 배열 Index로. - 0은 강남구.
const level = '좋음'; // '좋음', '보통', '나쁨', '매우나쁨' - API 데이터에서 가져오기
// response.RealtimeCityAir.row[도시인덱스].IDEX_NM

const PmDetailBlock = styled.div`
  width: 400px;
  border-radius: 15px;
  border: 2.5px solid #000;
  text-align: center;
  .detail-title {
    padding: 8px 0;
    font-size: 20px;
    border-bottom: 2.5px solid #000;
    margin-bottom: 14px;
  }
`;

const PmDetail = () => {
  return (
    <PmDetailBlock>
      <h3 className="detail-title">Info</h3>
      <PmDetailImage level={level} />
      <PmDetailText level={level} city={city} />
    </PmDetailBlock>
  );
};

export default PmDetail;

PmDetailImage


   
import { useEffect, useRef } from 'react';
import styled from 'styled-components';

import verybad from '../assets/photos/sky1.jpg';
import bad from '../assets/photos/sky2.jpg';
import soso from '../assets/photos/sky3.jpg';
import good from '../assets/photos/sky4.jpg';

const PmDetailImageBlock = styled.div`
  width: 340px;
  height: 230px;
  overflow: hidden;
  margin: 0 auto;
  margin-bottom: 16px;
`;

const PmDetailImage = ({ level }) => {
  const imgPath = useRef(null);

  useEffect(() => {
    console.log(level, imgPath);
    switch (level) {
      case '매우 나쁨':
        imgPath.current = verybad;
        return;
      case '나쁨':
        imgPath.current = bad;
        return;
      case '보통':
        imgPath.current = soso;
        return;
      case '좋음':
        imgPath.current = good;
        return;
      default:
        imgPath.current = soso;
    }
  }, [level, imgPath]);

  return (
    <PmDetailImageBlock>
      {imgPath.current && (
        <img src={imgPath.current} alt="sky" className="sky-img" />
      )}
    </PmDetailImageBlock>
  );
};

export default PmDetailImage;

PmDetailText

import styled from 'styled-components';
import { cities } from './CitySelect'; // 인덱스로 어느 구인지 찾기
import colors from '../lib/styles/colors';

const PmDetailTextBlock = styled.div`
  margin-bottom: 18px;
`;

const DateAreaWrapper = styled.div`
  font-size: 17px;
  letter-spacing: 0.03em;
  .month {
    font-weight: 500;
  }
  .date {
    font-weight: 500;
  }
  .cityName {
    font-size: 19px;
    font-weight: 700;
  }
`;

const PmInfo = styled.div`
  margin-bottom: 10px;
  .pm-level {
    display: inline-block;
    font-size: 20px;
    font-weight: 500;
    padding: 3px 6px;
    border-radius: 8px;
    color: #fff;
    background-color: ${(props) =>
      props.level === '매우 나쁨'
        ? colors.level[0]
        : props.level === '나쁨'
        ? colors.level[1]
        : props.level === '보통'
        ? colors.level[2]
        : colors.level[3]};
  }
`;

const MaskInfo = styled.div`
  color: ${colors.green[2]};
  font-weight: 700;
`;

const PmDetailText = ({ city, level }) => {
  const month = new Date().getMonth() + 1;
  const date = new Date().getDate();

  return (
    <PmDetailTextBlock>
      <DateAreaWrapper>
        <span className="month">{month}</span>{' '}
        <span className="date">{date}</span>{' '}
        <span className="cityName">{cities[city]}</span></DateAreaWrapper>
      <PmInfo level={level}>
        미세먼지 정도는 <span className="pm-level">{level}</span> 입니다.
      </PmInfo>

      <MaskInfo>
        {level === '매우 나쁨' || level === '나쁨'
          ? '마스크를 착용하세요.'
          : '맑은 하루가 되겠네요!'}
      </MaskInfo>
    </PmDetailTextBlock>
  );
};

export default PmDetailText;

ModalButton

import styled from 'styled-components';
import colors from '../lib/styles/colors';
import { ReactComponent as EarthICO } from '../assets/earth.svg';
import Modal from './Modal';
import { useState } from 'react';

const ModalButtonBlock = styled.div`
  margin-top: 16px;
`;

const StyledButton = styled.button`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 400px;
  border-radius: 10px;
  padding: 8px 0;
  text-align: canter;
  background-color: ${colors.green[2]};
  color: #fff;
  font-size: 20px;
  font-weight: 500;
  letter-spacing: 0.03em;
  cursor: pointer;
  svg {
    width: 26px;
    margin-left: 10px;
  }
  &:hover {
    background-color: ${colors.green[1]};
  }
`;

const ModalButton = () => {
  const [visible, setVisible] = useState(false);

  const onOpenModal = () => {
    setVisible(true);
  };

  const onCloseModal = () => {
    setVisible(false);
  };
  return (
    <ModalButtonBlock>
      <StyledButton onClick={onOpenModal}>
        For Earth
        <EarthICO />
      </StyledButton>
      <Modal
        visible={visible}
        randomIndex={Math.floor(Math.random() * 6)}
        onCloseModal={onCloseModal}
      />
    </ModalButtonBlock>
  );
};

export default ModalButton;
import styled from 'styled-components';
import colors from '../lib/styles/colors';
import { ReactComponent as PlantICO } from '../assets/plant.svg';
import { ReactComponent as CloseICO } from '../assets/close.svg';

const textArr = [
  '대중교통 이용하기 🚍',
  '친환경 세제 사용하기 🧼',
  '친환경 인증 마크 제품 구매하기 🌱',
  '일회용품 사용 자제하기 🥤',
  '사용하지 않는 콘센트를 뽑아 에너지 절약하기 🔌',
  '육류 섭취 줄이고 유기농 식품 섭취하기 🥩',
];

const FullScreen = styled.div`
  position: fixed;
  z-index: 50;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: ${colors.trans[0]};
  display: flex;
  justify-content: center;
  align-items: center;
`;

const ModalBlock = styled.div`
  position: relative;
  width: 850px;
  background-color: #fff;
  padding: 25px 45px;
  border-radius: 10px;
  text-align: center;
  .modal-title {
    font-size: 20px;
    margin-bottom: 20px;
    display: flex;
    justify-content: center;
    align-items: center;
    svg {
      width: 30px;
      margin-right: 8px;
    }
    .modal-content {
      font-size: 20px;
      font-weight: 500;
    }
  }
  .close {
    cursor: pointer;
    width: 30px;
    position: absolute;
    top: 20px;
    right: 20px;
    fill: ${colors.gray[2]};
  }
`;

const Modal = ({ randomIndex, visible, onCloseModal }) => {
  if (!visible) return null;
  return (
    <FullScreen>
      <ModalBlock>
        <h3 className="modal-title">
          <PlantICO /> Today's For Earth
        </h3>
        <p className="modal-content">{textArr[randomIndex]}</p>
        <CloseICO className="close" onClick={onCloseModal} />
      </ModalBlock>
    </FullScreen>
  );
};

export default Modal;

Result

1-1

1-2

  • modal

1-3


-> result / modal / button

profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글