├── .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 | 
21 | 
22 | 
23 | 
24 |
25 | ### 코드 관리
26 |
27 | 
28 | 
29 | 
30 | 
31 |
32 | ### 스타일
33 |
34 | 
35 | 
36 |
37 | ### 커뮤니케이션
38 |
39 | 
40 | 
41 | 
42 | 
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 |
112 | 지식 사냥터
113 |
114 | {quizSetList
115 | .filter(isContainKeyword)
116 | .sort((a, b) => sortBySelect(a, b, sortBy))
117 | .map((quizSet: ChannelAPI, idx) => (
118 | handleQuizClick(quizSet._id)}
122 | >
123 |
127 |
128 | ))}
129 |
130 |
131 | );
132 | };
133 |
134 | export default QuizSetList;
135 |
--------------------------------------------------------------------------------
/src/components/Home/QuizSetList/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 | import { Link } from 'react-router-dom';
4 |
5 | import { DarkGray, grayWhite, h3, small } from '@/styles/theme';
6 |
7 | export const FilterContainer = styled.div`
8 | display: flex;
9 | justify-content: flex-end;
10 | gap: 1rem;
11 | `;
12 |
13 | const FilterWrap = styled.div`
14 | height: 2.5rem;
15 | display: flex;
16 | padding: 0.5rem;
17 | align-items: center;
18 |
19 | border: 3px solid ${DarkGray};
20 | border-radius: 0.5rem;
21 | background-color: ${grayWhite};
22 | `;
23 |
24 | export const SearchWrap = styled(FilterWrap)`
25 | gap: 1rem;
26 | `;
27 | export const SearchInput = styled.input`
28 | width: 11rem;
29 | border: none;
30 | outline: none;
31 | background-color: ${grayWhite};
32 | ${small};
33 | `;
34 |
35 | export const Title = styled.div`
36 | ${h3}
37 | `;
38 |
39 | export const QuizSetListContainer = styled.div`
40 | margin: 2rem 0 4rem;
41 | display: grid;
42 | grid-template-columns: repeat(4, 1fr);
43 | gap: 1.25rem 1.5rem;
44 |
45 | @media all and (max-width: 1200px) {
46 | grid-template-columns: repeat(3, 1fr);
47 | }
48 | @media all and (max-width: 900px) {
49 | grid-template-columns: repeat(2, 1fr);
50 | }
51 | @media all and (max-width: 600px) {
52 | grid-template-columns: repeat(1, 1fr);
53 | }
54 | `;
55 |
56 | export const LinkToSolve = styled(Link)`
57 | text-decoration: none;
58 | color: ${DarkGray};
59 | `;
60 |
--------------------------------------------------------------------------------
/src/components/Home/RandomQuiz/index.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 |
3 | import { useHistory } from 'react-router';
4 |
5 | import Select from '@/components/Form/Select';
6 | import { QUIZ_CATEGORY_LIST } from '@/constants';
7 | import { useQuizContext } from '@/contexts/QuizContext';
8 |
9 | import * as S from './styles';
10 |
11 | const RandomQuiz = () => {
12 | const {
13 | randomQuizCount,
14 | setRandomQuizCount,
15 | setRandomQuizCategory,
16 | setChannelId,
17 | } = useQuizContext();
18 | const history = useHistory();
19 | const SelectStyle = {
20 | width: '7rem',
21 | color: '#555555',
22 | padding: '0',
23 | margin: '0 1rem',
24 | border: 'none',
25 | appearance: 'auto',
26 | backgroundImage: 'none',
27 | backgroundColor: '#E9ECEF',
28 | };
29 |
30 | const handleQuizChange = (value: string) => {
31 | setRandomQuizCount(Number(value));
32 | };
33 |
34 | const handleQuizStart = (e: React.MouseEvent) => {
35 | e.preventDefault();
36 | if (!randomQuizCount) return;
37 | setChannelId(null);
38 | history.push('/solve');
39 | };
40 |
41 | return (
42 |
43 |
44 | 내가 만드는 일일 퀘스트
45 |
46 |
47 |
48 |
49 |
50 | 퀘스트 요약 |
51 |
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 |
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 |
47 |
48 | >
49 | );
50 | };
51 |
52 | export default LoginForm;
53 |
--------------------------------------------------------------------------------
/src/components/Modal/NicknameModal.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | import { useCallback } from 'react';
3 |
4 | import { Form, Formik } from 'formik';
5 |
6 | import { updateFullName } from '@/api/UserServices';
7 | import { validationChangeName } from '@/utils/validation';
8 |
9 | import Button from '../Form/Button';
10 | import InputBox from '../Form/InputBox';
11 |
12 | import * as S from './styles';
13 |
14 | import type { UpdateNameFormData } from '@/interfaces/ChangeFormData';
15 | import type { UserAPI } from '@/interfaces/UserAPI';
16 |
17 | interface Props {
18 | user: UserAPI;
19 | isShown: boolean;
20 | onCloseNickname: () => void;
21 | }
22 |
23 | const NicknameModal = ({ user, isShown, onCloseNickname }: Props) => {
24 | const onSubmitFullName = useCallback(async (formData: UpdateNameFormData) => {
25 | try {
26 | const userInfo = await updateFullName(formData);
27 | if (Object.keys(userInfo).length !== 0) {
28 | // TODO: refresh 보다 나은 방법으로 변경
29 | window.location.reload();
30 | }
31 | } catch (error) {
32 | console.error('error occured at onSubmitFullName.');
33 | }
34 | }, []);
35 | return (
36 | <>
37 | {isShown && (
38 |
39 | {
41 | e.stopPropagation();
42 | }}
43 | >
44 | 닉네임 변경
45 | {
51 | actions.setSubmitting(false);
52 | actions.resetForm();
53 | const formData = {
54 | ...values,
55 | username: user.username ? user.username : '{}',
56 | };
57 | onSubmitFullName(formData);
58 | onCloseNickname();
59 | }}
60 | >
61 |
73 |
74 |
75 |
76 | )}
77 | {isShown && null}
78 | >
79 | );
80 | };
81 |
82 | export default NicknameModal;
83 |
--------------------------------------------------------------------------------
/src/components/Modal/PasswordModal.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | import { useCallback } from 'react';
3 |
4 | import { Form, Formik } from 'formik';
5 |
6 | import { updatePassword } from '@/api/UserServices';
7 | import { validationChangePassword } from '@/utils/validation';
8 |
9 | import Button from '../Form/Button';
10 | import InputBox from '../Form/InputBox';
11 |
12 | import * as S from './styles';
13 |
14 | import type { UpdatePasswordFormData } from '@/interfaces/ChangeFormData';
15 |
16 | interface Props {
17 | isShown: boolean;
18 | onClosePassword: () => void;
19 | }
20 |
21 | const PasswordModal = ({ isShown, onClosePassword }: Props) => {
22 | const onSubmitPassword = useCallback(
23 | async (formData: UpdatePasswordFormData) => {
24 | try {
25 | const response = await updatePassword(formData);
26 | // TODO: 비밀번호가 변경되었습니다 TOAST 현재 변경됨을 알기 어려워서 콘솔 찍어두었습니다.
27 | console.log(response);
28 | } catch (error) {
29 | console.error('error occured at onSubmitFullName.');
30 | }
31 | },
32 | []
33 | );
34 | return (
35 | <>
36 | {isShown && (
37 |
38 | {
40 | e.stopPropagation();
41 | }}
42 | >
43 | 비밀번호 변경
44 | {
51 | actions.setSubmitting(false);
52 | actions.resetForm();
53 | const formData = {
54 | password: values.password,
55 | };
56 | onSubmitPassword(formData);
57 | onClosePassword();
58 | }}
59 | >
60 |
78 |
79 |
80 |
81 | )}
82 | {isShown && null}
83 | >
84 | );
85 | };
86 |
87 | export default PasswordModal;
88 |
--------------------------------------------------------------------------------
/src/components/Modal/QuizModal.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2 | /* eslint-disable @typescript-eslint/no-unsafe-argument */
3 | import { getUserImageByPoints } from '@/utils/getUserImage';
4 |
5 | import * as S from './styles';
6 |
7 | import type { UserQuizType } from '@/interfaces/UserAPI';
8 |
9 | interface Props {
10 | quiz: UserQuizType;
11 | isShown: boolean;
12 | onClose: () => void;
13 | }
14 |
15 | const QuizModal = ({ quiz, isShown, onClose }: Props) => (
16 | <>
17 | {isShown && (
18 |
19 | e.stopPropagation()}>
20 | 퀴즈 자세히 보기
21 |
22 | 질문
23 | {quiz.question}
24 |
25 |
26 |
27 |
28 | 정답 {quiz.answer === 'true' ? 'O' : 'X'}
29 |
30 | {quiz.answerDescription}
31 |
32 |
33 | {quiz.comments.length > 0 && (
34 |
35 | 댓글
36 |
37 | {quiz.comments.map((comment) => (
38 |
39 |
47 |
48 |
49 | {comment.author.fullName}
50 |
51 |
52 | {comment.comment}
53 |
54 |
55 |
56 | ))}
57 |
58 |
59 | )}
60 |
61 |
62 |
66 | X
67 |
68 |
69 |
70 |
71 | )}
72 | {!isShown && null}
73 | >
74 | );
75 |
76 | export default QuizModal;
77 |
--------------------------------------------------------------------------------
/src/components/Modal/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import {
5 | borderRadius,
6 | DarkGray,
7 | detail,
8 | grayWhite,
9 | lightGrayWhite,
10 | medium,
11 | p,
12 | pointColor,
13 | small,
14 | } from '@/styles/theme';
15 |
16 | export const Wrapper = styled.div`
17 | display: flex;
18 | position: fixed;
19 | top: 0;
20 | left: 0;
21 | width: 100%;
22 | height: 100%;
23 | background-color: #1c1c1cc7;
24 | z-index: 10;
25 | `;
26 |
27 | export const Container = styled.div`
28 | width: 40rem;
29 | padding: 3rem;
30 | margin: auto;
31 | position: relative;
32 |
33 | background-color: ${lightGrayWhite};
34 | border-radius: ${borderRadius};
35 | text-align: left;
36 | `;
37 |
38 | export const Title = styled.h3`
39 | padding-bottom: 2rem;
40 | font-size: 1.5rem;
41 | font-weight: bold;
42 | `;
43 |
44 | export const Label = styled.label`
45 | display: block;
46 | padding-bottom: 0.5rem;
47 | `;
48 |
49 | export const TextInput = styled.input`
50 | display: block;
51 | width: 100%;
52 | height: 3.25rem;
53 |
54 | padding: 1rem;
55 | margin-bottom: 0.5rem;
56 |
57 | border-radius: ${borderRadius};
58 | border: 3px solid ${DarkGray};
59 | ${small}
60 | `;
61 |
62 | export const ButtonContainer = styled.div`
63 | display: flex;
64 | justify-content: flex-end;
65 | margin-top: 1rem;
66 | `;
67 |
68 | export const ButtonInput = styled.input`
69 | display: block;
70 | width: 8rem;
71 | height: 2.5rem;
72 |
73 | background-color: ${pointColor};
74 | border-radius: ${borderRadius};
75 | border: 3px solid ${DarkGray};
76 | ${small};
77 | cursor: pointer;
78 | `;
79 | export const CloseButton = styled.button`
80 | position: absolute;
81 | top: 1rem;
82 | right: 1rem;
83 | width: 2.5rem;
84 | height: 2.5rem;
85 | background-color: ${pointColor};
86 | border-radius: ${borderRadius};
87 | border: 3px solid ${DarkGray};
88 | ${medium};
89 | cursor: pointer;
90 | `;
91 | export const SectionContainer = styled.div`
92 | padding: 0.5rem 0;
93 | `;
94 | export const SectionTitle = styled.h4`
95 | font-size: 1rem;
96 | font-weight: bold;
97 | margin-bottom: 0.5rem;
98 | `;
99 |
100 | export const Question = styled.p`
101 | font-family: Pretendard, sans-serif;
102 | ${small}
103 | `;
104 |
105 | export const Answer = styled.div`
106 | padding: 1rem;
107 | background-color: ${grayWhite};
108 | border-radius: 8px;
109 | font-family: Pretendard, sans-serif;
110 | ${detail}
111 | `;
112 | export const CommentItem = styled.div`
113 | display: flex;
114 | `;
115 |
116 | export const UserImage = styled.img`
117 | background-color: #dee2e6;
118 | border-radius: 3px;
119 | padding: 0.5rem;
120 | `;
121 | export const CommentContent = styled.div`
122 | display: flex;
123 | flex-direction: column;
124 | justify-content: center;
125 | padding-left: 1rem;
126 | `;
127 | export const CommentContainer = styled.div`
128 | display: flex;
129 | flex-direction: column;
130 | gap: 1rem;
131 | `;
132 |
133 | export const CommentUsername = styled.span`
134 | ${p}
135 | `;
136 |
137 | export const CommentUsercomment = styled.p`
138 | ${detail}
139 | `;
140 |
--------------------------------------------------------------------------------
/src/components/Notification/Item.tsx:
--------------------------------------------------------------------------------
1 | import * as S from './styles';
2 |
3 | import type { CommentAPI } from '@/interfaces/CommentAPI';
4 | import type { LikeAPI } from '@/interfaces/LikeAPI';
5 | import type { UserAPI } from '@/interfaces/UserAPI';
6 |
7 | interface Props {
8 | author?: UserAPI;
9 | comment?: CommentAPI;
10 | like?: LikeAPI;
11 | }
12 |
13 | const Item = ({ author, comment, like }: Props) =>
14 | author ? (
15 |
16 | {author.fullName}님이
17 | {(comment && ' 댓글을 달았습니다') || (like && ' 좋아요를 눌렀습니다')}
18 |
19 | ) : (
20 | 알림이 없습니다
21 | );
22 |
23 | export default Item;
24 |
--------------------------------------------------------------------------------
/src/components/Notification/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | /* eslint-disable @typescript-eslint/no-unsafe-call */
3 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
4 | /* eslint-disable @typescript-eslint/no-unsafe-argument */
5 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
6 | import { useCallback, useEffect, useState } from 'react';
7 |
8 | import { getNotifications, seenNotifications } from '@/api/notification';
9 | import Item from '@/components/Notification/Item';
10 | import { useAuthContext } from '@/contexts/AuthContext';
11 |
12 | import * as S from './styles';
13 |
14 | import type { NotificationAPI } from '@/interfaces/NotificationAPI';
15 |
16 | const Notification = () => {
17 | const { token } = useAuthContext();
18 |
19 | const [notifications, setNotifications] = useState([]);
20 | const [loading, setLoading] = useState(true);
21 |
22 | const fetchNotifications = useCallback(async () => {
23 | const data = await getNotifications(token);
24 | setNotifications(
25 | data.filter((notification: NotificationAPI) => !notification.seen)
26 | );
27 | // TODO: 로딩 로직 변경 필요
28 | setLoading(false);
29 | }, [token]);
30 |
31 | const markSeenToNotifications = useCallback(async () => {
32 | await seenNotifications(token);
33 | }, [token]);
34 |
35 | useEffect(() => {
36 | fetchNotifications();
37 | }, [fetchNotifications]);
38 |
39 | if (loading) return null;
40 | return (
41 |
42 |
43 | {
45 | markSeenToNotifications();
46 | fetchNotifications();
47 | }}
48 | >
49 | 전체 읽음 표시
50 |
51 |
52 | {notifications.length === 0 ? (
53 |
54 | ) : (
55 | notifications.map((notification: NotificationAPI) => (
56 |
62 | ))
63 | )}
64 |
65 | );
66 | };
67 |
68 | export default Notification;
69 |
--------------------------------------------------------------------------------
/src/components/Notification/styles.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import theme from '@/styles/theme';
5 |
6 | interface ItemProps {
7 | padding?: string;
8 | }
9 |
10 | export const Notification = styled.div`
11 | display: flex;
12 | flex-direction: column;
13 | min-width: 300px;
14 | height: 300px;
15 | overflow: auto;
16 | position: absolute;
17 | top: 3.5rem;
18 | right: 0;
19 | background-color: #fff;
20 | border: 3px solid ${theme.themeColors.primary};
21 | border-radius: 8px;
22 |
23 | &::-webkit-scrollbar {
24 | width: 0;
25 | }
26 | `;
27 |
28 | export const Item = styled.div`
29 | padding: ${({ padding = `1rem 0.7rem` }) => padding};
30 | border-bottom: 1px solid ${theme.textAndBackGroundColor.lightGray};
31 | color: ${theme.textAndBackGroundColor.DarkGray};
32 | font-size: 0.9rem;
33 | `;
34 |
35 | export const Button = styled.button`
36 | width: fit-content;
37 | padding: 0.3rem 0.6rem;
38 | background-color: ${theme.themeColors.primary};
39 | color: #fff;
40 | border: none;
41 | border-radius: 4px;
42 | outline: none;
43 | cursor: pointer;
44 | `;
45 |
--------------------------------------------------------------------------------
/src/components/QuizCreate/QuizCreateForm/QuizCreateForm.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useState, useEffect } from 'react';
3 |
4 | import styled from '@emotion/styled';
5 | import { useHistory } from 'react-router';
6 |
7 | import { createQuiz, createQuizSet } from '@/api/create';
8 | import {
9 | QUIZ_ITEM_DEFAULT_STATE,
10 | QUIZ_SET_DEFAULT_STATE,
11 | } from '@/assets/QuizCreateMockData';
12 | import QuizList from '@/components/QuizCreate/QuizList';
13 | import QuizSetForm from '@/components/QuizCreate/QuizSetForm';
14 | import { useAuthContext } from '@/contexts/AuthContext';
15 | import useValidation from '@/hooks/useValidation';
16 | import { DarkGray, pointColor } from '@/styles/theme';
17 | import { validationQuizCreate } from '@/utils/validation';
18 |
19 | import type { QuizItem, QuizSet } from '@/interfaces/model';
20 |
21 | const QuizForm = () => {
22 | const [quizList, setQuizList] = useState([
23 | QUIZ_ITEM_DEFAULT_STATE,
24 | ]);
25 | const [isSet, toggleSet] = useState(false);
26 | const [quizSet, setQuizSet] = useState(QUIZ_SET_DEFAULT_STATE);
27 | const { user, token } = useAuthContext();
28 | const { errors, handleFormSubmit, reValidate } = useValidation(
29 | validationQuizCreate(),
30 | quizList
31 | );
32 | const history = useHistory();
33 |
34 | useEffect(() => {
35 | reValidate();
36 | }, [quizList, reValidate]);
37 |
38 | const handleQuizSubmit = async () => {
39 | if (isSet) {
40 | const set = await createQuizSet(quizSet, user);
41 |
42 | quizList.forEach(async (quiz) => {
43 | const { _id, ...quizData } = quiz;
44 | await createQuiz(quizData, token, set?._id);
45 | });
46 | } else {
47 | quizList.forEach(async (quiz) => {
48 | const { _id, ...quizData } = quiz;
49 | await createQuiz(quizData, token);
50 | });
51 | }
52 | history.push('/');
53 | };
54 |
55 | return (
56 | handleFormSubmit(e, handleQuizSubmit)}
58 | >
59 |
65 |
70 | 퀴즈 제출
71 |
72 | );
73 | };
74 |
75 | export default QuizForm;
76 |
77 | const FormContainer = styled.form`
78 | width: 100%;
79 | margin-top: 7rem;
80 | `;
81 |
82 | const SubmitButton = styled.button`
83 | position: fixed;
84 | right: 2rem;
85 | bottom: 2rem;
86 |
87 | width: 7.5rem;
88 | height: 3rem;
89 |
90 | border: 3px solid ${DarkGray};
91 | border-radius: 0.5rem;
92 | background-color: ${pointColor};
93 | text-align: center;
94 | font-family: 'MaplestoryOTFBold', sans-serif !important;
95 | cursor: pointer;
96 | `;
97 |
--------------------------------------------------------------------------------
/src/components/QuizCreate/QuizCreateForm/index.ts:
--------------------------------------------------------------------------------
1 | import QuizCreateForm from './QuizCreateForm';
2 |
3 | export default QuizCreateForm;
4 |
--------------------------------------------------------------------------------
/src/components/QuizCreate/QuizList/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2 | import { useEffect, useRef } from 'react';
3 |
4 | import styled from '@emotion/styled';
5 |
6 | import { QUIZ_ITEM_DEFAULT_STATE } from '@/assets/QuizCreateMockData';
7 | import Icon from '@/components/Icon';
8 | import { blackGray, DarkGray, small, white } from '@/styles/theme';
9 |
10 | import QuizItem from '../QuizItem';
11 |
12 | import type { QuizClientContent } from '@/interfaces/Quiz';
13 |
14 | interface QuizListProps {
15 | quizList: QuizClientContent[];
16 | setQuizList: React.Dispatch>;
17 | errors: any;
18 | }
19 | const QuizList = ({ quizList, setQuizList, errors }: QuizListProps) => {
20 | const moveController = useRef(false);
21 |
22 | const handleQuizAdd = () => {
23 | setQuizList([
24 | ...quizList,
25 | {
26 | ...QUIZ_ITEM_DEFAULT_STATE,
27 | _id: Math.max(...quizList.map((quiz) => quiz._id), 0) + 1,
28 | },
29 | ]);
30 | moveController.current = !moveController.current;
31 | };
32 | const handleQuizDelete = (id: number) => () => {
33 | if (quizList.length <= 1) return;
34 | setQuizList(quizList.filter((quiz) => quiz._id !== id));
35 | };
36 | const handleQuizChange = (id: number, key: string, value: unknown) => {
37 | setQuizList(
38 | quizList.map((quiz) =>
39 | quiz._id === id ? { ...quiz, [key]: value } : quiz
40 | )
41 | );
42 | };
43 |
44 | const insertButtonRef = useRef(null);
45 | const scrollToBottom = () => {
46 | insertButtonRef?.current?.scrollIntoView({
47 | behavior: 'smooth',
48 | });
49 | };
50 |
51 | useEffect(() => {
52 | scrollToBottom();
53 | }, []);
54 |
55 | return (
56 |
57 | {quizList.map((quiz, idx) => (
58 |
66 | ))}
67 |
72 |
76 | 퀴즈 추가하기
77 |
78 |
79 | );
80 | };
81 | export default QuizList;
82 |
83 | export const QuizListContainer = styled.section`
84 | width: 100%;
85 | display: flex;
86 | flex-direction: column;
87 | gap: 2rem;
88 | `;
89 |
90 | export const InsertQuizItem = styled.button`
91 | height: 12rem;
92 | display: flex;
93 | justify-content: center;
94 | align-items: center;
95 |
96 | border: 3px dashed ${DarkGray};
97 | border-radius: 0.5rem;
98 | background-color: ${white};
99 |
100 | ${small};
101 | color: ${blackGray};
102 | cursor: pointer;
103 | `;
104 |
--------------------------------------------------------------------------------
/src/components/QuizCreate/QuizSetForm/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { QUIZ_SET_TAG_LIST } from '@/constants';
6 | import { DarkGray, lightGrayWhite, pointColor } from '@/styles/theme';
7 |
8 | import type { QuizSet } from '@/interfaces/model';
9 |
10 | interface SetFormProps {
11 | isSet: boolean;
12 | quizSet: QuizSet;
13 | toggleSet: React.Dispatch>;
14 | setQuizSet: React.Dispatch>;
15 | }
16 | const QuizSetForm = ({
17 | isSet,
18 | quizSet,
19 | toggleSet,
20 | setQuizSet,
21 | }: SetFormProps) => {
22 | const handleSetTagChange = (tag: string) => {
23 | const index = quizSet.tags.indexOf(tag);
24 | if (index < 0) {
25 | setQuizSet({ ...quizSet, tags: [...quizSet.tags, tag] });
26 | } else {
27 | setQuizSet({ ...quizSet, tags: quizSet.tags.filter((t) => t !== tag) });
28 | }
29 | };
30 | return (
31 |
32 | toggleSet(!isSet)}
36 | />
37 | 세트화 여부
38 |
42 | setQuizSet({ ...quizSet, name: target.value })
43 | }
44 | />
45 | {isSet && (
46 |
47 | {QUIZ_SET_TAG_LIST.map((tag) => (
48 |
49 | handleSetTagChange(target.value)}
54 | />
55 | {tag}
56 |
57 | ))}
58 |
66 | )}
67 |
68 | );
69 | };
70 |
71 | export default QuizSetForm;
72 |
73 | export const SetWrapper = styled.section`
74 | margin-bottom: 1rem;
75 | padding: 0.5rem 1.5rem;
76 | border: 3px solid ${DarkGray};
77 | border-radius: 0.5rem;
78 | background-color: white;
79 |
80 | display: flex;
81 | flex-wrap: wrap;
82 | align-items: center;
83 | gap: 1rem;
84 | `;
85 |
86 | export const SetCheckBox = styled.input`
87 | width: 1.5rem;
88 | height: 1.5rem;
89 | border: 3px solid ${DarkGray};
90 | cursor: pointer;
91 | `;
92 |
93 | export const SetNameInput = styled.input`
94 | min-width: 30%;
95 | height: 3rem;
96 | padding: 0.25rem 1rem;
97 | border: 3px solid ${DarkGray};
98 | border-radius: 0.5rem;
99 | background-color: ${({ disabled }) => (disabled ? lightGrayWhite : 'white')};
100 | cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'auto')};
101 | font-family: 'Pretendard';
102 | `;
103 |
104 | export const SetInfoWrpper = styled.div`
105 | flex-basis: 100%;
106 |
107 | display: flex;
108 | flex-wrap: wrap;
109 | gap: 1rem;
110 | `;
111 |
112 | export const SetTag = styled.label`
113 | width: auto;
114 | min-width: 5rem;
115 | height: 3rem;
116 | padding: 0.5rem;
117 |
118 | border: 3px solid ${DarkGray};
119 | border-radius: 0.5rem;
120 | background-color: white;
121 | text-align: center;
122 | line-height: 2rem;
123 | cursor: pointer;
124 | `;
125 | export const SetTagInput = styled.input`
126 | display: none;
127 |
128 | &:checked + ${SetTag} {
129 | background-color: ${pointColor};
130 | }
131 | `;
132 |
133 | export const TextArea = styled.textarea`
134 | flex-basis: 100%;
135 | width: 100%;
136 | height: 7rem;
137 | padding: 1rem;
138 |
139 | border: 3px solid ${DarkGray};
140 | border-radius: 0.5rem;
141 | font-family: 'Pretendard';
142 | resize: none;
143 | `;
144 |
--------------------------------------------------------------------------------
/src/components/QuizCreate/Rate/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import { useState } from 'react';
3 |
4 | import styled from '@emotion/styled';
5 |
6 | import Icon from '@/components/Icon';
7 |
8 | interface RateProps {
9 | count: number;
10 | defaultVal: number;
11 | onChangeStar?: (value: number) => void;
12 | size?: number;
13 | [x: string]: unknown;
14 | }
15 |
16 | const Rate = ({
17 | count,
18 | defaultVal,
19 | onChangeStar,
20 | size = 40,
21 | ...props
22 | }: RateProps) => {
23 | const [currVal, setCurrVal] = useState(defaultVal);
24 |
25 | const handleStarClick = (clickedVal: number) => {
26 | let value = clickedVal;
27 |
28 | if (currVal === clickedVal) {
29 | value -= 1;
30 | setCurrVal(value);
31 | } else {
32 | setCurrVal(clickedVal);
33 | }
34 |
35 | if (onChangeStar) onChangeStar(value);
36 | };
37 | return (
38 |
39 | {Array.from({ length: count }, (_, i) => i + 1).map((starVal) => (
40 | handleStarClick(starVal)}
43 | >
44 | {starVal <= currVal ? (
45 |
50 | ) : (
51 |
55 | )}
56 |
57 | ))}
58 |
59 | );
60 | };
61 |
62 | export default Rate;
63 |
64 | const RateWrapper = styled.div`
65 | display: flex;
66 | `;
67 | const Star = styled.div``;
68 |
--------------------------------------------------------------------------------
/src/components/QuizResult/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import theme from '@/styles/theme';
5 |
6 | interface BoxProps {
7 | flex?: boolean;
8 | border?: boolean;
9 | background?: string;
10 | margin?: string;
11 | padding?: string;
12 | gap?: string;
13 | justify?: 'center' | 'start' | 'between' | 'around' | 'evenly';
14 | align?: 'center' | 'start' | 'end' | 'stretch';
15 | }
16 |
17 | interface StyledButtonProps {
18 | color?: 'point' | 'primary' | 'secondary';
19 | fullWidth?: boolean;
20 | }
21 |
22 | interface StyledSignProps {
23 | reverse?: boolean;
24 | color: 'correct' | 'incorrect' | 'default';
25 | }
26 |
27 | interface TextProps {
28 | color?: string;
29 | }
30 |
31 | export interface StyledQuizResultProps {
32 | correct: boolean;
33 | }
34 |
35 | export const Box = styled.div`
36 | display: ${({ flex }) => (flex ? 'flex' : 'block')};
37 | justify-content: ${({ justify }) => {
38 | if (justify === 'center') return 'center';
39 | if (justify === 'between') return 'space-between';
40 | if (justify === 'around') return 'space-around';
41 | if (justify === 'evenly') return 'space-evenly';
42 | return 'flex-start';
43 | }};
44 | align-items: ${({ align }) => {
45 | if (align === 'center') return 'center';
46 | if (align === 'start') return 'flex-start';
47 | if (align === 'end') return 'flex-end';
48 | return 'stretch';
49 | }};
50 | flex-grow: 1;
51 | gap: ${({ gap }) => gap || 0};
52 | margin: ${({ margin }) => margin || '1rem 0'};
53 | padding: ${({ padding }) => padding || 0};
54 | border: ${({ border }) =>
55 | border ? `3px solid ${theme.themeColors.primary}` : 'none'};
56 | background-color: ${({ background }) => background || 'transparent'};
57 | border-radius: 0.5rem;
58 | `;
59 |
60 | export const Wrapper = styled(Box)`
61 | padding: ${({ padding }) => padding || '0.5rem'};
62 | `;
63 |
64 | export const Container = styled.div`
65 | margin: 0 1rem;
66 | `;
67 |
68 | export const Header = styled.div`
69 | display: flex;
70 | justify-content: space-between;
71 | align-items: center;
72 | height: 4.5rem;
73 | border-bottom: 3px solid #14213d;
74 | transition: border 0.35s ease-in-out;
75 | `;
76 |
77 | export const HeaderLeft = styled.div`
78 | display: flex;
79 | align-items: center;
80 | gap: 1rem;
81 | padding: 1rem 1rem;
82 | `;
83 |
84 | export const HeaderRight = styled.div`
85 | display: flex;
86 | height: 100%;
87 |
88 | button {
89 | flex-shrink: 0;
90 | display: block;
91 | width: 4rem;
92 | height: 100%;
93 | border: none;
94 | background-color: rgba(252, 163, 17, 0.2);
95 | font-weight: bold;
96 | outline: none;
97 | cursor: pointer;
98 |
99 | :last-of-type {
100 | border-top-right-radius: 0.5rem;
101 | }
102 |
103 | :hover {
104 | background-color: rgba(252, 163, 17, 0.3);
105 | }
106 | }
107 | `;
108 |
109 | export const Sign = styled.div`
110 | flex-shrink: 0;
111 | display: flex;
112 | justify-content: center;
113 | align-items: center;
114 | width: 46px;
115 | height: 46px;
116 | border: ${({ reverse }) => (reverse ? `1px solid #14213d` : 'none')};
117 | border-radius: 0.5rem;
118 | background-color: ${({ reverse, color }) => {
119 | if (!reverse) return '#ffffff';
120 | if (color === 'correct') return theme.answerColor.correct;
121 | if (color === 'incorrect') return theme.answerColor.incorrect;
122 | return theme.themeColors.primary;
123 | }};
124 | color: ${({ reverse, color }) => {
125 | if (reverse) return '#ffffff';
126 | if (color === 'correct') return theme.answerColor.correct;
127 | if (color === 'incorrect') return theme.answerColor.incorrect;
128 | return theme.themeColors.primary;
129 | }};
130 | font-size: 2rem;
131 | font-weight: bold;
132 | `;
133 |
134 | export const Text = styled.span`
135 | color: ${({ color }) => color || 'inherit'};
136 | line-height: 1.4;
137 | `;
138 |
139 | export const Comment = styled(Wrapper)`
140 | display: flex;
141 | gap: 0.5rem;
142 | margin: 1rem 0;
143 | `;
144 |
145 | export const UserImage = styled.img`
146 | max-height: 5.5rem;
147 | `;
148 |
149 | export const ImageWrapper = styled.div`
150 | display: flex;
151 | justify-content: center;
152 | align-items: center;
153 | width: 5rem;
154 | height: 5rem;
155 | border: 3px solid;
156 | border-radius: 8px;
157 | box-sizing: border-box;
158 | color: inherit;
159 | background-color: #e9ecef;
160 | `;
161 |
162 | export const CommentCenter = styled.div`
163 | flex: 1;
164 | `;
165 |
166 | export const Description = styled.div`
167 | display: flex;
168 | align-items: center;
169 | gap: 1rem;
170 | margin-top: 1rem;
171 | * {
172 | font-family: Pretendard, sans-serif;
173 | }
174 | white-space: pre-wrap;
175 | `;
176 |
177 | export const Flex = styled.div`
178 | display: flex;
179 | align-items: center;
180 | gap: 0.5rem;
181 | `;
182 |
183 | export const TextInput = styled.input`
184 | flex: 1;
185 | display: block;
186 | padding: 0.5rem;
187 | border: 3px solid #14213d;
188 | border-radius: 0.5rem;
189 | font-size: 1rem;
190 | outline: none;
191 | `;
192 |
193 | export const InputWrapper = styled(Wrapper)`
194 | flex: 1;
195 | margin: 0;
196 | :hover {
197 | border-color: #565656;
198 | }
199 | `;
200 |
201 | export const Input = styled.input`
202 | width: 100%;
203 | border: none;
204 | font-size: 1rem;
205 | outline: none;
206 | background-color: transparent;
207 | `;
208 |
209 | export const Button = styled.button`
210 | padding: 0.5rem;
211 | border: 3px solid #14213d;
212 | border-radius: 0.5rem;
213 | background-color: ${({ color }) => {
214 | if (color === 'point') return '#fca211';
215 | if (color === 'primary') return '#14213d';
216 | return '#e5e5e5';
217 | }};
218 | outline: none;
219 | cursor: pointer;
220 | :hover {
221 | // TODO: 각 color 마다 추가 작업 필요
222 | background-color: #fca211d9;
223 | }
224 |
225 | :disabled {
226 | cursor: default;
227 | }
228 | `;
229 |
--------------------------------------------------------------------------------
/src/components/QuizSolve/Layout.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLAttributes } from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | interface Props extends HTMLAttributes {
6 | backgroundColor?: string;
7 | }
8 |
9 | const Layout = ({ backgroundColor, children, ...props }: Props) => (
10 |
11 | {backgroundColor && }
12 | {children}
13 |
14 | );
15 |
16 | export default Layout;
17 |
18 | const Background = styled.div(
19 | {
20 | position: 'fixed',
21 | top: 0,
22 | left: 0,
23 | width: '100%',
24 | height: '100%',
25 | zIndex: -1,
26 | },
27 | ({ backgroundColor }) => ({
28 | backgroundColor,
29 | })
30 | );
31 |
--------------------------------------------------------------------------------
/src/components/QuizSolve/QuizCarousel.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | import styled from '@emotion/styled';
4 | import Slider from 'react-slick';
5 | import 'slick-carousel/slick/slick-theme.css';
6 | import 'slick-carousel/slick/slick.css';
7 |
8 | import QuizCarouselItem from './QuizCarouselItem';
9 | import SliderButton from './SliderButton';
10 |
11 | import type { Quiz as QuizInterface } from '@/interfaces/Quiz';
12 | import type { Settings } from 'react-slick';
13 |
14 | interface Props {
15 | currentIndex: number;
16 | quizzes: QuizInterface[];
17 | beforeChange: (currentSlide: number, nextSlide: number) => void;
18 | onClickAnswer: (index: number, value: string) => void;
19 | }
20 |
21 | const QuizCarousel = ({
22 | currentIndex,
23 | quizzes,
24 | beforeChange,
25 | onClickAnswer,
26 | }: Props) => {
27 | const sliderRef = useRef(null);
28 |
29 | const settings: Settings = { ...CAROUSEL_SETTINGS, beforeChange };
30 |
31 | return (
32 |
33 | sliderRef.current?.slickPrev()}
37 | />
38 |
39 | {
42 | sliderRef.current = slider;
43 | }}
44 | >
45 | {quizzes.map((quiz, index) => (
46 |
52 | ))}
53 |
54 |
55 | sliderRef.current?.slickNext()}
59 | />
60 |
61 | );
62 | };
63 |
64 | export default QuizCarousel;
65 |
66 | const CAROUSEL_SETTINGS: Settings = {
67 | dots: false,
68 | infinite: false,
69 | speed: 500,
70 | slidesToShow: 1,
71 | slidesToScroll: 1,
72 | centerPadding: '40px',
73 | arrows: false,
74 | };
75 |
76 | const FlexWrapper = styled.div({
77 | display: 'flex',
78 | justifyContent: 'space-between',
79 | });
80 |
81 | const SliderContainer = styled.div`
82 | width: 80%;
83 | `;
84 |
--------------------------------------------------------------------------------
/src/components/QuizSolve/QuizCarouselItem.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import maple from '@/assets/maple.png';
6 |
7 | import type { Quiz as QuizInterface } from '@/interfaces/Quiz';
8 |
9 | interface Props {
10 | quiz: QuizInterface;
11 | index: number;
12 | onChangeUserAnswer: (index: number, value: string) => void;
13 | }
14 |
15 | /**
16 | * @description
17 | * QuizCarousel 내부에서 사용되는 Item입니다.
18 | * ANCHOR - 재사용성이 낮으므로, 다른 유형 퀴즈를 받기 위해 변경 필요
19 | * 이 컴포넌트 자체가 재사용성이 낮기 때문에 더 이상 나누는 것은 불필요
20 | */
21 | const QuizCarouselItem = ({ quiz, index, onChangeUserAnswer }: Props) => {
22 | const [clickedIndex, setClickedIndex] = useState(-1);
23 | const handleClickIndex = (idx: number) => setClickedIndex(idx);
24 | return (
25 |
26 |
27 | Q.
28 | {quiz.question}
29 |
30 |
31 | {OX_KEY_VALUE_PAIR.map(([key, value], idx) => (
32 | {
37 | onChangeUserAnswer(index, value);
38 | handleClickIndex(idx);
39 | }}
40 | >
41 | {key}
42 |
43 | ))}
44 |
45 |
46 | );
47 | };
48 |
49 | export default QuizCarouselItem;
50 |
51 | const OX_KEY_VALUE_PAIR = [
52 | ['O', 'true'],
53 | ['X', 'false'],
54 | ];
55 |
56 | const Container = styled.div({
57 | color: '#14213D',
58 | });
59 |
60 | const Question = styled.div({
61 | display: 'flex',
62 | alignItems: 'center',
63 | gap: '1rem',
64 | height: '15rem',
65 | margin: '0 auto',
66 | padding: '2rem',
67 | border: '3px solid #14213D',
68 | borderRadius: '0.5rem',
69 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
70 | background: `url(${maple}) 98% 100% /6% no-repeat white`,
71 | overflowY: 'auto',
72 | });
73 |
74 | const QuestionSign = styled.div({
75 | display: 'flex',
76 | justifyContent: 'center',
77 | alignItems: 'center',
78 | width: '3.5rem',
79 | height: '3.5rem',
80 | fontSize: '2.5rem',
81 | fontWeight: 600,
82 | color: '#FCA311',
83 | });
84 |
85 | const QuestionTitle = styled.div({
86 | fontSize: '1.25rem',
87 | lineHeight: '1.4',
88 | });
89 |
90 | const FlexContainer = styled.div({
91 | display: 'flex',
92 | justifyContent: 'center',
93 | gap: '2.5rem',
94 | margin: '3rem 0',
95 | });
96 |
97 | interface SelectButtonProps {
98 | selected: boolean;
99 | }
100 |
101 | const SelectButton = styled.button(
102 | {
103 | width: '25%',
104 | padding: '0.5rem 1rem',
105 | border: '3px solid #14213D',
106 | borderRadius: '0.5rem',
107 | fontSize: '1.5rem',
108 | outline: 'none',
109 | cursor: 'pointer',
110 | },
111 | ({ selected }) => ({
112 | backgroundColor: selected ? '#FCA311' : '#ffffff',
113 | })
114 | );
115 |
--------------------------------------------------------------------------------
/src/components/QuizSolve/QuizContentArea.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import QuizCarousel from './QuizCarousel';
6 |
7 | import type { Quiz as QuizInterface } from '@/interfaces/Quiz';
8 |
9 | interface Props {
10 | quizzes: QuizInterface[];
11 | onClickAnswer: (index: number, value: string) => void;
12 | }
13 |
14 | /**
15 | * @description
16 | * QuizSolvePage에서 사용되는 컴포넌트입니다.
17 | * 내부적으로 Carousel을 사용하고 있습니다.
18 | */
19 | const QuizContentArea = ({ quizzes, onClickAnswer }: Props) => {
20 | // ANCHOR - currentIndex는 단순 컴포넌트의 순서를 표시하는 용으로, form 로직에 영향을 미치지 않습니다.
21 | const [currentIndex, setCurrentIndex] = useState(0);
22 |
23 | const beforeChange = (currentSlide: number, nextSlide: number) => {
24 | setCurrentIndex(nextSlide);
25 | };
26 |
27 | return (
28 |
29 |
30 |
31 | {currentIndex + 1} / {quizzes.length}
32 |
33 |
34 |
40 |
41 | );
42 | };
43 |
44 | export default QuizContentArea;
45 |
46 | const FlexWrapper = styled.div({
47 | display: 'flex',
48 | justifyContent: 'center',
49 | });
50 |
51 | const Container = styled.div({
52 | display: 'flex',
53 | flexDirection: 'column',
54 | gap: '3rem',
55 | });
56 |
57 | const CounterBox = styled.div({
58 | display: 'flex',
59 | justifyContent: 'center',
60 | alignItems: 'center',
61 | width: '15rem',
62 | height: '5rem',
63 | backgroundColor: '#ffffff',
64 | border: '3px solid #14213D',
65 | borderRadius: '0.5rem',
66 | fontSize: '3rem',
67 | fontWeight: 600,
68 | userSelect: 'none',
69 | });
70 |
--------------------------------------------------------------------------------
/src/components/QuizSolve/QuizSubmitArea.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonHTMLAttributes } from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | const QuizSubmitArea = ({
6 | ...props
7 | }: ButtonHTMLAttributes) => (
8 |
9 |
13 | 제출
14 |
15 |
16 | );
17 |
18 | export default QuizSubmitArea;
19 |
20 | // TODO - 정의된 style로 변경하기
21 | const SubmitButton = styled.button({
22 | width: '25%',
23 | padding: '0.5rem 1rem',
24 | border: `3px solid #14213D`,
25 | borderRadius: '0.5rem',
26 | backgroundColor: 'royalblue',
27 | fontSize: '1.5rem',
28 | color: '#ffffff',
29 | outline: 'none',
30 | cursor: 'pointer',
31 | userSelect: 'none',
32 | '&:disabled': {
33 | backgroundColor: '#ababab',
34 | color: '#787878',
35 | cursor: 'not-allowed',
36 | },
37 | });
38 |
39 | const FlexWrapper = styled.div({
40 | display: 'flex',
41 | justifyContent: 'center',
42 | gap: '2.5rem',
43 | });
44 |
--------------------------------------------------------------------------------
/src/components/QuizSolve/SliderButton.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonHTMLAttributes } from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import Icon from '../Icon';
6 |
7 | interface Props extends ButtonHTMLAttributes {
8 | variant: 'left' | 'right';
9 | }
10 |
11 | const SliderButton = ({ variant, ...props }: Props) => (
12 |
18 | );
19 |
20 | export default SliderButton;
21 |
22 | const LeftIcon = () => (
23 |
29 | );
30 |
31 | const RightIcon = () => (
32 |
38 | );
39 |
40 | const Button = styled.button({
41 | alignSelf: 'flex-start',
42 | minHeight: '15rem',
43 | padding: '0 1rem',
44 | border: 'none',
45 | outline: 'none',
46 | cursor: 'pointer',
47 | backgroundColor: 'transparent',
48 | visibility: 'visible',
49 | userSelect: 'none',
50 | '&:disabled': {
51 | visibility: 'hidden',
52 | cursor: 'default',
53 | },
54 | });
55 |
--------------------------------------------------------------------------------
/src/components/QuizSolve/index.ts:
--------------------------------------------------------------------------------
1 | import Layout from './Layout';
2 | import QuizContentArea from './QuizContentArea';
3 | import QuizSubmitArea from './QuizSubmitArea';
4 |
5 | export { Layout, QuizContentArea, QuizSubmitArea };
6 |
--------------------------------------------------------------------------------
/src/components/SignUpForm/index.tsx:
--------------------------------------------------------------------------------
1 | import { Formik, Form } from 'formik';
2 |
3 | import Button from '@/components/Form/Button';
4 | import InputBox from '@/components/Form/InputBox';
5 | import * as S from '@/components/Form/Title/styles';
6 | import { useAuthContext } from '@/contexts/AuthContext';
7 | import { validationSignup } from '@/utils/validation';
8 |
9 | const SignUpForm = () => {
10 | const { signUp } = useAuthContext();
11 |
12 | return (
13 | <>
14 | 회원가입
15 | {
24 | actions.setSubmitting(false);
25 | actions.resetForm();
26 |
27 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
28 | signUp(values);
29 | }}
30 | >
31 |
61 |
62 | >
63 | );
64 | };
65 |
66 | export default SignUpForm;
67 |
--------------------------------------------------------------------------------
/src/components/Tag/index.tsx:
--------------------------------------------------------------------------------
1 | import * as S from './style';
2 |
3 | type PropsType = {
4 | colors: string;
5 | text: string;
6 | };
7 |
8 | const Tag = ({ colors, text, ...props }: PropsType) => (
9 |
13 | {text}
14 |
15 | );
16 |
17 | export default Tag;
18 |
--------------------------------------------------------------------------------
/src/components/Tag/style.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | /* eslint-disable import/prefer-default-export */
3 | import styled from '@emotion/styled';
4 |
5 | import {
6 | BLUE,
7 | BROWN,
8 | GREEN,
9 | NOCOMMENTS,
10 | NOLIKES,
11 | PINK,
12 | RED,
13 | YELLOW,
14 | } from '@/common/string';
15 | import {
16 | blackGray,
17 | borderRadius,
18 | borderWidth,
19 | tag0,
20 | tag10,
21 | tag100,
22 | tag1000,
23 | tag10000,
24 | tag50,
25 | tag500,
26 | tag5000,
27 | tag50000,
28 | tagBlue,
29 | tagGreen,
30 | tagLightBrown,
31 | tagNoComments,
32 | tagNoLikes,
33 | tagPink,
34 | tagRed,
35 | tagYellow,
36 | } from '@/styles/theme';
37 |
38 | type TagProps = {
39 | colors: string;
40 | };
41 |
42 | export const TagWrap = styled.div`
43 | font-family: 'MaplestoryOTFLight';
44 | color: ${blackGray};
45 | padding: 0.3125rem;
46 | border: ${borderWidth};
47 | border-radius: ${borderRadius};
48 | background-color: ${({ colors }) => {
49 | if (colors === GREEN) return tagGreen;
50 | if (colors === BLUE) return tagBlue;
51 | if (colors === YELLOW) return tagYellow;
52 | if (colors === RED) return tagRed;
53 | if (colors === PINK) return tagPink;
54 | if (colors === BROWN) return tagLightBrown;
55 | if (colors === '0') return tag0;
56 | if (colors === '10') return tag10;
57 | if (colors === '50') return tag50;
58 | if (colors === '100') return tag100;
59 | if (colors === '500') return tag500;
60 | if (colors === '1000') return tag1000;
61 | if (colors === '5000') return tag5000;
62 | if (colors === '10000') return tag10000;
63 | if (colors === '50000') return tag50000;
64 | if (colors === NOLIKES) return tagNoLikes;
65 | if (colors === NOCOMMENTS) return tagNoComments;
66 | return '#999';
67 | }};
68 |
69 | border-radius: ${borderRadius};
70 | `;
71 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserInfoCard/styles.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { gray } from '@/styles/theme';
4 |
5 | type CardProps = {
6 | width: string;
7 | };
8 |
9 | export const Card = styled.div`
10 | background-color: #f8f9fa;
11 | //TODO: 색 의견 물어보기
12 | color: #212529;
13 | border: 3px solid;
14 | border-radius: 8px;
15 | box-sizing: border-box;
16 | `;
17 |
18 | export const UserCard = styled(Card)`
19 | display: flex;
20 | position: relative;
21 | width: ${(props) => props.width};
22 | height: 14rem;
23 | padding: 1rem;
24 | margin-top: 1rem;
25 | z-index: 1;
26 | `;
27 |
28 | export const Username = styled.h2`
29 | font-size: 1.25rem;
30 | font-family: 'MaplestoryOTFLight', sans-serif !important;
31 | margin-top: 0.5rem;
32 | `;
33 |
34 | export const UserImage = styled.img`
35 | max-height: 5.5rem;
36 | `;
37 |
38 | export const ImageWrapper = styled.div`
39 | display: flex;
40 | justify-content: center;
41 | align-items: center;
42 | width: 6rem;
43 | height: 6rem;
44 | margin-bottom: 0.5rem;
45 | border: 3px solid;
46 | border-radius: 8px;
47 | box-sizing: border-box;
48 | color: inherit;
49 | background-color: #e9ecef;
50 | `;
51 | export const LevelText = styled.h2`
52 | font-size: 1.25rem;
53 | font-family: 'MaplestoryOTFBold', sans-serif !important;
54 | margin-top: 0.5rem;
55 | `;
56 | export const UserBasicContent = styled.div`
57 | display: flex;
58 | flex-direction: column;
59 | justify-content: flex-start;
60 | align-items: center;
61 | margin-right: 1rem;
62 | `;
63 |
64 | export const UserRankContent = styled.div`
65 | flex: 1;
66 | `;
67 |
68 | export const Rank = styled.h3`
69 | font-size: 1.5rem;
70 | font-family: 'MaplestoryOTFBold', sans-serif !important;
71 | border: 3px solid;
72 | padding: 0.5rem;
73 | width: 10rem;
74 | border-radius: 8px;
75 | background-color: white;
76 | `;
77 |
78 | export const ExpWrapper = styled.div`
79 | padding-top: 1rem;
80 | height: 1.25rem;
81 | `;
82 |
83 | export const ExpContainer = styled(Card)`
84 | width: 100%;
85 | height: 1.25rem;
86 | position: relative;
87 | background-color: white;
88 | `;
89 |
90 | export const ExpCurrentContainer = styled(Card)`
91 | width: ${({ percent }: { percent: number }) => `${percent + 1}%`};
92 | height: 1.25rem;
93 | position: absolute;
94 | top: -3px;
95 | left: -3px;
96 | background-color: #ffdc84;
97 | `;
98 |
99 | export const ExpDetail = styled.span`
100 | color: #495057;
101 | position: absolute;
102 | top: -1rem;
103 | right: 0px;
104 | font-family: 'MaplestoryOTFLight', sans-serif !important;
105 | font-size: 0.8rem;
106 | `;
107 |
108 | export const BadgeContent = styled.div`
109 | position: relative;
110 | padding-top: 1rem;
111 | `;
112 |
113 | export const Badge = styled.span`
114 | display: inline-block;
115 | background-color: ${({ color }: { color: string }) => color};
116 | color: #212529;
117 | border: 3px solid;
118 | border-radius: 8px;
119 | box-sizing: border-box;
120 | font-family: 'MaplestoryOTFLight', sans-serif !important;
121 | padding: 0.25rem 1rem;
122 | margin: 0.25rem;
123 | `;
124 |
125 | export const SettingContainer = styled.div`
126 | position: absolute;
127 | top: 1rem;
128 | right: 1rem;
129 | color: ${gray};
130 | `;
131 |
132 | export const SettingButton = styled.span`
133 | cursor: pointer;
134 | `;
135 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserInfoTab/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */
2 | /* eslint-disable @typescript-eslint/no-unsafe-return */
3 | /* eslint-disable @typescript-eslint/no-unsafe-call */
4 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
5 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
6 | import { useEffect, useState } from 'react';
7 |
8 | import { fetchPosts } from '@/api/user';
9 | import QuizModal from '@/components/Modal/QuizModal';
10 |
11 | import TabItem from '../UserInfoTabItem';
12 | import UserQuizItem from '../UserQuizItem';
13 |
14 | import * as S from './styles';
15 |
16 | import type { PostAPIUserInfo } from '@/interfaces/PostAPI';
17 | import type { UserQuizType } from '@/interfaces/UserAPI';
18 |
19 | const UserInfoTab = ({ id }: { id: string }) => {
20 | const [madeQuizzes, setMadeQuizzes] = useState([]);
21 | const [commentedQuizzes, setCommentedQuizzes] = useState([]);
22 | const [likedQuizzes, setLikedQuizzes] = useState([]);
23 | const [allQuizzes, setAllQuizzes] = useState([]);
24 |
25 | const [currentTab, setCurrentTab] = useState(0);
26 |
27 | const [currentQuiz, setCurrentQuiz] = useState({} as UserQuizType);
28 | const [isModalShown, setIsModalShown] = useState(false);
29 |
30 | const tabMapper = [madeQuizzes, commentedQuizzes, likedQuizzes];
31 |
32 | const tabs = [
33 | {
34 | id: 0,
35 | title: '만든 문제',
36 | },
37 | {
38 | id: 1,
39 | title: '댓글 단 문제',
40 | },
41 | {
42 | id: 2,
43 | title: '좋아요 한 문제',
44 | },
45 | ];
46 |
47 | useEffect(() => {
48 | const updateAllQuiz = async () => {
49 | const apiData = await fetchPosts();
50 | const realData = apiData.map((post: PostAPIUserInfo) => ({
51 | id: post._id,
52 | likes: post.likes,
53 | comments: post.comments,
54 | author: post.author,
55 | ...JSON.parse(post.title),
56 | }));
57 | setAllQuizzes(realData);
58 |
59 | const userMadeQuizzes = realData.filter(
60 | (quiz: UserQuizType) => quiz.author._id === id
61 | );
62 |
63 | const userCommentQuizzes = realData.filter((quiz: UserQuizType) =>
64 | quiz.comments.some((comment) => comment.author._id === id)
65 | );
66 | const userLikesQuizzes = realData.filter((quiz: UserQuizType) =>
67 | quiz.likes.some((like) => like.user === id)
68 | );
69 | setMadeQuizzes(userMadeQuizzes);
70 | setCommentedQuizzes(userCommentQuizzes);
71 | setLikedQuizzes(userLikesQuizzes);
72 | };
73 |
74 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
75 | updateAllQuiz();
76 | }, [id]);
77 | const updateSelectedQuiz = (quizId: string) => {
78 | const selectedQuiz = allQuizzes.find(
79 | (quiz: UserQuizType) => quiz.id === quizId
80 | );
81 | if (selectedQuiz) {
82 | setCurrentQuiz(selectedQuiz);
83 | setIsModalShown(true);
84 | }
85 | };
86 |
87 | const updateClickedTab = (tabId: number) => {
88 | setCurrentTab(tabId);
89 | };
90 | return (
91 |
92 | setIsModalShown(false)}
96 | />
97 |
98 |
99 | {tabs.map((tab) => (
100 | {
105 | updateClickedTab(tab.id);
106 | }}
107 | />
108 | ))}
109 |
110 |
111 |
112 |
113 |
114 | {tabMapper[currentTab].map((quiz: UserQuizType) => (
115 | updateSelectedQuiz(quiz.id)}
119 | likeCount={quiz.likes.length}
120 | question={quiz.question}
121 | />
122 | ))}
123 |
124 |
125 |
126 | );
127 | };
128 |
129 | export default UserInfoTab;
130 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserInfoTab/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import { lightGrayWhite, primary } from '@/styles/theme';
5 |
6 | export const TabWrapper = styled.div`
7 | margin-top: 2rem;
8 | width: 100%;
9 | min-width: 20rem;
10 | `;
11 |
12 | export const TabMenus = styled.div`
13 | display: flex;
14 | justify-content: space-between;
15 | `;
16 |
17 | export const TabContent = styled.div`
18 | position: relative;
19 | height: 28rem;
20 | padding: 1rem;
21 | background-color: #f8f9fa;
22 | border: 3px solid #343a40;
23 | border-radius: 8px;
24 | overflow: auto;
25 |
26 | &::-webkit-scrollbar {
27 | width: 0.5rem;
28 | }
29 | &::-webkit-scrollbar-thumb {
30 | background-color: ${primary};
31 | }
32 | &::-webkit-scrollbar-track {
33 | background-color: ${lightGrayWhite};
34 | }
35 | `;
36 |
37 | export const TabItemContainer = styled.div`
38 | position: relative;
39 | top: 0.3rem;
40 | `;
41 |
42 | type TabItemProps = {
43 | selected?: boolean;
44 | };
45 |
46 | export const TabItem = styled.div`
47 | display: inline-flex;
48 | justify-content: center;
49 | align-items: center;
50 | width: 10rem;
51 | height: 3rem;
52 | margin-right: 0.5rem;
53 | border: 3px solid #343a40;
54 | border-radius: 8px;
55 | color: ${({ selected }) => (selected ? '#f8f9fa' : 'black')};
56 | background-color: ${({ selected }) => (selected ? `#14213D` : `white`)};
57 | font-family: 'MaplestoryOTFLight', sans-serif;
58 | cursor: pointer;
59 | &:hover {
60 | background-color: ${({ selected }) => (selected ? '#14213D' : '#E9ECEF')};
61 | }
62 | `;
63 |
64 | export const UserQuizContainer = styled.div`
65 | display: grid;
66 | grid-template-columns: 1fr 1fr;
67 | grid-gap: 1rem;
68 | `;
69 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserInfoTabItem/index.tsx:
--------------------------------------------------------------------------------
1 | import TabItemWrapper from './styles';
2 |
3 | interface TabItemProps {
4 | title: string;
5 | selected: boolean;
6 | handleClick?: (e: React.MouseEvent) => void;
7 | }
8 | const TabItem = ({ title, selected, handleClick }: TabItemProps) => (
9 |
13 | {title}
14 |
15 | );
16 |
17 | export default TabItem;
18 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserInfoTabItem/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | type TabItemProps = {
5 | selected?: boolean;
6 | };
7 |
8 | const TabItemWrapper = styled.button`
9 | display: inline-flex;
10 | justify-content: center;
11 | align-items: center;
12 | width: 10rem;
13 | height: 3rem;
14 | margin-right: 0.5rem;
15 | border: 3px solid #343a40;
16 | border-radius: 8px;
17 | color: ${({ selected }) => (selected ? '#f8f9fa' : 'black')};
18 | background-color: ${({ selected }) => (selected ? `#14213D` : `white`)};
19 | font-family: 'MaplestoryOTFLight', sans-serif;
20 | cursor: pointer;
21 | &:hover {
22 | background-color: ${({ selected }) => (selected ? '#14213D' : '#E9ECEF')};
23 | }
24 | `;
25 |
26 | export default TabItemWrapper;
27 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserQuizItem/index.tsx:
--------------------------------------------------------------------------------
1 | import Icon from '@/components/Icon';
2 |
3 | import * as S from './styles';
4 |
5 | interface QuizItemProps {
6 | question: string;
7 | likeCount: number;
8 | commentCount: number;
9 | handleClick: () => void;
10 | }
11 |
12 | const IconProps = {
13 | name: '',
14 | size: 20,
15 | strokeWidth: 2,
16 | color: '#343A40',
17 | rotate: 0,
18 | };
19 |
20 | const UserQuizItem = ({
21 | question,
22 | likeCount,
23 | commentCount,
24 | handleClick,
25 | }: QuizItemProps) => {
26 | const renderCount = (currentCount: number, maxCount: number) => {
27 | if (currentCount > maxCount) {
28 | return `${maxCount}+`;
29 | }
30 | return `${currentCount}`;
31 | };
32 | return (
33 |
34 |
35 | Q.
36 | {question}
37 |
38 |
39 |
40 |
41 |
45 | {renderCount(likeCount, 100)}
46 |
47 |
48 |
52 | {renderCount(commentCount, 100)}
53 |
54 |
55 |
56 | );
57 | };
58 |
59 | export default UserQuizItem;
60 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserQuizItem/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import { borderRadius, large, primary, small } from '@/styles/theme';
5 |
6 | export const ItemWrapper = styled.div`
7 | display: flex;
8 | align-items: center;
9 | //width: 35rem;
10 | height: 5.5rem;
11 | border: 3px solid ${primary};
12 | border-radius: ${borderRadius};
13 | background-color: white;
14 | overflow: hidden;
15 | cursor: pointer;
16 | transition: all 0.1s;
17 | //TODO: hover 호불호 의견 구해보기
18 | &:hover {
19 | box-shadow: 2px 2px 0px 2px ${primary};
20 | transform: translateY(-5%);
21 | }
22 | `;
23 |
24 | export const ContentWrapper = styled.div`
25 | flex: 1;
26 | display: flex;
27 | padding: 1rem;
28 | height: 4.5rem;
29 | `;
30 | export const QuestionSymbol = styled.span`
31 | ${large};
32 | position: relative;
33 | bottom: 0.25rem;
34 | margin-right: 0.25rem;
35 | `;
36 | export const QuestionText = styled.h3`
37 | font-family: 'Pretendard', sans-serif;
38 | ${small};
39 | display: -webkit-box;
40 | -webkit-line-clamp: 2;
41 | -webkit-box-orient: vertical;
42 | overflow: hidden;
43 | text-overflow: ellipsis;
44 | max-width: 25rem;
45 | `;
46 |
47 | export const CountWrapper = styled.div`
48 | display: flex;
49 | flex-direction: column;
50 | justify-content: space-between;
51 | align-items: center;
52 | width: 5rem;
53 | height: 100%;
54 | padding: 0.8rem;
55 | background-color: #ffdc84;
56 | `;
57 | export const CountItem = styled.div`
58 | display: flex;
59 | justify-content: flex-start;
60 | align-items: flex-start;
61 | gap: 0.25rem;
62 | width: 100%;
63 | color: ${primary};
64 | `;
65 |
--------------------------------------------------------------------------------
/src/components/UserInfo/breakpoints.ts:
--------------------------------------------------------------------------------
1 | export const levelBreakpoints = [
2 | { level: 0, color: '#977C37' },
3 | { level: 10, color: '#5f7161' },
4 | { level: 50, color: '#6D8B74' },
5 | { level: 100, color: '#00FFAB' },
6 | { level: 500, color: '#0D99FF' },
7 | { level: 1000, color: '#FFBEB8' },
8 | { level: 5000, color: '#FAFF00' },
9 | { level: 10000, color: '#F30E5C' },
10 | { level: 50000, color: '#FF1809' },
11 | ];
12 |
13 | export const imageBreakpoints = [
14 | { level: 0, imageId: '100200' },
15 | { level: 10, imageId: '100120' },
16 | { level: 50, imageId: '100121' },
17 | { level: 100, imageId: '100122' },
18 | { level: 500, imageId: '100123' },
19 | { level: 1000, imageId: '100124' },
20 | { level: 5000, imageId: '2510000' },
21 | { level: 10000, imageId: '8600006' },
22 | { level: 50000, imageId: '6400007' },
23 | ];
24 |
25 | export const commentBreakpoints = [
26 | {
27 | count: 0,
28 | color: 'mediumpurple',
29 | text: '묵언수행중',
30 | exact: true,
31 | },
32 | {
33 | count: 10,
34 | color: '#ADE85A',
35 | text: '투머치토커',
36 | exact: false,
37 | },
38 | ];
39 |
40 | export const likeBreakpoints = [
41 | {
42 | count: 0,
43 | color: '#7E7474',
44 | text: '무뚝뚝그자체',
45 | exact: true,
46 | },
47 | {
48 | count: 10,
49 | color: '#FF937E ',
50 | text: '사랑꾼',
51 | exact: false,
52 | },
53 | ];
54 |
55 | export const likeAndCommentBreakpoints = [
56 | {
57 | count: 0,
58 | color: '#999',
59 | text: '혼자가좋아',
60 | exact: true,
61 | },
62 | {
63 | count: 10,
64 | color: '#FF6254',
65 | text: '소통왕',
66 | exact: false,
67 | },
68 | ];
69 |
--------------------------------------------------------------------------------
/src/components/shared/Modal/Modal.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { useModalContext } from './ModalProvider';
6 |
7 | interface Props {
8 | children: React.ReactNode;
9 | }
10 |
11 | const Modal = ({ children }: Props) => {
12 | const { modalState, closeModal } = useModalContext();
13 |
14 | return modalState ? (
15 |
16 | e.stopPropagation()}>{children}
17 |
18 | ) : null;
19 | };
20 |
21 | export default Modal;
22 |
23 | export const Wrapper = styled('div')({
24 | position: 'fixed',
25 | zIndex: '10',
26 | top: '0',
27 | left: '0',
28 | display: 'flex',
29 | width: '100%',
30 | height: '100%',
31 | backgroundColor: '#1c1c1cc7',
32 | });
33 |
34 | export const Container = styled('div')(
35 | {
36 | position: 'relative',
37 | width: '40rem',
38 | padding: '3rem',
39 | margin: 'auto',
40 | textAlign: 'left',
41 | },
42 | ({ theme }) => ({
43 | backgroundColor: theme.textAndBackGroundColor.lightGrayWhite,
44 | borderRadius: theme.borderStyle.borderRadius,
45 | })
46 | );
47 |
--------------------------------------------------------------------------------
/src/components/shared/Modal/ModalProvider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface ModalContextProps {
4 | modalState: boolean;
5 | openModal: () => void;
6 | closeModal: () => void;
7 | }
8 |
9 | const ModalContext = React.createContext({});
10 |
11 | export const useModalContext = () =>
12 | React.useContext(ModalContext) as ModalContextProps;
13 |
14 | interface ModalProviderProps {
15 | children: React.ReactNode;
16 | }
17 |
18 | const ModalProvider = ({ children }: ModalProviderProps) => {
19 | const [modalState, setModalState] = React.useState(false);
20 |
21 | const openModal = () => {
22 | setModalState(true);
23 | };
24 |
25 | const closeModal = () => {
26 | setModalState(false);
27 | };
28 |
29 | const ctx: ModalContextProps = React.useMemo(
30 | () => ({
31 | modalState,
32 | openModal,
33 | closeModal,
34 | }),
35 | [modalState]
36 | );
37 |
38 | return {children};
39 | };
40 |
41 | export default ModalProvider;
42 |
--------------------------------------------------------------------------------
/src/components/shared/Modal/ModalTrigger.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { useModalContext } from './ModalProvider';
6 |
7 | interface Props {
8 | children: React.ReactNode;
9 | }
10 |
11 | const ModalTrigger = ({ children }: Props) => {
12 | const { openModal } = useModalContext();
13 |
14 | return {children};
15 | };
16 |
17 | export default ModalTrigger;
18 |
19 | const Trigger = styled('div')({});
20 |
--------------------------------------------------------------------------------
/src/components/shared/Modal/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Modal } from './Modal';
2 | export { default as ModalProvider } from './ModalProvider';
3 | export { default as ModalTrigger } from './ModalTrigger';
4 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const DIFFICULTY_COUNT = 5;
2 | export const IMPORTANCE_COUNT = 5;
3 |
4 | export const QUIZ_CATEGORY_LIST = [
5 | { label: 'Html', value: 'html' },
6 | { label: 'CSS', value: 'css' },
7 | { label: '자바스크립트', value: 'javascript' },
8 | { label: 'React', value: 'react' },
9 | { label: 'Vue', value: 'vue' },
10 | { label: 'SCSS', value: 'scss' },
11 | { label: 'Web', value: 'web' },
12 | { label: 'CS지식', value: 'cs' },
13 | ];
14 |
15 | export const QUIZ_ANSWER_TYPE_LIST = [
16 | { label: 'O/X 문제', value: 'trueOrFalse', disabled: false },
17 | { label: '객관식 문제', value: 'multipleChoice', disabled: true },
18 | { label: '단답식 문제', value: 'shortAnswer', disabled: true },
19 | ];
20 |
21 | export const QUIZ_SET_TAG_LIST = [
22 | 'React',
23 | 'Vue',
24 | 'HTML',
25 | 'CSS',
26 | 'SCSS',
27 | 'Javascript',
28 | 'Web',
29 | 'CS지식',
30 | ];
31 |
32 | // used in QuizSolve and QuizResults pages.
33 | export const POST_IDS = 'post-ids';
34 | export const USER_ANSWERS = 'user-answers';
35 | export const POINTS = 'points';
36 |
--------------------------------------------------------------------------------
/src/containers/UserRankList/style.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import {
5 | blackGray,
6 | DarkGray,
7 | grayWhite,
8 | large,
9 | lightGrayWhite,
10 | small,
11 | } from '@/styles/theme';
12 |
13 | export const Container = styled.div`
14 | height: ${({ rank }: { rank: number }) => (rank <= 3 ? '10rem' : '8rem')};
15 |
16 | display: flex;
17 | color: ${blackGray};
18 | text-align: center;
19 | align-items: center;
20 |
21 | border: 3px solid ${DarkGray};
22 | border-top: 1px solid ${DarkGray};
23 | border-radius: 0.25rem;
24 | background-color: ${({ rank }: { rank: number }) => {
25 | if (rank === 1) return 'azure';
26 | if (rank === 2) return 'cornsilk';
27 | if (rank === 3) return 'lightgray';
28 | return lightGrayWhite;
29 | }};
30 | cursor: pointer;
31 | `;
32 |
33 | export const Rank = styled.div`
34 | width: 20%;
35 | ${large};
36 | font-size: ${({ rank }: { rank: number }) => (rank <= 3 ? '2rem' : '1.5rem')};
37 | font-family: 'MaplestoryOTFBold';
38 | text-overflow: ellipsis;
39 | white-space: nowrap;
40 | overflow: hidden;
41 | `;
42 |
43 | export const Exp = styled.div`
44 | width: 30%;
45 | ${small};
46 | font-family: Pretendard, sans-serif;
47 | text-overflow: ellipsis;
48 | white-space: nowrap;
49 | overflow: hidden;
50 | `;
51 |
52 | export const UserWrapper = styled.div`
53 | width: 50%;
54 |
55 | display: flex;
56 | align-items: center;
57 | gap: 1rem;
58 | `;
59 |
60 | export const UserProfile = styled.div`
61 | width: 6rem;
62 | height: 6rem;
63 | border: 3px solid
64 | ${({ rank }: { rank: number }) => (rank <= 3 ? 'gold' : 'none')};
65 | border-radius: 50%;
66 | background-color: ${grayWhite};
67 | display: flex;
68 | align-items: center;
69 | `;
70 |
71 | export const UserImg = styled.img`
72 | width: 100%;
73 | height: 100%;
74 | object-fit: contain;
75 | object-position: center;
76 | padding: 1.25rem;
77 | `;
78 |
79 | export const UserInfoWrap = styled.div`
80 | flex-grow: 1;
81 | display: flex;
82 | flex-direction: column;
83 | gap: 1rem;
84 | `;
85 |
86 | export const UserName = styled.div`
87 | display: flex;
88 | justify-content: flex-start;
89 | ${large};
90 | `;
91 |
92 | export const TagsWrap = styled.div`
93 | display: flex;
94 | flex-wrap: wrap;
95 | gap: 0.3125rem;
96 | `;
97 |
--------------------------------------------------------------------------------
/src/contexts/AuthContext/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2 | /* eslint-disable @typescript-eslint/no-unsafe-argument */
3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
4 | import {
5 | createContext,
6 | useCallback,
7 | useContext,
8 | useMemo,
9 | useState,
10 | } from 'react';
11 |
12 | import { v4 } from 'uuid';
13 |
14 | import auth from '@/api/auth';
15 | import { useLocalStorage } from '@/hooks/useStorage';
16 |
17 | import type { LoginFormData } from '@/interfaces/LoginFormData';
18 | import type { SignUpFormData } from '@/interfaces/SignUpFormData';
19 | import type { UserAPI } from '@/interfaces/UserAPI';
20 |
21 | interface AuthContextType {
22 | user: UserAPI;
23 | token: string;
24 | login: (formData: LoginFormData) => Promise;
25 | signUp: (formData: Partial) => Promise;
26 | authUser: () => Promise;
27 | setUser: (value: UserAPI) => void;
28 | isAuth: boolean;
29 | logout: () => Promise;
30 | }
31 |
32 | interface Props {
33 | children: React.ReactNode;
34 | }
35 |
36 | const AuthContext = createContext({});
37 | export const useAuthContext = () => useContext(AuthContext) as AuthContextType;
38 |
39 | const AuthProvider = ({ children }: Props) => {
40 | const [user, setUser, removeUser] = useLocalStorage('user', {});
41 | const [token, setToken, removeToken] = useLocalStorage('token', '');
42 | const [isAuth, setIsAuth] = useState(false);
43 |
44 | const login = useCallback(
45 | async (formData: LoginFormData) => {
46 | try {
47 | const data = await auth.login(formData);
48 | setUser(data.user);
49 | setToken(data.token);
50 | setIsAuth(true);
51 | // TODO: Success Toast
52 | } catch (error) {
53 | setIsAuth(false);
54 | // TODO: Error Toast
55 | }
56 | },
57 | [setUser, setToken]
58 | );
59 |
60 | const signUp = useCallback(
61 | async (formData: SignUpFormData) => {
62 | try {
63 | const data = await auth.signUp({
64 | ...formData,
65 | username: JSON.stringify({ _id: v4(), points: 0 }),
66 | });
67 |
68 | setUser(data.user);
69 | setToken(data.token);
70 | setIsAuth(true);
71 | // TODO: Success Toast
72 | } catch (error) {
73 | setIsAuth(false);
74 | // TODO: Error Toast
75 | }
76 | },
77 | [setUser, setToken]
78 | );
79 |
80 | const authUser = useCallback(async () => {
81 | try {
82 | const authedUser = await auth.getAuthUser(token);
83 | setUser(authedUser);
84 | setIsAuth(true);
85 | } catch (error) {
86 | removeUser();
87 | removeToken();
88 | setIsAuth(false);
89 | }
90 | }, [token, setUser, removeUser, removeToken]);
91 |
92 | const logout = useCallback(async () => {
93 | try {
94 | await auth.logout();
95 | removeUser();
96 | removeToken();
97 | setIsAuth(false);
98 | // TODO: Success Toast
99 | } catch (error) {
100 | // TODO: Error Toast
101 | }
102 | }, [removeUser, removeToken]);
103 |
104 | return (
105 | ({
108 | user,
109 | token,
110 | login,
111 | signUp,
112 | authUser,
113 | setUser,
114 | isAuth,
115 | logout,
116 | }),
117 | [user, token, login, signUp, authUser, setUser, isAuth, logout]
118 | )}
119 | >
120 | {children}
121 |
122 | );
123 | };
124 |
125 | export default AuthProvider;
126 |
--------------------------------------------------------------------------------
/src/contexts/QuizContext/index.tsx:
--------------------------------------------------------------------------------
1 | import type { Dispatch, SetStateAction } from 'react';
2 | import { createContext, useContext, useMemo, useState } from 'react';
3 |
4 | interface Props {
5 | children: React.ReactNode;
6 | }
7 | interface QuizContextType {
8 | channelId: string | null;
9 | randomQuizCount: number | null;
10 | randomQuizCategory: string;
11 | setChannelId: Dispatch>;
12 | setRandomQuizCount: Dispatch>;
13 | setRandomQuizCategory: Dispatch>;
14 | }
15 |
16 | const QuizContext = createContext({});
17 | export const useQuizContext = () => useContext(QuizContext) as QuizContextType;
18 |
19 | const QuizProvider = ({ children }: Props) => {
20 | const [channelId, setChannelId] = useState(null);
21 | const [randomQuizCount, setRandomQuizCount] = useState(null);
22 | const [randomQuizCategory, setRandomQuizCategory] = useState(null);
23 |
24 | const state = useMemo(
25 | () => ({
26 | channelId,
27 | randomQuizCount,
28 | randomQuizCategory,
29 | setChannelId,
30 | setRandomQuizCount,
31 | setRandomQuizCategory,
32 | }),
33 | [channelId, randomQuizCount, randomQuizCategory]
34 | );
35 |
36 | return {children};
37 | };
38 |
39 | export default QuizProvider;
40 |
--------------------------------------------------------------------------------
/src/designs/Option.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import {
3 | BORDER_DASH_STYLE,
4 | BORDER_DOT_STYLE,
5 | BORDER_LINE_STYLE,
6 | BORDER_RADIUS_STYLE,
7 | } from '@/foundations/border';
8 |
9 | type props = {
10 | color?: string;
11 | width?: string;
12 | height?: string;
13 | backgroundColor?: string;
14 | borderType?: string;
15 | show?: boolean;
16 | hover?: string;
17 | };
18 |
19 | const setBorderType = ({ borderType = 'none' }) => {
20 | if (borderType === 'line') {
21 | return BORDER_LINE_STYLE;
22 | }
23 |
24 | if (borderType === 'dash') {
25 | return BORDER_DASH_STYLE;
26 | }
27 |
28 | if (borderType === 'dot') {
29 | return BORDER_DOT_STYLE;
30 | }
31 |
32 | return 'none';
33 | };
34 |
35 | const setDisplay = ({ show = false }) => (show ? 'block' : 'none');
36 |
37 | export const OptionWrap = styled.ul`
38 | position: absolute;
39 | display: ${setDisplay};
40 | top: 0;
41 | left: 0;
42 | width: 100%;
43 | padding: 0.3125rem;
44 | border-radius: ${BORDER_RADIUS_STYLE};
45 | background-color: ${({ backgroundColor = 'inherit' }) => backgroundColor};
46 |
47 | li:hover {
48 | background-color: ${({ hover = 'inherit' }) => hover};
49 | }
50 | `;
51 |
52 | export const Option = styled.li`
53 | margin: 0.3125rem 0;
54 | list-style: none;
55 | padding: 0.3125rem;
56 | color: ${({ color = 'inherit' }) => color};
57 | width: ${({ width = 'auto' }) => width};
58 | height: ${({ height = 'auto' }) => height};
59 | background-color: ${({ backgroundColor = 'transparent' }) => backgroundColor};
60 | border-bottom: ${setBorderType};
61 | `;
62 |
--------------------------------------------------------------------------------
/src/designs/Select.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { BLACK } from '@/foundations/colors';
3 | import { BORDER_RADIUS_STYLE } from '@/foundations/border';
4 |
5 | type props = {
6 | color?: string;
7 | width?: string;
8 | height?: string;
9 | backgroundColor?: string;
10 | };
11 |
12 | export const Select = styled.div`
13 | position: relative;
14 | padding: 0.3125rem;
15 | border-radius: ${BORDER_RADIUS_STYLE};
16 | color: ${({ color = BLACK }) => color};
17 | width: ${({ width = 'auto' }) => width};
18 | height: ${({ height = 'auto' }) => height};
19 | background-color: ${({ backgroundColor = 'transparent' }) => backgroundColor};
20 | display: flex;
21 | justify-content: center;
22 | align-items: center;
23 | cursor: pointer;
24 | `;
25 |
--------------------------------------------------------------------------------
/src/designs/Text.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import {
3 | TEXT_100,
4 | TEXT_125,
5 | TEXT_150,
6 | TEXT_200,
7 | TEXT_250,
8 | TEXT_300,
9 | TEXT_80,
10 | TEXT_90,
11 | WEIGHT_LIGHT,
12 | WEIGHT_MEDIUM,
13 | WEIGHT_REGULAR,
14 | WEIGHT_SEMI_BOLD,
15 | } from '@/foundations/text';
16 | import { BLACK } from '@/foundations/colors';
17 |
18 | type props = {
19 | color?: string;
20 | type?: string;
21 | weight?: string;
22 | };
23 |
24 | const setFontSize = ({ type }: props) => {
25 | if (type === 'large') {
26 | return TEXT_150;
27 | }
28 |
29 | if (type === 'medium') {
30 | return TEXT_125;
31 | }
32 |
33 | if (type === 'small' || type === 'header' || type === 'button') {
34 | return TEXT_100;
35 | }
36 |
37 | if (type === 'detail') {
38 | return TEXT_80;
39 | }
40 |
41 | return TEXT_90;
42 | };
43 |
44 | const setFontWeight = ({ weight }: props) => {
45 | if (weight === 'bold') {
46 | return WEIGHT_SEMI_BOLD;
47 | }
48 |
49 | if (weight === 'medium') {
50 | return WEIGHT_MEDIUM;
51 | }
52 |
53 | if (weight === 'light') {
54 | return WEIGHT_LIGHT;
55 | }
56 |
57 | return WEIGHT_REGULAR;
58 | };
59 |
60 | export const H1 = styled.h1`
61 | color: ${({ color = BLACK }) => color};
62 | font-size: ${TEXT_300};
63 | font-weight: ${WEIGHT_SEMI_BOLD};
64 | `;
65 |
66 | export const H2 = styled.h2`
67 | color: ${({ color = BLACK }) => color};
68 | font-size: ${TEXT_250};
69 | font-weight: ${WEIGHT_SEMI_BOLD};
70 | `;
71 |
72 | export const H3 = styled.h3`
73 | color: ${({ color = BLACK }) => color};
74 | font-size: ${TEXT_200};
75 | font-weight: ${WEIGHT_SEMI_BOLD};
76 | `;
77 |
78 | export const H4 = styled.h4`
79 | color: ${({ color = BLACK }) => color};
80 | font-size: ${TEXT_150};
81 | font-weight: ${WEIGHT_SEMI_BOLD};
82 | `;
83 |
84 | export const H5 = styled.h5`
85 | color: ${({ color = BLACK }) => color};
86 | font-size: ${TEXT_125};
87 | font-weight: ${WEIGHT_SEMI_BOLD};
88 | `;
89 |
90 | export const LargeText = styled.span`
91 | color: ${({ color = BLACK }) => color};
92 | font-size: ${TEXT_150};
93 | font-weight: ${WEIGHT_REGULAR};
94 | `;
95 |
96 | export const MediumText = styled.span`
97 | color: ${({ color = BLACK }) => color};
98 | font-size: ${TEXT_125};
99 | font-weight: ${WEIGHT_REGULAR};
100 | `;
101 |
102 | export const SmallText = styled.span`
103 | color: ${({ color = BLACK }) => color};
104 | font-size: ${TEXT_100};
105 | font-weight: ${WEIGHT_REGULAR};
106 | `;
107 |
108 | export const DetailText = styled.span`
109 | color: ${({ color = BLACK }) => color};
110 | font-size: ${TEXT_80};
111 | font-weight: ${WEIGHT_REGULAR};
112 | `;
113 |
114 | export const BoldText = styled.span`
115 | color: ${({ color = BLACK }) => color};
116 | font-size: ${TEXT_90};
117 | font-weight: ${WEIGHT_SEMI_BOLD};
118 | `;
119 |
120 | export const MediumBoldText = styled.span`
121 | color: ${({ color = BLACK }) => color};
122 | font-size: ${TEXT_90};
123 | font-weight: ${WEIGHT_MEDIUM};
124 | `;
125 |
126 | export const LightText = styled.span`
127 | color: ${({ color = BLACK }) => color};
128 | font-size: ${TEXT_90};
129 | font-weight: ${WEIGHT_LIGHT};
130 | `;
131 |
132 | export const BasicText = styled.span`
133 | color: ${({ color = BLACK }) => color};
134 | font-size: ${TEXT_90};
135 | font-weight: ${WEIGHT_REGULAR};
136 | `;
137 |
138 | export const CustomText = styled.span`
139 | color: ${({ color = BLACK }) => color};
140 | font-size: ${setFontSize};
141 | font-weight: ${setFontWeight};
142 | `;
143 |
--------------------------------------------------------------------------------
/src/emotion.d.ts:
--------------------------------------------------------------------------------
1 | import '@emotion/react';
2 |
3 | declare module '@emotion/react' {
4 | interface ThemeColors {
5 | pointColor: string;
6 | primary: string;
7 | secondary: string;
8 | }
9 |
10 | interface TagColor {
11 | green: string;
12 | blue: string;
13 | yellow: string;
14 | red: string;
15 | lightBrown: string;
16 | pink: string;
17 | 0: string;
18 | 10: string;
19 | 50: string;
20 | 100: string;
21 | 500: string;
22 | 1000: string;
23 | 5000: string;
24 | 10000: string;
25 | 50000: string;
26 | noLikes: string;
27 | noComments: string;
28 | }
29 |
30 | interface TextAndBackGroundColor {
31 | blackGray: string;
32 | DarkGray: string;
33 | gray: string;
34 | lightGray: string;
35 | brightGray: string;
36 | grayWhite: string;
37 | lightGrayWhite: string;
38 | white: string;
39 | }
40 |
41 | interface BorderStyle {
42 | borderWidth: string;
43 | borderRadius: string;
44 | }
45 |
46 | interface Media {
47 | pc: string;
48 | tab: string;
49 | mobile: string;
50 | }
51 |
52 | interface FontStyle {
53 | h1: string;
54 | h2: string;
55 | h3: string;
56 | large: string;
57 | medium: string;
58 | small: string;
59 | p: string;
60 | detail: string;
61 | }
62 |
63 | export interface Theme {
64 | themeColors: ThemeColors;
65 | tagColor: TagColor;
66 | textAndBackGroundColor: TextAndBackGroundColor;
67 | borderStyle: BorderStyle;
68 | media: Media;
69 | fontStyle: FontStyle;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/foundations/border/index.ts:
--------------------------------------------------------------------------------
1 | export const BORDER_LINE_STYLE = '0.1875rem solid';
2 | export const BORDER_DASH_STYLE = '0.1875rem dashed';
3 | export const BORDER_DOT_STYLE = '0.1875rem dotted';
4 | export const BORDER_RADIUS_STYLE = '0.5rem';
5 |
--------------------------------------------------------------------------------
/src/foundations/colors/index.ts:
--------------------------------------------------------------------------------
1 | // Signature Color
2 | export const BRAND_COLOR = '#FCA311';
3 | export const PRIMARY = '#14213D';
4 | export const SECONDARY = '#E5E5E5';
5 |
6 | // Black&White
7 | export const BLACK = '#000';
8 | export const WHITE = '#FFF';
9 |
10 | // GrayScale
11 | export const GRAY_800 = '#212529';
12 | export const GRAY_700 = '#343A40';
13 | export const GRAY_600 = '#495057';
14 | export const GRAY_500 = '#ADB5BD';
15 | export const GRAY_400 = '#CED4DA';
16 | export const GRAY_300 = '#CED4DA';
17 | export const GRAY_200 = '#E9ECEF';
18 | export const GRAY_100 = '#F8F9FA';
19 |
20 | // RedScale
21 | export const RED_800 = '#871936';
22 | export const RED_700 = '#A8283E';
23 | export const RED_600 = '#CE4C4C';
24 | export const RED_500 = '#FF6254';
25 | export const RED_400 = '#F2857A';
26 | export const RED_300 = '#FF937E';
27 | export const RED_200 = '#FCCBBA';
28 | export const RED_100 = '#FDE8DC';
29 |
30 | // YellowScale
31 | export const YELLOW_800 = '#935100';
32 | export const YELLOW_700 = '#B76C00';
33 | export const YELLOW_600 = '#DB8800';
34 | export const YELLOW_500 = '#FFA800';
35 | export const YELLOW_400 = '#FFC43F';
36 | export const YELLOW_300 = '#FED36F';
37 | export const YELLOW_200 = '#FFE699';
38 | export const YELLOW_100 = '#FFF4CC';
39 |
40 | // GreenScale
41 | export const GREEN_800 = '#136463';
42 | export const GREEN_700 = '#1F7C72';
43 | export const GREEN_600 = '#5B9785';
44 | export const GREEN_500 = '#5B9785';
45 | export const GREEN_400 = '#6ACDA5';
46 | export const GREEN_300 = '#8DE6B9';
47 | export const GREEN_200 = '#B7F6D0';
48 | export const GREEN_100 = '#DAFAE4';
49 |
50 | // BlueScale
51 | export const BLUE_800 = '#044193';
52 | export const BLUE_700 = '#075CB7';
53 | export const BLUE_600 = '#0A7BDB';
54 | export const BLUE_500 = '#0F9FFF';
55 | export const BLUE_400 = '#4BC1FF';
56 | export const BLUE_300 = '#6FD6FF';
57 | export const BLUE_200 = '#9FE9FF';
58 | export const BLUE_100 = '#CFF7FF';
59 |
60 | // PurpleScale
61 | export const PURPLE_800 = '#4C1684';
62 | export const PURPLE_700 = '#6B23A4';
63 | export const PURPLE_600 = '#8D33C4';
64 | export const PURPLE_500 = '#B347E4';
65 | export const PURPLE_400 = '#D073EE';
66 | export const PURPLE_300 = '#E490F6';
67 | export const PURPLE_200 = '#F4B6FC';
68 | export const PURPLE_100 = '#FBDAFD';
69 |
--------------------------------------------------------------------------------
/src/foundations/grid/index.ts:
--------------------------------------------------------------------------------
1 | // margin
2 | export const MARGIN_OUTER_WRAPPER = '1.25rem';
3 | export const MARGIN_INNER_WRAPPER = '1.5rem';
4 |
5 | // padding
6 | export const PADDING_OUTER_WRAPPER = '1rem';
7 | export const PADDING_INNER_WRAPPER = '0.625rem';
8 |
9 | // width
10 | export const MAX_WIDTH_OUTER_WRAPPER = '75rem';
11 | export const MIN_WIDTH_OUTER_WRAPPER = '50rem';
12 | export const MAX_WIDTH_CARD = '18.75rem';
13 | export const MIN_WIDTH_CARD = '12.5rem';
14 | export const MIN_WIDTH_BUTTON = '5rem';
15 |
--------------------------------------------------------------------------------
/src/foundations/text/index.ts:
--------------------------------------------------------------------------------
1 | // family
2 | export const FONT_PRETENDARD = 'Pretendard';
3 | export const FONT_MAPLE_LIGHT = 'MaplestoryOTFLight';
4 | export const FONT_MAPLE_BOLD = 'MaplestoryOTFBold';
5 |
6 | // size
7 | export const TEXT_300 = '3rem';
8 | export const TEXT_250 = '2.5rem';
9 | export const TEXT_200 = '2rem';
10 | export const TEXT_150 = '1.5rem';
11 | export const TEXT_125 = '1.25rem';
12 | export const TEXT_100 = '1rem';
13 | export const TEXT_90 = '0.9rem';
14 | export const TEXT_80 = '0.8rem';
15 |
16 | // weight
17 | export const WEIGHT_SEMI_BOLD = 600;
18 | export const WEIGHT_MEDIUM = 500;
19 | export const WEIGHT_REGULAR = 400;
20 | export const WEIGHT_LIGHT = 300;
21 |
22 | // lineheight
23 | export const LINE_HEIGHT_100 = 1;
24 | export const LINE_HEIGHT_120 = 1.2;
25 | export const LINE_HEIGHT_140 = 1.4;
26 | export const LINE_HEIGHT_160 = 1.6;
27 |
28 | // letterspacing
29 | export const LETTER_SPACE_NARROW = '-0.0375rem';
30 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.scss' {
2 | const content: { [className: string]: string };
3 | export default content;
4 | }
5 |
6 | declare module '*.css' {
7 | const content: { [className: string]: string };
8 | export default content;
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/shared/useLoading/index.ts:
--------------------------------------------------------------------------------
1 | import useLoading from './useLoading';
2 |
3 | export default useLoading;
4 |
--------------------------------------------------------------------------------
/src/hooks/shared/useLoading/useLoading.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 |
3 | const useLoading: (
4 | initialValue?: boolean
5 | ) => [boolean, (promise: Promise) => Promise] = (
6 | initialValue = false
7 | ) => {
8 | const [isLoading, setIsLoading] = useState(initialValue);
9 |
10 | const startTransition = useCallback(async (promise: Promise) => {
11 | try {
12 | setIsLoading(true);
13 | const data = await promise;
14 |
15 | return data;
16 | } finally {
17 | setIsLoading(false);
18 | }
19 | }, []);
20 |
21 | return [isLoading, startTransition];
22 | };
23 |
24 | export default useLoading;
25 |
--------------------------------------------------------------------------------
/src/hooks/useInput/index.ts:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useCallback, useState } from 'react';
3 |
4 | type ReturnTypes = [
5 | T,
6 | (e: React.ChangeEvent) => void,
7 | React.Dispatch>
8 | ];
9 |
10 | function useInput(initialValue: T): ReturnTypes {
11 | const [value, setValue] = useState(initialValue);
12 | const handler = useCallback(
13 | (e: React.ChangeEvent) => {
14 | setValue(e.target.value as unknown as T);
15 | },
16 | []
17 | );
18 | return [value, handler, setValue];
19 | }
20 |
21 | export default useInput;
22 |
--------------------------------------------------------------------------------
/src/hooks/useQuiz/index.ts:
--------------------------------------------------------------------------------
1 | import useQuiz from './useQuiz';
2 |
3 | export default useQuiz;
4 |
--------------------------------------------------------------------------------
/src/hooks/useQuiz/useQuiz.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | import { getShuffledQuizzes, getQuizzesFromChannel } from '@/api/QuizServices';
4 |
5 | import type { Quiz as QuizInterface } from '@/interfaces/Quiz';
6 |
7 | const useQuiz = () => {
8 | const [quizzes, setQuizzes] = useState([]);
9 | const [userAnswers, setUserAnswers] = useState([]);
10 |
11 | const handleUserAnswers = useCallback((index: number, value: string) => {
12 | setUserAnswers((prev) =>
13 | prev.map((answer, idx) => (idx === index ? value : answer))
14 | );
15 | }, []);
16 |
17 | const getQuizRandom = useCallback(async (count: number) => {
18 | try {
19 | const randomQuizzes = await getShuffledQuizzes(count);
20 |
21 | setQuizzes(randomQuizzes);
22 | setUserAnswers(Array(randomQuizzes.length).fill(''));
23 | } catch (error) {
24 | console.error('error occurred at getRandomQuizzes.');
25 | console.error(error);
26 | }
27 | }, []);
28 |
29 | const getQuizSet = useCallback(async (channel: string) => {
30 | try {
31 | const quizSet = await getQuizzesFromChannel(channel);
32 |
33 | setQuizzes(quizSet);
34 | setUserAnswers(Array(quizSet.length).fill(''));
35 | } catch (error) {
36 | console.error('error occurred at getSetQuizzes.');
37 | console.error(error);
38 | }
39 | }, []);
40 |
41 | return {
42 | quizzes,
43 | userAnswers,
44 | handleUserAnswers,
45 | getQuizRandom,
46 | getQuizSet,
47 | };
48 | };
49 |
50 | export default useQuiz;
51 |
--------------------------------------------------------------------------------
/src/hooks/useStorage/index.ts:
--------------------------------------------------------------------------------
1 | import useLocalStorage from './useLocalStorage';
2 | import useSessionStorage from './useSessionStorage';
3 |
4 | export { useLocalStorage, useSessionStorage };
5 |
--------------------------------------------------------------------------------
/src/hooks/useStorage/useLocalStorage.ts:
--------------------------------------------------------------------------------
1 | import useStorage, { ReturnTypes } from './useStorage';
2 |
3 | function useLocalStorage(key: string, defaultValue: T): ReturnTypes {
4 | const [value, setItem, removeItem] = useStorage(
5 | key,
6 | defaultValue,
7 | 'localStorage',
8 | );
9 |
10 | return [value, setItem, removeItem];
11 | }
12 |
13 | export default useLocalStorage;
14 |
--------------------------------------------------------------------------------
/src/hooks/useStorage/useSessionStorage.ts:
--------------------------------------------------------------------------------
1 | import useStorage from './useStorage';
2 |
3 | import type { ReturnTypes } from './useStorage';
4 |
5 | function useSessionStorage(key: string, defaultValue: T): ReturnTypes {
6 | const [value, setItem, removeItem] = useStorage(
7 | key,
8 | defaultValue,
9 | 'sessionStorage'
10 | );
11 |
12 | return [value, setItem, removeItem];
13 | }
14 |
15 | export default useSessionStorage;
16 |
--------------------------------------------------------------------------------
/src/hooks/useStorage/useStorage.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef, useState } from 'react';
2 |
3 | export type ReturnTypes = [T, (value: T) => void, () => void];
4 |
5 | type StorageType = 'localStorage' | 'sessionStorage';
6 |
7 | const getStorage = (storageType: StorageType) =>
8 | storageType === 'localStorage' ? localStorage : sessionStorage;
9 |
10 | function useStorage(
11 | key: string,
12 | defaultValue: T,
13 | storageType: StorageType
14 | ): ReturnTypes {
15 | const defaultValueRef = useRef(defaultValue);
16 | const [value, setValue] = useState(() => {
17 | try {
18 | const item = getStorage(storageType).getItem(key);
19 | return item ? (JSON.parse(item) as T) : defaultValueRef.current;
20 | } catch (error) {
21 | console.error(error);
22 | return defaultValueRef.current;
23 | }
24 | });
25 |
26 | const setItem = useCallback(
27 | (newValue: T) => {
28 | try {
29 | setValue(newValue);
30 | getStorage(storageType).setItem(key, JSON.stringify(newValue));
31 | } catch (error) {
32 | console.error(error);
33 | }
34 | },
35 | [key, storageType]
36 | );
37 |
38 | const removeItem = useCallback(() => {
39 | try {
40 | setValue(defaultValueRef.current);
41 | getStorage(storageType).removeItem(key);
42 | } catch (error) {
43 | console.error(error);
44 | }
45 | }, [key, storageType]);
46 |
47 | return [value, setItem, removeItem];
48 | }
49 |
50 | export default useStorage;
51 |
--------------------------------------------------------------------------------
/src/hooks/useValidation/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */
2 | /* eslint-disable @typescript-eslint/no-unsafe-return */
3 | /* eslint-disable @typescript-eslint/no-unsafe-call */
4 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
5 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
6 | import { useState } from 'react';
7 |
8 | const useValidation = (schema: any, formValues: any) => {
9 | const [errors, setErrors] = useState({});
10 | const [isValidating, setIsValidating] = useState(false);
11 |
12 | const handleFormSubmit = (event: React.FormEvent, successCallback: any) => {
13 | event.preventDefault();
14 |
15 | if (!isValidating) setIsValidating(true);
16 |
17 | schema
18 | .validate(formValues, { abortEarly: false })
19 | .then(() => {
20 | setErrors({});
21 | successCallback(formValues);
22 | })
23 | .catch((err: any) => {
24 | if (err.name === 'ValidationError') {
25 | const flatErr = err.inner.flatMap((e: any) => ({
26 | [e.path]: e.errors,
27 | }));
28 | const errs = flatErr.reduce(
29 | (acc: any, cur: any) => ({ ...acc, ...cur }),
30 | {}
31 | );
32 | setErrors(errs);
33 | }
34 | });
35 | };
36 |
37 | const reValidate = () => {
38 | if (!isValidating) return;
39 |
40 | try {
41 | schema.validateSync(formValues, { abortEarly: false });
42 | setErrors({});
43 | // eslint-disable-next-line consistent-return
44 | return true;
45 | } catch (err: any) {
46 | if (err.name === 'ValidationError') {
47 | const flatErr = err.inner.flatMap((e: any) => ({
48 | [e.path]: e.errors,
49 | }));
50 | const errs = flatErr.reduce(
51 | (acc: any, cur: any) => ({ ...acc, ...cur }),
52 | {}
53 | );
54 | setErrors(errs);
55 | }
56 | }
57 | };
58 |
59 | const resetValidation = () => {
60 | setIsValidating(false);
61 | setErrors({});
62 | };
63 |
64 | return {
65 | errors,
66 | reValidate,
67 | handleFormSubmit,
68 | resetValidation,
69 | };
70 | };
71 |
72 | export default useValidation;
73 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 |
3 | import App from '@/App';
4 |
5 | const container = document.getElementById('root');
6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
7 | const root = createRoot(container!);
8 | root.render();
9 |
--------------------------------------------------------------------------------
/src/interfaces/BadgeType.d.ts:
--------------------------------------------------------------------------------
1 | export interface BadgeType {
2 | id: string;
3 | color?: string;
4 | content: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/interfaces/ChangeFormData.d.ts:
--------------------------------------------------------------------------------
1 | export interface UpdateNameFormData {
2 | fullName: string;
3 | username: string;
4 | }
5 |
6 | export interface UpdatePasswordFormData {
7 | password: string;
8 | }
9 |
--------------------------------------------------------------------------------
/src/interfaces/ChannelAPI.d.ts:
--------------------------------------------------------------------------------
1 | export interface ChannelAPI {
2 | _id: string;
3 | name: string;
4 | authRequired: false;
5 | description: string;
6 | posts: string[];
7 | createdAt: string;
8 | updatedAt: string;
9 | }
10 |
--------------------------------------------------------------------------------
/src/interfaces/CommentAPI.d.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-cycle
2 | import { UserAPI } from './UserAPI';
3 |
4 | export interface CommentAPI {
5 | _id: string;
6 | comment: string;
7 | author: UserAPI;
8 | post: string; // 포스트 id
9 | createdAt: string;
10 | updatedAt: string;
11 | }
12 |
--------------------------------------------------------------------------------
/src/interfaces/LikeAPI.d.ts:
--------------------------------------------------------------------------------
1 | export interface LikeAPI {
2 | _id: string;
3 | user: string; // 사용자 id
4 | post: string; // 포스트 id
5 | createdAt: string;
6 | updatedAt: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/interfaces/LoginFormData.d.ts:
--------------------------------------------------------------------------------
1 | export interface LoginFormData {
2 | email: string;
3 | password: string;
4 | }
5 |
--------------------------------------------------------------------------------
/src/interfaces/NotificationAPI.d.ts:
--------------------------------------------------------------------------------
1 | import { CommentAPI } from './CommentAPI';
2 | import { LikeAPI } from './LikeAPI';
3 | import { UserAPI } from './UserAPI';
4 |
5 | export interface NotificationAPI {
6 | seen: boolean;
7 | _id: string;
8 | author: UserAPI;
9 | user: UserAPI | string;
10 | post: Nullable; // 포스트 id
11 | follow: string | undefined; // 사용자 id
12 | comment?: CommentAPI;
13 | like?: LikeAPI;
14 | message: string | undefined; // 메시지 id
15 | createdAt: string;
16 | updatedAt: string;
17 | }
18 |
19 | export interface NotificationPayload {
20 | notificationType: 'COMMENT' | 'LIKE';
21 | notificationTypeId: string;
22 | userId: string;
23 | postId?: string;
24 | }
25 |
--------------------------------------------------------------------------------
/src/interfaces/PostAPI.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-cycle */
2 | import { ChannelAPI } from './ChannelAPI';
3 | import { CommentAPI } from './CommentAPI';
4 | import { LikeAPI } from './LikeAPI';
5 | import { UserAPI } from './UserAPI';
6 | /**
7 | * ANCHOR: title에는 stringify된 QuizContent interface 정보가 들어감
8 | * TODO: Like, Comment, Channel interface 추가
9 | */
10 |
11 | interface PostAPIImage {
12 | image?: string | null;
13 | }
14 |
15 | interface PostAPICustomTitle {
16 | question: string;
17 | des: string; // 해설
18 | tag: string; // 태그
19 | creator: UserAPI;
20 | difficulty: number;
21 | importance: number;
22 | answerType: string; // 문제 유형 설정 (O,X ["t/f"] 객관식 ["numberType"] 단답형 ["stringType"])
23 | answer: string; // "true" | "false"
24 | }
25 |
26 | interface PostAPITitle {
27 | title: string;
28 | }
29 |
30 | interface PostAPIChannelId {
31 | channelId: string;
32 | }
33 |
34 | export interface PostAPIBase {
35 | _id: string;
36 | likes: LikeAPI[];
37 | comments: CommentAPI[];
38 | image?: string | null;
39 | imagePublicId?: string;
40 | channel: ChannelAPI;
41 | author: UserAPI;
42 | createdAt: string;
43 | updatedAt: string;
44 | }
45 |
46 | export interface PostAPICreate
47 | extends PostAPIImage,
48 | PostAPITitle,
49 | PostAPIChannelId {}
50 |
51 | export interface PostAPIUpdate
52 | extends PostAPITitle,
53 | PostAPIImage,
54 | PostAPIChannelId {
55 | postId: string;
56 | imageToDeletePublicId?: string;
57 | }
58 |
59 | export interface PostAPIUserInfo extends PostAPIBase, PostAPITitle {}
60 |
61 | export interface PostAPI extends PostAPIBase, PostAPITitle {}
62 |
--------------------------------------------------------------------------------
/src/interfaces/Quiz.d.ts:
--------------------------------------------------------------------------------
1 | import { PostAPIBase } from './PostAPI';
2 |
3 | export interface QuizContent {
4 | question: string;
5 | answerDescription: string;
6 | category: string;
7 | difficulty: number;
8 | importance: number;
9 | answerType: 'trueOrFalse' | 'multipleChoice' | 'shortAnswer';
10 | answer: string;
11 | }
12 |
13 | export interface QuizClientContent extends QuizContent {
14 | _id: number;
15 | }
16 |
17 | export interface Quiz extends PostAPIBase, QuizContent {}
18 |
19 | export interface QuizShowContent extends QuizContent {
20 | _id: number;
21 | likes: LikeAPI[];
22 | comments: CommentAPI[];
23 | author: UserAPI;
24 | }
25 |
--------------------------------------------------------------------------------
/src/interfaces/Rank.d.ts:
--------------------------------------------------------------------------------
1 | export interface RankSearchProp {
2 | keyword: string;
3 | }
4 |
--------------------------------------------------------------------------------
/src/interfaces/SignUpFormData.d.ts:
--------------------------------------------------------------------------------
1 | export interface SignUpFormData {
2 | email: string;
3 | fullName: string;
4 | password: string;
5 | passwordConfirm: string;
6 | username: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/interfaces/UserAPI.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-cycle */
2 | import type { CommentAPI } from './CommentAPI';
3 | import type { PostAPI } from './PostAPI';
4 |
5 | export interface UserInfo {
6 | _id: string;
7 | points: number;
8 | }
9 |
10 | export interface FollowingType {
11 | _id: string;
12 | user: string;
13 | follower: string;
14 | createdAt: string;
15 | updatedAt: string;
16 | __v: 0;
17 | }
18 |
19 | export interface UserAPI {
20 | coverImage?: string; // 커버 이미지
21 | image?: string;
22 | emailVerified?: boolean; // 사용되지 않음
23 | banned?: boolean; // 사용되지 않음
24 | _id: string;
25 | role: string;
26 | isOnline: boolean;
27 | posts: PostAPI[];
28 | likes: LikeAPI[];
29 | comments: CommentAPI[] | string[];
30 | followers?: string[];
31 | following: FollowingType[];
32 | messages: Message[];
33 | notifications: NotificationAPI[];
34 | fullName: string;
35 | email: string;
36 | createdAt: string;
37 | updatedAt: string;
38 | username?: string;
39 | }
40 |
41 | export interface CustomUserAPI {
42 | id: string;
43 | fullName: string;
44 | posts: PostAPI[];
45 | likes: LikeAPI[];
46 | comments: CommentAPI[] | string[];
47 | totalExp?: number;
48 | }
49 | export interface UserSimpleType {
50 | id: string;
51 | fullName: string;
52 | points: number;
53 | createdAt?: string;
54 | }
55 | export interface UserQuizCategory {
56 | id: string;
57 | category: string;
58 | }
59 |
60 | export interface UserQuizType {
61 | id: string;
62 | question: string;
63 | answer: string;
64 | answerDescription: string;
65 | answerType: 'trueOrFalse' | 'multipleChoice' | 'shortAnswer';
66 | author: UserAPI;
67 | category: string;
68 | difficulty: number;
69 | importance: number;
70 | comments: CommentAPI[];
71 | likes: LikeAPI[];
72 | }
73 |
74 | export interface UserQuizInfo {
75 | _id: string;
76 | points: number;
77 | }
78 |
79 | export interface UserQuizPostAPI {
80 | fullName: string;
81 | username: UserQUizInfo;
82 | }
83 |
--------------------------------------------------------------------------------
/src/interfaces/apiType.d.ts:
--------------------------------------------------------------------------------
1 | export interface TestType {
2 | test: string;
3 | }
4 |
--------------------------------------------------------------------------------
/src/interfaces/model.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | _id: string;
3 | role: string;
4 | isOnline: boolean;
5 | posts: Post[];
6 | likes: Like[];
7 | comments: Comment[] | string[];
8 | notifications: Notification[];
9 | fullName: string;
10 | email: string;
11 | createdAt: string;
12 | updatedAt: string;
13 | username?: string;
14 | }
15 |
16 | export interface Channel {
17 | _id: string;
18 | name: string;
19 | authRequired: false;
20 | posts: string[];
21 | createdAt: string;
22 | updatedAt: string;
23 | description: string; // JSON.stringify(QuizSet)
24 | }
25 |
26 | export interface QuizSet {
27 | name: string;
28 | tags: string[];
29 | des: string;
30 | }
31 |
32 | export interface Post {
33 | _id: string;
34 | likes: Like[];
35 | comments: Comment[];
36 | image?: string | null;
37 | imagePublicId?: string;
38 | channel: Channel;
39 | author: User;
40 | createdAt: string;
41 | updatedAt: string;
42 | title: string; // JSON.stringify(QuizItem)
43 | }
44 |
45 | export interface QuizItem {
46 | _id: number;
47 | question: string;
48 | answerDescription: string;
49 | category: string;
50 | difficulty: number;
51 | importance: number;
52 | answerType: 'trueOrFalse' | 'multipleChoice' | 'shortAnswer';
53 | answer: string;
54 | }
55 |
56 | export interface Like {
57 | _id: string;
58 | user: string; // 사용자 id
59 | post: string; // 포스트 id
60 | createdAt: string;
61 | updatedAt: string;
62 | }
63 |
64 | export interface Comment {
65 | _id: string;
66 | comment: string;
67 | author: User;
68 | post: string; // 포스트 id
69 | createdAt: string;
70 | updatedAt: string;
71 | }
72 |
73 | export interface Notification {
74 | seen: boolean;
75 | _id: string;
76 | author: User;
77 | user: User | string;
78 | post: string | null; // 포스트 id
79 | follow: string | undefined; // 사용자 id
80 | comment?: Comment;
81 | like?: Like;
82 | message: string | undefined; // 메시지 id
83 | createdAt: string;
84 | updatedAt: string;
85 | }
86 |
--------------------------------------------------------------------------------
/src/pages/Children.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | const ChildrenCotainer = styled.div`
5 | padding: 20%;
6 |
7 | //임시보더
8 | border: 1px solid;
9 | `;
10 |
11 | const Children = () => (
12 |
13 | 홈페이지를 레이아웃으로 한 하위 라우팅 페이지 입니다.
14 |
15 | );
16 |
17 | export default Children;
18 |
--------------------------------------------------------------------------------
/src/pages/ErrorPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useHistory } from 'react-router';
2 |
3 | import * as S from './styles';
4 |
5 | const Error = () => {
6 | const history = useHistory();
7 |
8 | return (
9 |
10 | 404 Error!!!
11 |
12 |
16 |
17 | 오류가 발생했습니다
18 | {
21 | history.replace('/');
22 | }}
23 | >
24 | 메인페이지로 가기
25 |
26 |
27 | );
28 | };
29 |
30 | export default Error;
31 |
--------------------------------------------------------------------------------
/src/pages/ErrorPage/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import { borderRadius, h1, h2, large, primary, white } from '@/styles/theme';
5 |
6 | export const ErrorContainer = styled.div`
7 | text-align: center;
8 | display: flex;
9 | flex-direction: column;
10 | justify-content: center;
11 | align-items: center;
12 | position: absolute;
13 | top: 0;
14 | left: 0;
15 | bottom: 0;
16 | right: 0;
17 | /* background-color: rgba(0, 0, 0, 0.7); */
18 | `;
19 |
20 | export const ErrorTitle = styled.h1`
21 | ${h1}
22 | font-weight: semi-bold;
23 | color: red;
24 | `;
25 |
26 | export const ErrorBody = styled.p`
27 | ${h2}
28 | font-weight: regular;
29 | `;
30 |
31 | export const ErrorImgBox = styled.div`
32 | width: 20%;
33 | margin: 3rem 0;
34 |
35 | img {
36 | width: 100%;
37 | }
38 | `;
39 |
40 | export const HomeButton = styled.button`
41 | background-color: ${primary};
42 | color: ${white};
43 | outline: none;
44 | border: none;
45 | border-radius: ${borderRadius};
46 | padding: 20px;
47 | ${large};
48 | margin-top: 30px;
49 | cursor: pointer;
50 | `;
51 |
--------------------------------------------------------------------------------
/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import Header from '@/components/Header';
2 | import QuizSetList from '@/components/Home/QuizSetList';
3 | import RandomQuiz from '@/components/Home/RandomQuiz';
4 |
5 | const Home = () => (
6 | <>
7 |
8 |
9 |
10 | >
11 | );
12 |
13 | export default Home;
14 |
--------------------------------------------------------------------------------
/src/pages/QuizCreatePage/index.tsx:
--------------------------------------------------------------------------------
1 | import Header from '@/components/Header';
2 | import QuizCreateForm from '@/components/QuizCreate/QuizCreateForm';
3 |
4 | const QuizCreatePage = () => (
5 | <>
6 |
7 |
8 | >
9 | );
10 |
11 | export default QuizCreatePage;
12 |
--------------------------------------------------------------------------------
/src/pages/QuizResultPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { Redirect, useHistory } from 'react-router';
4 |
5 | import * as QuizServices from '@/api/QuizServices';
6 | import Header from '@/components/Header';
7 | import UserInfoCard from '@/components/UserInfo/UserInfoCard';
8 | import { POST_IDS, USER_ANSWERS } from '@/constants';
9 | import { useAuthContext } from '@/contexts/AuthContext';
10 | import { useSessionStorage } from '@/hooks/useStorage';
11 | import QuizResult from '@components/QuizResult';
12 |
13 | import * as S from './styles';
14 |
15 | import type { Quiz as QuizInterface } from '@/interfaces/Quiz';
16 |
17 | /**
18 | * ANCHOR: QuizResultPage 로직
19 | * 1. sessionStorage에서 post-ids, user-answers를 불러온다.
20 | * 2. post-ids와 user-answers 개수를 확인하여 일치하지 않았다면 404페이지로 이동한다.
21 | * 3. 댓글을 달 수 있는 input과, 좋아요를 누를 수 있는 like가 각 컴포넌트에 위치하여야 한다.
22 | * 4. random인지, random인지 아닌지 저장해야 한다.
23 | */
24 | const QuizResultPage = () => {
25 | const history = useHistory();
26 | const { user, isAuth } = useAuthContext();
27 | const [quizzes, setQuizzes] = useState([]);
28 | const [postIds] = useSessionStorage(POST_IDS, []);
29 | const [userAnswers] = useSessionStorage(USER_ANSWERS, []);
30 | const [loading, setLoading] = useState(true);
31 |
32 | // TODO: 나중에 책임 분리 가능
33 | const isAppropriateAccess = () => {
34 | if (!quizzes.length) return false;
35 | if (quizzes.length !== userAnswers.filter((answer) => answer).length)
36 | return false;
37 | return true;
38 | };
39 |
40 | useEffect(() => {
41 | QuizServices.getQuizzesFromPostIds(postIds)
42 | .then((quizArray) => setQuizzes(quizArray))
43 | .finally(() => setLoading(false));
44 | }, [history, postIds, userAnswers.length]);
45 |
46 | if (loading) return null;
47 | if (!isAppropriateAccess()) {
48 | return ;
49 | }
50 | return (
51 | <>
52 |
53 | {isAuth ? (
54 |
58 | ) : null}
59 |
60 | {quizzes.map((quiz, index) => (
61 |
66 | ))}
67 |
68 | 다른 문제 풀러가기
69 | 랭킹 보기
70 | 퀴즈 만들러 가기
71 |
72 |
73 | >
74 | );
75 | };
76 |
77 | export default QuizResultPage;
78 |
--------------------------------------------------------------------------------
/src/pages/QuizResultPage/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 | import { Link } from 'react-router-dom';
4 |
5 | import theme from '@/styles/theme';
6 |
7 | export interface StyledLinkedButtonProps {
8 | color?: 'point' | 'primary' | 'secondary';
9 | fill?: 'true' | 'false';
10 | fullWidth?: 'true' | 'false';
11 | round?: 'true' | 'false';
12 | padding?: string;
13 | }
14 |
15 | export const LinkButton = styled(Link)`
16 | display: ${({ fullWidth }) => (fullWidth ? 'block' : 'inline-block')};
17 | width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
18 | padding: ${({ padding }) => padding || `0.5rem 1rem`};
19 | border: none;
20 | border-radius: ${({ round }) => (round === 'true' ? '0.5rem' : 0)};
21 | background-color: ${({ color, fill }) => {
22 | if (!fill) return '#e5e5e5';
23 | if (color === 'point') return '#fca211';
24 | if (color === 'primary') return '#14213d';
25 | return '#e5e5e5';
26 | }};
27 | color: ${({ color, fill }) => {
28 | if (fill) return '#e5e5e5';
29 | if (color === 'point') return '#fca211';
30 | if (color === 'primary') return '#14213d';
31 | return '#e5e5e5';
32 | }};
33 | font-size: 1rem;
34 | text-decoration: none;
35 | outline: none;
36 | cursor: pointer;
37 | `;
38 |
39 | export const QuizResultPage = styled.div`
40 | * {
41 | font-family: 'MaplestoryOTFLight';
42 | }
43 | `;
44 |
45 | export const FooterButtonWrapper = styled.div`
46 | display: flex;
47 | justify-content: center;
48 | margin: 2rem auto;
49 |
50 | a {
51 | padding: 1rem;
52 | min-width: 10rem;
53 | text-align: center;
54 | color: ${theme.themeColors.primary};
55 | transition: all 0.2s ease;
56 |
57 | :first-of-type {
58 | border-top-left-radius: 0.5rem;
59 | border-bottom-left-radius: 0.5rem;
60 | }
61 |
62 | :last-of-type {
63 | border-top-right-radius: 0.5rem;
64 | border-bottom-right-radius: 0.5rem;
65 | }
66 |
67 | :hover {
68 | background-color: ${theme.themeColors.pointColor};
69 | }
70 | }
71 | `;
72 |
--------------------------------------------------------------------------------
/src/pages/QuizSolvePage/QuizSolvePage.helper.ts:
--------------------------------------------------------------------------------
1 | import { v4 } from 'uuid';
2 |
3 | import { updateTotalPoint as updateUserScore } from '@/api/UserServices';
4 | import { POINTS } from '@/constants';
5 |
6 | import type { UserAPI, UserQuizInfo } from '@/interfaces/UserAPI';
7 |
8 | /**
9 | * @description
10 | * userQuizInfo를 반환하는 QuizSolvePage의 helper 함수입니다.
11 | * @param user UserAPI 인터페이스를 갖는 유저 정보
12 | * @param point 이번 퀴즈를 해결해서 얻은 점수
13 | * @returns UserQuizInfo 객체
14 | */
15 | export const getUserScore = (user: UserAPI, point: number) => {
16 | // 유저가 처음 문제를 해결하면 정보가 없기 때문에, 이를 반환하는 객체가 필요하다.
17 | const newInfo: UserQuizInfo = {
18 | _id: v4(),
19 | points: point,
20 | };
21 |
22 | if (user.username) {
23 | const prevUserInfo = JSON.parse(user.username) as Partial;
24 |
25 | if (prevUserInfo._id) newInfo._id = prevUserInfo._id;
26 |
27 | if (prevUserInfo.points) newInfo.points = point + prevUserInfo.points;
28 | }
29 |
30 | return newInfo;
31 | };
32 |
33 | // totalPoint를 받아와 이를 서버에 반영한다.
34 | export const updateUserPoint = async (user: UserAPI, totalPoint: number) => {
35 | try {
36 | sessionStorage.setItem(POINTS, JSON.stringify(totalPoint));
37 |
38 | // user 정보 업데이트
39 | const newUserInfo = await updateUserScore({
40 | fullName: user.fullName,
41 | username: getUserScore(user, totalPoint),
42 | });
43 |
44 | return newUserInfo;
45 | } catch {
46 | throw new Error('error occurred at updateUserPoint.');
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/src/pages/QuizSolvePage/QuizSolvePage.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useEffect } from 'react';
3 |
4 | import styled from '@emotion/styled';
5 | import { Redirect, useHistory } from 'react-router';
6 |
7 | import * as QuizServices from '@api/QuizServices';
8 | import { Layout, QuizContentArea, QuizSubmitArea } from '@components/QuizSolve';
9 | import { POINTS, POST_IDS, USER_ANSWERS } from '@constants/.';
10 | import { useAuthContext } from '@contexts/AuthContext';
11 | import { useQuizContext } from '@contexts/QuizContext';
12 | import useLoading from '@hooks/shared/useLoading';
13 | import useQuiz from '@hooks/useQuiz';
14 |
15 | import { updateUserPoint } from './QuizSolvePage.helper';
16 |
17 | const QuizSolvePage = () => {
18 | const history = useHistory();
19 | const { user, setUser, isAuth } = useAuthContext();
20 | const { channelId, randomQuizCount, setChannelId, setRandomQuizCount } =
21 | useQuizContext();
22 |
23 | const { quizzes, userAnswers, handleUserAnswers, getQuizSet, getQuizRandom } =
24 | useQuiz();
25 |
26 | const [isLoading, startTransition] = useLoading(true);
27 |
28 | const validate = () => {
29 | if (quizzes.length !== userAnswers.filter((answer) => answer).length)
30 | throw new Error('모든 정답을 선택해 주세요!');
31 | };
32 |
33 | // form event 발생 시 호출될 함수
34 | // 1) validation - 모든 퀴즈 정답을 선택했는지 확인
35 | // 2) 모든 정답을 선택하면 유저가 선택한 정답과 퀴즈 id 배열을 세션 스토리지에 저장
36 | // 3) 로그인 했다면, 점수를 계산해서 반영
37 | // 4) context 정보 초기화
38 | // 5) go to result page
39 | const handleSubmit = async (e: React.FormEvent) => {
40 | e.preventDefault();
41 |
42 | try {
43 | validate();
44 |
45 | // 모든 정답을 선택하면 유저가 선택한 정답과 퀴즈 id 배열을 세션 스토리지에 저장
46 | sessionStorage.setItem(USER_ANSWERS, JSON.stringify(userAnswers));
47 | sessionStorage.setItem(
48 | POST_IDS,
49 | JSON.stringify(quizzes.map((quiz) => quiz._id))
50 | );
51 | } catch (error) {
52 | alert(error);
53 | return;
54 | }
55 |
56 | // 점수 계산
57 | const totalPoint = QuizServices.caculateScore(quizzes, userAnswers);
58 |
59 | // 로그인했다면, 사용자의 점수를 반영
60 | if (isAuth) {
61 | try {
62 | const newUserInfo = await updateUserPoint(user, totalPoint);
63 | setUser(newUserInfo);
64 | } catch (error) {
65 | console.error(error);
66 | }
67 | }
68 |
69 | // context 초기화
70 | setRandomQuizCount(null);
71 | setChannelId(null);
72 |
73 | // go to result page
74 | history.push('/result');
75 | };
76 |
77 | useEffect(() => {
78 | // initialize
79 | sessionStorage.removeItem(POST_IDS);
80 | sessionStorage.removeItem(USER_ANSWERS);
81 | sessionStorage.removeItem(POINTS);
82 | }, []);
83 |
84 | useEffect(() => {
85 | startTransition(
86 | (async () => {
87 | try {
88 | if (randomQuizCount && randomQuizCount > 0) {
89 | await getQuizRandom(randomQuizCount);
90 | } else if (channelId) {
91 | await getQuizSet(channelId);
92 | }
93 | } catch (error) {
94 | console.error('error occurred at QuizSolvePage.');
95 | console.error(error);
96 | }
97 | })()
98 | );
99 | }, [channelId, getQuizRandom, getQuizSet, randomQuizCount, startTransition]);
100 |
101 | const disabled =
102 | userAnswers.filter((answer) => answer).length < quizzes.length;
103 |
104 | if (isLoading) return null;
105 | if (!(channelId || randomQuizCount)) {
106 | return ;
107 | }
108 | return (
109 |
110 |
119 |
120 | );
121 | };
122 |
123 | export default QuizSolvePage;
124 |
125 | const Container = styled.div({
126 | display: 'flex',
127 | flexDirection: 'column',
128 | gap: '3rem',
129 | marginTop: '5rem',
130 | });
131 |
--------------------------------------------------------------------------------
/src/pages/QuizSolvePage/index.ts:
--------------------------------------------------------------------------------
1 | import QuizSolvePage from './QuizSolvePage';
2 |
3 | export default QuizSolvePage;
4 |
--------------------------------------------------------------------------------
/src/pages/RankingPage/index.tsx:
--------------------------------------------------------------------------------
1 | import type { ChangeEvent } from 'react';
2 | import { useState } from 'react';
3 |
4 | import Header from '@/components/Header';
5 | import Icon from '@/components/Icon';
6 | import UserRankList from '@/containers/UserRankList';
7 |
8 | import * as S from './style';
9 |
10 | const Ranking = () => {
11 | const iconProps = {
12 | name: 'search',
13 | size: 20,
14 | strokeWidth: 3,
15 | color: '#222',
16 | rotate: 0,
17 | };
18 |
19 | const [keyword, setKeyword] = useState('');
20 |
21 | const changeKeyword = (e: ChangeEvent) => {
22 | const { value = '' } = e.target;
23 | setKeyword(value);
24 | };
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 |
32 |
37 |
38 |
39 |
40 |
41 | 순위
42 | 경험치
43 | 유저정보
44 |
45 |
46 |
47 | >
48 | );
49 | };
50 |
51 | export default Ranking;
52 |
--------------------------------------------------------------------------------
/src/pages/RankingPage/style.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import {
5 | DarkGray,
6 | grayWhite,
7 | large,
8 | primary,
9 | small,
10 | white,
11 | } from '@/styles/theme';
12 |
13 | export const Wrap = styled.div`
14 | border: none;
15 | border-radius: 0.5rem;
16 | `;
17 |
18 | export const Container = styled.div`
19 | height: 3rem;
20 | font-family: Pretendard, sans-serif;
21 | ${large};
22 | color: ${white};
23 |
24 | border-radius: 0.5rem 0.5rem 0.25rem 0.25rem;
25 | background-color: ${primary};
26 | display: flex;
27 | text-align: center;
28 | padding: 0.75rem 0;
29 | `;
30 |
31 | export const Rank = styled.div`
32 | width: 20%;
33 | justify-content: center;
34 | `;
35 |
36 | export const Exp = styled.div`
37 | width: 30%;
38 | justify-content: space-around;
39 | `;
40 |
41 | export const UserInfoWrap = styled.div`
42 | width: 50%;
43 | justify-content: space-around;
44 | `;
45 |
46 | export const SearchContainer = styled.div`
47 | margin: 1.25rem 0;
48 | display: flex;
49 | align-items: center;
50 | justify-content: flex-end;
51 | `;
52 |
53 | export const SearchWrap = styled.span`
54 | height: 2.5rem;
55 | display: flex;
56 | padding: 0.5rem;
57 | align-items: center;
58 |
59 | border: 3px solid ${DarkGray};
60 | border-radius: 0.5rem;
61 | background-color: ${grayWhite};
62 |
63 | gap: 1rem;
64 | `;
65 |
66 | export const SearchInput = styled.input`
67 | width: 11rem;
68 | border: none;
69 | outline: none;
70 | background-color: ${grayWhite};
71 | ${small};
72 | cursor: pointer;
73 |
74 | background-color: transparent;
75 | `;
76 |
--------------------------------------------------------------------------------
/src/pages/UserInfoPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { useParams } from 'react-router';
4 |
5 | import { fetchUserList } from '@/api/user';
6 | import Header from '@/components/Header';
7 | import NicknameModal from '@/components/Modal/NicknameModal';
8 | import PasswordModal from '@/components/Modal/PasswordModal';
9 | import UserInfoCard from '@/components/UserInfo/UserInfoCard';
10 | import UserInfoTab from '@/components/UserInfo/UserInfoTab';
11 | import { useAuthContext } from '@/contexts/AuthContext';
12 |
13 | import * as S from './styles';
14 |
15 | interface Props {
16 | userId: string;
17 | }
18 | const UserInfo = () => {
19 | const [isExistUser, setIsExistUser] = useState(false);
20 | const [id, setId] = useState('');
21 | const [loading, isLoading] = useState(true);
22 |
23 | const [isNameModalShown, setNameModalShown] = useState(false);
24 | const [isPwModalShown, setPwModalShown] = useState(false);
25 |
26 | const { user } = useAuthContext();
27 | const params = useParams();
28 | useEffect(() => {
29 | const setValidId = async (urlId: string) => {
30 | const apiData = await fetchUserList();
31 | const idList = apiData.map((userItem) => userItem._id);
32 | const isValid = idList.some((item) => item === urlId);
33 | setIsExistUser(isValid);
34 | if (isValid) {
35 | setId(urlId);
36 | }
37 | };
38 |
39 | const { userId } = params;
40 | setValidId(userId).finally(() => isLoading(false));
41 | }, [params]);
42 |
43 | return (
44 |
45 |
46 |
{
50 | setNameModalShown(false);
51 | }}
52 | />
53 | {
56 | setPwModalShown(false);
57 | }}
58 | />
59 |
60 | {!loading && (
61 | <>
62 | {!isExistUser && (
63 | 해당 유저는 존재하지 않습니다.
64 | )}
65 | {id && (
66 |
67 |
68 | {user._id === id && (
69 |
70 | {
73 | setNameModalShown(true);
74 | }}
75 | >
76 | 닉네임 변경
77 |
78 | {
81 | setPwModalShown(true);
82 | }}
83 | >
84 | 비밀번호 변경
85 |
86 |
87 | )}
88 |
89 | )}
90 |
91 | {id && }
92 | >
93 | )}
94 |
95 | );
96 | };
97 | export default UserInfo;
98 |
--------------------------------------------------------------------------------
/src/pages/UserInfoPage/styles.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import styled from '@emotion/styled';
3 |
4 | import { borderRadius, DarkGray, h2 } from '@/styles/theme';
5 |
6 | export const notFoundText = styled.h2`
7 | /* display: flex; */
8 | justify-content: center;
9 | align-items: center;
10 | margin: 0 auto;
11 | width: 80%;
12 | height: 35rem;
13 | ${h2}
14 | `;
15 |
16 | export const CardWrapper = styled.div`
17 | display: flex;
18 | justify-content: flex-start;
19 | align-items: center;
20 | `;
21 |
22 | export const SettingDiv = styled.div`
23 | float: right;
24 | `;
25 |
26 | export const SettingButton = styled.button`
27 | display: block;
28 | position: relative;
29 | right: 1.5rem;
30 | margin: 1rem 0;
31 | width: 8rem;
32 | height: 2.5rem;
33 | border: 3px solid ${DarkGray};
34 | border-radius: ${borderRadius};
35 | transform: perspective(300px) rotateY(-25deg);
36 | background-color: #56caa7;
37 | cursor: pointer;
38 | &:nth-of-type(1) {
39 | transform: rotateY(-20deg);
40 | background-color: #ffd756;
41 | right: 0.9rem;
42 | }
43 | &:hover {
44 | filter: brightness(110%);
45 | }
46 | `;
47 |
--------------------------------------------------------------------------------
/src/routes/AuthRoute.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { Route } from 'react-router';
4 |
5 | import { useAuthContext } from '@/contexts/AuthContext';
6 |
7 | import PrivateRoute from './PrivateRoute';
8 |
9 | import type { RouteProps } from 'react-router';
10 |
11 | interface Props extends RouteProps {
12 | mode?: 'private' | 'public';
13 | }
14 |
15 | const AuthRoute = ({ mode, ...props }: Props) => {
16 | const { authUser } = useAuthContext();
17 |
18 | const [loading, setLoading] = useState(true);
19 |
20 | useEffect(() => {
21 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
22 | (async () => {
23 | await authUser();
24 | setLoading(false);
25 | })();
26 | }, [authUser]);
27 |
28 | if (loading) return null;
29 | return mode === 'private' ? (
30 |
31 | ) : (
32 |
33 | );
34 | };
35 |
36 | export default AuthRoute;
37 |
--------------------------------------------------------------------------------
/src/routes/PrivateRoute.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect, Route } from 'react-router';
2 |
3 | import { useAuthContext } from '@/contexts/AuthContext';
4 |
5 | import type { RouteProps } from 'react-router';
6 |
7 | const PrivateRoute = ({ ...props }: RouteProps) => {
8 | const { isAuth } = useAuthContext();
9 |
10 | return isAuth ? : ;
11 | };
12 |
13 | export default PrivateRoute;
14 |
--------------------------------------------------------------------------------
/src/routes/Router.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect } from 'react-router';
2 | import { Switch, BrowserRouter } from 'react-router-dom';
3 |
4 | import Error from '@/pages/ErrorPage';
5 | import Home from '@/pages/Home';
6 | import QuizCreate from '@/pages/QuizCreatePage';
7 | import QuizResultPage from '@/pages/QuizResultPage';
8 | import QuizSolvePage from '@/pages/QuizSolvePage';
9 | import Ranking from '@/pages/RankingPage';
10 | import UserInfoPage from '@/pages/UserInfoPage';
11 | import AuthRoute from '@/routes/AuthRoute';
12 |
13 | const Routers = () => (
14 |
15 |
16 |
22 |
27 |
32 |
37 |
41 |
45 |
50 | }
53 | />
54 |
55 |
56 | );
57 |
58 | export default Routers;
59 |
--------------------------------------------------------------------------------
/src/styles/fontStyle.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import { css } from '@emotion/react';
3 |
4 | const fontStyle = css`
5 | @import url('https://fonts.googleapis.com/css2?family=Permanent+Marker&display=swap');
6 | @import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
7 |
8 | @font-face {
9 | font-family: 'MaplestoryOTFLight';
10 | src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_20-04@2.1/MaplestoryOTFLight.woff')
11 | format('woff');
12 | font-weight: light;
13 | }
14 |
15 | @font-face {
16 | font-family: 'MaplestoryOTFBold';
17 | src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_20-04@2.1/MaplestoryOTFBold.woff')
18 | format('woff');
19 | font-weight: normal;
20 | font-style: normal;
21 | }
22 | `;
23 | export default fontStyle;
24 |
--------------------------------------------------------------------------------
/src/styles/reset.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @emotion/syntax-preference */
2 | import { css } from '@emotion/react';
3 |
4 | const reset = css`
5 | * {
6 | font-family: 'MaplestoryOTFLight', -apple-system, BlinkMacSystemFont,
7 | system-ui, 'Noto Sans KR', 'Malgun Gothic', sans-serif;
8 | box-sizing: border-box;
9 | }
10 | html,
11 | body,
12 | div,
13 | span,
14 | applet,
15 | object,
16 | iframe,
17 | h1,
18 | h2,
19 | h3,
20 | h4,
21 | h5,
22 | h6,
23 | p,
24 | blockquote,
25 | pre,
26 | a,
27 | abbr,
28 | acronym,
29 | address,
30 | big,
31 | cite,
32 | code,
33 | del,
34 | dfn,
35 | em,
36 | img,
37 | ins,
38 | kbd,
39 | q,
40 | s,
41 | samp,
42 | small,
43 | strike,
44 | strong,
45 | sub,
46 | sup,
47 | tt,
48 | var,
49 | b,
50 | u,
51 | i,
52 | center,
53 | dl,
54 | dt,
55 | dd,
56 | ol,
57 | ul,
58 | li,
59 | fieldset,
60 | form,
61 | label,
62 | legend,
63 | table,
64 | caption,
65 | tbody,
66 | tfoot,
67 | thead,
68 | tr,
69 | th,
70 | td,
71 | article,
72 | aside,
73 | canvas,
74 | details,
75 | embed,
76 | figure,
77 | figcaption,
78 | footer,
79 | header,
80 | hgroup,
81 | menu,
82 | nav,
83 | output,
84 | ruby,
85 | section,
86 | summary,
87 | time,
88 | mark,
89 | audio,
90 | video {
91 | margin: 0;
92 | padding: 0;
93 | border: 0;
94 | box-sizing: border-box;
95 | font-size: 100%;
96 | font: inherit;
97 | vertical-align: baseline;
98 | font-family: 'MaplestoryOTFLight', -apple-system, BlinkMacSystemFont,
99 | system-ui, 'Noto Sans KR', 'Malgun Gothic', sans-serif;
100 | }
101 | /* HTML5 display-role reset for older browsers */
102 | article,
103 | aside,
104 | details,
105 | figcaption,
106 | figure,
107 | footer,
108 | header,
109 | hgroup,
110 | menu,
111 | nav,
112 | section {
113 | display: block;
114 | }
115 | `;
116 |
117 | export default reset;
118 |
--------------------------------------------------------------------------------
/src/styles/theme.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-return */
2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
3 | /* eslint-disable no-shadow */
4 | const Size = {
5 | pc: '1200px', // 1200px
6 | tab: '900px', // 900px
7 | mobile: '600px', // 600px
8 | };
9 |
10 | const theme = {
11 | themeColors: {
12 | pointColor: '#FCA311',
13 | primary: '#14213D',
14 | secondary: '#E5E5E5',
15 | },
16 | tagColor: {
17 | green: '#ADE85A',
18 | blue: '#0F9fff',
19 | yellow: '#FFE100',
20 | red: '#FF6254',
21 | lightBrown: '#FED36F',
22 | pink: '#FF937E',
23 | 0: '#977C37',
24 | 10: '#5F7161',
25 | 50: '#6D8B74',
26 | 100: '#00FFAB',
27 | 500: '#0D99FF',
28 | 1000: '#FFBEB8',
29 | 5000: '#FAFF00',
30 | 10000: '#F30E5C',
31 | 50000: '#FF1809',
32 | noLikes: '#7E7474',
33 | noComments: 'mediumpurple',
34 | },
35 | textAndBackGroundColor: {
36 | blackGray: '#212529',
37 | DarkGray: '#343A40',
38 | gray: '#495057',
39 | lightGray: '#ADB5BD',
40 | brightGray: '#CED4DA',
41 | grayWhite: '#DEE2E6',
42 | lightGrayWhite: '#E9ECEF',
43 | white: '#F8F9FA',
44 | },
45 | answerColor: {
46 | correct: '#5B9785',
47 | incorrect: '#CE4C4C',
48 | },
49 | borderStyle: {
50 | borderWidth: '3px solid',
51 | borderRadius: '8px',
52 | },
53 | media: {
54 | pc: `@media screen and (max-width: ${Size.pc})`,
55 | tab: `@media screen and (max-width: ${Size.tab})`,
56 | mobile: `@media screen and (max-width: ${Size.mobile})`,
57 | },
58 | fontStyle: {
59 | h1: 'font-size: 3rem; font-weight: 600',
60 | h2: 'font-size: 2.5rem; font-weight: 600',
61 | h3: 'font-size: 2rem; font-weight: 600',
62 | large: 'font-size: 1.5rem; font-weight: 500',
63 | medium: 'font-size: 1.25rem; font-weight: 500',
64 | small: 'font-size: 1rem; font-weight: 500',
65 | p: 'font-size: 0.9rem; font-weight: 400',
66 | detail: 'font-size: 0.9rem; font-weight: 400',
67 | },
68 | };
69 |
70 | // 글로벌 emotion props 타입 지정
71 |
72 | export const pc = (props: any) => props.theme.media.pc;
73 | export const tab = (props: any) => props.theme.media.tab;
74 | export const mobile = (props: any) => props.theme.media.mobile;
75 | export const pointColor = (props: any) => props.theme.themeColors.pointColor;
76 | export const primary = (props: any) => props.theme.themeColors.primary;
77 | export const secondary = (props: any) => props.theme.themeColors.secondary;
78 | export const tagBlue = (props: any) => props.theme.tagColor.blue;
79 | export const tagGreen = (props: any) => props.theme.tagColor.green;
80 | export const tagLightBrown = (props: any) => props.theme.tagColor.lightBrown;
81 | export const tagPink = (props: any) => props.theme.tagColor.pink;
82 | export const tagRed = (props: any) => props.theme.tagColor.red;
83 | export const tagYellow = (props: any) => props.theme.tagColor.yellow;
84 | export const tag0 = (props: any) => props.theme.tagColor[0];
85 | export const tag10 = (props: any) => props.theme.tagColor[10];
86 | export const tag50 = (props: any) => props.theme.tagColor[50];
87 | export const tag100 = (props: any) => props.theme.tagColor[100];
88 | export const tag500 = (props: any) => props.theme.tagColor[500];
89 | export const tag1000 = (props: any) => props.theme.tagColor[1000];
90 | export const tag5000 = (props: any) => props.theme.tagColor[5000];
91 | export const tag10000 = (props: any) => props.theme.tagColor[10000];
92 | export const tag50000 = (props: any) => props.theme.tagColor[50000];
93 | export const tagNoLikes = (props: any) => props.theme.tagColor.noLikes;
94 | export const tagNoComments = (props: any) => props.theme.tagColor.noComments;
95 |
96 | export const DarkGray = (props: any) =>
97 | props.theme.textAndBackGroundColor.DarkGray;
98 | export const blackGray = (props: any) =>
99 | props.theme.textAndBackGroundColor.blackGray;
100 | export const brightGray = (props: any) =>
101 | props.theme.textAndBackGroundColor.brightGray;
102 | export const gray = (props: any) => props.theme.textAndBackGroundColor.gray;
103 | export const grayWhite = (props: any) =>
104 | props.theme.textAndBackGroundColor.grayWhite;
105 | export const lightGray = (props: any) =>
106 | props.theme.textAndBackGroundColor.lightGray;
107 | export const lightGrayWhite = (props: any) =>
108 | props.theme.textAndBackGroundColor.lightGrayWhite;
109 | export const white = (props: any) => props.theme.textAndBackGroundColor.white;
110 |
111 | export const correct = (props: any) => props.theme.answerColor.correct;
112 | export const incorrect = (props: any) => props.theme.answerColor.incorrect;
113 | export const borderRadius = (props: any) =>
114 | props.theme.borderStyle.borderRadius;
115 | export const borderWidth = (props: any) => props.theme.borderStyle.borderWidth;
116 |
117 | export const h1 = (props: any) => props.theme.fontStyle.h1;
118 | export const h2 = (props: any) => props.theme.fontStyle.h2;
119 | export const h3 = (props: any) => props.theme.fontStyle.h3;
120 | export const large = (props: any) => props.theme.fontStyle.large;
121 | export const medium = (props: any) => props.theme.fontStyle.medium;
122 | export const small = (props: any) => props.theme.fontStyle.small;
123 | export const p = (props: any) => props.theme.fontStyle.p;
124 | export const detail = (props: any) => props.theme.fontStyle.detail;
125 |
126 | export default theme;
127 |
--------------------------------------------------------------------------------
/src/utils/dateFormat.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ANCHOR: 10보다 작으면 앞에 0을 붙여주는 함수
3 | */
4 | export function format(number: number) {
5 | return number < 10 ? `0${number}` : `${number}`;
6 | }
7 |
8 | export default function dateFormat(dateString: string) {
9 | const specificDate = new Date(dateString);
10 | const now = new Date();
11 | // eslint-disable-next-line @typescript-eslint/naming-convention
12 | const _ = format;
13 |
14 | /**
15 | * 작성 시간이 오늘을 넘기지 않았다면, 시간, 분을 표시한다.
16 | * 작성 시간이 오늘 날짜가 아니라면, 월, 일을 표시한다.
17 | * 작성 시간이 올해가 아니라면, 년, 월을 표시한다.
18 | */
19 | const year = specificDate.getFullYear();
20 | const month = specificDate.getMonth();
21 | const date = specificDate.getDate();
22 | const hours = specificDate.getHours();
23 | const minutes = specificDate.getMinutes();
24 |
25 | if (year === now.getFullYear() && date === now.getDate())
26 | return `${_(hours)}:${_(minutes)}`;
27 | if (year === now.getFullYear()) return `${_(month + 1)}.${_(date)}`;
28 | return `${_(year).slice(-2, _(year).length)}.${_(month + 1)}`;
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/getUserImage.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */
2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
4 | import { fetchUserData } from '@/api/user';
5 | import { MAXEXP } from '@/common/number';
6 | import { imageBreakpoints } from '@/components/UserInfo/breakpoints';
7 |
8 | export const getUserImageByPoints = (points: number) => {
9 | const level = points ? Math.floor(points / MAXEXP) + 1 : 1;
10 | let selectedId = imageBreakpoints[0].imageId;
11 | imageBreakpoints.forEach((breakpoint) => {
12 | if (level >= breakpoint.level) {
13 | selectedId = breakpoint.imageId;
14 | }
15 | });
16 | return `https://maplestory.io/api/GMS/210.1.1/mob/${selectedId}/render/stand`;
17 | };
18 |
19 | export const getUserImageById = async (id: string) => {
20 | const apiData = await fetchUserData(id);
21 | const points = apiData.username ? JSON.parse(apiData.username).points : 0;
22 | return getUserImageByPoints(points);
23 | };
24 |
--------------------------------------------------------------------------------
/src/utils/validation.ts:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 |
3 | const REQUIRED_ERROR_TEXT = '반드시 작성해야합니다';
4 | const EMAIL_ERROR_TEXT = '이메일 형식을 작성해주세요';
5 | const PASSWORD_LENGTH_ERROR_TEXT = '비밀번호는 최소 8자리 이상이어야 합니다';
6 | const PASSWORD_CONFIRM_ERROR_TEXT = '비밀번호가 일치하지 않습니다';
7 |
8 | const validationRequired = () => Yup.string().required(REQUIRED_ERROR_TEXT);
9 |
10 | const validationEmail = () => validationRequired().email(EMAIL_ERROR_TEXT);
11 |
12 | const validationPassword = () =>
13 | validationRequired().min(8, PASSWORD_LENGTH_ERROR_TEXT);
14 |
15 | const validationFullname = () => validationRequired();
16 |
17 | const validationPasswordConfirm = () =>
18 | validationRequired().oneOf(
19 | [Yup.ref('password'), null],
20 | PASSWORD_CONFIRM_ERROR_TEXT
21 | );
22 |
23 | export const validationLogin = () =>
24 | Yup.object({
25 | email: validationRequired(),
26 | password: validationRequired(),
27 | });
28 |
29 | export const validationSignup = () =>
30 | Yup.object({
31 | email: validationEmail(),
32 | fullName: validationFullname(),
33 | password: validationPassword(),
34 | passwordConfirm: validationPasswordConfirm(),
35 | });
36 |
37 | export const validationChangeName = () =>
38 | Yup.object({
39 | fullName: validationRequired(),
40 | });
41 |
42 | export const validationChangePassword = () =>
43 | Yup.object({
44 | password: validationPassword(),
45 | passwordConfirm: validationPasswordConfirm(),
46 | });
47 |
48 | //* QuizForm
49 | export const validationQuizCreate = () =>
50 | Yup.array().of(
51 | Yup.object({
52 | _id: Yup.string(),
53 | category: Yup.string().trim().required('카테고리를 선택해주세요'),
54 | question: Yup.string().trim().required('문제를 입력해주세요'),
55 | difficulty: Yup.number().min(1).required('난이도를 선택해주세요'),
56 | importance: Yup.number().min(1).required('중요도를 선택해주세요'),
57 | answerType: Yup.string().trim().required('문제유형을 골라주세요'),
58 | answer: Yup.string().trim().required('정답을 선택해주세요'),
59 | answerDescription: Yup.string().trim().required('해설을 작성해주세요'),
60 | })
61 | );
62 |
--------------------------------------------------------------------------------
/tsconfig.path.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
4 | "paths": {
5 | "@/*": ["src/*"],
6 | "@api/*": ["src/api/*"],
7 | "@components/*": ["src/components/*"],
8 | "@constants/*": ["src/constants/*"],
9 | "@contexts/*": ["src/contexts/*"],
10 | "@hooks/*": ["src/hooks/*"],
11 | "@pages/*": ["src/pages/*"],
12 | "@routes/*": ["src/routes/*"],
13 | "@styles/*": ["src/styles/*"],
14 | "@utils/*": ["src/utils/*"],
15 | },
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | import DotenvPlugin from 'dotenv-webpack';
4 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
5 | import HtmlWebpackPlugin from 'html-webpack-plugin';
6 | import BundleAnalyzerPlugin from 'webpack-bundle-analyzer';
7 |
8 | import type { Configuration as WebpackConfiguration } from 'webpack';
9 | import type { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
10 |
11 | interface Configuration extends WebpackConfiguration {
12 | devserver?: WebpackDevServerConfiguration;
13 | }
14 |
15 | type WebpackArguments = {
16 | [key: string]: string;
17 | };
18 |
19 | export default (env: NodeJS.ProcessEnv, argv: WebpackArguments) => {
20 | const isProdcution = argv.mode === 'production';
21 | const isDevelopment = argv.mode === 'development';
22 |
23 | const config: Configuration = {
24 | resolve: {
25 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
26 | alias: {
27 | // ANCHOR: '@' 경로는 삭제 예정이므로, import시 하위 폴더 alias로 import 변경
28 | '@': path.resolve(__dirname, 'src'),
29 | '@api': path.resolve(__dirname, 'src/api'),
30 | '@components': path.resolve(__dirname, 'src/components'),
31 | '@constants': path.resolve(__dirname, 'src/constants'),
32 | '@contexts': path.resolve(__dirname, 'src/contexts'),
33 | '@hooks': path.resolve(__dirname, 'src/hooks'),
34 | '@pages': path.resolve(__dirname, 'src/pages'),
35 | '@routes': path.resolve(__dirname, 'src/routes'),
36 | '@styles': path.resolve(__dirname, 'src/styles'),
37 | '@utils': path.resolve(__dirname, 'src/utils'),
38 | },
39 | },
40 | entry: './src/index.tsx',
41 | output: {
42 | filename: 'static/js/[name].[contenthash:8].js',
43 | chunkFilename: 'static/js/[name].[contenthash:8].js',
44 | path: path.resolve(__dirname, 'dist'),
45 | publicPath: '/',
46 | clean: true,
47 | },
48 | module: {
49 | rules: [
50 | {
51 | test: /\.tsx?$/,
52 | use: 'babel-loader',
53 | exclude: /node_modules/,
54 | },
55 | {
56 | test: /\.s?css$/,
57 | use: ['style-loader', 'css-loader', 'sass-loader'],
58 | },
59 | {
60 | test: /\.(png|jpg|jpeg|gif)$/i,
61 | type: 'asset/resource',
62 | },
63 | ],
64 | },
65 | plugins: [
66 | new HtmlWebpackPlugin({
67 | inject: true,
68 | template: path.resolve(__dirname, 'public/index.html'),
69 | ...(isProdcution
70 | ? {
71 | minify: {
72 | removeComments: true,
73 | removeRedundantAttributes: true,
74 | minifyJS: true,
75 | minifyCSS: true,
76 | minifyURLs: true,
77 | },
78 | }
79 | : undefined),
80 | }),
81 | new ForkTsCheckerWebpackPlugin({
82 | async: isDevelopment,
83 | typescript: {
84 | diagnosticOptions: {
85 | syntactic: true,
86 | },
87 | },
88 | }),
89 | new DotenvPlugin({
90 | systemvars: true,
91 | }),
92 | new BundleAnalyzerPlugin.BundleAnalyzerPlugin({
93 | analyzerMode: 'static',
94 | reportFilename: 'bundle-report.html',
95 | openAnalyzer: false,
96 | generateStatsFile: true,
97 | statsFilename: 'bundle-stats.json',
98 | }),
99 | ],
100 | devServer: {
101 | port: 3000,
102 | hot: true,
103 | historyApiFallback: true,
104 | },
105 | optimization: {
106 | splitChunks: {
107 | chunks: 'all',
108 | },
109 | },
110 | };
111 |
112 | return config;
113 | };
114 |
--------------------------------------------------------------------------------