├── .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 |
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 |
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 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
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 |
103 | 퀴즈 추가
104 |
105 | );
106 | }
107 |
108 | function DeleteQuizButton({ onClick }) {
109 | return (
110 |
111 |
112 |
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 |
164 |
165 |
166 | {quizset.map((thumbnail, index) => (
167 |
168 | ))}
169 |
170 |
171 |
172 |
173 |
174 |
175 |
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 |
105 |
106 | {index + 1}
107 |
108 |
109 |
110 | {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 |
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 |
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 |
--------------------------------------------------------------------------------