├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── README.md ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.tsx ├── api │ ├── QuizServices.ts │ ├── UserServices.ts │ ├── apiUrl.ts │ ├── auth.ts │ ├── axiosInstance.ts │ ├── create.ts │ ├── getUserList.ts │ ├── notification.ts │ └── user.ts ├── assets.d.ts ├── assets │ ├── QuizCreateMockData.ts │ ├── QuizMockData.ts │ ├── RankingMockData.ts │ ├── UserInfoDefault.ts │ ├── UserInfoMockData.ts │ ├── downArrow.png │ ├── maple.png │ └── no-image.png ├── common │ ├── number.ts │ └── string.ts ├── components │ ├── Form │ │ ├── Button │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── InputBox │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── Select │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ └── Title │ │ │ └── styles.ts │ ├── Header │ │ ├── index.tsx │ │ └── styles.tsx │ ├── Home │ │ ├── QuizSetCard │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── QuizSetList │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ └── RandomQuiz │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ ├── Icon.tsx │ ├── LoginForm │ │ └── index.tsx │ ├── Modal │ │ ├── NicknameModal.tsx │ │ ├── PasswordModal.tsx │ │ ├── QuizModal.tsx │ │ └── styles.tsx │ ├── Notification │ │ ├── Item.tsx │ │ ├── index.tsx │ │ └── styles.ts │ ├── QuizCreate │ │ ├── QuizCreateForm │ │ │ ├── QuizCreateForm.tsx │ │ │ └── index.ts │ │ ├── QuizItem │ │ │ └── index.tsx │ │ ├── QuizList │ │ │ └── index.tsx │ │ ├── QuizSetForm │ │ │ └── index.tsx │ │ └── Rate │ │ │ └── index.tsx │ ├── QuizResult │ │ ├── index.tsx │ │ └── styles.tsx │ ├── QuizSolve │ │ ├── Layout.tsx │ │ ├── QuizCarousel.tsx │ │ ├── QuizCarouselItem.tsx │ │ ├── QuizContentArea.tsx │ │ ├── QuizSubmitArea.tsx │ │ ├── SliderButton.tsx │ │ └── index.ts │ ├── SignUpForm │ │ └── index.tsx │ ├── Tag │ │ ├── index.tsx │ │ └── style.tsx │ ├── UserInfo │ │ ├── UserInfoCard │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── UserInfoTab │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── UserInfoTabItem │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── UserQuizItem │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ └── breakpoints.ts │ └── shared │ │ └── Modal │ │ ├── Modal.tsx │ │ ├── ModalProvider.tsx │ │ ├── ModalTrigger.tsx │ │ └── index.ts ├── constants │ └── index.ts ├── containers │ └── UserRankList │ │ ├── index.tsx │ │ └── style.tsx ├── contexts │ ├── AuthContext │ │ └── index.tsx │ └── QuizContext │ │ └── index.tsx ├── designs │ ├── Option.ts │ ├── Select.ts │ └── Text.ts ├── emotion.d.ts ├── foundations │ ├── border │ │ └── index.ts │ ├── colors │ │ └── index.ts │ ├── grid │ │ └── index.ts │ └── text │ │ └── index.ts ├── global.d.ts ├── hooks │ ├── shared │ │ └── useLoading │ │ │ ├── index.ts │ │ │ └── useLoading.ts │ ├── useInput │ │ └── index.ts │ ├── useQuiz │ │ ├── index.ts │ │ └── useQuiz.ts │ ├── useStorage │ │ ├── index.ts │ │ ├── useLocalStorage.ts │ │ ├── useSessionStorage.ts │ │ └── useStorage.ts │ └── useValidation │ │ └── index.tsx ├── index.tsx ├── interfaces │ ├── BadgeType.d.ts │ ├── ChangeFormData.d.ts │ ├── ChannelAPI.d.ts │ ├── CommentAPI.d.ts │ ├── LikeAPI.d.ts │ ├── LoginFormData.d.ts │ ├── NotificationAPI.d.ts │ ├── PostAPI.d.ts │ ├── Quiz.d.ts │ ├── Rank.d.ts │ ├── SignUpFormData.d.ts │ ├── UserAPI.d.ts │ ├── apiType.d.ts │ └── model.ts ├── pages │ ├── Children.tsx │ ├── ErrorPage │ │ ├── index.tsx │ │ └── styles.tsx │ ├── Home.tsx │ ├── QuizCreatePage │ │ └── index.tsx │ ├── QuizResultPage │ │ ├── index.tsx │ │ └── styles.tsx │ ├── QuizSolvePage │ │ ├── QuizSolvePage.helper.ts │ │ ├── QuizSolvePage.tsx │ │ └── index.ts │ ├── RankingPage │ │ ├── index.tsx │ │ └── style.tsx │ └── UserInfoPage │ │ ├── index.tsx │ │ └── styles.tsx ├── routes │ ├── AuthRoute.tsx │ ├── PrivateRoute.tsx │ └── Router.tsx ├── styles │ ├── fontStyle.ts │ ├── reset.ts │ └── theme.ts └── utils │ ├── dateFormat.ts │ ├── getUserImage.ts │ └── validation.ts ├── tsconfig.json ├── tsconfig.path.json ├── webpack.config.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { "targets": { "browsers": ["last 2 versions", ">= 5% in KR"] } } 6 | ], 7 | ["@babel/preset-react", {"runtime": "automatic", "importSource": "@emotion/react"}], 8 | "@babel/typescript" 9 | ], 10 | "plugins": [ 11 | "@emotion", 12 | ["@babel/plugin-transform-runtime", { 13 | "corejs": 3 14 | } 15 | ] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *html 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "tsconfigRootDir": "./", 10 | "project": ["./tsconfig.json"] 11 | }, 12 | "extends": [ 13 | "airbnb", 14 | "airbnb-typescript", 15 | "airbnb/hooks", 16 | "plugin:react/jsx-runtime", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 19 | "plugin:import/recommended", 20 | "prettier" 21 | ], 22 | "plugins": ["@typescript-eslint", "@emotion"], 23 | "rules": { 24 | "@typescript-eslint/consistent-type-exports": [ 25 | "error", 26 | { "fixMixedExportsWithInlineTypeSpecifier": false } 27 | ], 28 | "@typescript-eslint/consistent-type-imports": [ 29 | "error", 30 | { "prefer": "type-imports" } 31 | ], 32 | "@typescript-eslint/no-misused-promises": "off", // ANCHOR: 무슨 옵션인지 정확히 알 수 없으나, callback 처리가 잘 안됨 33 | "@typescript-eslint/no-use-before-define": "off", 34 | "@typescript-eslint/no-floating-promises": "off", 35 | "@typescript-eslint/naming-convention": [ 36 | "error", 37 | { 38 | "selector": "variable", 39 | "format": null, 40 | "leadingUnderscore": "allow" 41 | } 42 | ], 43 | "import/order": [ 44 | "error", 45 | { 46 | "groups": [ 47 | "builtin", 48 | "external", 49 | "internal", 50 | "parent", 51 | "sibling", 52 | "index", 53 | "object", 54 | "type", 55 | "unknown" 56 | ], 57 | "pathGroups": [ 58 | { 59 | "pattern": "react", 60 | "group": "external", 61 | "position": "before" 62 | } 63 | ], 64 | "pathGroupsExcludedImportTypes": ["react"], 65 | "newlines-between": "always", 66 | "alphabetize": { "order": "asc", "caseInsensitive": false }, 67 | "warnOnUnassignedImports": true 68 | } 69 | ], 70 | "import/prefer-default-export": "off", 71 | "jsx-a11y/click-events-have-key-events": "off", 72 | "jsx-a11y/no-static-element-interactions": "off", 73 | "no-underscore-dangle": "off", 74 | "react/function-component-definition": [ 75 | "error", 76 | { 77 | "namedComponents": "arrow-function" 78 | } 79 | ], 80 | "react/jsx-props-no-spreading": "off", 81 | "react/require-default-props": "off", 82 | "react/jsx-sort-props": [ 83 | "error", 84 | { 85 | "callbacksLast": true, 86 | "shorthandFirst": true, 87 | "multiline": "last", 88 | "reservedFirst": true 89 | } 90 | ] 91 | }, 92 | "settings": { 93 | "react": { 94 | "version": "detect" 95 | }, 96 | "import/resolver": { 97 | "typescript": { 98 | "alwaysTryTypes": true 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feat]' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## ✏️ 작업 내용 10 | 11 | A clear and concise description of what you do. 12 | 13 | ## ⚠️ 주의사항 14 | 15 | A clear and concise description of precautions 16 | 17 | ## ⏰ 예상 시간 18 | 19 | H(hours), D(days)... 20 | 21 | ## 🔎 추가 정보 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 15 | 16 | ## 📌 PR 설명 17 | 18 | 19 | ## 💻 요구 사항과 구현 내용 20 | 21 | 22 | ## ✔️ PR 포인트 & 궁금한 점 23 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vscode/ 4 | 5 | .DS_Store 6 | .env 7 | .env.local 8 | .env.development.local 9 | .env.test.local 10 | .env.production.local 11 | 12 | .eslintcache 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | .editorconfig 19 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx tsc --noEmit && npx lint-staged --allow-empty 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "jsxSingleQuote": true, 4 | "singleAttributePerLine": true, 5 | "singleQuote": true, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## 서비스 소개 4 | 5 | >CHEQUIZ는 퀴즈라는 간단하지만 수치화할 수 있는 시스템을 통해 현재 나의 개발 지식을 측정할 수 있는 플랫폼입니다. 6 | 7 | ## 주요 기능 8 | 9 | - 랜덤으로 퀴즈를 요청하여 문제를 해결할 수 있습니다. 10 | - 만들어진 문제 세트에 포함되어 있는 엄선된 문제들을 해결할 수 있습니다. 11 | - 문제를 맞추면, 경험치를 받을 수 있습니다. 경험치를 쌓고 뱃지를 받아 나를 표현해보세요. 12 | - 경험치가 쌓여 레벨이 올라가면, 나를 표현하는 캐릭터의 모양도 변화합니다! 문제를 해결하고 더 높은 단계에 도달해 보세요. 13 | - 문제가 맘에 들면, 좋아요를 눌러 다음에 다시 확인할 수 있습니다. 14 | - 문제를 해결하고 난 뒤, 댓글을 남겨 출제자에게 피드백을 남기거나, 다른 사용자와 소통할 수 있습니다. 15 | 16 | ## 기술 스택 17 | 18 | ### 개발 19 | 20 | ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) 21 | ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) 22 | ![Context API](https://img.shields.io/badge/ContextAPI-4dd0e1.svg?&style=for-the-badge&logo=React&logoColor=white) 23 | ![Webpack](https://img.shields.io/badge/Webpack-8DD6F9?style=for-the-badge&logo=webpack&logoColor=white) 24 | 25 | ### 코드 관리 26 | 27 | ![ESLint](https://img.shields.io/badge/ESLint-4B3263?style=for-the-badge&logo=eslint&logoColor=white) 28 | ![prettier](https://img.shields.io/badge/prettier-ff69b4.svg?style=for-the-badge) 29 | ![husky](https://img.shields.io/badge/husky-8E562E.svg?style=for-the-badge) 30 | ![lint-staged](https://img.shields.io/badge/lint_staged-015E76.svg?style=for-the-badge) 31 | 32 | ### 스타일 33 | 34 | ![Figma](https://img.shields.io/badge/figma-%23F24E1E.svg?style=for-the-badge&logo=figma&logoColor=white) 35 | ![Emotion](https://img.shields.io/badge/Emotion-af8eb5.svg?&style=for-the-badge&logo=Emotion&logoColor=white) 36 | 37 | ### 커뮤니케이션 38 | 39 | ![GitHub](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white) 40 | ![Notion](https://img.shields.io/badge/Notion-%23000000.svg?style=for-the-badge&logo=notion&logoColor=white) 41 | ![Discord](https://img.shields.io/badge/Discord-%237289DA.svg?style=for-the-badge&logo=discord&logoColor=white) 42 | ![Slack](https://img.shields.io/badge/Slack-4A154B?style=for-the-badge&logo=slack&logoColor=white) 43 | 44 | ## 팀원 소개 45 | 46 |
47 | 48 | | [고준혁](https://github.com/mrbartrns) | [김정환](https://github.com/padd60) | [김창민](https://github.com/chmini) | [서인수](https://github.com/outwater) | [편미해](https://github.com/smilehae) | 49 | | :-------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------: | 50 | | | | | | | 51 | --- 52 | 53 |
54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chequiz", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.tsx", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot --progress", 8 | "build": "webpack --mode production --progress", 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "prepare": "husky install" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@emotion/react": "^11.9.0", 17 | "@emotion/styled": "^11.8.1", 18 | "axios": "^0.27.2", 19 | "feather-icons": "^4.29.0", 20 | "formik": "^2.2.9", 21 | "react": "^18.1.0", 22 | "react-animate-height": "^3.0.3", 23 | "react-dom": "^18.1.0", 24 | "react-router": "^5.2.1", 25 | "react-router-dom": "^5.3.0", 26 | "react-slick": "^0.29.0", 27 | "slick-carousel": "^1.8.1", 28 | "uuid": "^8.3.2", 29 | "yup": "^0.32.11" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.18.0", 33 | "@babel/plugin-transform-runtime": "^7.18.0", 34 | "@babel/preset-env": "^7.18.0", 35 | "@babel/preset-react": "^7.17.12", 36 | "@babel/preset-typescript": "^7.17.12", 37 | "@babel/runtime-corejs3": "^7.18.0", 38 | "@emotion/babel-plugin": "^11.9.2", 39 | "@emotion/eslint-plugin": "^11.10.0", 40 | "@types/dotenv-webpack": "^7.0.3", 41 | "@types/feather-icons": "^4.7.0", 42 | "@types/node": "^18.7.16", 43 | "@types/react": "^18.0.9", 44 | "@types/react-dom": "^18.0.5", 45 | "@types/react-router": "^5.1.18", 46 | "@types/react-router-dom": "^5.3.3", 47 | "@types/react-slick": "^0.23.8", 48 | "@types/uuid": "^8.3.4", 49 | "@types/webpack": "^5.28.0", 50 | "@types/webpack-bundle-analyzer": "^4.6.0", 51 | "@typescript-eslint/eslint-plugin": "^5.26.0", 52 | "@typescript-eslint/parser": "^5.26.0", 53 | "babel-loader": "^8.2.5", 54 | "buffer": "^6.0.3", 55 | "css-loader": "^6.7.1", 56 | "dotenv-webpack": "^7.1.0", 57 | "eslint": "^8.16.0", 58 | "eslint-config-airbnb": "^19.0.4", 59 | "eslint-config-airbnb-typescript": "^17.0.0", 60 | "eslint-config-prettier": "^8.5.0", 61 | "eslint-import-resolver-typescript": "^3.5.0", 62 | "eslint-plugin-import": "^2.26.0", 63 | "eslint-plugin-jsx-a11y": "^6.5.1", 64 | "eslint-plugin-prettier": "^4.0.0", 65 | "eslint-plugin-react": "^7.30.0", 66 | "eslint-plugin-react-hooks": "^4.5.0", 67 | "fork-ts-checker-webpack-plugin": "^7.2.11", 68 | "html-webpack-plugin": "^5.5.0", 69 | "husky": ">=6", 70 | "lint-staged": ">=10", 71 | "prettier": "^2.6.2", 72 | "sass": "^1.54.9", 73 | "sass-loader": "^13.0.2", 74 | "style-loader": "^3.3.1", 75 | "ts-node": "^10.9.1", 76 | "typescript": "^4.7.2", 77 | "webpack": "^5.72.1", 78 | "webpack-bundle-analyzer": "^4.7.0", 79 | "webpack-cli": "^4.9.2", 80 | "webpack-dev-server": "^4.9.0" 81 | }, 82 | "lint-staged": { 83 | "*.{ts,tsx}": [ 84 | "eslint --cache --fix", 85 | "prettier --write" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC2_CheQuiz_Gidong/c1da801a23c42027d606f94a780501783f2f972c/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CheQuiz 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @emotion/syntax-preference */ 2 | import { Global, ThemeProvider } from '@emotion/react'; 3 | import styled from '@emotion/styled'; 4 | 5 | import AuthProvider from '@/contexts/AuthContext'; 6 | import QuizProvider from '@/contexts/QuizContext'; 7 | import Router from '@/routes/Router'; 8 | import fontStyle from '@/styles/fontStyle'; 9 | import reset from '@/styles/reset'; 10 | import theme from '@/styles/theme'; 11 | 12 | const Layout = styled.div` 13 | min-width: 32.5rem; 14 | max-width: 1200px; 15 | padding: 0 1rem; 16 | margin: 0 auto; 17 | `; 18 | 19 | const App = (): JSX.Element => ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /src/api/QuizServices.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import api from '@/api/axiosInstance'; 4 | 5 | import type { ChannelAPI } from '@/interfaces/ChannelAPI'; 6 | import type { PostAPI } from '@/interfaces/PostAPI'; 7 | import type { Quiz } from '@/interfaces/Quiz'; 8 | 9 | function shuffle(array: T[], count: number): T[] { 10 | const ret = [...array]; 11 | for (let i = 0; i < array.length - 1; i += 1) { 12 | const j = Math.floor(Math.random() * (i + 1)); 13 | [ret[i], ret[j]] = [ret[j], ret[i]]; 14 | } 15 | return ret.slice(0, count < ret.length ? count : ret.length); 16 | } 17 | 18 | function getPostIds() { 19 | return api 20 | .get('/channels') 21 | .then((response) => response.data) 22 | .then((data) => data.flatMap((channel) => channel.posts)) 23 | .catch(() => { 24 | throw new Error('error occured at getAllPostIds.'); 25 | }); 26 | } 27 | 28 | function getPostsFromPostIds(postIds: string[]) { 29 | return axios.all( 30 | postIds.map((postId) => 31 | api 32 | .get(`/posts/${postId}`) 33 | .then((response) => response.data) 34 | .catch(() => { 35 | throw new Error('error occured at getPostsfromPostIds.'); 36 | }) 37 | ) 38 | ); 39 | } 40 | 41 | function getPosts() { 42 | return api 43 | .get('/posts') 44 | .then((response) => response.data) 45 | .catch(() => { 46 | throw new Error('error occured at getPosts.'); 47 | }); 48 | } 49 | 50 | function getShuffledPosts(count: number) { 51 | return getPosts().then((posts) => shuffle(posts, count)); 52 | } 53 | 54 | function parseQuiz(post: PostAPI) { 55 | const postCopy: Partial = { ...post }; 56 | const quizContent = postCopy.title as string; 57 | delete postCopy.title; 58 | return { ...postCopy, ...JSON.parse(quizContent) } as Quiz; 59 | } 60 | 61 | function getPostsFromChannel(channelId: string): Promise { 62 | return api 63 | .get(`/posts/channel/${channelId}`) 64 | .then((response) => response.data); 65 | } 66 | 67 | /** 68 | * @deprecated 69 | */ 70 | export function getPostIdsFromChannel(channelName: string): Promise { 71 | return api 72 | .get(`/channels/${channelName}`) 73 | .then((response) => response.data) 74 | .then((data) => (data.posts ? data.posts : [])) 75 | .catch(() => { 76 | throw new Error('error occured at getPostIdsFromChannel.'); 77 | }); 78 | } 79 | 80 | /** 81 | * @deprecated 82 | */ 83 | export function getShuffledPostIds(count: number) { 84 | return getPostIds() 85 | .then((postIds) => shuffle(postIds, count)) 86 | .catch(() => { 87 | throw new Error('error occured at getShuffledPostIds.'); 88 | }); 89 | } 90 | 91 | export function getQuizzesFromPostIds(postIds: string[]): Promise { 92 | return getPostsFromPostIds(postIds) 93 | .then((response) => response.map((post) => parseQuiz(post))) 94 | .catch(() => { 95 | throw new Error('error occured at getQuizzes'); 96 | }); 97 | } 98 | 99 | export function getQuizzesFromChannel(channelId: string) { 100 | return getPostsFromChannel(channelId) 101 | .then((posts) => posts.map((post) => parseQuiz(post))) 102 | .then((quiz) => quiz.reverse()); 103 | } 104 | 105 | export function getShuffledQuizzes(count: number) { 106 | return getShuffledPosts(count).then((posts) => 107 | posts.map((post) => parseQuiz(post)) 108 | ); 109 | } 110 | 111 | export function caculateScore(quizzes: Quiz[], userAnswers: string[]) { 112 | // 전부 선택하지 않았거나 user가 임의로 조작했다면 0점을 부여한다. 113 | if (quizzes.length !== userAnswers.filter((answer) => answer).length) 114 | return 0; 115 | // filter corrected quizzes and add scores 116 | return quizzes 117 | .filter((quiz, index) => quiz.answer === userAnswers[index]) 118 | .reduce((acc, cur) => acc + cur.difficulty * 10, 0); 119 | } 120 | 121 | export async function getChannels() { 122 | return api 123 | .get('/channels') 124 | .then((response) => response.data) 125 | .catch(() => { 126 | throw new Error('error occured at getChannels.'); 127 | }); 128 | } 129 | -------------------------------------------------------------------------------- /src/api/UserServices.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 2 | import api from '@/api/axiosInstance'; 3 | 4 | import type { 5 | UpdateNameFormData, 6 | UpdatePasswordFormData, 7 | } from '@/interfaces/ChangeFormData'; 8 | import type { CommentAPI } from '@/interfaces/CommentAPI'; 9 | import type { LikeAPI } from '@/interfaces/LikeAPI'; 10 | import type { UserAPI, UserQuizPostAPI } from '@/interfaces/UserAPI'; 11 | import type { AxiosRequestHeaders } from 'axios'; 12 | 13 | const isNotNull = (item: string | null): item is string => !!item; 14 | 15 | function getHeaders(): AxiosRequestHeaders { 16 | const token = localStorage.getItem('token'); 17 | return { 18 | Authorization: `Bearer ${isNotNull(token) ? JSON.parse(token) : ''}`, 19 | }; 20 | } 21 | 22 | export function like(postId: string) { 23 | return api 24 | .post( 25 | '/likes/create', 26 | { postId }, 27 | { 28 | headers: { ...getHeaders() }, 29 | } 30 | ) 31 | .then((response) => response.data) 32 | .catch(() => { 33 | throw new Error('error occuread at like.'); 34 | }); 35 | } 36 | 37 | export function cancelLike(likeId: string) { 38 | return api.delete('/likes/delete', { 39 | data: { id: likeId }, 40 | headers: { ...getHeaders() }, 41 | }); 42 | } 43 | 44 | export function createComment({ 45 | comment, 46 | postId, 47 | }: { 48 | comment: string; 49 | postId: string; 50 | }) { 51 | return api 52 | .post( 53 | '/comments/create', 54 | { comment, postId }, 55 | { 56 | headers: { ...getHeaders() }, 57 | } 58 | ) 59 | .then((response) => response.data) 60 | .catch((error) => { 61 | console.error(error); 62 | throw new Error('error occured at createComment.'); 63 | }); 64 | } 65 | 66 | export function deleteComment(commentId: string) { 67 | return api.delete('/comments/delete', { 68 | data: { id: commentId }, 69 | headers: { ...getHeaders() }, 70 | }); 71 | } 72 | 73 | export function updateTotalPoint(info: UserQuizPostAPI) { 74 | return api 75 | .put( 76 | '/settings/update-user', 77 | { ...info, username: JSON.stringify(info.username) }, 78 | { headers: { ...getHeaders() } } 79 | ) 80 | .then((response) => response.data) 81 | .catch(() => { 82 | throw new Error('error occured at updateTotalPoint.'); 83 | }); 84 | } 85 | 86 | export function updateFullName(userUpdateData: UpdateNameFormData) { 87 | return api 88 | .put( 89 | '/settings/update-user', 90 | { ...userUpdateData }, 91 | { headers: { ...getHeaders() } } 92 | ) 93 | .then((response) => response.data) 94 | .catch(() => { 95 | throw new Error('error occured at updateFullName.'); 96 | }); 97 | } 98 | export function updatePassword(passwordData: UpdatePasswordFormData) { 99 | return api 100 | .put( 101 | '/settings/update-password', 102 | { ...passwordData }, 103 | { headers: { ...getHeaders() } } 104 | ) 105 | .then((response) => response.data) 106 | .catch(() => { 107 | throw new Error('error occured at updatePassword.'); 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /src/api/apiUrl.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export const rankingUrl = { 3 | getAllUsers: '/users/get-users', 4 | getUser: '/search/users/', 5 | }; 6 | -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | import axiosInstance from '@/api/axiosInstance'; 3 | 4 | import type { LoginFormData } from '@/interfaces/LoginFormData'; 5 | import type { SignUpFormData } from '@/interfaces/SignUpFormData'; 6 | 7 | const login = async (data: LoginFormData) => { 8 | try { 9 | const res = await axiosInstance({ 10 | method: 'POST', 11 | url: '/login', 12 | data, 13 | }); 14 | 15 | return res.data; 16 | } catch (error) { 17 | throw new Error('Login Failed'); 18 | } 19 | }; 20 | 21 | const signUp = async (data: SignUpFormData) => { 22 | try { 23 | const res = await axiosInstance({ 24 | method: 'POST', 25 | url: '/signup', 26 | data, 27 | }); 28 | 29 | return res.data; 30 | } catch (error) { 31 | throw new Error('SignUp Failed'); 32 | } 33 | }; 34 | 35 | const getAuthUser = async (token: string) => { 36 | try { 37 | if (!token) throw new Error('Token is required'); 38 | 39 | const res = await axiosInstance({ 40 | method: 'GET', 41 | url: '/auth-user', 42 | headers: { 43 | Authorization: `Bearer ${token}`, 44 | }, 45 | }); 46 | 47 | if (!res.data) throw new Error('Token is invalid'); 48 | return res.data; 49 | } catch (error) { 50 | throw new Error('Auth User Failed'); 51 | } 52 | }; 53 | 54 | const logout = async () => { 55 | try { 56 | await axiosInstance({ 57 | method: 'POST', 58 | url: '/logout', 59 | }); 60 | } catch (error) { 61 | throw new Error('Logout Failed'); 62 | } 63 | }; 64 | 65 | export default { 66 | login, 67 | signUp, 68 | getAuthUser, 69 | logout, 70 | }; 71 | -------------------------------------------------------------------------------- /src/api/axiosInstance.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import type { AxiosInstance } from 'axios'; 4 | 5 | const host = process.env.REACT_APP_API_HOST || 'localhost'; 6 | const port = process.env.REACT_APP_API_PORT || 3000; 7 | 8 | const API_ENDPOINT = `${host}:${port}`; 9 | 10 | const axiosInstance: AxiosInstance = axios.create({ 11 | baseURL: API_ENDPOINT, // baseURL 미리세팅 12 | timeout: 5000, 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | }, 16 | }); 17 | 18 | axiosInstance.interceptors.response.use( 19 | (response) => Promise.resolve(response), 20 | (error) => { 21 | console.error(error); 22 | return Promise.reject(error); 23 | } 24 | ); 25 | 26 | export default axiosInstance; 27 | -------------------------------------------------------------------------------- /src/api/create.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import axiosInstance from '@/api/axiosInstance'; 3 | 4 | import type { UserAPI } from '@/interfaces/UserAPI'; 5 | import type { Channel, QuizItem, QuizSet } from '@/interfaces/model'; 6 | 7 | export const createQuiz = async ( 8 | quiz: Omit, 9 | token: string, 10 | channelId = process.env.DEFAULT_CHANNEL_ID 11 | ) => { 12 | try { 13 | await axiosInstance({ 14 | method: 'POST', 15 | url: '/posts/create', 16 | data: { image: null, channelId, title: JSON.stringify(quiz) }, 17 | headers: { 18 | Authorization: `Bearer ${token}`, 19 | }, 20 | }); 21 | } catch (error) { 22 | throw new Error('Create Quiz Failed'); 23 | } 24 | }; 25 | 26 | export const createQuizSet = async (set: QuizSet, user: UserAPI) => { 27 | const { name, ...quizSetCustomData } = set; 28 | try { 29 | const { data }: { data: Channel } = await axiosInstance({ 30 | method: 'POST', 31 | url: 'channels/create', 32 | data: { 33 | authRequired: false, 34 | name, 35 | description: JSON.stringify({ 36 | ...quizSetCustomData, 37 | creator: user, 38 | }), 39 | }, 40 | headers: { 41 | Authorization: `Bearer ${process.env.ADMIN_USER_TOKEN || ''}`, 42 | }, 43 | }); 44 | return data; 45 | } catch (error) { 46 | throw new Error('Create Quiz Set Failed'); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/api/getUserList.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import { rankingUrl } from '@/api/apiUrl'; 3 | import apiInstance from '@/api/axiosInstance'; 4 | 5 | import type { UserAPI } from '@/interfaces/UserAPI'; 6 | 7 | const getUserList = async () => { 8 | try { 9 | const { data }: { data: UserAPI[] } = await apiInstance({ 10 | method: 'get', 11 | url: rankingUrl.getAllUsers, 12 | }); 13 | return data; 14 | } catch (error) { 15 | throw new Error('Get UserList failed'); 16 | } 17 | }; 18 | 19 | export default getUserList; 20 | -------------------------------------------------------------------------------- /src/api/notification.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | import axiosInstance from '@/api/axiosInstance'; 3 | 4 | import type { NotificationPayload } from '@/interfaces/NotificationAPI'; 5 | 6 | export const getNotifications = async (token: string) => { 7 | try { 8 | const res = await axiosInstance({ 9 | method: 'GET', 10 | url: '/notifications', 11 | headers: { 12 | Authorization: `Bearer ${token}`, 13 | }, 14 | }); 15 | 16 | return res.data; 17 | } catch (error) { 18 | throw new Error('Get Notifications Failed'); 19 | } 20 | }; 21 | 22 | export const createNotification = async ( 23 | token: string, 24 | notificationPayload: NotificationPayload 25 | ) => { 26 | try { 27 | await axiosInstance({ 28 | method: 'POST', 29 | url: '/notifications/create', 30 | headers: { 31 | Authorization: `Bearer ${token}`, 32 | }, 33 | data: notificationPayload, 34 | }); 35 | } catch (error) { 36 | throw new Error('Create Notification Failed'); 37 | } 38 | }; 39 | 40 | export const seenNotifications = async (token: string) => { 41 | try { 42 | await axiosInstance({ 43 | method: 'PUT', 44 | url: '/notifications/seen', 45 | headers: { 46 | Authorization: `Bearer ${token}`, 47 | }, 48 | }); 49 | } catch (error) { 50 | throw new Error('Notifications seen Failed'); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/api/user.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | import axiosInstance from '@/api/axiosInstance'; 3 | 4 | import type { UserAPI } from '@/interfaces/UserAPI'; 5 | 6 | export const fetchUserData = async (userId: string) => { 7 | try { 8 | const res = await axiosInstance({ 9 | method: 'GET', 10 | url: `/users/${userId}`, 11 | }); 12 | return res.data; 13 | } catch (error) { 14 | throw new Error('Get UserData Failed'); 15 | } 16 | }; 17 | 18 | export const fetchUserList = async () => { 19 | try { 20 | const res = await axiosInstance({ 21 | method: 'GET', 22 | url: `/users/get-users`, 23 | }); 24 | return res.data as UserAPI[]; 25 | } catch (error) { 26 | throw new Error('Get UserList Failed'); 27 | } 28 | }; 29 | 30 | export const fetchUserQuiz = async (userId: string) => { 31 | try { 32 | const res = await axiosInstance({ 33 | method: 'GET', 34 | url: `/posts/author/${userId}`, 35 | }); 36 | return res.data; 37 | } catch (error) { 38 | throw new Error('Get UserQuiz Failed'); 39 | } 40 | }; 41 | 42 | // 임시로 사용 => 추후 준혁님의 quizService merge시 해당 API 사용 예정 43 | export const fetchPosts = async () => { 44 | try { 45 | const res = await axiosInstance({ 46 | method: 'GET', 47 | url: `/posts`, 48 | }); 49 | return res.data; 50 | } catch (error) { 51 | throw new Error('Get Posts Failed'); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.gif' { 2 | const value: any; 3 | export = value; 4 | } 5 | declare module '*.png' { 6 | const value: any; 7 | export = value; 8 | } 9 | declare module '*.jpg' { 10 | const value: any; 11 | export = value; 12 | } 13 | declare module '*.jpeg' { 14 | const value: any; 15 | export = value; 16 | } 17 | -------------------------------------------------------------------------------- /src/assets/QuizCreateMockData.ts: -------------------------------------------------------------------------------- 1 | import { QuizClientContent } from '@/interfaces/Quiz'; 2 | 3 | const QUIZ_ITEM_DEFAULT_STATE: QuizClientContent = { 4 | _id: 0, 5 | category: '', 6 | question: '', 7 | difficulty: 0, 8 | importance: 0, 9 | answerType: 'trueOrFalse', 10 | answer: '', 11 | answerDescription: '', 12 | }; 13 | 14 | const QUIZ_SET_DEFAULT_STATE = { 15 | name: '', 16 | tags: [], 17 | des: '', 18 | }; 19 | const SAMPLE_QUIZ_LIST_STATE: QuizClientContent[] = [ 20 | { 21 | _id: 0, 22 | category: '', 23 | question: '', 24 | difficulty: 0, 25 | importance: 0, 26 | answerType: 'trueOrFalse', 27 | answer: '', 28 | answerDescription: '', 29 | }, 30 | { 31 | _id: 1, 32 | category: 'javascript', 33 | question: '질문타이틀_자바스크립트는 인터프리터언어?', 34 | difficulty: 5, 35 | importance: 5, 36 | answerType: 'trueOrFalse', 37 | answer: 'true', 38 | answerDescription: 39 | '정답해설_자바스크립트는 인터프리터언어이다. 왜냐하면 인터프리터이기 때문이다.', 40 | }, 41 | { 42 | _id: 2, 43 | category: 'react', 44 | question: '질문타이틀_리액트는 프레임워크입니까?', 45 | difficulty: 3, 46 | importance: 2, 47 | answerType: 'trueOrFalse', 48 | answer: 'false', 49 | answerDescription: 50 | '정답해설_리액트는 프레임워크가 아니라 라이브러리입니다. 왜냐하면 js를 자유롭게 사용가능하기 때문입니다.', 51 | }, 52 | ]; 53 | export { 54 | QUIZ_ITEM_DEFAULT_STATE, 55 | QUIZ_SET_DEFAULT_STATE, 56 | SAMPLE_QUIZ_LIST_STATE, 57 | }; 58 | -------------------------------------------------------------------------------- /src/assets/RankingMockData.ts: -------------------------------------------------------------------------------- 1 | import { UserAPI } from '@/interfaces/UserAPI'; 2 | 3 | const RankingMockData: UserAPI[] = [ 4 | { 5 | _id: 'user01', 6 | role: 'Regular', 7 | isOnline: true, 8 | posts: [], 9 | likes: [], 10 | comments: [], 11 | notifications: [], 12 | fullName: 'Apple', 13 | username: JSON.stringify({ 14 | totalPoints: 0, 15 | }), 16 | email: 'test@naver.com', 17 | createdAt: Date.now().toString(), 18 | updatedAt: Date.now().toString(), 19 | messages: [], 20 | following: [], 21 | }, 22 | { 23 | _id: 'user02', 24 | role: 'Regular', 25 | isOnline: true, 26 | posts: [], 27 | likes: ['1'], 28 | comments: [], 29 | notifications: [], 30 | fullName: 'Banana', 31 | username: JSON.stringify({ 32 | totalPoints: 1000, 33 | }), 34 | email: 'test@naver.com', 35 | createdAt: Date.now().toString(), 36 | updatedAt: Date.now().toString(), 37 | messages: [], 38 | following: [], 39 | }, 40 | { 41 | _id: 'user03', 42 | role: 'Regular', 43 | isOnline: true, 44 | posts: [], 45 | likes: [], 46 | comments: ['1', '1'], 47 | notifications: [], 48 | fullName: 'Candle', 49 | username: JSON.stringify({ 50 | totalPoints: 5000, 51 | }), 52 | email: 'test@naver.com', 53 | createdAt: Date.now().toString(), 54 | updatedAt: Date.now().toString(), 55 | messages: [], 56 | following: [], 57 | }, 58 | { 59 | _id: 'user04', 60 | role: 'Regular', 61 | isOnline: true, 62 | posts: [], 63 | likes: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1'], 64 | comments: ['1', '1'], 65 | notifications: [], 66 | fullName: 'Dollar', 67 | username: JSON.stringify({ 68 | totalPoints: 10000, 69 | }), 70 | email: 'test@naver.com', 71 | createdAt: Date.now().toString(), 72 | updatedAt: Date.now().toString(), 73 | messages: [], 74 | following: [], 75 | }, 76 | { 77 | _id: 'user05', 78 | role: 'Regular', 79 | isOnline: true, 80 | posts: [], 81 | likes: ['1', '1'], 82 | comments: ['1', '1'], 83 | notifications: [], 84 | fullName: 'Edison', 85 | username: JSON.stringify({ 86 | totalPoints: 50000, 87 | }), 88 | email: 'test@naver.com', 89 | createdAt: Date.now().toString(), 90 | updatedAt: Date.now().toString(), 91 | messages: [], 92 | following: [], 93 | }, 94 | { 95 | _id: 'user06', 96 | role: 'Regular', 97 | isOnline: true, 98 | posts: [], 99 | likes: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1'], 100 | comments: ['1', '1'], 101 | notifications: [], 102 | fullName: 'Fries', 103 | username: JSON.stringify({ 104 | totalPoints: 100000, 105 | }), 106 | email: 'test@naver.com', 107 | createdAt: Date.now().toString(), 108 | updatedAt: Date.now().toString(), 109 | messages: [], 110 | following: [], 111 | }, 112 | { 113 | _id: 'user07', 114 | role: 'Regular', 115 | isOnline: true, 116 | posts: [], 117 | likes: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1'], 118 | comments: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1'], 119 | notifications: [], 120 | fullName: 'Galaxy', 121 | username: JSON.stringify({ 122 | totalPoints: 500000, 123 | }), 124 | email: 'test@naver.com', 125 | createdAt: Date.now().toString(), 126 | updatedAt: Date.now().toString(), 127 | messages: [], 128 | following: [], 129 | }, 130 | { 131 | _id: 'user08', 132 | role: 'Regular', 133 | isOnline: true, 134 | posts: [], 135 | likes: ['1', '1', '1'], 136 | comments: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1'], 137 | notifications: [], 138 | fullName: 'History', 139 | username: JSON.stringify({ 140 | totalPoints: 1000000, 141 | }), 142 | email: 'test@naver.com', 143 | createdAt: Date.now().toString(), 144 | updatedAt: Date.now().toString(), 145 | messages: [], 146 | following: [], 147 | }, 148 | { 149 | _id: 'user09', 150 | role: 'Regular', 151 | isOnline: true, 152 | posts: [], 153 | likes: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1'], 154 | comments: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1'], 155 | notifications: [], 156 | fullName: 'Iron', 157 | username: JSON.stringify({ 158 | totalPoints: 5000000, 159 | }), 160 | email: 'test@naver.com', 161 | createdAt: Date.now().toString(), 162 | updatedAt: Date.now().toString(), 163 | messages: [], 164 | following: [], 165 | }, 166 | ]; 167 | 168 | export default RankingMockData; 169 | -------------------------------------------------------------------------------- /src/assets/UserInfoDefault.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const DEFAULT_USER_DATA = { 3 | id: 'loading', 4 | posts: [], 5 | likes: [], 6 | comments: [], 7 | following: [], 8 | fullName: 'loading중...', 9 | email: 'asdf@asdf.com', 10 | totalExp: 0, 11 | }; 12 | -------------------------------------------------------------------------------- /src/assets/downArrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC2_CheQuiz_Gidong/c1da801a23c42027d606f94a780501783f2f972c/src/assets/downArrow.png -------------------------------------------------------------------------------- /src/assets/maple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC2_CheQuiz_Gidong/c1da801a23c42027d606f94a780501783f2f972c/src/assets/maple.png -------------------------------------------------------------------------------- /src/assets/no-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC2_CheQuiz_Gidong/c1da801a23c42027d606f94a780501783f2f972c/src/assets/no-image.png -------------------------------------------------------------------------------- /src/common/number.ts: -------------------------------------------------------------------------------- 1 | // 고정 숫자 상수들 정의 2 | 3 | export const DEFAULTNUMBER = 0; 4 | export const MAXEXP = 100; 5 | export const DIFFICULTY_COUNT = 5; 6 | export const IMPORTANCE_COUNT = 5; 7 | -------------------------------------------------------------------------------- /src/common/string.ts: -------------------------------------------------------------------------------- 1 | export const GREEN = 'green'; 2 | export const BLUE = 'blue'; 3 | export const YELLOW = 'yellow'; 4 | export const RED = 'red'; 5 | export const PINK = 'pink'; 6 | export const BROWN = 'brown'; 7 | export const NOLIKES = 'noLikes'; 8 | export const NOCOMMENTS = 'noComments'; 9 | -------------------------------------------------------------------------------- /src/components/Form/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import * as S from './styles'; 2 | 3 | interface Props { 4 | text: string; 5 | type: 'button' | 'submit'; 6 | } 7 | 8 | const Button = ({ text, type }: Props) => ( 9 | {text} 10 | ); 11 | 12 | export default Button; 13 | -------------------------------------------------------------------------------- /src/components/Form/Button/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | // TODO: 전역 스타일 컬러 적용 4 | export const Button = styled.button` 5 | padding: 8px 24px; 6 | background-color: #fca311; 7 | border: 2px solid #14213d; 8 | border-radius: 8px; 9 | box-sizing: border-box; 10 | outline: none; 11 | font-size: 1rem; 12 | cursor: pointer; 13 | 14 | &:hover { 15 | background-color: tomato; 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /src/components/Form/InputBox/index.tsx: -------------------------------------------------------------------------------- 1 | import { useField } from 'formik'; 2 | 3 | import * as S from './styles'; 4 | 5 | interface Props { 6 | label: string; 7 | name: string; 8 | type: string; 9 | placeholder: string; 10 | } 11 | 12 | const InputBox = ({ label, ...props }: Props) => { 13 | const [field, meta] = useField(props); 14 | 15 | return ( 16 | 17 | {label} 18 | 22 | {meta.touched && meta.error ? ( 23 | {meta.error} 24 | ) : null} 25 | 26 | ); 27 | }; 28 | 29 | export default InputBox; 30 | -------------------------------------------------------------------------------- /src/components/Form/InputBox/styles.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @emotion/syntax-preference */ 2 | import styled from '@emotion/styled'; 3 | 4 | export const InputBox = styled.div` 5 | display: flex; 6 | flex-direction: column; 7 | padding: 8px 0; 8 | `; 9 | 10 | export const Label = styled.label` 11 | margin-bottom: 4px; 12 | `; 13 | 14 | // TODO: 전역 스타일 컬러 적용 15 | export const Input = styled.input` 16 | width: 100%; 17 | border: 2px solid #14213d; 18 | border-radius: 8px; 19 | box-sizing: border-box; 20 | outline: none; 21 | padding: 8px; 22 | font-size: 1rem; 23 | `; 24 | 25 | export const ErrorText = styled.div` 26 | color: #ff0000; 27 | font-size: 0.9rem; 28 | `; 29 | -------------------------------------------------------------------------------- /src/components/Form/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import * as S from './styles'; 2 | 3 | interface SelectProps { 4 | defaultValue?: string; 5 | value?: string; 6 | options: string[] | { label: string; value: string; disabled?: boolean }[]; 7 | onChangeValue?: (value: string) => void; 8 | addStyle?: { [x: string]: unknown }; 9 | [x: string]: unknown; 10 | } 11 | 12 | const Select = ({ 13 | defaultValue, 14 | value, 15 | options, 16 | onChangeValue, 17 | addStyle, 18 | ...props 19 | }: SelectProps) => ( 20 | 23 | onChangeValue && onChangeValue(target.value) 24 | } 25 | {...props} 26 | style={{ ...addStyle }} 27 | > 28 | {defaultValue && ( 29 | 35 | )} 36 | {options 37 | .map((opt) => 38 | typeof opt === 'string' 39 | ? { label: opt, value: opt, disabled: false } 40 | : opt 41 | ) 42 | .map((opt) => ( 43 | 50 | ))} 51 | 52 | ); 53 | export default Select; 54 | -------------------------------------------------------------------------------- /src/components/Form/Select/styles.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @emotion/syntax-preference */ 2 | import styled from '@emotion/styled'; 3 | 4 | import arrowIcon from '@/assets/downArrow.png'; 5 | import { DarkGray, small } from '@/styles/theme'; 6 | 7 | // eslint-disable-next-line import/prefer-default-export 8 | export const SelectBox = styled.select` 9 | width: 10rem; 10 | height: 2.5rem; 11 | padding: 0.25rem 2rem; 12 | font-family: 'MaplestoryOTFLight'; 13 | ${small}; 14 | white-space: nowrap; 15 | overflow: hidden; 16 | text-overflow: ellipsis; 17 | 18 | border: 3px solid ${DarkGray}; 19 | border-radius: 0.5rem; 20 | box-sizing: border-box; 21 | appearance: none; 22 | outline: none; 23 | background: url(${arrowIcon}) 95.5% center/10% no-repeat; 24 | cursor: pointer; 25 | `; 26 | -------------------------------------------------------------------------------- /src/components/Form/Title/styles.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @emotion/syntax-preference */ 2 | import styled from '@emotion/styled'; 3 | 4 | // eslint-disable-next-line import/prefer-default-export 5 | export const Title = styled.h1` 6 | font-size: 1.5rem; 7 | font-weight: bold; 8 | margin-bottom: 1rem; 9 | `; 10 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { useState } from 'react'; 3 | 4 | import Icon from '@/components/Icon'; 5 | import Notification from '@/components/Notification'; 6 | import { Modal, ModalProvider, ModalTrigger } from '@/components/shared/Modal'; 7 | import { useAuthContext } from '@/contexts/AuthContext'; 8 | 9 | import LoginForm from '../LoginForm'; 10 | import SignUpForm from '../SignUpForm'; 11 | 12 | import * as S from './styles'; 13 | 14 | const Header = (): JSX.Element => { 15 | const { user, isAuth, logout } = useAuthContext(); 16 | 17 | const [notiShow, setNotiShow] = useState(false); 18 | 19 | return ( 20 | <> 21 | 22 | 23 | 28 | CheQuiz 29 | 30 | {isAuth ? ( 31 | 32 | 36 | 문제 만들기 37 | 38 | 42 | 랭킹 보기 43 | 44 | 48 | 내 정보 49 | 50 | { 53 | logout(); 54 | setNotiShow(false); 55 | }} 56 | > 57 | 로그아웃 58 | 59 | { 62 | setNotiShow(!notiShow); 63 | }} 64 | > 65 | 69 | 70 | 71 | ) : ( 72 | 73 | 77 | 랭킹 보기 78 | 79 | 80 | 81 | 로그인 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 회원가입 90 | 91 | 92 | 93 | 94 | 95 | 96 | )} 97 | {notiShow && } 98 | 99 | 100 | 101 | 102 | ); 103 | }; 104 | 105 | export default Header; 106 | -------------------------------------------------------------------------------- /src/components/Header/styles.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @emotion/syntax-preference */ 2 | import styled from '@emotion/styled'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | export interface StyledLinkedButtonProps { 6 | color: 'point' | 'primary' | 'secondary'; 7 | fill?: 'true' | 'false'; 8 | fullWidth?: 'true' | 'false'; 9 | logo?: 'true' | 'false'; 10 | } 11 | 12 | export const HeaderContainer = styled.div` 13 | position: fixed; 14 | top: 0px; 15 | left: 0px; 16 | height: 3.5rem; 17 | width: 100%; 18 | border-bottom: 3px solid #343a40; 19 | background-color: #f8f9fa; 20 | z-index: 3; 21 | box-sizing: content-box; 22 | `; 23 | 24 | // header : fixed에 의한 레이아웃 용 25 | export const HeaderSpacer = styled.div` 26 | height: 3.5rem; 27 | width: 100%; 28 | `; 29 | 30 | export const ContentContainer = styled.div` 31 | height: inherit; 32 | position: relative; 33 | display: flex; 34 | flex-wrap: nowrap; 35 | justify-content: space-between; 36 | align-items: center; 37 | max-width: 1200px; 38 | margin: auto; 39 | padding: 0 1rem; 40 | `; 41 | 42 | export const ButtonGroup = styled.div` 43 | display: flex; 44 | align-items: center; 45 | height: inherit; 46 | `; 47 | 48 | export const LinkButton = styled(Link)` 49 | width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')}; 50 | display: ${({ fullWidth }) => (fullWidth ? 'block' : 'inline-block')}; 51 | font-family: ${({ logo }) => 52 | logo === 'true' ? `'Permanent Marker', sans-serif` : null}; 53 | font-size: ${({ logo }) => (logo === 'true' ? '2rem' : '1rem')}; 54 | text-shadow: ${({ logo }) => 55 | logo === 'true' 56 | ? '-2px 0 black, 0 2px black, 2px 0 black, 0 -2px black' 57 | : null}; 58 | padding: 0 1rem; 59 | border: none; 60 | background-color: ${({ color, fill }) => { 61 | if (!fill) return '#f8f9fa'; 62 | if (color === 'point') return '#fca211'; 63 | if (color === 'primary') return '#14213d'; 64 | return '#e5e5e5'; 65 | }}; 66 | color: ${({ color, fill }) => { 67 | if (fill) return '#f8f9fa'; 68 | if (color === 'point') return '#fca211'; 69 | if (color === 'primary') return '#14213d'; 70 | return '#e5e5e5'; 71 | }}; 72 | text-decoration: none; 73 | transition: color 0.2s ease-in-out; 74 | outline: none; 75 | cursor: pointer; 76 | &:hover { 77 | color: #fca311; 78 | } 79 | `; 80 | 81 | export const Button = styled.button` 82 | display: inline-block; 83 | text-align: center; 84 | padding: 0 1rem; 85 | font-family: 'MaplestoryOTFLight', 'Segoe UI', 'Apple SD Gothic Neo', 86 | 'Noto Sans KR', 'Malgun Gothic', sans-serif; 87 | background-color: rgba(0, 0, 0, 0); 88 | border: none; 89 | outline: none; 90 | font-size: 1rem; 91 | text-decoration: none; 92 | color: #343a40; 93 | cursor: pointer; 94 | transition: color 0.2s ease-in-out; 95 | &:hover { 96 | color: #fca311; 97 | } 98 | `; 99 | -------------------------------------------------------------------------------- /src/components/Home/QuizSetCard/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 5 | import { getUserImageByPoints } from '@/utils/getUserImage'; 6 | 7 | import * as S from './styles'; 8 | 9 | import type { ChannelAPI } from '@/interfaces/ChannelAPI'; 10 | 11 | interface QuizSetCardProps { 12 | quizSet: ChannelAPI; 13 | cardIdx: number; 14 | } 15 | const QuizSetCard = ({ quizSet, cardIdx }: QuizSetCardProps) => { 16 | const { description, name } = quizSet; 17 | const { tags, des, creator } = JSON.parse(description); 18 | const points = JSON.parse(creator?.username || null)?.points || 1000; 19 | 20 | return ( 21 | 22 | 23 | {name} 24 | 25 | {`총 문제수 ${quizSet.posts.length}`} 26 | {tags.map((t: string, idx: number) => ( 27 | 31 | {t} 32 | 33 | ))} 34 | 35 | {des} 36 | 37 | 38 | {creator.fullName || '익명의 사용자'} 39 | 40 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default QuizSetCard; 51 | -------------------------------------------------------------------------------- /src/components/Home/QuizSetCard/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | import { 4 | DarkGray, 5 | medium, 6 | pointColor, 7 | small, 8 | tagBlue, 9 | tagGreen, 10 | tagLightBrown, 11 | tagPink, 12 | tagRed, 13 | tagYellow, 14 | white, 15 | detail, 16 | primary, 17 | } from '@/styles/theme'; 18 | 19 | const colors = [ 20 | pointColor, 21 | tagBlue, 22 | tagYellow, 23 | tagRed, 24 | tagLightBrown, 25 | tagPink, 26 | tagGreen, 27 | ]; 28 | export const CardContainer = styled.div` 29 | min-height: 25rem; 30 | 31 | border: 3px solid ${DarkGray}; 32 | border-radius: 0.5rem; 33 | background-color: ${white}; 34 | transform: ${({ cardIdx }: { cardIdx: number }) => 35 | cardIdx % 2 === 0 ? 'none' : 'translateY(1rem)'}; 36 | 37 | display: flex; 38 | flex-direction: column; 39 | gap: 1rem; 40 | cursor: pointer; 41 | `; 42 | 43 | export const QuizBox = styled.div` 44 | padding: 1rem; 45 | flex-grow: 1; 46 | 47 | display: flex; 48 | flex-direction: column; 49 | gap: 1rem; 50 | `; 51 | export const Title = styled.div` 52 | ${medium} 53 | `; 54 | export const TagBox = styled.div` 55 | display: grid; 56 | grid-template-columns: repeat(2, 1fr); 57 | gap: 0.75rem 0.5rem; 58 | `; 59 | type TagProps = { 60 | order: number; 61 | }; 62 | export const Tag = styled.div` 63 | height: 2.125rem; 64 | 65 | ${small}; 66 | text-align: center; 67 | line-height: 2.125rem; 68 | 69 | border-radius: 0.25rem; 70 | 71 | background-color: ${(props) => colors[props.order]}; 72 | `; 73 | 74 | export const Description = styled.div` 75 | flex-grow: 1; 76 | color: #686868; 77 | ${detail}; 78 | 79 | white-space: break-spaces; 80 | `; 81 | 82 | export const UserBox = styled.div` 83 | height: 5.25rem; 84 | color: white; 85 | background-color: ${primary}; 86 | 87 | display: flex; 88 | justify-content: space-around; 89 | `; 90 | export const UserName = styled.div` 91 | align-self: flex-end; 92 | margin-bottom: 1rem; 93 | `; 94 | export const UserImageWrapper = styled.div` 95 | align-self: center; 96 | width: 4rem; 97 | height: 4rem; 98 | 99 | border: 3px solid; 100 | border-radius: 0.5rem; 101 | background-color: white; 102 | 103 | display: flex; 104 | justify-content: center; 105 | align-items: center; 106 | `; 107 | export const UserImage = styled.img` 108 | max-height: 5.5rem; 109 | `; 110 | 111 | export const ImageWrapper = styled.div` 112 | display: flex; 113 | justify-content: center; 114 | align-items: center; 115 | width: 6rem; 116 | height: 6rem; 117 | margin-bottom: 0.5rem; 118 | border: 3px solid; 119 | border-radius: 8px; 120 | box-sizing: border-box; 121 | color: inherit; 122 | background-color: #e9ecef; 123 | `; 124 | -------------------------------------------------------------------------------- /src/components/Home/QuizSetList/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 5 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 6 | /* eslint-disable @typescript-eslint/no-floating-promises */ 7 | import { useEffect, useState } from 'react'; 8 | 9 | import { getChannels } from '@/api/QuizServices'; 10 | import Select from '@/components/Form/Select'; 11 | import Icon from '@/components/Icon'; 12 | import { useQuizContext } from '@/contexts/QuizContext'; 13 | 14 | import QuizSetCard from '../QuizSetCard'; 15 | 16 | import * as S from './styles'; 17 | 18 | import type { ChannelAPI } from '@/interfaces/ChannelAPI'; 19 | 20 | const QuizSetList = () => { 21 | const [quizSetList, setQuizSetList] = useState([]); 22 | const [keyword, setKeyword] = useState(''); 23 | const [sortBy, setSortBy] = useState('new'); 24 | 25 | const { setRandomQuizCategory, setRandomQuizCount, setChannelId } = 26 | useQuizContext(); 27 | 28 | useEffect(() => { 29 | const fetchQuizSets = async () => { 30 | const apiData = await getChannels(); 31 | setQuizSetList( 32 | apiData.filter( 33 | (quizset) => quizset._id !== process.env.DEFAULT_CHANNEL_ID 34 | ) 35 | ); 36 | }; 37 | 38 | fetchQuizSets(); 39 | }, []); 40 | 41 | const handleInputChange = ({ target }: { target: HTMLInputElement }) => { 42 | setKeyword(target.value); 43 | }; 44 | 45 | const isContainKeyword = (quizSet: ChannelAPI) => { 46 | const { name, description } = quizSet; 47 | const { tags, creator } = JSON.parse(description); 48 | 49 | const parseValue = (value: string) => 50 | value.replace(/\s/g, '').toLowerCase(); 51 | 52 | const lowerTitle = parseValue(name); 53 | const lowerTags = tags.map((tag: string) => parseValue(tag)); 54 | const lowerUserName = parseValue(creator.fullName); 55 | const lowerKeyword = parseValue(keyword); 56 | 57 | const isFiltered = 58 | lowerTitle.indexOf(lowerKeyword) >= 0 || 59 | lowerTags.includes(lowerKeyword) || 60 | lowerUserName.indexOf(lowerKeyword) >= 0; 61 | return isFiltered; 62 | }; 63 | 64 | const handleQuizClick = (id: string) => { 65 | setRandomQuizCategory(null); 66 | setRandomQuizCount(null); 67 | setChannelId(id); 68 | }; 69 | 70 | const sortBySelect = ( 71 | prev: ChannelAPI, 72 | next: ChannelAPI, 73 | sortValue: string 74 | ) => { 75 | const byNewer = +new Date(next.createdAt) - +new Date(prev?.createdAt); 76 | const byOlder = +new Date(prev.createdAt) - +new Date(next?.createdAt); 77 | 78 | switch (sortValue) { 79 | case 'new': 80 | return byNewer; 81 | case 'old': 82 | return byOlder; 83 | default: 84 | return byNewer; 85 | } 86 | }; 87 | return ( 88 |
89 | 90 | 91 | 95 | 101 | 102 | setRandomQuizCategory(value)} 56 | /> 57 | 의 문제를 58 | handleQuizChange(target.value)} 64 | onFocus={({ target }) => target.setAttribute('placeholder', '')} 65 | onBlur={({ target }) => 66 | target.value === '' && 67 | target.setAttribute('placeholder', '문제수') 68 | } 69 | /> 70 | 만큼 풀게나! 71 | 72 | 보상 | 소정의 경험치 획득 73 | 74 | 78 | 퀘스트 수행하러 79 | Go! 80 | 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default RandomQuiz; 87 | -------------------------------------------------------------------------------- /src/components/Home/RandomQuiz/styles.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @emotion/syntax-preference */ 2 | import styled from '@emotion/styled'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import { 6 | DarkGray, 7 | h1, 8 | h3, 9 | lightGrayWhite, 10 | medium, 11 | pointColor, 12 | small, 13 | } from '@/styles/theme'; 14 | 15 | export const Container = styled.div` 16 | margin-top: 4rem; 17 | margin-bottom: 2rem; 18 | display: flex; 19 | flex-direction: column; 20 | `; 21 | 22 | export const TitleBox = styled.div` 23 | display: flex; 24 | `; 25 | 26 | export const Title = styled.div` 27 | width: 20rem; 28 | height: 4rem; 29 | 30 | color: white; 31 | ${h3}; 32 | text-align: center; 33 | line-height: 4rem; 34 | 35 | background-color: ${DarkGray}; 36 | border: 3px solid ${DarkGray}; 37 | border-radius: 0.5rem 0 0 0; 38 | border-right: none; 39 | `; 40 | export const RoundShapeBox = styled.div` 41 | width: 4rem; 42 | height: 4rem; 43 | 44 | background-color: ${DarkGray}; 45 | border: 3px solid ${DarkGray}; 46 | border-radius: 0 4rem 0 0; 47 | border-left: none; 48 | `; 49 | 50 | export const ContentBox = styled.div` 51 | display: flex; 52 | `; 53 | 54 | export const Content = styled.div` 55 | width: 36rem; 56 | height: 8rem; 57 | padding: 1rem; 58 | 59 | display: flex; 60 | flex-direction: column; 61 | 62 | color: ${DarkGray}; 63 | ${h3}; 64 | 65 | background-color: ${lightGrayWhite}; 66 | border: 3px solid ${DarkGray}; 67 | border-radius: 0 0 0 0.5rem; 68 | border-right: none; 69 | gap: 1rem; 70 | `; 71 | type TextProps = { 72 | type?: string; 73 | }; 74 | export const Text = styled.div` 75 | ${({ type }) => (type === 'small' ? small : medium)}; 76 | `; 77 | 78 | export const BoldText = styled.div` 79 | ${h1}; 80 | color: ${pointColor}; 81 | -webkit-text-stroke: 1px ${DarkGray}; 82 | `; 83 | export const StartBox = styled(Link)` 84 | width: 8rem; 85 | height: 8rem; 86 | 87 | color: ${DarkGray}; 88 | background-color: white; 89 | border: 3px solid ${DarkGray}; 90 | border-radius: 0 1rem 4rem 0; 91 | text-decoration: none; 92 | cursor: pointer; 93 | 94 | display: flex; 95 | flex-direction: column; 96 | justify-content: center; 97 | align-items: center; 98 | `; 99 | 100 | export const Input = styled.input` 101 | width: 5.5rem; 102 | height: 1.75rem; 103 | margin: 0 1rem; 104 | ${small}; 105 | text-align: center; 106 | vertical-align: middle; 107 | 108 | border: none; 109 | outline: none; 110 | background-color: ${lightGrayWhite}; 111 | cursor: pointer; 112 | `; 113 | -------------------------------------------------------------------------------- /src/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @emotion/syntax-preference */ 2 | /* eslint-disable global-require */ 3 | /* eslint-disable @typescript-eslint/no-var-requires */ 4 | import { Buffer } from 'buffer'; 5 | 6 | import styled from '@emotion/styled'; 7 | import { icons } from 'feather-icons'; 8 | 9 | type PropsType = { 10 | name: string; 11 | size?: number; 12 | strokeWidth?: number; 13 | color?: string; 14 | rotate?: number; 15 | fill?: boolean; 16 | addStyle?: { 17 | [x: string]: unknown; 18 | }; 19 | [x: string]: unknown; 20 | }; 21 | 22 | const IconWrapper = styled.i` 23 | display: inline-block; 24 | `; 25 | 26 | const Icon = ({ 27 | name, 28 | size = 16, 29 | strokeWidth = 2, 30 | color = '#222', 31 | rotate = 0, 32 | fill, 33 | addStyle, 34 | ...props 35 | }: PropsType) => { 36 | const iconStyle = { 37 | 'stroke-width': strokeWidth, 38 | stroke: color, 39 | width: size, 40 | height: size, 41 | fill: fill ? color : 'none', 42 | }; 43 | const shapeStyle = { 44 | width: size, 45 | height: size, 46 | transform: rotate ? `rotate(${rotate}deg)` : undefined, 47 | }; 48 | const icon = icons[name]; 49 | const svg = icon ? icon.toSvg(iconStyle) : ''; 50 | const base64 = Buffer.from(svg, 'utf8').toString('base64'); 51 | return ( 52 | 56 | {name} 60 | 61 | ); 62 | }; 63 | 64 | export default Icon; 65 | -------------------------------------------------------------------------------- /src/components/LoginForm/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { Formik, Form } from 'formik'; 3 | 4 | import Button from '@/components/Form/Button'; 5 | import InputBox from '@/components/Form/InputBox'; 6 | import * as S from '@/components/Form/Title/styles'; 7 | import { useAuthContext } from '@/contexts/AuthContext'; 8 | import { validationLogin } from '@/utils/validation'; 9 | 10 | const LoginForm = () => { 11 | const { login } = useAuthContext(); 12 | 13 | return ( 14 | <> 15 | 로그인 16 | { 23 | actions.setSubmitting(false); 24 | actions.resetForm(); 25 | 26 | login(values); 27 | }} 28 | > 29 |
30 | 36 | 42 |