├── .gitignore ├── README.md ├── game2048 ├── final │ ├── .gitignore │ ├── jsconfig.json │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ └── src │ │ ├── App.js │ │ ├── component │ │ ├── AboveGame.js │ │ ├── Game.js │ │ ├── Header.js │ │ └── Tile.js │ │ ├── constant.js │ │ ├── hook │ │ ├── useLocalStorageNumber.js │ │ └── useMoveTile.js │ │ ├── index.css │ │ ├── index.js │ │ ├── setupTests.js │ │ └── util │ │ ├── __snapshots__ │ │ └── tile.test.js.snap │ │ ├── assert.js │ │ ├── keyboard.js │ │ ├── number.js │ │ ├── tile.js │ │ └── tile.test.js └── start │ ├── .gitignore │ ├── jsconfig.json │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ └── src │ ├── App.js │ ├── component │ ├── AboveGame.js │ ├── Game.js │ └── Header.js │ ├── index.css │ ├── index.js │ └── setupTests.js ├── ts-todo ├── final │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── Command.ts │ │ ├── Input.ts │ │ ├── Todo.ts │ │ ├── index.ts │ │ ├── type.ts │ │ └── util.ts │ └── tsconfig.json └── start │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── Input.ts │ ├── index.ts │ └── util.ts │ └── tsconfig.json └── whois ├── final ├── .env.development ├── .env.production ├── .gitignore ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── server │ ├── data.db │ ├── db.js │ ├── index.js │ ├── package-lock.json │ └── package.json └── src │ ├── App.js │ ├── auth │ ├── component │ │ └── AuthLayout.js │ ├── container │ │ ├── Login.js │ │ └── Signup.js │ ├── hook │ │ └── useBlockLoginUser.js │ └── state │ │ ├── index.js │ │ └── saga.js │ ├── common │ ├── component │ │ └── History.js │ ├── constant.js │ ├── hook │ │ ├── useFetchInfo.js │ │ └── useNeedLogin.js │ ├── redux-helper.js │ ├── state │ │ └── index.js │ ├── store.js │ └── util │ │ ├── api.js │ │ └── fetch.js │ ├── index.js │ ├── search │ ├── component │ │ └── Settings.js │ ├── container │ │ ├── Search.js │ │ └── SearchInput.js │ └── state │ │ ├── index.js │ │ └── saga.js │ ├── setupTests.js │ └── user │ ├── component │ └── FetchLabel.js │ ├── container │ ├── Department.js │ ├── TagList.js │ └── User.js │ └── state │ ├── index.js │ └── saga.js └── start ├── .env.development ├── .env.production ├── .gitignore ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── server ├── data.db ├── db.js ├── index.js ├── package-lock.json └── package.json └── src ├── App.js ├── common ├── constant.js ├── hook │ └── useFetchInfo.js ├── redux-helper.js ├── state │ └── index.js ├── store.js └── util │ ├── api.js │ └── fetch.js ├── index.js └── setupTests.js /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | .storybookOutput 3 | .serverOutput 4 | .static 5 | build.zip 6 | yarn.lock 7 | node_modules 8 | nginxLocal/error.log 9 | nginxLocal/access.log 10 | nginxLocal/healthd.application.log* 11 | .DS_Store 12 | .vscode/chrome 13 | .vscode/launch.json 14 | stories/**/*.js 15 | access.log 16 | static/html/next 17 | out 18 | **/__pycache__ 19 | .cache 20 | i18n/token.json 21 | logs -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 인프런 강의 프로젝트 코드입니다 2 | 3 | [실전 리액트 프로그래밍](https://www.inflearn.com/course/%EC%8B%A4%EC%A0%84-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D) 4 | 5 | [타입스크립트 시작하기](https://www.inflearn.com/course/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0) 6 | -------------------------------------------------------------------------------- /game2048/final/.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /game2048/final/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "target": "es6", 6 | "checkJs": true 7 | }, 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /game2048/final/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game2048-final", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.5.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "classnames": "^2.2.6", 10 | "hotkeys-js": "^3.8.1", 11 | "lodash": "^4.17.19", 12 | "react": "^16.13.1", 13 | "react-dom": "^16.13.1", 14 | "react-scripts": "3.4.1" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /game2048/final/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/game2048/final/public/favicon.ico -------------------------------------------------------------------------------- /game2048/final/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /game2048/final/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/game2048/final/public/logo192.png -------------------------------------------------------------------------------- /game2048/final/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/game2048/final/public/logo512.png -------------------------------------------------------------------------------- /game2048/final/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /game2048/final/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /game2048/final/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Header from './component/Header'; 3 | import AboveGame from './component/AboveGame'; 4 | import Game from './component/Game'; 5 | import useLocalStorageNumber from './hook/useLocalStorageNumber'; 6 | 7 | export default function App() { 8 | const [score, setScore] = useState(0); 9 | const [bestScore, setBestScore] = useLocalStorageNumber('bestScore', 0); 10 | 11 | useEffect(() => { 12 | if (score > bestScore) { 13 | setBestScore(score); 14 | } 15 | }); 16 | 17 | return ( 18 |
19 |
20 | 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /game2048/final/src/component/AboveGame.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function AboveGame() { 4 | return ( 5 |
6 |

7 | Join the numbers and get to the 2048 tile! 8 |

9 | 10 | New Game 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /game2048/final/src/component/Game.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import times from 'lodash/times'; 3 | import useMoveTile from '../hook/useMoveTile'; 4 | import { getInitialTileList } from '../util/tile'; 5 | import { MAX_POS } from '../constant'; 6 | import Tile from './Tile'; 7 | 8 | export default function Game({ setScore }) { 9 | const [tileList, setTileList] = useState(getInitialTileList); 10 | useMoveTile({ tileList, setTileList, setScore }); 11 | return ( 12 |
13 |
14 | {times(MAX_POS, y => ( 15 |
16 | {times(MAX_POS, x => ( 17 |
18 | ))} 19 |
20 | ))} 21 |
22 | 23 |
24 | {tileList.map(item => ( 25 | 26 | ))} 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /game2048/final/src/component/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Header({ score, bestScore }) { 4 | return ( 5 |
6 |

2048

7 |
8 |
9 | {score} 10 |
11 |
{bestScore}
12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /game2048/final/src/component/Tile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | export default function Tile({ x, y, value, isMerged, isNew }) { 5 | return ( 6 |
12 |
{value}
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /game2048/final/src/constant.js: -------------------------------------------------------------------------------- 1 | export const MAX_POS = 4; 2 | -------------------------------------------------------------------------------- /game2048/final/src/hook/useLocalStorageNumber.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export default function useLocalStorageNumber(key, initialValue) { 4 | const [value, setValue] = useState(initialValue); 5 | 6 | useEffect(() => { 7 | const valueStr = window.localStorage.getItem(key); 8 | if (valueStr) { 9 | setValue(Number(valueStr)); 10 | } 11 | }, [key]); 12 | 13 | useEffect(() => { 14 | const prev = window.localStorage.getItem(key); 15 | const next = String(value); 16 | if (prev !== next) { 17 | window.localStorage.setItem(key, next); 18 | } 19 | }, [key, value]); 20 | 21 | return [value, setValue]; 22 | } 23 | -------------------------------------------------------------------------------- /game2048/final/src/hook/useMoveTile.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { makeTile, moveTile } from '../util/tile'; 3 | import { addKeyCallback, removeKeyCallback } from '../util/keyboard'; 4 | 5 | export default function useMoveTile({ tileList, setTileList, setScore }) { 6 | useEffect(() => { 7 | function moveAndAdd({ x, y }) { 8 | const newTileList = moveTile({ tileList, x, y }); 9 | const score = newTileList.reduce( 10 | (acc, item) => (item.isMerged ? acc + item.value : acc), 11 | 0, 12 | ); 13 | setScore(v => v + score); 14 | const newTile = makeTile(newTileList); 15 | newTile.isNew = true; 16 | newTileList.push(newTile); 17 | setTileList(newTileList); 18 | } 19 | 20 | function moveUp() { 21 | moveAndAdd({ x: 0, y: -1 }); 22 | } 23 | function moveDown() { 24 | moveAndAdd({ x: 0, y: 1 }); 25 | } 26 | function moveLeft() { 27 | moveAndAdd({ x: -1, y: 0 }); 28 | } 29 | function moveRight() { 30 | moveAndAdd({ x: 1, y: 0 }); 31 | } 32 | addKeyCallback('up', moveUp); 33 | addKeyCallback('down', moveDown); 34 | addKeyCallback('left', moveLeft); 35 | addKeyCallback('right', moveRight); 36 | return () => { 37 | removeKeyCallback('up', moveUp); 38 | removeKeyCallback('down', moveDown); 39 | removeKeyCallback('left', moveLeft); 40 | removeKeyCallback('right', moveRight); 41 | }; 42 | }, [tileList, setTileList, setScore]); 43 | } 44 | -------------------------------------------------------------------------------- /game2048/final/src/index.css: -------------------------------------------------------------------------------- 1 | /* @import url(fonts/clear-sans.css); */ 2 | html, 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | background: #faf8ef; 7 | color: #776e65; 8 | font-family: 'Clear Sans', 'Helvetica Neue', Arial, sans-serif; 9 | font-size: 18px; 10 | } 11 | 12 | body { 13 | margin: 80px 0; 14 | } 15 | 16 | input { 17 | display: inline-block; 18 | background: #8f7a66; 19 | border-radius: 3px; 20 | padding: 0 20px; 21 | text-decoration: none; 22 | color: #f9f6f2; 23 | height: 40px; 24 | line-height: 42px; 25 | cursor: pointer; 26 | font: inherit; 27 | border: none; 28 | outline: none; 29 | box-sizing: border-box; 30 | font-weight: bold; 31 | margin: 0; 32 | -webkit-appearance: none; 33 | -moz-appearance: none; 34 | appearance: none; 35 | } 36 | input[type='text'], 37 | input[type='email'] { 38 | cursor: auto; 39 | background: #fcfbf9; 40 | font-weight: normal; 41 | color: #776e65; 42 | padding: 0 15px; 43 | } 44 | input[type='text']::-webkit-input-placeholder, 45 | input[type='email']::-webkit-input-placeholder { 46 | color: #9d948c; 47 | } 48 | input[type='text']::-moz-placeholder, 49 | input[type='email']::-moz-placeholder { 50 | color: #9d948c; 51 | } 52 | input[type='text']:-ms-input-placeholder, 53 | input[type='email']:-ms-input-placeholder { 54 | color: #9d948c; 55 | } 56 | 57 | .heading:after { 58 | content: ''; 59 | display: block; 60 | clear: both; 61 | } 62 | 63 | h1.title { 64 | font-size: 80px; 65 | font-weight: bold; 66 | margin: 0; 67 | display: block; 68 | float: left; 69 | } 70 | 71 | @-webkit-keyframes move-up { 72 | 0% { 73 | top: 25px; 74 | opacity: 1; 75 | } 76 | 77 | 100% { 78 | top: -50px; 79 | opacity: 0; 80 | } 81 | } 82 | @-moz-keyframes move-up { 83 | 0% { 84 | top: 25px; 85 | opacity: 1; 86 | } 87 | 88 | 100% { 89 | top: -50px; 90 | opacity: 0; 91 | } 92 | } 93 | @keyframes move-up { 94 | 0% { 95 | top: 25px; 96 | opacity: 1; 97 | } 98 | 99 | 100% { 100 | top: -50px; 101 | opacity: 0; 102 | } 103 | } 104 | .scores-container { 105 | float: right; 106 | text-align: right; 107 | } 108 | 109 | .score-container, 110 | .best-container { 111 | position: relative; 112 | display: inline-block; 113 | background: #bbada0; 114 | padding: 15px 25px; 115 | font-size: 25px; 116 | height: 25px; 117 | line-height: 47px; 118 | font-weight: bold; 119 | border-radius: 3px; 120 | color: white; 121 | margin-top: 8px; 122 | text-align: center; 123 | } 124 | .score-container:after, 125 | .best-container:after { 126 | position: absolute; 127 | width: 100%; 128 | top: 10px; 129 | left: 0; 130 | text-transform: uppercase; 131 | font-size: 13px; 132 | line-height: 13px; 133 | text-align: center; 134 | color: #eee4da; 135 | } 136 | .score-container .score-addition, 137 | .best-container .score-addition { 138 | position: absolute; 139 | right: 30px; 140 | color: red; 141 | font-size: 25px; 142 | line-height: 25px; 143 | font-weight: bold; 144 | color: rgba(119, 110, 101, 0.9); 145 | z-index: 100; 146 | -webkit-animation: move-up 600ms ease-in; 147 | -moz-animation: move-up 600ms ease-in; 148 | animation: move-up 600ms ease-in; 149 | -webkit-animation-fill-mode: both; 150 | -moz-animation-fill-mode: both; 151 | animation-fill-mode: both; 152 | } 153 | 154 | .score-container:after { 155 | content: 'Score'; 156 | } 157 | 158 | .best-container:after { 159 | content: 'Best'; 160 | } 161 | 162 | p { 163 | margin-top: 0; 164 | margin-bottom: 10px; 165 | line-height: 1.65; 166 | } 167 | 168 | a { 169 | color: #776e65; 170 | font-weight: bold; 171 | text-decoration: underline; 172 | cursor: pointer; 173 | } 174 | 175 | strong.important { 176 | text-transform: uppercase; 177 | } 178 | 179 | hr { 180 | border: none; 181 | border-bottom: 1px solid #d8d4d0; 182 | margin-top: 20px; 183 | margin-bottom: 30px; 184 | } 185 | 186 | .container { 187 | width: 500px; 188 | margin: 0 auto; 189 | } 190 | 191 | @-webkit-keyframes fade-in { 192 | 0% { 193 | opacity: 0; 194 | } 195 | 196 | 100% { 197 | opacity: 1; 198 | } 199 | } 200 | @-moz-keyframes fade-in { 201 | 0% { 202 | opacity: 0; 203 | } 204 | 205 | 100% { 206 | opacity: 1; 207 | } 208 | } 209 | @keyframes fade-in { 210 | 0% { 211 | opacity: 0; 212 | } 213 | 214 | 100% { 215 | opacity: 1; 216 | } 217 | } 218 | @-webkit-keyframes slide-up { 219 | 0% { 220 | margin-top: 32%; 221 | } 222 | 223 | 100% { 224 | margin-top: 20%; 225 | } 226 | } 227 | @-moz-keyframes slide-up { 228 | 0% { 229 | margin-top: 32%; 230 | } 231 | 232 | 100% { 233 | margin-top: 20%; 234 | } 235 | } 236 | @keyframes slide-up { 237 | 0% { 238 | margin-top: 32%; 239 | } 240 | 241 | 100% { 242 | margin-top: 20%; 243 | } 244 | } 245 | .game-container { 246 | margin-top: 40px; 247 | position: relative; 248 | padding: 15px; 249 | cursor: default; 250 | -webkit-touch-callout: none; 251 | -ms-touch-callout: none; 252 | -webkit-user-select: none; 253 | -moz-user-select: none; 254 | -ms-user-select: none; 255 | -ms-touch-action: none; 256 | touch-action: none; 257 | background: #bbada0; 258 | border-radius: 6px; 259 | width: 500px; 260 | height: 500px; 261 | -webkit-box-sizing: border-box; 262 | -moz-box-sizing: border-box; 263 | box-sizing: border-box; 264 | } 265 | 266 | .game-message { 267 | display: none; 268 | position: absolute; 269 | top: 0; 270 | right: 0; 271 | bottom: 0; 272 | left: 0; 273 | background: rgba(238, 228, 218, 0.73); 274 | z-index: 100; 275 | padding-top: 40px; 276 | text-align: center; 277 | -webkit-animation: fade-in 800ms ease 1200ms; 278 | -moz-animation: fade-in 800ms ease 1200ms; 279 | animation: fade-in 800ms ease 1200ms; 280 | -webkit-animation-fill-mode: both; 281 | -moz-animation-fill-mode: both; 282 | animation-fill-mode: both; 283 | } 284 | .game-message p { 285 | font-size: 60px; 286 | font-weight: bold; 287 | height: 60px; 288 | line-height: 60px; 289 | margin-top: 222px; 290 | } 291 | .game-message .lower { 292 | display: block; 293 | margin-top: 29px; 294 | } 295 | .game-message .mailing-list { 296 | margin-top: 52px; 297 | } 298 | .game-message .mailing-list strong { 299 | display: block; 300 | margin-bottom: 10px; 301 | } 302 | .game-message .mailing-list .mailing-list-email-field { 303 | width: 230px; 304 | margin-right: 5px; 305 | } 306 | .game-message a { 307 | display: inline-block; 308 | background: #8f7a66; 309 | border-radius: 3px; 310 | padding: 0 20px; 311 | text-decoration: none; 312 | color: #f9f6f2; 313 | height: 40px; 314 | line-height: 42px; 315 | cursor: pointer; 316 | margin-left: 9px; 317 | } 318 | .game-message a.keep-playing-button { 319 | display: none; 320 | } 321 | .game-message .score-sharing { 322 | display: inline-block; 323 | vertical-align: middle; 324 | margin-left: 10px; 325 | } 326 | .game-message.game-won { 327 | background: rgba(237, 194, 46, 0.5); 328 | color: #f9f6f2; 329 | } 330 | .game-message.game-won a.keep-playing-button { 331 | display: inline-block; 332 | } 333 | .game-message.game-won, 334 | .game-message.game-over { 335 | display: block; 336 | } 337 | .game-message.game-won p, 338 | .game-message.game-over p { 339 | -webkit-animation: slide-up 1.5s ease-in-out 2500ms; 340 | -moz-animation: slide-up 1.5s ease-in-out 2500ms; 341 | animation: slide-up 1.5s ease-in-out 2500ms; 342 | -webkit-animation-fill-mode: both; 343 | -moz-animation-fill-mode: both; 344 | animation-fill-mode: both; 345 | } 346 | .game-message.game-won .mailing-list, 347 | .game-message.game-over .mailing-list { 348 | -webkit-animation: fade-in 1.5s ease-in-out 2500ms; 349 | -moz-animation: fade-in 1.5s ease-in-out 2500ms; 350 | animation: fade-in 1.5s ease-in-out 2500ms; 351 | -webkit-animation-fill-mode: both; 352 | -moz-animation-fill-mode: both; 353 | animation-fill-mode: both; 354 | } 355 | 356 | .grid-container { 357 | position: absolute; 358 | z-index: 1; 359 | } 360 | 361 | .grid-row { 362 | margin-bottom: 15px; 363 | } 364 | .grid-row:last-child { 365 | margin-bottom: 0; 366 | } 367 | .grid-row:after { 368 | content: ''; 369 | display: block; 370 | clear: both; 371 | } 372 | 373 | .grid-cell { 374 | width: 106.25px; 375 | height: 106.25px; 376 | margin-right: 15px; 377 | float: left; 378 | border-radius: 3px; 379 | background: rgba(238, 228, 218, 0.35); 380 | } 381 | .grid-cell:last-child { 382 | margin-right: 0; 383 | } 384 | 385 | .tile-container { 386 | position: absolute; 387 | z-index: 2; 388 | } 389 | 390 | .tile, 391 | .tile .tile-inner { 392 | width: 107px; 393 | height: 107px; 394 | line-height: 107px; 395 | } 396 | .tile.tile-position-1-1 { 397 | -webkit-transform: translate(0px, 0px); 398 | -moz-transform: translate(0px, 0px); 399 | -ms-transform: translate(0px, 0px); 400 | transform: translate(0px, 0px); 401 | } 402 | .tile.tile-position-1-2 { 403 | -webkit-transform: translate(0px, 121px); 404 | -moz-transform: translate(0px, 121px); 405 | -ms-transform: translate(0px, 121px); 406 | transform: translate(0px, 121px); 407 | } 408 | .tile.tile-position-1-3 { 409 | -webkit-transform: translate(0px, 242px); 410 | -moz-transform: translate(0px, 242px); 411 | -ms-transform: translate(0px, 242px); 412 | transform: translate(0px, 242px); 413 | } 414 | .tile.tile-position-1-4 { 415 | -webkit-transform: translate(0px, 363px); 416 | -moz-transform: translate(0px, 363px); 417 | -ms-transform: translate(0px, 363px); 418 | transform: translate(0px, 363px); 419 | } 420 | .tile.tile-position-2-1 { 421 | -webkit-transform: translate(121px, 0px); 422 | -moz-transform: translate(121px, 0px); 423 | -ms-transform: translate(121px, 0px); 424 | transform: translate(121px, 0px); 425 | } 426 | .tile.tile-position-2-2 { 427 | -webkit-transform: translate(121px, 121px); 428 | -moz-transform: translate(121px, 121px); 429 | -ms-transform: translate(121px, 121px); 430 | transform: translate(121px, 121px); 431 | } 432 | .tile.tile-position-2-3 { 433 | -webkit-transform: translate(121px, 242px); 434 | -moz-transform: translate(121px, 242px); 435 | -ms-transform: translate(121px, 242px); 436 | transform: translate(121px, 242px); 437 | } 438 | .tile.tile-position-2-4 { 439 | -webkit-transform: translate(121px, 363px); 440 | -moz-transform: translate(121px, 363px); 441 | -ms-transform: translate(121px, 363px); 442 | transform: translate(121px, 363px); 443 | } 444 | .tile.tile-position-3-1 { 445 | -webkit-transform: translate(242px, 0px); 446 | -moz-transform: translate(242px, 0px); 447 | -ms-transform: translate(242px, 0px); 448 | transform: translate(242px, 0px); 449 | } 450 | .tile.tile-position-3-2 { 451 | -webkit-transform: translate(242px, 121px); 452 | -moz-transform: translate(242px, 121px); 453 | -ms-transform: translate(242px, 121px); 454 | transform: translate(242px, 121px); 455 | } 456 | .tile.tile-position-3-3 { 457 | -webkit-transform: translate(242px, 242px); 458 | -moz-transform: translate(242px, 242px); 459 | -ms-transform: translate(242px, 242px); 460 | transform: translate(242px, 242px); 461 | } 462 | .tile.tile-position-3-4 { 463 | -webkit-transform: translate(242px, 363px); 464 | -moz-transform: translate(242px, 363px); 465 | -ms-transform: translate(242px, 363px); 466 | transform: translate(242px, 363px); 467 | } 468 | .tile.tile-position-4-1 { 469 | -webkit-transform: translate(363px, 0px); 470 | -moz-transform: translate(363px, 0px); 471 | -ms-transform: translate(363px, 0px); 472 | transform: translate(363px, 0px); 473 | } 474 | .tile.tile-position-4-2 { 475 | -webkit-transform: translate(363px, 121px); 476 | -moz-transform: translate(363px, 121px); 477 | -ms-transform: translate(363px, 121px); 478 | transform: translate(363px, 121px); 479 | } 480 | .tile.tile-position-4-3 { 481 | -webkit-transform: translate(363px, 242px); 482 | -moz-transform: translate(363px, 242px); 483 | -ms-transform: translate(363px, 242px); 484 | transform: translate(363px, 242px); 485 | } 486 | .tile.tile-position-4-4 { 487 | -webkit-transform: translate(363px, 363px); 488 | -moz-transform: translate(363px, 363px); 489 | -ms-transform: translate(363px, 363px); 490 | transform: translate(363px, 363px); 491 | } 492 | 493 | .tile { 494 | position: absolute; 495 | -webkit-transition: 100ms ease-in-out; 496 | -moz-transition: 100ms ease-in-out; 497 | transition: 100ms ease-in-out; 498 | -webkit-transition-property: -webkit-transform; 499 | -moz-transition-property: -moz-transform; 500 | transition-property: transform; 501 | } 502 | .tile .tile-inner { 503 | border-radius: 3px; 504 | background: #eee4da; 505 | text-align: center; 506 | font-weight: bold; 507 | z-index: 10; 508 | font-size: 55px; 509 | } 510 | .tile.tile-2 .tile-inner { 511 | background: #eee4da; 512 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0), 513 | inset 0 0 0 1px rgba(255, 255, 255, 0); 514 | } 515 | .tile.tile-4 .tile-inner { 516 | background: #ede0c8; 517 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0), 518 | inset 0 0 0 1px rgba(255, 255, 255, 0); 519 | } 520 | .tile.tile-8 .tile-inner { 521 | color: #f9f6f2; 522 | background: #f2b179; 523 | } 524 | .tile.tile-16 .tile-inner { 525 | color: #f9f6f2; 526 | background: #f59563; 527 | } 528 | .tile.tile-32 .tile-inner { 529 | color: #f9f6f2; 530 | background: #f67c5f; 531 | } 532 | .tile.tile-64 .tile-inner { 533 | color: #f9f6f2; 534 | background: #f65e3b; 535 | } 536 | .tile.tile-128 .tile-inner { 537 | color: #f9f6f2; 538 | background: #edcf72; 539 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.2381), 540 | inset 0 0 0 1px rgba(255, 255, 255, 0.14286); 541 | font-size: 45px; 542 | } 543 | @media screen and (max-width: 520px) { 544 | .tile.tile-128 .tile-inner { 545 | font-size: 25px; 546 | } 547 | } 548 | .tile.tile-256 .tile-inner { 549 | color: #f9f6f2; 550 | background: #edcc61; 551 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.31746), 552 | inset 0 0 0 1px rgba(255, 255, 255, 0.19048); 553 | font-size: 45px; 554 | } 555 | @media screen and (max-width: 520px) { 556 | .tile.tile-256 .tile-inner { 557 | font-size: 25px; 558 | } 559 | } 560 | .tile.tile-512 .tile-inner { 561 | color: #f9f6f2; 562 | background: #edc850; 563 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.39683), 564 | inset 0 0 0 1px rgba(255, 255, 255, 0.2381); 565 | font-size: 45px; 566 | } 567 | @media screen and (max-width: 520px) { 568 | .tile.tile-512 .tile-inner { 569 | font-size: 25px; 570 | } 571 | } 572 | .tile.tile-1024 .tile-inner { 573 | color: #f9f6f2; 574 | background: #edc53f; 575 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.47619), 576 | inset 0 0 0 1px rgba(255, 255, 255, 0.28571); 577 | font-size: 35px; 578 | } 579 | @media screen and (max-width: 520px) { 580 | .tile.tile-1024 .tile-inner { 581 | font-size: 15px; 582 | } 583 | } 584 | .tile.tile-2048 .tile-inner { 585 | color: #f9f6f2; 586 | background: #edc22e; 587 | box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.55556), 588 | inset 0 0 0 1px rgba(255, 255, 255, 0.33333); 589 | font-size: 35px; 590 | } 591 | @media screen and (max-width: 520px) { 592 | .tile.tile-2048 .tile-inner { 593 | font-size: 15px; 594 | } 595 | } 596 | .tile.tile-super .tile-inner { 597 | color: #f9f6f2; 598 | background: #3c3a32; 599 | font-size: 30px; 600 | } 601 | @media screen and (max-width: 520px) { 602 | .tile.tile-super .tile-inner { 603 | font-size: 10px; 604 | } 605 | } 606 | 607 | @-webkit-keyframes appear { 608 | 0% { 609 | opacity: 0; 610 | -webkit-transform: scale(0); 611 | -moz-transform: scale(0); 612 | -ms-transform: scale(0); 613 | transform: scale(0); 614 | } 615 | 616 | 100% { 617 | opacity: 1; 618 | -webkit-transform: scale(1); 619 | -moz-transform: scale(1); 620 | -ms-transform: scale(1); 621 | transform: scale(1); 622 | } 623 | } 624 | @-moz-keyframes appear { 625 | 0% { 626 | opacity: 0; 627 | -webkit-transform: scale(0); 628 | -moz-transform: scale(0); 629 | -ms-transform: scale(0); 630 | transform: scale(0); 631 | } 632 | 633 | 100% { 634 | opacity: 1; 635 | -webkit-transform: scale(1); 636 | -moz-transform: scale(1); 637 | -ms-transform: scale(1); 638 | transform: scale(1); 639 | } 640 | } 641 | @keyframes appear { 642 | 0% { 643 | opacity: 0; 644 | -webkit-transform: scale(0); 645 | -moz-transform: scale(0); 646 | -ms-transform: scale(0); 647 | transform: scale(0); 648 | } 649 | 650 | 100% { 651 | opacity: 1; 652 | -webkit-transform: scale(1); 653 | -moz-transform: scale(1); 654 | -ms-transform: scale(1); 655 | transform: scale(1); 656 | } 657 | } 658 | .tile-new .tile-inner { 659 | -webkit-animation: appear 200ms ease 100ms; 660 | -moz-animation: appear 200ms ease 100ms; 661 | animation: appear 200ms ease 100ms; 662 | -webkit-animation-fill-mode: backwards; 663 | -moz-animation-fill-mode: backwards; 664 | animation-fill-mode: backwards; 665 | } 666 | 667 | @-webkit-keyframes pop { 668 | 0% { 669 | -webkit-transform: scale(0); 670 | -moz-transform: scale(0); 671 | -ms-transform: scale(0); 672 | transform: scale(0); 673 | } 674 | 675 | 50% { 676 | -webkit-transform: scale(1.2); 677 | -moz-transform: scale(1.2); 678 | -ms-transform: scale(1.2); 679 | transform: scale(1.2); 680 | } 681 | 682 | 100% { 683 | -webkit-transform: scale(1); 684 | -moz-transform: scale(1); 685 | -ms-transform: scale(1); 686 | transform: scale(1); 687 | } 688 | } 689 | @-moz-keyframes pop { 690 | 0% { 691 | -webkit-transform: scale(0); 692 | -moz-transform: scale(0); 693 | -ms-transform: scale(0); 694 | transform: scale(0); 695 | } 696 | 697 | 50% { 698 | -webkit-transform: scale(1.2); 699 | -moz-transform: scale(1.2); 700 | -ms-transform: scale(1.2); 701 | transform: scale(1.2); 702 | } 703 | 704 | 100% { 705 | -webkit-transform: scale(1); 706 | -moz-transform: scale(1); 707 | -ms-transform: scale(1); 708 | transform: scale(1); 709 | } 710 | } 711 | @keyframes pop { 712 | 0% { 713 | -webkit-transform: scale(0); 714 | -moz-transform: scale(0); 715 | -ms-transform: scale(0); 716 | transform: scale(0); 717 | } 718 | 719 | 50% { 720 | -webkit-transform: scale(1.2); 721 | -moz-transform: scale(1.2); 722 | -ms-transform: scale(1.2); 723 | transform: scale(1.2); 724 | } 725 | 726 | 100% { 727 | -webkit-transform: scale(1); 728 | -moz-transform: scale(1); 729 | -ms-transform: scale(1); 730 | transform: scale(1); 731 | } 732 | } 733 | .tile-merged .tile-inner { 734 | z-index: 20; 735 | -webkit-animation: pop 200ms ease 100ms; 736 | -moz-animation: pop 200ms ease 100ms; 737 | animation: pop 200ms ease 100ms; 738 | -webkit-animation-fill-mode: backwards; 739 | -moz-animation-fill-mode: backwards; 740 | animation-fill-mode: backwards; 741 | } 742 | 743 | .above-game:after { 744 | content: ''; 745 | display: block; 746 | clear: both; 747 | } 748 | 749 | .game-intro { 750 | float: left; 751 | line-height: 42px; 752 | margin-bottom: 0; 753 | } 754 | 755 | .restart-button { 756 | display: inline-block; 757 | background: #8f7a66; 758 | border-radius: 3px; 759 | padding: 0 20px; 760 | text-decoration: none; 761 | color: #f9f6f2; 762 | height: 40px; 763 | line-height: 42px; 764 | cursor: pointer; 765 | display: block; 766 | text-align: center; 767 | float: right; 768 | } 769 | 770 | .game-explanation { 771 | margin-top: 30px; 772 | } 773 | 774 | .sharing { 775 | margin-top: 20px; 776 | text-align: center; 777 | } 778 | .sharing > iframe, 779 | .sharing > span, 780 | .sharing > form { 781 | display: inline-block; 782 | vertical-align: middle; 783 | } 784 | 785 | @media screen and (max-width: 520px) { 786 | html, 787 | body { 788 | font-size: 15px; 789 | } 790 | 791 | body { 792 | margin-top: 0; 793 | padding: 20px; 794 | } 795 | 796 | h1.title { 797 | font-size: 27px; 798 | margin-top: 15px; 799 | } 800 | 801 | .container { 802 | width: 280px; 803 | margin: 0 auto; 804 | } 805 | 806 | .score-container, 807 | .best-container { 808 | margin-top: 0; 809 | padding: 15px 10px; 810 | min-width: 40px; 811 | } 812 | 813 | .heading { 814 | margin-bottom: 10px; 815 | } 816 | 817 | .game-intro { 818 | width: 55%; 819 | display: block; 820 | box-sizing: border-box; 821 | line-height: 1.65; 822 | } 823 | 824 | .restart-button { 825 | width: 42%; 826 | padding: 0; 827 | display: block; 828 | box-sizing: border-box; 829 | margin-top: 2px; 830 | } 831 | 832 | .game-container { 833 | margin-top: 17px; 834 | position: relative; 835 | padding: 10px; 836 | cursor: default; 837 | -webkit-touch-callout: none; 838 | -ms-touch-callout: none; 839 | -webkit-user-select: none; 840 | -moz-user-select: none; 841 | -ms-user-select: none; 842 | -ms-touch-action: none; 843 | touch-action: none; 844 | background: #bbada0; 845 | border-radius: 6px; 846 | width: 280px; 847 | height: 280px; 848 | -webkit-box-sizing: border-box; 849 | -moz-box-sizing: border-box; 850 | box-sizing: border-box; 851 | } 852 | 853 | .game-message { 854 | display: none; 855 | position: absolute; 856 | top: 0; 857 | right: 0; 858 | bottom: 0; 859 | left: 0; 860 | background: rgba(238, 228, 218, 0.73); 861 | z-index: 100; 862 | padding-top: 40px; 863 | text-align: center; 864 | -webkit-animation: fade-in 800ms ease 1200ms; 865 | -moz-animation: fade-in 800ms ease 1200ms; 866 | animation: fade-in 800ms ease 1200ms; 867 | -webkit-animation-fill-mode: both; 868 | -moz-animation-fill-mode: both; 869 | animation-fill-mode: both; 870 | } 871 | .game-message p { 872 | font-size: 60px; 873 | font-weight: bold; 874 | height: 60px; 875 | line-height: 60px; 876 | margin-top: 222px; 877 | } 878 | .game-message .lower { 879 | display: block; 880 | margin-top: 29px; 881 | } 882 | .game-message .mailing-list { 883 | margin-top: 52px; 884 | } 885 | .game-message .mailing-list strong { 886 | display: block; 887 | margin-bottom: 10px; 888 | } 889 | .game-message .mailing-list .mailing-list-email-field { 890 | width: 230px; 891 | margin-right: 5px; 892 | } 893 | .game-message a { 894 | display: inline-block; 895 | background: #8f7a66; 896 | border-radius: 3px; 897 | padding: 0 20px; 898 | text-decoration: none; 899 | color: #f9f6f2; 900 | height: 40px; 901 | line-height: 42px; 902 | cursor: pointer; 903 | margin-left: 9px; 904 | } 905 | .game-message a.keep-playing-button { 906 | display: none; 907 | } 908 | .game-message .score-sharing { 909 | display: inline-block; 910 | vertical-align: middle; 911 | margin-left: 10px; 912 | } 913 | .game-message.game-won { 914 | background: rgba(237, 194, 46, 0.5); 915 | color: #f9f6f2; 916 | } 917 | .game-message.game-won a.keep-playing-button { 918 | display: inline-block; 919 | } 920 | .game-message.game-won, 921 | .game-message.game-over { 922 | display: block; 923 | } 924 | .game-message.game-won p, 925 | .game-message.game-over p { 926 | -webkit-animation: slide-up 1.5s ease-in-out 2500ms; 927 | -moz-animation: slide-up 1.5s ease-in-out 2500ms; 928 | animation: slide-up 1.5s ease-in-out 2500ms; 929 | -webkit-animation-fill-mode: both; 930 | -moz-animation-fill-mode: both; 931 | animation-fill-mode: both; 932 | } 933 | .game-message.game-won .mailing-list, 934 | .game-message.game-over .mailing-list { 935 | -webkit-animation: fade-in 1.5s ease-in-out 2500ms; 936 | -moz-animation: fade-in 1.5s ease-in-out 2500ms; 937 | animation: fade-in 1.5s ease-in-out 2500ms; 938 | -webkit-animation-fill-mode: both; 939 | -moz-animation-fill-mode: both; 940 | animation-fill-mode: both; 941 | } 942 | 943 | .grid-container { 944 | position: absolute; 945 | z-index: 1; 946 | } 947 | 948 | .grid-row { 949 | margin-bottom: 10px; 950 | } 951 | .grid-row:last-child { 952 | margin-bottom: 0; 953 | } 954 | .grid-row:after { 955 | content: ''; 956 | display: block; 957 | clear: both; 958 | } 959 | 960 | .grid-cell { 961 | width: 57.5px; 962 | height: 57.5px; 963 | margin-right: 10px; 964 | float: left; 965 | border-radius: 3px; 966 | background: rgba(238, 228, 218, 0.35); 967 | } 968 | .grid-cell:last-child { 969 | margin-right: 0; 970 | } 971 | 972 | .tile-container { 973 | position: absolute; 974 | z-index: 2; 975 | } 976 | 977 | .tile, 978 | .tile .tile-inner { 979 | width: 58px; 980 | height: 58px; 981 | line-height: 58px; 982 | } 983 | .tile.tile-position-1-1 { 984 | -webkit-transform: translate(0px, 0px); 985 | -moz-transform: translate(0px, 0px); 986 | -ms-transform: translate(0px, 0px); 987 | transform: translate(0px, 0px); 988 | } 989 | .tile.tile-position-1-2 { 990 | -webkit-transform: translate(0px, 67px); 991 | -moz-transform: translate(0px, 67px); 992 | -ms-transform: translate(0px, 67px); 993 | transform: translate(0px, 67px); 994 | } 995 | .tile.tile-position-1-3 { 996 | -webkit-transform: translate(0px, 135px); 997 | -moz-transform: translate(0px, 135px); 998 | -ms-transform: translate(0px, 135px); 999 | transform: translate(0px, 135px); 1000 | } 1001 | .tile.tile-position-1-4 { 1002 | -webkit-transform: translate(0px, 202px); 1003 | -moz-transform: translate(0px, 202px); 1004 | -ms-transform: translate(0px, 202px); 1005 | transform: translate(0px, 202px); 1006 | } 1007 | .tile.tile-position-2-1 { 1008 | -webkit-transform: translate(67px, 0px); 1009 | -moz-transform: translate(67px, 0px); 1010 | -ms-transform: translate(67px, 0px); 1011 | transform: translate(67px, 0px); 1012 | } 1013 | .tile.tile-position-2-2 { 1014 | -webkit-transform: translate(67px, 67px); 1015 | -moz-transform: translate(67px, 67px); 1016 | -ms-transform: translate(67px, 67px); 1017 | transform: translate(67px, 67px); 1018 | } 1019 | .tile.tile-position-2-3 { 1020 | -webkit-transform: translate(67px, 135px); 1021 | -moz-transform: translate(67px, 135px); 1022 | -ms-transform: translate(67px, 135px); 1023 | transform: translate(67px, 135px); 1024 | } 1025 | .tile.tile-position-2-4 { 1026 | -webkit-transform: translate(67px, 202px); 1027 | -moz-transform: translate(67px, 202px); 1028 | -ms-transform: translate(67px, 202px); 1029 | transform: translate(67px, 202px); 1030 | } 1031 | .tile.tile-position-3-1 { 1032 | -webkit-transform: translate(135px, 0px); 1033 | -moz-transform: translate(135px, 0px); 1034 | -ms-transform: translate(135px, 0px); 1035 | transform: translate(135px, 0px); 1036 | } 1037 | .tile.tile-position-3-2 { 1038 | -webkit-transform: translate(135px, 67px); 1039 | -moz-transform: translate(135px, 67px); 1040 | -ms-transform: translate(135px, 67px); 1041 | transform: translate(135px, 67px); 1042 | } 1043 | .tile.tile-position-3-3 { 1044 | -webkit-transform: translate(135px, 135px); 1045 | -moz-transform: translate(135px, 135px); 1046 | -ms-transform: translate(135px, 135px); 1047 | transform: translate(135px, 135px); 1048 | } 1049 | .tile.tile-position-3-4 { 1050 | -webkit-transform: translate(135px, 202px); 1051 | -moz-transform: translate(135px, 202px); 1052 | -ms-transform: translate(135px, 202px); 1053 | transform: translate(135px, 202px); 1054 | } 1055 | .tile.tile-position-4-1 { 1056 | -webkit-transform: translate(202px, 0px); 1057 | -moz-transform: translate(202px, 0px); 1058 | -ms-transform: translate(202px, 0px); 1059 | transform: translate(202px, 0px); 1060 | } 1061 | .tile.tile-position-4-2 { 1062 | -webkit-transform: translate(202px, 67px); 1063 | -moz-transform: translate(202px, 67px); 1064 | -ms-transform: translate(202px, 67px); 1065 | transform: translate(202px, 67px); 1066 | } 1067 | .tile.tile-position-4-3 { 1068 | -webkit-transform: translate(202px, 135px); 1069 | -moz-transform: translate(202px, 135px); 1070 | -ms-transform: translate(202px, 135px); 1071 | transform: translate(202px, 135px); 1072 | } 1073 | .tile.tile-position-4-4 { 1074 | -webkit-transform: translate(202px, 202px); 1075 | -moz-transform: translate(202px, 202px); 1076 | -ms-transform: translate(202px, 202px); 1077 | transform: translate(202px, 202px); 1078 | } 1079 | 1080 | .tile .tile-inner { 1081 | font-size: 35px; 1082 | } 1083 | 1084 | .game-message { 1085 | padding-top: 0; 1086 | } 1087 | .game-message p { 1088 | font-size: 30px !important; 1089 | height: 30px !important; 1090 | line-height: 30px !important; 1091 | margin-top: 32% !important; 1092 | margin-bottom: 0 !important; 1093 | } 1094 | .game-message .lower { 1095 | margin-top: 10px !important; 1096 | } 1097 | .game-message.game-won .score-sharing { 1098 | margin-top: 10px; 1099 | } 1100 | .game-message.game-over .mailing-list { 1101 | margin-top: 25px; 1102 | } 1103 | .game-message .mailing-list { 1104 | margin-top: 10px; 1105 | } 1106 | .game-message .mailing-list .mailing-list-email-field { 1107 | width: 180px; 1108 | } 1109 | 1110 | .sharing > iframe, 1111 | .sharing > span, 1112 | .sharing > form { 1113 | display: block; 1114 | margin: 0 auto; 1115 | margin-bottom: 20px; 1116 | } 1117 | } 1118 | .pp-donate button { 1119 | -webkit-appearance: none; 1120 | -moz-appearance: none; 1121 | appearance: none; 1122 | border: none; 1123 | font: inherit; 1124 | color: inherit; 1125 | display: inline-block; 1126 | background: #8f7a66; 1127 | border-radius: 3px; 1128 | padding: 0 20px; 1129 | text-decoration: none; 1130 | color: #f9f6f2; 1131 | height: 40px; 1132 | line-height: 42px; 1133 | cursor: pointer; 1134 | } 1135 | .pp-donate button img { 1136 | vertical-align: -4px; 1137 | margin-right: 8px; 1138 | } 1139 | 1140 | .btc-donate { 1141 | position: relative; 1142 | margin-left: 10px; 1143 | display: inline-block; 1144 | background: #8f7a66; 1145 | border-radius: 3px; 1146 | padding: 0 20px; 1147 | text-decoration: none; 1148 | color: #f9f6f2; 1149 | height: 40px; 1150 | line-height: 42px; 1151 | cursor: pointer; 1152 | } 1153 | .btc-donate img { 1154 | vertical-align: -4px; 1155 | margin-right: 8px; 1156 | } 1157 | .btc-donate a { 1158 | color: #f9f6f2; 1159 | text-decoration: none; 1160 | font-weight: normal; 1161 | } 1162 | .btc-donate .address { 1163 | cursor: auto; 1164 | position: absolute; 1165 | width: 340px; 1166 | right: 50%; 1167 | margin-right: -170px; 1168 | padding-bottom: 7px; 1169 | top: -30px; 1170 | opacity: 0; 1171 | pointer-events: none; 1172 | -webkit-transition: 400ms ease; 1173 | -moz-transition: 400ms ease; 1174 | transition: 400ms ease; 1175 | -webkit-transition-property: top, opacity; 1176 | -moz-transition-property: top, opacity; 1177 | transition-property: top, opacity; 1178 | } 1179 | .btc-donate .address:after { 1180 | position: absolute; 1181 | border-top: 10px solid #bbada0; 1182 | border-right: 7px solid transparent; 1183 | border-left: 7px solid transparent; 1184 | content: ''; 1185 | bottom: 0px; 1186 | left: 50%; 1187 | margin-left: -7px; 1188 | } 1189 | .btc-donate .address code { 1190 | background-color: #bbada0; 1191 | padding: 10px 15px; 1192 | width: 100%; 1193 | border-radius: 3px; 1194 | line-height: 1; 1195 | font-weight: normal; 1196 | font-size: 15px; 1197 | font-family: Consolas, 'Liberation Mono', Courier, monospace; 1198 | text-align: center; 1199 | } 1200 | .btc-donate:hover .address, 1201 | .btc-donate .address:hover .address { 1202 | opacity: 1; 1203 | top: -45px; 1204 | pointer-events: auto; 1205 | } 1206 | @media screen and (max-width: 520px) { 1207 | .btc-donate { 1208 | width: 120px; 1209 | } 1210 | .btc-donate .address { 1211 | margin-right: -150px; 1212 | width: 300px; 1213 | } 1214 | .btc-donate .address code { 1215 | font-size: 13px; 1216 | } 1217 | .btc-donate .address:after { 1218 | left: 50%; 1219 | bottom: 2px; 1220 | } 1221 | } 1222 | 1223 | @-webkit-keyframes pop-in-big { 1224 | 0% { 1225 | -webkit-transform: scale(0) translateZ(0); 1226 | -moz-transform: scale(0) translateZ(0); 1227 | transform: scale(0) translateZ(0); 1228 | opacity: 0; 1229 | margin-top: -40px; 1230 | } 1231 | 1232 | 100% { 1233 | -webkit-transform: scale(1) translateZ(0); 1234 | -moz-transform: scale(1) translateZ(0); 1235 | transform: scale(1) translateZ(0); 1236 | opacity: 1; 1237 | margin-top: 30px; 1238 | } 1239 | } 1240 | @-moz-keyframes pop-in-big { 1241 | 0% { 1242 | -webkit-transform: scale(0) translateZ(0); 1243 | -moz-transform: scale(0) translateZ(0); 1244 | transform: scale(0) translateZ(0); 1245 | opacity: 0; 1246 | margin-top: -40px; 1247 | } 1248 | 1249 | 100% { 1250 | -webkit-transform: scale(1) translateZ(0); 1251 | -moz-transform: scale(1) translateZ(0); 1252 | transform: scale(1) translateZ(0); 1253 | opacity: 1; 1254 | margin-top: 30px; 1255 | } 1256 | } 1257 | @keyframes pop-in-big { 1258 | 0% { 1259 | -webkit-transform: scale(0) translateZ(0); 1260 | -moz-transform: scale(0) translateZ(0); 1261 | transform: scale(0) translateZ(0); 1262 | opacity: 0; 1263 | margin-top: -40px; 1264 | } 1265 | 1266 | 100% { 1267 | -webkit-transform: scale(1) translateZ(0); 1268 | -moz-transform: scale(1) translateZ(0); 1269 | transform: scale(1) translateZ(0); 1270 | opacity: 1; 1271 | margin-top: 30px; 1272 | } 1273 | } 1274 | @-webkit-keyframes pop-in-small { 1275 | 0% { 1276 | -webkit-transform: scale(0) translateZ(0); 1277 | -moz-transform: scale(0) translateZ(0); 1278 | transform: scale(0) translateZ(0); 1279 | opacity: 0; 1280 | margin-top: -40px; 1281 | } 1282 | 1283 | 100% { 1284 | -webkit-transform: scale(1) translateZ(0); 1285 | -moz-transform: scale(1) translateZ(0); 1286 | transform: scale(1) translateZ(0); 1287 | opacity: 1; 1288 | margin-top: 10px; 1289 | } 1290 | } 1291 | @-moz-keyframes pop-in-small { 1292 | 0% { 1293 | -webkit-transform: scale(0) translateZ(0); 1294 | -moz-transform: scale(0) translateZ(0); 1295 | transform: scale(0) translateZ(0); 1296 | opacity: 0; 1297 | margin-top: -40px; 1298 | } 1299 | 1300 | 100% { 1301 | -webkit-transform: scale(1) translateZ(0); 1302 | -moz-transform: scale(1) translateZ(0); 1303 | transform: scale(1) translateZ(0); 1304 | opacity: 1; 1305 | margin-top: 10px; 1306 | } 1307 | } 1308 | @keyframes pop-in-small { 1309 | 0% { 1310 | -webkit-transform: scale(0) translateZ(0); 1311 | -moz-transform: scale(0) translateZ(0); 1312 | transform: scale(0) translateZ(0); 1313 | opacity: 0; 1314 | margin-top: -40px; 1315 | } 1316 | 1317 | 100% { 1318 | -webkit-transform: scale(1) translateZ(0); 1319 | -moz-transform: scale(1) translateZ(0); 1320 | transform: scale(1) translateZ(0); 1321 | opacity: 1; 1322 | margin-top: 10px; 1323 | } 1324 | } 1325 | .app-notice { 1326 | position: relative; 1327 | -webkit-animation: pop-in-big 700ms ease 2s both; 1328 | -moz-animation: pop-in-big 700ms ease 2s both; 1329 | animation: pop-in-big 700ms ease 2s both; 1330 | background: #edc53f; 1331 | color: white; 1332 | padding: 10px; 1333 | margin-top: 30px; 1334 | height: 40px; 1335 | box-sizing: border-box; 1336 | border-radius: 3px; 1337 | } 1338 | .app-notice:after { 1339 | content: ''; 1340 | display: block; 1341 | clear: both; 1342 | } 1343 | .app-notice .notice-close-button { 1344 | float: right; 1345 | font-weight: bold; 1346 | cursor: pointer; 1347 | margin-left: 10px; 1348 | opacity: 0.7; 1349 | } 1350 | .app-notice p { 1351 | margin-bottom: 0; 1352 | } 1353 | .app-notice, 1354 | .app-notice p { 1355 | line-height: 20px; 1356 | } 1357 | .app-notice a { 1358 | color: white; 1359 | } 1360 | @media screen and (max-width: 520px) { 1361 | .app-notice { 1362 | -webkit-animation: pop-in-small 700ms ease 2s both; 1363 | -moz-animation: pop-in-small 700ms ease 2s both; 1364 | animation: pop-in-small 700ms ease 2s both; 1365 | margin-top: 10px; 1366 | height: 40px; 1367 | } 1368 | } 1369 | 1370 | .links { 1371 | text-align: center; 1372 | margin-top: 20px; 1373 | } 1374 | 1375 | .privacy { 1376 | word-wrap: break-word; 1377 | } 1378 | 1379 | /* extras */ 1380 | .sidebar { 1381 | width: 180px; 1382 | top: 0; 1383 | bottom: 0; 1384 | right: 0; 1385 | position: fixed; 1386 | display: flex; 1387 | align-items: center; 1388 | justify-content: center; 1389 | } 1390 | 1391 | @media (max-width: 880px) { 1392 | .sidebar, 1393 | .sidebar .adsbygoogle { 1394 | display: none; 1395 | } 1396 | } 1397 | 1398 | .under-board-container { 1399 | margin-top: 38px; 1400 | } 1401 | 1402 | .under-board-container, 1403 | .under-board-container .adsbygoogle { 1404 | width: 100%; 1405 | height: 80px; 1406 | display: none; 1407 | } 1408 | 1409 | @media (max-width: 880px) { 1410 | .under-board-container, 1411 | .under-board-container .adsbygoogle { 1412 | display: block; 1413 | } 1414 | } 1415 | 1416 | .cookie-notice { 1417 | position: fixed; 1418 | font-size: 15px; 1419 | z-index: 999; 1420 | right: 20px; 1421 | bottom: 20px; 1422 | width: 20%; 1423 | min-width: 460px; 1424 | background: #e8e5db; 1425 | padding: 10px; 1426 | margin-top: 30px; 1427 | box-sizing: border-box; 1428 | border-radius: 3px; 1429 | display: flex; 1430 | align-items: center; 1431 | justify-content: center; 1432 | } 1433 | @media screen and (max-width: 520px) { 1434 | .cookie-notice { 1435 | width: auto; 1436 | left: 20px; 1437 | min-width: auto; 1438 | } 1439 | } 1440 | .cookie-notice, 1441 | .cookie-notice p a { 1442 | color: #a09488; 1443 | } 1444 | .cookie-notice p { 1445 | margin-bottom: 0; 1446 | flex: 1; 1447 | } 1448 | .cookie-notice, 1449 | .cookie-notice p { 1450 | line-height: 20px; 1451 | } 1452 | 1453 | .cookie-notice-dismiss-button { 1454 | display: inline-block; 1455 | background: #8f7a66; 1456 | border-radius: 3px; 1457 | padding: 0 20px; 1458 | text-decoration: none; 1459 | color: #f9f6f2; 1460 | height: 40px; 1461 | line-height: 42px; 1462 | cursor: pointer; 1463 | flex: 0 0 auto; 1464 | margin-left: 20px; 1465 | } 1466 | -------------------------------------------------------------------------------- /game2048/final/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /game2048/final/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /game2048/final/src/util/__snapshots__/tile.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`moveTile 가로 2 4 8 8 x=-1 1`] = ` 4 | Array [ 5 | Object { 6 | "value": 2, 7 | "x": 1, 8 | "y": 1, 9 | }, 10 | Object { 11 | "value": 4, 12 | "x": 2, 13 | "y": 1, 14 | }, 15 | Object { 16 | "isDisabled": true, 17 | "value": 8, 18 | "x": 3, 19 | "y": 1, 20 | }, 21 | Object { 22 | "isDisabled": true, 23 | "value": 8, 24 | "x": 3, 25 | "y": 1, 26 | }, 27 | Object { 28 | "isMerged": true, 29 | "value": 16, 30 | "x": 3, 31 | "y": 1, 32 | }, 33 | ] 34 | `; 35 | 36 | exports[`moveTile 가로 2 4 8 8 x=1 1`] = ` 37 | Array [ 38 | Object { 39 | "isDisabled": true, 40 | "value": 8, 41 | "x": 4, 42 | "y": 1, 43 | }, 44 | Object { 45 | "isDisabled": true, 46 | "value": 8, 47 | "x": 4, 48 | "y": 1, 49 | }, 50 | Object { 51 | "value": 4, 52 | "x": 3, 53 | "y": 1, 54 | }, 55 | Object { 56 | "value": 2, 57 | "x": 2, 58 | "y": 1, 59 | }, 60 | Object { 61 | "isMerged": true, 62 | "value": 16, 63 | "x": 4, 64 | "y": 1, 65 | }, 66 | ] 67 | `; 68 | 69 | exports[`moveTile 가로 2 x 8 8 x=-1 1`] = ` 70 | Array [ 71 | Object { 72 | "value": 2, 73 | "x": 1, 74 | "y": 1, 75 | }, 76 | Object { 77 | "isDisabled": true, 78 | "value": 8, 79 | "x": 2, 80 | "y": 1, 81 | }, 82 | Object { 83 | "isDisabled": true, 84 | "value": 8, 85 | "x": 2, 86 | "y": 1, 87 | }, 88 | Object { 89 | "isMerged": true, 90 | "value": 16, 91 | "x": 2, 92 | "y": 1, 93 | }, 94 | ] 95 | `; 96 | 97 | exports[`moveTile 가로 2 x 8 8 x=1 1`] = ` 98 | Array [ 99 | Object { 100 | "isDisabled": true, 101 | "value": 8, 102 | "x": 4, 103 | "y": 1, 104 | }, 105 | Object { 106 | "isDisabled": true, 107 | "value": 8, 108 | "x": 4, 109 | "y": 1, 110 | }, 111 | Object { 112 | "value": 2, 113 | "x": 3, 114 | "y": 1, 115 | }, 116 | Object { 117 | "isMerged": true, 118 | "value": 16, 119 | "x": 4, 120 | "y": 1, 121 | }, 122 | ] 123 | `; 124 | 125 | exports[`moveTile 가로 8 8 4 4 x=-1 1`] = ` 126 | Array [ 127 | Object { 128 | "isDisabled": true, 129 | "value": 8, 130 | "x": 1, 131 | "y": 1, 132 | }, 133 | Object { 134 | "isDisabled": true, 135 | "value": 8, 136 | "x": 1, 137 | "y": 1, 138 | }, 139 | Object { 140 | "isDisabled": true, 141 | "value": 4, 142 | "x": 2, 143 | "y": 1, 144 | }, 145 | Object { 146 | "isDisabled": true, 147 | "value": 4, 148 | "x": 2, 149 | "y": 1, 150 | }, 151 | Object { 152 | "isMerged": true, 153 | "value": 16, 154 | "x": 1, 155 | "y": 1, 156 | }, 157 | Object { 158 | "isMerged": true, 159 | "value": 8, 160 | "x": 2, 161 | "y": 1, 162 | }, 163 | ] 164 | `; 165 | 166 | exports[`moveTile 가로 8 8 4 4 x=1 1`] = ` 167 | Array [ 168 | Object { 169 | "isDisabled": true, 170 | "value": 4, 171 | "x": 4, 172 | "y": 1, 173 | }, 174 | Object { 175 | "isDisabled": true, 176 | "value": 4, 177 | "x": 4, 178 | "y": 1, 179 | }, 180 | Object { 181 | "isDisabled": true, 182 | "value": 8, 183 | "x": 3, 184 | "y": 1, 185 | }, 186 | Object { 187 | "isDisabled": true, 188 | "value": 8, 189 | "x": 3, 190 | "y": 1, 191 | }, 192 | Object { 193 | "isMerged": true, 194 | "value": 8, 195 | "x": 4, 196 | "y": 1, 197 | }, 198 | Object { 199 | "isMerged": true, 200 | "value": 16, 201 | "x": 3, 202 | "y": 1, 203 | }, 204 | ] 205 | `; 206 | 207 | exports[`moveTile 가로 8 8 4 4 y=-1 1`] = ` 208 | Array [ 209 | Object { 210 | "value": 8, 211 | "x": 1, 212 | "y": 1, 213 | }, 214 | Object { 215 | "value": 8, 216 | "x": 2, 217 | "y": 1, 218 | }, 219 | Object { 220 | "value": 4, 221 | "x": 3, 222 | "y": 1, 223 | }, 224 | Object { 225 | "value": 4, 226 | "x": 4, 227 | "y": 1, 228 | }, 229 | ] 230 | `; 231 | 232 | exports[`moveTile 가로 8 8 4 4 y=1 1`] = ` 233 | Array [ 234 | Object { 235 | "value": 8, 236 | "x": 1, 237 | "y": 4, 238 | }, 239 | Object { 240 | "value": 8, 241 | "x": 2, 242 | "y": 4, 243 | }, 244 | Object { 245 | "value": 4, 246 | "x": 3, 247 | "y": 4, 248 | }, 249 | Object { 250 | "value": 4, 251 | "x": 4, 252 | "y": 4, 253 | }, 254 | ] 255 | `; 256 | 257 | exports[`moveTile 가로 여러 줄, 2 2 4 4, 8 x 4 4 x=-1 1`] = ` 258 | Array [ 259 | Object { 260 | "isDisabled": true, 261 | "value": 2, 262 | "x": 1, 263 | "y": 1, 264 | }, 265 | Object { 266 | "isDisabled": true, 267 | "value": 2, 268 | "x": 1, 269 | "y": 1, 270 | }, 271 | Object { 272 | "isDisabled": true, 273 | "value": 4, 274 | "x": 2, 275 | "y": 1, 276 | }, 277 | Object { 278 | "isDisabled": true, 279 | "value": 4, 280 | "x": 2, 281 | "y": 1, 282 | }, 283 | Object { 284 | "value": 8, 285 | "x": 1, 286 | "y": 2, 287 | }, 288 | Object { 289 | "isDisabled": true, 290 | "value": 4, 291 | "x": 2, 292 | "y": 2, 293 | }, 294 | Object { 295 | "isDisabled": true, 296 | "value": 4, 297 | "x": 2, 298 | "y": 2, 299 | }, 300 | Object { 301 | "isMerged": true, 302 | "value": 4, 303 | "x": 1, 304 | "y": 1, 305 | }, 306 | Object { 307 | "isMerged": true, 308 | "value": 8, 309 | "x": 2, 310 | "y": 1, 311 | }, 312 | Object { 313 | "isMerged": true, 314 | "value": 8, 315 | "x": 2, 316 | "y": 2, 317 | }, 318 | ] 319 | `; 320 | 321 | exports[`moveTile 가로 여러 줄, 2 2 4 4, 8 x 4 4 x=1 1`] = ` 322 | Array [ 323 | Object { 324 | "isDisabled": true, 325 | "value": 4, 326 | "x": 4, 327 | "y": 1, 328 | }, 329 | Object { 330 | "isDisabled": true, 331 | "value": 4, 332 | "x": 4, 333 | "y": 1, 334 | }, 335 | Object { 336 | "isDisabled": true, 337 | "value": 2, 338 | "x": 3, 339 | "y": 1, 340 | }, 341 | Object { 342 | "isDisabled": true, 343 | "value": 2, 344 | "x": 3, 345 | "y": 1, 346 | }, 347 | Object { 348 | "isDisabled": true, 349 | "value": 4, 350 | "x": 4, 351 | "y": 2, 352 | }, 353 | Object { 354 | "isDisabled": true, 355 | "value": 4, 356 | "x": 4, 357 | "y": 2, 358 | }, 359 | Object { 360 | "value": 8, 361 | "x": 3, 362 | "y": 2, 363 | }, 364 | Object { 365 | "isMerged": true, 366 | "value": 8, 367 | "x": 4, 368 | "y": 1, 369 | }, 370 | Object { 371 | "isMerged": true, 372 | "value": 4, 373 | "x": 3, 374 | "y": 1, 375 | }, 376 | Object { 377 | "isMerged": true, 378 | "value": 8, 379 | "x": 4, 380 | "y": 2, 381 | }, 382 | ] 383 | `; 384 | 385 | exports[`moveTile 세로 2 x 8 8 y=-1 1`] = ` 386 | Array [ 387 | Object { 388 | "value": 2, 389 | "x": 1, 390 | "y": 1, 391 | }, 392 | Object { 393 | "isDisabled": true, 394 | "value": 8, 395 | "x": 1, 396 | "y": 2, 397 | }, 398 | Object { 399 | "isDisabled": true, 400 | "value": 8, 401 | "x": 1, 402 | "y": 2, 403 | }, 404 | Object { 405 | "isMerged": true, 406 | "value": 16, 407 | "x": 1, 408 | "y": 2, 409 | }, 410 | ] 411 | `; 412 | 413 | exports[`moveTile 세로 2 x 8 8 y=1 1`] = ` 414 | Array [ 415 | Object { 416 | "isDisabled": true, 417 | "value": 8, 418 | "x": 1, 419 | "y": 4, 420 | }, 421 | Object { 422 | "isDisabled": true, 423 | "value": 8, 424 | "x": 1, 425 | "y": 4, 426 | }, 427 | Object { 428 | "value": 2, 429 | "x": 1, 430 | "y": 3, 431 | }, 432 | Object { 433 | "isMerged": true, 434 | "value": 16, 435 | "x": 1, 436 | "y": 4, 437 | }, 438 | ] 439 | `; 440 | 441 | exports[`moveTile 세로 8 8 4 4 x=-1 1`] = ` 442 | Array [ 443 | Object { 444 | "value": 8, 445 | "x": 1, 446 | "y": 1, 447 | }, 448 | Object { 449 | "value": 8, 450 | "x": 1, 451 | "y": 2, 452 | }, 453 | Object { 454 | "value": 4, 455 | "x": 1, 456 | "y": 3, 457 | }, 458 | Object { 459 | "value": 4, 460 | "x": 1, 461 | "y": 4, 462 | }, 463 | ] 464 | `; 465 | 466 | exports[`moveTile 세로 8 8 4 4 x=1 1`] = ` 467 | Array [ 468 | Object { 469 | "value": 8, 470 | "x": 4, 471 | "y": 1, 472 | }, 473 | Object { 474 | "value": 8, 475 | "x": 4, 476 | "y": 2, 477 | }, 478 | Object { 479 | "value": 4, 480 | "x": 4, 481 | "y": 3, 482 | }, 483 | Object { 484 | "value": 4, 485 | "x": 4, 486 | "y": 4, 487 | }, 488 | ] 489 | `; 490 | 491 | exports[`moveTile 세로 8 8 4 4 y=-1 1`] = ` 492 | Array [ 493 | Object { 494 | "isDisabled": true, 495 | "value": 8, 496 | "x": 1, 497 | "y": 1, 498 | }, 499 | Object { 500 | "isDisabled": true, 501 | "value": 8, 502 | "x": 1, 503 | "y": 1, 504 | }, 505 | Object { 506 | "isDisabled": true, 507 | "value": 4, 508 | "x": 1, 509 | "y": 2, 510 | }, 511 | Object { 512 | "isDisabled": true, 513 | "value": 4, 514 | "x": 1, 515 | "y": 2, 516 | }, 517 | Object { 518 | "isMerged": true, 519 | "value": 16, 520 | "x": 1, 521 | "y": 1, 522 | }, 523 | Object { 524 | "isMerged": true, 525 | "value": 8, 526 | "x": 1, 527 | "y": 2, 528 | }, 529 | ] 530 | `; 531 | 532 | exports[`moveTile 세로 8 8 4 4 y=1 1`] = ` 533 | Array [ 534 | Object { 535 | "isDisabled": true, 536 | "value": 4, 537 | "x": 1, 538 | "y": 4, 539 | }, 540 | Object { 541 | "isDisabled": true, 542 | "value": 4, 543 | "x": 1, 544 | "y": 4, 545 | }, 546 | Object { 547 | "isDisabled": true, 548 | "value": 8, 549 | "x": 1, 550 | "y": 3, 551 | }, 552 | Object { 553 | "isDisabled": true, 554 | "value": 8, 555 | "x": 1, 556 | "y": 3, 557 | }, 558 | Object { 559 | "isMerged": true, 560 | "value": 8, 561 | "x": 1, 562 | "y": 4, 563 | }, 564 | Object { 565 | "isMerged": true, 566 | "value": 16, 567 | "x": 1, 568 | "y": 3, 569 | }, 570 | ] 571 | `; 572 | -------------------------------------------------------------------------------- /game2048/final/src/util/assert.js: -------------------------------------------------------------------------------- 1 | export const assert = function (condition, message) { 2 | if (!condition) { 3 | throw new Error(`Assertion failed: ${message}`); 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /game2048/final/src/util/keyboard.js: -------------------------------------------------------------------------------- 1 | import hotkeys from 'hotkeys-js'; 2 | 3 | const observerMap = {}; 4 | export function addKeyCallback(key, callback) { 5 | if (!observerMap[key]) { 6 | observerMap[key] = []; 7 | hotkeys(key, () => executeCallbacks(key)); 8 | } 9 | observerMap[key].push(callback); 10 | } 11 | export function removeKeyCallback(key, callback) { 12 | observerMap[key] = observerMap[key].filter(item => item !== callback); 13 | } 14 | 15 | function executeCallbacks(key) { 16 | for (const ob of observerMap[key]) { 17 | ob(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /game2048/final/src/util/number.js: -------------------------------------------------------------------------------- 1 | export function getRandomInteger(from, to) { 2 | return Math.floor(Math.random() * to + from); 3 | } 4 | -------------------------------------------------------------------------------- /game2048/final/src/util/tile.js: -------------------------------------------------------------------------------- 1 | import { getRandomInteger } from './number'; 2 | import { MAX_POS } from '../constant'; 3 | import { assert } from './assert'; 4 | 5 | export function getInitialTileList() { 6 | const tileList = []; 7 | const tile1 = makeTile(tileList); 8 | tileList.push(tile1); 9 | const tile2 = makeTile(tileList); 10 | tileList.push(tile2); 11 | return tileList; 12 | } 13 | function checkCollision(tileList, newTile) { 14 | return tileList.some(tile => tile.x === newTile.x && tile.y === newTile.y); 15 | } 16 | let currentId = 0; 17 | export function makeTile(tileList) { 18 | let newTile; 19 | while (!newTile || (tileList && checkCollision(tileList, newTile))) { 20 | newTile = { 21 | id: currentId++, 22 | x: getRandomInteger(1, MAX_POS), 23 | y: getRandomInteger(1, MAX_POS), 24 | value: 2, 25 | isNew: undefined, 26 | isMerged: undefined, 27 | }; 28 | } 29 | return newTile; 30 | } 31 | 32 | export function moveTile({ tileList, x, y }) { 33 | assert(x === 0 || y === 0, ''); 34 | const isMoveY = y !== 0; 35 | const isMinus = x + y < 0; 36 | const sorted = tileList 37 | .map(item => ({ ...item, isMerged: false, isNew: false })) 38 | .filter(item => !item.isDisabled) 39 | .sort((a, b) => { 40 | const res = isMoveY ? a.x - b.x : a.y - b.y; 41 | if (res) { 42 | return res; 43 | } else { 44 | if (isMoveY) { 45 | return isMinus ? a.y - b.y : b.y - a.y; 46 | } else { 47 | return isMinus ? a.x - b.x : b.x - a.x; 48 | } 49 | } 50 | }); 51 | const initialPos = isMinus ? 1 : MAX_POS; 52 | let pos = initialPos; 53 | for (let i = 0; i < sorted.length; i++) { 54 | if (isMoveY) { 55 | sorted[i].y = pos; 56 | pos = isMinus ? pos + 1 : pos - 1; 57 | if (sorted[i].x !== sorted[i + 1]?.x) { 58 | pos = initialPos; 59 | } 60 | } else { 61 | sorted[i].x = pos; 62 | pos = isMinus ? pos + 1 : pos - 1; 63 | if (sorted[i].y !== sorted[i + 1]?.y) { 64 | pos = initialPos; 65 | } 66 | } 67 | } 68 | 69 | let nextPos = 0; 70 | const newTileList = [...sorted]; 71 | for (let i = 0; i < sorted.length; i++) { 72 | if (sorted[i].isDisabled) { 73 | continue; 74 | } 75 | 76 | if ( 77 | nextPos && 78 | (isMoveY 79 | ? sorted[i].x === sorted[i - 1]?.x 80 | : sorted[i].y === sorted[i - 1]?.y) 81 | ) { 82 | if (isMoveY) { 83 | sorted[i].y = nextPos; 84 | } else { 85 | sorted[i].x = nextPos; 86 | } 87 | nextPos += isMinus ? 1 : -1; 88 | } else { 89 | nextPos = 0; 90 | } 91 | 92 | if ( 93 | (isMoveY 94 | ? sorted[i].x === sorted[i + 1]?.x 95 | : sorted[i].y === sorted[i + 1]?.y) && 96 | sorted[i].value === sorted[i + 1]?.value 97 | ) { 98 | const tile = makeTile(); 99 | tile.x = sorted[i].x; 100 | tile.y = sorted[i].y; 101 | tile.isMerged = true; 102 | tile.value = sorted[i].value * 2; 103 | newTileList.push(tile); 104 | sorted[i].isDisabled = true; 105 | sorted[i + 1].isDisabled = true; 106 | if (isMoveY) { 107 | nextPos = sorted[i + 1].y; 108 | sorted[i + 1].y = sorted[i].y; 109 | } else { 110 | nextPos = sorted[i + 1].x; 111 | sorted[i + 1].x = sorted[i].x; 112 | } 113 | } 114 | } 115 | return newTileList; 116 | } 117 | -------------------------------------------------------------------------------- /game2048/final/src/util/tile.test.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { moveTile } from './tile'; 3 | 4 | describe('moveTile', () => { 5 | function removeProps(tileList) { 6 | for (const tile of tileList) { 7 | delete tile.id; 8 | if (tile.isMerged === false) { 9 | delete tile.isMerged; 10 | } 11 | if (tile.isNew === false) { 12 | delete tile.isNew; 13 | } 14 | } 15 | return tileList; 16 | } 17 | describe('가로 8 8 4 4', () => { 18 | const tileList = [ 19 | { 20 | id: 11, 21 | x: 1, 22 | y: 1, 23 | value: 8, 24 | }, 25 | { 26 | id: 12, 27 | x: 2, 28 | y: 1, 29 | value: 8, 30 | }, 31 | { 32 | id: 13, 33 | x: 3, 34 | y: 1, 35 | value: 4, 36 | }, 37 | { 38 | id: 14, 39 | x: 4, 40 | y: 1, 41 | value: 4, 42 | }, 43 | ]; 44 | it('x=1', () => { 45 | expect(removeProps(moveTile({ x: 1, y: 0, tileList }))).toMatchSnapshot(); 46 | }); 47 | it('x=-1', () => { 48 | expect( 49 | removeProps(moveTile({ x: -1, y: 0, tileList })), 50 | ).toMatchSnapshot(); 51 | }); 52 | it('y=1', () => { 53 | expect(removeProps(moveTile({ x: 0, y: 1, tileList }))).toMatchSnapshot(); 54 | }); 55 | it('y=-1', () => { 56 | expect( 57 | removeProps(moveTile({ x: 0, y: -1, tileList })), 58 | ).toMatchSnapshot(); 59 | }); 60 | }); 61 | describe('세로 8 8 4 4', () => { 62 | const tileList = [ 63 | { 64 | id: 11, 65 | x: 1, 66 | y: 1, 67 | value: 8, 68 | }, 69 | { 70 | id: 12, 71 | x: 1, 72 | y: 2, 73 | value: 8, 74 | }, 75 | { 76 | id: 13, 77 | x: 1, 78 | y: 3, 79 | value: 4, 80 | }, 81 | { 82 | id: 14, 83 | x: 1, 84 | y: 4, 85 | value: 4, 86 | }, 87 | ]; 88 | it('x=1', () => { 89 | expect(removeProps(moveTile({ x: 1, y: 0, tileList }))).toMatchSnapshot(); 90 | }); 91 | it('x=-1', () => { 92 | expect( 93 | removeProps(moveTile({ x: -1, y: 0, tileList })), 94 | ).toMatchSnapshot(); 95 | }); 96 | it('y=1', () => { 97 | expect(removeProps(moveTile({ x: 0, y: 1, tileList }))).toMatchSnapshot(); 98 | }); 99 | it('y=-1', () => { 100 | expect( 101 | removeProps(moveTile({ x: 0, y: -1, tileList })), 102 | ).toMatchSnapshot(); 103 | }); 104 | }); 105 | describe('가로 2 4 8 8', () => { 106 | const tileList = [ 107 | { 108 | id: 11, 109 | x: 1, 110 | y: 1, 111 | value: 2, 112 | }, 113 | { 114 | id: 12, 115 | x: 2, 116 | y: 1, 117 | value: 4, 118 | }, 119 | { 120 | id: 13, 121 | x: 3, 122 | y: 1, 123 | value: 8, 124 | }, 125 | { 126 | id: 14, 127 | x: 4, 128 | y: 1, 129 | value: 8, 130 | }, 131 | ]; 132 | it('x=1', () => { 133 | expect(removeProps(moveTile({ x: 1, y: 0, tileList }))).toMatchSnapshot(); 134 | }); 135 | it('x=-1', () => { 136 | expect( 137 | removeProps(moveTile({ x: -1, y: 0, tileList })), 138 | ).toMatchSnapshot(); 139 | }); 140 | }); 141 | describe('가로 2 x 8 8', () => { 142 | const tileList = [ 143 | { 144 | id: 11, 145 | x: 1, 146 | y: 1, 147 | value: 2, 148 | }, 149 | { 150 | id: 13, 151 | x: 3, 152 | y: 1, 153 | value: 8, 154 | }, 155 | { 156 | id: 14, 157 | x: 4, 158 | y: 1, 159 | value: 8, 160 | }, 161 | ]; 162 | it('x=1', () => { 163 | expect(removeProps(moveTile({ x: 1, y: 0, tileList }))).toMatchSnapshot(); 164 | }); 165 | it('x=-1', () => { 166 | expect( 167 | removeProps(moveTile({ x: -1, y: 0, tileList })), 168 | ).toMatchSnapshot(); 169 | }); 170 | }); 171 | describe('세로 2 x 8 8', () => { 172 | const tileList = [ 173 | { 174 | id: 11, 175 | x: 1, 176 | y: 1, 177 | value: 2, 178 | }, 179 | { 180 | id: 13, 181 | x: 1, 182 | y: 3, 183 | value: 8, 184 | }, 185 | { 186 | id: 14, 187 | x: 1, 188 | y: 4, 189 | value: 8, 190 | }, 191 | ]; 192 | it('y=1', () => { 193 | expect(removeProps(moveTile({ x: 0, y: 1, tileList }))).toMatchSnapshot(); 194 | }); 195 | it('y=-1', () => { 196 | expect( 197 | removeProps(moveTile({ x: 0, y: -1, tileList })), 198 | ).toMatchSnapshot(); 199 | }); 200 | }); 201 | describe('가로 여러 줄, 2 2 4 4, 8 x 4 4', () => { 202 | const tileList = [ 203 | { 204 | id: 11, 205 | x: 1, 206 | y: 1, 207 | value: 2, 208 | }, 209 | { 210 | id: 12, 211 | x: 2, 212 | y: 1, 213 | value: 2, 214 | }, 215 | { 216 | id: 13, 217 | x: 3, 218 | y: 1, 219 | value: 4, 220 | }, 221 | { 222 | id: 14, 223 | x: 4, 224 | y: 1, 225 | value: 4, 226 | }, 227 | { 228 | id: 15, 229 | x: 1, 230 | y: 2, 231 | value: 8, 232 | }, 233 | { 234 | id: 16, 235 | x: 3, 236 | y: 2, 237 | value: 4, 238 | }, 239 | { 240 | id: 17, 241 | x: 4, 242 | y: 2, 243 | value: 4, 244 | }, 245 | ]; 246 | it('x=1', () => { 247 | expect(removeProps(moveTile({ x: 1, y: 0, tileList }))).toMatchSnapshot(); 248 | }); 249 | it('x=-1', () => { 250 | expect( 251 | removeProps(moveTile({ x: -1, y: 0, tileList })), 252 | ).toMatchSnapshot(); 253 | }); 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /game2048/start/.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /game2048/start/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "target": "es6", 6 | "checkJs": true 7 | }, 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /game2048/start/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game2048", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.5.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "classnames": "^2.2.6", 10 | "hotkeys-js": "^3.8.1", 11 | "lodash": "^4.17.19", 12 | "react": "^16.13.1", 13 | "react-dom": "^16.13.1", 14 | "react-scripts": "3.4.1" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /game2048/start/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/game2048/start/public/favicon.ico -------------------------------------------------------------------------------- /game2048/start/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /game2048/start/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/game2048/start/public/logo192.png -------------------------------------------------------------------------------- /game2048/start/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/game2048/start/public/logo512.png -------------------------------------------------------------------------------- /game2048/start/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /game2048/start/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /game2048/start/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Header from './component/Header'; 3 | import AboveGame from './component/AboveGame'; 4 | import Game from './component/Game'; 5 | 6 | export default function App() { 7 | return ( 8 |
9 |
10 | 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /game2048/start/src/component/AboveGame.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function AboveGame() { 4 | return ( 5 |
6 |

7 | Join the numbers and get to the 2048 tile! 8 |

9 | 10 | New Game 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /game2048/start/src/component/Game.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Game() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 |
4
36 |
37 |
38 |
2
39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /game2048/start/src/component/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Header() { 4 | return ( 5 |
6 |

2048

7 |
8 |
9 | 0 10 |
11 |
2480
12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /game2048/start/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /game2048/start/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /ts-todo/final/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-list", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon --watch '*.ts' --exec 'ts-node' src/index.ts", 8 | "build": "tsc" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@types/node": "^14.6.0", 15 | "chalk": "^4.1.0", 16 | "nodemon": "^2.0.4", 17 | "ts-node": "^9.0.0", 18 | "typescript": "^4.0.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ts-todo/final/src/Command.ts: -------------------------------------------------------------------------------- 1 | import { waitForInput } from './Input'; 2 | import { 3 | PRIORITY_NAME_MAP, 4 | Priority, 5 | Action, 6 | ActionNewTodo, 7 | AppState, 8 | ActionDeleteTodo, 9 | } from './type'; 10 | import { getIsValidEnumValue } from './util'; 11 | import chalk from 'chalk'; 12 | 13 | export abstract class Command { 14 | constructor(public key: string, private desc: string) {} 15 | toString() { 16 | return chalk`{blue.bold ${this.key}}: ${this.desc}`; 17 | } 18 | abstract async run(state: AppState): Promise; 19 | } 20 | 21 | export class CommandPrintTodos extends Command { 22 | constructor() { 23 | super('p', chalk`모든 할 일 {red.bold 출력}하기`); 24 | } 25 | async run(state: AppState): Promise { 26 | for (const todo of state.todos) { 27 | const text = todo.toString(); 28 | console.log(text); 29 | } 30 | await waitForInput('press any key: '); 31 | } 32 | } 33 | 34 | export class CommandDeleteTodo extends Command { 35 | constructor() { 36 | super('d', chalk`할 일 {red.bold 제거}하기`); 37 | } 38 | async run(state: AppState): Promise { 39 | for (const todo of state.todos) { 40 | const text = todo.toString(); 41 | console.log(text); 42 | } 43 | const idStr = await waitForInput('press todo id to delete: '); 44 | const id = Number(idStr); 45 | return { 46 | type: 'deleteTodo', 47 | id, 48 | }; 49 | } 50 | } 51 | 52 | export class CommandNewTodo extends Command { 53 | constructor() { 54 | super('n', chalk`할 일 {red.bold 추가}하기`); 55 | } 56 | async run(): Promise { 57 | const title = await waitForInput('title: '); 58 | const priorityStr = await waitForInput( 59 | `priority ${PRIORITY_NAME_MAP[Priority.High]}(${Priority.High}) ~ ${ 60 | PRIORITY_NAME_MAP[Priority.Low] 61 | }(${Priority.Low}): `, 62 | ); 63 | const priority = Number(priorityStr); 64 | if (title && CommandNewTodo.getIsPriority(priority)) { 65 | return { 66 | type: 'newTodo', 67 | title, 68 | priority, 69 | }; 70 | } 71 | } 72 | 73 | static getIsPriority(priority: number): priority is Priority { 74 | return getIsValidEnumValue(Priority, priority); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ts-todo/final/src/Input.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline'; 2 | 3 | const readlineInterface = readline.createInterface({ 4 | input: process.stdin, 5 | output: process.stdout, 6 | }); 7 | 8 | export function waitForInput(msg: string) { 9 | return new Promise(res => 10 | readlineInterface.question(msg, key => { 11 | res(key); 12 | }), 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ts-todo/final/src/Todo.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { Priority, PRIORITY_NAME_MAP } from './type'; 3 | 4 | export default class Todo { 5 | static nextId: number = 1; 6 | constructor( 7 | private title: string, 8 | private priority: Priority, 9 | public id: number = Todo.nextId, 10 | ) { 11 | Todo.nextId++; 12 | } 13 | toString() { 14 | return chalk`{blue.bold ${this.id})} 제목: {bold ${ 15 | this.title 16 | }} (우선순위: {${PRIORITY_STYLE_MAP[this.priority]} ${ 17 | PRIORITY_NAME_MAP[this.priority] 18 | }})`; 19 | } 20 | } 21 | 22 | const PRIORITY_STYLE_MAP: { [key in Priority]: string } = { 23 | [Priority.High]: 'red.bold', 24 | [Priority.Medium]: 'grey.bold', 25 | [Priority.Low]: 'yellow.bold', 26 | }; 27 | -------------------------------------------------------------------------------- /ts-todo/final/src/index.ts: -------------------------------------------------------------------------------- 1 | import Todo from './Todo'; 2 | import { waitForInput } from './Input'; 3 | import { 4 | Command, 5 | CommandNewTodo, 6 | CommandPrintTodos, 7 | CommandDeleteTodo, 8 | } from './Command'; 9 | import { Priority, AppState, Action } from './type'; 10 | 11 | const commands: Array = [ 12 | new CommandPrintTodos(), 13 | new CommandNewTodo(), 14 | new CommandDeleteTodo(), 15 | ]; 16 | 17 | async function main() { 18 | let state: AppState = { 19 | todos: [ 20 | new Todo('test1', Priority.High), 21 | new Todo('test2', Priority.Medium), 22 | new Todo('test3', Priority.Low), 23 | ], 24 | }; 25 | while (true) { 26 | console.clear(); 27 | for (const command of commands) { 28 | console.log(command.toString()); 29 | } 30 | console.log(); 31 | const key = await waitForInput(`input command: `); 32 | console.clear(); 33 | const command = commands.find(item => item.key === key); 34 | if (command) { 35 | const action = await command.run(state); 36 | if (action) { 37 | state = getNextState(state, action); 38 | } 39 | } 40 | } 41 | } 42 | main(); 43 | 44 | function getNextState(state: AppState, action: Action): AppState { 45 | switch (action.type) { 46 | case 'newTodo': 47 | return { 48 | ...state, 49 | todos: [...state.todos, new Todo(action.title, action.priority)], 50 | }; 51 | case 'deleteTodo': 52 | return { 53 | ...state, 54 | todos: state.todos.filter(item => item.id !== action.id), 55 | }; 56 | default: 57 | return state; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ts-todo/final/src/type.ts: -------------------------------------------------------------------------------- 1 | import Todo from './Todo'; 2 | 3 | export interface AppState { 4 | todos: Todo[]; 5 | } 6 | 7 | export interface ActionNewTodo { 8 | type: 'newTodo'; 9 | title: string; 10 | priority: Priority; 11 | } 12 | export interface ActionDeleteTodo { 13 | type: 'deleteTodo'; 14 | id: number; 15 | } 16 | export type Action = ActionNewTodo | ActionDeleteTodo; 17 | 18 | export enum Priority { 19 | High, 20 | Medium, 21 | Low, 22 | } 23 | 24 | export const PRIORITY_NAME_MAP: { [key in Priority]: string } = { 25 | [Priority.High]: '높음', 26 | [Priority.Medium]: '중간', 27 | [Priority.Low]: '낮음', 28 | }; 29 | -------------------------------------------------------------------------------- /ts-todo/final/src/util.ts: -------------------------------------------------------------------------------- 1 | export function getIsValidEnumValue(enumObject: any, value: number | string) { 2 | return Object.keys(enumObject) 3 | .filter(key => isNaN(Number(key))) 4 | .some(key => enumObject[key] === value); 5 | } 6 | -------------------------------------------------------------------------------- /ts-todo/final/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true /* Skip type checking of declaration files. */, 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ts-todo/start/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-list", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon --watch '*.ts' --exec 'ts-node' src/index.ts", 8 | "build": "tsc" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@types/node": "^14.6.0", 15 | "chalk": "^4.1.0", 16 | "nodemon": "^2.0.4", 17 | "ts-node": "^9.0.0", 18 | "typescript": "^4.0.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ts-todo/start/src/Input.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline'; 2 | 3 | const readlineInterface = readline.createInterface({ 4 | input: process.stdin, 5 | output: process.stdout, 6 | }); 7 | 8 | export function waitForInput(msg: string) { 9 | return new Promise(res => 10 | readlineInterface.question(msg, key => { 11 | res(key); 12 | }), 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ts-todo/start/src/index.ts: -------------------------------------------------------------------------------- 1 | console.log('hello world'); 2 | -------------------------------------------------------------------------------- /ts-todo/start/src/util.ts: -------------------------------------------------------------------------------- 1 | export function getIsValidEnumValue(enumObject: any, value: number | string) { 2 | return Object.keys(enumObject) 3 | .filter(key => isNaN(Number(key))) 4 | .some(key => enumObject[key] === value); 5 | } 6 | -------------------------------------------------------------------------------- /ts-todo/start/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true /* Skip type checking of declaration files. */, 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /whois/final/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_HOST=http://localhost:3001 -------------------------------------------------------------------------------- /whois/final/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_HOST=http://localhost:3001 -------------------------------------------------------------------------------- /whois/final/.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /whois/final/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "target": "es2020", 6 | "checkJs": true 7 | }, 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /whois/final/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whois", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.5.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "react": "^16.13.1", 10 | "react-dom": "^16.13.1", 11 | "react-scripts": "3.4.1", 12 | "@ant-design/icons": "^4.2.1", 13 | "@testing-library/react-hooks": "^3.3.0", 14 | "@types/jest": "^26.0.4", 15 | "antd": "^4.4.2", 16 | "axios": "^0.19.2", 17 | "diff": "^4.0.2", 18 | "react-redux": "^7.2.0", 19 | "react-router-dom": "^5.2.0", 20 | "react-test-renderer": "^16.13.1", 21 | "redux": "^4.0.5", 22 | "redux-saga": "^1.1.3" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /whois/final/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/final/public/favicon.ico -------------------------------------------------------------------------------- /whois/final/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 73 | 81 | 82 | 83 | 84 |
85 |
86 |

Downloading...

87 |
88 |
89 |
90 |
91 |
92 |
93 | 98 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /whois/final/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/final/public/logo192.png -------------------------------------------------------------------------------- /whois/final/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/final/public/logo512.png -------------------------------------------------------------------------------- /whois/final/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /whois/final/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /whois/final/server/data.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/final/server/data.db -------------------------------------------------------------------------------- /whois/final/server/db.js: -------------------------------------------------------------------------------- 1 | const sqlite3 = require('sqlite3'); 2 | 3 | // const db = new sqlite3.Database(':memory:'); 4 | const db = new sqlite3.Database('./data.db', sqlite3.OPEN_READWRITE); 5 | 6 | const users = [ 7 | ['land', '글로벌웹', '팀장, 웹, 결제, 리액트'], 8 | ['bono', '글로벌웹', '팀원, 로그인, 작품홈'], 9 | ['shai', '국내웹', '팀장, 비디오 플레이어, 카톡더보기'], 10 | ]; 11 | const placeholders = users.map(_ => '(?,?,?)').join(','); 12 | const sql = 'INSERT INTO user(name, department, tag) VALUES ' + placeholders; 13 | db.run( 14 | sql, 15 | users.flatMap(_ => _), 16 | function (err) { 17 | if (err) { 18 | return console.error(err.message); 19 | } 20 | console.log(`Rows inserted ${this.changes}`); 21 | }, 22 | ); 23 | 24 | db.close(); 25 | -------------------------------------------------------------------------------- /whois/final/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const sqlite3 = require('sqlite3'); 3 | const cors = require('cors'); 4 | const bodyParser = require('body-parser'); 5 | const cookieParser = require('cookie-parser'); 6 | 7 | const app = express(); 8 | app.use( 9 | cors({ 10 | origin: 'http://localhost:3000', 11 | credentials: true, 12 | }), 13 | ); 14 | app.use(bodyParser.json()); 15 | app.use(cookieParser()); 16 | 17 | const db = new sqlite3.Database('./data.db', sqlite3.OPEN_READWRITE); 18 | app.get('/user/search', (req, res) => { 19 | setTimeout(() => { 20 | const keyword = req.query.keyword; 21 | db.all( 22 | `SELECT * FROM user where name like '%${keyword}%' or department like '%${keyword}%' or tag like '%${keyword}%'`, 23 | [], 24 | (err, rows) => { 25 | if (err) { 26 | throw err; 27 | } 28 | res.send(makeResponse({ data: rows })); 29 | }, 30 | ); 31 | }, 1); 32 | }); 33 | app.get('/history', (req, res) => { 34 | setTimeout(() => { 35 | const { name, page = 0 } = req.query; 36 | // @ts-ignore 37 | const pagination = `limit ${PAGING_SIZE} offset ${PAGING_SIZE * page}`; 38 | const sql = name 39 | ? `SELECT * FROM history where name='${name}' order by date DESC ${pagination}` 40 | : `SELECT * FROM history order by date DESC ${pagination}`; 41 | db.all(sql, [], (err, rows) => { 42 | if (err) { 43 | throw err; 44 | } 45 | db.all('SELECT count(*) as totalCount FROM history', [], (err, rows2) => { 46 | const totalCount = rows2[0].totalCount; 47 | res.send(makeResponse({ data: rows, totalCount })); 48 | }); 49 | }); 50 | }, 1); 51 | }); 52 | app.post('/user/update', (req, res) => { 53 | setTimeout(() => { 54 | const { key, name, value, oldValue } = req.body; 55 | const sql = `UPDATE user SET ${key} = ? WHERE name = ?`; 56 | db.run(sql, [value, name], function (err) { 57 | if (err) { 58 | return console.error(err.message); 59 | } 60 | 61 | const date = new Date(new Date().getTime() + 9 * 3600 * 1000); 62 | const iso = date.toISOString(); 63 | const dateStr = `${iso.substr(0, 10)} ${iso.substr(11, 8)}`; 64 | const editor = req.cookies.token || 'unknown'; 65 | const history = { 66 | editor, 67 | name, 68 | column: key, 69 | before: oldValue, 70 | after: value, 71 | date: dateStr, 72 | }; 73 | const sql = `INSERT INTO history(editor, name, column, before, after, date) VALUES (?,?,?,?,?,?)`; 74 | db.run( 75 | sql, 76 | [ 77 | history.editor, 78 | history.name, 79 | history.column, 80 | history.before, 81 | history.after, 82 | history.date, 83 | ], 84 | function (err) { 85 | if (err) { 86 | return console.error(err.message); 87 | } 88 | history.id = this.lastID; 89 | res.send(makeResponse({ data: { history } })); 90 | }, 91 | ); 92 | }); 93 | }, 1); 94 | }); 95 | 96 | app.get('/auth/user', (req, res) => { 97 | setTimeout(() => { 98 | const name = req.cookies.token; 99 | res.send(makeResponse({ data: { name } })); 100 | }, 1); 101 | }); 102 | 103 | app.post('/auth/login', (req, res) => { 104 | setTimeout(() => { 105 | const { name } = req.body; 106 | db.all(`SELECT * FROM user where name='${name}'`, [], (err, rows) => { 107 | if (err) { 108 | throw err; 109 | } 110 | if (rows.length) { 111 | res.cookie('token', name, { 112 | maxAge: COOKIE_MAX_AGE, 113 | httpOnly: true, 114 | }); 115 | res.send(makeResponse({ data: { name } })); 116 | } else { 117 | res.send( 118 | makeResponse({ 119 | resultCode: -1, 120 | resultMessage: '존재하지 않는 사용자입니다.', 121 | }), 122 | ); 123 | } 124 | }); 125 | }, 1); 126 | }); 127 | 128 | app.get('/auth/logout', (req, res) => { 129 | setTimeout(() => { 130 | res.cookie('token', '', { 131 | maxAge: 0, 132 | httpOnly: true, 133 | }); 134 | res.send(makeResponse({})); 135 | }, 1); 136 | }); 137 | 138 | app.post('/auth/signup', (req, res) => { 139 | setTimeout(() => { 140 | const { email } = req.body; 141 | if (!email.includes('@')) { 142 | res.send( 143 | makeResponse({ 144 | resultCode: -1, 145 | resultMessage: '이메일 형식이 아닙니다.', 146 | }), 147 | ); 148 | return; 149 | } 150 | const name = email.substr(0, email.lastIndexOf('@')); 151 | db.all(`SELECT * FROM user where name='${name}'`, [], (err, rows) => { 152 | if (err) { 153 | throw err; 154 | } 155 | console.log('rows', rows, rows[0]); 156 | if (rows.length) { 157 | res.send( 158 | makeResponse({ 159 | resultCode: -1, 160 | resultMessage: '이미 존재하는 사용자입니다.', 161 | }), 162 | ); 163 | } else { 164 | const sql = `INSERT INTO user(name, department, tag) VALUES (?,?,?)`; 165 | db.run(sql, [name, '소속없음', ''], function (err) { 166 | if (err) { 167 | return console.error(err.message); 168 | } 169 | res.cookie('token', name, { maxAge: COOKIE_MAX_AGE, httpOnly: true }); 170 | res.send(makeResponse({ data: { name } })); 171 | }); 172 | } 173 | }); 174 | }, 1); 175 | }); 176 | 177 | const COOKIE_MAX_AGE = 3600000 * 24 * 14; 178 | const PAGING_SIZE = 20; 179 | 180 | /** 181 | * 182 | * @param {object} param 183 | * @param {object=} param.data 184 | * @param {number=} param.totalCount 185 | * @param {number=} param.resultCode 186 | * @param {string=} param.resultMessage 187 | */ 188 | function makeResponse({ data, totalCount, resultCode, resultMessage }) { 189 | return { 190 | data, 191 | totalCount, 192 | resultCode: resultCode || 0, 193 | resultMessage: resultMessage || '', 194 | }; 195 | } 196 | 197 | const PORT = 3001; 198 | app.listen(PORT, () => console.log(`app listening on port ${PORT}!`)); 199 | -------------------------------------------------------------------------------- /whois/final/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon index.js" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "body-parser": "^1.19.0", 14 | "cookie-parser": "^1.4.5", 15 | "cors": "^2.8.5", 16 | "express": "^4.17.1", 17 | "lru-cache": "^6.0.0", 18 | "nodemon": "^2.0.4", 19 | "sqlite3": "^5.1.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /whois/final/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import Search from './search/container/Search'; 3 | import 'antd/dist/antd.css'; 4 | import { Route } from 'react-router-dom'; 5 | import User from './user/container/User'; 6 | import Login from './auth/container/Login'; 7 | import Signup from './auth/container/Signup'; 8 | import { useDispatch } from 'react-redux'; 9 | import { actions as authActions } from './auth/state'; 10 | 11 | export default function App() { 12 | useEffect(() => { 13 | const bodyEl = document.getElementsByTagName('body')[0]; 14 | const loadingEl = document.getElementById('init-loading'); 15 | bodyEl.removeChild(loadingEl); 16 | }, []); 17 | const dispatch = useDispatch(); 18 | useEffect(() => { 19 | dispatch(authActions.fetchUser()); 20 | }, [dispatch]); 21 | return ( 22 | <> 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /whois/final/src/auth/component/AuthLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Col, Row, Typography, Form } from 'antd'; 3 | 4 | /** 5 | * 6 | * @param {object} param 7 | * @param {() => void} param.onFinish 8 | * @param {import('react').ReactNode} param.children 9 | */ 10 | export default function AuthLayout({ children, onFinish }) { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 찾 아 야 한 다 17 | 18 | 19 | 20 | 21 | 22 |
27 | {children} 28 |
29 | 30 |
31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /whois/final/src/auth/container/Login.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form, Input, Button } from 'antd'; 3 | import { UserOutlined, LockOutlined } from '@ant-design/icons'; 4 | import { Link } from 'react-router-dom'; 5 | import AuthLayout from '../component/AuthLayout'; 6 | import { useDispatch } from 'react-redux'; 7 | import { actions } from '../state'; 8 | import useBlockLoginUser from '../hook/useBlockLoginUser'; 9 | 10 | export default function Login() { 11 | useBlockLoginUser(); 12 | const dispatch = useDispatch(); 13 | function onFinish({ username, password }) { 14 | dispatch(actions.fetchLogin(username, password)); 15 | } 16 | return ( 17 | 18 | 22 | } placeholder="Username" /> 23 | 24 | 28 | } 30 | type="password" 31 | placeholder="Password" 32 | /> 33 | 34 | 35 | 38 | Or register now! 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /whois/final/src/auth/container/Signup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AuthLayout from '../component/AuthLayout'; 3 | import { Input, Button, Form } from 'antd'; 4 | import { Link } from 'react-router-dom'; 5 | import { useDispatch } from 'react-redux'; 6 | import { actions } from '../state'; 7 | import useBlockLoginUser from '../hook/useBlockLoginUser'; 8 | 9 | export default function Signup() { 10 | useBlockLoginUser(); 11 | const dispatch = useDispatch(); 12 | function onFinish({ name }) { 13 | const email = `${name}${EMAIL_SUFFIX}`; 14 | dispatch(actions.fetchSignup(email)); 15 | } 16 | return ( 17 | 18 | 27 | 28 | 29 | 30 | 33 | Or login 34 | 35 | 36 | ); 37 | } 38 | 39 | const EMAIL_SUFFIX = '@company.com'; 40 | -------------------------------------------------------------------------------- /whois/final/src/auth/hook/useBlockLoginUser.js: -------------------------------------------------------------------------------- 1 | import { useHistory } from 'react-router-dom'; 2 | import { useSelector } from 'react-redux'; 3 | import { useEffect } from 'react'; 4 | import { AuthStatus } from '../../common/constant'; 5 | 6 | export default function useBlockLoginUser() { 7 | const history = useHistory(); 8 | const status = useSelector(state => state.auth.status); 9 | useEffect(() => { 10 | if (status === AuthStatus.Login) { 11 | history.replace('/'); 12 | } 13 | }, [status, history]); 14 | } 15 | -------------------------------------------------------------------------------- /whois/final/src/auth/state/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | createReducer, 3 | createSetValueAction, 4 | setValueReducer, 5 | } from '../../common/redux-helper'; 6 | import { AuthStatus } from '../../common/constant'; 7 | 8 | export const Types = { 9 | SetValue: 'auth/SetValue', 10 | FetchLogin: 'auth/FetchLogin', 11 | SetUser: 'auth/SetUser', 12 | FetchSignup: 'auth/FetchSignup', 13 | FetchUser: 'auth/FetchUser', 14 | FetchLogout: 'auth/FetchLogout', 15 | }; 16 | 17 | export const actions = { 18 | setValue: createSetValueAction(Types.SetValue), 19 | fetchLogin: (name, password) => ({ 20 | type: Types.FetchLogin, 21 | name, 22 | password, 23 | }), 24 | setUser: name => ({ 25 | type: Types.SetUser, 26 | name, 27 | }), 28 | fetchSignup: email => ({ 29 | type: Types.FetchSignup, 30 | email, 31 | }), 32 | fetchUser: () => ({ 33 | type: Types.FetchUser, 34 | }), 35 | fetchLogout: () => ({ type: Types.FetchLogout }), 36 | }; 37 | 38 | const INITIAL_STATE = { 39 | name: '', 40 | status: undefined, 41 | }; 42 | const reducer = createReducer(INITIAL_STATE, { 43 | [Types.SetValue]: setValueReducer, 44 | [Types.SetUser]: (state, action) => { 45 | state.name = action.name; 46 | state.status = action.name ? AuthStatus.Login : AuthStatus.NotLogin; 47 | }, 48 | }); 49 | export default reducer; 50 | -------------------------------------------------------------------------------- /whois/final/src/auth/state/saga.js: -------------------------------------------------------------------------------- 1 | import { all, put, call, takeLeading } from 'redux-saga/effects'; 2 | import { actions, Types } from './index'; 3 | import { callApi } from '../../common/util/api'; 4 | import { makeFetchSaga } from '../../common/util/fetch'; 5 | 6 | function* fetchLogin({ name, password }) { 7 | const { isSuccess, data } = yield call(callApi, { 8 | url: '/auth/login', 9 | method: 'post', 10 | data: { 11 | name, 12 | password, 13 | }, 14 | }); 15 | 16 | if (isSuccess && data) { 17 | yield put(actions.setUser(data.name)); 18 | } 19 | } 20 | 21 | function* fetchSignup({ email }) { 22 | const { isSuccess, data } = yield call(callApi, { 23 | url: '/auth/signup', 24 | method: 'post', 25 | data: { 26 | email, 27 | }, 28 | }); 29 | 30 | if (isSuccess && data) { 31 | yield put(actions.setUser(data.name)); 32 | } 33 | } 34 | 35 | function* fetchUser() { 36 | const { isSuccess, data } = yield call(callApi, { 37 | url: '/auth/user', 38 | }); 39 | 40 | if (isSuccess && data) { 41 | yield put(actions.setUser(data.name)); 42 | } 43 | } 44 | 45 | function* fetchLogout() { 46 | const { isSuccess } = yield call(callApi, { 47 | url: '/auth/logout', 48 | }); 49 | 50 | if (isSuccess) { 51 | yield put(actions.setUser('')); 52 | } 53 | } 54 | 55 | export default function* () { 56 | yield all([ 57 | takeLeading( 58 | Types.FetchLogin, 59 | makeFetchSaga({ fetchSaga: fetchLogin, canCache: false }), 60 | ), 61 | takeLeading( 62 | Types.FetchSignup, 63 | makeFetchSaga({ fetchSaga: fetchSignup, canCache: false }), 64 | ), 65 | takeLeading( 66 | Types.FetchUser, 67 | makeFetchSaga({ fetchSaga: fetchUser, canCache: false }), 68 | ), 69 | takeLeading( 70 | Types.FetchLogout, 71 | makeFetchSaga({ fetchSaga: fetchLogout, canCache: false }), 72 | ), 73 | ]); 74 | } 75 | -------------------------------------------------------------------------------- /whois/final/src/common/component/History.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Timeline, Space, Tag, Typography } from 'antd'; 3 | import { Link } from 'react-router-dom'; 4 | import { diffWords } from 'diff'; 5 | 6 | /** 7 | * 8 | * @param {object} param 9 | * @param {object[]} param.items 10 | */ 11 | export default function History({ items }) { 12 | return ( 13 | 14 | {items.map(item => ( 15 | 16 | 17 | 18 | 19 | 20 | 수정한 사람: {item.editor} 21 | 22 | 23 | 24 | 수정된 사람: {item.name} 25 | 26 | 날짜: {item.date} 27 | 속성: {COLUMN_MAP[item.column]} 28 | 29 | 30 | {getDiff(item).map((diff, index) => ( 31 | 38 | {diff.value} 39 | 40 | ))} 41 | 42 | 43 | 44 | ))} 45 | 46 | ); 47 | } 48 | 49 | const COLUMN_MAP = { 50 | tag: '태그', 51 | department: '소속', 52 | }; 53 | 54 | /** 55 | * 56 | * @param {object} param 57 | * @param {'tag' | 'department'} param.column 58 | * @param {string} param.before 59 | * @param {string} param.after 60 | * @returns {Array<{value: string, removed?: boolean, added?: boolean}>} 61 | */ 62 | function getDiff({ column, before, after }) { 63 | if (column === 'tag') { 64 | const tags1 = before.split(',').map(item => item.trim()); 65 | const tags2 = after.split(',').map(item => item.trim()); 66 | if (tags1.length > tags2.length) { 67 | const tag = tags1.find(item => !tags2.includes(item)); 68 | if (tag) { 69 | return [{ value: tag, removed: true }]; 70 | } 71 | } else if (tags1.length < tags2.length) { 72 | const tag = tags2.find(item => !tags1.includes(item)); 73 | if (tag) { 74 | return [{ value: tag, added: true }]; 75 | } 76 | } 77 | } 78 | 79 | return diffWords(before, after); 80 | } 81 | -------------------------------------------------------------------------------- /whois/final/src/common/constant.js: -------------------------------------------------------------------------------- 1 | export const API_HOST = process.env.REACT_APP_API_HOST; 2 | export const FetchStatus = { 3 | Request: 'Request', 4 | Success: 'Success', 5 | Fail: 'Fail', 6 | }; 7 | export const AuthStatus = { 8 | Login: 'Login', 9 | NotLogin: 'NotLogin', 10 | }; 11 | -------------------------------------------------------------------------------- /whois/final/src/common/hook/useFetchInfo.js: -------------------------------------------------------------------------------- 1 | import { getFetchKey } from '../util/fetch'; 2 | import { useSelector, shallowEqual } from 'react-redux'; 3 | import { FetchStatus } from '../constant'; 4 | import { FETCH_KEY } from '../redux-helper'; 5 | 6 | export default function useFetchInfo(actionType, fetchKey) { 7 | const _fetchKey = getFetchKey({ 8 | type: actionType, 9 | [FETCH_KEY]: fetchKey, 10 | }); 11 | return useSelector( 12 | state => ({ 13 | fetchStatus: 14 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey], 15 | isFetching: 16 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey] === 17 | FetchStatus.Request, 18 | isFetched: 19 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey] === 20 | FetchStatus.Success || 21 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey] === 22 | FetchStatus.Fail, 23 | isSlow: !!state.common.fetchInfo.isSlowMap[actionType]?.[_fetchKey], 24 | nextPage: 25 | state.common.fetchInfo.nextPageMap[actionType]?.[_fetchKey] || 0, 26 | totalCount: 27 | state.common.fetchInfo.totalCountMap[actionType]?.[_fetchKey] || 0, 28 | errorMessage: 29 | state.common.fetchInfo.errorMessageMap[actionType]?.[_fetchKey], 30 | }), 31 | shallowEqual, 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /whois/final/src/common/hook/useNeedLogin.js: -------------------------------------------------------------------------------- 1 | import { useHistory } from 'react-router-dom'; 2 | import { useSelector } from 'react-redux'; 3 | import { useEffect } from 'react'; 4 | import { AuthStatus } from '../constant'; 5 | 6 | export default function useNeedLogin() { 7 | const history = useHistory(); 8 | const status = useSelector(state => state.auth.status); 9 | useEffect(() => { 10 | if (status === AuthStatus.NotLogin) { 11 | history.replace('/login'); 12 | } 13 | }, [status, history]); 14 | } 15 | -------------------------------------------------------------------------------- /whois/final/src/common/redux-helper.js: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | 3 | export function createReducer(initialState, handlerMap) { 4 | return function (state = initialState, action) { 5 | const handler = handlerMap[action.type]; 6 | if (handler) { 7 | if (action[NOT_IMMUTABLE]) { 8 | return handler(state, action); 9 | } else { 10 | return produce(state, draft => { 11 | const handler = handlerMap[action.type]; 12 | handler(draft, action); 13 | }); 14 | } 15 | } else { 16 | return state; 17 | } 18 | }; 19 | } 20 | 21 | export function createSetValueAction(type) { 22 | return (key, value) => ({ type, key, value }); 23 | } 24 | export function setValueReducer(state, action) { 25 | state[action.key] = action.value; 26 | } 27 | 28 | export const FETCH_PAGE = Symbol('FETCH_PAGE'); 29 | export const FETCH_KEY = Symbol('FETCH_KEY'); 30 | export const NOT_IMMUTABLE = Symbol('NOT_IMMUTABLE'); 31 | -------------------------------------------------------------------------------- /whois/final/src/common/state/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | createReducer, 3 | createSetValueAction, 4 | setValueReducer, 5 | } from '../../common/redux-helper'; 6 | import { FetchStatus } from '../constant'; 7 | 8 | export const Types = { 9 | SetValue: 'common/SetValue', 10 | SetIsSlow: 'common/SetIsSlow', 11 | SetFetchStatus: 'common/SetFetchStatus', 12 | }; 13 | 14 | export const actions = { 15 | setValue: createSetValueAction(Types.SetValue), 16 | setIsSlow: payload => ({ type: Types.SetIsSlow, payload }), 17 | setFetchStatus: payload => ({ type: Types.SetFetchStatus, payload }), 18 | }; 19 | 20 | const INITIAL_STATE = { 21 | fetchInfo: { 22 | fetchStatusMap: {}, 23 | isSlowMap: {}, 24 | totalCountMap: {}, 25 | errorMessageMap: {}, 26 | nextPageMap: {}, 27 | }, 28 | }; 29 | const reducer = createReducer(INITIAL_STATE, { 30 | [Types.SetValue]: setValueReducer, 31 | [Types.SetFetchStatus]: (state, action) => { 32 | const { 33 | actionType, 34 | fetchKey, 35 | status, 36 | totalCount, 37 | nextPage, 38 | errorMessage, 39 | } = action.payload; 40 | if (!state.fetchInfo.fetchStatusMap[actionType]) { 41 | state.fetchInfo.fetchStatusMap[actionType] = {}; 42 | } 43 | state.fetchInfo.fetchStatusMap[actionType][fetchKey] = status; 44 | 45 | if (status !== FetchStatus.Request) { 46 | if (state.fetchInfo.isSlowMap[actionType]) { 47 | state.fetchInfo.isSlowMap[actionType][fetchKey] = false; 48 | } 49 | if (totalCount !== undefined) { 50 | if (!state.fetchInfo.totalCountMap[actionType]) { 51 | state.fetchInfo.totalCountMap[actionType] = {}; 52 | } 53 | state.fetchInfo.totalCountMap[actionType][fetchKey] = totalCount; 54 | } 55 | if (nextPage !== undefined) { 56 | if (!state.fetchInfo.nextPageMap[actionType]) { 57 | state.fetchInfo.nextPageMap[actionType] = {}; 58 | } 59 | state.fetchInfo.nextPageMap[actionType][fetchKey] = nextPage; 60 | } 61 | if (!state.fetchInfo.errorMessageMap[actionType]) { 62 | state.fetchInfo.errorMessageMap[actionType] = {}; 63 | } 64 | if (errorMessage) { 65 | state.fetchInfo.errorMessageMap[actionType][fetchKey] = errorMessage; 66 | } 67 | } 68 | }, 69 | [Types.SetIsSlow]: (state, action) => { 70 | const { actionType, fetchKey, isSlow } = action.payload; 71 | if (!state.fetchInfo.isSlowMap[actionType]) { 72 | state.fetchInfo.isSlowMap[actionType] = {}; 73 | } 74 | state.fetchInfo.isSlowMap[actionType][fetchKey] = isSlow; 75 | }, 76 | }); 77 | export default reducer; 78 | -------------------------------------------------------------------------------- /whois/final/src/common/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, compose, applyMiddleware } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import { all } from 'redux-saga/effects'; 4 | import searchReducer from '../search/state'; 5 | import searchSaga from '../search/state/saga'; 6 | import userReducer from '../user/state'; 7 | import userSaga from '../user/state/saga'; 8 | import commonReducer from '../common/state'; 9 | import authReducer from '../auth/state'; 10 | import authSaga from '../auth/state/saga'; 11 | 12 | const reducer = combineReducers({ 13 | common: commonReducer, 14 | search: searchReducer, 15 | user: userReducer, 16 | auth: authReducer, 17 | }); 18 | const sagaMiddleware = createSagaMiddleware(); 19 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 20 | const store = createStore( 21 | reducer, 22 | composeEnhancers(applyMiddleware(sagaMiddleware)), 23 | ); 24 | 25 | function* rootSaga() { 26 | yield all([searchSaga(), userSaga(), authSaga()]); 27 | } 28 | sagaMiddleware.run(rootSaga); 29 | 30 | export default store; 31 | -------------------------------------------------------------------------------- /whois/final/src/common/util/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { API_HOST } from "../constant"; 3 | import { message } from "antd"; 4 | 5 | /** 6 | * 7 | * @param {object} param 8 | * @param {'get' | 'post' =} param.method 9 | * @param {string} param.url 10 | * @param {object=} param.params 11 | * @param {object=} param.data 12 | * @param {object=} param.totalCount 13 | */ 14 | export function callApi({ method = "get", url, params, data }) { 15 | return axios({ 16 | url, 17 | method, 18 | baseURL: API_HOST, 19 | params, 20 | data, 21 | withCredentials: true, 22 | }) 23 | .then((response) => { 24 | const { resultCode, resultMessage, totalCount } = response.data; 25 | if (resultCode < 0) { 26 | message.error(resultMessage); 27 | } 28 | return { 29 | isSuccess: resultCode === ResultCode.Success, 30 | data: response.data.data, 31 | resultCode, 32 | resultMessage, 33 | totalCount, 34 | }; 35 | }) 36 | .catch(() => { 37 | return { 38 | isSuccess: false, 39 | }; 40 | }); 41 | } 42 | 43 | export const ResultCode = { 44 | Success: 0, 45 | }; 46 | -------------------------------------------------------------------------------- /whois/final/src/common/util/fetch.js: -------------------------------------------------------------------------------- 1 | import { put, delay, fork, cancel, select, call } from 'redux-saga/effects'; 2 | import lruCache from 'lru-cache'; 3 | import { FetchStatus } from '../constant'; 4 | import { callApi } from './api'; 5 | import { actions } from '../state'; 6 | import { FETCH_PAGE, FETCH_KEY } from '../redux-helper'; 7 | 8 | function makeCheckSlowSaga(actionType, fetchKey) { 9 | return function* () { 10 | yield delay(500); 11 | yield put( 12 | actions.setIsSlow({ 13 | actionType, 14 | fetchKey, 15 | isSlow: true, 16 | }), 17 | ); 18 | }; 19 | } 20 | 21 | const apiCache = new lruCache({ 22 | max: 500, 23 | maxAge: 1000 * 60 * 2, 24 | }); 25 | 26 | const SAGA_CALL_TYPE = call(() => {}).type; 27 | function getIsCallEffect(value) { 28 | return value && value.type === SAGA_CALL_TYPE; 29 | } 30 | export function makeFetchSaga({ 31 | fetchSaga, 32 | canCache, 33 | getTotalCount = res => res?.totalCount, 34 | }) { 35 | return function* (action) { 36 | const { type: actionType } = action; 37 | const fetchPage = action[FETCH_PAGE]; 38 | const fetchKey = getFetchKey(action); 39 | const nextPage = yield select( 40 | state => state.common.fetchInfo.nextPageMap[actionType]?.[fetchKey] || 0, 41 | ); 42 | const page = fetchPage !== undefined ? fetchPage : nextPage; 43 | const iterStack = []; 44 | let iter = fetchSaga(action, page); 45 | let res; 46 | let checkSlowTask; 47 | let params; 48 | while (true) { 49 | const { value, done } = iter.next(res); 50 | if (getIsCallEffect(value) && getIsGeneratorFunction(value.payload.fn)) { 51 | iterStack.push(iter); 52 | iter = value.payload.fn(...value.payload.args); 53 | continue; 54 | } 55 | if (getIsCallEffect(value) && value.payload.fn === callApi) { 56 | yield put( 57 | actions.setFetchStatus({ 58 | actionType, 59 | fetchKey, 60 | status: FetchStatus.Request, 61 | }), 62 | ); 63 | const apiParam = value.payload.args[0]; 64 | const cacheKey = getApiCacheKey(actionType, apiParam); 65 | let apiResult = 66 | canCache && apiCache.has(cacheKey) 67 | ? apiCache.get(cacheKey) 68 | : undefined; 69 | const isFromCache = !!apiResult; 70 | if (!isFromCache) { 71 | if (!apiResult) { 72 | checkSlowTask = yield fork(makeCheckSlowSaga(actionType, fetchKey)); 73 | apiResult = yield value; 74 | if (checkSlowTask) { 75 | yield cancel(checkSlowTask); 76 | } 77 | } 78 | } 79 | res = apiResult; 80 | if (apiResult) { 81 | const isSuccess = apiResult.isSuccess; 82 | if (isSuccess && canCache && !isFromCache) { 83 | apiCache.set(cacheKey, apiResult); 84 | } 85 | const totalCount = getTotalCount(apiResult); 86 | params = { 87 | actionType, 88 | fetchKey, 89 | status: isSuccess ? FetchStatus.Success : FetchStatus.Fail, 90 | totalCount, 91 | nextPage: isSuccess ? page + 1 : page, 92 | errorMessage: isSuccess ? '' : apiResult.resultMessage, 93 | }; 94 | } 95 | } else if (value !== undefined) { 96 | res = yield value; 97 | } 98 | if (done) { 99 | const nextIter = iterStack.pop(); 100 | if (nextIter) { 101 | iter = nextIter; 102 | continue; 103 | } 104 | 105 | if (params) { 106 | yield put(actions.setFetchStatus(params)); 107 | } 108 | break; 109 | } 110 | } 111 | }; 112 | } 113 | 114 | // 쿼리 파라미터 순서가 바뀌어도 같은 key가 나오도록 키 이름으로 정렬한다 115 | export function getApiCacheKey(actionType, { apiHost, url, params }) { 116 | const prefix = `${actionType}_${apiHost ? apiHost + url : url}`; 117 | const keys = params ? Object.keys(params) : []; 118 | if (keys.length) { 119 | return ( 120 | prefix + 121 | keys.sort().reduce((acc, key) => `${acc}&${key}=${params[key]}`, '') 122 | ); 123 | } else { 124 | return prefix; 125 | } 126 | } 127 | 128 | export function getFetchKey(action) { 129 | const fetchKey = action[FETCH_KEY]; 130 | return fetchKey === undefined ? action.type : String(fetchKey); 131 | } 132 | 133 | function getIsGeneratorFunction(obj) { 134 | const constructor = obj.constructor; 135 | if (!constructor) { 136 | return false; 137 | } 138 | if ( 139 | 'GeneratorFunction' === constructor.name || 140 | 'GeneratorFunction' === constructor.displayName 141 | ) { 142 | return true; 143 | } 144 | const proto = constructor.prototype; 145 | return 'function' === typeof proto.next && 'function' === typeof proto.throw; 146 | } 147 | 148 | /** 149 | * 150 | * @param {string=} actionType 151 | */ 152 | export function deleteApiCache(actionType) { 153 | let keys = apiCache.keys(); 154 | if (actionType) { 155 | keys = keys.filter(key => key.includes(actionType)); 156 | } 157 | for (const key of keys) { 158 | apiCache.del(key); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /whois/final/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import store from './common/store'; 5 | import { Provider } from 'react-redux'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | 13 | , 14 | document.getElementById('root'), 15 | ); 16 | -------------------------------------------------------------------------------- /whois/final/src/search/component/Settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dropdown, Menu, Button } from 'antd'; 3 | import { SettingFilled } from '@ant-design/icons'; 4 | 5 | /** 6 | * 7 | * @param {object} param 8 | * @param {() => void} param.logout 9 | */ 10 | export default function Settings({ logout }) { 11 | return ( 12 | 15 | 로그아웃 16 | 17 | } 18 | trigger={['click']} 19 | placement="bottomRight" 20 | > 21 | 54 | )} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /whois/final/src/user/container/TagList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Tag, Input, message } from 'antd'; 4 | import { actions } from '../state'; 5 | import { PlusOutlined } from '@ant-design/icons'; 6 | 7 | export default function TagList() { 8 | const dispatch = useDispatch(); 9 | const user = useSelector(state => state.user.user); 10 | const tags = user?.tag ? user.tag.split(',').map(item => item.trim()) : []; 11 | 12 | const [isAdd, setIsAdd] = useState(false); 13 | const [tempTag, setTempTag] = useState(''); 14 | function onAdd() { 15 | setIsAdd(true); 16 | setTempTag(''); 17 | } 18 | 19 | function onSave() { 20 | if (!tempTag) { 21 | setIsAdd(false); 22 | } else if (tags.includes(tempTag)) { 23 | message.error('이미 같은 태그가 있습니다.'); 24 | } else { 25 | const newTag = user?.tag ? `${user.tag}, ${tempTag}` : tempTag; 26 | dispatch( 27 | actions.fetchUpdateUser({ 28 | user, 29 | key: 'tag', 30 | value: newTag, 31 | fetchKey: 'tag', 32 | }), 33 | ); 34 | setIsAdd(false); 35 | } 36 | } 37 | 38 | function onDelete(tag) { 39 | const newTag = tags.filter(item => item !== tag).join(', '); 40 | dispatch( 41 | actions.fetchUpdateUser({ 42 | user, 43 | key: 'tag', 44 | value: newTag, 45 | fetchKey: 'tag', 46 | }), 47 | ); 48 | } 49 | 50 | return ( 51 | <> 52 | {tags.map(item => ( 53 | onDelete(item)}> 54 | {item} 55 | 56 | ))} 57 | {!isAdd && ( 58 | 59 | New Tag 60 | 61 | )} 62 | {isAdd && ( 63 | setTempTag(e.target.value)} 70 | onBlur={() => setIsAdd(false)} 71 | onPressEnter={onSave} 72 | /> 73 | )} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /whois/final/src/user/container/User.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { PageHeader, Col, Row, Descriptions, Typography } from 'antd'; 3 | import { useHistory } from 'react-router-dom'; 4 | import { useSelector, useDispatch } from 'react-redux'; 5 | import { actions, Types } from '../state'; 6 | import useFetchInfo from '../../common/hook/useFetchInfo'; 7 | import History from '../../common/component/History'; 8 | import Department from './Department'; 9 | import TagList from './TagList'; 10 | import FetchLabel from '../component/FetchLabel'; 11 | import useNeedLogin from '../../common/hook/useNeedLogin'; 12 | 13 | /** 14 | * 15 | * @param {object} param 16 | * @param {import('react-router').match} param.match 17 | */ 18 | export default function User({ match }) { 19 | useNeedLogin(); 20 | const history = useHistory(); 21 | const dispatch = useDispatch(); 22 | const user = useSelector(state => state.user.user); 23 | const userHistory = useSelector(state => state.user.userHistory); 24 | 25 | const name = match.params.name; 26 | useEffect(() => { 27 | dispatch(actions.fetchUser(name)); 28 | dispatch(actions.fetchUserHistory(name)); 29 | }, [dispatch, name]); 30 | 31 | useEffect(() => { 32 | return () => dispatch(actions.initialize()); 33 | }, [dispatch]); 34 | 35 | const { isFetched } = useFetchInfo(Types.FetchUser); 36 | 37 | return ( 38 | 39 | 40 | history.push('/')} 42 | title={ 43 | 44 | } 45 | > 46 | {user && ( 47 | 48 | 49 | {user.name} 50 | 51 | 58 | } 59 | > 60 | 61 | 62 | 69 | } 70 | > 71 | 72 | 73 | 74 | 75 | 76 | 77 | )} 78 | {!user && isFetched && ( 79 | 존재하지 않는 사용자 입니다. 80 | )} 81 | 82 | 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /whois/final/src/user/state/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | createReducer, 3 | createSetValueAction, 4 | setValueReducer, 5 | FETCH_KEY, 6 | NOT_IMMUTABLE, 7 | } from '../../common/redux-helper'; 8 | 9 | export const Types = { 10 | SetValue: 'user/SetValue', 11 | FetchUser: 'user/FetchUser', 12 | FetchUpdateUser: 'user/FetchUpdateUser', 13 | FetchUserHistory: 'user/FetchUserHistory', 14 | AddHistory: 'user/AddHistory', 15 | Initialize: 'user/Initialize', 16 | }; 17 | 18 | export const actions = { 19 | setValue: createSetValueAction(Types.SetValue), 20 | fetchUser: name => ({ type: Types.FetchUser, name }), 21 | fetchUpdateUser: ({ user, key, value, fetchKey }) => ({ 22 | type: Types.FetchUpdateUser, 23 | user, 24 | key, 25 | value, 26 | [FETCH_KEY]: fetchKey, 27 | }), 28 | fetchUserHistory: name => ({ type: Types.FetchUserHistory, name }), 29 | addHistory: history => ({ type: Types.AddHistory, history }), 30 | initialize: () => ({ type: Types.Initialize, [NOT_IMMUTABLE]: true }), 31 | }; 32 | 33 | const INITIAL_STATE = { 34 | user: undefined, 35 | userHistory: [], 36 | }; 37 | const reducer = createReducer(INITIAL_STATE, { 38 | [Types.SetValue]: setValueReducer, 39 | [Types.AddHistory]: (state, action) => 40 | (state.userHistory = [action.history, ...state.userHistory]), 41 | [Types.Initialize]: () => INITIAL_STATE, 42 | }); 43 | export default reducer; 44 | -------------------------------------------------------------------------------- /whois/final/src/user/state/saga.js: -------------------------------------------------------------------------------- 1 | import { all, call, put, takeLeading } from 'redux-saga/effects'; 2 | import { Types, actions } from '.'; 3 | import { callApi } from '../../common/util/api'; 4 | import { makeFetchSaga, deleteApiCache } from '../../common/util/fetch'; 5 | 6 | function* fetchUser({ name }) { 7 | const { isSuccess, data } = yield call(callApi, { 8 | url: '/user/search', 9 | params: { keyword: name }, 10 | }); 11 | 12 | if (isSuccess && data) { 13 | const user = data.find(item => item.name === name); 14 | if (user) { 15 | yield put(actions.setValue('user', user)); 16 | } 17 | } 18 | } 19 | 20 | function* fetchUpdateUser({ user, key, value }) { 21 | const oldValue = user[key]; 22 | yield put(actions.setValue('user', { ...user, [key]: value })); 23 | const { isSuccess, data } = yield call(callApi, { 24 | url: '/user/update', 25 | method: 'post', 26 | data: { name: user.name, key, value, oldValue }, 27 | }); 28 | 29 | if (isSuccess && data) { 30 | deleteApiCache(); 31 | yield put(actions.addHistory(data.history)); 32 | } else { 33 | yield put(actions.setValue('user', user)); 34 | } 35 | } 36 | 37 | function* fetchUserHistory({ name }) { 38 | const { isSuccess, data } = yield call(callApi, { 39 | url: '/history', 40 | params: { name }, 41 | }); 42 | 43 | if (isSuccess && data) { 44 | yield put(actions.setValue('userHistory', data)); 45 | } 46 | } 47 | 48 | export default function* () { 49 | yield all([ 50 | takeLeading( 51 | Types.FetchUser, 52 | makeFetchSaga({ fetchSaga: fetchUser, canCache: false }), 53 | ), 54 | takeLeading( 55 | Types.FetchUpdateUser, 56 | makeFetchSaga({ fetchSaga: fetchUpdateUser, canCache: false }), 57 | ), 58 | takeLeading( 59 | Types.FetchUserHistory, 60 | makeFetchSaga({ fetchSaga: fetchUserHistory, canCache: false }), 61 | ), 62 | ]); 63 | } 64 | -------------------------------------------------------------------------------- /whois/start/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_HOST=http://localhost:3001 -------------------------------------------------------------------------------- /whois/start/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_HOST=http://localhost:3001 -------------------------------------------------------------------------------- /whois/start/.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /whois/start/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "target": "es2020", 6 | "checkJs": true 7 | }, 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /whois/start/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whois", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.5.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "react": "^16.13.1", 10 | "react-dom": "^16.13.1", 11 | "react-scripts": "3.4.1", 12 | "@ant-design/icons": "^4.2.1", 13 | "@testing-library/react-hooks": "^3.3.0", 14 | "@types/jest": "^26.0.4", 15 | "antd": "^4.4.2", 16 | "axios": "^0.19.2", 17 | "diff": "^4.0.2", 18 | "react-redux": "^7.2.0", 19 | "react-router-dom": "^5.2.0", 20 | "react-test-renderer": "^16.13.1", 21 | "redux": "^4.0.5", 22 | "redux-saga": "^1.1.3" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /whois/start/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/start/public/favicon.ico -------------------------------------------------------------------------------- /whois/start/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /whois/start/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/start/public/logo192.png -------------------------------------------------------------------------------- /whois/start/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/start/public/logo512.png -------------------------------------------------------------------------------- /whois/start/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /whois/start/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /whois/start/server/data.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/landvibe/inflearn-react-project/0cf5de7764649b64c1626b991c3d5f07d6b058cb/whois/start/server/data.db -------------------------------------------------------------------------------- /whois/start/server/db.js: -------------------------------------------------------------------------------- 1 | const sqlite3 = require('sqlite3'); 2 | 3 | // const db = new sqlite3.Database(':memory:'); 4 | const db = new sqlite3.Database('./data.db', sqlite3.OPEN_READWRITE); 5 | 6 | const users = [ 7 | ['land', '글로벌웹', '팀장, 웹, 결제, 리액트'], 8 | ['bono', '글로벌웹', '팀원, 로그인, 작품홈'], 9 | ['shai', '국내웹', '팀장, 비디오 플레이어, 카톡더보기'], 10 | ]; 11 | const placeholders = users.map(_ => '(?,?,?)').join(','); 12 | const sql = 'INSERT INTO user(name, department, tag) VALUES ' + placeholders; 13 | db.run( 14 | sql, 15 | users.flatMap(_ => _), 16 | function (err) { 17 | if (err) { 18 | return console.error(err.message); 19 | } 20 | console.log(`Rows inserted ${this.changes}`); 21 | }, 22 | ); 23 | 24 | db.close(); 25 | -------------------------------------------------------------------------------- /whois/start/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const sqlite3 = require('sqlite3'); 3 | const cors = require('cors'); 4 | const bodyParser = require('body-parser'); 5 | const cookieParser = require('cookie-parser'); 6 | 7 | const app = express(); 8 | app.use( 9 | cors({ 10 | origin: 'http://localhost:3000', 11 | credentials: true, 12 | }), 13 | ); 14 | app.use(bodyParser.json()); 15 | app.use(cookieParser()); 16 | 17 | const db = new sqlite3.Database('./data.db', sqlite3.OPEN_READWRITE); 18 | app.get('/user/search', (req, res) => { 19 | setTimeout(() => { 20 | const keyword = req.query.keyword; 21 | db.all( 22 | `SELECT * FROM user where name like '%${keyword}%' or department like '%${keyword}%' or tag like '%${keyword}%'`, 23 | [], 24 | (err, rows) => { 25 | if (err) { 26 | throw err; 27 | } 28 | res.send(makeResponse({ data: rows })); 29 | }, 30 | ); 31 | }, 1); 32 | }); 33 | app.get('/history', (req, res) => { 34 | setTimeout(() => { 35 | const { name, page = 0 } = req.query; 36 | // @ts-ignore 37 | const pagination = `limit ${PAGING_SIZE} offset ${PAGING_SIZE * page}`; 38 | const sql = name 39 | ? `SELECT * FROM history where name='${name}' order by date DESC ${pagination}` 40 | : `SELECT * FROM history order by date DESC ${pagination}`; 41 | db.all(sql, [], (err, rows) => { 42 | if (err) { 43 | throw err; 44 | } 45 | db.all('SELECT count(*) as totalCount FROM history', [], (err, rows2) => { 46 | const totalCount = rows2[0].totalCount; 47 | res.send(makeResponse({ data: rows, totalCount })); 48 | }); 49 | }); 50 | }, 1); 51 | }); 52 | app.post('/user/update', (req, res) => { 53 | setTimeout(() => { 54 | const { key, name, value, oldValue } = req.body; 55 | const sql = `UPDATE user SET ${key} = ? WHERE name = ?`; 56 | db.run(sql, [value, name], function (err) { 57 | if (err) { 58 | return console.error(err.message); 59 | } 60 | 61 | const date = new Date(new Date().getTime() + 9 * 3600 * 1000); 62 | const iso = date.toISOString(); 63 | const dateStr = `${iso.substr(0, 10)} ${iso.substr(11, 8)}`; 64 | const editor = req.cookies.token || 'unknown'; 65 | const history = { 66 | editor, 67 | name, 68 | column: key, 69 | before: oldValue, 70 | after: value, 71 | date: dateStr, 72 | }; 73 | const sql = `INSERT INTO history(editor, name, column, before, after, date) VALUES (?,?,?,?,?,?)`; 74 | db.run( 75 | sql, 76 | [ 77 | history.editor, 78 | history.name, 79 | history.column, 80 | history.before, 81 | history.after, 82 | history.date, 83 | ], 84 | function (err) { 85 | if (err) { 86 | return console.error(err.message); 87 | } 88 | history.id = this.lastID; 89 | res.send(makeResponse({ data: { history } })); 90 | }, 91 | ); 92 | }); 93 | }, 1); 94 | }); 95 | 96 | app.get('/auth/user', (req, res) => { 97 | setTimeout(() => { 98 | const name = req.cookies.token; 99 | res.send(makeResponse({ data: { name } })); 100 | }, 1); 101 | }); 102 | 103 | app.post('/auth/login', (req, res) => { 104 | setTimeout(() => { 105 | const { name } = req.body; 106 | db.all(`SELECT * FROM user where name='${name}'`, [], (err, rows) => { 107 | if (err) { 108 | throw err; 109 | } 110 | if (rows.length) { 111 | res.cookie('token', name, { 112 | maxAge: COOKIE_MAX_AGE, 113 | httpOnly: true, 114 | }); 115 | res.send(makeResponse({ data: { name } })); 116 | } else { 117 | res.send( 118 | makeResponse({ 119 | resultCode: -1, 120 | resultMessage: '존재하지 않는 사용자입니다.', 121 | }), 122 | ); 123 | } 124 | }); 125 | }, 1); 126 | }); 127 | 128 | app.get('/auth/logout', (req, res) => { 129 | setTimeout(() => { 130 | res.cookie('token', '', { 131 | maxAge: 0, 132 | httpOnly: true, 133 | }); 134 | res.send(makeResponse({})); 135 | }, 1); 136 | }); 137 | 138 | app.post('/auth/signup', (req, res) => { 139 | setTimeout(() => { 140 | const { email } = req.body; 141 | if (!email.includes('@')) { 142 | res.send( 143 | makeResponse({ 144 | resultCode: -1, 145 | resultMessage: '이메일 형식이 아닙니다.', 146 | }), 147 | ); 148 | return; 149 | } 150 | const name = email.substr(0, email.lastIndexOf('@')); 151 | db.all(`SELECT * FROM user where name='${name}'`, [], (err, rows) => { 152 | if (err) { 153 | throw err; 154 | } 155 | console.log('rows', rows, rows[0]); 156 | if (rows.length) { 157 | res.send( 158 | makeResponse({ 159 | resultCode: -1, 160 | resultMessage: '이미 존재하는 사용자입니다.', 161 | }), 162 | ); 163 | } else { 164 | const sql = `INSERT INTO user(name, department, tag) VALUES (?,?,?)`; 165 | db.run(sql, [name, '소속없음', ''], function (err) { 166 | if (err) { 167 | return console.error(err.message); 168 | } 169 | res.cookie('token', name, { maxAge: COOKIE_MAX_AGE, httpOnly: true }); 170 | res.send(makeResponse({ data: { name } })); 171 | }); 172 | } 173 | }); 174 | }, 1); 175 | }); 176 | 177 | const COOKIE_MAX_AGE = 3600000 * 24 * 14; 178 | const PAGING_SIZE = 20; 179 | 180 | /** 181 | * 182 | * @param {object} param 183 | * @param {object=} param.data 184 | * @param {number=} param.totalCount 185 | * @param {number=} param.resultCode 186 | * @param {string=} param.resultMessage 187 | */ 188 | function makeResponse({ data, totalCount, resultCode, resultMessage }) { 189 | return { 190 | data, 191 | totalCount, 192 | resultCode: resultCode || 0, 193 | resultMessage: resultMessage || '', 194 | }; 195 | } 196 | 197 | const PORT = 3001; 198 | app.listen(PORT, () => console.log(`app listening on port ${PORT}!`)); 199 | -------------------------------------------------------------------------------- /whois/start/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon index.js" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "body-parser": "^1.19.0", 14 | "cookie-parser": "^1.4.5", 15 | "cors": "^2.8.5", 16 | "express": "^4.17.1", 17 | "lru-cache": "^6.0.0", 18 | "nodemon": "^2.0.4", 19 | "sqlite3": "^5.1.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /whois/start/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function App() { 4 | return
찾아야한다
; 5 | } 6 | -------------------------------------------------------------------------------- /whois/start/src/common/constant.js: -------------------------------------------------------------------------------- 1 | export const API_HOST = process.env.REACT_APP_API_HOST; 2 | export const FetchStatus = { 3 | Request: 'Request', 4 | Success: 'Success', 5 | Fail: 'Fail', 6 | }; 7 | export const AuthStatus = { 8 | Login: 'Login', 9 | NotLogin: 'NotLogin', 10 | }; 11 | -------------------------------------------------------------------------------- /whois/start/src/common/hook/useFetchInfo.js: -------------------------------------------------------------------------------- 1 | import { getFetchKey } from '../util/fetch'; 2 | import { useSelector, shallowEqual } from 'react-redux'; 3 | import { FetchStatus } from '../constant'; 4 | import { FETCH_KEY } from '../redux-helper'; 5 | 6 | export default function useFetchInfo(actionType, fetchKey) { 7 | const _fetchKey = getFetchKey({ 8 | type: actionType, 9 | [FETCH_KEY]: fetchKey, 10 | }); 11 | return useSelector( 12 | state => ({ 13 | fetchStatus: 14 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey], 15 | isFetching: 16 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey] === 17 | FetchStatus.Request, 18 | isFetched: 19 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey] === 20 | FetchStatus.Success || 21 | state.common.fetchInfo.fetchStatusMap[actionType]?.[_fetchKey] === 22 | FetchStatus.Fail, 23 | isSlow: !!state.common.fetchInfo.isSlowMap[actionType]?.[_fetchKey], 24 | nextPage: 25 | state.common.fetchInfo.nextPageMap[actionType]?.[_fetchKey] || 0, 26 | totalCount: 27 | state.common.fetchInfo.totalCountMap[actionType]?.[_fetchKey] || 0, 28 | errorMessage: 29 | state.common.fetchInfo.errorMessageMap[actionType]?.[_fetchKey], 30 | }), 31 | shallowEqual, 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /whois/start/src/common/redux-helper.js: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | 3 | export function createReducer(initialState, handlerMap) { 4 | return function (state = initialState, action) { 5 | const handler = handlerMap[action.type]; 6 | if (handler) { 7 | if (action[NOT_IMMUTABLE]) { 8 | return handler(state, action); 9 | } else { 10 | return produce(state, draft => { 11 | const handler = handlerMap[action.type]; 12 | handler(draft, action); 13 | }); 14 | } 15 | } else { 16 | return state; 17 | } 18 | }; 19 | } 20 | 21 | export function createSetValueAction(type) { 22 | return (key, value) => ({ type, key, value }); 23 | } 24 | export function setValueReducer(state, action) { 25 | state[action.key] = action.value; 26 | } 27 | 28 | export const FETCH_PAGE = Symbol('FETCH_PAGE'); 29 | export const FETCH_KEY = Symbol('FETCH_KEY'); 30 | export const NOT_IMMUTABLE = Symbol('NOT_IMMUTABLE'); 31 | -------------------------------------------------------------------------------- /whois/start/src/common/state/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | createReducer, 3 | createSetValueAction, 4 | setValueReducer, 5 | } from '../../common/redux-helper'; 6 | import { FetchStatus } from '../constant'; 7 | 8 | export const Types = { 9 | SetValue: 'common/SetValue', 10 | SetIsSlow: 'common/SetIsSlow', 11 | SetFetchStatus: 'common/SetFetchStatus', 12 | }; 13 | 14 | export const actions = { 15 | setValue: createSetValueAction(Types.SetValue), 16 | setIsSlow: payload => ({ type: Types.SetIsSlow, payload }), 17 | setFetchStatus: payload => ({ type: Types.SetFetchStatus, payload }), 18 | }; 19 | 20 | const INITIAL_STATE = { 21 | fetchInfo: { 22 | fetchStatusMap: {}, 23 | isSlowMap: {}, 24 | totalCountMap: {}, 25 | errorMessageMap: {}, 26 | nextPageMap: {}, 27 | }, 28 | }; 29 | const reducer = createReducer(INITIAL_STATE, { 30 | [Types.SetValue]: setValueReducer, 31 | [Types.SetFetchStatus]: (state, action) => { 32 | const { 33 | actionType, 34 | fetchKey, 35 | status, 36 | totalCount, 37 | nextPage, 38 | errorMessage, 39 | } = action.payload; 40 | if (!state.fetchInfo.fetchStatusMap[actionType]) { 41 | state.fetchInfo.fetchStatusMap[actionType] = {}; 42 | } 43 | state.fetchInfo.fetchStatusMap[actionType][fetchKey] = status; 44 | 45 | if (status !== FetchStatus.Request) { 46 | if (state.fetchInfo.isSlowMap[actionType]) { 47 | state.fetchInfo.isSlowMap[actionType][fetchKey] = false; 48 | } 49 | if (totalCount !== undefined) { 50 | if (!state.fetchInfo.totalCountMap[actionType]) { 51 | state.fetchInfo.totalCountMap[actionType] = {}; 52 | } 53 | state.fetchInfo.totalCountMap[actionType][fetchKey] = totalCount; 54 | } 55 | if (nextPage !== undefined) { 56 | if (!state.fetchInfo.nextPageMap[actionType]) { 57 | state.fetchInfo.nextPageMap[actionType] = {}; 58 | } 59 | state.fetchInfo.nextPageMap[actionType][fetchKey] = nextPage; 60 | } 61 | if (!state.fetchInfo.errorMessageMap[actionType]) { 62 | state.fetchInfo.errorMessageMap[actionType] = {}; 63 | } 64 | if (errorMessage) { 65 | state.fetchInfo.errorMessageMap[actionType][fetchKey] = errorMessage; 66 | } 67 | } 68 | }, 69 | [Types.SetIsSlow]: (state, action) => { 70 | const { actionType, fetchKey, isSlow } = action.payload; 71 | if (!state.fetchInfo.isSlowMap[actionType]) { 72 | state.fetchInfo.isSlowMap[actionType] = {}; 73 | } 74 | state.fetchInfo.isSlowMap[actionType][fetchKey] = isSlow; 75 | }, 76 | }); 77 | export default reducer; 78 | -------------------------------------------------------------------------------- /whois/start/src/common/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, compose, applyMiddleware } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import { all } from 'redux-saga/effects'; 4 | import commonReducer from '../common/state'; 5 | 6 | const reducer = combineReducers({ 7 | common: commonReducer, 8 | }); 9 | const sagaMiddleware = createSagaMiddleware(); 10 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 11 | const store = createStore( 12 | reducer, 13 | composeEnhancers(applyMiddleware(sagaMiddleware)), 14 | ); 15 | 16 | function* rootSaga() { 17 | yield all([]); 18 | } 19 | sagaMiddleware.run(rootSaga); 20 | 21 | export default store; 22 | -------------------------------------------------------------------------------- /whois/start/src/common/util/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { API_HOST } from "../constant"; 3 | import { message } from "antd"; 4 | 5 | /** 6 | * 7 | * @param {object} param 8 | * @param {'get' | 'post' =} param.method 9 | * @param {string} param.url 10 | * @param {object=} param.params 11 | * @param {object=} param.data 12 | * @param {object=} param.totalCount 13 | */ 14 | export function callApi({ method = "get", url, params, data }) { 15 | return axios({ 16 | url, 17 | method, 18 | baseURL: API_HOST, 19 | params, 20 | data, 21 | withCredentials: true, 22 | }) 23 | .then((response) => { 24 | const { resultCode, resultMessage, totalCount } = response.data; 25 | if (resultCode < 0) { 26 | message.error(resultMessage); 27 | } 28 | return { 29 | isSuccess: resultCode === ResultCode.Success, 30 | data: response.data.data, 31 | resultCode, 32 | resultMessage, 33 | totalCount, 34 | }; 35 | }) 36 | .catch(() => { 37 | return { 38 | isSuccess: false, 39 | }; 40 | }); 41 | } 42 | 43 | export const ResultCode = { 44 | Success: 0, 45 | }; 46 | -------------------------------------------------------------------------------- /whois/start/src/common/util/fetch.js: -------------------------------------------------------------------------------- 1 | import { put, delay, fork, cancel, select, call } from 'redux-saga/effects'; 2 | import lruCache from 'lru-cache'; 3 | import { FetchStatus } from '../constant'; 4 | import { callApi } from './api'; 5 | import { actions } from '../state'; 6 | import { FETCH_PAGE, FETCH_KEY } from '../redux-helper'; 7 | 8 | function makeCheckSlowSaga(actionType, fetchKey) { 9 | return function* () { 10 | yield delay(500); 11 | yield put( 12 | actions.setIsSlow({ 13 | actionType, 14 | fetchKey, 15 | isSlow: true, 16 | }), 17 | ); 18 | }; 19 | } 20 | 21 | const apiCache = new lruCache({ 22 | max: 500, 23 | maxAge: 1000 * 60 * 2, 24 | }); 25 | 26 | const SAGA_CALL_TYPE = call(() => {}).type; 27 | function getIsCallEffect(value) { 28 | return value && value.type === SAGA_CALL_TYPE; 29 | } 30 | export function makeFetchSaga({ 31 | fetchSaga, 32 | canCache, 33 | getTotalCount = res => res?.totalCount, 34 | }) { 35 | return function* (action) { 36 | const { type: actionType } = action; 37 | const fetchPage = action[FETCH_PAGE]; 38 | const fetchKey = getFetchKey(action); 39 | const nextPage = yield select( 40 | state => state.common.fetchInfo.nextPageMap[actionType]?.[fetchKey] || 0, 41 | ); 42 | const page = fetchPage !== undefined ? fetchPage : nextPage; 43 | const iterStack = []; 44 | let iter = fetchSaga(action, page); 45 | let res; 46 | let checkSlowTask; 47 | let params; 48 | while (true) { 49 | const { value, done } = iter.next(res); 50 | if (getIsCallEffect(value) && getIsGeneratorFunction(value.payload.fn)) { 51 | iterStack.push(iter); 52 | iter = value.payload.fn(...value.payload.args); 53 | continue; 54 | } 55 | if (getIsCallEffect(value) && value.payload.fn === callApi) { 56 | yield put( 57 | actions.setFetchStatus({ 58 | actionType, 59 | fetchKey, 60 | status: FetchStatus.Request, 61 | }), 62 | ); 63 | const apiParam = value.payload.args[0]; 64 | const cacheKey = getApiCacheKey(actionType, apiParam); 65 | let apiResult = 66 | canCache && apiCache.has(cacheKey) 67 | ? apiCache.get(cacheKey) 68 | : undefined; 69 | const isFromCache = !!apiResult; 70 | if (!isFromCache) { 71 | if (!apiResult) { 72 | checkSlowTask = yield fork(makeCheckSlowSaga(actionType, fetchKey)); 73 | apiResult = yield value; 74 | if (checkSlowTask) { 75 | yield cancel(checkSlowTask); 76 | } 77 | } 78 | } 79 | res = apiResult; 80 | if (apiResult) { 81 | const isSuccess = apiResult.isSuccess; 82 | if (isSuccess && canCache && !isFromCache) { 83 | apiCache.set(cacheKey, apiResult); 84 | } 85 | const totalCount = getTotalCount(apiResult); 86 | params = { 87 | actionType, 88 | fetchKey, 89 | status: isSuccess ? FetchStatus.Success : FetchStatus.Fail, 90 | totalCount, 91 | nextPage: isSuccess ? page + 1 : page, 92 | errorMessage: isSuccess ? '' : apiResult.resultMessage, 93 | }; 94 | } 95 | } else if (value !== undefined) { 96 | res = yield value; 97 | } 98 | if (done) { 99 | const nextIter = iterStack.pop(); 100 | if (nextIter) { 101 | iter = nextIter; 102 | continue; 103 | } 104 | 105 | if (params) { 106 | yield put(actions.setFetchStatus(params)); 107 | } 108 | break; 109 | } 110 | } 111 | }; 112 | } 113 | 114 | // 쿼리 파라미터 순서가 바뀌어도 같은 key가 나오도록 키 이름으로 정렬한다 115 | export function getApiCacheKey(actionType, { apiHost, url, params }) { 116 | const prefix = `${actionType}_${apiHost ? apiHost + url : url}`; 117 | const keys = params ? Object.keys(params) : []; 118 | if (keys.length) { 119 | return ( 120 | prefix + 121 | keys.sort().reduce((acc, key) => `${acc}&${key}=${params[key]}`, '') 122 | ); 123 | } else { 124 | return prefix; 125 | } 126 | } 127 | 128 | export function getFetchKey(action) { 129 | const fetchKey = action[FETCH_KEY]; 130 | return fetchKey === undefined ? action.type : String(fetchKey); 131 | } 132 | 133 | function getIsGeneratorFunction(obj) { 134 | const constructor = obj.constructor; 135 | if (!constructor) { 136 | return false; 137 | } 138 | if ( 139 | 'GeneratorFunction' === constructor.name || 140 | 'GeneratorFunction' === constructor.displayName 141 | ) { 142 | return true; 143 | } 144 | const proto = constructor.prototype; 145 | return 'function' === typeof proto.next && 'function' === typeof proto.throw; 146 | } 147 | 148 | /** 149 | * 150 | * @param {string=} actionType 151 | */ 152 | export function deleteApiCache(actionType) { 153 | let keys = apiCache.keys(); 154 | if (actionType) { 155 | keys = keys.filter(key => key.includes(actionType)); 156 | } 157 | for (const key of keys) { 158 | apiCache.del(key); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /whois/start/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import store from './common/store'; 5 | import { Provider } from 'react-redux'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | 13 | , 14 | document.getElementById('root'), 15 | ); 16 | -------------------------------------------------------------------------------- /whois/start/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | --------------------------------------------------------------------------------