├── .github └── ISSUE_TEMPLATE │ └── bug-report.md ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── client ├── .gitignore ├── .prettierrc ├── README.md ├── dev.env ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── static-logo.png ├── src │ ├── App.js │ ├── Router.js │ ├── assets │ │ └── images │ │ │ ├── bronzeMedal.png │ │ │ ├── checkMark.png │ │ │ ├── click.png │ │ │ ├── deleteButton.png │ │ │ ├── edit_room.png │ │ │ ├── emptyImage.png │ │ │ ├── goldMedal.png │ │ │ ├── multiple_choice.svg │ │ │ ├── naverLoginButton.PNG │ │ │ ├── naverLoginButton_long.PNG │ │ │ ├── silverMedal.png │ │ │ ├── transparency.png │ │ │ └── trash.png │ ├── components │ │ ├── common │ │ │ ├── Buttons.js │ │ │ ├── CopyrightFooter.js │ │ │ ├── Dashboard.js │ │ │ ├── FlexibleInput.js │ │ │ ├── GoBackButton.js │ │ │ ├── Header.js │ │ │ ├── InformationArea.js │ │ │ ├── Loading.js │ │ │ ├── LoadingCircle.js │ │ │ ├── MainContainer.js │ │ │ ├── Modal.js │ │ │ ├── ModalProvider.js │ │ │ ├── ScoreChart.js │ │ │ └── ToastProvider.js │ │ ├── detailRoom │ │ │ ├── QuizList.js │ │ │ ├── QuizTab.js │ │ │ ├── RoomInformation.js │ │ │ └── TabContents.js │ │ ├── edit │ │ │ ├── EditContextProvider.js │ │ │ ├── ImageField.js │ │ │ ├── Item.js │ │ │ ├── ItemContainer.js │ │ │ ├── OptionPanel.js │ │ │ ├── RangeInput.js │ │ │ ├── SaveButton.js │ │ │ ├── Section.js │ │ │ ├── SideBar.js │ │ │ ├── Thumbnail.js │ │ │ ├── TimeLimitPicker.js │ │ │ └── Title.js │ │ ├── inGame │ │ │ ├── HostFooter.js │ │ │ ├── HostLoading.js │ │ │ ├── HostPlaying.js │ │ │ ├── HostQuizPlayingRoom.js │ │ │ ├── HostResult.js │ │ │ ├── HostSubResult.js │ │ │ ├── HostWaitingRoom.js │ │ │ ├── Hourglass.js │ │ │ ├── Layout.js │ │ │ ├── PlayerFooter.js │ │ │ ├── PlayerQuiz.js │ │ │ ├── PlayerQuizLoading.js │ │ │ ├── PlayerResult.js │ │ │ ├── PlayerSubResult.js │ │ │ ├── PlayerWaiting.js │ │ │ ├── PlayerWarning.js │ │ │ └── ProgressBar.js │ │ ├── logo │ │ │ ├── Logo.css │ │ │ └── Logo.js │ │ ├── mainPage │ │ │ ├── EnterNickname.js │ │ │ ├── EnterRoomNumber.js │ │ │ └── NaverLogin.js │ │ └── selectRoom │ │ │ └── RoomList.js │ ├── constants │ │ ├── colors.js │ │ ├── domain.js │ │ └── media.js │ ├── index.css │ ├── index.js │ ├── pages │ │ ├── Gameover.js │ │ ├── MainPage.js │ │ ├── host │ │ │ ├── EditPage.js │ │ │ ├── HostDetailRoom.js │ │ │ ├── HostGameRoom.js │ │ │ └── SelectRoom.js │ │ ├── login │ │ │ └── CallBackPage.js │ │ └── player │ │ │ └── PlayerGameRoom.js │ ├── reducer │ │ ├── hostEditReducer.js │ │ └── hostGameReducer.js │ ├── styles │ │ └── common.js │ └── utils │ │ ├── caseChanger.js │ │ ├── fetch.js │ │ ├── naverLoginSdk.js │ │ └── util.js └── yarn.lock ├── docs ├── logogif.gif ├── structure.png ├── technology_stack.png └── thumbnail.gif ├── package.json ├── server ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── app.js ├── bin │ └── www ├── constants │ └── tableName.js ├── dev.env ├── middleware │ └── validations.js ├── models │ ├── database │ │ ├── dbManager.js │ │ └── tables │ │ │ ├── Analysis.js │ │ │ ├── Item.js │ │ │ ├── Quiz.js │ │ │ ├── Quizset.js │ │ │ ├── Room.js │ │ │ ├── Table.js │ │ │ └── User.js │ ├── inMemory.js │ ├── rooms.js │ └── templates │ │ ├── quiz.js │ │ └── room.js ├── objectStorage.js ├── package.json ├── routes │ ├── api.js │ └── apis │ │ ├── edit.js │ │ ├── game.js │ │ ├── login.js │ │ ├── room.js │ │ └── user.js ├── socket.js ├── utils │ └── checkJsonHasKeys.js └── yarn.lock └── yarn.lock /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: "피키포키에 관심을 가져주셔서 감사합니다\U0001F91E" 4 | title: '' 5 | labels: "\U0001F622bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | 피키포키에 관심을 가져주셔서 감사합니다🤞 11 | 12 | ## 어떤 버그가 발생했나요? 13 | > 참고할만한 이미지도 첨부해주시면 많은 도움이 될 거에요.😊 14 | 15 | 16 | ### 버그의 발생 과정을 알려주세요 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ### 예상했던 결과는 무엇이었나요? 23 | 24 | 25 | ## 특이 사항이 있었다면 알려주세요! 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": ["client", "server"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 CONNECT FOUNDATION & TEAM Pickyforcky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.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 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # 피키포키 클라이언트 사이드 -------------------------------------------------------------------------------- /client/dev.env: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_HOST= 2 | REACT_APP_NAVER_LOGIN_API_CLIENT_ID= 3 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "dotenv": "^8.2.0", 7 | "eslint-config-prettier": "^6.5.0", 8 | "prop-types": "^15.7.2", 9 | "react": "^16.11.0", 10 | "react-dom": "^16.11.0", 11 | "react-dropzone": "^10.2.1", 12 | "react-router": "^5.1.2", 13 | "react-router-dom": "^5.1.2", 14 | "react-scripts": "3.2.0", 15 | "socket.io-client": "^2.3.0", 16 | "styled-components": "^4.4.1" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "eslint": "6.1.0", 38 | "eslint-config-airbnb": "^18.0.1", 39 | "eslint-plugin-import": "2.18.2", 40 | "eslint-plugin-jsx-a11y": "6.2.3", 41 | "eslint-plugin-react": "^7.16.0", 42 | "eslint-plugin-react-hooks": "1.7.0" 43 | }, 44 | "eslintConfig": { 45 | "root": true, 46 | "extends": [ 47 | "airbnb", 48 | "prettier" 49 | ], 50 | "rules": { 51 | "react/prefer-stateless-function": 0, 52 | "react/jsx-one-expression-per-line": 0, 53 | "linebreak-style": 0, 54 | "react/jsx-filename-extension": [ 55 | 1, 56 | { 57 | "extensions": [ 58 | ".js", 59 | ".jsx" 60 | ] 61 | } 62 | ], 63 | "quotes": [ 64 | "error", 65 | "single", 66 | { 67 | "allowTemplateLiterals": true 68 | } 69 | ] 70 | }, 71 | "env": { 72 | "browser": true 73 | } 74 | }, 75 | "proxy": "http://localhost:3001/" 76 | } 77 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 피키포키 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /client/public/static-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/public/static-logo.png -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Router from './Router'; 3 | import ToastProvider from './components/common/ToastProvider'; 4 | import ModalProvider from './components/common/ModalProvider'; 5 | 6 | function App() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /client/src/Router.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 3 | import MainPage from './pages/MainPage'; 4 | import HostGameRoom from './pages/host/HostGameRoom'; 5 | import HostDetailRoom from './pages/host/HostDetailRoom'; 6 | import EditPage from './pages/host/EditPage'; 7 | import PlayerGameRoom from './pages/player/PlayerGameRoom'; 8 | import CallBackPage from './pages/login/CallBackPage'; 9 | import SelectRoom from './pages/host/SelectRoom'; 10 | import GameOver from './pages/Gameover'; 11 | 12 | export default function() { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /client/src/assets/images/bronzeMedal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/bronzeMedal.png -------------------------------------------------------------------------------- /client/src/assets/images/checkMark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/checkMark.png -------------------------------------------------------------------------------- /client/src/assets/images/click.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/click.png -------------------------------------------------------------------------------- /client/src/assets/images/deleteButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/deleteButton.png -------------------------------------------------------------------------------- /client/src/assets/images/edit_room.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/edit_room.png -------------------------------------------------------------------------------- /client/src/assets/images/emptyImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/emptyImage.png -------------------------------------------------------------------------------- /client/src/assets/images/goldMedal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/goldMedal.png -------------------------------------------------------------------------------- /client/src/assets/images/multiple_choice.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/assets/images/naverLoginButton.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/naverLoginButton.PNG -------------------------------------------------------------------------------- /client/src/assets/images/naverLoginButton_long.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/naverLoginButton_long.PNG -------------------------------------------------------------------------------- /client/src/assets/images/silverMedal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/silverMedal.png -------------------------------------------------------------------------------- /client/src/assets/images/transparency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/transparency.png -------------------------------------------------------------------------------- /client/src/assets/images/trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/client/src/assets/images/trash.png -------------------------------------------------------------------------------- /client/src/components/common/Buttons.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import * as colors from '../../constants/colors'; 5 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 6 | 7 | const ButtonWrapper = styled.div.attrs({ 8 | className: 'buttonWrapper', 9 | })` 10 | position: relative; 11 | display: flex; 12 | `; 13 | 14 | const ButtonTop = styled.button` 15 | position: relative; 16 | flex: 1; 17 | background-color: ${props => props.backgroundColor}; 18 | color: ${props => props.fontColor}; 19 | filter: brightness(100%); 20 | border-radius: 0.5rem; 21 | border: none; 22 | font-weight: bold; 23 | font-size: 1.5rem; 24 | padding: 0.5rem; 25 | text-shadow: black 0.8px 0.8px; 26 | user-select: none; 27 | transform: translateY(-0.3rem); 28 | transition: transform 0.1s; 29 | cursor: pointer; 30 | 31 | &:active { 32 | transform: translateY(-0.1rem); 33 | filter: brightness(95%); 34 | } 35 | &:focus { 36 | outline: 0; 37 | } 38 | 39 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 40 | &:hover { 41 | transform: translateY(-0.1rem); 42 | } 43 | font-size: 2rem; 44 | padding: 1rem; 45 | } 46 | `; 47 | 48 | const ButtonBottom = styled.div` 49 | position: absolute; 50 | height: 100%; 51 | width: 100%; 52 | background-color: ${props => props.backgroundColor}; 53 | filter: brightness(50%); 54 | border-radius: 0.5rem; 55 | box-shadow: 0 0.2rem 0.3rem 0.1rem gray; 56 | `; 57 | 58 | function Button({ children, backgroundColor, fontColor, onClick }) { 59 | return ( 60 | 61 | 62 | 67 | {children} 68 | 69 | 70 | ); 71 | } 72 | 73 | function YellowButton({ children, onClick }) { 74 | return ( 75 | 82 | ); 83 | } 84 | 85 | function GreenButton({ children, onClick }) { 86 | return ( 87 | 94 | ); 95 | } 96 | 97 | function WhiteButton({ children, onClick }) { 98 | return ( 99 | 106 | ); 107 | } 108 | 109 | function GrayButton({ children, onClick }) { 110 | return ( 111 | 118 | ); 119 | } 120 | 121 | Button.defaultProps = { 122 | backgroundColor: colors.BACKGROUND_DEEP_GRAY, 123 | fontColor: colors.TEXT_BLACK, 124 | onClick: undefined, 125 | }; 126 | 127 | const customButtonDefaultProps = { 128 | onClick: undefined, 129 | }; 130 | 131 | GreenButton.defaultProps = customButtonDefaultProps; 132 | YellowButton.defaultProps = customButtonDefaultProps; 133 | WhiteButton.defaultProps = customButtonDefaultProps; 134 | GrayButton.defaultProps = customButtonDefaultProps; 135 | 136 | Button.propTypes = { 137 | children: PropTypes.node.isRequired, 138 | backgroundColor: PropTypes.string, 139 | fontColor: PropTypes.string, 140 | onClick: PropTypes.func, 141 | }; 142 | 143 | const customButtonPropTypes = { 144 | children: PropTypes.node.isRequired, 145 | onClick: PropTypes.func, 146 | }; 147 | 148 | YellowButton.propTypes = customButtonPropTypes; 149 | GreenButton.propTypes = customButtonPropTypes; 150 | WhiteButton.propTypes = customButtonPropTypes; 151 | GrayButton.propTypes = customButtonPropTypes; 152 | 153 | export { Button, YellowButton, GreenButton, WhiteButton, GrayButton }; 154 | -------------------------------------------------------------------------------- /client/src/components/common/CopyrightFooter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const FOOTER_MARGIN_BOTTOM = '1.5rem'; 5 | 6 | const FooterStyle = styled.footer` 7 | justify-self: flex-end; 8 | flex: 0 0 auto; 9 | font-size: 2vmin; 10 | margin-bottom: ${FOOTER_MARGIN_BOTTOM}; 11 | `; 12 | 13 | const Contact = styled.a.attrs({ 14 | href: 'https://github.com/connect-foundation/2019-07', 15 | target: '_blank', 16 | })` 17 | ::before { 18 | content: '👉 '; 19 | } 20 | 21 | text-decoration: none; 22 | &:visited, 23 | &:link { 24 | color: black; 25 | } 26 | `; 27 | 28 | function Footer() { 29 | return ( 30 | 31 |
32 | Create your own pickyforky for FREE 33 |
34 |
35 | Contact Us! 36 |
37 |
38 | ); 39 | } 40 | 41 | export default Footer; 42 | -------------------------------------------------------------------------------- /client/src/components/common/GoBackButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory } from 'react-router'; 3 | import styled, { css, keyframes } from 'styled-components'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | 7 | const CONTAINER_HEIGHT = '5vmin'; 8 | const CONTAINER_WIDTH = `calc(${CONTAINER_HEIGHT} * 1.5)`; 9 | const ARROW_HEIGHT = `calc(${CONTAINER_HEIGHT} / 6)`; 10 | const ANIMATION_DELAY = 0.25; 11 | const progresses = [0, 1, 2, 3]; 12 | 13 | const Container = styled.div` 14 | position: absolute; 15 | left: 0; 16 | width: ${CONTAINER_WIDTH}; 17 | height: ${CONTAINER_HEIGHT}; 18 | padding: 0 2vmin; 19 | `; 20 | 21 | const ButtonArea = styled.div` 22 | position: relative; 23 | width: 100%; 24 | height: 100%; 25 | cursor: pointer; 26 | `; 27 | 28 | const ArrowWrapper = styled.div` 29 | position: absolute; 30 | transform: translate(${props => `${props.margin}, ${props.margin}`}); 31 | width: 100%; 32 | height: 100%; 33 | display: flex; 34 | align-items: center; 35 | `; 36 | 37 | const ArrowStyle = css` 38 | position: absolute; 39 | display: flex; 40 | height: ${ARROW_HEIGHT}; 41 | border-radius: 10000px; 42 | padding: 1px; 43 | `; 44 | 45 | const ArrowBody = styled.div` 46 | ${ArrowStyle}; 47 | width: ${CONTAINER_WIDTH}; 48 | height: ${ARROW_HEIGHT}; 49 | background-color: ${props => props.color}; 50 | `; 51 | 52 | const ArrowHead = styled.div` 53 | ${ArrowStyle}; 54 | width: calc(${CONTAINER_HEIGHT} / 1.5); 55 | background-color: ${props => props.color}; 56 | transform-origin: calc(0% + ${ARROW_HEIGHT} / 2) 50%; 57 | transform: rotateZ(${props => props.degree}deg); 58 | `; 59 | 60 | const ProgressAnimation = keyframes` 61 | from{ 62 | opacity: 0; 63 | } 64 | to{ 65 | opacity: 1; 66 | } 67 | `; 68 | 69 | const Progress = styled.div` 70 | position: relative; 71 | flex: 1; 72 | background-color: ${colors.PRIMARY_DEEP_GREEN}; 73 | margin: 0 1px; 74 | border-radius: 100000px; 75 | 76 | animation-name: ${ProgressAnimation}; 77 | animation-duration: ${ANIMATION_DELAY * progresses.length}s; 78 | animation-iteration-count: infinite; 79 | animation-timing-function: linear; 80 | animation-delay: ${props => props.delay}s; 81 | `; 82 | 83 | function GoBackButton() { 84 | const history = useHistory(); 85 | return ( 86 | 87 | history.goBack()}> 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | {progresses.map(item => ( 98 | 99 | ))} 100 | 101 | 102 | 103 | 104 | ); 105 | } 106 | 107 | export default GoBackButton; 108 | -------------------------------------------------------------------------------- /client/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory } from 'react-router'; 3 | import styled from 'styled-components'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import * as colors from '../../constants/colors'; 7 | import GoBackButton from './GoBackButton'; 8 | 9 | const HEADER_HEIGHT = '10vmin'; 10 | 11 | const HeaderArea = styled.div` 12 | position: relative; 13 | flex: none; 14 | width: 100%; 15 | height: ${HEADER_HEIGHT}; 16 | `; 17 | 18 | const HeaderStyle = styled.header` 19 | position: fixed; 20 | width: 100%; 21 | height: ${HEADER_HEIGHT}; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | background-color: ${colors.PRIMARY_DEEP_GREEN}; 26 | z-index: 900; 27 | `; 28 | 29 | const ServiceLogoImage = styled.img.attrs({ 30 | src: '../../static-logo.png', 31 | })` 32 | height: 60%; 33 | cursor: pointer; 34 | user-select: none; 35 | `; 36 | 37 | const ButtonWrapper = styled.div` 38 | position: absolute; 39 | right: 2vmin; 40 | button { 41 | padding: 1vmin 1.5vmin; 42 | font-size: 3.5vmin; 43 | transform: translateY(-0.4vmin); 44 | } 45 | `; 46 | 47 | function Header({ button }) { 48 | const history = useHistory(); 49 | return ( 50 | 51 | 52 | 53 | history.push('/')} /> 54 | {button} 55 | 56 | 57 | ); 58 | } 59 | 60 | Header.defaultProps = { 61 | button: '', 62 | }; 63 | 64 | Header.propTypes = { 65 | button: PropTypes.node, 66 | }; 67 | 68 | export default Header; 69 | -------------------------------------------------------------------------------- /client/src/components/common/InformationArea.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const InformationAreaStyle = styled.div` 6 | position: relative; 7 | display: flex; 8 | justify-content: space-between; 9 | font-weight: bold; 10 | font-size: 4vmin; 11 | color: black; 12 | margin-bottom: 4vmin; 13 | `; 14 | 15 | function InformationArea({ children }) { 16 | return {children}; 17 | } 18 | 19 | InformationArea.propTypes = { 20 | children: PropTypes.node.isRequired, 21 | }; 22 | 23 | export default InformationArea; 24 | -------------------------------------------------------------------------------- /client/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import LoadingCircle from './LoadingCircle'; 5 | 6 | const LoadingContainer = styled.div` 7 | position: absolute; 8 | top: 0; 9 | bottom: 0; 10 | left: 0; 11 | right: 0; 12 | z-index: 9999; 13 | &::before { 14 | position: absolute; 15 | content: ''; 16 | background-color: black; 17 | opacity: 0.7; 18 | width: 100%; 19 | height: 100%; 20 | } 21 | `; 22 | 23 | const LoadingText = styled.span` 24 | position: absolute; 25 | color: white; 26 | font-weight: bold; 27 | font-size: 4vmin; 28 | width: 100%; 29 | text-align: center; 30 | top: 80%; 31 | transform: translateY(-100%); 32 | user-select: none; 33 | `; 34 | 35 | function Loading({ message }) { 36 | return ( 37 | 38 | 39 | {message} 40 | 41 | ); 42 | } 43 | Loading.defaultProps = { 44 | message: '로딩 중입니다', 45 | }; 46 | Loading.propTypes = { 47 | message: PropTypes.string, 48 | }; 49 | export default Loading; 50 | -------------------------------------------------------------------------------- /client/src/components/common/LoadingCircle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const degrees = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]; 6 | 7 | const Container = styled.div` 8 | position: absolute; 9 | display: flex; 10 | justify-content: center; 11 | top: 50%; 12 | left: 50%; 13 | transform: translate(-50%, -50%); 14 | width: ${props => props.size}; 15 | height: ${props => props.size}; 16 | `; 17 | 18 | const CapsuleWrapper = styled.div` 19 | position: absolute; 20 | width: calc(${props => props.size} / 12); 21 | height: calc(${props => props.size} / 2); 22 | transform: rotateZ(${props => props.degree}deg); 23 | transform-origin: 50% 100%; 24 | `; 25 | 26 | const Animation = keyframes` 27 | from{ 28 | opacity: 1; 29 | } 30 | to{ 31 | opacity: 0; 32 | } 33 | `; 34 | 35 | const Capsule = styled.div` 36 | position: absolute; 37 | width: 100%; 38 | height: 40%; 39 | background-color: ${props => props.color}; 40 | border-radius: 10000px; 41 | 42 | animation-name: ${Animation}; 43 | animation-iteration-count: infinite; 44 | animation-timing-function: linear; 45 | animation-duration: 1.2s; 46 | animation-delay: ${props => props.delay}; 47 | `; 48 | 49 | function LoadingCircle({ size, color }) { 50 | return ( 51 | 52 | {degrees.map((degree, index) => ( 53 | 54 | 55 | 56 | ))} 57 | 58 | ); 59 | } 60 | 61 | LoadingCircle.defaultProps = { 62 | size: '15vw', 63 | color: 'salmon', 64 | }; 65 | 66 | LoadingCircle.propTypes = { 67 | size: PropTypes.string, 68 | color: PropTypes.string, 69 | }; 70 | 71 | export default LoadingCircle; 72 | 73 | //https://loading.io/css/ 74 | -------------------------------------------------------------------------------- /client/src/components/common/MainContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | 7 | const Main = styled.main` 8 | position: relative; 9 | display: flex; 10 | flex-direction: column; 11 | flex: 1; 12 | background-color: ${colors.BACKGROUND_LIGHT_GRAY}; 13 | padding: 4vmin 4vmin 2vmin 4vmin; 14 | `; 15 | 16 | function MainContainer({ children }) { 17 | return
{children}
; 18 | } 19 | 20 | MainContainer.propTypes = { 21 | children: PropTypes.node.isRequired, 22 | }; 23 | 24 | export default MainContainer; 25 | -------------------------------------------------------------------------------- /client/src/components/common/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import * as colors from '../../constants/colors'; 4 | import { Button, YellowButton } from './Buttons'; 5 | import { ModalContext } from './ModalProvider'; 6 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 7 | 8 | const ModalOutside = styled.div` 9 | position: fixed; 10 | top: 0; 11 | bottom: 0; 12 | left: 0; 13 | right: 0; 14 | background-color: rgba(0, 0, 0, 0.7); 15 | z-index: 999; 16 | `; 17 | 18 | const ModalMain = styled.main` 19 | position: relative; 20 | top: 50%; 21 | left: 50%; 22 | width: 25rem; 23 | transform: translate(-50%, -50%); 24 | background-color: white; 25 | border-radius: 5px; 26 | padding: 2rem; 27 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 28 | width: 40rem; 29 | } 30 | `; 31 | 32 | const ModalTitle = styled.div` 33 | color: ${colors.TEXT_BLACK}; 34 | font-size: 2rem; 35 | font-weight: bold; 36 | text-align: center; 37 | margin-bottom: 1rem; 38 | padding-bottom: 1rem; 39 | border-bottom: 1px solid lightgray; 40 | `; 41 | 42 | const ModalButtons = styled.div` 43 | display: flex; 44 | width: 100%; 45 | justify-content: space-between; 46 | margin-top: 2rem; 47 | 48 | div.buttonWrapper { 49 | width: calc(50% - 1vw); 50 | } 51 | button { 52 | font-size: 1.5rem; 53 | 54 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 55 | font-size: 2rem; 56 | } 57 | } 58 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 59 | margin-top: 2rem; 60 | justify-content: space-evenly; 61 | } 62 | `; 63 | 64 | const Description = styled.div` 65 | margin-bottom: 1rem; 66 | color: ${colors.TEXT_GRAY}; 67 | word-break: keep-all; 68 | text-align: center; 69 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 70 | font-size: 1.5rem; 71 | } 72 | `; 73 | 74 | function Modal({ 75 | children, 76 | title, 77 | description, 78 | closeButton, 79 | actionButton, 80 | action, 81 | }) { 82 | const { closeModal, isModalOn } = useContext(ModalContext); 83 | 84 | return ( 85 | isModalOn && ( 86 | 87 | e.preventDefault()}> 88 | {title} 89 | {description && {description}} 90 | {children} 91 | 92 | {closeButton && } 93 | { 95 | if (action && !action()) return; 96 | closeModal(); 97 | }} 98 | > 99 | {actionButton} 100 | 101 | 102 | 103 | 104 | ) 105 | ); 106 | } 107 | 108 | Modal.defaultProps = { 109 | actionButton: '확인', 110 | }; 111 | 112 | export default Modal; 113 | -------------------------------------------------------------------------------- /client/src/components/common/ModalProvider.js: -------------------------------------------------------------------------------- 1 | import React, { useState, createContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export const ModalContext = createContext(); 5 | 6 | function ModalProvider({ children }) { 7 | const [isModalOn, setModalOn] = useState(false); 8 | 9 | function closeModal(e) { 10 | if (e && e.defaultPrevented) return; 11 | setModalOn(false); 12 | } 13 | 14 | function openModal() { 15 | setModalOn(true); 16 | } 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | ModalProvider.propTypes = { 26 | children: PropTypes.node.isRequired, 27 | }; 28 | 29 | export default ModalProvider; 30 | -------------------------------------------------------------------------------- /client/src/components/common/ScoreChart.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import * as colors from '../../constants/colors'; 5 | 6 | const graphMargin = '0.75vw'; 7 | const countFontSize = '3vw'; 8 | const answerFontSize = '2vw'; 9 | 10 | const getItemColor = index => 11 | index < colors.ITEM_COLOR.length ? colors.ITEM_COLOR[index] : 'salmon'; 12 | 13 | const Container = styled.div.attrs({ 14 | className: 'scoreChartContainer', 15 | })` 16 | position: absolute; 17 | display: flex; 18 | justify-items: center; 19 | width: 90%; 20 | height: 90%; 21 | top: 50%; 22 | left: 50%; 23 | transform: translate(-50%, -50%); 24 | `; 25 | 26 | const GraphWrapper = styled.div` 27 | display: flex; 28 | flex-direction: column-reverse; 29 | position: relative; 30 | height: 100%; 31 | width: ${props => props.width}; 32 | margin: auto; 33 | overflow: hidden; 34 | `; 35 | 36 | const GraphBottom = styled.div` 37 | display: inline-flex; 38 | flex: none; 39 | justify-content: center; 40 | align-items: center; 41 | width: 100%; 42 | border-radius: 0.4rem; 43 | background-color: ${props => getItemColor(props.index)}; 44 | margin-top: ${graphMargin}; 45 | `; 46 | 47 | const ItemTitle = styled.span` 48 | max-width: 100%; 49 | max-height: 100%; 50 | color: white; 51 | font-size: 1.5vw; 52 | font-weight: bold; 53 | overflow: hidden; 54 | text-overflow: ellipsis; 55 | white-space: nowrap; 56 | `; 57 | 58 | const GraphTopWrapper = styled.div` 59 | display: flex; 60 | flex-direction: column-reverse; 61 | flex: 1; 62 | width: 100%; 63 | `; 64 | 65 | const GraphTopLimiter = styled.div` 66 | width: 100%; 67 | height: calc(${graphMargin} + ${countFontSize} + 1.5vw); 68 | `; 69 | 70 | const GraphTop = styled.div` 71 | position: relative; 72 | width: 100%; 73 | background-color: ${props => getItemColor(props.index)}; 74 | animation-name: ${props => props.animationName}; 75 | animation-fill-mode: forwards; 76 | animation-duration: 2s; 77 | animation-timing-function: linear; 78 | `; 79 | 80 | const GraphCount = styled.span` 81 | position: absolute; 82 | width: 100%; 83 | text-align: center; 84 | top: calc(-${graphMargin} - ${countFontSize} - 1vw); 85 | font-size: ${countFontSize}; 86 | font-weight: bold; 87 | color: ${props => getItemColor(props.index)}; 88 | `; 89 | 90 | const AnswerMark = styled.span` 91 | background-color: ${props => colors.ITEM_COLOR[props.index]}; 92 | color: white; 93 | padding: 0 0.5vw; 94 | border-radius: 0.5vw; 95 | font-size: ${answerFontSize}; 96 | margin-right: 0.5vw; 97 | `; 98 | 99 | function findMaxHandler(previous, current) { 100 | return previous.playerCount > current.playerCount ? previous : current; 101 | } 102 | 103 | function findMaxCount(array) { 104 | return array.reduce(findMaxHandler).playerCount; 105 | } 106 | 107 | function getAnimation(item, maxCount) { 108 | const scorePercent = (item.playerCount / maxCount) * 100; 109 | const animationName = keyframes` 110 | from{ 111 | height: 0%; 112 | } 113 | ${scorePercent}%{ 114 | height: ${scorePercent}%; 115 | } 116 | to{ 117 | height: ${scorePercent}%; 118 | } 119 | `; 120 | return animationName; 121 | } 122 | 123 | function convertDatasToItems(array, setState) { 124 | const maxCount = findMaxCount(array); 125 | for (let index = 0; index < array.length; index += 1) { 126 | const item = array[index]; 127 | const animation = getAnimation(item, maxCount); 128 | item.animationName = animation; 129 | } 130 | setState(array); 131 | } 132 | 133 | function ScoreChart({ itemDatas }) { 134 | const [items, setItems] = useState([]); 135 | const graphWidth = `calc(${100 / items.length}% - ${graphMargin})`; 136 | useEffect(() => { 137 | convertDatasToItems(itemDatas, setItems); 138 | }, []); 139 | return ( 140 | 141 | {items.map((item, index) => { 142 | if (!item.title) return null; 143 | return ( 144 | 145 | 146 | {item.title} 147 | 148 | 149 | 150 | 151 | {item.isAnswer && 정답} 152 | {item.playerCount} 153 | 154 | 155 | 156 | 157 | 158 | ); 159 | })} 160 | 161 | ); 162 | } 163 | ScoreChart.propTypes = { 164 | itemDatas: PropTypes.arrayOf( 165 | PropTypes.shape({ 166 | title: PropTypes.string.isRequired, 167 | playerCount: PropTypes.number.isRequired, 168 | isAnswer: PropTypes.bool.isRequired, 169 | }), 170 | ).isRequired, 171 | }; 172 | export default ScoreChart; 173 | -------------------------------------------------------------------------------- /client/src/components/common/ToastProvider.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useEffect } from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import * as colors from '../../constants/colors'; 5 | 6 | export const ToastContext = createContext(); 7 | 8 | const ToastMessageBackground = styled.div` 9 | position: fixed; 10 | width: 100%; 11 | bottom: 0; 12 | height: 6rem; 13 | background-color: ${colors.PRIMARY_DEEP_GREEN}; 14 | transform: translateY(100%); 15 | 16 | animation-name: ${props => props.animationName}; 17 | animation-duration: 3s; 18 | animation-timing-function: linear; 19 | `; 20 | 21 | const ToastMessageContent = styled.span` 22 | position: absolute; 23 | font-weight: bold; 24 | font-size: 2rem; 25 | color: ${colors.TEXT_WHITE}; 26 | top: 50%; 27 | transform: translateY(-50%); 28 | margin: 0 1rem; 29 | `; 30 | 31 | function ToastProvider({ children }) { 32 | const [isToastOn, setToastOn] = useState(false); 33 | const [isTriggerOn, setTrigger] = useState(false); 34 | const [message, setMessage] = useState(''); 35 | 36 | useEffect(() => { 37 | if (isTriggerOn) { 38 | setTrigger(false); 39 | } 40 | }, [isTriggerOn]); 41 | 42 | function onToast(toastMessage) { 43 | setMessage(toastMessage); 44 | setTrigger(true); 45 | setToastOn(true); 46 | } 47 | 48 | function offToast() { 49 | setToastOn(false); 50 | } 51 | 52 | function ToastMessage() { 53 | const PopUp = keyframes` 54 | 0% { 55 | transform: translateY(100%); 56 | } 57 | 10% { 58 | transform: translateY(0%); 59 | } 60 | 90%{ 61 | transform: translateY(0%); 62 | } 63 | 100%{ 64 | transform: translateY(100%); 65 | } 66 | `; 67 | const animationName = isToastOn ? PopUp : 'none'; 68 | 69 | return ( 70 | 74 | {message} 75 | 76 | ); 77 | } 78 | 79 | return ( 80 | 87 | {children} 88 | 89 | ); 90 | } 91 | 92 | ToastProvider.propTypes = { 93 | children: PropTypes.node.isRequired, 94 | }; 95 | export default ToastProvider; 96 | -------------------------------------------------------------------------------- /client/src/components/detailRoom/QuizList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import { TEXT_BLACK, BACKGROUND_LIGHT_WHITE } from '../../constants/colors'; 5 | import multipleChoiceImage from '../../assets/images/multiple_choice.svg'; 6 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 7 | 8 | const QuizSet = styled.div` 9 | display: flex; 10 | position: relative; 11 | background-color: ${BACKGROUND_LIGHT_WHITE}; 12 | margin-bottom: 2vmin; 13 | border-radius: 5px; 14 | box-shadow: 1px 1px 3px gray; 15 | padding: 1rem; 16 | color: ${TEXT_BLACK}; 17 | flex-wrap: nowrap; 18 | `; 19 | 20 | const QuizImage = styled.img.attrs({ 21 | src: multipleChoiceImage, 22 | })` 23 | width: 10rem; 24 | margin-right: 1rem; 25 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 26 | width: 20rem; 27 | } 28 | `; 29 | 30 | const QuizInformation = styled.div` 31 | width: calc(100% - 11rem); 32 | h1 { 33 | overflow: hidden; 34 | white-space: nowrap; 35 | text-overflow: ellipsis; 36 | } 37 | p { 38 | display: inline-block; 39 | font-size: 1.5rem; 40 | position: absolute; 41 | bottom: 1rem; 42 | span:last-child { 43 | margin-left: 1rem; 44 | } 45 | } 46 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 47 | max-width: calc(100% - 22rem); 48 | span { 49 | font-size: 1.5rem; 50 | } 51 | h1 { 52 | font-size: 2.5rem; 53 | } 54 | } 55 | `; 56 | 57 | function QuizList({ quizData }) { 58 | return ( 59 | <> 60 | {quizData.map(quiz => ( 61 | 62 | 63 | 64 | 문제 {quiz.quiz_order + 1} 65 |

{quiz.title}

66 |

67 | 68 | 🎉 69 | 70 | {quiz.score}점 71 | 72 | ⏱ 73 | 74 | {quiz.time_limit}초 75 |

76 |
77 |
78 | ))} 79 | 80 | ); 81 | } 82 | 83 | QuizList.propTypes = { 84 | quizData: PropTypes.arrayOf(PropTypes.object).isRequired, 85 | }; 86 | 87 | export default QuizList; 88 | -------------------------------------------------------------------------------- /client/src/components/detailRoom/QuizTab.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import { useHistory } from 'react-router'; 5 | 6 | import { YellowButton } from '../common/Buttons'; 7 | import { readQuizset, readQuizsetId } from '../../utils/fetch'; 8 | import QuizList from './QuizList'; 9 | import RoomInformation from './RoomInformation'; 10 | import InformationArea from '../common/InformationArea'; 11 | import MainContainer from '../common/MainContainer'; 12 | import Loading from '../common/Loading'; 13 | 14 | const ButtonContainer = styled.div` 15 | position: relative; 16 | button { 17 | font-size: 3vmin; 18 | padding: 0.75vmin 1.25vmin; 19 | transform: translateY(-0.4vmin); 20 | } 21 | `; 22 | 23 | function QuizTab({ roomId, setId }) { 24 | const history = useHistory(); 25 | const [quizsetId, setQuizsetId] = useState(undefined); 26 | const [isLoading, setLoading] = useState(true); 27 | const [quizData, setQuizdata] = useState([]); 28 | 29 | function editPage() { 30 | history.push({ 31 | pathname: '/edit', 32 | state: { 33 | roomId, 34 | quizsetId, 35 | }, 36 | }); 37 | } 38 | 39 | useEffect(() => { 40 | const initQuizData = [ 41 | { 42 | id: -1, 43 | quiz_order: -1, 44 | title: '퀴즈가 없으면 어떻게 될까요? 퀴즈를 생성하세요!', 45 | score: 0, 46 | time_limit: 'infinity', 47 | }, 48 | ]; 49 | 50 | async function getQuizsetId() { 51 | const { isSuccess, data } = await readQuizsetId(roomId); 52 | if (!isSuccess) { 53 | setQuizdata(initQuizData); 54 | setLoading(false); 55 | return; 56 | } 57 | setQuizsetId(data.quizsetId); 58 | setId(data.quizsetId); 59 | } 60 | 61 | getQuizsetId(); 62 | 63 | if (!quizsetId) { 64 | return; 65 | } 66 | 67 | async function getQuizset(count) { 68 | if (count === 0) { 69 | alert('오류로 인해 퀴즈 데이터를 받는 데 실패했습니다'); 70 | setQuizdata(initQuizData); 71 | return; 72 | } 73 | 74 | const { isSuccess, data } = await readQuizset(quizsetId); 75 | 76 | if (!isSuccess) { 77 | getQuizset(count - 1); 78 | return; 79 | } 80 | 81 | const quizset = data.quizset.sort((quiz1, quiz2) => { 82 | return quiz1.quiz_order - quiz2.quiz_order; 83 | }); 84 | 85 | setLoading(false); 86 | setQuizdata(quizset); 87 | } 88 | 89 | getQuizset(3); 90 | }, [quizsetId]); 91 | 92 | return ( 93 | 94 | 95 | 96 | 97 | 98 | {quizsetId === undefined ? '퀴즈 생성' : '퀴즈 편집'} 99 | 100 | 101 | 102 | {isLoading ? ( 103 | 104 | ) : ( 105 | 106 | )} 107 | 108 | ); 109 | } 110 | 111 | QuizTab.propTypes = { 112 | roomId: PropTypes.number.isRequired, 113 | setId: PropTypes.func.isRequired, 114 | }; 115 | 116 | export default QuizTab; 117 | -------------------------------------------------------------------------------- /client/src/components/detailRoom/RoomInformation.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 6 | import { fetchRoomTitle, updateRoomTitle } from '../../utils/fetch'; 7 | import Modal from '../common/Modal'; 8 | import FlexibleInput from '../common/FlexibleInput'; 9 | import { ModalContext } from '../common/ModalProvider'; 10 | import editRoomImage from '../../assets/images/edit_room.png'; 11 | 12 | const TitleUpdateContainer = styled.div.attrs({ 13 | title: '방 이름 수정하기', 14 | })` 15 | position: relative; 16 | height: 100%; 17 | user-select: none; 18 | text-decoration: underline; 19 | cursor: pointer; 20 | `; 21 | 22 | const EditRoomNameImage = styled.img.attrs({ 23 | src: editRoomImage, 24 | })` 25 | position: absolute; 26 | height: 100%; 27 | margin-left: 1rem; 28 | `; 29 | 30 | const Notify = styled.div` 31 | margin-top: 0.5rem; 32 | padding: 0.5rem; 33 | background-color: #ffc6c6; 34 | border-radius: 5px; 35 | color: white; 36 | text-align: center; 37 | font-weight: bold; 38 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 39 | font-size: 2rem; 40 | } 41 | `; 42 | 43 | function RoomInformation({ roomId }) { 44 | const [roomName, setRoomName] = useState(''); 45 | const [inputValue, setInputValue] = useState(''); 46 | const [message, setMessage] = useState(''); 47 | const { openModal } = useContext(ModalContext); 48 | 49 | useEffect(() => { 50 | if (roomName) return; 51 | 52 | async function getRoomTitle() { 53 | const { isSuccess, data } = await fetchRoomTitle({ roomId }); 54 | 55 | if (!isSuccess) { 56 | alert('오류로 인해 방의 정보를 불러올 수 없습니다'); 57 | return; 58 | } 59 | 60 | const [roomInformation] = data; 61 | setRoomName(roomInformation.title); 62 | } 63 | 64 | getRoomTitle(); 65 | }, [roomName]); 66 | 67 | useEffect(() => { 68 | const clearMessage = setTimeout(() => { 69 | setMessage(''); 70 | }, 1500); 71 | 72 | return () => { 73 | clearTimeout(clearMessage); 74 | }; 75 | }, [message]); 76 | 77 | function handleRoomName() { 78 | if (!inputValue) { 79 | setMessage('수정할 방의 이름을 입력하세요'); 80 | return false; 81 | } 82 | 83 | updateRoomTitle({ roomId, title: inputValue }).then(({ isSuccess }) => { 84 | if (!isSuccess) { 85 | alert('오류로 인해 방의 이름을 수정할 수 없습니다'); 86 | return false; 87 | } 88 | setRoomName(inputValue); 89 | return true; 90 | }); 91 | 92 | return true; 93 | } 94 | 95 | return ( 96 | <> 97 | 98 | {roomName} 99 | 100 | 101 | 102 | 109 | 114 | {message && {message}} 115 | 116 | 117 | ); 118 | } 119 | 120 | RoomInformation.propTypes = { 121 | roomId: PropTypes.number.isRequired, 122 | }; 123 | 124 | export default RoomInformation; 125 | -------------------------------------------------------------------------------- /client/src/components/detailRoom/TabContents.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | import QuizTab from './QuizTab'; 7 | 8 | const NavigationBar = styled.nav` 9 | display: flex; 10 | background-color: ${colors.BACKGROUND_LIGHT_WHITE}; 11 | height: 5rem; 12 | box-shadow: 0 5px 5px -4px ${colors.TEXT_GRAY}; 13 | justify-content: center; 14 | align-items: center; 15 | `; 16 | 17 | const TabMenuButton = styled.div` 18 | border-bottom: ${props => 19 | props.isSelected ? `3px solid ${colors.TEXT_BLACK}` : `none`}; 20 | background-color: ${colors.BACKGROUND_LIGHT_WHITE}; 21 | font-weight: bold; 22 | font-size: 2rem; 23 | color: ${colors.TEXT_BLACK}; 24 | margin-right: 1rem; 25 | cursor: pointer; 26 | &:hover { 27 | border-bottom: 3px solid ${colors.TEXT_BLACK}; 28 | } 29 | &:focus { 30 | outline: none; 31 | } 32 | `; 33 | 34 | function TabContents({ roomId, quizsetId }) { 35 | const [isQuizMenuSelected, setQuizMenuState] = useState(true); 36 | const [isAnalysisMenuSelected, setAnalysisMenuState] = useState(false); 37 | 38 | function resetMenuState() { 39 | setQuizMenuState(false); 40 | setAnalysisMenuState(false); 41 | } 42 | 43 | function handleQuizMenuClick() { 44 | resetMenuState(); 45 | setQuizMenuState(true); 46 | } 47 | 48 | function handleAnalysisMenuClick() { 49 | resetMenuState(); 50 | setAnalysisMenuState(true); 51 | } 52 | 53 | return ( 54 | <> 55 | 56 | 61 | 퀴즈 62 | 63 | 68 | 분석 69 | 70 | 71 |
72 | {isQuizMenuSelected && ( 73 | 74 | )} 75 | {isAnalysisMenuSelected && <>ver 1.0에서 제공되지 않는 기능입니다} 76 |
77 | 78 | ); 79 | } 80 | 81 | TabContents.defaultProps = { 82 | quizsetId: undefined, 83 | }; 84 | 85 | TabContents.propTypes = { 86 | roomId: PropTypes.number.isRequired, 87 | quizsetId: PropTypes.number, 88 | }; 89 | 90 | export default TabContents; 91 | -------------------------------------------------------------------------------- /client/src/components/edit/EditContextProvider.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer, createContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | initialQuizsetState, 5 | quizsetReducer, 6 | actionTypes, 7 | loadingTypes, 8 | } from '../../reducer/hostEditReducer'; 9 | 10 | export const EditContext = createContext(); 11 | 12 | function EditContextProvider({ children }) { 13 | const [quizsetState, dispatch] = useReducer( 14 | quizsetReducer, 15 | initialQuizsetState, 16 | ); 17 | return ( 18 | 21 | {children} 22 | 23 | ); 24 | } 25 | 26 | EditContextProvider.propTypes = { 27 | children: PropTypes.node.isRequired, 28 | }; 29 | 30 | export default EditContextProvider; 31 | -------------------------------------------------------------------------------- /client/src/components/edit/ImageField.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState, useContext, useEffect } from 'react'; 2 | import { useDropzone } from 'react-dropzone'; 3 | import styled from 'styled-components'; 4 | 5 | import emptyImage from '../../assets/images/emptyImage.png'; 6 | import trash from '../../assets/images/trash.png'; 7 | import * as colors from '../../constants/colors'; 8 | import { EditContext } from './EditContextProvider'; 9 | 10 | const Container = styled.div` 11 | position: absolute; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | width: 100%; 16 | height: 100%; 17 | left: 50%; 18 | transform: translateX(-50%); 19 | box-sizing: border-box; 20 | border: 2px dashed black; 21 | `; 22 | 23 | const UploadGuide = styled.span` 24 | position: absolute; 25 | text-align: center; 26 | font-size: 10vmin; 27 | `; 28 | 29 | const EmptyImage = styled.img.attrs({ 30 | src: emptyImage, 31 | })` 32 | width: 10vmin; 33 | height: 10vmin; 34 | user-select: none; 35 | `; 36 | 37 | const DropHereContainer = styled.div` 38 | position: absolute; 39 | display: flex; 40 | justify-content: center; 41 | align-items: center; 42 | width: 100%; 43 | height: 100%; 44 | z-index: 2; 45 | `; 46 | 47 | const DropHereBackground = styled.div` 48 | position: absolute; 49 | width: 100%; 50 | height: 100%; 51 | background-color: ${colors.PRIMARY_DEEP_GREEN}; 52 | opacity: 0.8; 53 | `; 54 | 55 | const DropHereText = styled.span` 56 | position: absolute; 57 | z-index: 3; 58 | font-size: 5vw; 59 | font-weight: bold; 60 | color: white; 61 | `; 62 | 63 | const UploadedImage = styled.div` 64 | background-image: url(${props => props.url}); 65 | background-size: contain; 66 | background-repeat: no-repeat; 67 | background-position: center; 68 | width: 100%; 69 | height: 100%; 70 | z-index: 1; 71 | `; 72 | 73 | const DeleteButton = styled.div` 74 | position: absolute; 75 | bottom: 2vmin; 76 | right: 2vmin; 77 | width: 10vmin; 78 | height: 10vmin; 79 | background-color: white; 80 | border-radius: 50%; 81 | box-shadow: 0 2px 2px 1px gray; 82 | background-image: url(${trash}); 83 | opacity: 0.5; 84 | background-size: 70%; 85 | background-repeat: no-repeat; 86 | background-position: center; 87 | cursor: pointer; 88 | transition: opacity 0.2s; 89 | z-index: 999; 90 | 91 | &:hover { 92 | opacity: 1; 93 | } 94 | `; 95 | 96 | function ImageField() { 97 | const { quizsetState, dispatch, actionTypes } = useContext(EditContext); 98 | const { quizset, currentIndex } = quizsetState; 99 | const [imagePath, setImagePath] = useState(null); 100 | 101 | useEffect(() => { 102 | setImagePath(quizset[currentIndex].imagePath); 103 | }, [currentIndex]); 104 | 105 | const onDrop = useCallback(acceptedFiles => { 106 | try { 107 | const [imageFile] = acceptedFiles; 108 | const newImagePath = URL.createObjectURL(imageFile); 109 | dispatch({ 110 | type: actionTypes.UPDATE_IMAGE, 111 | imagePath: newImagePath, 112 | imageFile, 113 | }); 114 | setImagePath(newImagePath); 115 | } catch (error) { 116 | //error 117 | } 118 | }, []); 119 | 120 | function deleteImage() { 121 | dispatch({ 122 | type: actionTypes.UPDATE_IMAGE, 123 | imagePath: null, 124 | imageFile: null, 125 | }); 126 | setImagePath(null); 127 | } 128 | 129 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ 130 | accept: 'image/*', 131 | onDrop, 132 | }); 133 | 134 | return ( 135 | 136 | 137 | {!imagePath && } 138 | 139 | 140 | 141 | {imagePath && } 142 | {isDragActive && ( 143 | 144 | 145 | Drop Here 146 | 147 | )} 148 | {imagePath && ( 149 | { 151 | event.stopPropagation(); 152 | deleteImage(); 153 | }} 154 | /> 155 | )} 156 | 157 | ); 158 | } 159 | 160 | export default ImageField; 161 | -------------------------------------------------------------------------------- /client/src/components/edit/Item.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import { EditContext } from './EditContextProvider'; 5 | import * as colors from '../../constants/colors'; 6 | import checkMark from '../../assets/images/checkMark.png'; 7 | 8 | const ITEM_PADDING_VERTICAL = '1.5vmin'; 9 | const ITEM_PADDING_HORIZONTAL = '1.5vmin'; 10 | 11 | const ItemWrapper = styled.div` 12 | position: relative; 13 | display: flex; 14 | flex: 1; 15 | align-items: center; 16 | background-color: ${props => props.backgroundColor}; 17 | color: ${props => props.fontColor}; 18 | border-radius: 0.5rem; 19 | border: none; 20 | font-weight: bold; 21 | font-size: 3vmin; 22 | text-shadow: black 0.1rem 0.1rem; 23 | user-select: none; 24 | transform: translateY(-0.3rem); 25 | transition: transform 0.1s; 26 | z-index: initial; 27 | `; 28 | 29 | const ContentArea = styled.div` 30 | position: absolute; 31 | display: flex; 32 | align-items: center; 33 | top: ${ITEM_PADDING_VERTICAL}; 34 | bottom: ${ITEM_PADDING_VERTICAL}; 35 | left: ${ITEM_PADDING_HORIZONTAL}; 36 | right: ${ITEM_PADDING_HORIZONTAL}; 37 | `; 38 | 39 | const InputWrapper = styled.div` 40 | position: relative; 41 | flex: 1; 42 | height: 100%; 43 | margin-right: 1vmin; 44 | `; 45 | 46 | const ItemInput = styled.input` 47 | position: absolute; 48 | height: 100%; 49 | width: 100%; 50 | font-size: 3vmin; 51 | padding: 2vmin; 52 | box-sizing: border-box; 53 | border: none; 54 | border-radius: 5px; 55 | background-color: ${props => props.backgroundColor}; 56 | color: ${colors.TEXT_WHITE}; 57 | outline-color: black; 58 | 59 | transition: background-color 0.3s; 60 | `; 61 | 62 | const ItemCheckBox = styled.div` 63 | position: relative; 64 | display: ${props => (props.isActive ? 'block' : 'none')}; 65 | flex: none; 66 | width: 3vmin; 67 | height: 3vmin; 68 | 69 | border: 2px solid #ffffff; 70 | border-radius: 50%; 71 | cursor: pointer; 72 | background-color: ${props => props.isAnswer && '#65bf39'}; 73 | background-image: url(${checkMark}); 74 | background-repeat: no-repeat; 75 | background-size: 70%; 76 | background-position: 50% 50%; 77 | 78 | transition: background-color 0.2s; 79 | `; 80 | 81 | function Item({ itemIndex }) { 82 | const { quizsetState, dispatch, actionTypes } = useContext(EditContext); 83 | const { quizset, currentIndex, deleteCount } = quizsetState; 84 | const { items } = quizset[currentIndex]; 85 | const { isAnswer, title } = items[itemIndex]; 86 | const inputRef = useRef(); 87 | 88 | useEffect(() => { 89 | inputRef.current.value = title; 90 | }, [currentIndex, deleteCount]); 91 | 92 | function updateIsAnswer(itemIsAnswer) { 93 | dispatch({ 94 | type: actionTypes.UPDATE_ITEM_IS_ANSWER, 95 | itemIsAnswer, 96 | itemIndex, 97 | }); 98 | } 99 | 100 | function onChangeHanlder(event) { 101 | const itemTitle = event.target.value; 102 | if (itemTitle.length === 0) updateIsAnswer(0); 103 | dispatch({ type: actionTypes.UPDATE_ITEM_TITLE, itemTitle, itemIndex }); 104 | } 105 | 106 | return ( 107 | 108 | 109 | 110 | 121 | 122 | 0} 124 | isAnswer={isAnswer} 125 | onClick={() => { 126 | updateIsAnswer(1 - isAnswer); 127 | }} 128 | /> 129 | 130 | 131 | ); 132 | } 133 | 134 | Item.propTypes = { 135 | itemIndex: PropTypes.number.isRequired, 136 | }; 137 | 138 | export default Item; 139 | -------------------------------------------------------------------------------- /client/src/components/edit/ItemContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as ingameLayout from '../inGame/Layout'; 3 | import Item from './Item'; 4 | 5 | const itemIndexes = [0, 1, 2, 3]; 6 | 7 | function ItemContainer() { 8 | return ( 9 | <> 10 | {itemIndexes.map(itemIndex => ( 11 | 12 | 13 | 14 | ))} 15 | 16 | ); 17 | } 18 | 19 | export default ItemContainer; 20 | -------------------------------------------------------------------------------- /client/src/components/edit/OptionPanel.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import RangeInput from './RangeInput'; 5 | import { EditContext } from './EditContextProvider'; 6 | 7 | const TIME_PARAMS = [5, 10, 20, 30, 60, 90, 120]; 8 | const SCORE_PARAMS = [0, 1000, 2000]; 9 | 10 | const Background = styled.div` 11 | position: relative; 12 | width: 100%; 13 | height: 100%; 14 | `; 15 | 16 | const OptionWrapper = styled.div` 17 | position: relative; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | width: 100%; 23 | height: 50%; 24 | `; 25 | 26 | const OptionTitle = styled.span` 27 | font-weight: bold; 28 | font-size: 4vmin; 29 | user-select: none; 30 | margin-bottom: 2vmin; 31 | `; 32 | 33 | function OptionPanel() { 34 | const { quizsetState, dispatch, actionTypes } = useContext(EditContext); 35 | const { quizset, currentIndex } = quizsetState; 36 | const { timeLimit, score } = quizset[currentIndex]; 37 | const timeLimitIndex = TIME_PARAMS.indexOf(timeLimit); 38 | const scoreIndex = SCORE_PARAMS.indexOf(score); 39 | 40 | function updateTimeLimit(value) { 41 | dispatch({ type: actionTypes.UPDATE_TIME_LIMIT, timeLimit: value }); 42 | } 43 | 44 | function updateScore(value) { 45 | dispatch({ type: actionTypes.UPDATE_SCORE, score: value }); 46 | } 47 | 48 | return ( 49 | 50 | 51 | 시간 52 | 57 | 58 | 59 | 점수 60 | 65 | 66 | 67 | ); 68 | } 69 | 70 | export default OptionPanel; 71 | -------------------------------------------------------------------------------- /client/src/components/edit/RangeInput.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | 7 | const THUMB_WIDTH = '8vmin'; 8 | const DOT_SIZE = '6px'; 9 | 10 | const Container = styled.div` 11 | position: relative; 12 | width: 95%; 13 | height: calc(${THUMB_WIDTH} / 2); 14 | `; 15 | 16 | const ThumbStyle = css` 17 | width: ${THUMB_WIDTH}; 18 | height: calc(${THUMB_WIDTH} / 2); 19 | border-radius: 20px; 20 | border: none; 21 | top: 50%; 22 | transform: translateY(-50%); 23 | `; 24 | 25 | const Thumb = styled.div` 26 | ${ThumbStyle} 27 | position: absolute; 28 | background-color: white; 29 | box-shadow: 0 1px 1px 1px black; 30 | left: ${props => props.left}; 31 | transform: translate(-${props => props.left}, -50%); 32 | pointer-events: none; 33 | user-select: none; 34 | 35 | &::before { 36 | content: '${props => props.value}'; 37 | position: absolute; 38 | top: 50%; 39 | left: 50%; 40 | transform: translate(-50%, -50%); 41 | font-weight: bold; 42 | font-size: calc(${THUMB_WIDTH} / 3); 43 | } 44 | `; 45 | 46 | const InputStyle = styled.input.attrs({ 47 | type: 'range', 48 | min: 0, 49 | })` 50 | -webkit-appearance: none; 51 | position: absolute; 52 | width: 100%; 53 | height: 100%; 54 | box-sizing: border-box; 55 | background-color: transparent; 56 | border: none; 57 | left: 50%; 58 | top: 50%; 59 | transform: translate(-50%, -50%); 60 | margin: 0; 61 | outline: none; 62 | 63 | ::-webkit-slider-runnable-track { 64 | -webkit-appearance: none; 65 | position: relative; 66 | width: 100%; 67 | height: 100%; 68 | cursor: pointer; 69 | } 70 | 71 | ::-webkit-slider-thumb { 72 | ${ThumbStyle} 73 | -webkit-appearance: none; 74 | position: relative; 75 | cursor: grab; 76 | 77 | &:active { 78 | cursor: grabbing; 79 | } 80 | } 81 | `; 82 | 83 | const ContentArea = styled.div` 84 | position: absolute; 85 | pointer-events: none; 86 | width: calc(100% - ${THUMB_WIDTH}); 87 | background-color: transparent; 88 | height: 0; 89 | left: 50%; 90 | top: 50%; 91 | transform: translateX(-50%); 92 | `; 93 | 94 | const Track = styled.div` 95 | position: absolute; 96 | background-color: ${colors.TEXT_BLACK}; 97 | width: 100%; 98 | height: 2px; 99 | top: 50%; 100 | transform: translateY(-50%); 101 | `; 102 | 103 | const Dot = styled.div` 104 | position: absolute; 105 | background-color: ${colors.TEXT_BLACK}; 106 | width: ${DOT_SIZE}; 107 | height: ${DOT_SIZE}; 108 | border-radius: 50%; 109 | top: 50%; 110 | left: ${props => props.left}; 111 | transform: translate(-50%, -50%) scale(1); 112 | `; 113 | 114 | function RangeInput({ params, onChange, dependency }) { 115 | const inputRef = useRef(); 116 | const lastIndex = params.length - 1; 117 | const defaultValue = parseInt(lastIndex / 2, 10); 118 | const [currentIndex, setCurrentIndex] = useState(defaultValue); 119 | const getLeft = index => { 120 | return `${(index / lastIndex) * 100}%`; 121 | }; 122 | const getValue = index => { 123 | return params[index]; 124 | }; 125 | const thumbLeft = getLeft(currentIndex); 126 | 127 | function handleChange(event) { 128 | const index = event.target.value; 129 | setCurrentIndex(index); 130 | if (onChange) onChange(getValue(index)); 131 | } 132 | 133 | useEffect(() => { 134 | if (!dependency) return; 135 | setCurrentIndex(dependency); 136 | }, [dependency]); 137 | 138 | return ( 139 | 140 | 141 | 142 | {params.map((param, index) => ( 143 | 144 | ))} 145 | 146 | 147 | handleChange(event)} 152 | /> 153 | 154 | 155 | ); 156 | } 157 | 158 | RangeInput.defaultProps = { 159 | onChange: undefined, 160 | dependency: undefined, 161 | }; 162 | 163 | RangeInput.propTypes = { 164 | params: PropTypes.arrayOf(PropTypes.number).isRequired, 165 | onChange: PropTypes.func, 166 | dependency: PropTypes.number, 167 | }; 168 | 169 | export default RangeInput; 170 | -------------------------------------------------------------------------------- /client/src/components/edit/Section.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import SideBar from './SideBar'; 6 | import ImageField from './ImageField'; 7 | import Title from './Title'; 8 | import ItemContainer from './ItemContainer'; 9 | import * as ingameLayout from '../inGame/Layout'; 10 | import { EditContext } from './EditContextProvider'; 11 | import { readQuizset } from '../../utils/fetch'; 12 | import Loading from '../common/Loading'; 13 | import OptionPanel from './OptionPanel'; 14 | import * as colors from '../../constants/colors'; 15 | 16 | const MAIN_PADDING = '3vmin'; 17 | 18 | const Background = styled.section` 19 | position: relative; 20 | display: flex; 21 | flex-direction: column-reverse; 22 | flex: 1; 23 | 24 | @media (orientation: landscape) { 25 | flex-direction: row; 26 | } 27 | `; 28 | 29 | const Main = styled.main` 30 | position: relative; 31 | flex: 1; 32 | `; 33 | 34 | const MainContentContainer = styled.div` 35 | position: absolute; 36 | display: flex; 37 | flex-direction: column; 38 | top: ${MAIN_PADDING}; 39 | bottom: ${MAIN_PADDING}; 40 | left: ${MAIN_PADDING}; 41 | right: ${MAIN_PADDING}; 42 | `; 43 | 44 | const CenterRightPanel = styled.div` 45 | position: relative; 46 | width: 30%; 47 | height: 100%; 48 | background-color: ${colors.BACKGROUND_LIGHT_WHITE}; 49 | border-radius: 5px; 50 | box-shadow: 0px 2px 2px 2px ${colors.BORDER_DARK_GRAY}; 51 | `; 52 | 53 | function Section({ roomId, quizsetId }) { 54 | const { quizsetState, dispatch, actionTypes, loadingTypes } = useContext( 55 | EditContext, 56 | ); 57 | const { loadingType } = quizsetState; 58 | useEffect(() => { 59 | async function fetchData(tryCount) { 60 | if (tryCount === 0) return; 61 | const result = await readQuizset(quizsetId); 62 | if (!result.isSuccess) { 63 | fetchData(tryCount - 1); 64 | return; 65 | } 66 | const { quizset } = result.data; 67 | dispatch({ type: actionTypes.READ_QUIZSET, quizset }); 68 | } 69 | dispatch({ type: actionTypes.RESET_DELETE_QUIZZES }); 70 | 71 | dispatch({ type: actionTypes.UPDATE_IDS, roomId, quizsetId }); 72 | 73 | if (quizsetId === undefined) { 74 | dispatch({ type: actionTypes.CREATE_QUIZ }); 75 | return; 76 | } 77 | fetchData(3); 78 | }, []); 79 | 80 | return ( 81 | <> 82 | {loadingType !== loadingTypes.IDLE ? ( 83 | 84 | ) : ( 85 | 86 | 87 |
88 | 89 | 90 | 91 | 92 | </ingameLayout.TitleContainer> 93 | <ingameLayout.Center> 94 | <ingameLayout.CenterContentContainer> 95 | <ingameLayout.ImagePanel> 96 | <ImageField /> 97 | </ingameLayout.ImagePanel> 98 | <CenterRightPanel> 99 | <OptionPanel /> 100 | </CenterRightPanel> 101 | </ingameLayout.CenterContentContainer> 102 | </ingameLayout.Center> 103 | <ingameLayout.Bottom> 104 | <ingameLayout.ItemContainer> 105 | <ItemContainer /> 106 | </ingameLayout.ItemContainer> 107 | </ingameLayout.Bottom> 108 | </ingameLayout.Background> 109 | </MainContentContainer> 110 | </Main> 111 | </Background> 112 | )} 113 | </> 114 | ); 115 | } 116 | 117 | Section.defaultProps = { 118 | quizsetId: undefined, 119 | }; 120 | 121 | Section.propTypes = { 122 | roomId: PropTypes.number.isRequired, 123 | quizsetId: PropTypes.number, 124 | }; 125 | 126 | export default Section; 127 | -------------------------------------------------------------------------------- /client/src/components/edit/SideBar.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext, useRef } from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | import { Button, YellowButton } from '../common/Buttons'; 7 | import { EditContext } from './EditContextProvider'; 8 | import Thumbnail from './Thumbnail'; 9 | 10 | const SIDE_BAR_SIZE = '20vmin'; 11 | const BUTTON_PADDING = '1vmin'; 12 | const BUTTON_FONT_SIZE = '2.5vmin'; 13 | const BUTTONS_CONTAINER_PADDING = '1vmin'; 14 | const BUTTON_WRAPPER_SIZE = '3.5'; 15 | 16 | const FlexStyle = css` 17 | display: flex; 18 | flex-direction: column; 19 | 20 | @media (orientation: portrait) { 21 | flex-direction: row; 22 | } 23 | `; 24 | 25 | const Background = styled.aside` 26 | position: relative; 27 | flex: none; 28 | width: ${SIDE_BAR_SIZE}; 29 | height: 100%; 30 | background-color: ${colors.BACKGROUND_LIGHT_WHITE}; 31 | box-shadow: rgba(0, 0, 0, 0.15) 0px 2px 4px 0px; 32 | 33 | @media (orientation: portrait) { 34 | width: 100%; 35 | height: ${SIDE_BAR_SIZE}; 36 | } 37 | `; 38 | 39 | const Container = styled.div` 40 | ${FlexStyle}; 41 | position: absolute; 42 | top: 0; 43 | bottom: 0; 44 | left: 0; 45 | right: 0; 46 | `; 47 | 48 | const ThumbnailContainer = styled.div` 49 | ${FlexStyle}; 50 | overflow-y: auto; 51 | overflow-x: hidden; 52 | scroll-behavior: smooth; 53 | @media (orientation: portrait) { 54 | overflow-x: auto; 55 | overflow-y: hidden; 56 | height: 100%; 57 | } 58 | `; 59 | 60 | const ButtonContainers = styled.div` 61 | ${FlexStyle}; 62 | padding: ${BUTTONS_CONTAINER_PADDING} 0; 63 | @media (orientation: portrait) { 64 | padding: 0 ${BUTTONS_CONTAINER_PADDING}; 65 | } 66 | `; 67 | 68 | const ButtonWrapper = styled.div` 69 | position: relative; 70 | flex: none; 71 | width: ${SIDE_BAR_SIZE}; 72 | height: calc(${SIDE_BAR_SIZE} / ${BUTTON_WRAPPER_SIZE}); 73 | 74 | @media (orientation: portrait) { 75 | width: calc(${SIDE_BAR_SIZE} / ${BUTTON_WRAPPER_SIZE}); 76 | height: ${SIDE_BAR_SIZE}; 77 | } 78 | 79 | div.buttonWrapper { 80 | top: 50%; 81 | left: 50%; 82 | transform: translate(-50%, -50%); 83 | 84 | width: 80%; 85 | height: calc(${BUTTON_PADDING} * 2 + ${BUTTON_FONT_SIZE}); 86 | 87 | @media (orientation: portrait) { 88 | width: calc(${BUTTON_PADDING} * 2 + ${BUTTON_FONT_SIZE}); 89 | height: 80%; 90 | } 91 | } 92 | 93 | button { 94 | padding: ${BUTTON_PADDING}; 95 | font-size: ${BUTTON_FONT_SIZE}; 96 | text-shadow: none; 97 | } 98 | `; 99 | 100 | function AddQuizButton({ onClick }) { 101 | return ( 102 | <ButtonWrapper> 103 | <YellowButton onClick={onClick}>퀴즈 추가</YellowButton> 104 | </ButtonWrapper> 105 | ); 106 | } 107 | 108 | function DeleteQuizButton({ onClick }) { 109 | return ( 110 | <ButtonWrapper> 111 | <Button onClick={onClick}>퀴즈 삭제</Button> 112 | </ButtonWrapper> 113 | ); 114 | } 115 | 116 | const buttonPropTypes = { onClick: PropTypes.func.isRequired }; 117 | 118 | AddQuizButton.propTypes = buttonPropTypes; 119 | DeleteQuizButton.propTypes = buttonPropTypes; 120 | 121 | function SideBar() { 122 | const { quizsetState, dispatch, actionTypes } = useContext(EditContext); 123 | const { quizset, currentIndex } = quizsetState; 124 | const thumbnailContainerRef = useRef(); 125 | const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); 126 | 127 | function addQuiz() { 128 | dispatch({ type: actionTypes.CREATE_QUIZ }); 129 | } 130 | 131 | function deleteQuiz() { 132 | dispatch({ type: actionTypes.DELETE_QUIZ }); 133 | } 134 | 135 | function moveScroll() { 136 | const thumbContainer = thumbnailContainerRef.current; 137 | const horizontalMax = 138 | thumbContainer.scrollWidth - thumbContainer.offsetWidth; 139 | const verticalMax = 140 | thumbContainer.scrollHeight - thumbContainer.offsetHeight; 141 | const lastIndex = quizset.length - 1; 142 | const scrollPosition = currentIndex / lastIndex; 143 | thumbContainer.scrollLeft = scrollPosition * horizontalMax; 144 | thumbContainer.scrollTop = scrollPosition * verticalMax; 145 | } 146 | 147 | useEffect(() => { 148 | function changeWindowSize() { 149 | setWindowSize({ width: window.innerWidth, height: window.innerHeight }); 150 | } 151 | 152 | window.addEventListener('resize', changeWindowSize); 153 | return () => { 154 | window.removeEventListener('resize', changeWindowSize); 155 | }; 156 | }, []); 157 | 158 | useEffect(() => { 159 | moveScroll(); 160 | }, [currentIndex, windowSize]); 161 | 162 | return ( 163 | <Background> 164 | <Container> 165 | <ThumbnailContainer ref={thumbnailContainerRef}> 166 | {quizset.map((thumbnail, index) => ( 167 | <Thumbnail key={index} index={index} /> 168 | ))} 169 | </ThumbnailContainer> 170 | <ButtonContainers> 171 | <DeleteQuizButton onClick={deleteQuiz} /> 172 | <AddQuizButton onClick={addQuiz} /> 173 | </ButtonContainers> 174 | </Container> 175 | </Background> 176 | ); 177 | } 178 | 179 | export default SideBar; 180 | -------------------------------------------------------------------------------- /client/src/components/edit/Thumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import { EditContext } from './EditContextProvider'; 6 | import * as colors from '../../constants/colors'; 7 | import * as ingameLayout from '../inGame/Layout'; 8 | import emptyImage from '../../assets/images/emptyImage.png'; 9 | 10 | const SIDE_BAR_SIZE = '20vmin'; 11 | const PADDING = '1vmin'; 12 | const MARGIN_RIGHT = '0.5vmin'; 13 | 14 | const ThumbnailBackground = styled.div` 15 | position: relative; 16 | flex: none; 17 | background-color: ${props => (props.isActive ? '#00c3ff' : 'white')}; 18 | width: 100%; 19 | height: calc(${SIDE_BAR_SIZE} / 1.5); 20 | user-select: none; 21 | 22 | @media (orientation: portrait) { 23 | height: 100%; 24 | width: calc(${SIDE_BAR_SIZE} * 1.5); 25 | } 26 | `; 27 | 28 | const PaddingArea = styled.div` 29 | position: absolute; 30 | display: flex; 31 | top: ${PADDING}; 32 | bottom: ${PADDING}; 33 | left: ${PADDING}; 34 | right: ${PADDING}; 35 | 36 | pointer-events: none; 37 | `; 38 | 39 | const Index = styled.span` 40 | font-size: 1.5vmin; 41 | font-weight: bold; 42 | margin-right: ${MARGIN_RIGHT}; 43 | `; 44 | 45 | const Content = styled.div` 46 | position: relative; 47 | display: flex; 48 | flex-direction: column; 49 | background-color: ${colors.BACKGROUND_DEEP_GRAY}; 50 | border-radius: 5px; 51 | height: 100%; 52 | width: 100%; 53 | margin-right: ${MARGIN_RIGHT}; 54 | overflow: hidden; 55 | `; 56 | 57 | const Title = styled.div` 58 | display: inline-block; 59 | width: 100%; 60 | font-size: 2.5vmin; 61 | height: 3vmin; 62 | font-weight: bold; 63 | text-align: center; 64 | padding: 0 0.5vmin; 65 | color: black; 66 | white-space: nowrap; 67 | text-overflow: ellipsis; 68 | `; 69 | 70 | const ImageField = styled.div` 71 | position: absolute; 72 | display: flex; 73 | align-items: center; 74 | justify-content: center; 75 | width: 80%; 76 | height: 60%; 77 | top: 50%; 78 | left: 50%; 79 | transform: translate(-50%, -50%); 80 | 81 | border: 1px dashed black; 82 | ${props => props.isEmpty && `border-width: 0`}; 83 | `; 84 | 85 | const UploadedImage = styled.div` 86 | background-image: url(${props => props.url}); 87 | background-size: contain; 88 | background-repeat: no-repeat; 89 | background-position: center; 90 | width: 100%; 91 | height: 100%; 92 | z-index: 1; 93 | `; 94 | 95 | function Thumbnail({ index }) { 96 | const { quizsetState, dispatch, actionTypes } = useContext(EditContext); 97 | const { quizset, currentIndex } = quizsetState; 98 | const { title, imagePath } = quizset[index]; 99 | function selectQuiz() { 100 | dispatch({ type: actionTypes.UPDATE_CURRENT_INDEX, currentIndex: index }); 101 | } 102 | 103 | return ( 104 | <ThumbnailBackground isActive={currentIndex === index} onClick={selectQuiz}> 105 | <PaddingArea> 106 | <Index>{index + 1}</Index> 107 | <Content> 108 | <ingameLayout.Background> 109 | <ingameLayout.TitleContainer> 110 | <Title>{title} 111 | 112 | 113 | 114 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | ); 124 | } 125 | 126 | Thumbnail.propTypes = { 127 | index: PropTypes.number.isRequired, 128 | }; 129 | 130 | export default Thumbnail; 131 | -------------------------------------------------------------------------------- /client/src/components/edit/TimeLimitPicker.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import * as colors from '../../constants/colors'; 5 | 6 | const circles = [5, 10, 20, 30, 60, 90, 120, 240]; 7 | const degs = [270, 315, 0, 45, 90, 135, 180, 225]; 8 | const delays = [0.05, 0.075, 0.1, 0.125, 0.15, 0.175, 0.2, 0.225]; 9 | 10 | const TimeLimitPickerWrapper = styled.div` 11 | z-index: 5; 12 | position: absolute; 13 | display: flex; 14 | width: 100%; 15 | height: 15rem; 16 | justify-content: center; 17 | align-items: center; 18 | user-select: none; 19 | `; 20 | 21 | const CenterCircle = styled.button` 22 | z-index: 6; 23 | position: absolute; 24 | width: 6rem; 25 | height: 6rem; 26 | border: none; 27 | border-radius: 50%; 28 | outline: none; 29 | cursor: pointer; 30 | background: #ffffff; 31 | box-shadow: rgba(0, 0, 0, 0.7) 0px 1px 2px 0px; 32 | font-size: 2rem; 33 | font-weight: bold; 34 | color: ${colors.TEXT_BLACK}; 35 | `; 36 | 37 | const CircleWrapper = styled.div` 38 | position: absolute; 39 | display: flex; 40 | justify-content: center; 41 | align-items: center; 42 | height: 4.5rem; 43 | transform: rotate(${props => props.deg}deg); 44 | `; 45 | 46 | const Circle = styled.button` 47 | z-index: 7; 48 | position: relative; 49 | left: ${props => (props.isClicked ? '7rem' : 0)}; 50 | width: 4.5rem; 51 | height: 4.5rem; 52 | border: none; 53 | border-radius: 50%; 54 | outline: none; 55 | cursor: pointer; 56 | transform: rotate(${props => -props.deg}deg); 57 | background: #ffffff; 58 | box-shadow: rgba(0, 0, 0, 0.7) 0px 1px 2px 0px; 59 | transition: left 0.1s; 60 | transition-delay: ${props => props.delay}s; 61 | transition-timing-function: linear; 62 | font-size: 1.5rem; 63 | font-weight: bold; 64 | color: ${colors.TEXT_BLACK}; 65 | &:hover { 66 | color: red; 67 | } 68 | `; 69 | 70 | function TimeLimitPicker({ timeLimit, setTimeLimit }) { 71 | const [isClicked, setClicked] = useState(false); 72 | 73 | function handleCenterClick() { 74 | setClicked(!isClicked); 75 | } 76 | 77 | return ( 78 | 79 | {timeLimit} sec 80 | {circles.map((circle, index) => ( 81 | 82 | { 87 | setTimeLimit(circles[index]); 88 | setClicked(!isClicked); 89 | }} 90 | > 91 | {circle} 92 | 93 | 94 | ))} 95 | 96 | ); 97 | } 98 | 99 | TimeLimitPicker.propTypes = { 100 | timeLimit: PropTypes.number.isRequired, 101 | setTimeLimit: PropTypes.func.isRequired, 102 | }; 103 | 104 | export default TimeLimitPicker; 105 | -------------------------------------------------------------------------------- /client/src/components/edit/Title.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { EditContext } from './EditContextProvider'; 3 | import FlexibleInput from '../common/FlexibleInput'; 4 | 5 | function Title() { 6 | const { quizsetState, dispatch, actionTypes } = useContext(EditContext); 7 | const { quizset, currentIndex } = quizsetState; 8 | const { title } = quizset[currentIndex]; 9 | 10 | function onChangeHanlder(value) { 11 | dispatch({ type: actionTypes.UPDATE_TITLE, title: value }); 12 | } 13 | 14 | return ( 15 | 21 | ); 22 | } 23 | 24 | export default Title; 25 | -------------------------------------------------------------------------------- /client/src/components/inGame/HostFooter.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | import * as styles from '../../styles/common'; 7 | import DOMAIN from '../../constants/domain'; 8 | import clickImage from '../../assets/images/click.png'; 9 | 10 | const SHAKE_POWER = 50; 11 | const CLIPBOARD_START_POSITION = -150; 12 | const getCenterPosition = () => 13 | `transform: translateX(${CLIPBOARD_START_POSITION}%);`; 14 | const getLeftPosition = () => 15 | `transform: translateX(${CLIPBOARD_START_POSITION - SHAKE_POWER}%);`; 16 | const getRightPosition = () => 17 | `transform: translateX(${CLIPBOARD_START_POSITION + SHAKE_POWER}%);`; 18 | 19 | const Footer = styled.footer` 20 | ${styles.InGameFooterStyle} 21 | cursor: copy; 22 | user-select: none; 23 | `; 24 | 25 | const ClickImage = styled.img.attrs({ 26 | src: clickImage, 27 | })` 28 | left: 1rem; 29 | position: absolute; 30 | height: 60%; 31 | top: 50%; 32 | transform: translateY(-50%); 33 | `; 34 | 35 | const RoomUrl = styled.span` 36 | ${styles.InGameFooterTextStyle} 37 | right: 0.5rem; 38 | color: ${colors.TEXT_BLACK}; 39 | 40 | &::before { 41 | content: '📋'; 42 | position: absolute; 43 | animation-name: ${props => props.animation}; 44 | animation-duration: 1s; 45 | animation-iteration-count: 1; 46 | transform: translateX(${CLIPBOARD_START_POSITION}%); 47 | } 48 | 49 | &::after { 50 | content: '${props => props.url}'; 51 | position: relative; 52 | } 53 | `; 54 | 55 | const FakeInput = styled.input` 56 | width: 1px; 57 | height: 1px; 58 | opacity: 0; 59 | margin: 0; 60 | padding: 0; 61 | border: none; 62 | cursor: default; 63 | `; 64 | 65 | function getShakeAnimation() { 66 | return keyframes` 67 | 0%{ 68 | ${getCenterPosition()}; 69 | } 70 | 20%{ 71 | ${getLeftPosition()}; 72 | } 73 | 40%{ 74 | ${getRightPosition()}; 75 | } 76 | 60%{ 77 | ${getLeftPosition()}; 78 | } 79 | 80%{ 80 | ${getRightPosition()}; 81 | } 82 | 100%{ 83 | ${getCenterPosition()}; 84 | } 85 | `; 86 | } 87 | 88 | function HostFooter({ roomNumber }) { 89 | const [shakeTrigger, setShakeTrigger] = useState(false); 90 | const inputRef = useRef(); 91 | const url = `${DOMAIN}/join/${roomNumber}`; 92 | 93 | function copyUrl() { 94 | const input = inputRef.current; 95 | input.value = `http://${url}`; 96 | input.select(); 97 | document.execCommand('copy'); 98 | setShakeTrigger(true); 99 | } 100 | 101 | return ( 102 |
103 | 104 | setShakeTrigger(false)} 108 | /> 109 | 110 |
111 | ); 112 | } 113 | 114 | HostFooter.propTypes = { 115 | roomNumber: PropTypes.string.isRequired, 116 | }; 117 | 118 | export default HostFooter; 119 | -------------------------------------------------------------------------------- /client/src/components/inGame/HostLoading.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import * as colors from '../../constants/colors'; 4 | import ProgressBar from './ProgressBar'; 5 | import { fetchQuizSet } from '../../utils/fetch'; 6 | import { HostGameAction, HostGameContext } from '../../reducer/hostGameReducer'; 7 | 8 | const Container = styled.div` 9 | display: flex; 10 | flex-direction: column; 11 | width: 100%; 12 | height: 100%; 13 | `; 14 | 15 | const Main = styled.div` 16 | display: flex; 17 | flex-direction: column; 18 | background-color: ${colors.BACKGROUND_LIGHT_GRAY}; 19 | flex: 1; 20 | align-items: center; 21 | padding: 0 2rem 2rem; 22 | `; 23 | 24 | const Notify = styled.p` 25 | position: absolute; 26 | top: 50%; 27 | transform: translateY(-50%); 28 | padding: 0 2rem; 29 | font-size: 2rem; 30 | text-align: center; 31 | font-weight: bold; 32 | color: ${colors.TEXT_BLACK}; 33 | `; 34 | 35 | function HostLoading() { 36 | const { roomState, dispatcher } = useContext(HostGameContext); 37 | 38 | useEffect(() => { 39 | fetchQuizSet(roomState.roomNumber).then(response => { 40 | dispatcher({ 41 | type: HostGameAction.SET_ENTIRE_QUIZ, 42 | data: response.quizSet, 43 | }); 44 | }); 45 | }, [roomState.currentQuiz]); 46 | 47 | return ( 48 | 49 |
50 | 51 | 퀴즈가 준비 중이에요
52 | 잠시 기다려주세요 53 |
54 | 55 |
56 |
57 | ); 58 | } 59 | 60 | export default HostLoading; 61 | -------------------------------------------------------------------------------- /client/src/components/inGame/HostPlaying.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import { GreenButton } from '../common/Buttons'; 4 | import * as layout from './Layout'; 5 | import Hourglass from './Hourglass'; 6 | import { HostGameAction, HostGameContext } from '../../reducer/hostGameReducer'; 7 | import multipleChoiceImage from '../../assets/images/multiple_choice.svg'; 8 | 9 | const RemainTime = styled.span` 10 | position: absolute; 11 | margin-top: auto; 12 | font-size: 2vw; 13 | font-weight: bold; 14 | user-select: none; 15 | `; 16 | 17 | const ImageContainer = styled.div` 18 | width: 100%; 19 | height: 100%; 20 | 21 | background-image: url(${props => props.image}); 22 | background-size: contain; 23 | background-position: center; 24 | background-repeat: no-repeat; 25 | `; 26 | 27 | function HostPlaying() { 28 | const [remainTime, setRemainTime] = useState(0); 29 | const { dispatcher, roomState } = useContext(HostGameContext); 30 | 31 | useEffect(() => { 32 | setRemainTime(Number(roomState.currentQuiz.timeLimit)); 33 | const timer = setInterval(() => { 34 | setRemainTime(cur => { 35 | if (cur === 0) { 36 | clearInterval(timer); 37 | dispatcher({ type: HostGameAction.REQUEST_SUB_RESULT }); 38 | return 0; 39 | } 40 | return cur - 1; 41 | }); 42 | }, 1000); 43 | 44 | return () => { 45 | clearInterval(timer); 46 | }; 47 | }, [roomState.currentQuiz]); 48 | 49 | return ( 50 | 51 | 52 | { 54 | dispatcher({ type: HostGameAction.REQUEST_SUB_RESULT }); 55 | }} 56 | > 57 | 정답확인 58 | 59 | 60 | 61 | 62 | {remainTime} 63 | 64 | 65 | 68 | 69 | 70 | 71 |
72 | {roomState.players.length}명이 참가 중 73 |
74 |
75 |
76 | ); 77 | } 78 | 79 | export default HostPlaying; 80 | -------------------------------------------------------------------------------- /client/src/components/inGame/HostQuizPlayingRoom.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import * as colors from '../../constants/colors'; 4 | import HostPlaying from './HostPlaying'; 5 | import HostSubResult from './HostSubResult'; 6 | import * as layout from './Layout'; 7 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 8 | import { HostGameContext } from '../../reducer/hostGameReducer'; 9 | 10 | const QuizInformation = styled.span` 11 | position: absolute; 12 | left: 1rem; 13 | top: 1rem; 14 | font-size: 1rem; 15 | color: ${colors.TEXT_GRAY}; 16 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 17 | font-size: 1.5rem; 18 | } 19 | `; 20 | 21 | const ItemList = styled.div` 22 | width: 100%; 23 | border-radius: 5px; 24 | background-color: ${props => props.fontColor}; 25 | p { 26 | word-break: break-all; 27 | position: absolute; 28 | top: 50%; 29 | transform: translateY(-50%); 30 | width: 100%; 31 | margin: 0; 32 | color: ${colors.TEXT_WHITE}; 33 | font-size: 3vmin; 34 | font-weight: bold; 35 | text-align: center; 36 | } 37 | `; 38 | 39 | function HostQuizPlayingRoom() { 40 | const { dispatcher, roomState } = useContext(HostGameContext); 41 | const [showSubResult, setSubResultState] = useState(false); 42 | 43 | useEffect(() => { 44 | if (roomState.quizSubResult) { 45 | setSubResultState(true); 46 | } 47 | }, [roomState.quizSubResult]); 48 | 49 | useEffect(() => { 50 | setSubResultState(false); 51 | }, [roomState.currentQuiz]); 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | {roomState.currentQuiz.index + 1}/{roomState.totalQuizCount} 59 | 60 | {roomState.currentQuiz.title} 61 | 62 | 63 | 64 | { 65 | { 66 | false: , 67 | true: , 68 | }[showSubResult] 69 | } 70 | 71 | 72 | 73 | {roomState.currentQuiz.items.map((item, index) => { 74 | if (!item.title) return null; 75 | return ( 76 | 77 | 78 |

{item.title}

79 |
80 |
81 | ); 82 | })} 83 |
84 |
85 |
86 | ); 87 | } 88 | 89 | export default HostQuizPlayingRoom; 90 | -------------------------------------------------------------------------------- /client/src/components/inGame/HostResult.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory } from 'react-router'; 3 | import PropTypes from 'prop-types'; 4 | import styled from 'styled-components'; 5 | 6 | import Dashboard from '../common/Dashboard'; 7 | import { YellowButton } from '../common/Buttons'; 8 | import * as colors from '../../constants/colors'; 9 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 10 | 11 | const Background = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | width: 100%; 16 | height: 100%; 17 | background-color: ${colors.BACKGROUND_LIGHT_GRAY}; 18 | user-select: none; 19 | `; 20 | 21 | const ButtonContainer = styled.div` 22 | right: 0; 23 | position: absolute; 24 | margin: 1rem; 25 | z-index: 1; 26 | 27 | button { 28 | font-size: 1rem; 29 | } 30 | 31 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 32 | button { 33 | font-size: 2rem; 34 | } 35 | } 36 | `; 37 | 38 | function HostGameResult({ ranking }) { 39 | const history = useHistory(); 40 | function exit() { 41 | history.go(-1); 42 | } 43 | 44 | return ( 45 | 46 | 47 | 나가기 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | HostGameResult.defaultProps = { 55 | ranking: [], 56 | }; 57 | 58 | HostGameResult.propTypes = { 59 | ranking: PropTypes.arrayOf(PropTypes.object), 60 | }; 61 | 62 | export default HostGameResult; 63 | -------------------------------------------------------------------------------- /client/src/components/inGame/HostSubResult.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useEffect } from 'react'; 2 | import { GreenButton, YellowButton } from '../common/Buttons'; 3 | import ScoreChart from '../common/ScoreChart'; 4 | import * as layout from './Layout'; 5 | import { HostGameAction, HostGameContext } from '../../reducer/hostGameReducer'; 6 | 7 | function HostSubResult() { 8 | const { dispatcher, roomState } = useContext(HostGameContext); 9 | const [nextButtonName, setNextButtonName] = useState('다음퀴즈'); 10 | 11 | const itemDatas = roomState.quizSubResult.map((cur, index) => { 12 | if (roomState.currentQuiz.answers.includes(index)) { 13 | return { ...cur, isAnswer: true }; 14 | } 15 | 16 | return { ...cur, isAnswer: false }; 17 | }); 18 | 19 | useEffect(() => { 20 | if (roomState.currentQuiz.index === roomState.totalQuizCount - 1) { 21 | setNextButtonName('최종결과'); 22 | } 23 | }, [nextButtonName]); 24 | 25 | function handleNextButtonClick() { 26 | if (roomState.currentQuiz.index === roomState.totalQuizCount - 1) { 27 | dispatcher({ type: HostGameAction.REQUEST_QUIZ_END }); 28 | return; 29 | } 30 | dispatcher({ type: HostGameAction.REQUEST_NEXT_QUIZ }); 31 | } 32 | 33 | function handleCloseButtonClick() { 34 | dispatcher({ type: HostGameAction.REQUEST_QUIZ_END }); 35 | } 36 | 37 | return ( 38 | 39 | 40 | 41 | {nextButtonName} 42 | 43 | 44 | 45 | {roomState.currentQuiz.index !== roomState.totalQuizCount - 1 && ( 46 | 47 | 퀴즈종료하기 48 | 49 | )} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | export default HostSubResult; 61 | -------------------------------------------------------------------------------- /client/src/components/inGame/HostWaitingRoom.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import * as colors from '../../constants/colors'; 5 | import Header from '../common/Header'; 6 | import { YellowButton } from '../common/Buttons'; 7 | import { HostGameAction, HostGameContext } from '../../reducer/hostGameReducer'; 8 | import InformationArea from '../common/InformationArea'; 9 | import MainContainer from '../common/MainContainer'; 10 | 11 | const RoomInformation = styled.div` 12 | position: relative; 13 | 14 | &::before { 15 | content: '방 번호 '; 16 | user-select: none; 17 | } 18 | `; 19 | 20 | const PlayerCount = styled.div` 21 | position: relative; 22 | user-select: none; 23 | `; 24 | 25 | const PlayerList = styled.ul` 26 | background-color: white; 27 | flex: 1; 28 | overflow-y: auto; 29 | box-shadow: 1px 1px 3px gray; 30 | border-radius: 5px; 31 | padding: 0; 32 | margin: 0; 33 | 34 | li { 35 | display: inline-block; 36 | margin: 2vmin 4vmin; 37 | font-size: 6vmin; 38 | font-weight: bold; 39 | color: ${colors.TEXT_BLACK}; 40 | } 41 | `; 42 | 43 | function HostWaitingRoom() { 44 | const { roomState, dispatcher } = useContext(HostGameContext); 45 | function startQuiz() { 46 | dispatcher({ type: HostGameAction.GAME_START }); 47 | } 48 | return ( 49 | <> 50 |
퀴즈 시작} 52 | /> 53 | 54 | 55 | 56 | {roomState.roomNumber} 57 | 58 | {`대기자 ${roomState.players.length}명`} 59 | 60 | 61 | {roomState.players.map(player => ( 62 |
  • {player.nickname}
  • 63 | ))} 64 |
    65 |
    66 | 67 | ); 68 | } 69 | 70 | export default HostWaitingRoom; 71 | -------------------------------------------------------------------------------- /client/src/components/inGame/Hourglass.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { css, keyframes } from 'styled-components'; 3 | 4 | const GLASS_COLOR = '#b2eef9'; 5 | const GLASS_SIZE = '5vw'; 6 | const SAND_COLOR = '#ffd858'; 7 | const ANIMATION_DURATION = '5s'; 8 | const BORDER_COLOR = 'black'; 9 | const ROTATE_PERCENT = 97; 10 | const FALL_START_PERCENT = 10; 11 | 12 | const RotateGlassAnimation = keyframes` 13 | ${ROTATE_PERCENT}% { 14 | transform: rotateZ(0deg); 15 | } 16 | 100% { 17 | transform: rotateZ(180deg); 18 | } 19 | `; 20 | 21 | const HourglassContainer = styled.div.attrs({ 22 | className: 'hourglass', 23 | })` 24 | animation-name: ${RotateGlassAnimation}; 25 | animation-duration: ${ANIMATION_DURATION}; 26 | animation-iteration-count: infinite; 27 | `; 28 | 29 | function getTriangleStyle(isTop, color) { 30 | const Style = css` 31 | position: absolute; 32 | width: 0; 33 | height: 0; 34 | border-left: calc(${GLASS_SIZE} / 2) solid transparent; 35 | border-right: calc(${GLASS_SIZE} / 2) solid transparent; 36 | ${isTop 37 | ? `border-top: calc(${GLASS_SIZE} / 2) solid ${color};` 38 | : `border-bottom: calc(${GLASS_SIZE} / 2) solid ${color};`} 39 | `; 40 | return Style; 41 | } 42 | 43 | const Top = styled.div` 44 | position: relative; 45 | width: ${GLASS_SIZE}; 46 | height: ${GLASS_SIZE}; 47 | border-top: calc(${GLASS_SIZE} / 10) solid ${BORDER_COLOR}; 48 | `; 49 | 50 | const Bottom = styled.div` 51 | position: relative; 52 | width: ${GLASS_SIZE}; 53 | height: ${GLASS_SIZE}; 54 | border-bottom: calc(${GLASS_SIZE} / 10) solid ${BORDER_COLOR}; 55 | `; 56 | 57 | const TopGlass = styled.div` 58 | position: absolute; 59 | width: ${GLASS_SIZE}; 60 | height: calc(${GLASS_SIZE} / 2); 61 | background-color: ${GLASS_COLOR}; 62 | transform: scale(1); 63 | 64 | &::before { 65 | content: ''; 66 | ${getTriangleStyle(true, GLASS_COLOR)}; 67 | bottom: calc(-${GLASS_SIZE} / 2); 68 | } 69 | `; 70 | 71 | const BottomGlass = styled.div` 72 | position: absolute; 73 | width: ${GLASS_SIZE}; 74 | height: calc(${GLASS_SIZE} / 2); 75 | background-color: ${GLASS_COLOR}; 76 | bottom: 0; 77 | 78 | &::before { 79 | content: ''; 80 | ${getTriangleStyle(false, GLASS_COLOR)}; 81 | top: calc(-${GLASS_SIZE} / 2); 82 | } 83 | `; 84 | 85 | const ClearSandAnimation = keyframes` 86 | 0%{ 87 | transform: scale(1); 88 | } 89 | ${FALL_START_PERCENT}%{ 90 | transform: scale(1); 91 | } 92 | ${ROTATE_PERCENT - 5}%{ 93 | transform: scale(0); 94 | } 95 | 100%{ 96 | transform: scale(0); 97 | } 98 | `; 99 | 100 | const FillSandAnimation = keyframes` 101 | 0%{ 102 | transform: scale(0); 103 | bottom: 0; 104 | } 105 | ${FALL_START_PERCENT}%{ 106 | transform: scale(0); 107 | bottom: 0; 108 | } 109 | ${ROTATE_PERCENT - 5}%{ 110 | transform: scale(1); 111 | bottom: 0; 112 | } 113 | ${ROTATE_PERCENT}%{ 114 | transform: scale(1); 115 | bottom: 0; 116 | } 117 | 100%{ 118 | transform: scale(1); 119 | bottom: calc(${GLASS_SIZE} / 2); 120 | } 121 | `; 122 | 123 | const TopSand = styled.div` 124 | ${getTriangleStyle(true, SAND_COLOR)} 125 | top: calc(${GLASS_SIZE} / 2); 126 | transform-origin: 50% 100%; 127 | 128 | animation-name: ${ClearSandAnimation}; 129 | animation-duration: ${ANIMATION_DURATION}; 130 | animation-iteration-count: infinite; 131 | animation-timing-function: linear; 132 | `; 133 | 134 | const BottomSand = styled.div` 135 | ${getTriangleStyle(false, SAND_COLOR)} 136 | transform: scale(0); 137 | transform-origin: 50% 100%; 138 | 139 | animation-name: ${FillSandAnimation}; 140 | animation-duration: ${ANIMATION_DURATION}; 141 | animation-iteration-count: infinite; 142 | animation-timing-function: linear; 143 | `; 144 | 145 | const FallSandAnimation = keyframes` 146 | 0%{ 147 | opacity: 0; 148 | height: ${GLASS_SIZE}; 149 | } 150 | ${FALL_START_PERCENT}%{ 151 | opacity: 0; 152 | height: ${GLASS_SIZE}; 153 | } 154 | ${ROTATE_PERCENT - 10}%{ 155 | opacity: 1; 156 | height: ${GLASS_SIZE}; 157 | top: 0; 158 | } 159 | ${ROTATE_PERCENT - 8}%{ 160 | opacity: 1; 161 | height: 0; 162 | top: calc(${GLASS_SIZE} / 2); 163 | } 164 | 100%{ 165 | opacity: 0; 166 | height: 0; 167 | } 168 | `; 169 | 170 | const FallSand = styled.div` 171 | position: absolute; 172 | border-left: 3px solid ${SAND_COLOR}; 173 | height: ${GLASS_SIZE}; 174 | left: calc(50% - 1px); 175 | 176 | animation-name: ${FallSandAnimation}; 177 | animation-duration: ${ANIMATION_DURATION}; 178 | animation-iteration-count: infinite; 179 | animation-timing-function: linear; 180 | `; 181 | 182 | function Hourglass() { 183 | return ( 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | ); 198 | } 199 | 200 | export default Hourglass; 201 | 202 | //http://riophae.github.io/css-animated-hourglass/demo.html 203 | //https://codepen.io/MarvinVK/pen/MpPMwB 204 | //https://codepen.io/nckg/pen/ojGdK 205 | -------------------------------------------------------------------------------- /client/src/components/inGame/Layout.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import * as colors from '../../constants/colors'; 4 | 5 | const VERTICAL_PADDING = '1.5vh'; 6 | const HORIZONTAL_PADDING = '1.5vw'; 7 | const BUTTON_MARGIN = '0.4rem'; 8 | 9 | const Background = styled.div` 10 | position: relative; 11 | display: flex; 12 | flex: 1; 13 | flex-direction: column; 14 | width: 100%; 15 | background-color: ${colors.BACKGROUND_DEEP_GRAY}; 16 | `; 17 | 18 | const TitleContainer = styled.div` 19 | position: relative; 20 | flex: none; 21 | background-color: ${colors.BACKGROUND_LIGHT_WHITE}; 22 | width: 100%; 23 | box-shadow: 0 5px 5px -4px ${colors.TEXT_GRAY}; 24 | `; 25 | 26 | const Title = styled.div` 27 | font-size: 4.5vmin; 28 | font-weight: bold; 29 | text-align: center; 30 | padding: ${VERTICAL_PADDING} ${HORIZONTAL_PADDING}; 31 | color: black; 32 | `; 33 | 34 | const Center = styled.div` 35 | position: relative; 36 | flex: 1.5; 37 | `; 38 | 39 | const Bottom = styled.div` 40 | position: relative; 41 | flex: 1; 42 | `; 43 | 44 | const CenterContentContainer = styled.div` 45 | position: absolute; 46 | display: flex; 47 | align-items: center; 48 | top: calc(${VERTICAL_PADDING} * 2); 49 | bottom: ${VERTICAL_PADDING}; 50 | left: ${HORIZONTAL_PADDING}; 51 | right: ${HORIZONTAL_PADDING}; 52 | `; 53 | 54 | const NextButtonWrapper = styled.div` 55 | align-self: flex-start; 56 | div.buttonWrapper { 57 | position: absolute; 58 | right: 0; 59 | flex: none; 60 | display: inline-block; 61 | } 62 | button { 63 | font-size: 2vw; 64 | padding: 0.4vh 0.75vw; 65 | } 66 | `; 67 | 68 | const CloseButtonWrapper = styled.div` 69 | align-self: flex-start; 70 | div.buttonWrapper { 71 | position: absolute; 72 | left: 0; 73 | flex: none; 74 | display: inline-block; 75 | } 76 | button { 77 | font-size: 2vw; 78 | padding: 0.4vh 0.75vw; 79 | } 80 | `; 81 | 82 | const CenterLeftPanel = styled.div` 83 | display: flex; 84 | flex-direction: column; 85 | width: 15vw; 86 | height: 100%; 87 | align-items: center; 88 | justify-content: center; 89 | `; 90 | 91 | const CenterRightPanel = styled.div` 92 | display: flex; 93 | flex-direction: column; 94 | align-items: center; 95 | width: 15vw; 96 | height: 100%; 97 | `; 98 | 99 | const RemainPeople = styled.span` 100 | font-size: 2vw; 101 | font-weight: bold; 102 | text-align: center; 103 | justify-self: center; 104 | margin: auto; 105 | user-select: none; 106 | 107 | &::before { 108 | content: '✍'; 109 | font-size: 5vw; 110 | } 111 | `; 112 | 113 | const ImagePanel = styled.div` 114 | position: relative; 115 | flex: 1; 116 | height: 100%; 117 | margin: 0 2vw; 118 | 119 | display: flex; 120 | align-items: center; 121 | justify-content: center; 122 | `; 123 | 124 | const ItemContainer = styled.div` 125 | position: absolute; 126 | top: ${VERTICAL_PADDING}; 127 | bottom: ${VERTICAL_PADDING}; 128 | left: ${HORIZONTAL_PADDING}; 129 | right: ${HORIZONTAL_PADDING}; 130 | display: flex; 131 | flex-wrap: wrap; 132 | `; 133 | 134 | const Item = styled.div` 135 | position: relative; 136 | display: flex; 137 | min-height: calc(50% - ${BUTTON_MARGIN} * 2); 138 | flex: 1 0 calc(50% - ${BUTTON_MARGIN} * 2); 139 | margin: ${BUTTON_MARGIN}; 140 | 141 | div.buttonWrapper { 142 | flex: 1; 143 | } 144 | 145 | button { 146 | font-size: 2.25vmin; 147 | padding: 0.4vmin; 148 | } 149 | `; 150 | 151 | export { 152 | Background, 153 | TitleContainer, 154 | Title, 155 | Center, 156 | Bottom, 157 | CenterContentContainer, 158 | NextButtonWrapper, 159 | CloseButtonWrapper, 160 | CenterLeftPanel, 161 | CenterRightPanel, 162 | RemainPeople, 163 | ImagePanel, 164 | ItemContainer, 165 | Item, 166 | }; 167 | -------------------------------------------------------------------------------- /client/src/components/inGame/PlayerFooter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import * as colors from '../../constants/colors'; 5 | import * as styles from '../../styles/common'; 6 | 7 | const Nickname = styled.span` 8 | ${styles.InGameFooterTextStyle} 9 | margin-left: 0.5rem; 10 | color: ${colors.TEXT_BLACK}; 11 | `; 12 | 13 | const Score = styled.span` 14 | ${styles.InGameFooterTextStyle} 15 | right: 0.5rem; 16 | color: ${colors.TEXT_WHITE}; 17 | background-color: ${colors.TEXT_BLACK}; 18 | padding: 0.3vmin 1.25vmin; 19 | border-radius: 0.5rem; 20 | `; 21 | 22 | const Footer = styled.footer` 23 | ${styles.InGameFooterStyle}; 24 | `; 25 | 26 | function PlayerFooter({ nickname, score }) { 27 | return ( 28 |
    29 | {nickname} 30 | {score} 31 |
    32 | ); 33 | } 34 | 35 | PlayerFooter.propTypes = { 36 | nickname: PropTypes.string.isRequired, 37 | score: PropTypes.number.isRequired, 38 | }; 39 | 40 | export default PlayerFooter; 41 | -------------------------------------------------------------------------------- /client/src/components/inGame/PlayerQuiz.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | import { Button } from '../common/Buttons'; 7 | import * as layout from './Layout'; 8 | 9 | import LoadingCircle from '../common/LoadingCircle'; 10 | import { readAnswer } from '../../utils/fetch'; 11 | import Hourglass from './Hourglass'; 12 | import multipleChoiceImage from '../../assets/images/multiple_choice.svg'; 13 | 14 | const RemainTime = styled.span` 15 | position: absolute; 16 | margin-top: auto; 17 | font-size: 2vw; 18 | font-weight: bold; 19 | user-select: none; 20 | `; 21 | 22 | const ImageContainer = styled.div` 23 | width: 100%; 24 | height: 100%; 25 | 26 | background-image: url(${props => props.image}); 27 | background-size: contain; 28 | background-position: center; 29 | background-repeat: no-repeat; 30 | `; 31 | 32 | function Selection({ currentQuiz, chooseAnswer, setIsAnswer }) { 33 | const [remainTime, setRemainTime] = useState(0); 34 | 35 | useEffect(() => { 36 | // 새로운 문제이므로, 이전의 정답결과를 초기화 37 | setIsAnswer(false); 38 | 39 | setRemainTime(Number(currentQuiz.timeLimit)); 40 | const timer = setInterval(() => { 41 | setRemainTime(cur => { 42 | if (cur === 0) { 43 | clearInterval(timer); 44 | return 0; 45 | } 46 | return cur - 1; 47 | }); 48 | }, 1000); 49 | 50 | return () => { 51 | clearInterval(timer); 52 | }; 53 | }, [currentQuiz, setIsAnswer]); 54 | 55 | return ( 56 | <> 57 | 58 | 59 | 60 | 61 | {remainTime} 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {currentQuiz.items.map((item, index) => { 72 | if (!item.title) return null; 73 | return ( 74 | 75 | 82 | 83 | ); 84 | })} 85 | 86 | 87 | 88 | ); 89 | } 90 | 91 | function PlayerQuiz({ quizSet, roomNumber, quizIndex, setIsAnswer, nickname }) { 92 | const [choosed, setChoosed] = useState(false); 93 | 94 | const currentQuiz = quizSet[quizIndex]; 95 | async function chooseAnswer(itemIndex) { 96 | setChoosed(true); 97 | readAnswer(roomNumber, nickname, quizIndex, itemIndex).then(response => { 98 | if (response.isCorrect) { 99 | setIsAnswer(true); 100 | } else { 101 | setIsAnswer(false); 102 | } 103 | }); 104 | } 105 | 106 | return ( 107 | 108 | 109 | {currentQuiz.title} 110 | 111 | {!choosed && ( 112 | 117 | )} 118 | {choosed && } 119 | 120 | ); 121 | } 122 | 123 | Selection.propTypes = { 124 | currentQuiz: PropTypes.shape({ 125 | items: PropTypes.arrayOf( 126 | PropTypes.shape({ 127 | title: PropTypes.string.isRequired, 128 | }), 129 | ).isRequired, 130 | image: PropTypes.string, 131 | timeLimit: PropTypes.number.isRequired, 132 | }).isRequired, 133 | chooseAnswer: PropTypes.func.isRequired, 134 | setIsAnswer: PropTypes.func.isRequired, 135 | }; 136 | 137 | PlayerQuiz.propTypes = { 138 | quizSet: PropTypes.arrayOf( 139 | PropTypes.shape({ 140 | items: PropTypes.arrayOf( 141 | PropTypes.shape({ 142 | title: PropTypes.string.isRequired, 143 | }), 144 | ).isRequired, 145 | title: PropTypes.string.isRequired, 146 | }), 147 | ).isRequired, 148 | roomNumber: PropTypes.string.isRequired, 149 | quizIndex: PropTypes.number.isRequired, 150 | setIsAnswer: PropTypes.func.isRequired, 151 | nickname: PropTypes.string.isRequired, 152 | }; 153 | 154 | export default PlayerQuiz; 155 | -------------------------------------------------------------------------------- /client/src/components/inGame/PlayerQuizLoading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import * as colors from '../../constants/colors'; 5 | import ProgressBar from './ProgressBar'; 6 | 7 | const Container = styled.div` 8 | display: flex; 9 | flex-direction: column; 10 | width: 100%; 11 | height: 100%; 12 | `; 13 | 14 | const Main = styled.div` 15 | display: flex; 16 | flex-direction: column; 17 | background-color: ${colors.BACKGROUND_LIGHT_GRAY}; 18 | flex: 1; 19 | align-items: center; 20 | padding: 0 2rem 2rem; 21 | `; 22 | 23 | const Notify = styled.p` 24 | position: absolute; 25 | top: 50%; 26 | transform: translateY(-50%); 27 | padding: 0 2rem; 28 | font-size: 2rem; 29 | text-align: center; 30 | font-weight: bold; 31 | color: ${colors.TEXT_BLACK}; 32 | `; 33 | 34 | function PlayerQuizLoading() { 35 | return ( 36 | 37 |
    38 | 39 | 퀴즈가 준비 중이에요
    40 | 잠시 기다려주세요 41 |
    42 | 43 |
    44 |
    45 | ); 46 | } 47 | 48 | export default PlayerQuizLoading; 49 | -------------------------------------------------------------------------------- /client/src/components/inGame/PlayerResult.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | import Dashboard from '../common/Dashboard'; 6 | import LoadingCircle from '../common/LoadingCircle'; 7 | import { YellowButton } from '../common/Buttons'; 8 | import { readRank } from '../../utils/fetch'; 9 | import * as colors from '../../constants/colors'; 10 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 11 | import goldMedalImage from '../../assets/images/goldMedal.png'; 12 | import silverMedalImage from '../../assets/images/silverMedal.png'; 13 | import bronzeMedalImage from '../../assets/images/bronzeMedal.png'; 14 | 15 | const medalImages = [goldMedalImage, silverMedalImage, bronzeMedalImage]; 16 | 17 | const Background = styled.div` 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | width: 100%; 22 | height: 100%; 23 | background-color: ${colors.BACKGROUND_LIGHT_GRAY}; 24 | user-select: none; 25 | `; 26 | 27 | const RankSection = styled.div` 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | flex-direction: column; 32 | 33 | margin: 3vmin 0; 34 | `; 35 | 36 | const Rank = styled.div` 37 | height: 8vmin; 38 | 39 | font-size: 6vmin; 40 | font-weight: bold; 41 | text-align: center; 42 | color: #000; 43 | `; 44 | 45 | const Medal = styled.div` 46 | height: 8vmin; 47 | width: 8vmin; 48 | 49 | background-repeat: no-repeat; 50 | background-size: contain; 51 | background-position: center; 52 | ${props => `background-image: url(${medalImages[props.rank - 1]})`}; 53 | `; 54 | 55 | const ButtonContainer = styled.div` 56 | right: 0; 57 | position: absolute; 58 | margin: 1rem; 59 | 60 | button { 61 | font-size: 1rem; 62 | } 63 | 64 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 65 | button { 66 | font-size: 2rem; 67 | } 68 | } 69 | `; 70 | 71 | function PlayerResult({ ranking, roomNumber, nickname }) { 72 | const [rank, setRank] = useState(0); 73 | 74 | useEffect(() => { 75 | readRank(roomNumber, nickname).then(response => { 76 | setRank(response.rank); 77 | }); 78 | }, [roomNumber, nickname]); 79 | 80 | // fetch 요청이 끝나지 않아 rank === undefined인 경우 81 | if (!rank) { 82 | return ; 83 | } 84 | 85 | function exit() { 86 | window.location.href = '/'; 87 | } 88 | // fetch 요청으로 rank를 받아온 경우 89 | if (rank) { 90 | return ( 91 | 92 | 93 | 나가기 94 | 95 | 96 | {rank <= 3 && } 97 | {rank > 3 && {rank}등} 98 | 99 | 100 | 101 | ); 102 | } 103 | } 104 | 105 | PlayerResult.defaultProps = { 106 | ranking: [], 107 | }; 108 | 109 | PlayerResult.propTypes = { 110 | ranking: PropTypes.arrayOf(PropTypes.object), 111 | roomNumber: PropTypes.string.isRequired, 112 | nickname: PropTypes.string.isRequired, 113 | }; 114 | 115 | export default PlayerResult; 116 | -------------------------------------------------------------------------------- /client/src/components/inGame/PlayerSubResult.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const COLORS = { 6 | GREEN: '#51ce66', 7 | RED: '#ff6b6b', 8 | WHITE: '#ffffff', 9 | }; 10 | 11 | const Background = styled.div` 12 | width: 100%; 13 | height: 100%; 14 | 15 | position: relative; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | 20 | background-color: ${props => props.color}; 21 | `; 22 | 23 | const Message = styled.div` 24 | font-size: 3rem; 25 | text-align: center; 26 | `; 27 | 28 | const Score = styled.div` 29 | position: absolute; 30 | font-size: 3rem; 31 | 32 | padding: 2rem; 33 | 34 | color: #ffffff; 35 | background-color: #008001; 36 | 37 | transform: translateY(10rem); 38 | `; 39 | 40 | function PlayerSubResult({ plusScore, score, setScore, isAnswer }) { 41 | useEffect(() => { 42 | // 정답인 경우에만 점수를 갱신함 43 | if (isAnswer) { 44 | setScore(score + plusScore); 45 | } 46 | }, [isAnswer]); 47 | 48 | return ( 49 | <> 50 | {!isAnswer && ( 51 | 52 | 틀렸습니다. 53 | 54 | )} 55 | {isAnswer && ( 56 | 57 | 맞았습니다. 58 | +{plusScore} 59 | 60 | )} 61 | 62 | ); 63 | } 64 | 65 | PlayerSubResult.propTypes = { 66 | plusScore: PropTypes.number.isRequired, 67 | score: PropTypes.number.isRequired, 68 | setScore: PropTypes.func.isRequired, 69 | isAnswer: PropTypes.bool.isRequired, 70 | }; 71 | 72 | export default PlayerSubResult; 73 | -------------------------------------------------------------------------------- /client/src/components/inGame/PlayerWaiting.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 7 | import LoadingCircle from '../common/LoadingCircle'; 8 | 9 | import { fetchQuizSet } from '../../utils/fetch'; 10 | 11 | const LoadingText = styled.span` 12 | font-size: 1.5rem; 13 | margin-top: 5rem; 14 | color: ${colors.PRIMARY_DEEP_GREEN}; 15 | font-weight: bold; 16 | margin-top: auto; 17 | justify-self: flex-end; 18 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 19 | font-size: 2rem; 20 | } 21 | `; 22 | 23 | const Main = styled.main` 24 | display: flex; 25 | flex-direction: column; 26 | background-color: ${colors.BACKGROUND_LIGHT_GRAY}; 27 | flex: 1; 28 | padding: 3rem; 29 | align-items: center; 30 | `; 31 | 32 | function PlayerWaiting({ roomNumber, setQuizSet }) { 33 | useEffect(() => { 34 | fetchQuizSet(roomNumber).then(response => { 35 | if (response.isSuccess) { 36 | setQuizSet(response.quizSet); 37 | } else { 38 | // 유효하지 않은 방의 퀴즈세트를 받는 경우 39 | window.location.href = '/'; 40 | } 41 | }); 42 | }, [roomNumber, setQuizSet]); 43 | 44 | return ( 45 |
    46 | 47 | 게임 시작을 기다리고 있습니다... 48 |
    49 | ); 50 | } 51 | 52 | PlayerWaiting.propTypes = { 53 | setQuizSet: PropTypes.func.isRequired, 54 | roomNumber: PropTypes.string.isRequired, 55 | }; 56 | 57 | export default PlayerWaiting; 58 | -------------------------------------------------------------------------------- /client/src/components/inGame/PlayerWarning.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { BACKGROUND_LIGHT_WHITE } from '../../constants/colors'; 5 | 6 | const Background = styled.div` 7 | width: 100%; 8 | height: 100%; 9 | 10 | position: relative; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | 15 | background-color: ${props => props.color}; 16 | `; 17 | 18 | const Message = styled.div` 19 | font-size: 3rem; 20 | text-align: center; 21 | `; 22 | 23 | function PlayerSubResult() { 24 | return ( 25 | 26 | 중간에 들어오셨군요. 잠시만 기다려주세요 27 | 28 | ); 29 | } 30 | 31 | export default PlayerSubResult; 32 | -------------------------------------------------------------------------------- /client/src/components/inGame/ProgressBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import * as colors from '../../constants/colors'; 5 | 6 | const ProgressBarBackground = styled.div` 7 | position: relative; 8 | justify-self: flex-end; 9 | height: 2rem; 10 | width: 100%; 11 | margin-top: auto; 12 | border-radius: 1rem; 13 | background-color: ${colors.PRIMARY_DEEP_YELLOW}; 14 | `; 15 | 16 | const ProgressAnimation = keyframes` 17 | from { 18 | width: 0; 19 | } 20 | to{ 21 | width: 100%; 22 | } 23 | `; 24 | 25 | const ProgressBarContent = styled.div` 26 | height: 100%; 27 | border-radius: 1rem; 28 | background-color: ${colors.PRIMARY_DEEP_GREEN}; 29 | animation: ${ProgressAnimation} ${(props) => props.animationDurationSeconds}s 30 | linear forwards; 31 | `; 32 | 33 | function ProgressBar({ animationDurationSeconds }) { 34 | return ( 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | ProgressBar.propTypes = { 42 | animationDurationSeconds: PropTypes.number.isRequired, 43 | }; 44 | 45 | export default ProgressBar; 46 | -------------------------------------------------------------------------------- /client/src/components/logo/Logo.css: -------------------------------------------------------------------------------- 1 | @keyframes rotate { 2 | 0% { 3 | transform: rotateY(-45deg); 4 | } 5 | 50% { 6 | transform: rotateY(0); 7 | } 8 | 100% { 9 | transform: rotateY(45deg); 10 | } 11 | } 12 | 13 | div#logo3d { 14 | position: relative; 15 | perspective: 900px; 16 | perspective-origin: 50% -100%; 17 | transform-style: preserve-3d; 18 | } 19 | 20 | div#string { 21 | position: relative; 22 | transform-style: preserve-3d; 23 | align-self: center; 24 | 25 | animation-name: rotate; 26 | animation-duration: 1.5s; 27 | animation-iteration-count: infinite; 28 | animation-timing-function: linear; 29 | animation-direction: alternate; 30 | } 31 | 32 | div.char, 33 | div.box { 34 | position: absolute; 35 | box-sizing: border-box; 36 | transform-style: preserve-3d; 37 | } 38 | 39 | div.face { 40 | position: absolute; 41 | border: 1px solid #495057; 42 | box-sizing: border-box; 43 | } 44 | div.face-green { 45 | position: absolute; 46 | background-color: #0b7545; 47 | border: 1px solid #ebebeb; 48 | box-sizing: border-box; 49 | } 50 | div.face-yellow { 51 | position: absolute; 52 | background-color: #fee25d; 53 | border: 1px solid #495057; 54 | box-sizing: border-box; 55 | } 56 | div.shadow { 57 | position: absolute; 58 | box-sizing: content-box; 59 | background-color: #495057; 60 | border: 1px solid #495057; 61 | transform-origin: 50% 100%; 62 | } 63 | -------------------------------------------------------------------------------- /client/src/components/mainPage/EnterNickname.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useEffect } from 'react'; 2 | import { useHistory, useRouteMatch } from 'react-router'; 3 | import styled from 'styled-components'; 4 | 5 | import * as styles from '../../styles/common'; 6 | import { GreenButton } from '../common/Buttons'; 7 | import { fetchNickname, fetchRoomNumber } from '../../utils/fetch'; 8 | import { ToastContext } from '../common/ToastProvider'; 9 | 10 | const BUTTON_MARGIN_TOP = '1.5rem'; 11 | 12 | const ButtonContainer = styled.div` 13 | margin-top: ${BUTTON_MARGIN_TOP}; 14 | `; 15 | 16 | const Input = styled.input.attrs({ 17 | maxLength: 20, 18 | })` 19 | ${styles.InputStyle} 20 | `; 21 | 22 | function EnterNickname() { 23 | const history = useHistory(); 24 | const match = useRouteMatch(); 25 | const { roomNumber } = match.params; 26 | 27 | useEffect(() => { 28 | async function validateRoomNumber() { 29 | const { isSuccess } = await fetchRoomNumber(roomNumber); 30 | if (!isSuccess) { 31 | history.push('/'); 32 | alert('존재하지 않는 방번호입니다'); 33 | } 34 | } 35 | 36 | validateRoomNumber(); 37 | }, [roomNumber]); 38 | 39 | const [nickname, setNickname] = useState(''); 40 | const { onToast, offToast } = useContext(ToastContext); 41 | useEffect(offToast, []); 42 | 43 | function moveWaitingRoom() { 44 | history.push({ 45 | pathname: '/player', 46 | state: { roomNumber, nickname }, 47 | }); 48 | } 49 | 50 | function handleInputChange(e) { 51 | setNickname(e.target.value); 52 | } 53 | 54 | async function handleCreateButtonClick() { 55 | const response = await fetchNickname(nickname, roomNumber); 56 | 57 | if (response.isError) { 58 | onToast(response.message); 59 | return; 60 | } 61 | 62 | if (!response.isSuccess) { 63 | onToast(response.message); 64 | return; 65 | } 66 | 67 | moveWaitingRoom(); 68 | } 69 | 70 | function handleKeyUp(e) { 71 | if (e.key === 'Enter') { 72 | handleCreateButtonClick(); 73 | return; 74 | } 75 | 76 | if (/[^ㄱ-힣\w]+/g.test(e.target.value)) { 77 | e.target.value = e.target.value.replace(/[^ㄱ-힣\w]+/g, ''); 78 | setNickname(e.target.value); 79 | onToast('닉네임에 특수문자는 입력할 수 없습니다'); 80 | } 81 | } 82 | 83 | return ( 84 | <> 85 | 90 | 91 | 92 | 닉네임 정하기 93 | 94 | 95 | 96 | ); 97 | } 98 | 99 | export default EnterNickname; 100 | -------------------------------------------------------------------------------- /client/src/components/mainPage/EnterRoomNumber.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useEffect } from 'react'; 2 | import { useHistory } from 'react-router'; 3 | import styled from 'styled-components'; 4 | 5 | import * as styles from '../../styles/common'; 6 | import { GreenButton } from '../common/Buttons'; 7 | import { fetchRoomNumber } from '../../utils/fetch'; 8 | import { ToastContext } from '../common/ToastProvider'; 9 | 10 | const BUTTON_MARGIN_TOP = '1.5rem'; 11 | 12 | const ButtonContainer = styled.div` 13 | margin-top: ${BUTTON_MARGIN_TOP}; 14 | `; 15 | 16 | const Input = styled.input.attrs({ 17 | type: 'number', 18 | pattern: 'd*', 19 | })` 20 | &::-webkit-inner-spin-button { 21 | -webkit-appearance: none; 22 | margin: 0; 23 | } 24 | ${styles.InputStyle} 25 | `; 26 | 27 | function EnterRoomNumber() { 28 | const history = useHistory(); 29 | const [roomNumber, setRoomNumber] = useState(''); 30 | const { onToast, offToast } = useContext(ToastContext); 31 | useEffect(offToast, []); 32 | 33 | function moveNicknamePage() { 34 | history.push(`/join/${roomNumber}`); 35 | } 36 | 37 | function moveLoginPage() { 38 | history.push({ 39 | pathname: '/login', 40 | }); 41 | } 42 | 43 | function handleInputChange(e) { 44 | setRoomNumber(e.target.value); 45 | } 46 | 47 | async function handleEnterButtonClick() { 48 | const response = await fetchRoomNumber(roomNumber); 49 | 50 | if (response.isError) { 51 | onToast(response.message); 52 | return; 53 | } 54 | 55 | if (!response.isSuccess) { 56 | onToast(response.message); 57 | return; 58 | } 59 | 60 | moveNicknamePage(); 61 | } 62 | 63 | function handleMakeButtonClick() { 64 | moveLoginPage(); 65 | } 66 | 67 | function handlePressEnter(e) { 68 | if (e.which === 229) return; 69 | if (e.ctrlKey || e.key === 'Backspace') return; 70 | if (e.key === 'Enter') { 71 | handleEnterButtonClick(); 72 | return; 73 | } 74 | if (/[^0-9]/.test(e.key)) { 75 | onToast('방번호는 숫자만 입력할 수 있습니다'); 76 | return; 77 | } 78 | 79 | if (e.target.value.length >= 6) e.preventDefault(); 80 | } 81 | 82 | return ( 83 | <> 84 | 89 | 90 | 입장하기 91 | 92 | 93 | 방 만들기 94 | 95 | 96 | ); 97 | } 98 | 99 | export default EnterRoomNumber; 100 | -------------------------------------------------------------------------------- /client/src/components/mainPage/NaverLogin.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import NaverLogin from '../../utils/naverLoginSdk'; 5 | import loginImage from '../../assets/images/naverLoginButton_long.PNG'; 6 | 7 | const clientId = process.env.REACT_APP_NAVER_LOGIN_API_CLIENT_ID; 8 | 9 | const NoStyleButton = styled.button` 10 | background: none; 11 | cursor: pointer; 12 | color: #fff; 13 | border: none; 14 | margin: 0; 15 | padding: 0; 16 | 17 | height: 5rem; 18 | background-image: url(${loginImage}); 19 | background-repeat: no-repeat; 20 | background-size: contain; 21 | `; 22 | 23 | function LoginPage() { 24 | return ( 25 | ( 29 | 30 | )} 31 | /> 32 | ); 33 | } 34 | 35 | export default LoginPage; 36 | -------------------------------------------------------------------------------- /client/src/components/selectRoom/RoomList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory } from 'react-router'; 3 | import styled from 'styled-components'; 4 | import PropTypes from 'prop-types'; 5 | import * as colors from '../../constants/colors'; 6 | import { deleteRoom } from '../../utils/fetch'; 7 | import deleteButtonImage from '../../assets/images/deleteButton.png'; 8 | 9 | const RoomWrapper = styled.div` 10 | position: relative; 11 | display: flex; 12 | align-items: center; 13 | width: 100%; 14 | height: 12.5vmin; 15 | margin-bottom: 2vmin; 16 | box-sizing: border-box; 17 | padding: 1vmin; 18 | background-color: white; 19 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); 20 | user-select: none; 21 | border-radius: 0.4rem; 22 | cursor: pointer; 23 | 24 | &:hover { 25 | div.door { 26 | transform: rotateY(-45deg); 27 | } 28 | } 29 | `; 30 | 31 | const RoomFrame = styled.div` 32 | position: relative; 33 | width: 7vmin; 34 | height: 100%; 35 | margin: 0 2vmin 0 1vmin; 36 | border: 0.4vmin solid black; 37 | box-sizing: border-box; 38 | perspective: 100px; 39 | transform-style: preserve-3d; 40 | `; 41 | 42 | const RoomDoor = styled.div.attrs({ 43 | className: 'door', 44 | })` 45 | position: absolute; 46 | display: flex; 47 | align-items: center; 48 | justify-content: flex-end; 49 | width: 100%; 50 | height: 100%; 51 | background-color: brown; 52 | transform-origin: 0% 50%; 53 | transition: 0.5s; 54 | `; 55 | 56 | const DoorKnob = styled.div` 57 | width: 1vmin; 58 | height: 1vmin; 59 | box-sizing: border-box; 60 | background-color: ${colors.PRIMARY_DEEP_YELLOW}; 61 | border-radius: 50%; 62 | border: 1px solid black; 63 | margin-right: 0.5vmin; 64 | `; 65 | 66 | const RoomTitle = styled.span` 67 | font-size: 4vmin; 68 | `; 69 | 70 | const DeleteRoomButton = styled.img.attrs({ 71 | src: deleteButtonImage, 72 | })` 73 | position: absolute; 74 | right: 2rem; 75 | width: 2rem; 76 | height: 2rem; 77 | opacity: 0.2; 78 | 79 | &:hover { 80 | opacity: 1; 81 | } 82 | `; 83 | 84 | function RoomList({ rooms, setRooms }) { 85 | const history = useHistory(); 86 | 87 | function handleRoomClick(e) { 88 | if (e.defaultPrevented) return; 89 | 90 | const roomTitle = e.target.textContent; 91 | const roomId = rooms.find(room => room.title === roomTitle).id; 92 | 93 | history.push({ 94 | pathname: '/host/room/detail', 95 | state: { 96 | roomId, 97 | }, 98 | }); 99 | } 100 | 101 | function handleDeleteRoomClick(e) { 102 | const roomTitle = e.target.previousElementSibling.textContent; 103 | const roomId = rooms.find(room => room.title === roomTitle).id; 104 | 105 | async function removeRoom() { 106 | const { isSuccess } = await deleteRoom({ roomId }); 107 | 108 | if (!isSuccess) { 109 | alert('오류로 인해 방이 삭제되지 않았습니다'); 110 | return; 111 | } 112 | 113 | setRooms(rooms.filter(room => room.id !== roomId)); 114 | } 115 | 116 | removeRoom(); 117 | e.preventDefault(); 118 | } 119 | 120 | return rooms.map(room => ( 121 | 122 | 123 | 124 | 125 | 126 | 127 | {room.title} 128 | 129 | 130 | )); 131 | } 132 | 133 | RoomList.propTypes = { 134 | rooms: PropTypes.arrayOf(PropTypes.object), 135 | }; 136 | 137 | export default RoomList; 138 | -------------------------------------------------------------------------------- /client/src/constants/colors.js: -------------------------------------------------------------------------------- 1 | const TEXT_BLACK = '#495057'; 2 | const TEXT_GRAY = '#868E96'; 3 | const TEXT_WHITE = '#FFFFFF'; 4 | const PRIMARY_DEEP_YELLOW = '#FEE25D'; 5 | const PRIMARY_DEEP_GREEN = '#0B7545'; 6 | const PRIMARY_LIGHT_YELLOW = '#FFFCDA'; 7 | const BORDER_LIGHT_GRAY = '#EBEBEB'; 8 | const BORDER_DARK_GRAY = '#A3A3A3'; 9 | const BACKGROUND_LIGHT_GRAY = '#F7F7F7'; 10 | const BACKGROUND_LIGHT_WHITE = '#FFFFFF'; 11 | const BACKGROUND_DEEP_GRAY = '#EBEBEB'; 12 | const ITEM_COLOR = ['#E21B3C', '#1368CE', '#D89E00', '#26890C']; 13 | 14 | export { 15 | TEXT_BLACK, 16 | TEXT_GRAY, 17 | TEXT_WHITE, 18 | PRIMARY_DEEP_YELLOW, 19 | PRIMARY_DEEP_GREEN, 20 | PRIMARY_LIGHT_YELLOW, 21 | BORDER_LIGHT_GRAY, 22 | BORDER_DARK_GRAY, 23 | BACKGROUND_LIGHT_GRAY, 24 | BACKGROUND_LIGHT_WHITE, 25 | BACKGROUND_DEEP_GRAY, 26 | ITEM_COLOR, 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/constants/domain.js: -------------------------------------------------------------------------------- 1 | const DOMAIN_URL = 'pickyforky.xyz'; 2 | 3 | export default DOMAIN_URL; 4 | -------------------------------------------------------------------------------- /client/src/constants/media.js: -------------------------------------------------------------------------------- 1 | const DESKTOP_MIN_WIDTH = '700px'; 2 | 3 | export default DESKTOP_MIN_WIDTH; 4 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 10px; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | } 8 | 9 | #root { 10 | width: 100%; 11 | height: 100%; 12 | } 13 | a:link { 14 | text-decoration: none; 15 | } 16 | -------------------------------------------------------------------------------- /client/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 | -------------------------------------------------------------------------------- /client/src/pages/Gameover.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import styled, { css, keyframes } from 'styled-components'; 4 | import PropTypes from 'prop-types'; 5 | import { Button } from '../components/common/Buttons'; 6 | 7 | const words = ['G', 'A', 'M', 'E', 'O', 'V', 'E', 'R']; 8 | const CONTAINER_SIZE_PERCENT = 80; 9 | const MOVING_DELAY = 0.1; 10 | const MOVING_DURATION = 0.5; 11 | 12 | const Background = styled.div` 13 | width: 100vw; 14 | height: 100vh; 15 | background-color: black; 16 | `; 17 | 18 | const WordContainer = styled.div` 19 | position: absolute; 20 | display: flex; 21 | justify-content: center; 22 | align-content: center; 23 | flex-wrap: wrap; 24 | overflow: hidden; 25 | width: ${CONTAINER_SIZE_PERCENT}vmin; 26 | height: ${CONTAINER_SIZE_PERCENT}vmin; 27 | top: 50%; 28 | left: 50%; 29 | transform: translate(-50%, -50%); 30 | `; 31 | 32 | const WordMovingAnimation = keyframes` 33 | from{ 34 | top: 100%; 35 | } 36 | to{ 37 | top: 0%; 38 | } 39 | `; 40 | 41 | const WordStyle = css` 42 | position: absolute; 43 | text-align: center; 44 | left: -1vmin; 45 | top: -1vmin; 46 | width: 100%; 47 | height: 100%; 48 | `; 49 | 50 | const ButtonWrapper = styled.div` 51 | div.buttonWrapper { 52 | position: absolute; 53 | display: inline-block; 54 | left: 50%; 55 | bottom: 0; 56 | transform: translate(-50%, -100%); 57 | } 58 | button { 59 | font-size: 5vh; 60 | padding: 1vh 4vw; 61 | } 62 | `; 63 | 64 | function getRandomInt(min, max) { 65 | const minInt = Math.ceil(min); 66 | const maxInt = Math.floor(max); 67 | return Math.floor(Math.random() * (maxInt - minInt)) + minInt; 68 | } 69 | 70 | function getRandomColor() { 71 | const max = 256; 72 | const r = getRandomInt(0, max); 73 | const g = getRandomInt(0, max); 74 | const b = getRandomInt(0, max); 75 | return `rgb(${r}, ${g}, ${b})`; 76 | } 77 | 78 | function getColorChangeAnimation(index) { 79 | const percents = []; 80 | const percentRate = 11.5; 81 | for (let i = 0; i < words.length; i += 1) 82 | percents.push(percentRate * (i + 1)); 83 | return keyframes` 84 | 0%{ 85 | color: white; 86 | transform: scale(1); 87 | } 88 | ${percents.map( 89 | (percent, order) => `${percent}%{ 90 | color: ${getRandomColor()}; 91 | transform: scale(${index === order ? 1.4 : 1}); 92 | }`, 93 | )} 94 | 100%{ 95 | color: white; 96 | transform: scale(1); 97 | } 98 | `; 99 | } 100 | 101 | function Word({ word, index }) { 102 | const colorChangeDelay = MOVING_DURATION + MOVING_DELAY * 8; 103 | 104 | const WordWrapper = styled.div` 105 | position: relative; 106 | display: inline-block; 107 | color: gray; 108 | font-size: ${CONTAINER_SIZE_PERCENT / 4}vmin; 109 | text-align: center; 110 | font-weight: bold; 111 | width: 25%; 112 | top: 100%; 113 | user-select: none; 114 | animation-name: ${WordMovingAnimation}; 115 | animation-iteration-count: 1; 116 | animation-timing-function: linear; 117 | animation-fill-mode: forwards; 118 | animation-duration: ${MOVING_DURATION}s; 119 | animation-delay: ${index * MOVING_DELAY}s; 120 | 121 | &::before { 122 | ${WordStyle} 123 | content: '${word}'; 124 | color: white; 125 | } 126 | 127 | &::after { 128 | ${WordStyle} 129 | content: '${word}'; 130 | font-size: 95%; 131 | color: white; 132 | 133 | animation-name: ${getColorChangeAnimation(index)}; 134 | animation-delay: ${colorChangeDelay}s; 135 | animation-duration: 5s; 136 | animation-timing-function: linear; 137 | animation-iteration-count: 1; 138 | } 139 | `; 140 | return {word}; 141 | } 142 | 143 | function GameOver() { 144 | return ( 145 | 146 | 147 | {words.map((word, index) => ( 148 | 149 | ))} 150 | 151 | 152 | 153 | 156 | 157 | 158 | 159 | ); 160 | } 161 | 162 | Word.propTypes = { 163 | word: PropTypes.string.isRequired, 164 | index: PropTypes.number.isRequired, 165 | }; 166 | 167 | export default GameOver; 168 | -------------------------------------------------------------------------------- /client/src/pages/MainPage.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | 5 | import Logo from '../components/logo/Logo'; 6 | import CopyrightFooter from '../components/common/CopyrightFooter'; 7 | import EnterRoomNumber from '../components/mainPage/EnterRoomNumber'; 8 | import EnterNickname from '../components/mainPage/EnterNickname'; 9 | import NaverLogin from '../components/mainPage/NaverLogin'; 10 | import { ToastContext } from '../components/common/ToastProvider'; 11 | import { PRIMARY_LIGHT_YELLOW } from '../constants/colors'; 12 | import DESKTOP_MIN_WIDTH from '../constants/media'; 13 | 14 | const Background = styled.div` 15 | position: relative; 16 | display: flex; 17 | height: 100vh; 18 | flex-direction: column; 19 | background-color: ${PRIMARY_LIGHT_YELLOW}; 20 | overflow-x: hidden; 21 | `; 22 | 23 | const ContentSection = styled.div` 24 | display: flex; 25 | flex-direction: column; 26 | flex: 1 1 auto; 27 | align-items: center; 28 | justify-content: center; 29 | text-align: center; 30 | `; 31 | 32 | const MainContainer = styled.main` 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | flex: 1 1 0%; 37 | padding: 5rem 0; 38 | width: 100%; 39 | `; 40 | 41 | const LogoContainer = styled.div` 42 | display: flex; 43 | flex: 2; 44 | flex-direction: column; 45 | justify-content: center; 46 | div#logo3d { 47 | transform: scale3d(0.4, 0.4, 0.4); 48 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 49 | transform: scale3d(1, 1, 1); 50 | } 51 | } 52 | `; 53 | 54 | const RoutingContainer = styled.div` 55 | display: flex; 56 | flex: 1; 57 | flex-direction: column; 58 | button, 59 | input { 60 | width: 25rem; 61 | min-height: 5rem; 62 | } 63 | `; 64 | 65 | function MainPage() { 66 | const { ToastMessage } = useContext(ToastContext); 67 | return ( 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ); 87 | } 88 | 89 | export default MainPage; 90 | -------------------------------------------------------------------------------- /client/src/pages/host/EditPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory, useLocation } from 'react-router'; 3 | import styled from 'styled-components'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | import Header from '../../components/common/Header'; 7 | import Section from '../../components/edit/Section'; 8 | import EditContextProvider from '../../components/edit/EditContextProvider'; 9 | import SaveButton from '../../components/edit/SaveButton'; 10 | 11 | const Background = styled.div` 12 | position: relative; 13 | display: flex; 14 | flex-direction: column; 15 | width: 100%; 16 | height: 100vh; 17 | background-color: ${colors.BACKGROUND_DEEP_GRAY}; 18 | `; 19 | 20 | function EditPage() { 21 | const history = useHistory(); 22 | const location = useLocation(); 23 | if (location.state === undefined) { 24 | history.push('/gameover'); 25 | return ''; 26 | } 27 | const { roomId, quizsetId } = location.state; 28 | return ( 29 | 30 | 31 |
    } /> 32 |
    33 | 34 | 35 | ); 36 | } 37 | 38 | export default EditPage; 39 | -------------------------------------------------------------------------------- /client/src/pages/host/HostDetailRoom.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useHistory, useLocation } from 'react-router'; 3 | import styled from 'styled-components'; 4 | 5 | import * as colors from '../../constants/colors'; 6 | import Header from '../../components/common/Header'; 7 | import { YellowButton } from '../../components/common/Buttons'; 8 | import QuizTab from '../../components/detailRoom/QuizTab'; 9 | 10 | const Background = styled.div` 11 | position: relative; 12 | display: flex; 13 | width: 100%; 14 | height: 100vh; 15 | flex-direction: column; 16 | background-color: ${colors.BACKGROUND_LIGHT_GRAY}; 17 | `; 18 | 19 | function DetailRoom() { 20 | const history = useHistory(); 21 | const location = useLocation(); 22 | 23 | if (!location.state) { 24 | window.location.href = '/host/room/select'; 25 | } 26 | 27 | const [quizsetId, setQuizsetId] = useState(undefined); 28 | const { roomId } = location.state; 29 | 30 | function handlePlayButton() { 31 | history.push({ 32 | pathname: '/host', 33 | state: { 34 | roomId, 35 | }, 36 | }); 37 | } 38 | 39 | return ( 40 | 41 |
    시작하기 45 | ) 46 | } 47 | /> 48 | 49 | 50 | ); 51 | } 52 | 53 | export default DetailRoom; 54 | -------------------------------------------------------------------------------- /client/src/pages/host/HostGameRoom.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useReducer } from 'react'; 2 | import { Prompt, useLocation } from 'react-router'; 3 | import styled from 'styled-components'; 4 | import io from 'socket.io-client'; 5 | 6 | import HostFooter from '../../components/inGame/HostFooter'; 7 | import HostWaitingRoom from '../../components/inGame/HostWaitingRoom'; 8 | import HostLoading from '../../components/inGame/HostLoading'; 9 | import HostQuizPlayingRoom from '../../components/inGame/HostQuizPlayingRoom'; 10 | import GameResult from '../../components/inGame/HostResult'; 11 | import { 12 | roomReducer, 13 | initialRoomState, 14 | HostGameAction, 15 | HostGameContext, 16 | } from '../../reducer/hostGameReducer'; 17 | import Loading from '../../components/common/Loading'; 18 | import Header from '../../components/common/Header'; 19 | 20 | const Container = styled.div` 21 | display: flex; 22 | flex-direction: column; 23 | width: 100vw; 24 | height: 100vh; 25 | `; 26 | 27 | const LoadingWrapper = styled.div` 28 | position: relative; 29 | flex: 1; 30 | `; 31 | 32 | const RoomNumber = styled.span` 33 | position: absolute; 34 | color: white; 35 | font-size: 10vmin; 36 | font-weight: bold; 37 | width: 100%; 38 | text-align: center; 39 | z-index: 10000; 40 | top: 50%; 41 | transform: translateY(-250%); 42 | 43 | &::before { 44 | content: '방 번호 '; 45 | } 46 | `; 47 | 48 | function HostGameRoom() { 49 | const location = useLocation(); 50 | if (!location.state) { 51 | window.location.href = '/host/room/select'; 52 | } 53 | 54 | const [roomState, dispatcher] = useReducer(roomReducer, initialRoomState); 55 | const [ranking, setRanking] = useState([]); 56 | const isEmptyRoom = roomState.players.length === 0; 57 | 58 | useEffect(() => { 59 | const socket = io.connect(process.env.REACT_APP_BACKEND_HOST); 60 | 61 | dispatcher({ type: HostGameAction.SET_SOCKET, socket }); 62 | socket.emit('openRoom', { roomId: location.state.roomId }); 63 | socket.on('openRoom', ({ roomNumber }) => { 64 | dispatcher({ type: HostGameAction.SET_ROOM_NUMBER, roomNumber }); 65 | }); 66 | 67 | function blockClose(e) { 68 | e.returnValue = 'warning'; 69 | } 70 | 71 | function closeRoom() { 72 | socket.close(); 73 | } 74 | 75 | window.addEventListener('beforeunload', blockClose); 76 | window.addEventListener('unload', closeRoom); 77 | 78 | return () => { 79 | closeRoom(); 80 | window.removeEventListener('beforeunload', blockClose); 81 | window.removeEventListener('unload', closeRoom); 82 | }; 83 | }, [location.state.roomId]); 84 | 85 | useEffect(() => { 86 | if (!roomState.socket) return; 87 | roomState.socket.on('enterPlayer', players => { 88 | dispatcher({ type: HostGameAction.SET_PLAYERS, players }); 89 | }); 90 | 91 | roomState.socket.on('leavePlayer', players => { 92 | dispatcher({ type: HostGameAction.SET_PLAYERS, players }); 93 | }); 94 | 95 | roomState.socket.on('next', nextQuizIndex => { 96 | dispatcher({ 97 | type: HostGameAction.SET_CURRENT_QUIZ, 98 | index: nextQuizIndex, 99 | }); 100 | }); 101 | 102 | roomState.socket.on('subResult', subResult => { 103 | dispatcher({ type: HostGameAction.SET_SUB_RESULT, subResult }); 104 | }); 105 | 106 | roomState.socket.on('end', orderedRanking => { 107 | setRanking(orderedRanking); 108 | dispatcher({ type: HostGameAction.SHOW_SCOREBOARD }); 109 | }); 110 | }, [roomState.socket]); 111 | 112 | return ( 113 | 114 | {roomState.pageState !== 'END' && ( 115 | 116 | )} 117 | {isEmptyRoom && roomState.pageState === 'WAITING' ? ( 118 | <> 119 |
    120 | 121 | 122 | {roomState.roomNumber} 123 | 124 | 125 | ) : ( 126 | 127 | { 128 | { 129 | WAITING: , 130 | LOADING: , 131 | PLAYING: , 132 | END: , 133 | }[roomState.pageState] 134 | } 135 | 136 | )} 137 | 138 | 139 | 140 | ); 141 | } 142 | 143 | export default HostGameRoom; 144 | -------------------------------------------------------------------------------- /client/src/pages/host/SelectRoom.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import Header from '../../components/common/Header'; 5 | import { YellowButton } from '../../components/common/Buttons'; 6 | import Modal from '../../components/common/Modal'; 7 | import { ModalContext } from '../../components/common/ModalProvider'; 8 | import FlexibleInput from '../../components/common/FlexibleInput'; 9 | import { fetchRooms, addRoom } from '../../utils/fetch'; 10 | import RoomList from '../../components/selectRoom/RoomList'; 11 | import { parseCookie } from '../../utils/util'; 12 | import DESKTOP_MIN_WIDTH from '../../constants/media'; 13 | import MainContainer from '../../components/common/MainContainer'; 14 | import InformationArea from '../../components/common/InformationArea'; 15 | 16 | const Container = styled.div` 17 | position: relative; 18 | display: flex; 19 | flex-direction: column; 20 | width: 100%; 21 | height: 100vh; 22 | `; 23 | 24 | const ButtonContainer = styled.div` 25 | position: relative; 26 | button { 27 | font-size: 3vmin; 28 | padding: 0.75vmin 1.25vmin; 29 | transform: translateY(-0.4vmin); 30 | } 31 | `; 32 | 33 | const RoomCounter = styled.span` 34 | position: relative; 35 | user-select: none; 36 | `; 37 | 38 | const RoomContainer = styled.div` 39 | position: relative; 40 | display: inline-block; 41 | width: 100%; 42 | `; 43 | 44 | const Notify = styled.div` 45 | margin-top: 0.5rem; 46 | padding: 0.5rem; 47 | background-color: #ffc6c6; 48 | border-radius: 5px; 49 | color: white; 50 | text-align: center; 51 | font-weight: bold; 52 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 53 | font-size: 2rem; 54 | } 55 | `; 56 | 57 | function parsingUserNaverId() { 58 | const cookies = parseCookie(document.cookie); 59 | return cookies.naverId; 60 | } 61 | 62 | function SelectRoom() { 63 | const [rooms, setRooms] = useState([]); 64 | const [userId, setUserId] = useState(''); 65 | const [inputValue, setInputValue] = useState(''); 66 | const [message, setMessage] = useState(''); 67 | const { openModal } = useContext(ModalContext); 68 | 69 | useEffect(() => { 70 | try { 71 | setUserId(parsingUserNaverId()); 72 | } catch (err) { 73 | window.location.href = '/'; 74 | } 75 | }, []); 76 | 77 | useEffect(() => { 78 | async function getRooms(count) { 79 | if (count === 0) { 80 | alert('오류로 인해 방을 가져올 수 없습니다'); 81 | return; 82 | } 83 | const { isSuccess, data } = await fetchRooms({ userId }); 84 | if (!isSuccess) { 85 | getRooms(count - 1); 86 | return; 87 | } 88 | setRooms(data); 89 | } 90 | if (userId) getRooms(3); 91 | }, [userId]); 92 | 93 | useEffect(() => { 94 | const clearMessage = setTimeout(() => { 95 | setMessage(''); 96 | }, 1500); 97 | 98 | return () => { 99 | clearTimeout(clearMessage); 100 | }; 101 | }, [message]); 102 | 103 | function handleCreateButtonClick() { 104 | if (!inputValue.trim()) { 105 | setMessage('방의 이름을 입력하세요'); 106 | return false; 107 | } 108 | 109 | if (rooms.find(room => room.title === inputValue)) { 110 | setMessage('방의 이름은 중복될 수 없습니다'); 111 | return false; 112 | } 113 | 114 | async function createNewRoom() { 115 | const { isSuccess, data } = await addRoom({ 116 | userId, 117 | roomTitle: inputValue.trim(), 118 | }); 119 | 120 | if (!isSuccess) { 121 | alert('방이 오류로 인해 추가되지 못했습니다'); 122 | return; 123 | } 124 | 125 | setRooms([...rooms, { id: data.insertId, title: inputValue }]); 126 | } 127 | 128 | createNewRoom(); 129 | return true; 130 | } 131 | 132 | return ( 133 | 134 |
    135 | 136 | 137 | {`방 ${rooms.length}개`} 138 | 139 | 방 만들기 140 | 141 | 142 | 143 | 144 | 145 | 146 | 153 | 158 | {message && {message}} 159 | 160 | 161 | ); 162 | } 163 | 164 | export default SelectRoom; 165 | -------------------------------------------------------------------------------- /client/src/pages/login/CallBackPage.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useHistory } from 'react-router'; 3 | import { getToken } from '../../utils/fetch'; 4 | 5 | import Loading from '../../components/common/Loading'; 6 | 7 | const loginPageUrl = '/login'; 8 | 9 | function splitHash(rawHash) { 10 | // hash는 #access_token=....... 의 형태로 이루어 지기 때문에 앞의 # 제거 11 | const hash = rawHash.replace('#', ''); 12 | const object = {}; 13 | 14 | const hashParams = hash.split('&'); 15 | 16 | hashParams.forEach(element => { 17 | const [key, value] = element.split('='); 18 | object[key] = value; 19 | }); 20 | 21 | return object; 22 | } 23 | 24 | function LoginPage() { 25 | const history = useHistory(); 26 | const { hash } = window.location; 27 | 28 | const tokenObject = splitHash(hash); 29 | 30 | useEffect(() => { 31 | getToken(tokenObject).then(response => { 32 | if (response.isSuccess) { 33 | history.replace({ 34 | pathname: '/host/room/select', 35 | }); 36 | } else { 37 | history.push({ 38 | pathname: loginPageUrl, 39 | }); 40 | } 41 | }); 42 | }, [history]); 43 | 44 | return ( 45 | 46 | ); 47 | } 48 | 49 | export default LoginPage; 50 | -------------------------------------------------------------------------------- /client/src/reducer/hostGameReducer.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | const HostGameAction = { 4 | SET_SOCKET: 'SET_SOCKET', 5 | SET_ROOM_NUMBER: 'SET_ROOM_NUMBER', 6 | SET_PLAYERS: 'SET_PLAYERS', 7 | GAME_START: 'GAME_START', 8 | SET_CURRENT_QUIZ: 'SET_CURRENT_QUIZ', 9 | REQUEST_NEXT_QUIZ: 'REQUEST_NEXT_QUIZ', 10 | REQUEST_SUB_RESULT: 'REQUEST_SUB_RESULT', 11 | SET_SUB_RESULT: 'SET_SUB_RESULT', 12 | SET_ENTIRE_QUIZ: 'SET_ENTIRE_QUIZ', 13 | REQUEST_QUIZ_END: 'REQUEST_QUIZ_END', 14 | SHOW_SCOREBOARD: 'SHOW_SCOREBOARD', 15 | }; 16 | 17 | const HOST_PAGE_STATE = { 18 | WAITING: 'WAITING', 19 | LOADING: 'LOADING', 20 | PLAYING: 'PLAYING', 21 | END: 'END', 22 | }; 23 | 24 | const roomReducer = (state, action) => { 25 | switch (action.type) { 26 | case HostGameAction.SET_SOCKET: { 27 | return { ...state, socket: action.socket }; 28 | } 29 | case HostGameAction.SET_ROOM_NUMBER: { 30 | return { ...state, roomNumber: action.roomNumber }; 31 | } 32 | case HostGameAction.SET_PLAYERS: { 33 | return { ...state, players: action.players }; 34 | } 35 | case HostGameAction.GAME_START: { 36 | state.socket.emit('start', { roomNumber: state.roomNumber }); 37 | return { ...state, pageState: HOST_PAGE_STATE.LOADING }; 38 | } 39 | case HostGameAction.SET_CURRENT_QUIZ: { 40 | return { 41 | ...state, 42 | currentQuiz: { 43 | ...state.fullQuizData[action.index], 44 | index: action.index, 45 | }, 46 | pageState: HOST_PAGE_STATE.PLAYING, 47 | }; 48 | } 49 | case HostGameAction.REQUEST_NEXT_QUIZ: { 50 | state.socket.emit('next', { 51 | roomNumber: state.roomNumber, 52 | }); 53 | 54 | return state; 55 | } 56 | case HostGameAction.REQUEST_SUB_RESULT: { 57 | state.socket.emit('break', { 58 | roomNumber: state.roomNumber, 59 | }); 60 | 61 | return state; 62 | } 63 | case HostGameAction.SET_SUB_RESULT: { 64 | return { ...state, quizSubResult: action.subResult }; 65 | } 66 | case HostGameAction.SET_ENTIRE_QUIZ: { 67 | return { 68 | ...state, 69 | fullQuizData: action.data, 70 | totalQuizCount: action.data.length, 71 | }; 72 | } 73 | case HostGameAction.REQUEST_QUIZ_END: { 74 | state.socket.emit('end', { 75 | roomNumber: state.roomNumber, 76 | }); 77 | 78 | return state; 79 | } 80 | case HostGameAction.SHOW_SCOREBOARD: { 81 | return { ...state, pageState: HOST_PAGE_STATE.END }; 82 | } 83 | default: 84 | return state; 85 | } 86 | }; 87 | 88 | const initialRoomState = { 89 | roomNumber: '', 90 | players: [], 91 | socket: null, 92 | fullQuizData: [], 93 | totalQuizCount: 0, 94 | pageState: HOST_PAGE_STATE.WAITING, 95 | currentQuiz: null, 96 | quizSubResult: null, 97 | }; 98 | 99 | const HostGameContext = createContext(); 100 | 101 | export { initialRoomState, roomReducer, HostGameAction, HostGameContext }; 102 | -------------------------------------------------------------------------------- /client/src/styles/common.js: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components'; 2 | import * as colors from '../constants/colors'; 3 | import DESKTOP_MIN_WIDTH from '../constants/media'; 4 | 5 | const InGameFooterStyle = css` 6 | position: relative; 7 | flex: none; 8 | justify-self: flex-end; 9 | height: 10vmin; 10 | border-top: 1px solid ${colors.BORDER_LIGHT_GRAY}; 11 | box-sizing: border-box; 12 | background-color: ${colors.BACKGROUND_LIGHT_WHITE}; 13 | 14 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 15 | height: 5vmin; 16 | } 17 | `; 18 | 19 | const InGameFooterTextStyle = css` 20 | position: absolute; 21 | font-size: 5vmin; 22 | font-weight: bold; 23 | top: 50%; 24 | transform: translateY(-50%); 25 | vertical-align: middle; 26 | 27 | @media (min-width: ${DESKTOP_MIN_WIDTH}) { 28 | font-size: 2.5vmin; 29 | } 30 | `; 31 | 32 | const InputStyle = css` 33 | color: ${colors.TEXT_BLACK}; 34 | font-size: 2rem; 35 | border-radius: 0.5rem; 36 | text-align: center; 37 | border: 1px solid ${colors.BORDER_DARK_GRAY}; 38 | `; 39 | 40 | export { InGameFooterStyle, InGameFooterTextStyle, InputStyle }; 41 | -------------------------------------------------------------------------------- /client/src/utils/caseChanger.js: -------------------------------------------------------------------------------- 1 | const ASCII_A = 65; 2 | const ASCII_Z = 90; 3 | const CAN_STRING_VALUE_CHANGE_DEFAULT = false; 4 | 5 | function parseCamelString(string) { 6 | const splitSnakeWord = string.split('_'); 7 | if (splitSnakeWord.length < 2) return string; 8 | 9 | return splitSnakeWord.reduce((result, now, index) => { 10 | if (index === 0) return now; 11 | if (now.length === 0) return result; 12 | const camel = now.charAt(0).toUpperCase() + now.substring(1); 13 | return result + camel; 14 | }, ''); 15 | } 16 | 17 | function parseSnakeString(string) { 18 | const splitWord = string.split(''); 19 | return splitWord.reduce((result, now, index) => { 20 | const nowAscii = now.charCodeAt(0); 21 | if (nowAscii < ASCII_A || nowAscii > ASCII_Z) return result + now; 22 | const snake = index > 0 ? '_' : ''; 23 | return result + snake + now.toLowerCase(); 24 | }, ''); 25 | } 26 | 27 | function parseObjectFunction( 28 | object, 29 | parseCaseString, 30 | option = { canStringValueChange: CAN_STRING_VALUE_CHANGE_DEFAULT }, 31 | ) { 32 | if (option.canStringValueChange && typeof object === 'string') 33 | return parseCaseString(object); 34 | if (!object || typeof object !== 'object') return object; 35 | const newObject = Array.isArray(object) ? [] : {}; 36 | 37 | Object.keys(object).forEach(key => { 38 | const parsedKey = parseCaseString(key) || key; 39 | const value = object[key]; 40 | newObject[parsedKey] = parseObjectFunction(value, parseCaseString, option); 41 | }); 42 | return newObject; 43 | } 44 | 45 | function parseCamelObject(object, option) { 46 | return parseObjectFunction(object, parseCamelString, option); 47 | } 48 | 49 | function parseSnakeObject(object, option) { 50 | return parseObjectFunction(object, parseSnakeString, option); 51 | } 52 | 53 | export { parseCamelObject, parseSnakeObject }; 54 | -------------------------------------------------------------------------------- /client/src/utils/naverLoginSdk.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 이 코드는 외부에서 가져온 것이므로 리뷰하지 않아도 됩니다 3 | * 출처 : https://www.npmjs.com/package/react-naver-login 4 | */ 5 | 6 | import { Component } from 'react'; 7 | 8 | /*! ***************************************************************************** 9 | Copyright (c) Microsoft Corporation. All rights reserved. 10 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 11 | this file except in compliance with the License. You may obtain a copy of the 12 | License at http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED 16 | WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, 17 | MERCHANTABLITY OR NON-INFRINGEMENT. 18 | 19 | See the Apache Version 2.0 License for specific language governing permissions 20 | and limitations under the License. 21 | ***************************************************************************** */ 22 | 23 | let extendStatics = (d, b) => { 24 | extendStatics = 25 | Object.setPrototypeOf || 26 | ({ __proto__: [] } instanceof Array && 27 | function(d, b) { 28 | d.__proto__ = b; 29 | }) || 30 | function(d, b) { 31 | for (const p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; 32 | }; 33 | return extendStatics(d, b); 34 | }; 35 | 36 | function __extends(d, b) { 37 | extendStatics(d, b); 38 | function __() { 39 | this.constructor = d; 40 | } 41 | d.prototype = 42 | b === null ? Object.create(b) : ((__.prototype = b.prototype), new __()); 43 | } 44 | 45 | const NAVER_ID_SDK_URL = 46 | 'https://static.nid.naver.com/js/naveridlogin_js_sdk_2.0.0.js'; 47 | /** 48 | * 이 함수는 브라우저 환경에서만 호출이 되야 한다. window 객체에 직접 접근한다. 49 | * @param props 50 | */ 51 | const initLoginButton = function(props) { 52 | if (!('browser' in process)) { 53 | return; 54 | } 55 | const { clientId } = props; 56 | const { callbackUrl } = props; 57 | const { onSuccess } = props; 58 | const { onFailure } = props; 59 | const { location } = { ...window }; 60 | const { naver } = window; 61 | const naverLogin = new naver.LoginWithNaverId({ 62 | callbackUrl, 63 | clientId, 64 | isPopup: false, 65 | loginButton: { color: 'green', type: 3, height: 60 }, 66 | }); 67 | naverLogin.init(); 68 | if (!window.opener) { 69 | naver.successCallback = function(data) { 70 | return onSuccess(data); 71 | }; 72 | naver.FailureCallback = onFailure; 73 | } else { 74 | naverLogin.getLoginStatus(function(status) { 75 | if (!status || location.hash.indexOf('#access_token') === -1) { 76 | return; 77 | } 78 | window.opener.naver.successCallback(naverLogin.user); 79 | window.close(); 80 | // clearInterval(initLoop); 81 | }); 82 | } 83 | }; 84 | const appendNaverButton = function() { 85 | if (document && document.querySelectorAll('#naverIdLogin').length === 0) { 86 | const naverId = document.createElement('div'); 87 | naverId.id = 'naverIdLogin'; 88 | naverId.style.position = 'absolute'; 89 | naverId.style.top = '-10000px'; 90 | document.body.appendChild(naverId); 91 | } 92 | }; 93 | const loadScript = function(props) { 94 | if (document && document.querySelectorAll('#naver-login-sdk').length === 0) { 95 | const script = document.createElement('script'); 96 | script.id = 'naver-login-sdk'; 97 | script.src = NAVER_ID_SDK_URL; 98 | script.onload = function() { 99 | return initLoginButton(props); 100 | }; 101 | document.head.appendChild(script); 102 | } 103 | }; 104 | const LoginNaver = /** @class */ (function(_super) { 105 | __extends(LoginNaver, _super); 106 | function LoginNaver() { 107 | return (_super !== null && _super.apply(this, arguments)) || this; 108 | } 109 | LoginNaver.prototype.componentDidMount = function() { 110 | if (!('browser' in process)) { 111 | return; 112 | } 113 | // 네이버 로그인 버튼을 먼저 붙인 후 스크립트 로드하고 초기화를 해야 한다. 114 | appendNaverButton(); 115 | loadScript(this.props); 116 | }; 117 | LoginNaver.prototype.render = function() { 118 | const { render } = this.props; 119 | return render({ 120 | onClick() { 121 | if (!document || !document.querySelector('#naverIdLogin').firstChild) 122 | return; 123 | const naverLoginButton = document.querySelector('#naverIdLogin') 124 | .firstChild; 125 | naverLoginButton.click(); 126 | }, 127 | }); 128 | }; 129 | return LoginNaver; 130 | })(Component); 131 | 132 | export default LoginNaver; 133 | -------------------------------------------------------------------------------- /client/src/utils/util.js: -------------------------------------------------------------------------------- 1 | function parseCookie(cookieString) { 2 | return cookieString 3 | .split(';') 4 | .map(cookie => cookie.split('=')) 5 | .reduce((acc, cookie) => { 6 | acc[decodeURIComponent(cookie[0].trim())] = decodeURIComponent( 7 | cookie[1].trim(), 8 | ); 9 | return acc; 10 | }, {}); 11 | } 12 | 13 | export { parseCookie }; 14 | -------------------------------------------------------------------------------- /docs/logogif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/docs/logogif.gif -------------------------------------------------------------------------------- /docs/structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/docs/structure.png -------------------------------------------------------------------------------- /docs/technology_stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/docs/technology_stack.png -------------------------------------------------------------------------------- /docs/thumbnail.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connect-foundation/2019-07/6969e6f40d3b60e3993dd96f1f0208bf08db8e9f/docs/thumbnail.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "2019-07", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "npm-run-all install:** --parallel start:**", 7 | "install:server": "yarn --cwd server install", 8 | "install:client": "yarn --cwd client install", 9 | "start:server": "yarn --cwd server start", 10 | "start:client": "yarn --cwd client start" 11 | }, 12 | "devDependencies": { 13 | "npm-run-all": "^4.1.5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | commonjs: true, 6 | es6: true, 7 | node: true, 8 | }, 9 | extends: 'airbnb', 10 | rules: { 11 | 'react/prefer-stateless-function': 0, 12 | 'react/jsx-filename-extension': 0, 13 | 'react/jsx-one-expression-per-line': 0, 14 | 'linebreak-style': 0, 15 | 'react/jsx-filename-extension': [ 16 | 1, 17 | { 18 | extensions: ['.js', '.jsx'], 19 | }, 20 | ], 21 | quotes: [ 22 | 'error', 23 | 'single', 24 | { 25 | allowTemplateLiterals: true, 26 | }, 27 | ], 28 | 'object-curly-newline': [ 29 | 'error', 30 | { 31 | ObjectExpression: 'always', 32 | }, 33 | ], 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const createError = require('http-errors'); 2 | const express = require('express'); 3 | const path = require('path'); 4 | const cookieParser = require('cookie-parser'); 5 | const logger = require('morgan'); 6 | const io = require('./socket.js'); 7 | 8 | const apiRouter = require('./routes/api'); 9 | 10 | const app = express(); 11 | app.io = io; 12 | 13 | app.use(logger('dev')); 14 | app.use(express.json()); 15 | app.use( 16 | express.urlencoded({ 17 | extended: false, 18 | }), 19 | ); 20 | app.use(cookieParser()); 21 | app.use(express.static(path.join(__dirname, 'public'))); 22 | 23 | app.use('/api/', apiRouter); 24 | 25 | app.use((req, res, next) => { 26 | next(createError(404)); 27 | }); 28 | 29 | app.use((err, req, res) => { 30 | res.locals.message = err.message; 31 | res.locals.error = req.app.get('env') === 'development' ? err : {}; 32 | 33 | res.status(err.status || 500); 34 | res.render('error'); 35 | }); 36 | 37 | module.exports = app; 38 | -------------------------------------------------------------------------------- /server/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const debug = require('debug')('server:server'); 3 | const http = require('http'); 4 | const app = require('../app'); 5 | 6 | const port = normalizePort(process.env.PORT || '3001'); 7 | app.set('port', port); 8 | 9 | const server = http.createServer(app); 10 | app.io.attach(server); 11 | server.listen(port); 12 | server.on('error', onError); 13 | server.on('listening', onListening); 14 | 15 | function normalizePort(val) { 16 | const port = parseInt(val, 10); 17 | if (isNaN(port)) { 18 | return val; 19 | } 20 | if (port >= 0) { 21 | return port; 22 | } 23 | return false; 24 | } 25 | 26 | function onError(error) { 27 | if (error.syscall !== 'listen') { 28 | throw error; 29 | } 30 | const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`; 31 | switch (error.code) { 32 | case 'EACCES': 33 | console.error(`${bind} requires elevated privileges`); 34 | process.exit(1); 35 | break; 36 | case 'EADDRINUSE': 37 | console.error(`${bind} is already in use`); 38 | process.exit(1); 39 | break; 40 | default: 41 | throw error; 42 | } 43 | } 44 | 45 | function onListening() { 46 | const addr = server.address(); 47 | const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`; 48 | debug(`Listening on ${bind}`); 49 | } 50 | -------------------------------------------------------------------------------- /server/constants/tableName.js: -------------------------------------------------------------------------------- 1 | const analysisTable = 'ANALYSIS'; 2 | const itemTable = 'ITEM'; 3 | const quizTable = 'QUIZ'; 4 | const quizsetTable = 'QUIZSET'; 5 | const roomTable = 'ROOM'; 6 | const userTable = 'USER'; 7 | 8 | module.exports = { 9 | analysisTable, 10 | itemTable, 11 | quizTable, 12 | quizsetTable, 13 | roomTable, 14 | userTable, 15 | }; 16 | -------------------------------------------------------------------------------- /server/dev.env: -------------------------------------------------------------------------------- 1 | JWT_SECRET= 2 | DB_HOST= 3 | DB_USER= 4 | DB_PASSWORD= 5 | DB_DATABASE= 6 | OS_ACCESS_KEY_ID= 7 | OS_SECRET_ACCESS_KEY= 8 | OS_BUCKET= 9 | -------------------------------------------------------------------------------- /server/middleware/validations.js: -------------------------------------------------------------------------------- 1 | const { check, validationResult } = require('express-validator'); 2 | const jwt = require('jsonwebtoken'); 3 | const inMemory = require('../models/inMemory'); 4 | 5 | require('dotenv').config(); 6 | 7 | /** 8 | * 방 번호를 입력 받아서 값이 유효한지 확인함. (6자리 체크, 숫자가 아닌지 체크) 9 | * 10 | * @param {String} roomNumber 6자리 숫자로 이루어진 방 번호 11 | * 12 | * @action Check-Fail: 13 | * HTTP/1.1 200 OK 14 | * { 15 | * isSuccess: false, 16 | * message: '유효하지 않은 방입니다. 방 번호를 다시 입력해주세요' 17 | * } 18 | * @action Check-Success: 19 | * next middleware 20 | */ 21 | async function isRoomNumberValid(req, res, next) { 22 | await check('roomNumber') 23 | .trim() 24 | .isLength(6) 25 | .bail() 26 | .isNumeric() 27 | .run(req); 28 | 29 | if (!validationResult(req).isEmpty()) { 30 | res.json({ 31 | isSuccess: false, 32 | message: '유효하지 않은 방입니다. 방 번호를 다시 입력해주세요.', 33 | }); 34 | return; 35 | } 36 | 37 | next(); 38 | } 39 | 40 | /** 41 | * 방 번호를 입력 받아서 존재하는 방인지 확인함. (인메모리에 존재하는 번호인지 확인) 42 | * 43 | * @param {String} roomNumber 6자리 숫자로 이루어진 방 번호 44 | * 45 | * @action Check-Fail: 46 | * HTTP/1.1 200 OK 47 | * { 48 | * isSuccess: false, 49 | * message: '존재하지 않는 방입니다. 방 번호를 다시 입력해주세요' 50 | * } 51 | * @action Check-Success: 52 | * next middleware 53 | */ 54 | function isRoomExist(req, res, next) { 55 | let { roomNumber } = req.params; 56 | if (!roomNumber) roomNumber = req.body.roomNumber; 57 | 58 | if (!inMemory.room.isRoomExist(roomNumber)) { 59 | res.json({ 60 | isSuccess: false, 61 | message: '존재하지 않는 방입니다. 방 번호를 다시 입력해주세요.', 62 | }); 63 | return; 64 | } 65 | 66 | next(); 67 | } 68 | 69 | /** 70 | * 닉네임을 입력받아서 유효한지 확인함 (3자리 이상, 20자리 이하) 71 | * 72 | * @param {String} nickname 3자리 이상, 20자리 이하의 닉네임 73 | * 74 | * @action Check-Fail: 75 | * HTTP/1.1 200 OK 76 | * { 77 | * isSuccess: false, 78 | * message: '유효하지 않은 닉네임입니다. 닉네임을 다시 입력해주세요' 79 | * } 80 | * @action Check-Success: 81 | * next middleware 82 | */ 83 | async function isValidNickname(req, res, next) { 84 | await check('nickname') 85 | .trim() 86 | .isLength({ 87 | min: 2, 88 | max: 20, 89 | }) 90 | .bail() 91 | .custom((value) => /[ㄱ-힣\w]+/g.exec(value)[0] === value) 92 | .run(req); 93 | 94 | if (!validationResult(req).isEmpty()) { 95 | res.json({ 96 | isSuccess: false, 97 | message: '유효하지 않은 닉네임입니다. 닉네임을 다시 입력해주세요.', 98 | }); 99 | return; 100 | } 101 | 102 | next(); 103 | } 104 | 105 | /** 106 | * 닉네임을 입력받아서 중복되는지 확인함 (인메모리 내 같은 닉네임이 존재하는지 확인) 107 | * 108 | * @param {String} nickname 3자리 이상, 20자리 이하의 닉네임 109 | * 110 | * @action Check-Fail: 111 | * HTTP/1.1 200 OK 112 | * { 113 | * isSuccess: false, 114 | * message: '이미 존재하는 닉네임입니다. 닉네임을 다시 입력해주세요' 115 | * } 116 | * @action Check-Success: 117 | * next middleware 118 | */ 119 | function isNicknameOverlap(req, res, next) { 120 | const { nickname, roomNumber } = req.params; 121 | 122 | const isAlreadyExist = inMemory.room.isPlayerExist(roomNumber, nickname); 123 | 124 | if (isAlreadyExist) { 125 | res.json({ 126 | isSuccess: false, 127 | message: '이미 존재하는 닉네임입니다. 닉네임을 다시 입력해주세요.', 128 | }); 129 | return; 130 | } 131 | 132 | next(); 133 | } 134 | 135 | /** 136 | * param으로 받은 닉네임이 현재 방에서 존재하는지 확인 137 | * 138 | * @param {String} nickname 3자리 이상, 20자리 이하의 닉네임 139 | * 140 | * @action Check-Fail: 141 | * HTTP/1.1 200 OK 142 | * { 143 | * isSuccess: false, 144 | * message: '존재하지 않는 닉네임입니다. 닉네임을 다시 입력해주세요' 145 | * } 146 | * @action Check-Success: 147 | * next middleware 148 | */ 149 | function isNicknameExist(req, res, next) { 150 | let { nickname, roomNumber } = req.params; 151 | 152 | if (!nickname && !roomNumber) { 153 | nickname = req.body.nickname; 154 | roomNumber = req.body.roomNumber; 155 | } 156 | 157 | const isExist = inMemory.room.isPlayerExist(roomNumber, nickname); 158 | 159 | if (!isExist) { 160 | res.json({ 161 | isSuccess: false, 162 | message: '존재하지 않는 닉네임입니다.', 163 | }); 164 | return; 165 | } 166 | 167 | next(); 168 | } 169 | 170 | function isUserValid(req, res, next) { 171 | const { cookies } = req; 172 | const jwtObj = { 173 | secret: process.env.JWT_SECRET, 174 | }; 175 | 176 | try { 177 | jwt.verify(cookies.jwt, jwtObj.secret); 178 | next(); 179 | } catch (err) { 180 | res.json({ 181 | isError: true, 182 | message: '유효하지 않은 접근 토큰입니다', 183 | }); 184 | } 185 | } 186 | 187 | module.exports = { 188 | isRoomExist, 189 | isRoomNumberValid, 190 | isNicknameOverlap, 191 | isValidNickname, 192 | isNicknameExist, 193 | isUserValid, 194 | }; 195 | -------------------------------------------------------------------------------- /server/models/database/dbManager.js: -------------------------------------------------------------------------------- 1 | const mysql = require('mysql2/promise'); 2 | 3 | require('dotenv').config(); 4 | 5 | const Analysis = require('./tables/Analysis'); 6 | const Item = require('./tables/Item'); 7 | const Quizset = require('./tables/Quizset'); 8 | const Quiz = require('./tables/Quiz'); 9 | const Room = require('./tables/Room'); 10 | const User = require('./tables/User'); 11 | 12 | /** 13 | * 데이터베이스에 접근하는 CRUD를 전부 관리하는 객체 14 | * 15 | * @class DatabaseManager 16 | */ 17 | class DatabaseManager { 18 | constructor(pool) { 19 | this.pool = pool; 20 | 21 | this.analysis = new Analysis(this.pool); 22 | this.item = new Item(this.pool); 23 | this.quizset = new Quizset(this.pool); 24 | this.quiz = new Quiz(this.pool); 25 | this.room = new Room(this.pool); 26 | this.user = new User(this.pool); 27 | } 28 | } 29 | 30 | const dbManager = new DatabaseManager( 31 | mysql.createPool({ 32 | host: process.env.DB_HOST, 33 | user: process.env.DB_USER, 34 | database: process.env.DB_DATABASE, 35 | password: process.env.DB_PASSWORD, 36 | }), 37 | ); 38 | 39 | module.exports = dbManager; 40 | -------------------------------------------------------------------------------- /server/models/database/tables/Analysis.js: -------------------------------------------------------------------------------- 1 | const Table = require('./Table'); 2 | const { analysisTable } = require('../../../constants/tableName'); 3 | 4 | class Analysis extends Table { 5 | /** 6 | * 메소드 가이드라인 7 | * 8 | * method() { 9 | * return this.query(query, params); 10 | * } 11 | */ 12 | } 13 | 14 | module.exports = Analysis; 15 | -------------------------------------------------------------------------------- /server/models/database/tables/Item.js: -------------------------------------------------------------------------------- 1 | const Table = require('./Table'); 2 | const { itemTable, quizTable } = require('../../../constants/tableName'); 3 | 4 | class Item extends Table { 5 | createItems(quizId, items) { 6 | const itemValues = items.reduce((array, item) => { 7 | const { title, itemOrder, isAnswer } = item; 8 | return [...array, [title, itemOrder, quizId, isAnswer]]; 9 | }, []); 10 | const insert = `INSERT INTO ${itemTable} (title, item_order, quiz_id, is_answer)`; 11 | const values = `VALUES ?`; 12 | const query = `${insert} ${values};`; 13 | return this.query(query, itemValues); 14 | } 15 | 16 | readItems(quizsetId) { 17 | const select = `SELECT id, title, item_order, quiz_id, is_answer`; 18 | const from = `FROM ${itemTable}`; 19 | const where = `WHERE quiz_id IN (SELECT id FROM ${quizTable} WHERE quizset_id = ?)`; 20 | const query = `${select} ${from} ${where};`; 21 | return this.query(query, quizsetId); 22 | } 23 | 24 | updateItem(item) { 25 | const { id, title, isAnswer } = item; 26 | const update = `UPDATE ${itemTable}`; 27 | const set = `SET title = ?, is_answer = ?`; 28 | const where = `WHERE id = ?`; 29 | const query = `${update} ${set} ${where}`; 30 | return this.query(query, title, isAnswer, id); 31 | } 32 | } 33 | 34 | module.exports = Item; 35 | -------------------------------------------------------------------------------- /server/models/database/tables/Quiz.js: -------------------------------------------------------------------------------- 1 | const Table = require('./Table'); 2 | const { quizTable } = require('../../../constants/tableName'); 3 | 4 | class Quiz extends Table { 5 | createQuiz(quizsetId, quiz) { 6 | const { title, quizOrder, score, timeLimit } = quiz; 7 | const insert = `INSERT INTO ${quizTable} (title, quiz_order, score, time_limit, quizset_id)`; 8 | const values = `VALUES (?, ?, ?, ?, ?)`; 9 | const query = `${insert} ${values};`; 10 | return this.query(query, title, quizOrder, score, timeLimit, quizsetId); 11 | } 12 | 13 | updateQuiz(quiz) { 14 | const { id, title, quizOrder, score, timeLimit } = quiz; 15 | const update = `UPDATE ${quizTable}`; 16 | const set = `SET title = ?, quiz_order = ?, score = ?, time_limit = ?`; 17 | const where = `WHERE id = ?`; 18 | const query = `${update} ${set} ${where};`; 19 | return this.query(query, title, quizOrder, score, timeLimit, id); 20 | } 21 | 22 | readQuizzes(quizsetId) { 23 | const select = `SELECT id, title, quiz_order, score, time_limit, image_path`; 24 | const from = `FROM ${quizTable}`; 25 | const where = `WHERE quizset_id = ?`; 26 | const query = `${select} ${from} ${where};`; 27 | return this.query(query, quizsetId); 28 | } 29 | 30 | deleteQuiz(quizId) { 31 | const query = `DELETE FROM ${quizTable} WHERE id = ?;`; 32 | return this.query(query, quizId); 33 | } 34 | 35 | updateImagePath(quizId, imagePath) { 36 | const update = `UPDATE ${quizTable}`; 37 | const set = `SET image_path = ?`; 38 | const where = `WHERE id = ?`; 39 | const query = `${update} ${set} ${where};`; 40 | return this.query(query, imagePath, quizId); 41 | } 42 | } 43 | 44 | module.exports = Quiz; 45 | -------------------------------------------------------------------------------- /server/models/database/tables/Quizset.js: -------------------------------------------------------------------------------- 1 | const Table = require('./Table'); 2 | const { 3 | quizTable, 4 | quizsetTable, 5 | itemTable, 6 | } = require('../../../constants/tableName'); 7 | 8 | class Quizset extends Table { 9 | getQuizset(roomId) { 10 | const select = `SELECT Q.id, Q.title AS quizTitle, Q.score, Q.time_limit, Q.image_path, Q.quiz_order, I.title AS itemTitle, I.item_order, I.is_answer`; 11 | const from = `FROM ${quizTable} AS Q JOIN ${itemTable} AS I ON Q.id = I.quiz_id`; 12 | const where = `WHERE quizset_id = (SELECT QS.id FROM ${quizsetTable} AS QS WHERE QS.room_id = ?)`; 13 | const query = `${select} ${from} ${where};`; 14 | 15 | return this.query(query, roomId); 16 | } 17 | 18 | createQuizset(roomId, quizsetTitle, quizsetOrder) { 19 | const insert = `INSERT INTO ${quizsetTable} (title, quizset_order, room_id)`; 20 | const values = `VALUES (?, ?, ?)`; 21 | const query = `${insert} ${values}`; 22 | return this.query(query, quizsetTitle, quizsetOrder, roomId); 23 | } 24 | 25 | readLastQuizsetId(roomId) { 26 | const select = `SELECT id`; 27 | const from = `FROM ${quizsetTable}`; 28 | const where = `WHERE room_id = ?`; 29 | const orderBy = `ORDER BY id DESC LIMIT 1`; 30 | const query = `${select} ${from} ${where} ${orderBy};`; 31 | return this.query(query, roomId); 32 | } 33 | } 34 | 35 | module.exports = Quizset; 36 | -------------------------------------------------------------------------------- /server/models/database/tables/Room.js: -------------------------------------------------------------------------------- 1 | const Table = require('./Table'); 2 | const { roomTable, userTable } = require('../../../constants/tableName'); 3 | 4 | class Room extends Table { 5 | insertRoom(naverId, title) { 6 | const currentDate = new Date() 7 | .toISOString() 8 | .slice(0, 19) 9 | .replace('T', ' '); 10 | 11 | return this.query( 12 | `INSERT INTO ${roomTable} (title, user_id, created_at) VALUES (?, (SELECT id FROM ${userTable} WHERE naver_id=?), '${currentDate}')`, 13 | title, 14 | naverId, 15 | ); 16 | } 17 | 18 | selectRooms(naverId) { 19 | return this.query( 20 | `SELECT R.title, R.id FROM ${roomTable} R LEFT JOIN ${userTable} U ON R.user_id=U.id WHERE U.naver_id=?`, 21 | naverId, 22 | ); 23 | } 24 | 25 | getRoomTitle(roomId) { 26 | return this.query(`SELECT title FROM ${roomTable} WHERE id=?`, roomId); 27 | } 28 | 29 | updateRoom(roomId, title) { 30 | return this.query( 31 | `UPDATE ${roomTable} SET title=? WHERE id=?`, 32 | title, 33 | roomId, 34 | ); 35 | } 36 | 37 | deleteRoom(roomId) { 38 | return this.query(`DELETE FROM ${roomTable} WHERE id=?`, roomId); 39 | } 40 | } 41 | 42 | module.exports = Room; 43 | -------------------------------------------------------------------------------- /server/models/database/tables/Table.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 각 테이블의 CRUD를 담당하는 class가 상속하는 객체 3 | * 연결, 쿼리 등 공통된 부분을 관리한다 4 | * 5 | * @class Table 6 | * @param pool {mysql.createPool()} database와 연결할 때 사용하는 정보 7 | */ 8 | class Table { 9 | constructor(pool) { 10 | this.pool = pool; 11 | } 12 | 13 | /** 14 | * connection 후 쿼리를 수행해주는 함수 15 | * 풀을 받아옴(연결) - 쿼리 - 풀 반납 순서를 거친다. 16 | * 17 | * @param {string} query mysql에서 실행할 쿼리 18 | * @param {list} args 쿼리의 ?에 들어갈 인자 19 | * @returns {object} rows 쿼리 후 받아온 rows를 포함한 객체 (성공시) 20 | * @returns {object} 에러 발생 시 return하는 객체 (에러) 21 | * @memberof Table 22 | */ 23 | async query(query, ...args) { 24 | try { 25 | const connection = await this.pool.getConnection(async (conn) => conn); 26 | 27 | try { 28 | const [rows] = await connection.query(query, args); 29 | 30 | return { 31 | isSuccess: true, 32 | data: JSON.parse(JSON.stringify(rows)), 33 | }; 34 | } catch (error) { 35 | return { 36 | isError: true, 37 | message: error.message, 38 | }; 39 | } finally { 40 | connection.release(); 41 | } 42 | } catch (error) { 43 | return { 44 | isError: true, 45 | message: error.message, 46 | }; 47 | } 48 | } 49 | 50 | /** 51 | * connection 후 트랜잭션을 수행해주는 함수 52 | * 풀을 받아옴(연결) - 트랜잭션 시작 - 쿼리 - 커밋/롤백 - 풀 반납 순서를 거친다. 53 | * 54 | * @param {string} query mysql에서 실행할 쿼리 55 | * @param {list} args 쿼리의 ?에 들어갈 인자 56 | * @returns {object} rows 쿼리 후 받아온 rows를 포함한 객체 (성공시) 57 | * @returns {object} 에러 발생 시 return하는 객체 (에러) 58 | * @memberof Table 59 | */ 60 | async transaction(query, ...args) { 61 | try { 62 | const connection = await this.pool.getConnection(async (conn) => conn); 63 | 64 | try { 65 | await connection.beginTransaction(); 66 | const [rows] = await connection.query(query, args); 67 | await connection.commit(); 68 | 69 | return { 70 | isSuccess: true, 71 | data: JSON.parse(JSON.stringify(rows)), 72 | }; 73 | } catch (error) { 74 | await connection.rollback(); 75 | 76 | return { 77 | isError: true, 78 | message: error.message, 79 | }; 80 | } finally { 81 | connection.release(); 82 | } 83 | } catch (error) { 84 | return { 85 | isError: true, 86 | message: error.message, 87 | }; 88 | } 89 | } 90 | } 91 | 92 | module.exports = Table; 93 | -------------------------------------------------------------------------------- /server/models/database/tables/User.js: -------------------------------------------------------------------------------- 1 | const Table = require('./Table'); 2 | const { userTable } = require('../../../constants/tableName'); 3 | 4 | class User extends Table { 5 | insertUser({ id }) { 6 | return this.query(`INSERT INTO ${userTable} (naver_id) VALUES ('${id}')`); 7 | } 8 | 9 | selectAllUser() { 10 | return this.query(`select id, naver_id from ${userTable}`); 11 | } 12 | } 13 | 14 | module.exports = User; 15 | -------------------------------------------------------------------------------- /server/models/inMemory.js: -------------------------------------------------------------------------------- 1 | const Rooms = require('./rooms'); 2 | 3 | const inMemory = { 4 | room: new Rooms(), 5 | }; 6 | 7 | module.exports = inMemory; 8 | -------------------------------------------------------------------------------- /server/models/templates/quiz.js: -------------------------------------------------------------------------------- 1 | const quizTemplate = () => ({ 2 | title: '', 3 | image: '', 4 | items: [], 5 | answers: [], 6 | timeLimit: 30, 7 | score: 1000, 8 | }); 9 | 10 | const itemTemplate = () => ({ 11 | title: '', 12 | playerCount: 0, 13 | }); 14 | 15 | module.exports = { 16 | quizTemplate, 17 | itemTemplate, 18 | }; 19 | -------------------------------------------------------------------------------- /server/models/templates/room.js: -------------------------------------------------------------------------------- 1 | const roomTemplate = () => ({ 2 | players: new Map(), 3 | submittedPlayers: new Map(), 4 | deletedPlayers: new Map(), 5 | waitingPlayers: [], 6 | hostId: '', 7 | quizSet: [], 8 | quizIndex: -1, 9 | }); 10 | 11 | module.exports = { 12 | roomTemplate, 13 | }; 14 | -------------------------------------------------------------------------------- /server/objectStorage.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | require('dotenv').config(); 3 | 4 | const endpoint = new AWS.Endpoint('https://kr.object.ncloudstorage.com'); 5 | const region = 'kr-standard'; 6 | AWS.config.update({ 7 | accessKeyId: process.env.OS_ACCESS_KEY_ID, 8 | secretAccessKey: process.env.OS_SECRET_ACCESS_KEY, 9 | }); 10 | 11 | const S3 = new AWS.S3({ 12 | endpoint, 13 | region, 14 | }); 15 | 16 | const rootFolder = 'images/'; 17 | const bucket = process.env.OS_BUCKET; 18 | 19 | function getDeleteObjects(listedObjects) { 20 | return listedObjects.Contents.reduce( 21 | (array, { Key }) => [ 22 | ...array, 23 | { 24 | Key, 25 | }, 26 | ], 27 | [], 28 | ); 29 | } 30 | 31 | async function emptyS3Directory(dir) { 32 | const listParams = { 33 | Bucket: bucket, 34 | Prefix: dir, 35 | }; 36 | 37 | const listedObjects = await S3.listObjectsV2(listParams).promise(); 38 | 39 | if (listedObjects.Contents.length === 0) return; 40 | 41 | const deleteParams = { 42 | Bucket: bucket, 43 | Delete: { 44 | Objects: getDeleteObjects(listedObjects), 45 | }, 46 | }; 47 | 48 | await S3.deleteObjects(deleteParams).promise(); 49 | 50 | if (listedObjects.IsTruncated) await emptyS3Directory(bucket, dir); 51 | } 52 | 53 | async function deleteQuizsetFolder(roomId) { 54 | const folder = `${rootFolder}${roomId}/`; 55 | emptyS3Directory(folder); 56 | } 57 | 58 | async function deleteQuizFolder(roomId, quizId) { 59 | const folder = `${rootFolder}${roomId}/${quizId}/`; 60 | emptyS3Directory(folder); 61 | } 62 | 63 | async function createFolder(folder) { 64 | await S3.putObject({ 65 | Bucket: bucket, 66 | Key: folder, 67 | }).promise(); 68 | } 69 | 70 | async function uploadImage(roomId, quizId, filename, buffer) { 71 | const folder = `${rootFolder}${roomId}/${quizId}/`; 72 | await createFolder(folder); 73 | 74 | await S3.putObject({ 75 | Bucket: bucket, 76 | Key: `${folder}${filename}`, 77 | Body: buffer, 78 | ACL: 'public-read', 79 | }).promise(); 80 | } 81 | 82 | module.exports = { 83 | uploadImage, 84 | deleteQuizsetFolder, 85 | deleteQuizFolder, 86 | }; 87 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "nodemon ./bin/www" 7 | }, 8 | "dependencies": { 9 | "aws-sdk": "^2.588.0", 10 | "cookie-parser": "~1.4.4", 11 | "cors": "^2.8.5", 12 | "debug": "~2.6.9", 13 | "dotenv": "^8.2.0", 14 | "express": "^4.16.4", 15 | "express-validator": "^6.3.0", 16 | "http-errors": "~1.6.3", 17 | "jsonwebtoken": "^8.5.1", 18 | "mongoose": "^5.7.11", 19 | "morgan": "^1.9.1", 20 | "multer": "^1.4.2", 21 | "mysql2": "^2.0.0", 22 | "request": "^2.88.0", 23 | "request-promise": "^4.2.5", 24 | "socket.io": "^2.3.0" 25 | }, 26 | "devDependencies": { 27 | "eslint": "^6.6.0", 28 | "eslint-config-airbnb": "^18.0.1", 29 | "eslint-plugin-import": "^2.18.2", 30 | "eslint-plugin-jsx-a11y": "^6.2.3", 31 | "eslint-plugin-react": "^7.16.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const router = express.Router(); 4 | 5 | const room = require('./apis/room'); 6 | const login = require('./apis/login'); 7 | const game = require('./apis/game'); 8 | const user = require('./apis/user'); 9 | const edit = require('./apis/edit'); 10 | 11 | router.use('/room', room); 12 | router.use('/login', login); 13 | router.use('/user', user); 14 | router.use('/edit', edit); 15 | router.use('/room', game); 16 | 17 | module.exports = router; 18 | -------------------------------------------------------------------------------- /server/routes/apis/edit.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const multer = require('multer'); 3 | 4 | const upload = multer(); 5 | const router = express.Router(); 6 | 7 | const dbManager = require('../../models/database/dbManager'); 8 | const objectStorage = require('../../objectStorage'); 9 | const { isUserValid } = require('../../middleware/validations'); 10 | 11 | router.use(isUserValid); 12 | 13 | function getQuizset(quizzes, items) { 14 | quizzes.sort((quiz1, quiz2) => quiz1.quiz_order - quiz2.quiz_order); 15 | function pushQuiz(quizset, quiz) { 16 | const matchedItems = items.filter((item) => item.quiz_id === quiz.id); 17 | const newQuiz = { 18 | ...quiz, 19 | items: matchedItems, 20 | }; 21 | return [...quizset, newQuiz]; 22 | } 23 | 24 | return quizzes.reduce(pushQuiz, []); 25 | } 26 | 27 | function getImagePath(roomId, quizId, originalname) { 28 | const filename = encodeURIComponent(originalname) 29 | .replace(/[!'()]/g, escape) 30 | .replace(/\*/g, '%2A'); 31 | return `https://kr.object.ncloudstorage.com/pickyforky/images/${roomId}/${quizId}/${filename}`; 32 | } 33 | 34 | router.get('/quizset/:quizsetId', async (req, res) => { 35 | const { quizsetId } = req.params; 36 | const quizzes = await dbManager.quiz.readQuizzes(quizsetId); 37 | const items = await dbManager.item.readItems(quizsetId); 38 | const isSuccess = !quizzes.isError && !items.isError; 39 | if (isSuccess) { 40 | res.json({ 41 | isSuccess, 42 | data: { 43 | quizset: getQuizset(quizzes.data, items.data), 44 | }, 45 | }); 46 | return; 47 | } 48 | res.json({ 49 | isSuccess, 50 | }); 51 | }); 52 | 53 | router.post('/quizset', async (req, res) => { 54 | const { roomId, quizsetTitle, quizsetOrder } = req.body; 55 | const { data, isError } = await dbManager.quizset.createQuizset( 56 | roomId, 57 | quizsetTitle, 58 | quizsetOrder, 59 | ); 60 | const isSuccess = isError === undefined; 61 | if (!isSuccess) { 62 | res.json({ 63 | isSuccess, 64 | }); 65 | return; 66 | } 67 | res.json({ 68 | isSuccess, 69 | data: { 70 | quizsetId: data.insertId, 71 | }, 72 | }); 73 | }); 74 | 75 | router.post('/quiz', upload.single('file'), async (req, res) => { 76 | const { roomId, quizsetId, title, quizOrder, score, timeLimit } = req.body; 77 | const { data, isError } = await dbManager.quiz.createQuiz(quizsetId, { 78 | title, 79 | quizOrder, 80 | score, 81 | timeLimit, 82 | }); 83 | const isSuccess = isError === undefined; 84 | if (!isSuccess) { 85 | res.json({ 86 | isSuccess, 87 | }); 88 | return; 89 | } 90 | const quizId = data.insertId; 91 | const { file } = req; 92 | if (file !== undefined) { 93 | const { buffer, originalname } = file; 94 | await objectStorage.uploadImage(roomId, quizId, originalname, buffer); 95 | const newImagePath = getImagePath(roomId, quizId, originalname); 96 | await dbManager.quiz.updateImagePath(quizId, newImagePath); 97 | } 98 | res.json({ 99 | isSuccess, 100 | data: { 101 | quizId, 102 | }, 103 | }); 104 | }); 105 | 106 | router.put('/quiz', upload.single('file'), async (req, res) => { 107 | const { 108 | roomId, 109 | id, 110 | title, 111 | quizOrder, 112 | score, 113 | timeLimit, 114 | requestDeleteImage, 115 | } = req.body; 116 | const { file } = req; 117 | const { isError } = await dbManager.quiz.updateQuiz({ 118 | id, 119 | title, 120 | quizOrder, 121 | score, 122 | timeLimit, 123 | }); 124 | const isSuccess = isError === undefined; 125 | const quizId = id; 126 | if (file) { 127 | const { buffer, originalname } = file; 128 | await objectStorage.uploadImage(roomId, quizId, originalname, buffer); 129 | const newImagePath = getImagePath(roomId, quizId, originalname); 130 | await dbManager.quiz.updateImagePath(quizId, newImagePath); 131 | } 132 | if (requestDeleteImage) { 133 | await objectStorage.deleteQuizFolder(roomId, quizId); 134 | await dbManager.quiz.updateImagePath(quizId, null); 135 | } 136 | 137 | res.json({ 138 | isSuccess, 139 | }); 140 | }); 141 | 142 | router.delete('/quiz', async (req, res) => { 143 | const { roomId, quizId } = req.body; 144 | const { isError } = await dbManager.quiz.deleteQuiz(quizId); 145 | await objectStorage.deleteQuizFolder(roomId, quizId); 146 | const isSuccess = isError === undefined; 147 | res.json({ 148 | isSuccess, 149 | }); 150 | }); 151 | 152 | router.post('/items', async (req, res) => { 153 | const { quizId, items } = req.body; 154 | const { isError } = await dbManager.item.createItems(quizId, items); 155 | const isSuccess = isError === undefined; 156 | res.json({ 157 | isSuccess, 158 | }); 159 | }); 160 | 161 | router.put('/item', async (req, res) => { 162 | const { item } = req.body; 163 | const { isError } = await dbManager.item.updateItem(item); 164 | const isSuccess = isError === undefined; 165 | res.json({ 166 | isSuccess, 167 | }); 168 | }); 169 | 170 | module.exports = router; 171 | -------------------------------------------------------------------------------- /server/routes/apis/game.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const router = express.Router(); 4 | const inMemory = require('../../models/inMemory'); 5 | 6 | const { 7 | isRoomExist, 8 | isNicknameExist, 9 | } = require('../../middleware/validations'); 10 | 11 | /** 12 | * @api {get} /api/room/:roomNumber/quiz 현재 방에서 진행 할 퀴즈 세트를 가져오는 API 13 | * @apiName quiz 14 | * @apiGroup room 15 | * 16 | * @apiParam {string} roomNumber 6글자 방 번호 17 | * 18 | * @apiSuccess {Object} quizDataSet 퀴즈 세트 19 | */ 20 | router.get('/:roomNumber/quiz', async (req, res) => { 21 | const { roomNumber } = req.params; 22 | 23 | try { 24 | // inMemory서 quizSet을 가져옴. 25 | const quizSet = inMemory.room.getQuizSet(roomNumber); 26 | // 받아온 quizSet을 전송 27 | res.json({ 28 | isSuccess: true, 29 | quizSet, 30 | }); 31 | } catch (error) { 32 | res.json({ 33 | isError: true, 34 | message: error.message, 35 | }); 36 | } 37 | }); 38 | 39 | /** 40 | * 유저의 점수 총 합을 알려주는 API 41 | * @api {get} /api/room/:roomNumber/user/:nickname 42 | * @apiName subResult 43 | * @apiGroup room 44 | * 45 | * @apiParam {string} roomNumber 6글자 방 번호 46 | * @apiParam {string} nickname 유저의 입장 닉네임 47 | * 48 | * @apiSuccess {string} nickname 현제 유저의 닉네임 (string) 49 | * @apiSuccess {Integer} scores 최신 상태의 점수 (int) 50 | */ 51 | router.get( 52 | '/:roomNumber/player/:nickname', 53 | isRoomExist, 54 | isNicknameExist, 55 | async (req, res) => { 56 | const { roomNumber, nickname } = req.params; 57 | 58 | const score = inMemory.room.getPlayerScore(roomNumber, nickname); 59 | 60 | res.json({ 61 | nickname, 62 | score, 63 | }); 64 | }, 65 | ); 66 | 67 | /** 68 | * 퀴즈가 끝나고 특정 유저의 결과 (등수, 점수)를 알려주는 API 69 | * @api {get} /api/room/:roomNumber/player/:nickname/result 70 | * @apiName getResult 71 | * @apiGroup room 72 | * 73 | * @apiParam {string} roomNumber 6글자 방 번호 74 | * @apiParam {string} nickname 유저의 입장 닉네임 75 | * 76 | * @apiSuccess {string} nickname 현제 유저의 닉네임 (string) 77 | * @apiSuccess {int} scores 최신 상태의 점수 (int) 78 | * @apiSuccess {int} 등수 (int) 79 | */ 80 | router.get( 81 | '/:roomNumber/player/:nickname/result', 82 | isRoomExist, 83 | isNicknameExist, 84 | async (req, res) => { 85 | const { roomNumber, nickname } = req.params; 86 | 87 | let rank = 1; 88 | const score = inMemory.room.getPlayerScore(roomNumber, nickname); 89 | const players = inMemory.room.getPlayers(roomNumber); 90 | 91 | for (let index = 0; index < players.length; index += 1) { 92 | const currentPlayer = players[index]; 93 | const previousPlayer = players[index - 1]; 94 | 95 | if (previousPlayer) { 96 | rank = previousPlayer.score === currentPlayer.score ? rank : index + 1; 97 | } 98 | 99 | if (currentPlayer.nickname === nickname) break; 100 | } 101 | 102 | res.json({ 103 | nickname, 104 | score, 105 | rank, 106 | }); 107 | }, 108 | ); 109 | 110 | /** 111 | * 플레이어가 문항을 선택했을 때 카운트를 증가시키고, 112 | * 정답, 오답여부를 판별해주는 API 113 | * @api {post} /api/room/player/choose/check 114 | * @apiName choose 115 | * @apiGroup room 116 | * 117 | * @apiParam {string} roomNumber 6글자 방 번호 118 | * @apiParam {string} nickname 유저의 입장 닉네임 119 | * @apiParam {int} quizIndex 현재 문제의 index 120 | * @apiParam {int} choose 유저가 선택한 번호 121 | * 122 | * @apiSuccess {bool} isCorrect 선택한 항목이 정답인지 여부 123 | * @apiSuccess {int} score 갱신된 점수 124 | */ 125 | router.post( 126 | '/player/choose/check', 127 | isRoomExist, 128 | isNicknameExist, 129 | async (req, res) => { 130 | const { quizIndex, choose, roomNumber, nickname } = req.body; 131 | 132 | const [isCorrect, score] = inMemory.room.updatePlayerScore({ 133 | roomNumber, 134 | quizIndex, 135 | nickname, 136 | choose, 137 | }); 138 | 139 | inMemory.room.updateQuizCount({ 140 | roomNumber, 141 | quizIndex, 142 | choose, 143 | }); 144 | 145 | res.json({ 146 | isCorrect, 147 | score, 148 | }); 149 | 150 | inMemory.room.setSubmit({ 151 | roomNumber, 152 | nickname, 153 | }); 154 | 155 | const isLast = inMemory.room.isLastSubmit({ 156 | roomNumber, 157 | }); 158 | const socket = req.app.io.sockets; 159 | if (isLast) { 160 | const hostId = inMemory.room.getRoomHostId(roomNumber); 161 | socket 162 | .to(hostId) 163 | .emit('subResult', inMemory.room.getSubResult(roomNumber, quizIndex)); 164 | socket.to(roomNumber).emit('break'); 165 | } 166 | }, 167 | ); 168 | 169 | module.exports = router; 170 | -------------------------------------------------------------------------------- /server/routes/apis/login.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const jwt = require('jsonwebtoken'); 3 | const rp = require('request-promise'); 4 | 5 | const dbManager = require('../../models/database/dbManager'); 6 | 7 | require('dotenv').config(); 8 | 9 | const router = express.Router(); 10 | 11 | const jwtObj = { 12 | secret: process.env.JWT_SECRET, 13 | }; 14 | 15 | /** 16 | * @api {get} /api/login/token/:accessToken 네이버 프로필 조회 후 쿠키에 jwt로 설정하는 API. 17 | * @apiName setToken 18 | * @apiGroup login 19 | * 20 | * @apiParam {String} accessToken 네이버 로그인 후 제공한 접근 토큰 21 | */ 22 | router.get('/token/:accessToken', async (req, res) => { 23 | const { accessToken } = req.params; 24 | const header = `Bearer ${accessToken}`; // Bearer 다음에 공백을 추가해야함 25 | const apiUrl = 'https://openapi.naver.com/v1/nid/me'; 26 | 27 | const options = { 28 | method: 'GET', 29 | uri: apiUrl, 30 | headers: { 31 | Authorization: header, 32 | }, 33 | }; 34 | let profileObject; 35 | let getProfileSuccess = false; 36 | let errorCode = 'unknown'; 37 | 38 | await rp(options).then((body) => { 39 | /** 40 | * body의 형태 41 | * resultcode: '00', 42 | * message: 'success', 43 | * response: { 44 | * id: '', 45 | * } 46 | */ 47 | const { resultcode, message, response } = JSON.parse(body); 48 | 49 | if (message !== 'success') { 50 | getProfileSuccess = false; 51 | errorCode = `${resultcode}`; 52 | return; 53 | } 54 | 55 | profileObject = response; 56 | getProfileSuccess = true; 57 | }); 58 | 59 | if (!getProfileSuccess) { 60 | res.json({ 61 | isError: true, 62 | isSuccess: false, 63 | message: `네이버 프로필 API를 호출할 수 없습니다. 에러코드 : ${errorCode}`, 64 | }); 65 | return; 66 | } 67 | 68 | /** 69 | * 프로필객체 예시 70 | * profileObject : { 71 | * "email": "openapi@naver.com", 72 | * "nickname": "OpenAPI", 73 | * "profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif", 74 | * "age": "40-49", 75 | * "gender": "F", 76 | * "id": "32742776", 77 | * "name": "오픈 API", 78 | * "birthday": "10-01" 79 | * } 80 | */ 81 | 82 | /** 83 | * 최초의 사용자 로그인 시 프로필객체의 id를 저장하고, 84 | * 이미 저장된 사용자가 로그인 시 다음과 같은 객체를 return함 85 | * { 86 | * isError: true, 87 | * message: "Duplicate entry '프로필객체의 숫자 id' for key 'naver_id_UNIQUE'" 88 | * } 89 | */ 90 | await dbManager.user.insertUser(profileObject); 91 | 92 | const token = jwt.sign( 93 | { 94 | naverId: profileObject.id, 95 | }, 96 | jwtObj.secret, 97 | { 98 | expiresIn: '1h', 99 | }, 100 | ); 101 | 102 | /** 103 | * 쿠키에 정보 추가 104 | */ 105 | res.cookie('naverId', profileObject.id, { 106 | maxAge: 60 * 60 * 1000, // 60분 * 60초 * 1000 ms 107 | }); 108 | res.cookie('jwt', token, { 109 | maxAge: 60 * 60 * 1000, // 60분 * 60초 * 1000 ms 110 | }); 111 | 112 | res.json({ 113 | isSuccess: true, 114 | }); 115 | }); 116 | 117 | /** 118 | * @api {get} /api/login/token/check 설정된 jwt가 유효한지 검사하는 API 119 | * @apiName checkJWT 120 | * @apiGroup login 121 | * 122 | * @apiParam {String} accessToken 네이버 로그인 후 제공한 접근 토큰 123 | */ 124 | router.get('/check/token', async (req, res) => { 125 | const { cookies } = req; 126 | let decodedJWT; 127 | 128 | try { 129 | decodedJWT = jwt.verify(cookies.jwt, jwtObj.secret); 130 | res.json({ 131 | isSuccess: true, 132 | naverId: decodedJWT.naverId, 133 | }); 134 | return; 135 | } catch (error) { 136 | res.json({ 137 | isSuccess: false, 138 | message: error.message, 139 | }); 140 | } 141 | }); 142 | 143 | module.exports = router; 144 | -------------------------------------------------------------------------------- /server/routes/apis/room.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { 3 | isRoomExist, 4 | isRoomNumberValid, 5 | isValidNickname, 6 | isNicknameOverlap, 7 | } = require('../../middleware/validations'); 8 | 9 | const router = express.Router(); 10 | 11 | router.get('/', (req, res) => { 12 | res.json({ 13 | isError: true, 14 | message: '방 번호를 입력하세요.', 15 | }); 16 | }); 17 | 18 | /** 19 | * @api {get} /api/room/:roomNumber 유효한 방인지 확인 요청 20 | * @apiName checkValidRoomNumber 21 | * @apiGroup room 22 | * 23 | * @apiParam {String} roomNumber 방의 고유한 6자리 번호. 24 | * 25 | * @apiSuccess {boolean} isSuccess 방을 들어갈 수 있는지 여부 26 | * @apiSuccess {String} message 오류가 발생한 경우, 오류 메시지 27 | * 28 | * @apiSuccessExample Success-Response: 29 | * HTTP/1.1 200 OK 30 | * { 31 | * isSuccess: true, 32 | * } 33 | * 34 | * @apiSuccessExample 열리지 않는 방에 접근함 35 | * HTTP/1.1 200 OK 36 | * { 37 | * isSuccess: false, 38 | * message: '존재하지 않는 방입니다. 방 번호를 다시 입력해주세요.', 39 | * } 40 | */ 41 | router.get('/:roomNumber', isRoomNumberValid, isRoomExist, (req, res) => { 42 | res.json({ 43 | isSuccess: true, 44 | }); 45 | }); 46 | 47 | router.get('/:roomNumber/name', isRoomNumberValid, isRoomExist, (req, res) => { 48 | res.json({ 49 | isError: true, 50 | message: '닉네임을 입력하세요.', 51 | }); 52 | }); 53 | 54 | /** 55 | * @api {get} /api/room/:roomNumber/:nickname 방에서 유효한 닉네임인지 확인 요청 56 | * @apiName checkValidNickname 57 | * @apiGroup room 58 | * 59 | * @apiParam {String} roomNumber 방의 고유한 6자리 번호. 60 | * @apiParam {String} nickname 유저가 입력한 닉네임. 61 | * 62 | * @apiSuccess {boolean} isSuccess 입력한 닉네임으로 방을 들어갈 수 있는지 여부 63 | * @apiSuccess {String} message 오류가 발생한 경우, 오류 메시지 64 | */ 65 | router.get( 66 | '/:roomNumber/name/:nickname', 67 | isRoomNumberValid, 68 | isRoomExist, 69 | isValidNickname, 70 | isNicknameOverlap, 71 | (req, res) => { 72 | res.json({ 73 | isSuccess: true, 74 | }); 75 | }, 76 | ); 77 | 78 | module.exports = router; 79 | -------------------------------------------------------------------------------- /server/routes/apis/user.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { check, validationResult } = require('express-validator'); 3 | const { isUserValid } = require('../../middleware/validations'); 4 | 5 | const router = express.Router(); 6 | 7 | const dbManager = require('../../models/database/dbManager'); 8 | 9 | router.use(isUserValid); 10 | /** 11 | * 유저의 모든 방을 가져오는 API 12 | * @api {get} /api/user/:userId/rooms 13 | * @apiName getRooms 14 | * @apiGroup user 15 | * 16 | * @apiParam {string} userId 유저의 email id 17 | * 18 | * @apiSuccessData {object array} 유저가 가진 모든 방 19 | * { 20 | * title: '방 이름' 21 | * id: '방의 id' 22 | * } 23 | */ 24 | router.get('/:userId/rooms', async (req, res) => { 25 | const { userId } = req.params; 26 | const result = await dbManager.room.selectRooms(userId); 27 | 28 | res.send(result); 29 | }); 30 | 31 | router.get('/room/:roomId', async (req, res) => { 32 | const { roomId } = req.params; 33 | const result = await dbManager.room.getRoomTitle(roomId); 34 | 35 | res.send(result); 36 | }); 37 | 38 | /** 39 | * 새로운 방을 추가하는 API 40 | * 41 | * @api {post} /api/user/room 42 | * @apiName addRoom 43 | * @apiGroup user 44 | * 45 | * @apiParam {string} title 새로운 방의 이름 (26글자 이내) 46 | * @apiParam {string} userId 유저의 email id 47 | * 48 | * @apiSuccessData {object} DB insert 결과 (inserId 등) 49 | */ 50 | router.post( 51 | '/room', 52 | [ 53 | check('userId').exists(), 54 | check('title') 55 | .exists() 56 | .bail() 57 | .isLength({ 58 | min: 1, 59 | max: 26, 60 | }), 61 | ], 62 | async (req, res) => { 63 | try { 64 | validationResult(req).throw(); 65 | const { title, userId } = req.body; 66 | const result = await dbManager.room.insertRoom(userId, title); 67 | 68 | res.send(result); 69 | } catch (err) { 70 | res.send({ 71 | isError: true, 72 | message: err.message, 73 | }); 74 | } 75 | }, 76 | ); 77 | 78 | /** 79 | * 방의 이름을 수정하는 API 80 | * @api {put} /api/user/room 81 | * @apiName updateRoomTitle 82 | * @apiGroup 83 | * 84 | * @apiParam {string} roomId 방의 ID 85 | * @apiParam {string} 수정되는 방의 이름 (26글자 이내) 86 | * 87 | * @apiSuccess {object} DB insert 결과 (영향을 받은 column의 개수 등) 88 | */ 89 | router.put( 90 | '/room', 91 | [ 92 | check('roomId').exists(), 93 | check('title') 94 | .exists() 95 | .bail() 96 | .isLength({ 97 | min: 1, 98 | max: 26, 99 | }), 100 | ], 101 | async (req, res) => { 102 | try { 103 | validationResult(req).throw(); 104 | const { roomId, title } = req.body; 105 | const { isSuccess, data } = await dbManager.room.updateRoom( 106 | roomId, 107 | title, 108 | ); 109 | 110 | if (isSuccess && data.affectedRows) { 111 | res.send({ 112 | isSuccess, 113 | }); 114 | return; 115 | } 116 | 117 | res.send({ 118 | isError: true, 119 | data, 120 | }); 121 | } catch (err) { 122 | res.send({ 123 | isError: true, 124 | message: err.message, 125 | }); 126 | } 127 | }, 128 | ); 129 | 130 | router.delete('/room', [check('roomId').exists()], async (req, res) => { 131 | try { 132 | validationResult(req).throw(); 133 | const { roomId } = req.body; 134 | const { isSuccess, data } = await dbManager.room.deleteRoom(roomId); 135 | 136 | if (isSuccess && data.affectedRows) { 137 | res.send({ 138 | isSuccess, 139 | }); 140 | return; 141 | } 142 | 143 | res.send({ 144 | isError: true, 145 | data, 146 | }); 147 | } catch (err) { 148 | res.send({ 149 | isError: true, 150 | message: err.message, 151 | }); 152 | } 153 | }); 154 | 155 | /** 156 | * 방의 퀴즈 목록을 가져오는 API 157 | * @api {put} /api/user/quizset/:roomId 158 | * @apiName getRoomQuizset 159 | * @apiGroup 160 | * 161 | * @apiParam {string} roomId 방의 ID 162 | * 163 | * @apiSuccess {object} DB select 결과 (방에 속한 퀴즈의 목록) 164 | */ 165 | router.get('/quizset/:roomId', async (req, res) => { 166 | const { roomId } = req.params; 167 | const { isError, data } = await dbManager.quizset.readLastQuizsetId(roomId); 168 | const isSuccess = isError === undefined && data.length > 0; 169 | const quizsetId = isSuccess ? data[0].id : undefined; 170 | // undefined를 front에서 사용하기 때문에 보냄 171 | res.json({ 172 | isSuccess, 173 | data: { 174 | quizsetId, 175 | }, 176 | }); 177 | }); 178 | 179 | module.exports = router; 180 | -------------------------------------------------------------------------------- /server/socket.js: -------------------------------------------------------------------------------- 1 | const io = require('socket.io')(); 2 | const inMemory = require('./models/inMemory'); 3 | 4 | async function handleOpenRoom({ roomId }) { 5 | const roomNumber = inMemory.room.setNewRoom(this.id); 6 | 7 | await inMemory.room.setQuizSet(roomNumber, roomId); 8 | 9 | this.host = true; 10 | this.join(roomNumber, () => { 11 | io.to(inMemory.room.getRoomHostId(roomNumber)).emit('openRoom', { 12 | roomNumber, 13 | }); 14 | }); 15 | } 16 | 17 | function handleStartQuiz({ roomNumber }) { 18 | if (!inMemory.room.isRoomExist(roomNumber)) return; 19 | 20 | io.to(roomNumber).emit('start'); 21 | 22 | setTimeout(() => { 23 | inMemory.room.setNextQuizIndex(roomNumber); 24 | io.to(roomNumber).emit('next', 0); 25 | }, 3000); 26 | } 27 | 28 | function handleNextQuiz({ roomNumber }) { 29 | if (!inMemory.room.isRoomExist(roomNumber)) return; 30 | const nextQuizIndex = inMemory.room.setNextQuizIndex(roomNumber); 31 | const players = inMemory.room.setWaitingPlayersToGame(roomNumber); 32 | 33 | io.to(this.id).emit('enterPlayer', players); 34 | io.to(roomNumber).emit('next', nextQuizIndex); 35 | } 36 | 37 | function handleBreakQuiz({ roomNumber }) { 38 | if (!inMemory.room.isRoomExist(roomNumber)) return; 39 | const quizIndex = inMemory.room.getQuizIndex(roomNumber); 40 | const hostId = inMemory.room.getRoomHostId(roomNumber); 41 | io.to(hostId).emit( 42 | 'subResult', 43 | inMemory.room.getSubResult(roomNumber, quizIndex), 44 | ); 45 | 46 | io.to(roomNumber).emit('break'); 47 | } 48 | 49 | function handleEndQuiz({ roomNumber }) { 50 | if (!inMemory.room.isRoomExist(roomNumber)) return; 51 | 52 | io.to(roomNumber).emit('end', inMemory.room.getFinalResult(roomNumber)); 53 | } 54 | 55 | function handleEnterPlayer({ roomNumber, nickname }) { 56 | if (!inMemory.room.isRoomExist(roomNumber)) return; 57 | 58 | const score = inMemory.room.isRefreshingPlayer(roomNumber, nickname); 59 | 60 | if (score !== undefined) { 61 | this.join(roomNumber, () => { 62 | io.to(this.id).emit('settingScore', score); 63 | }); 64 | } 65 | 66 | if (inMemory.room.isQuizPlaying(roomNumber)) { 67 | inMemory.room.updateWaitingPlayers({ 68 | roomNumber, 69 | nickname, 70 | score, 71 | }); 72 | this.join(roomNumber); 73 | return; 74 | } 75 | 76 | const players = inMemory.room.setNewPlayer(roomNumber, nickname, score); 77 | 78 | this.join(roomNumber, () => { 79 | io.to(inMemory.room.getRoomHostId(roomNumber)).emit('enterPlayer', players); 80 | }); 81 | } 82 | 83 | function handleLeavePlayer({ roomNumber, nickname }) { 84 | if (!inMemory.room.isRoomExist(roomNumber)) return; 85 | const result = inMemory.room.deletePlayer(roomNumber, nickname); 86 | 87 | if (result) { 88 | io.to(inMemory.room.getRoomHostId(roomNumber)).emit( 89 | 'leavePlayer', 90 | inMemory.room.getPlayers(roomNumber), 91 | ); 92 | const isLast = inMemory.room.isLastSubmit({ 93 | roomNumber, 94 | }); 95 | if (isLast) { 96 | handleBreakQuiz({ 97 | roomNumber, 98 | }); 99 | } 100 | } 101 | } 102 | 103 | function handleCloseRoom() { 104 | if (!this.host) return; 105 | const roomNumber = inMemory.room.deleteRoom(this.id); 106 | io.to(roomNumber).emit('closeRoom'); 107 | } 108 | 109 | io.on('connection', (socket) => { 110 | socket.on('disconnect', handleCloseRoom.bind(socket)); 111 | socket.on('openRoom', handleOpenRoom.bind(socket)); 112 | socket.on('start', handleStartQuiz.bind(socket)); 113 | socket.on('next', handleNextQuiz.bind(socket)); 114 | socket.on('break', handleBreakQuiz.bind(socket)); 115 | socket.on('end', handleEndQuiz.bind(socket)); 116 | socket.on('enterPlayer', handleEnterPlayer.bind(socket)); 117 | socket.on('leavePlayer', handleLeavePlayer.bind(socket)); 118 | }); 119 | 120 | module.exports = io; 121 | -------------------------------------------------------------------------------- /server/utils/checkJsonHasKeys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * req.body에 key가 있는지 검사 3 | * @param {string} body : request.body 4 | * @param {List} keys : body가 갖고 있는지 검사하고 싶은 key들 5 | */ 6 | function checkJsonHasKeys(body, keys) { 7 | return !keys.find((element) => body[element] === undefined); 8 | } 9 | 10 | module.exports = checkJsonHasKeys; 11 | --------------------------------------------------------------------------------