diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f42c3ed..60952ef 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @bluetree7878 @rhehfl @dmsdnWkd1234 @chamjin @zzzRYT @dg1418 +* @bluetree7878 @rhehfl @dmsdnWkd1234 @chamjin @zzzRYT @dg1418 @ssi02014 diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 897e74e..d2db205 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -18,13 +18,13 @@ jobs: runs-on: ubuntu-latest steps: # github repository에서 checkout - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 # docker build 수행 - name: Set up docker buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Cache docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ env.VERSION }} @@ -36,7 +36,7 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GHCR_TOKEN }} + password: ${{ github.actor == 'bluetree7878' && secrets.bluetree7878_GHCR_TOKEN || secrets.rhehfl_ghcr_token }} - name: Build and push id: docker_build uses: docker/build-push-action@v2 @@ -56,7 +56,7 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GHCR_TOKEN }} + password: ${{ github.actor == 'bluetree7878' && secrets.bluetree7878_GHCR_TOKEN || secrets.rhehfl_ghcr_token }} # 3000 -> 80 포트로 수행하도록 지정 - name: Docker run run: | diff --git a/.gitignore b/.gitignore index ecb8a85..b0ff42f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* - +.env node_modules dist dist-ssr diff --git a/index.html b/index.html index 05212aa..adc81db 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@
+ diff --git a/package-lock.json b/package-lock.json index aef2fd5..a4f7d8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,13 @@ "name": "8term-main-front", "version": "0.0.0", "dependencies": { + "@tanstack/react-query": "^5.59.0", + "@tanstack/react-query-devtools": "^5.59.0", + "@types/dompurify": "^3.0.5", "axios": "^1.7.7", + "dompurify": "^3.1.7", + "html-react-parser": "^5.1.18", + "query-string": "^9.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", @@ -17,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.0", + "@swc/plugin-styled-components": "^3.0.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", @@ -1011,6 +1018,16 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", "dev": true }, + "node_modules/@swc/plugin-styled-components": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@swc/plugin-styled-components/-/plugin-styled-components-3.0.2.tgz", + "integrity": "sha512-ga3065ACLCHBfE3h6bsXABK3usFyqGr0BB/EWReGWu3igUinI6IqK50g76xEqcaEgF6ywM0cmrZ7ARqPPVOe+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@swc/types": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", @@ -1020,6 +1037,63 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/query-core": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.0.tgz", + "integrity": "sha512-WGD8uIhX6/deH/tkZqPNcRyAhDUqs729bWKoByYHSogcshXfFbppOdTER5+qY7mFvu8KEFJwT0nxr8RfPTVh0Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.58.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.58.0.tgz", + "integrity": "sha512-iFdQEFXaYYxqgrv63ots+65FGI+tNp5ZS5PdMU1DWisxk3fez5HG3FyVlbUva+RdYS5hSLbxZ9aw3yEs97GNTw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.0.tgz", + "integrity": "sha512-YDXp3OORbYR+8HNQx+lf4F73NoiCmCcSvZvgxE29OifmQFk0sBlO26NWLHpcNERo92tVk3w+JQ53/vkcRUY1hA==", + "dependencies": { + "@tanstack/query-core": "5.59.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.59.0.tgz", + "integrity": "sha512-Kz7577FQGU8qmJxROIT/aOwmkTcxfBqgTP6r1AIvuJxVMVHPkp8eQxWQ7BnfBsy/KTJHiV9vMtRVo1+R1tB3vg==", + "dependencies": { + "@tanstack/query-devtools": "5.58.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.59.0", + "react": "^18 || ^19" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -1056,6 +1130,11 @@ "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", @@ -1518,6 +1597,14 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", + "engines": { + "node": ">=14.16" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1532,6 +1619,73 @@ "node": ">=0.4.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==" + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1828,6 +1982,17 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1948,6 +2113,53 @@ "node": ">=8" } }, + "node_modules/html-dom-parser": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.0.10.tgz", + "integrity": "sha512-GwArYL3V3V8yU/mLKoFF7HlLBv80BZ2Ey1BzfVNRpAci0cEKhFHI/Qh8o8oyt3qlAMLlK250wsxLdYX4viedvg==", + "dependencies": { + "domhandler": "5.0.3", + "htmlparser2": "9.1.0" + } + }, + "node_modules/html-react-parser": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.1.18.tgz", + "integrity": "sha512-65BwC0zzrdeW96jB2FRr5f1ovBhRMpLPJNvwkY5kA8Ay5xdL9t/RH2/uUTM7p+cl5iM88i6dDk4LXtfMnRmaJQ==", + "dependencies": { + "domhandler": "5.0.3", + "html-dom-parser": "5.0.10", + "react-property": "2.0.2", + "style-to-js": "1.1.16" + }, + "peerDependencies": { + "@types/react": "0.14 || 15 || 16 || 17 || 18", + "react": "0.14 || 15 || 16 || 17 || 18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1982,6 +2194,11 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2348,6 +2565,22 @@ "node": ">=6" } }, + "node_modules/query-string": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.1.1.tgz", + "integrity": "sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg==", + "dependencies": { + "decode-uri-component": "^0.4.1", + "filter-obj": "^5.1.0", + "split-on-first": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2391,6 +2624,11 @@ "react": "^18.3.1" } }, + "node_modules/react-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", + "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==" + }, "node_modules/react-router": { "version": "6.26.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", @@ -2552,6 +2790,17 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", + "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2576,6 +2825,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", + "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "dependencies": { + "style-to-object": "1.0.8" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/styled-components": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.13.tgz", diff --git a/package.json b/package.json index eff04fd..639d451 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.59.0", + "@tanstack/react-query-devtools": "^5.59.0", + "@types/dompurify": "^3.0.5", "axios": "^1.7.7", + "dompurify": "^3.1.7", + "html-react-parser": "^5.1.18", + "query-string": "^9.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", @@ -19,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.0", + "@swc/plugin-styled-components": "^3.0.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/src/App.tsx b/src/App.tsx index cf97f02..8a6f726 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,31 @@ -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import Router from './route/Router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ThemeProvider } from 'styled-components'; import { theme } from './style/theme'; import GlobalStyle from './style/GlobalStyle'; import GlobalFont from './style/GlobalFont'; -import Main from './pages/main/Main'; -import Quest from './pages/Quest/Quest'; -import Ranking from './pages/Ranking/Ranking'; function App() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: Infinity, + }, + }, + }); return ( - - - - - - }> - }> - }> - - - + <> + + + + + + + + + ); } diff --git a/src/ModalPortal.tsx b/src/ModalPortal.tsx new file mode 100644 index 0000000..43e194e --- /dev/null +++ b/src/ModalPortal.tsx @@ -0,0 +1,6 @@ +import { PropsWithChildren } from 'react'; +import ReactDom from 'react-dom'; +export default function ModalPortal({ children }: PropsWithChildren) { + const modalRoot = document.getElementById('modal-root') as HTMLElement; + return ReactDom.createPortal(children, modalRoot); +} diff --git a/src/apis/axios/instance.ts b/src/apis/axios/instance.ts new file mode 100644 index 0000000..6c27c10 --- /dev/null +++ b/src/apis/axios/instance.ts @@ -0,0 +1,14 @@ +import axios from 'axios'; +import queryString from 'query-string'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_BASE_URL, + timeout: 5000, + paramsSerializer: params => { + return queryString.stringify(params, { + skipEmptyString: true, + skipNull: true, + }); + }, +}); +export default api; diff --git a/src/apis/axios/intercepter.ts b/src/apis/axios/intercepter.ts new file mode 100644 index 0000000..6f33e3c --- /dev/null +++ b/src/apis/axios/intercepter.ts @@ -0,0 +1,19 @@ +import axiosConfig from './instance'; +axiosConfig.interceptors.request.use(config => { + //요청 성공 직전 호출 + //헤더에 인가 토큰 부착 + //로컬스토리지에 저장한다고 가정한다면 + const accessToken: string | null = localStorage.getItem('Token'); + if (accessToken !== null) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}); +axiosConfig.interceptors.response.use( + //http status가 200번대인 경우 호출 + response => response, + error => { + //http status가 에러 코드인경우 실행 + } +); diff --git a/src/apis/axios/statusCode.ts b/src/apis/axios/statusCode.ts new file mode 100644 index 0000000..61a20b1 --- /dev/null +++ b/src/apis/axios/statusCode.ts @@ -0,0 +1 @@ +export const HTTP_STATUS = {}; diff --git a/src/apis/quizzesApis.ts b/src/apis/quizzesApis.ts new file mode 100644 index 0000000..f6b0c29 --- /dev/null +++ b/src/apis/quizzesApis.ts @@ -0,0 +1,13 @@ +import Quiz from '../types/Quiz'; +import api from './axios/instance'; + +const quizzesApis = { + getquizzes: async (params?: { + sectionId?: number; + partId: number; + }): Promise => { + const response = await api.get('/quizzes', { params }); + return response.data; + }, +}; +export default quizzesApis; diff --git a/src/apis/usersApis.ts b/src/apis/usersApis.ts new file mode 100644 index 0000000..611c6f8 --- /dev/null +++ b/src/apis/usersApis.ts @@ -0,0 +1,14 @@ +import api from './axios/instance'; +const usersApis = { + putQuizzesProgress: ({ + userId, + quizId, + body, + }: { + userId: number; + quizId: number; + body: Record<'isCorrect', boolean>; + }) => + api.put>(`/users/${userId}/progress/quizzes/${quizId}`, body), +}; +export default usersApis; diff --git a/src/common/layout/Modal.tsx b/src/common/layout/Modal.tsx new file mode 100644 index 0000000..049f3d9 --- /dev/null +++ b/src/common/layout/Modal.tsx @@ -0,0 +1,20 @@ +import { PropsWithChildren, useEffect } from 'react'; +import { OverRay } from './style'; +import ModalPortal from '../../ModalPortal'; + +interface ModalProps { + isShow: boolean; +} +export default function Modal({ + isShow, + children, +}: PropsWithChildren) { + useEffect(() => { + if (isShow) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'auto'; + } + }, [isShow]); + return {isShow && {children}}; +} diff --git a/src/common/layout/style.ts b/src/common/layout/style.ts new file mode 100644 index 0000000..9a31614 --- /dev/null +++ b/src/common/layout/style.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components'; +export const OverRay = styled.div` + position: absolute; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.2); +`; diff --git a/src/features/quiz/service/emptyChangeToDiv.ts b/src/features/quiz/service/emptyChangeToDiv.ts new file mode 100644 index 0000000..cea67d1 --- /dev/null +++ b/src/features/quiz/service/emptyChangeToDiv.ts @@ -0,0 +1,11 @@ +const emptyChangeToDiv = (text: string) => { + let index = 0; + const newText = text.replace(/(#empty#)/g, () => { + const replacement = `
`; + index++; + return replacement; + }); + + return newText; +}; +export default emptyChangeToDiv; diff --git a/src/features/quiz/service/lineChanger.ts b/src/features/quiz/service/lineChanger.ts new file mode 100644 index 0000000..abef4b7 --- /dev/null +++ b/src/features/quiz/service/lineChanger.ts @@ -0,0 +1,4 @@ +const lineChanger = (text: string) => { + return text.replace(/\n/g, "
"); +}; +export default lineChanger; diff --git a/src/features/quiz/styles.css b/src/features/quiz/styles.css new file mode 100644 index 0000000..7cf8aa5 --- /dev/null +++ b/src/features/quiz/styles.css @@ -0,0 +1,19 @@ +.line-change { + flex-basis: 100%; + height: 10px; +} +.empty { + width: 100px; + height: 20px; + background-color: gray; + border-radius: 15px; +} +.text-block { + cursor: pointer; + border-radius: 8px; + background: #19191b; + color: #ffffff; + list-style-type: none; + padding: 0 20px; + height: 26px; +} diff --git a/src/features/quiz/styles.ts b/src/features/quiz/styles.ts new file mode 100644 index 0000000..ebe41f7 --- /dev/null +++ b/src/features/quiz/styles.ts @@ -0,0 +1,164 @@ +import styled, { css, keyframes } from 'styled-components'; + +//문제(Quiz)의 제목(title)과 문항(question)이 들어갈 공간 +export const QuestionSection = styled.section` + display: flex; + flex-direction: column; + height: 343px; + margin-top: 22px; + border-radius: 2px; + border: 1px solid #afb1b6; + background: #efeff0; + grid-column: 3; + font-size: 1rem; +`; +//답을 적거나 클릭하는 영역을 잡는 스타일 +interface ResponseBoxProps { + $gapColumn?: string; + $gridColumn?: string; + $justifyContent?: string; +} + +export const ResponseBoxSection = styled.section` + display: flex; + justify-content: ${({ $justifyContent }) => $justifyContent || 'center'}; + align-items: center; + flex-wrap: wrap; + background: #efeff0; + grid-column: ${({ $gridColumn }) => $gridColumn || 3}; + margin-top: 20px; + border-radius: 2px; + border: 1px solid #afb1b6; + column-gap: ${({ $gapColumn }) => $gapColumn || '0px'}; +`; +//캐릭터가 들어갈 박스 +interface CharacterBoxProps { + $margin: string; +} + +export const CharacterBox = styled.div` + width: 176px; + height: 115.757px; + border: 2px solid #afb1b6; + background: #efeff0; + margin: ${({ $margin }) => $margin || '0'}; + border-radius: 8px; +`; +//ox유형에서 ox버튼 +interface OXButtonProps { + $backGroundColor: boolean; +} +export const OXButton = styled.button` + cursor: pointer; + background: ${({ $backGroundColor }) => + $backGroundColor ? 'red' : '#19191b'}; + width: 110px; + height: 108px; + border-radius: 10px; +`; +//객관식에서 각 문항 버튼 +interface MultipleChoiceQuestionButtonProps { + $backGroundColor: boolean; +} +export const MultipleChoiceQuestionButton = styled.button` + cursor: pointer; + width: 372px; + height: 26px; + border-radius: 8px; + background: ${({ $backGroundColor }) => + $backGroundColor ? 'red' : '#19191b'}; + color: #ffffff; + margin-top: 13px; +`; +//단답형 문항에서 단답형을 쓰는 인풋박스 +export const ShortAnswerInput = styled.input` + width: 372px; + height: 23px; +`; +//블럭유형에서 리스트박스를 잡는 리스트 박스 +export const CombinationUl = styled.ul` + display: flex; + grid-column: 2/5; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + margin: 49px 0 0 0; + padding: 0; + :nth-last-child(1) { + margin-right: auto; + } +`; + +//블럭유형에서 각 텍스트에 해당하는 리스트 스타일 +interface TextBlockButtonProps { + $selected?: boolean; +} +export const TextBlockButton = styled.button` + cursor: pointer; + border-radius: 8px; + background: #19191b; + color: #ffffff; + list-style-type: none; + padding: 0 20px; + height: 26px; + ${({ $selected }) => + $selected && + css` + background: gray; + color: gray; + cursor: default; + `} +`; +//화면 하단의 +export const ResponseButton = styled.button` + width: 94px; + height: 26px; + border-radius: 24px; + cursor: pointer; +`; +export const SubmitSection = styled.section` + display: flex; + height: 108px; + align-items: center; + justify-content: space-between; + grid-column: 3; + margin-top: 19px; +`; + +export const QuestionDiv = styled.div` + display: flex; + flex-wrap: wrap; +`; +export const EmptyDiv = styled.div` + width: 100px; + height: 20px; + background-color: gray; + border-radius: 15px; +`; +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(40%); + } + to { + opacity: 1; + transform: translateY(0); + } +`; +export const ScoreSection = styled.section` + display: flex; + align-items: center; + justify-content: space-around; + background-color: gray; + width: 100vw; + height: 25%; + position: fixed; + bottom: 0; + + animation: ${fadeIn} 0.7s ease-out; +`; + +export const LineChangeDiv = styled.div` + flex-basis: 100%; + height: 10px; +`; diff --git a/src/features/quiz/ui/Combination.tsx b/src/features/quiz/ui/Combination.tsx new file mode 100644 index 0000000..80e9cee --- /dev/null +++ b/src/features/quiz/ui/Combination.tsx @@ -0,0 +1,38 @@ +import Quiz from '../../../types/Quiz'; +import { CombinationUl, TextBlockButton } from '../styles'; +import { useClientQuizStore } from '../../../store/useClientQuizStore'; +import compact from '../../../utils/compact'; +interface CombinationProps { + answerChoice: Quiz['answerChoice']; + answer: Quiz['answer']; +} +export default function Combination({ + answerChoice, + answer, +}: CombinationProps) { + const { userResponseAnswer, pushUserResponseAnswer } = useClientQuizStore(); + + return ( + <> + + {answerChoice.map((choice, index) => { + const isSelect = userResponseAnswer.includes(choice); + return ( + { + //답 수랑 내가 선택한 답 (공백빼고) 갯수 비교 정답보다 선택한게 많으면 안되니 + answer.length > compact(userResponseAnswer).length && + pushUserResponseAnswer(choice); + }} + $selected={isSelect} + disabled={isSelect} + > + {choice} + + ); + })} + + + ); +} diff --git a/src/features/quiz/ui/MultipleChoice.tsx b/src/features/quiz/ui/MultipleChoice.tsx new file mode 100644 index 0000000..01ddfcc --- /dev/null +++ b/src/features/quiz/ui/MultipleChoice.tsx @@ -0,0 +1,24 @@ +import Quiz from '../../../types/Quiz'; +import { MultipleChoiceQuestionButton, ResponseBoxSection } from '../styles'; +import { useClientQuizStore } from '../../../store/useClientQuizStore'; +interface MultipleChoiceProps { + answerChoice: Quiz['answerChoice']; +} +export default function MultipleChoice({ answerChoice }: MultipleChoiceProps) { + const { userResponseAnswer, setUserResponseAnswer } = useClientQuizStore(); + return ( + <> + + {answerChoice.map((value, index) => ( + setUserResponseAnswer(value)} + $backGroundColor={userResponseAnswer[0] === value} + > + {index + 1} : {value} + + ))} + + + ); +} diff --git a/src/features/quiz/ui/OXSelector.tsx b/src/features/quiz/ui/OXSelector.tsx new file mode 100644 index 0000000..8a65133 --- /dev/null +++ b/src/features/quiz/ui/OXSelector.tsx @@ -0,0 +1,21 @@ +import { OXButton, CharacterBox, ResponseBoxSection } from '../styles'; +import { useClientQuizStore } from '../../../store/useClientQuizStore'; +export default function OXSelector() { + //OX버튼을 눌러 답을 제출함 + const { userResponseAnswer, setUserResponseAnswer } = useClientQuizStore(); + return ( + <> + + setUserResponseAnswer('O')} + $backGroundColor={userResponseAnswer[0] === 'O'} + /> + 캐릭터 들어갈예정 + setUserResponseAnswer('X')} + $backGroundColor={userResponseAnswer[0] === 'X'} + /> + + + ); +} diff --git a/src/features/quiz/ui/Question.tsx b/src/features/quiz/ui/Question.tsx new file mode 100644 index 0000000..a3e45ac --- /dev/null +++ b/src/features/quiz/ui/Question.tsx @@ -0,0 +1,69 @@ +import { QuestionDiv, QuestionSection, TextBlockButton } from './../styles'; +import { useClientQuizStore } from '../../../store/useClientQuizStore'; +import parse, { HTMLReactParserOptions, Element } from 'html-react-parser'; +import '../styles.css'; +import emptyChangeToDiv from '../service/emptyChangeToDiv'; +import lineChanger from '../service/lineChanger'; +import { useRef } from 'react'; +import Dompurify from 'dompurify'; +interface questiontype { + title: string; + question: string; + category: string; +} +export default function Question({ title, question }: questiontype) { + const { + userResponseAnswer, + swapUserResponseAnswer, + spliceUserResponseAnswer, + } = useClientQuizStore(); + //\n을 줄바꿈 요소로 변경 + const lineChangeQuestion = lineChanger(question); + //#empty#을 html Element로 변경 + const nonEmptyQuestion = emptyChangeToDiv(lineChangeQuestion); + const dragItem = useRef(null); // 드래그할 아이템의 인덱스 + const dragOverItem = useRef(null); // 드랍할 위치의 아이템의 인덱스 + const options: HTMLReactParserOptions = { + replace: domNode => { + //class명이 empty인(빈칸) html Element를 찾음 + if (domNode instanceof Element && domNode.attribs.class === 'empty') { + //그 노드의 id를 가져옴 + const id = Number(domNode.attribs.id.match(/\d+$/)); + //전역상태(유저의 응답) 배열에서 해당 id의 값 가져와서 빈칸을 TextBlock 컴포넌트로 변경 해당 인덱스가 빈칸이면 그대로 domNod + return userResponseAnswer[id] ? ( + spliceUserResponseAnswer(id)} + draggable + onDragStart={() => { + dragOverItem.current = id; + }} + onDragEnter={() => { + dragOverItem.current = id; + }} + onDragEnd={() => { + if (dragItem.current !== null && dragOverItem.current != null) { + swapUserResponseAnswer(dragItem.current, dragOverItem.current); + } + }} + onDragOver={e => e.preventDefault()} + > + {userResponseAnswer[id]} + + ) : ( + domNode + ); + } + }, + }; + + return ( + +

{title}

+

+ + {/* Dompurify를 이용한 xss공격 방어 문자열 랜더링*/} + {parse(Dompurify.sanitize(nonEmptyQuestion), options)} + +
+ ); +} diff --git a/src/features/quiz/ui/ResultModal.tsx b/src/features/quiz/ui/ResultModal.tsx new file mode 100644 index 0000000..a0d9d62 --- /dev/null +++ b/src/features/quiz/ui/ResultModal.tsx @@ -0,0 +1,46 @@ +import progressQuery from '../../../queries/usersQuery'; +import { useClientQuizStore } from '../../../store/useClientQuizStore'; +import useUserStore from '../../../store/useUserStore'; +import Quiz from '../../../types/Quiz'; +import { ScoreSection } from '../styles'; +interface ResultModalProps { + quizId: Quiz['id']; + result: boolean; + closeModal: () => void; +} +export default function ResultModal({ + quizId, + result, + closeModal, +}: ResultModalProps) { + const { handleNextPage, resetUserResponseAnswer, pushTotalResults } = + useClientQuizStore(); + //임시 유저 가져오기 + const { user } = useUserStore(); + const userId = user.id; + const addProgress = progressQuery.put(); + //임시 유저 가져오기 + return ( + <> + + {result ? '정답' : '오답'} + + + + ); +} diff --git a/src/features/quiz/ui/ShortAnswer.tsx b/src/features/quiz/ui/ShortAnswer.tsx new file mode 100644 index 0000000..4dbd7b5 --- /dev/null +++ b/src/features/quiz/ui/ShortAnswer.tsx @@ -0,0 +1,20 @@ +import { useClientQuizStore } from '../../../store/useClientQuizStore'; +import { ResponseBoxSection, ShortAnswerInput } from '../styles'; +import { CharacterBox } from '../styles'; + +export default function ShortAnswer() { + const { setUserResponseAnswer } = useClientQuizStore(); + return ( + <> + + { + setUserResponseAnswer(e.target.value); + }} + > + + + + ); +} diff --git a/src/features/quiz/ui/TotalResults.tsx b/src/features/quiz/ui/TotalResults.tsx new file mode 100644 index 0000000..5067e2b --- /dev/null +++ b/src/features/quiz/ui/TotalResults.tsx @@ -0,0 +1,24 @@ +import { Link } from 'react-router-dom'; +import type Quiz from '../../../types/Quiz'; +interface TotalResultsProps { + quizzes: Quiz[]; + totalResults: boolean[]; +} +export default function TotalResults({ + quizzes, + totalResults, +}: TotalResultsProps) { + return ( + <> +
+
    +
  • 총 문제 수 : {quizzes.length}
  • +
  • 맞은 문제 수 : {totalResults.filter(result => result).length}
  • +
  • 맞은 퀴즈 아이디:
  • +
    기타 결과들..
    +
+
+ learn페이지로 돌아가기 + + ); +} diff --git a/src/hooks/useBeforeUnload.ts b/src/hooks/useBeforeUnload.ts new file mode 100644 index 0000000..f3e66d7 --- /dev/null +++ b/src/hooks/useBeforeUnload.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import usePreservedCallback from './usePreservedCallback'; + +interface UseBeforeUnloadProps { + enabled?: boolean; + beforeUnloadAction?: (event: BeforeUnloadEvent) => void; +} + +const useBeforeUnload = ({ + enabled = true, // 기본적으로 이벤트 바인딩 + beforeUnloadAction, // noop으로 기본 함수 설정도 가능 +}: UseBeforeUnloadProps = {}): void => { + // usePreservedCallback는 함수의 참조를 유지해주는 커스텀 훅입니다. @modern-kit 참고 + // props로 전달하는 함수의 참조를 유지해야되는 이유는 함수도 결국 객체이기 때문에 props로 전달하는 함수는 리렌더링마다 재생성되기 때문에 + // useCallback으로 관리하더라도 매번 재생성됩니다. 이런 문제를 해결하기 위해 usePreservedCallback와 같은 훅을 사용 + //내부적으로 ref를 통해 최신 콜백함수 유지 useCallback을 통해 함수 참조값 메모이제이션 + const handleBeforeUnload = usePreservedCallback( + (event: BeforeUnloadEvent) => { + event.preventDefault(); + + // beforeUnloadAction이 있을 경우 호출 + if (beforeUnloadAction) { + beforeUnloadAction(event); + } + return (event.returnValue = ''); + } + ); + + useEffect(() => { + if (!enabled) return; // enabled가 false이면 이벤트 바인딩하지 않음 + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [enabled, handleBeforeUnload]); +}; +export default useBeforeUnload; diff --git a/src/hooks/useModal.ts b/src/hooks/useModal.ts new file mode 100644 index 0000000..02361e5 --- /dev/null +++ b/src/hooks/useModal.ts @@ -0,0 +1,18 @@ +import { useState } from 'react'; +import Modal from '../common/layout/Modal'; +/** + * @description 모달을 쉽게 띄우기 위해 만들어진 커스텀 훅 입니다. + * + * @return isShow, openModal, closeModal, Modal 순서대로 현재 모달이 띄워진 상태인지를 알려주는 isShow 이를 쉽게 열고닫을 수 있는 handle함수, + * childern을 모달로 띄울 수 있는 Modal 컴포넌트가 리턴됩니다. + * @example const {isShow, openModal, closeModal, Modal} = useModal() + *