├── .babelrc ├── .editorconfig ├── .env.example ├── .eslintrc.js ├── .github └── workflows │ └── checkLint.yml ├── .gitignore ├── .prettierrc ├── README.md ├── components ├── Card.tsx ├── ChannelTalk.tsx ├── Chart │ └── Map.tsx ├── Container.tsx ├── Emoji.tsx ├── Jumbotron.tsx ├── Main │ ├── Desktop.tsx │ ├── Mobile.tsx │ └── StatTable.tsx ├── MapPolygon.tsx ├── Mask │ ├── Map.tsx │ ├── card.tsx │ └── search.tsx └── StatCard.tsx ├── layouts ├── components │ ├── Footer.tsx │ ├── Header.tsx │ ├── HeaderMobile.tsx │ ├── Menu.ts │ └── Sidebar.tsx └── main.tsx ├── manifest.yml ├── maps └── southKorea.js ├── next-env.d.ts ├── next.config.js ├── now.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── _error.tsx ├── index.tsx └── mask.tsx ├── public ├── robots.txt └── static │ ├── favicon │ └── favicon.ico │ └── images │ └── bg.png ├── styles ├── core.scss └── libraries │ ├── _grid.scss │ └── _reset.scss ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel", "@emotion/babel-preset-css-prop"], 3 | "plugins": [["emotion"]] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = false -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | API_URL=https://api.coronas.info 2 | NAVER_MAP_API= -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', 5 | 'prettier/@typescript-eslint', 6 | 'react-app', 7 | 'plugin:prettier/recommended', 8 | ], 9 | plugins: ['@typescript-eslint', 'react'], 10 | rules: {}, 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/checkLint.yml: -------------------------------------------------------------------------------- 1 | name: ESLint Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Setup Node 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: '12.x' 22 | - name: Get yarn cache 23 | id: yarn-cache 24 | run: echo "::set-output name=dir::$(yarn cache dir)" 25 | - name: Cache dependencies 26 | uses: actions/cache@v1 27 | with: 28 | path: ${{ steps.yarn-cache.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | - run: yarn install 33 | - run: yarn lint 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .env 27 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "bracketSpacing": true, 7 | "semi": false, 8 | "useTabs": false, 9 | "arrowParens": "avoid", 10 | "endOfLine": "auto" 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

코로나인포

2 |

신종 코로나바이러스에 대한 정보를 알려드립니다!

3 | 4 |

5 | 6 |

7 | 8 | --- 9 | 10 | ## Development 11 | 12 | ``` 13 | yarn dev 14 | ``` 15 | -------------------------------------------------------------------------------- /components/Card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from '@emotion/styled' 3 | 4 | const CardStyle = styled.div` 5 | display: block; 6 | padding: 20px 0; 7 | border-radius: 8px; 8 | padding: 20px; 9 | line-height: 1.5; 10 | background: #fff; 11 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09); 12 | border: 0; 13 | z-index: 101; 14 | ` 15 | 16 | interface CardProps { 17 | children: React.ReactNode 18 | style?: React.CSSProperties 19 | } 20 | 21 | const Card = ({ children, style }: CardProps): JSX.Element => { 22 | return ( 23 | <> 24 | {children} 25 | 26 | ) 27 | } 28 | 29 | export default Card 30 | -------------------------------------------------------------------------------- /components/ChannelTalk.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | 3 | declare global { 4 | interface Window { 5 | ChannelIO: any 6 | ChannelIOInitialized: any 7 | attachEvent: any 8 | } 9 | } 10 | 11 | const ChannelTalk = ({ pluginId }): JSX.Element => { 12 | useEffect(() => { 13 | if (window.ChannelIO) { 14 | return (window.console.error || window.console.log)('ChannelIO script included twice.') 15 | } 16 | const document = window.document 17 | const ch = function() { 18 | // eslint-disable-next-line prefer-rest-params 19 | ch.c(arguments) 20 | } 21 | ch.q = [] 22 | ch.c = function(args) { 23 | ch.q.push(args) 24 | } 25 | window.ChannelIO = ch 26 | function l() { 27 | if (window.ChannelIOInitialized) { 28 | return 29 | } 30 | window.ChannelIOInitialized = true 31 | const s = document.createElement('script') 32 | s.type = 'text/javascript' 33 | s.async = true 34 | s.src = 'https://cdn.channel.io/plugin/ch-plugin-web.js' 35 | s.charset = 'UTF-8' 36 | const x = document.getElementsByTagName('script')[0] 37 | x.parentNode.insertBefore(s, x) 38 | } 39 | if (document.readyState === 'complete') { 40 | l() 41 | } else if (window.attachEvent) { 42 | window.attachEvent('onload', l) 43 | } else { 44 | window.addEventListener('DOMContentLoaded', l, false) 45 | window.addEventListener('load', l, false) 46 | } 47 | window.ChannelIO('boot', { 48 | pluginKey: pluginId, 49 | }) 50 | return () => { 51 | window.ChannelIO('shutdown') 52 | } 53 | }, []) 54 | return <> 55 | } 56 | 57 | export default ChannelTalk 58 | -------------------------------------------------------------------------------- /components/Chart/Map.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { css } from '@emotion/core' 3 | import styled from '@emotion/styled' 4 | 5 | import SouthKoreaSvg from '../../maps/southKorea' 6 | import { SVGMap } from 'react-svg-map' 7 | 8 | const getLocationName = (event): string => { 9 | return event.target.attributes.name.value 10 | } 11 | 12 | const ChartMap = ({ location }): JSX.Element => { 13 | const createCSS = () => { 14 | let styles = '' 15 | 16 | location.forEach(item => { 17 | let color = '' 18 | if (item.total >= 100000) { 19 | color = '#BE1D1D' 20 | } else if (item.total >= 10000) { 21 | color = '#E03232' 22 | } else if (item.total >= 5000) { 23 | color = '#FF3939' 24 | } else if (item.total >= 1000) { 25 | color = '#FF6161' 26 | } else if (item.total >= 100) { 27 | color = '#FF7E7E' 28 | } else if (item.total >= 50) { 29 | color = '#FFB4B4' 30 | } else if (item.total >= 10) { 31 | color = '#FFD4D4' 32 | } else if (item.total >= 1) { 33 | color = '#FFEBEB' 34 | } else { 35 | color = '#FFF' 36 | } 37 | styles += ` 38 | path[name='${item.name}'] { 39 | fill: ${color}; 40 | } 41 | ` 42 | }) 43 | 44 | return css` 45 | ${styles} 46 | ` 47 | } 48 | 49 | const ChartMapStyle = styled.div` 50 | @import 'react-svg-map/lib/index.css'; 51 | padding: 20px; 52 | width: 350px; 53 | margin: 0 auto; 54 | @media (max-width: 992px) { 55 | width: 100%; 56 | } 57 | .map__tooltip { 58 | position: fixed; 59 | min-width: 130px; 60 | padding: 10px 20px; 61 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.29); 62 | border-radius: 8px; 63 | background-color: white; 64 | text-align: center; 65 | .total { 66 | font-size: 17px; 67 | font-weight: 500; 68 | } 69 | } 70 | path { 71 | stroke: #a2a2a2; 72 | stroke-width: 1px; 73 | } 74 | ${createCSS()}; 75 | ` 76 | 77 | const [pointedLocation, setPointedLocation] = useState(null) 78 | const [tooltipStyle, setTooltipStyle] = useState({ display: 'none' }) 79 | const mouseOver = event => { 80 | const pointedLocation = getLocationName(event) 81 | setPointedLocation(pointedLocation) 82 | } 83 | 84 | const handleLocationMouseMove = event => { 85 | const tooltipStyle = { 86 | display: 'block', 87 | top: event.clientY + 10, 88 | left: event.clientX - 100, 89 | } 90 | setTooltipStyle(tooltipStyle) 91 | } 92 | 93 | const handleLocationMouseOut = () => { 94 | setPointedLocation(null) 95 | setTooltipStyle({ display: 'none' }) 96 | } 97 | 98 | // 툴팁 내부 정보 처리 99 | const [locationData, setLocationData] = useState({ id: 0, name: '', total: 0, increase: 0 }) 100 | useEffect(() => { 101 | const findData = location.find(d => { 102 | return d.name === pointedLocation 103 | }) 104 | setLocationData(findData) 105 | }, [location, pointedLocation]) 106 | 107 | const Tooltip = (): JSX.Element => { 108 | if (locationData) { 109 | return ( 110 | <> 111 |
112 |

{locationData.name}

113 |

{locationData.total.toLocaleString()}명

114 |
115 | 116 | ) 117 | } else { 118 | return <> 119 | } 120 | } 121 | 122 | return ( 123 | 124 | 130 | 131 | 132 | ) 133 | } 134 | 135 | export default ChartMap 136 | -------------------------------------------------------------------------------- /components/Container.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | 3 | const Container = styled.div` 4 | width: 100%; 5 | padding: 0 5px; 6 | margin: 0 auto; 7 | 8 | @media (min-width: 576px) { 9 | max-width: 540px; 10 | } 11 | 12 | @media (min-width: 768px) { 13 | max-width: 720px; 14 | } 15 | 16 | @media (min-width: 992px) { 17 | max-width: 960px; 18 | } 19 | 20 | @media (min-width: 1200px) { 21 | max-width: 1140px; 22 | } 23 | 24 | @media (min-width: 1300px) { 25 | max-width: 1240px; 26 | } 27 | ` 28 | 29 | export default Container 30 | -------------------------------------------------------------------------------- /components/Emoji.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from '@emotion/styled' 3 | import Twemoji from 'react-twemoji' 4 | 5 | const EmojiBox = styled.span` 6 | .emoji { 7 | height: 1em; 8 | width: 1em; 9 | margin: 0 0.05em 0 0.1em; 10 | vertical-align: -0.2em; 11 | } 12 | ` 13 | 14 | interface EmojiProps { 15 | str: string 16 | } 17 | 18 | const Emoji: React.FC = props => { 19 | const TwemojiOptions = { 20 | folder: 'svg', 21 | ext: '.svg', 22 | } 23 | return ( 24 | 25 | 26 | {props.str} 27 | 28 | 29 | ) 30 | } 31 | 32 | export default Emoji 33 | -------------------------------------------------------------------------------- /components/Jumbotron.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from '@emotion/styled' 3 | import Container from './Container' 4 | 5 | const Jumbotron = styled.div` 6 | background: #f1f1f1; 7 | padding: 35px 0; 8 | line-height: 1.6; 9 | h1 { 10 | font-size: 1.7rem; 11 | font-weight: 700; 12 | } 13 | p.description { 14 | font-size: 1rem; 15 | } 16 | @media (max-width: 992px) { 17 | padding: 35px 10px; 18 | } 19 | ` 20 | 21 | interface JumboProps { 22 | title?: string 23 | desc?: string 24 | } 25 | 26 | const JumbotronComponent = ({ title, desc }: JumboProps): JSX.Element => ( 27 | <> 28 | 29 | 30 |

{title}

31 |

{desc}

32 |
33 |
34 | 35 | ) 36 | 37 | export default JumbotronComponent 38 | -------------------------------------------------------------------------------- /components/Main/Desktop.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import dynamic from 'next/dynamic' 3 | import React from 'react' 4 | import Card from '../Card' 5 | import Container from '../Container' 6 | import StatCard from '../StatCard' 7 | 8 | const MapChart = dynamic(() => import('@/components/Chart/Map'), { ssr: false }) 9 | const StatTable = dynamic(() => import('./StatTable')) 10 | 11 | const MapContainer = styled.section` 12 | padding: 20px 0; 13 | h2 { 14 | font-size: 20px; 15 | font-weight: 500; 16 | margin-bottom: 15px; 17 | padding-left: 6px; 18 | span { 19 | font-size: 15px; 20 | } 21 | } 22 | ` 23 | 24 | const MainDesktop = ({ report, location }): JSX.Element => { 25 | return ( 26 | <> 27 | 32 |
33 |
34 | 39 | 40 | 41 | 45 | 46 | 47 | } 48 | /> 49 |
50 |
51 | 56 | 57 | 61 | 62 | 63 | } 64 | /> 65 |
66 |
67 | 72 | 73 | 77 | 78 | 79 | 80 | 81 | } 82 | /> 83 |
84 |
85 |
86 |
87 | 88 | 89 |
90 |

확진자 지도

91 |
92 | 93 |
94 |
95 |
96 |
97 | 98 | 99 |
100 |

101 | 감염 통계 (오늘 기준) 102 |

103 |
104 | 105 |
106 |
107 |
108 |
109 |
110 | 111 | ) 112 | } 113 | 114 | export default MainDesktop 115 | -------------------------------------------------------------------------------- /components/Main/Mobile.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import dynamic from 'next/dynamic' 3 | import React from 'react' 4 | import Card from '../Card' 5 | import Container from '../Container' 6 | import Link from 'next/link' 7 | 8 | import { Icon, InlineIcon } from '@iconify/react' 9 | import mapIcon from '@iconify/icons-ion/map' 10 | import receiptIcon from '@iconify/icons-ion/receipt' 11 | import personIcon from '@iconify/icons-ion/person' 12 | import peopleSharp from '@iconify/icons-ion/people-sharp' 13 | 14 | const MapChart = dynamic(() => import('@/components/Chart/Map'), { ssr: false }) 15 | const StatTable = dynamic(() => import('./StatTable')) 16 | 17 | const MapContainer = styled.section` 18 | padding: 20px 10px; 19 | h2 { 20 | font-size: 20px; 21 | font-weight: 500; 22 | margin-bottom: 15px; 23 | padding-left: 6px; 24 | span { 25 | font-size: 15px; 26 | } 27 | } 28 | ` 29 | 30 | const MarginBox = styled.div` 31 | margin-bottom: 20px; 32 | ` 33 | 34 | const StatusItem = styled.div` 35 | text-align: center; 36 | h3 { 37 | font-size: 20px; 38 | font-weight: 600; 39 | &.total { 40 | color: var(--main); 41 | } 42 | &.cure { 43 | color: #33a77c; 44 | } 45 | &.death { 46 | color: #f24147; 47 | } 48 | } 49 | ` 50 | 51 | const QuickLink = styled.a` 52 | display: block; 53 | border-radius: 8px; 54 | padding: 20px 17px; 55 | color: #333; 56 | background: #fff; 57 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09); 58 | text-decoration: none; 59 | text-align: center; 60 | 61 | h3 { 62 | font-size: 16px; 63 | font-weight: 500; 64 | } 65 | svg { 66 | width: 28px; 67 | height: auto; 68 | margin-bottom: 10px; 69 | &.orange { 70 | color: #ff7800; 71 | } 72 | &.green { 73 | color: #13b363; 74 | } 75 | &.blue { 76 | color: #24a5ff; 77 | } 78 | &.red { 79 | color: #ff5454; 80 | } 81 | } 82 | ` 83 | 84 | const MainMobile = ({ report, location }): JSX.Element => { 85 | return ( 86 | <> 87 | 88 |
89 |
90 | 91 | 96 |
97 | 98 |

확진자

99 |

{report.total_count.toLocaleString()}

100 |
101 | 102 |

격리 해제

103 |

{report.cure_count.toLocaleString()}

104 |
105 | 106 |

사망

107 |

{report.death_count.toLocaleString()}

108 |
109 |
110 |
111 |
117 |
118 | 119 | 120 | 121 |

주변 마스크

122 |
123 | 124 |
125 |
126 | 127 | 128 | 129 |

확진자 통계

130 |
131 | 132 |
133 |
134 |
140 |
141 | 142 | 143 | 144 |

실시간 뉴스

145 |
146 | 147 |
148 |
149 | 150 | 151 | 152 |

확진자 목록

153 |
154 | 155 |
156 |
157 | 158 | 159 |

확진자 지도

160 | 161 |
162 |
163 | 164 |

165 | 통계 (오늘 기준) 166 |

167 | 168 |
169 |
170 |
171 |
172 |
173 | 174 | ) 175 | } 176 | 177 | export default MainMobile 178 | -------------------------------------------------------------------------------- /components/Main/StatTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from '@emotion/styled' 3 | 4 | const StatTable = styled.table` 5 | width: 100%; 6 | text-align: center; 7 | td { 8 | padding: 10px 20px; 9 | border: 0; 10 | width: 50%; 11 | } 12 | h4 { 13 | font-size: 15px; 14 | margin-bottom: 3px; 15 | } 16 | .data { 17 | font-weight: 500; 18 | font-size: 24px; 19 | span.small { 20 | font-size: 14px; 21 | } 22 | } 23 | .green { 24 | color: #27af7d; 25 | } 26 | .red { 27 | color: #f24147; 28 | } 29 | ` 30 | 31 | const StatTableComponent = ({ report }): JSX.Element => ( 32 | 33 | 34 | 35 | 36 |

오늘 확진자 증가

37 |

0 ? 'data red' : 'data'}> 38 | {report.increase_count.toLocaleString()}명 39 |

40 | 41 | 42 |

격리해제 (완치) 비율

43 |

{report.cure_rate}%

44 | 45 | 46 | 47 | 48 |

확진자 제일 많은 지역

49 |

50 | {report.top_rate_total_location.name}{' '} 51 | 52 | ({report.top_rate_total_location.total.toLocaleString()}명) 53 | 54 |

55 | 56 | 57 |

오늘 확진자 제일 증가한 지역

58 |

59 | {report.top_rate_increase_location.name}{' '} 60 | 61 | (+{report.top_rate_increase_location.increase.toLocaleString()}명) 62 | 63 |

64 | 65 | 66 | 67 |
68 | ) 69 | 70 | export default StatTableComponent 71 | -------------------------------------------------------------------------------- /components/MapPolygon.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react' 2 | import { RenderAfterNavermapsLoaded, NaverMap, Polyline } from 'react-naver-maps' 3 | import randomColor from 'randomcolor' 4 | 5 | declare global { 6 | interface Window { 7 | naver: any 8 | } 9 | } 10 | 11 | const Map = ({ pdata }): JSX.Element => { 12 | const data = {} 13 | pdata.map(item => { 14 | if (data.hasOwnProperty(item.index)) { 15 | data[item.index] = [...data[item.index], item] 16 | } else { 17 | data[item.index] = [item] 18 | } 19 | }) 20 | return ( 21 | <> 22 | 31 | {Object.keys(data).map(key => { 32 | const navermaps = window.naver.maps 33 | const color = randomColor() 34 | const paths = [] 35 | data[key].map(item => { 36 | paths.push(new navermaps.LatLng(item.lat, item.lng)) 37 | }) 38 | return ( 39 | 50 | ) 51 | })} 52 | 53 | 54 | ) 55 | } 56 | const MapComponent = ({ pdata }): JSX.Element => { 57 | if (window.naver.maps) { 58 | return 59 | } else { 60 | return

Loading...

61 | } 62 | } 63 | export default MapComponent 64 | -------------------------------------------------------------------------------- /components/Mask/Map.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NaverMap, Marker } from 'react-naver-maps' 3 | import styled from '@emotion/styled' 4 | 5 | declare global { 6 | interface Window { 7 | naver: any 8 | } 9 | const naver: any 10 | const MarkerClustering: any 11 | const N: any 12 | } 13 | 14 | const MapContainer = styled.div` 15 | display: block; 16 | ` 17 | 18 | class MaskMap extends React.Component { 19 | mapRef: any 20 | 21 | render(): JSX.Element { 22 | return ( 23 | 24 | { 26 | this.mapRef = ref 27 | }} 28 | mapDivId={'dash-map'} // default: react-naver-map 29 | style={{ 30 | width: '100%', 31 | height: '100vh', 32 | }} 33 | defaultCenter={{ lat: 36.3213564, lng: 127.0978459 }} 34 | defaultZoom={17} 35 | > 36 | 37 | ) 38 | } 39 | 40 | componentDidMount(): void { 41 | navigator.geolocation.getCurrentPosition( 42 | position => { 43 | this.mapRef.instance.setCenter( 44 | new naver.maps.LatLng(position.coords.latitude, position.coords.longitude), 45 | ) 46 | new naver.maps.Marker({ 47 | position: new naver.maps.LatLng(position.coords.latitude, position.coords.longitude), 48 | map: this.mapRef.instance, 49 | }) 50 | }, 51 | err => { 52 | console.log(err) 53 | }, 54 | ) 55 | } 56 | } 57 | 58 | export default MaskMap 59 | -------------------------------------------------------------------------------- /components/Mask/card.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import styled from '@emotion/styled' 3 | import Card from '@/components/Card' 4 | 5 | const StyleSection = styled.div` 6 | line-height: 1.5; 7 | .gray { 8 | color: gray; 9 | } 10 | .name { 11 | font-size: 20px; 12 | font-weight: 600; 13 | span { 14 | margin-left: 8px; 15 | font-size: 14px; 16 | } 17 | } 18 | .openBtn { 19 | width: 30px; 20 | float: right; 21 | } 22 | table { 23 | width: 100%; 24 | td { 25 | padding: 12px 16px; 26 | border: 1px solid #dedede; 27 | &:nth-of-type(1) { 28 | font-weight: 500; 29 | width: 130px; 30 | text-align: center; 31 | } 32 | } 33 | tbody { 34 | background: #fff; 35 | } 36 | } 37 | .infomation { 38 | margin-top: 20px; 39 | } 40 | .mapLink { 41 | margin-top: 15px; 42 | a { 43 | display: block; 44 | background: #fae000; 45 | color: #1d1d1d; 46 | text-decoration: none; 47 | text-align: center; 48 | padding: 10px 0; 49 | font-size: 16px; 50 | &.naver { 51 | background: rgb(3, 207, 93); 52 | } 53 | } 54 | } 55 | ` 56 | const Tag = styled.span` 57 | display: inline-block; 58 | border-radius: 24px; 59 | color: #fff; 60 | font-weight: 500; 61 | padding: 3px 30px; 62 | margin-bottom: 3px; 63 | 64 | &.plenty { 65 | background: #00a769; 66 | } 67 | &.some { 68 | background: #f1c40f; 69 | color: #000; 70 | } 71 | &.few { 72 | background: #de2e2e; 73 | } 74 | &.empty { 75 | background: #6b6b6b; 76 | } 77 | &.break { 78 | background: #fff; 79 | color: #333; 80 | } 81 | ` 82 | 83 | const Address = styled.span` 84 | color: #888; 85 | ` 86 | 87 | const MaskCard = ({ data }) => { 88 | const [open, setOpen] = useState(false) 89 | 90 | const changeOpen = (): void => { 91 | open ? setOpen(false) : setOpen(true) 92 | } 93 | 94 | let color 95 | 96 | switch (data.remain_stat) { 97 | case 'plenty': 98 | color = '#d7fdef' 99 | break 100 | case 'some': 101 | color = '#fffae6' 102 | break 103 | case 'few': 104 | color = '#ffdede' 105 | break 106 | case 'empty': 107 | color = '#dcdcdc' 108 | break 109 | case 'break': 110 | color = '#f7f7f7' 111 | break 112 | default: 113 | color = '#fff' 114 | break 115 | } 116 | 117 | return ( 118 | <> 119 | 124 | 125 | 126 | {open ? ( 127 | 128 | 129 | 130 | ) : ( 131 | 132 | 133 | 134 | )} 135 | 136 | {data.remain_stat === 'plenty' && ( 137 | <> 138 | 넉넉 (100개 이상) 139 | 140 | )} 141 | {data.remain_stat === 'some' && ( 142 | <> 143 | 조금 (30~100) 144 | 145 | )} 146 | {data.remain_stat === 'few' && ( 147 | <> 148 | 부족 (2~30) 149 | 150 | )} 151 | {data.remain_stat === 'empty' && ( 152 | <> 153 | 없음 (1개 이하) 154 | 155 | )} 156 | {data.remain_stat === 'break' && ( 157 | <> 158 | 판매 중지 159 | 160 | )} 161 |

162 | {data.name} 163 | {data.type === '01' && (약국)} 164 | {data.type === '02' && (우체국)} 165 | {data.type === '03' && (농협)} 166 |

167 |
{data.addr}
168 | {open === true && ( 169 | <> 170 |
171 | 172 | 173 | 174 | 175 | 182 | 183 | 184 | 185 | 218 | 219 | 220 | 221 | 228 | 229 | 230 |
최근 입고 176 | {data.stock_at ? ( 177 | {data.stock_at} 178 | ) : ( 179 | 정보 없음 180 | )} 181 |
재고 상태 186 | {data.remain_stat ? ( 187 | 188 | {data.remain_stat === 'plenty' && ( 189 | <> 190 | 100개 이상 191 | 192 | )} 193 | {data.remain_stat === 'some' && ( 194 | <> 195 | 30~100 196 | 197 | )} 198 | {data.remain_stat === 'few' && ( 199 | <> 200 | 2~30 201 | 202 | )} 203 | {data.remain_stat === 'empty' && ( 204 | <> 205 | 1개 이하 206 | 207 | )} 208 | {data.remain_stat === 'break' && ( 209 | <> 210 | 판매 중지 211 | 212 | )} 213 | 214 | ) : ( 215 | 정보 없음 216 | )} 217 |
정보 업데이트 222 | {data.created_at ? ( 223 | {data.created_at} 224 | ) : ( 225 | 정보 없음 226 | )} 227 |
231 | 262 |
263 | 264 | )} 265 |
266 |
267 | 268 | ) 269 | } 270 | 271 | export default MaskCard 272 | -------------------------------------------------------------------------------- /components/Mask/search.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import styled from '@emotion/styled' 3 | import Card from '@/components/Card' 4 | import { InlineIcon } from '@iconify/react' 5 | import bxSearch from '@iconify/icons-bx/bx-search' 6 | 7 | const SearchForm = styled.form` 8 | margin: 20px 0; 9 | display: flex; 10 | border-radius: 8px; 11 | justify-content: space-between; 12 | width: 100%; 13 | background: #fff; 14 | box-shadow: 0 3px 6px rgba(0, 0, 0, 0.07); 15 | transition: box-shadow 0.3s ease-in-out; 16 | &:hover, 17 | &:focus, 18 | &:active { 19 | box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15); 20 | } 21 | input { 22 | border: 0; 23 | background: transparent; 24 | outline: none; 25 | padding: 20px 30px; 26 | font-size: 16px; 27 | width: 100%; 28 | -webkit-appearance: none; 29 | } 30 | button { 31 | -webkit-appearance: none; 32 | background: transparent; 33 | border: 0; 34 | outline: none; 35 | padding: 20px 30px; 36 | cursor: pointer; 37 | svg { 38 | width: 20px; 39 | height: 20px; 40 | } 41 | } 42 | ` 43 | 44 | const SearchResult = styled.div` 45 | margin: 15px 0; 46 | div { 47 | cursor: pointer; 48 | } 49 | ` 50 | 51 | const MaskSearch = (): JSX.Element => { 52 | const [keyword, setKeyword] = useState('') 53 | const [data, setData] = useState(null) 54 | 55 | const searchAction = () => { 56 | if (keyword !== '') { 57 | fetch('https://dapi.kakao.com/v2/local/search/keyword.json?query=' + keyword, { 58 | method: 'get', 59 | headers: { 60 | Authorization: 'KakaoAK 1b4a73d6cb2add7318c6c956f2c4022e', 61 | }, 62 | }) 63 | .then(function(res) { 64 | return res.json() 65 | }) 66 | .then(function(json) { 67 | console.log(json) 68 | setData(json) 69 | }) 70 | } 71 | } 72 | 73 | const formOnSubmit = e => { 74 | e.preventDefault() 75 | searchAction() 76 | } 77 | 78 | const DataCard = ({ data }) => ( 79 |
84 | {data.address_name} 85 |
86 | ) 87 | 88 | const SearchResultContainer = (): JSX.Element => { 89 | if (data !== null) { 90 | if (data.documents && data.documents.length > 0) { 91 | return ( 92 | 93 | {data.documents.map((row, i) => { 94 | return 95 | })} 96 | 97 | ) 98 | } else { 99 | return ( 100 |
101 | 검색 결과가 없거나, 오류입니다. 102 |
103 | ) 104 | } 105 | } else { 106 | return <> 107 | } 108 | } 109 | 110 | return ( 111 | <> 112 | 113 | { 117 | setKeyword(e.target.value) 118 | }} 119 | /> 120 | 123 | 124 | 125 | 126 | ) 127 | } 128 | 129 | export default MaskSearch 130 | -------------------------------------------------------------------------------- /components/StatCard.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React from 'react' 3 | 4 | const Card = styled.div` 5 | display: flex; 6 | align-items: center; 7 | padding: 20px 0; 8 | border-radius: 8px; 9 | line-height: 1.6; 10 | background: #fff; 11 | border: 0; 12 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09); 13 | z-index: 101; 14 | .a { 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | width: 100px; 19 | margin-left: 5px; 20 | svg { 21 | width: 45px; 22 | } 23 | } 24 | .b { 25 | margin-bottom: -3px; 26 | .d { 27 | font-weight: 500; 28 | font-size: 18px; 29 | @media (max-width: 997px) { 30 | font-size: 15px; 31 | } 32 | } 33 | .n { 34 | font-weight: 700; 35 | font-size: 24px; 36 | } 37 | } 38 | ` 39 | 40 | const StatCard = ({ icon, title, content }): JSX.Element => { 41 | return ( 42 | <> 43 | 44 |
{icon}
45 |
46 |

{title}

47 |

{content}

48 |
49 |
50 | 51 | ) 52 | } 53 | 54 | export default StatCard 55 | -------------------------------------------------------------------------------- /layouts/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import styled from '@emotion/styled' 3 | import Emoji from '../../components/Emoji' 4 | import fetch from 'isomorphic-unfetch' 5 | 6 | const StyledFooter = styled.footer` 7 | width: 100%; 8 | height: 50px; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | background: #ffffff; 13 | border-top: 1px solid #e4e4e4; 14 | text-align: center; 15 | padding: 25px 0; 16 | line-height: 2; 17 | a { 18 | text-decoration: none; 19 | color: #194a7d; 20 | } 21 | .small { 22 | font-size: 90%; 23 | } 24 | ` 25 | 26 | const Footer = (): JSX.Element => { 27 | return ( 28 | 29 |
30 | 35 | 코로나인포 소개 36 | 37 |
38 |
39 | ) 40 | } 41 | 42 | export default Footer 43 | -------------------------------------------------------------------------------- /layouts/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | import styled from '@emotion/styled' 4 | import Container from '@/components/Container' 5 | import { Menu } from '@/layouts/components/Menu' 6 | 7 | const EditedContainer = styled(Container)` 8 | align-items: center; 9 | display: flex; 10 | height: 100%; 11 | ` 12 | 13 | const Nav = styled.nav` 14 | background: #fff; 15 | height: 60px; 16 | z-index: 20; 17 | &.fixed { 18 | position: fixed; 19 | width: 100%; 20 | top: 0; 21 | z-index: 999; 22 | } 23 | ` 24 | 25 | const NavLogo = styled.a` 26 | font-size: 1.3rem; 27 | text-decoration: none; 28 | color: #2c2c2c; 29 | span { 30 | color: var(--main); 31 | font-weight: 700; 32 | } 33 | ` 34 | 35 | const NavMenu = styled.ul` 36 | align-items: center; 37 | display: flex; 38 | list-style: none; 39 | margin-left: 20px; 40 | a { 41 | padding: 0 13px; 42 | font-size: 0.95rem; 43 | text-decoration: none; 44 | color: #585858; 45 | } 46 | ` 47 | 48 | // const NavRight = styled.div` 49 | // display: block; 50 | // margin-left: auto; 51 | // ` 52 | 53 | // const NavButton = styled.a` 54 | // background: var(--main); 55 | // border-radius: 30px; 56 | // color: #fff; 57 | // padding: 10px 48px; 58 | // font-weight: 500; 59 | // ` 60 | 61 | interface HeaderProps { 62 | fix?: boolean 63 | } 64 | 65 | const Header = ({ fix }: HeaderProps): JSX.Element => { 66 | return ( 67 | 89 | ) 90 | } 91 | 92 | export default Header 93 | -------------------------------------------------------------------------------- /layouts/components/HeaderMobile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from '@emotion/styled' 3 | import Container from '../../components/Container' 4 | import Link from 'next/link' 5 | 6 | const EditedContainer = styled(Container)` 7 | align-items: center; 8 | display: flex; 9 | height: 100%; 10 | position: relative; 11 | justify-content: space-between; 12 | padding: 0 10px; 13 | ` 14 | 15 | const Nav = styled.nav` 16 | background: #fff; 17 | height: 60px; 18 | width: 100vw; 19 | &.fixed { 20 | position: fixed; 21 | top: 0; 22 | width: 100%; 23 | z-index: 30; 24 | } 25 | ` 26 | 27 | const NavLogo = styled.a` 28 | position: absolute; 29 | left: 50%; 30 | -webkit-transform: translateX(-50%); 31 | transform: translateX(-50%); 32 | font-size: 1.3rem; 33 | cursor: pointer; 34 | span { 35 | color: var(--main); 36 | font-weight: 700; 37 | } 38 | ` 39 | 40 | const Menubar = styled.button` 41 | background: transparent; 42 | border: 0; 43 | height: 100%; 44 | outline: none; 45 | cursor: pointer; 46 | ` 47 | 48 | interface HeaderProps { 49 | sidebarChange: () => void 50 | fix?: boolean 51 | } 52 | 53 | const Header = (props: HeaderProps): JSX.Element => { 54 | return ( 55 | 81 | ) 82 | } 83 | 84 | export default Header 85 | -------------------------------------------------------------------------------- /layouts/components/Menu.ts: -------------------------------------------------------------------------------- 1 | export const Menu = [{ title: '마스크 재고 현황', href: '/mask' }] 2 | -------------------------------------------------------------------------------- /layouts/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from '@emotion/styled' 3 | import Link from 'next/link' 4 | import { Menu } from '@/layouts/components/Menu' 5 | 6 | const Aside = styled.aside` 7 | background: #fff; 8 | width: 230px; 9 | height: 100%; 10 | z-index: 600; 11 | padding: 25px 25px; 12 | .logo { 13 | display: flex; 14 | justify-content: space-between; 15 | align-items: center; 16 | button { 17 | background: none; 18 | border: 0; 19 | outline: none; 20 | cursor: pointer; 21 | } 22 | } 23 | .menu { 24 | padding: 30px 0; 25 | ul { 26 | list-style: none; 27 | a, 28 | li { 29 | color: inherit; 30 | text-decoration: none; 31 | display: list-item; 32 | padding: 12px 0; 33 | font-size: 1rem; 34 | } 35 | } 36 | } 37 | ` 38 | 39 | const NavLogo = styled.a` 40 | font-size: 1.4rem; 41 | line-height: 1.1; 42 | cursor: pointer; 43 | span { 44 | color: var(--main); 45 | font-weight: 700; 46 | } 47 | ` 48 | 49 | interface SidebarProps { 50 | sidebarChange: () => void 51 | } 52 | 53 | const Sidebar: React.FC = props => { 54 | return ( 55 | 95 | ) 96 | } 97 | 98 | export default Sidebar 99 | -------------------------------------------------------------------------------- /layouts/main.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import styled from '@emotion/styled' 3 | import dynamic from 'next/dynamic' 4 | 5 | const HeaderMobile = dynamic(() => import('./components/HeaderMobile')) 6 | const Header = dynamic(() => import('./components/Header')) 7 | const Footer = dynamic(() => import('./components/Footer')) 8 | 9 | const LayoutWrapper = styled.div`` 10 | 11 | const SideBackground = styled.div` 12 | position: absolute; 13 | visibility: hidden; 14 | top: 0; 15 | bottom: 0; 16 | left: 0; 17 | right: 0; 18 | background: rgba(0, 0, 0, 0.4); 19 | opacity: 0; 20 | transition: visibility 0s, opacity 0.1s linear; 21 | z-index: 200; 22 | &.show { 23 | visibility: visible; 24 | opacity: 1; 25 | } 26 | ` 27 | 28 | const SidebarWrapper = styled.div` 29 | position: absolute; 30 | top: 0; 31 | bottom: 0; 32 | left: -230px; 33 | right: 0; 34 | z-index: 400; 35 | visibility: hidden; 36 | transition: visibility 0s, left 0.3s ease-in-out; 37 | &.show { 38 | visibility: visible; 39 | left: 0; 40 | } 41 | ` 42 | 43 | interface MainLayoutProps { 44 | header?: boolean 45 | footer?: boolean 46 | isFull?: boolean 47 | children?: React.ReactNode 48 | } 49 | 50 | const MainLayout = ({ children, isFull, header, footer }: MainLayoutProps): JSX.Element => { 51 | const [isMobile, setIsMobile] = useState(false) // 모바일 여부 52 | const [sidebar, setSidebar] = useState(false) // 사이드바 On/Off 53 | 54 | const resizeEvent = (): void => { 55 | if (window.innerWidth < 992) { 56 | setIsMobile(true) 57 | } else { 58 | setIsMobile(false) 59 | setSidebar(false) // desktop 버전으로 가면 사이드바 해제해놓기 60 | } 61 | } 62 | 63 | const sidebarChange = (): void => { 64 | sidebar ? setSidebar(false) : setSidebar(true) 65 | } 66 | 67 | useEffect(() => { 68 | if (typeof window !== 'undefined') { 69 | resizeEvent() 70 | window.addEventListener('resize', resizeEvent) 71 | return (): void => { 72 | window.removeEventListener('resize', resizeEvent) 73 | } 74 | } 75 | }, []) 76 | 77 | if (isMobile) { 78 | // mobile mode 79 | const HeaderMobile = dynamic(() => import('./components/HeaderMobile')) 80 | const Sidebar = dynamic(() => import('./components/Sidebar')) 81 | return ( 82 | <> 83 | {header && ( 84 | <> 85 | 86 | 87 | 88 | 89 | 90 | 91 | )} 92 | {children} 93 | {footer &&