├── .editorconfig ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── q-a.md └── workflows │ └── chromatic.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── LICENSE.md ├── Readme.md ├── assets ├── icons │ ├── ArrowDown.svg │ ├── ArrowRight.svg │ ├── ArrowUp.svg │ ├── BlackSpinner.svg │ ├── Book.svg │ ├── Chart.svg │ ├── Check.svg │ ├── DocumentCopy.svg │ ├── Dropdown.svg │ ├── ProfileLogo.svg │ ├── WhiteSpinner.svg │ └── XCharacter.svg └── images │ ├── github.png │ ├── logo-white.png │ └── noContent.png ├── babel.config.js ├── build ├── app-paths.js ├── optionalPlugin │ └── webpack.bundleAnalyzer.js ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── md ├── HOW_TO_SAVE_SOLUTION.md ├── HOW_TO_SEE_SOLUTION_INFO.md └── Q&A.md ├── package.json ├── src ├── api │ ├── login │ │ └── getCurrentUser.ts │ └── solution │ │ ├── addSolvedProblemId.ts │ │ ├── getSuccessProblemIdList.ts │ │ ├── getUserEmail.ts │ │ ├── getUserInfoStorage.ts │ │ └── setUserInfoStorage.ts ├── components │ ├── domain │ │ ├── profile │ │ │ ├── Header.tsx │ │ │ └── NavBar.tsx │ │ ├── solution │ │ │ ├── Content.tsx │ │ │ ├── Header.tsx │ │ │ └── SelectList.tsx │ │ └── testPage │ │ │ └── CreateMemoListButton.tsx │ └── shared │ │ ├── box │ │ ├── RefreshRequestBox.tsx │ │ └── SkeletonUI.tsx │ │ ├── button │ │ ├── CopyClipBoardButton.tsx │ │ ├── CreateSolutionsButton.tsx │ │ └── GoogleLoginButton.tsx │ │ ├── code │ │ ├── Code.tsx │ │ ├── CodeMirror.tsx │ │ └── SubmissionDetail.tsx │ │ ├── error │ │ ├── AsyncBoundary.tsx │ │ └── ErrorBoundary.tsx │ │ ├── modal │ │ ├── Modal.tsx │ │ └── Portal.tsx │ │ ├── section │ │ ├── CloseBoxOnOutside.tsx │ │ ├── Pagination.tsx │ │ ├── TransitionOut.tsx │ │ └── VirtualScroll.tsx │ │ └── select │ │ ├── CheckOption.tsx │ │ ├── PartTitleSelect.tsx │ │ ├── SolutionSelect.tsx │ │ ├── SortSelect.tsx │ │ └── index.tsx ├── constants │ ├── level.ts │ ├── profile.ts │ ├── solution.ts │ └── url.ts ├── firebase.ts ├── hooks │ ├── popup │ │ └── useAuth.ts │ ├── profile │ │ ├── index.ts │ │ ├── useProblems.ts │ │ └── useUserEmail.ts │ ├── solution │ │ └── useAllSolution.ts │ └── useIsLoaded.ts ├── pages │ ├── background │ │ ├── createChromeTab.ts │ │ ├── createMemoTab.ts │ │ ├── createSolutionsTab.ts │ │ ├── createSuccessProblemTab.ts │ │ ├── getAllSolutions.ts │ │ ├── index.ts │ │ └── postCurrentSolution.ts │ ├── content │ │ ├── problemPage.tsx │ │ ├── solutionPage.tsx │ │ └── testPage.tsx │ ├── newTab │ │ ├── Solution.tsx │ │ └── profile │ │ │ ├── Problems.tsx │ │ │ ├── ProfileTab.tsx │ │ │ ├── Statistics.tsx │ │ │ └── index.tsx │ └── popup │ │ ├── Footer.tsx │ │ ├── Login.tsx │ │ ├── Title.tsx │ │ └── index.tsx ├── service │ ├── profile │ │ ├── filterSolvedProblemsByPartTitle.ts │ │ ├── getAllProblemsList.ts │ │ ├── getChartInfoList.ts │ │ ├── getFilteredSolvedProblems.ts │ │ ├── getPartTitleListOfSolvedProblems.ts │ │ ├── getProblemsCnt.ts │ │ ├── getProblemsLevelList.ts │ │ ├── getSolvedProblemList.ts │ │ ├── index.ts │ │ └── sortSolvedProblems.ts │ ├── solution │ │ ├── filteredSolutions.ts │ │ ├── getAllSolutions.ts │ │ └── index.ts │ └── testPage │ │ ├── getProblemInfo.ts │ │ ├── memo │ │ ├── CreateMemoListButton.tsx │ │ ├── createMemoTabButton.tsx │ │ └── index.ts │ │ └── problemUpload │ │ ├── createShowSolutionsButton.tsx │ │ ├── index.tsx │ │ ├── parsingDomNodeToUpload.ts │ │ ├── printIsUploadSuccess.ts │ │ ├── printLoadingText.ts │ │ ├── printRequestOfRefresh.ts │ │ └── uploadCurrentSolution.ts ├── static │ ├── icon.png │ └── manifest.json ├── store │ ├── modal.ts │ ├── profile.ts │ └── select.ts ├── stories │ ├── components │ │ └── shared │ │ │ ├── section │ │ │ └── Pagination.stories.tsx │ │ │ └── select │ │ │ ├── CheckOption.stories.tsx │ │ │ └── SortSelect.stories.tsx │ └── pages │ │ └── solution │ │ ├── Content.stories.tsx │ │ ├── Header.stories.tsx │ │ ├── SelectList.stories.tsx │ │ ├── SolutionTab.stories.tsx │ │ └── mock-data.ts ├── styles │ ├── font.css │ ├── global.ts │ └── theme │ │ ├── color.ts │ │ ├── index.ts │ │ └── media.ts ├── types │ ├── file-extension.d.ts │ ├── global.ts │ ├── library.d.ts │ ├── problem │ │ └── problem.ts │ ├── profile │ │ ├── profile-layout.ts │ │ ├── profile-problems.ts │ │ ├── profile-statistics.ts │ │ └── profile-tab.ts │ ├── select.ts │ └── solution.ts └── utils │ ├── date │ ├── formatDateToYmdhms.ts │ └── formatTimestampToDate.ts │ ├── fetchRequest.ts │ ├── getPercentile.ts │ └── location │ └── getQueryParams.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | webextensions: true, 7 | }, 8 | ignorePatterns: ['dist', 'node_modules', '*.js'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | 'prettier', 13 | 'plugin:storybook/recommended', 14 | ], 15 | parser: '@typescript-eslint/parser', 16 | parserOptions: { 17 | ecmaFeatures: { 18 | jsx: true, 19 | }, 20 | ecmaVersion: 2018, 21 | sourceType: 'module', 22 | project: ['./tsconfig.json'], 23 | }, 24 | plugins: ['react', 'react-hooks', 'import', 'jsx-a11y', '@typescript-eslint'], 25 | rules: { 26 | 'no-unused-vars': ['error', { varsIgnorePattern: '.*', args: 'none' }], 27 | 'no-restricted-syntax': ['error', 'ObjectPattern > RestElement'], 28 | '@typescript-eslint/no-non-null-assertion': 'off', 29 | 'react/jsx-uses-react': 'off', 30 | 'react/react-in-jsx-scope': 'off', 31 | 'react-hooks/rules-of-hooks': 'off', 32 | 'react-hooks/exhaustive-deps': [ 33 | 'warn', 34 | { 35 | additionalHooks: 'useRecoilCallback', 36 | }, 37 | ], 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug, proposal 6 | assignees: dev-redo 7 | 8 | --- 9 | 10 | ## 버그를 설명해주세요 11 | 버그를 여기에 서술해주시길 바랍니다. 12 | 13 | 14 | 15 | ## 버그가 발생하는 과정을 설명해주세요 16 | 버그가 아래의 과정을 통해 발생됩니다. 17 | 1. ... 18 | 2. ... 19 | 3. ... 20 | 4. 버그가 발생했습니다. 21 | 22 | ## 기대했던 동작 23 | 익스텐션에서 기대했던 동작에 대해 설명해주세요. 24 | 25 | ## 스크린샷 26 | 자세한 설명을 위해 스크린샷 첨부를 부탁드립니다. (콘솔 등...) 27 | 28 | ## 추가 설명 29 | 추가로 버그에 대해 설명하고 싶은 사항이 있으시다면 여기에 부탁드립니다. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: proposal 6 | assignees: dev-redo 7 | 8 | --- 9 | 10 | ## 제안하고 싶은 기능을 설명해주세요 11 | 제안하고 싶은 기능을 여기에 설명해주세요. 12 | 13 | ## 추가 설명 14 | 추가로 설명하고 싶으신 사항이 있으시다면 여기에 설명해주세요. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/q-a.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Q&A 3 | about: Ask any questions you have about Pro-Solve 4 | title: '' 5 | labels: question 6 | assignees: dev-redo 7 | 8 | --- 9 | 10 | ## 질문 사항 11 | -------------------------------------------------------------------------------- /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | name: 'Chromatic Deployment' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | paths-ignore: 9 | - '**/*.md' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - run: yarn 17 | - uses: chromaui/action@v1 18 | with: 19 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # production 7 | /dist 8 | build.zip 9 | .firebase 10 | storybook-static 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | firebase-debug.log 24 | *.log -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "arrowParens": "avoid", 6 | "endOfLine": "auto" 7 | } 8 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 3 | 4 | module.exports = { 5 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 6 | addons: [ 7 | '@storybook/addon-links', 8 | '@storybook/addon-essentials', 9 | '@storybook/addon-interactions', 10 | ], 11 | framework: { 12 | name: '@storybook/react-webpack5', 13 | options: {}, 14 | }, 15 | core: { 16 | builder: 'webpack5', 17 | }, 18 | webpackFinal: async config => { 19 | const fileLoaderRule = config.module.rules.find(rule => rule.test && rule.test.test('.svg')); 20 | fileLoaderRule.exclude = /\.svg$/; 21 | 22 | config.module.rules.push({ 23 | test: /\.svg$/, 24 | enforce: 'pre', 25 | loader: require.resolve('@svgr/webpack'), 26 | }); 27 | 28 | config.resolve.plugins = [ 29 | ...(config.resolve.plugins || []), 30 | new TsconfigPathsPlugin({ 31 | configFile: path.resolve(__dirname, '../tsconfig.json'), 32 | }), 33 | ]; 34 | return config; 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from 'styled-components'; 2 | import { theme } from '../src/styles/theme'; 3 | import { RecoilRoot } from 'recoil'; 4 | import GlobalStyles from '../src/styles/global'; 5 | 6 | const themeDecorator = Story => ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | export const decorators = [themeDecorator]; 15 | 16 | export const parameters = { 17 | actions: { argTypesRegex: '^on[A-Z].*' }, 18 | controls: { 19 | matchers: { 20 | color: /(background|color)$/i, 21 | date: /Date$/, 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hsy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # 프로솔브(Pro-Solve) 2 | 3 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/pjffalefhahlellpckbbiehmbljjhihl)](https://chrome.google.com/webstore/detail/%ED%94%84%EB%A1%9C%EC%86%94%EB%B8%8Cpro-solve/pjffalefhahlellpckbbiehmbljjhihl) 4 | ![Chrome Web Store Users](https://img.shields.io/chrome-web-store/users/pjffalefhahlellpckbbiehmbljjhihl?label=users%40chrome) 5 | ![Chrome Web Store Rating Count](https://img.shields.io/chrome-web-store/rating-count/pjffalefhahlellpckbbiehmbljjhihl) 6 | ![Chrome Web Store Ratings](https://img.shields.io/chrome-web-store/rating/pjffalefhahlellpckbbiehmbljjhihl) 7 | [![sync-problems](https://github.com/dev-red5/programmers-problems/actions/workflows/sync-problems.yaml/badge.svg)](https://github.com/dev-red5/programmers-problems/actions/workflows/sync-problems.yaml) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | 10 | [![Chrome Web Store](https://storage.googleapis.com/chrome-gcs-uploader.appspot.com/image/WlD8wC6g8khYWPJUsQceQkhXSlv1/UV4C4ybeBTsZt43U4xis.png)](https://chrome.google.com/webstore/detail/%ED%94%84%EB%A1%9C%EC%86%94%EB%B8%8Cpro-solve/pjffalefhahlellpckbbiehmbljjhihl/related?hl=ko) 11 | 12 |
13 | 14 | ## ✨ 지원 기능 15 | 16 | 프로솔브는 **크롬 브라우저**에서만 이용할 수 있습니다. 17 | 18 | | **성공한 문제 차트** | **성공한 문제 표** | 19 | | :---------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------: | 20 | | | | 21 | | **풀이 저장** | **풀이 클립보드** | 22 | | | | 23 | 24 |
25 | 26 | ## 🎞 동작 화면 27 | 28 | https://user-images.githubusercontent.com/69149030/208983900-57fd1ad7-03f8-4345-ace4-cba6adaf4b09.mp4 29 | 30 |

성공한 문제 차트 & 표

31 | 32 |
33 | 34 | https://user-images.githubusercontent.com/69149030/194714332-ec61e267-1d86-42e3-89ee-93de7ef969ad.mp4 35 | 36 |

제출한 풀이 저장 및 보여주기

37 | 38 |
39 | 40 | https://user-images.githubusercontent.com/69149030/196757224-1fd436c6-cef2-45b7-931f-d216c19c3ae3.mp4 41 | 42 |

다른 사람 풀이 페이지의 코드 클립보드

43 | 44 |
45 | 46 | ## 💡 왜 만들게 되었나요? 47 | 48 | ### 기능 1. 성공한 문제 차트 & 표 49 | 50 | 현재 프로그래머스는 푼 문제 정보를 확인하고 일정 기준을 통해 정렬하기 위해 select 박스를 이용할 수 있습니다. 51 | 52 | 53 |

현재 프로그래머스의 모든 문제 페이지

54 | 55 |
56 | 57 | 좋은 기능이지만 유저가 각 레벨 문제를 몇 개(퍼센트) 풀었는지 확인하기 위해 select 박스를 하나하나 클릭해 계산해야 하는 번거로움이 있습니다.
58 | 그래서 백준의 solved.ac를 레퍼런스 삼아 성공한 문제 Chart와 표를 만들었습니다. 59 | 60 | Chart는 유저가 각 레벨 문제들을 전체 중 몇 개(퍼센트) 풀었는지, 레벨 비율은 어떤지를 확인할 수 있습니다. 61 | 62 | 표는 난이도와 완료한 사람, 정답률을 기준으로 정렬한 성공한 문제 list를 확인할 수 있습니다. 63 | 64 | 유저가 성공한 문제 정보를 받아오기 위해서는 프로그래머스 로그인이 필요합니다.
65 | 따라서 로그아웃된 상태일 시 로그인을 하게끔 alert를 띄워주고 있습니다. 66 | 67 |
68 | 69 | ### 기능 2. 제출한 풀이 저장 70 | 71 | 현재 프로그래머스는 각 언어의 성공한 첫 풀이만을 사용자에게 보여주고 있습니다. 72 | 73 | 이 점이 아쉬워 사용자가 프로그래머스 문제 풀이 제출 시 저장을 하고 보여주는 기능을 구현하였습니다. 74 | 75 |
76 | 77 | ### 기능 3. 풀이 클립보드 78 | 79 | 현재 프로그래머스는 다른 사람의 풀이 페이지에서 코드 클립보드 기능을 제공하고 있지 않습니다. 80 | 81 | 82 |

현재 프로그래머스의 다른 사람 풀이 페이지

83 | 84 |
85 | 86 | 코드가 길 시 드래그를 하며 복사하기 힘들어 클립보드 기능을 구현하였습니다. 87 | 88 |
89 | 90 | ## 🙋‍♀️ 어떻게 사용할 수 있나요? 91 | 92 | 프로솔브 익스텐션의 각 기능을 어떻게 이용할 수 있나요? 아래 문서들을 확인해주세요! 93 | 94 | - [성공한 문제 차트 & 표 기능 사용법](https://github.com/dev-redo/pro-solve/blob/main/md/HOW_TO_SEE_SOLUTION_INFO.md) 95 | - [제출한 풀이 저장 기능 사용법](https://github.com/dev-redo/pro-solve/blob/main/md/HOW_TO_SAVE_SOLUTION.md) 96 | 97 |
98 | 99 | ## 😲 Q&A 100 | 101 | 프로솔브 익스텐션을 이용하다가 궁금하신 점이 생기셨나요? 102 | 103 | [Q&A](https://github.com/dev-redo/pro-solve/blob/main/md/Q&A.md) 문서를 참고해주신 다음, 해당 문서에 존재하지 않는 질문일 시 이슈를 남겨주세요 104 | 105 |
106 | 107 | ## 📚 링크 & 문서 108 | 109 | - [전체 문제 저장소](https://github.com/dev-red5/programmers-problems) 110 | - [개발 회고록](https://velog.io/@dev-redo/%ED%94%84%EB%A1%9C%EC%86%94%EB%B8%8C-%ED%81%AC%EB%A1%AC-%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EA%B0%9C%EB%B0%9C-%ED%9B%84%EA%B8%B0) 111 | - [트러블슈팅](https://zircon-ambulance-d5e.notion.site/cce8099aab50406791681e3387b852d0?v=101c2a5127bf4c1887b23bfd23078901) 112 | -------------------------------------------------------------------------------- /assets/icons/ArrowDown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/ArrowRight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/ArrowUp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/BlackSpinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/icons/Book.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/icons/Chart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/icons/Check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/icons/DocumentCopy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/icons/Dropdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/ProfileLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/icons/WhiteSpinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 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 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /assets/icons/XCharacter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-redo/pro-solve/997b73da3e238c1c72ebabb3b719668faa770e33/assets/images/github.png -------------------------------------------------------------------------------- /assets/images/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-redo/pro-solve/997b73da3e238c1c72ebabb3b719668faa770e33/assets/images/logo-white.png -------------------------------------------------------------------------------- /assets/images/noContent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-redo/pro-solve/997b73da3e238c1c72ebabb3b719668faa770e33/assets/images/noContent.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: '> 0.25%' }], 4 | ['@babel/preset-react', { runtime: 'automatic' }], 5 | '@babel/preset-typescript', 6 | ], 7 | plugins: [ 8 | ['@babel/plugin-transform-runtime', { corejs: 3 }], 9 | '@babel/plugin-syntax-dynamic-import', 10 | ], 11 | }; 12 | 13 | // Fix: Storybook build시 오류 발생하는 설정 (env field 문제) 14 | // module.exports = { 15 | // presets: [ 16 | // ['@babel/preset-env', { targets: '> 0.25%' }], 17 | // ['@babel/preset-react', { runtime: 'automatic' }], 18 | // '@babel/preset-typescript', 19 | // ], 20 | // plugins: [ 21 | // ['@babel/plugin-transform-runtime', { corejs: 3 }], 22 | // '@babel/plugin-syntax-dynamic-import', 23 | // 'babel-plugin-styled-components', 24 | // ], 25 | // env: { 26 | // production: { 27 | // only: ['src'], 28 | // plugins: [ 29 | // ['babel-plugin-styled-components', { displayName: false }], 30 | // 'transform-react-remove-prop-types', 31 | // '@babel/plugin-transform-react-inline-elements', 32 | // '@babel/plugin-transform-react-constant-elements', 33 | // ], 34 | // }, 35 | // }, 36 | // }; 37 | -------------------------------------------------------------------------------- /build/app-paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const APP_PATH = pathDir => path.resolve(__dirname, `../${pathDir}`); 3 | 4 | module.exports = APP_PATH; 5 | -------------------------------------------------------------------------------- /build/optionalPlugin/webpack.bundleAnalyzer.js: -------------------------------------------------------------------------------- 1 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | new BundleAnalyzerPlugin({ 6 | analyzerMode: 'static', // 분석된 결과를 파일로 저장 7 | reportFilename: `bundle-size.html.html`, // 분석 결과 파일명 8 | openAnalyzer: true, // 웹팩 빌드 후 보고서파일을 자동으로 열지 여부 9 | }), 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /build/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const APP_PATH = require('./app-paths'); 4 | const HTML_PLUGIN = chunks => { 5 | return chunks.map( 6 | ({ chunk, title }) => 7 | new HtmlPlugin({ 8 | title: `${title}`, 9 | filename: `${chunk}.html`, 10 | chunks: [chunk], 11 | }), 12 | ); 13 | }; 14 | 15 | const Dotenv = require('dotenv-webpack'); 16 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 17 | const CopyPlugin = require('copy-webpack-plugin'); 18 | const HtmlPlugin = require('html-webpack-plugin'); 19 | const { env } = require('process'); 20 | 21 | module.exports = { 22 | entry: { 23 | popup: APP_PATH('src/pages/popup/index.tsx'), 24 | background: APP_PATH('src/pages/background/index.ts'), 25 | testContent: APP_PATH('src/pages/content/testPage.tsx'), 26 | solutionContent: APP_PATH('src/pages/content/solutionPage.tsx'), 27 | problemContent: APP_PATH('src/pages/content/problemPage.tsx'), 28 | profileTab: APP_PATH('src/pages/newTab/profile/index.tsx'), 29 | solutionTab: APP_PATH('src/pages/newTab/Solution.tsx'), 30 | }, 31 | output: { 32 | filename: 'script/[name].js', 33 | publicPath: './', 34 | path: APP_PATH('dist'), 35 | }, 36 | cache: { 37 | type: env.dev ? 'memory' : 'filesystem', 38 | buildDependencies: { 39 | config: [__filename], 40 | }, 41 | idleTimeout: 2000, 42 | }, 43 | module: { 44 | rules: [ 45 | { 46 | test: /\.ts(x?)$/, 47 | exclude: /node_modules/, 48 | loader: 'esbuild-loader', 49 | options: { 50 | loader: 'tsx', 51 | target: 'esnext', 52 | tsconfigRaw: require('../tsconfig.json'), 53 | }, 54 | }, 55 | { 56 | test: /\.css$/i, 57 | use: ['style-loader', 'css-loader'], 58 | }, 59 | { 60 | test: /\.svg$/i, 61 | use: ['@svgr/webpack'], 62 | }, 63 | ], 64 | }, 65 | resolve: { 66 | extensions: ['.tsx', '.ts', '.js', '.json'], 67 | alias: { 68 | '@src': APP_PATH('./src'), 69 | '@assets': APP_PATH('./assets'), 70 | }, 71 | }, 72 | plugins: [ 73 | new Dotenv({ 74 | path: '.env', 75 | }), 76 | new CopyPlugin({ 77 | patterns: [ 78 | { 79 | from: APP_PATH('src/static'), 80 | to: APP_PATH('dist'), 81 | }, 82 | ], 83 | }), 84 | new CleanWebpackPlugin({ 85 | cleanOnceBeforeBuildPatterns: ['**/*', path.resolve(process.cwd(), 'dist/**/*')], 86 | }), 87 | ...HTML_PLUGIN([ 88 | { chunk: 'popup', title: '프로솔브 - PopUp 페이지' }, 89 | { chunk: 'solutionTab', title: '프로솔브 - 문제 풀이 페이지' }, 90 | { chunk: 'profileTab', title: '프로솔브 - 나의 풀이 페이지' }, 91 | ]), 92 | ], 93 | }; 94 | -------------------------------------------------------------------------------- /build/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { HotModuleReplacementPlugin } = require('webpack'); 2 | 3 | module.exports = { 4 | mode: 'development', 5 | devtool: 'source-map', 6 | plugins: [new HotModuleReplacementPlugin()], 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.(jpe?g|png|gif)$/i, 11 | type: 'asset/inline', 12 | }, 13 | ], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /build/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { ESBuildMinifyPlugin } = require('esbuild-loader'); 2 | 3 | module.exports = { 4 | mode: 'production', 5 | devtool: 'hidden-source-map', 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.(jpe?g|png|gif)$/i, 10 | type: 'asset', 11 | parser: { 12 | dataUrlCondition: { 13 | maxSize: 10 * 1024, // 10KB 14 | }, 15 | }, 16 | use: [ 17 | { 18 | loader: 'image-webpack-loader', 19 | options: { 20 | mozjpeg: { 21 | progressive: true, 22 | quality: 65, 23 | }, 24 | optipng: { 25 | enabled: false, 26 | }, 27 | pngquant: { 28 | quality: [0.65, 0.9], 29 | speed: 4, 30 | }, 31 | gifsicle: { 32 | interlaced: false, 33 | }, 34 | webp: { 35 | quality: 75, 36 | }, 37 | }, 38 | }, 39 | ], 40 | }, 41 | ], 42 | }, 43 | optimization: { 44 | minimize: true, 45 | usedExports: true, 46 | minimizer: [ 47 | new ESBuildMinifyPlugin({ 48 | target: 'es2015', 49 | css: true, 50 | }), 51 | ], 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /md/HOW_TO_SAVE_SOLUTION.md: -------------------------------------------------------------------------------- 1 | # 제출한 문제 저장 기능 사용법 2 | 3 | ## 목차 4 | 5 | 1. [로그인](#로그인) 6 | 2. [풀이를 업로드 하는 방법](#풀이를-업로드-하는-방법) 7 | 3. [저장한 풀이들을 확인하는 방법](#저장한-풀이들을-확인하는-방법) 8 | 9 | ## 로그인 10 | 11 | 1. 크롬 웹스토어에서 [프로솔브](https://chrome.google.com/webstore/detail/%ED%94%84%EB%A1%9C%EC%86%94%EB%B8%8Cpro-solve/pjffalefhahlellpckbbiehmbljjhihl/related?hl=ko)를 다운받아주세요. 12 | 13 |

14 | 15 |

16 | 17 |
18 | 19 | 2. 다운받은 후 프로솔브 익스텐션을 클릭해주세요. 20 | 21 |

22 | 23 |

24 | 25 |
26 | 27 | 3. 그럼 아래와 같이 팝업창이 뜨게 됩니다. 로그인 버튼을 클릭해주세요. 28 |

29 | 30 |

31 | 32 |
33 | 34 | 4. 로그인을 해주세요. 35 |

36 | 37 | 38 |

39 | 40 |
41 | 42 | 5. 성공적으로 로그인을 마치게 되면 프로솔브 익스텐션 팝업창에 다음과 같이 로그인한 이메일이 보여지게 됩니다. 43 |

44 | 45 |

46 | 47 |
48 | 49 | ## 풀이를 업로드 하는 방법 50 | 51 | 1. 프로그래머스에서 풀이를 제출 시 익스텐션에서 풀이를 저장합니다. 52 |

53 | 54 |

55 | 56 | 2. 업로드 성공 시 성공했다는 문구를 띄워주게 됩니다. 57 | 58 |

59 | 60 |

61 | 62 |
63 | 64 | ## 저장한 풀이들을 확인하는 방법 65 | 66 | **저장된 모든 풀이 버튼**을 통해 저장한 풀이들을 확인할 수 있습니다.
67 | 68 | 1. 해당 버튼은 풀이 제출 시 띄워지는 모달과 다른 사람 풀이 페이지 두 곳에 있습니다. 69 | 70 |

71 | 72 | 73 |

74 | 75 | 2. 버튼을 클릭할 시 새로운 크롬 익스텐션 창이 생성되어 저장된 풀이들을 보여주게 됩니다. 76 | 77 |

78 | 79 |

80 | -------------------------------------------------------------------------------- /md/HOW_TO_SEE_SOLUTION_INFO.md: -------------------------------------------------------------------------------- 1 | # 성공한 문제 차트 & 표 기능 사용법 2 | 3 | 아래 `나의 풀이 버튼`을 클릭하면 성공한 문제 차트 & 표 tab이 생성됩니다. 4 | 5 | 6 | 7 |
8 | 9 | 버튼을 클릭하면 크롬 tab이 생성되어 다음과 같이 이용할 수 있습니다. 10 | 11 | https://user-images.githubusercontent.com/69149030/196756245-4cccaffe-3f1b-44f9-9309-1585142cb75f.mp4 12 | 13 |
14 | 15 | 나의 풀이 버튼은 `모든 문제` / `코딩테스트 입문` / `코딩테스트 고득점 Kit` / `SQL 고득점 Kit` 페이지에 존재합니다. 16 | 17 |
18 | 19 | 20 | 21 | 해당 기능은 프로그래머스 로그인이 필요한 서비스이므로 유저가 로그아웃 상태일 시 버튼을 클릭하면 로그인을 하도록 alert를 띄워주었습니다. 22 | -------------------------------------------------------------------------------- /md/Q&A.md: -------------------------------------------------------------------------------- 1 | # Q&A 2 | 3 | ## 1. 프로솔브에서 인증이 필요한 이유가 무엇인가요? 4 | 5 | 프로솔브 익스텐션에는 _제출한 풀이 저장_ 기능이 있습니다.
6 | 이 기능에서 유저들을 식별하고 각 유저의 풀이를 저장하기 위해 파이어베이스를 이용하였습니다.
7 | 인증에는 구글 Oauth를 이용하였으며, 유저가 한번 로그인한 계정으로 계속 이용하리라 판단하여 로그아웃 기능을 구현하지 않았습니다. 8 | 9 | ## 2. 인증 유효기간은 어느 정도 되나요? 10 | 11 | 프로솔브는 크롬 익스텐션 api인 **getAuthToken**를 이용해 파이어베이스에서 발급받은 구글 Oauth 토큰을 캐싱합니다.
12 | 이를 통해 익스텐션을 사용하는 동안 계속 인증을 유지하게끔 구현하였습니다.
13 | 14 | 하지만 로그인한 계정의 비밀번호가 바뀌게 될 시 다시 토큰을 재발급해야 하는데, 이 경우 로그인을 다시 해주셔야 합니다. 15 | 16 | ## 3. 성공한 문제 차트 & 표에 제가 푼 문제 중 몇 개가 보이지 않아요! 17 | 18 | 해당 이슈는 프로그래머스 전체 문제 list를 저장하는 jsDelivr cdn이 업데이트 되지 않았기에 발생하는 이슈입니다. 19 | 20 | 프로솔브가 유저가 전체 문제와 성공한 문제 list를 받아오기 위해 아래의 flow를 따르고 있습니다. 21 | 22 | - 해당 익스텐션이 install될 때 프로그래머스 api를 이용해 성공한 문제 id list를 받아옵니다. 23 | - 성공한 문제 차트 & 표 tab에 진입 시 jsDelivr cdn으로부터 전체 문제 list를 받아옵니다. 24 | - 전체 문제 list는 github action을 이용해 매일 6시간마다 크롤링 한 후 jsDelivr Opensource CDN를 이용해 제공하고 있습니다. 25 | - [해당 cdn 주소](https://cdn.jsdelivr.net/gh/dev-redo/programmers-problems@main/problems.json)로 request하여 전체 문제 list를 받아옵니다. 26 | - [전체 문제를 저장하는 리포지토리](https://github.com/dev-redo/programmers-problems) 27 | - 전체 문제 list를 이용해 성공한 문제 정보 list를 얻습니다. 28 | 29 | 따라서 추가된 문제를 업데이트하지 못했을 시 그 문제는 보여지지 않게 됩니다. 30 | 31 | 그러므로 해당 이슈를 경험하셨더라도 6시간마다 진행되는 업데이트 때마다 해결될 것입니다. 32 | 33 | ## 4. 풀이 업로드가 되지 않아요! 34 | 35 | 풀이가 업로드 되지 않는 원인에는 2가지가 있습니다. 36 | 37 | 1. 만약 로그인을 하지 않았을 시 업로드에 실패하게 됩니다. 38 | 39 |

40 | 41 |

42 | 43 | 2. 프로솔브 익스텐션 세부사항을 변경할 시(ex. 익스텐션을 시크릿 모드에서 허용) 새로고침을 해주어야 합니다. 44 | 만약 새로고침을 안 할 시 업로드에 실패하게 됩니다. 45 | 46 |

47 | 48 |

49 | 50 | 두 가지 원인이 아님에도 풀이가 업로드되지 않는다면 이슈를 작성해주시길 바랍니다. 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosolve", 3 | "version": "1.1.1", 4 | "main": "index.js", 5 | "pretty": "prettier --write \"src/**/*.(ts|tsx)\"", 6 | "scripts": { 7 | "bundle": "webpack --watch --progress --config webpack.config.js --env env=dev", 8 | "bundle:bundleAnalyzer": "yarn run bundle -- --env optionalPlugin=bundleAnalyzer", 9 | "build": "webpack --progress --config webpack.config.js --env env=prod", 10 | "build:bundleAnalyzer": "yarn run build -- --env optionalPlugin=bundleAnalyzer", 11 | "build-zip": "yarn run build && bestzip build.zip dist/", 12 | "storybook": "npx storybook dev -p 6006", 13 | "build-storybook": "npx storybook build", 14 | "chromatic": "npx chromatic --project-token=%CHROMATIC_PROJECT_TOKEN%", 15 | "prepare": "husky install", 16 | "lint": "eslint src" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/dev-redo/pro-solve.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/dev-redo/pro-solve/issues" 24 | }, 25 | "homepage": "https://github.com/dev-redo/pro-solve#readme", 26 | "browser": { 27 | "[module-name]": false 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.19.6", 31 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 32 | "@babel/plugin-transform-react-constant-elements": "^7.18.12", 33 | "@babel/plugin-transform-react-inline-elements": "^7.18.6", 34 | "@babel/plugin-transform-runtime": "^7.18.10", 35 | "@babel/preset-env": "^7.18.10", 36 | "@babel/preset-react": "^7.18.6", 37 | "@babel/runtime-corejs3": "^7.20.13", 38 | "@storybook/addon-essentials": "7.0.0-alpha.35", 39 | "@storybook/addon-interactions": "7.0.0-alpha.35", 40 | "@storybook/addon-links": "7.0.0-alpha.35", 41 | "@storybook/jest": "^0.0.10", 42 | "@storybook/preset-typescript": "^3.0.0", 43 | "@storybook/react": "7.0.0-alpha.35", 44 | "@storybook/react-webpack5": "7.0.0-alpha.35", 45 | "@storybook/testing-library": "^0.0.13", 46 | "@svgr/webpack": "^6.3.1", 47 | "@types/chrome": "^0.0.195", 48 | "@types/cors": "^2.8.12", 49 | "@types/react": "^18.0.17", 50 | "@types/react-dom": "^18.0.6", 51 | "@types/react-syntax-highlighter": "^15.5.5", 52 | "@types/styled-components": "^5.1.26", 53 | "@typescript-eslint/eslint-plugin": "^5.25.0", 54 | "@typescript-eslint/parser": "^5.25.0", 55 | "babel-plugin-styled-components": "^2.0.7", 56 | "babel-plugin-transform-react-remove-prop-types": "^0.4.24", 57 | "clean-webpack-plugin": "^4.0.0", 58 | "copy-webpack-plugin": "^11.0.0", 59 | "css-loader": "^6.7.1", 60 | "dotenv-webpack": "^8.0.1", 61 | "esbuild-loader": "^2.19.0", 62 | "eslint": "^8.16.0", 63 | "eslint-config-prettier": "^8.5.0", 64 | "eslint-plugin-import": "^2.26.0", 65 | "eslint-plugin-jsx-a11y": "^6.6.1", 66 | "eslint-plugin-prettier": "^4.0.0", 67 | "eslint-plugin-react": "^7.31.10", 68 | "eslint-plugin-react-hooks": "^4.6.0", 69 | "eslint-plugin-storybook": "^0.6.6", 70 | "extract-text-webpack-plugin": "^3.0.2", 71 | "firebase": "^9.9.3", 72 | "firebase-admin": "^11.0.1", 73 | "firebase-tools": "^11.8.0", 74 | "fork-ts-checker-webpack-plugin": "^7.2.13", 75 | "html-webpack-plugin": "^5.5.0", 76 | "husky": "^8.0.1", 77 | "lint-staged": "^13.0.3", 78 | "mini-css-extract-plugin": "^2.6.1", 79 | "speed-measure-webpack-plugin": "^1.5.0", 80 | "storybook": "7.0.0-alpha.35", 81 | "style-loader": "^3.3.1", 82 | "svgo-loader": "^4.0.0", 83 | "terser-webpack-plugin": "^5.3.6", 84 | "tsconfig-paths-webpack-plugin": "^4.0.0", 85 | "webpack": "^5.74.0", 86 | "webpack-bundle-analyzer": "^4.8.0", 87 | "webpack-cli": "^4.10.0", 88 | "webpack-dev-middleware": "^5.3.3", 89 | "webpack-virtual-modules": "^0.4.5", 90 | "yarn": "^1.22.19" 91 | }, 92 | "dependencies": { 93 | "@storybook/addon-actions": "^6.5.16", 94 | "@storybook/addon-docs": "^6.5.16", 95 | "@types/estree": "^1.0.0", 96 | "bestzip": "^2.2.1", 97 | "chart.js": "^3.9.1", 98 | "core-js": "^3.28.0", 99 | "cors": "^2.8.5", 100 | "expo-font": "^10.2.0", 101 | "image-webpack-loader": "^8.1.0", 102 | "react": "^18.2.0", 103 | "react-chartjs-2": "^4.3.1", 104 | "react-dom": "^18.2.0", 105 | "react-syntax-highlighter": "^15.5.0", 106 | "react-uid": "^2.3.2", 107 | "react-use": "^17.4.0", 108 | "recoil": "^0.7.5", 109 | "storybook-addon-react-docgen": "^1.2.43", 110 | "styled-components": "^5.3.5", 111 | "styled-reset": "^4.4.2", 112 | "typescript": "^4.8.2" 113 | }, 114 | "lint-staged": { 115 | "*.{ts,ts}": [ 116 | "prettier --write", 117 | "eslint --fix" 118 | ] 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/api/login/getCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@src/firebase'; 2 | import { User } from 'firebase/auth'; 3 | 4 | type GetCurrentUserFn = () => Promise; 5 | const getCurrentUser: GetCurrentUserFn = () => 6 | new Promise((resolve, reject) => 7 | auth.onAuthStateChanged( 8 | user => resolve(user), 9 | error => reject(error), 10 | ), 11 | ); 12 | 13 | export { getCurrentUser }; 14 | -------------------------------------------------------------------------------- /src/api/solution/addSolvedProblemId.ts: -------------------------------------------------------------------------------- 1 | import { getUserEmailStorage, getSuccessProblemsIdListStorage } from './getUserInfoStorage'; 2 | 3 | const addSolvedProblemId = async (id: number, isSuccess: boolean) => { 4 | if (!isSuccess) return; 5 | 6 | const { userEmail } = await getUserEmailStorage(); 7 | const solvedProblem = await getSuccessProblemsIdListStorage(userEmail); 8 | 9 | if (solvedProblem.includes(id)) { 10 | console.log(`[Pro-Solve] 이전에 성공한 문제입니다. :>> ${id}`); 11 | return; 12 | } 13 | 14 | return chrome.storage.local.set({ 15 | [userEmail]: [...solvedProblem, id], 16 | }); 17 | }; 18 | 19 | export { addSolvedProblemId }; 20 | -------------------------------------------------------------------------------- /src/api/solution/getSuccessProblemIdList.ts: -------------------------------------------------------------------------------- 1 | import { PROBLEM_URL } from '@src/constants/url'; 2 | import { getJSON } from '@src/utils/fetchRequest'; 3 | 4 | interface Problem { 5 | id: string; 6 | title: string; 7 | partTitle: string; 8 | level: number; 9 | finishedCount: number; 10 | acceptanceRate: number; 11 | status: 'solved' | 'unsolved'; 12 | openedAt: string; 13 | contentUpdatedAt: null | string; 14 | } 15 | 16 | interface FetchedProblemList { 17 | page: number; 18 | perPage: number; 19 | totalPages: number; 20 | totalEntries: number; 21 | languages: 22 | | 'c' 23 | | 'cpp' 24 | | 'csharp' 25 | | 'go' 26 | | 'java' 27 | | 'javascript' 28 | | 'kotlin' 29 | | 'python' 30 | | 'python3' 31 | | 'ruby' 32 | | 'scala' 33 | | 'swift' 34 | | 'mysql' 35 | | 'oracle'; 36 | result: Problem[]; 37 | } 38 | 39 | const getSuccessProblemIdList = async () => { 40 | const { totalPages } = await getJSON({ url: PROBLEM_URL + 1 }); 41 | const promisedFetchedDataList = [...new Array(totalPages)].map((_, idx) => 42 | fetchProblemPageList(idx + 1), 43 | ); 44 | 45 | const fetchedDataList = await Promise.all(promisedFetchedDataList); 46 | return fetchedDataList 47 | .reduce((prev, curr) => [...prev, ...curr], []) 48 | .map(({ id }: { id: string }) => id); 49 | }; 50 | 51 | export { getSuccessProblemIdList }; 52 | 53 | const fetchProblemPageList = async (pageNum: number) => 54 | (await getJSON({ url: PROBLEM_URL + pageNum })).result; 55 | -------------------------------------------------------------------------------- /src/api/solution/getUserEmail.ts: -------------------------------------------------------------------------------- 1 | import { USER_INFO_URL as url } from '@src/constants/url'; 2 | import { getJSON } from '@src/utils/fetchRequest'; 3 | 4 | interface UserInfo { 5 | isLoggedIn: boolean; 6 | timeZone: string; 7 | userInfo: { 8 | id: number; 9 | name: string; 10 | email: string; 11 | profileImageUrl: string; 12 | confirmed: boolean; 13 | isCustomer: boolean; 14 | isAdmin: boolean; 15 | abTestGroup: string; 16 | }; 17 | } 18 | 19 | const getUserEmail = async () => { 20 | const { userInfo } = await getJSON({ url }); 21 | if (userInfo === undefined) return; 22 | 23 | const { email } = await userInfo; 24 | return email; 25 | }; 26 | 27 | export { getUserEmail }; 28 | -------------------------------------------------------------------------------- /src/api/solution/getUserInfoStorage.ts: -------------------------------------------------------------------------------- 1 | const getUserEmailStorage = async () => chrome.storage.local.get('userEmail'); 2 | 3 | type functionType = (userEmail: string) => Promise; 4 | const getSuccessProblemsIdListStorage: functionType = async (userEmail: string) => 5 | (await chrome.storage.local.get([userEmail]))[userEmail]; 6 | 7 | export { getUserEmailStorage, getSuccessProblemsIdListStorage }; 8 | -------------------------------------------------------------------------------- /src/api/solution/setUserInfoStorage.ts: -------------------------------------------------------------------------------- 1 | import { getUserEmail } from './getUserEmail'; 2 | import { getSuccessProblemIdList } from './getSuccessProblemIdList'; 3 | 4 | const setUserInfoStorage = async () => { 5 | const userEmail = await setUserEmailStorage(); 6 | await setSuccessProblemsIdListStorage(userEmail!); 7 | }; 8 | 9 | const setUserEmailStorage = async () => { 10 | const newUserEmail = await getUserEmail(); 11 | 12 | await chrome.storage.local.set({ 13 | userEmail: newUserEmail, 14 | }); 15 | 16 | return newUserEmail; 17 | }; 18 | 19 | const setSuccessProblemsIdListStorage = async (userEmail: string) => { 20 | console.log('[Pro-Solve] 현재 로그인한 프로그래머스 계정 이메일 :>> ', userEmail); 21 | 22 | if (userEmail === undefined) return; 23 | const response = await chrome.storage.local.get([userEmail]); 24 | const solvedProblemsIdList = response[userEmail] ?? (await getSuccessProblemIdList()); 25 | 26 | return chrome.storage.local.set({ 27 | [userEmail]: solvedProblemsIdList, 28 | }); 29 | }; 30 | 31 | export { setUserInfoStorage }; 32 | -------------------------------------------------------------------------------- /src/components/domain/profile/Header.tsx: -------------------------------------------------------------------------------- 1 | import 'chart.js/auto'; 2 | 3 | import { GNBStyle } from '@src/styles/global'; 4 | import LogoWhite from '@assets/images/logo-white.png'; 5 | 6 | const Header = () => ( 7 | 8 | 9 |
10 | 나의 풀이 페이지 11 |
12 |
13 | ); 14 | 15 | export default Header; 16 | -------------------------------------------------------------------------------- /src/components/domain/profile/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useRecoilState } from 'recoil'; 4 | import { uid } from 'react-uid'; 5 | 6 | import { navOption } from '@src/store/profile'; 7 | import { NavType } from '@src/types/profile/profile-tab'; 8 | import { NAV_LIST, NAV_TYPE } from '@src/constants/profile'; 9 | 10 | const NavBar = () => ( 11 | 12 | {NAV_LIST.map((item, idx) => ( 13 | 14 | ))} 15 | 16 | ); 17 | 18 | const NavItem = ({ item }: { item: string }) => { 19 | const [selectedItem, setSelectedItem] = useRecoilState(navOption); 20 | const itemName = (NAV_TYPE as NavType)[item]; 21 | const onChangeOption = () => setSelectedItem(item); 22 | 23 | return ( 24 | 25 | {itemName} 26 | 27 | ); 28 | }; 29 | 30 | export default React.memo(NavBar); 31 | 32 | const NavStyle = styled.nav` 33 | display: flex; 34 | width: 100%; 35 | border-bottom: 2px solid ${({ theme }) => theme.color.grey}; 36 | padding: 0 2rem; 37 | margin-top: 0.5rem; 38 | font-family: 'Noto Sans KR'; 39 | font-size: 1.1rem; 40 | cursor: pointer; 41 | `; 42 | 43 | const NavItemStyle = styled.span<{ selected: boolean }>` 44 | padding: 1rem 2.5rem; 45 | font-weight: 300; 46 | padding: 1rem 2.5rem; 47 | font-weight: ${({ selected }) => (selected ? 500 : 300)}; 48 | border-bottom: ${({ selected }) => selected && '2px solid black'}; 49 | `; 50 | -------------------------------------------------------------------------------- /src/components/domain/solution/Content.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { uid } from 'react-uid'; 3 | import Spinner from '@assets/icons/BlackSpinner.svg'; 4 | import { CenterContainer } from '@src/styles/global'; 5 | import Code from '@src/components/shared/code/Code'; 6 | import '@src/styles/font.css'; 7 | import { Solution, SolutionResponse } from '@src/types/solution'; 8 | import { LoaderStyle } from '@src/styles/global'; 9 | import { filteredSolutions } from '@src/service/solution'; 10 | 11 | interface ContentProps { 12 | isLoaded: boolean; 13 | solutions: SolutionResponse; 14 | } 15 | 16 | const Content = ({ isLoaded, solutions }: ContentProps) => { 17 | const { status, data } = solutions; 18 | const submittedSolutions = filteredSolutions(data!); 19 | 20 | if (!isLoaded) { 21 | return ( 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | if (status === false) { 29 | return ( 30 | 31 | 로그인을 하지 않아 풀이를 받아오는 데 실패했습니다. 32 | 로그인을 해주세요. 33 | 34 | ); 35 | } 36 | 37 | return ( 38 | <> 39 | {submittedSolutions!.length > 0 && ( 40 | 41 | {submittedSolutions!.map((solution: Solution, index: number) => ( 42 | 43 | ))} 44 | 45 | )} 46 | {submittedSolutions!.length === 0 && 저장된 풀이가 없습니다.} 47 | 48 | ); 49 | }; 50 | 51 | export default Content; 52 | 53 | const RequestLoginStyle = styled(CenterContainer)` 54 | display: flex; 55 | flex-direction: column; 56 | text-align: center; 57 | gap: 0.6rem; 58 | line-height: 1.65rem; 59 | font-size: 1.1rem; 60 | font-weight: 400; 61 | color: ${({ theme }) => theme.color.darkGrey}; 62 | 63 | ${({ theme }) => theme.media.tablet` 64 | font-size: 0.9rem; 65 | line-height: 1.25rem; 66 | `} 67 | `; 68 | 69 | const NoContentStyle = styled(CenterContainer)` 70 | font-size: 1.1rem; 71 | font-weight: 400; 72 | color: ${({ theme }) => theme.color.darkGrey}; 73 | `; 74 | 75 | const ContentStyle = styled.div` 76 | padding: 1rem 8rem; 77 | 78 | ${({ theme }) => theme.media.tablet` 79 | padding: 1rem 5rem; 80 | `} 81 | `; 82 | -------------------------------------------------------------------------------- /src/components/domain/solution/Header.tsx: -------------------------------------------------------------------------------- 1 | import LogoWhite from '@assets/images/logo-white.png'; 2 | import ArrowRight from '@assets/icons/ArrowRight.svg'; 3 | import { GNBStyle, CenterContainer } from '@src/styles/global'; 4 | import '@src/styles/font.css'; 5 | 6 | interface HeaderProps { 7 | selectedLanguage: string; 8 | problemName: string; 9 | } 10 | 11 | const Header = ({ selectedLanguage, problemName }: HeaderProps) => { 12 | selectedLanguage = selectedLanguage.replace(/^[a-z]/, char => char.toUpperCase()); 13 | 14 | return ( 15 | 16 | 17 |
18 | 저장된 모든 풀이 19 | 20 | 21 | 22 | 23 | [{selectedLanguage}] {problemName} 24 | 25 |
26 |
27 | ); 28 | }; 29 | 30 | export default Header; 31 | -------------------------------------------------------------------------------- /src/components/domain/solution/SelectList.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import SolutionSelect from '@src/components/shared/select/SolutionSelect'; 3 | import SortSelect from '@src/components/shared/select/SortSelect'; 4 | import '@src/styles/font.css'; 5 | 6 | const SelectList = ({ isLoaded }: { isLoaded: boolean }) => { 7 | if (!isLoaded) { 8 | return <>; 9 | } 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | const SelectStyle = styled.div` 20 | display: flex; 21 | justify-content: flex-end; 22 | padding: 2rem 8rem; 23 | gap: 1rem; 24 | 25 | ${({ theme }) => theme.media.tablet` 26 | padding: 2rem 5rem; 27 | `}; 28 | `; 29 | 30 | export default SelectList; 31 | -------------------------------------------------------------------------------- /src/components/domain/testPage/CreateMemoListButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Modal from '@src/components/shared/modal/Modal'; 4 | import RefreshRequestBox from '@src/components/shared/box/RefreshRequestBox'; 5 | 6 | interface ButtonProps { 7 | problemId: string; 8 | problemName: string; 9 | } 10 | 11 | const CreateMemoListButton = (href: ButtonProps) => { 12 | const [isModalOpen, setIsModalOpen] = React.useState(false); 13 | 14 | const createMemoTab = () => { 15 | if (chrome.runtime?.id === undefined) { 16 | return setIsModalOpen(true); 17 | } 18 | 19 | chrome.runtime.sendMessage({ 20 | method: 'createMemoTab', 21 | href, 22 | }); 23 | }; 24 | 25 | return ( 26 | <> 27 | setIsModalOpen(false)}> 28 | 새로고침을 해주세요! 29 | 30 | 아이디어 아카이빙 31 | 32 | ); 33 | }; 34 | 35 | const ButtonStyle = styled.button` 36 | background-color: ${({ theme }) => theme.color.darkGrey}; 37 | color: ${({ theme }) => theme.color.white}; 38 | border-radius: 0.25rem; 39 | border: none; 40 | padding: 0.125rem 0.375rem; 41 | font-size: 0.75rem; 42 | line-height: 1.5rem; 43 | font-weight: 500; 44 | transition: color 0.08s ease-in-out, background-color 0.08s ease-in-out, 45 | border-color 0.08s ease-in-out, box-shadow 0.08s ease-in-out; 46 | `; 47 | 48 | export default CreateMemoListButton; 49 | -------------------------------------------------------------------------------- /src/components/shared/box/RefreshRequestBox.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { theme } from '@src/styles/theme'; 3 | 4 | type BoxProps = { 5 | children: React.ReactNode; 6 | backgroundColor?: string; 7 | color?: string; 8 | }; 9 | 10 | const RefreshRequestBox = ({ children, backgroundColor, color }: BoxProps) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | }; 17 | 18 | const BoxStyle = styled.div` 19 | transform: translate(-50%, 0%); 20 | background-color: ${({ backgroundColor }) => backgroundColor || theme.color.grey}; 21 | padding: 0.4375rem 0.8125rem; 22 | color: ${({ color }) => color || theme.color.black}; 23 | font-size: 1rem; 24 | font-weight: 500; 25 | border-radius: 0.25rem; 26 | box-shadow: 0 0.25rem 0.5rem rgb(20 20 84 / 4%), 0 0.5rem 1.125rem rgb(20 20 84 / 8%), 27 | 0 1rem 2rem -0.125rem rgb(20 20 84 / 8%), 0 0 0 0.0625rem rgb(20 20 84 / 12%); 28 | `; 29 | 30 | export default RefreshRequestBox; 31 | -------------------------------------------------------------------------------- /src/components/shared/box/SkeletonUI.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | interface SkeletonProps { 5 | width?: string; 6 | height?: string; 7 | borderRadius?: string; 8 | animationDuration?: number; 9 | } 10 | 11 | const SkeletonUI = ({ width, height, borderRadius, animationDuration }: SkeletonProps) => { 12 | return ( 13 | 19 | ); 20 | }; 21 | 22 | const SkeletonPulse = keyframes` 23 | 0% { 24 | opacity: 1; 25 | } 26 | 27 | 50% { 28 | opacity: 0.5; 29 | } 30 | 31 | 100% { 32 | opacity: 1; 33 | } 34 | `; 35 | 36 | const SkeletonStyle = styled.div` 37 | width: ${({ width }) => width || '100%'}; 38 | height: ${({ height }) => height || '16px'}; 39 | border-radius: ${({ borderRadius }) => borderRadius || '4px'}; 40 | background-color: ${({ theme }) => theme.color.darkGrey}; 41 | animation: ${SkeletonPulse} ${({ animationDuration }) => animationDuration || 1}s infinite 42 | ease-in-out; 43 | `; 44 | 45 | export default React.memo(SkeletonUI); 46 | -------------------------------------------------------------------------------- /src/components/shared/button/CopyClipBoardButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import DocumentCopy from '@assets/icons/DocumentCopy.svg'; 4 | import XCharacter from '@assets/icons/XCharacter.svg'; 5 | import Check from '@assets/icons/Check.svg'; 6 | import '@src/styles/font.css'; 7 | 8 | interface ClibBoardProps { 9 | codeText: string; 10 | } 11 | 12 | const CopyClipBoardButton = ({ codeText }: ClibBoardProps) => { 13 | const { isCopy, copyToClipboard } = useCopyToClipboard(); 14 | 15 | return ( 16 | copyToClipboard(codeText)}> 17 | {isCopy === null && } 18 | {isCopy === true && ( 19 | <> 20 | 21 | 복사 성공! 22 | 23 | )} 24 | {isCopy === false && ( 25 | <> 26 | 27 | 복사 실패 28 | 29 | )} 30 | 31 | ); 32 | }; 33 | 34 | type CopiedValue = boolean | null; 35 | type CopyFn = (text: string) => Promise; 36 | 37 | const useCopyToClipboard = () => { 38 | const [isCopy, setIsCopy] = React.useState(null); 39 | 40 | const copyToClipboard: CopyFn = async text => { 41 | if (!navigator?.clipboard) { 42 | alert('클립보드가 지원되지 않는 브라우저입니다.'); 43 | } 44 | 45 | try { 46 | await navigator.clipboard.writeText(text); 47 | setIsCopy(true); 48 | setTimeout(() => { 49 | setIsCopy(null); 50 | }, 3000); 51 | } catch (error) { 52 | if (error instanceof Error) { 53 | console.error(`풀이 복사에 실패했습니다! :>> ${error.message}`); 54 | 55 | setIsCopy(false); 56 | setTimeout(() => { 57 | setIsCopy(null); 58 | }, 3000); 59 | } 60 | } 61 | }; 62 | 63 | return { isCopy, copyToClipboard }; 64 | }; 65 | 66 | const CopyButton = styled.button` 67 | display: flex; 68 | align-items: center; 69 | position: absolute; 70 | top: 1.5rem; 71 | right: 1.5rem; 72 | background-color: transparent; 73 | border: none; 74 | svg:hover path { 75 | stroke: #5b7af9; 76 | } 77 | `; 78 | 79 | const ToolTipStyle = styled.p` 80 | position: absolute; 81 | width: 5rem; 82 | padding: 0.4rem; 83 | top: 2.2rem; 84 | right: -1.7rem; 85 | -webkit-border-radius: 8px; 86 | -moz-border-radius: 8px; 87 | border-radius: 0.5rem; 88 | background: ${({ theme }) => theme.color.steelGrey}; 89 | color: ${({ theme }) => theme.color.white}; 90 | font-size: 0.85rem; 91 | font-family: 'NanumSquareRound', sans-serif; 92 | font-weight: 400; 93 | &:after { 94 | position: absolute; 95 | bottom: 100%; 96 | left: 50%; 97 | width: 0; 98 | height: 0; 99 | margin-left: -10px; 100 | border: solid transparent; 101 | border-color: rgba(51, 51, 51, 0); 102 | border-bottom-color: ${({ theme }) => theme.color.steelGrey}; 103 | border-width: 10px; 104 | pointer-events: none; 105 | content: ' '; 106 | } 107 | `; 108 | 109 | export default CopyClipBoardButton; 110 | -------------------------------------------------------------------------------- /src/components/shared/button/CreateSolutionsButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Modal from '@src/components/shared/modal/Modal'; 4 | import RefreshRequestBox from '@src/components/shared/box/RefreshRequestBox'; 5 | 6 | interface ButtonProps { 7 | selectedLanguage: string; 8 | problemId: string; 9 | problemName: string; 10 | } 11 | 12 | const CreateSolutionsButton = (href: ButtonProps) => { 13 | const [isModalOpen, setIsModalOpen] = React.useState(false); 14 | 15 | const createSolutionTab = () => { 16 | if (chrome.runtime?.id === undefined) { 17 | return setIsModalOpen(true); 18 | } 19 | 20 | chrome.runtime.sendMessage({ 21 | method: 'createSolutionsTab', 22 | href, 23 | }); 24 | }; 25 | 26 | return ( 27 | <> 28 | setIsModalOpen(false)}> 29 | 새로고침을 해주세요! 30 | 31 | 저장된 모든 풀이 32 | 33 | ); 34 | }; 35 | 36 | const ButtonStyle = styled.button` 37 | background-color: ${({ theme }) => theme.color.darkGrey}; 38 | margin: 0; 39 | border-radius: 0.25rem; 40 | color: ${({ theme }) => theme.color.white}; 41 | border: 1px solid ${({ theme }) => theme.color.darkGrey}; 42 | padding: 0.4375rem 0.8125rem; 43 | font-size: 1rem; 44 | line-height: 1.5rem; 45 | border: 1px solid transparent; 46 | font-weight: 500; 47 | transition: color 0.08s ease-in-out, background-color 0.08s ease-in-out, 48 | border-color 0.08s ease-in-out, box-shadow 0.08s ease-in-out; 49 | 50 | ${({ theme }) => theme.media.tablet` 51 | padding: 0.3125rem 0.8125rem; 52 | font-size: 0.875rem; 53 | line-height: 1.25rem; 54 | `} 55 | `; 56 | 57 | export default CreateSolutionsButton; 58 | -------------------------------------------------------------------------------- /src/components/shared/button/GoogleLoginButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Spinner from '@assets/icons/WhiteSpinner.svg'; 4 | import { auth } from '@src/firebase'; 5 | import { GoogleAuthProvider, signInWithCredential } from 'firebase/auth'; 6 | import '@src/styles/font.css'; 7 | 8 | export default function GoogleLoginButton() { 9 | const [isLoaded, setIsLoaded] = React.useState(false); 10 | 11 | return ( 12 | { 14 | setIsLoaded(true); 15 | getGoogleAuthCredential(); 16 | }} 17 | > 18 | {isLoaded || Sign in with Google} 19 | {isLoaded && } 20 | 21 | ); 22 | } 23 | 24 | type GoogleLoginFn = () => void; 25 | const getGoogleAuthCredential: GoogleLoginFn = () => { 26 | chrome.identity.getAuthToken({ interactive: true }, token => { 27 | if (chrome.runtime.lastError || !token) { 28 | alert( 29 | `[Pro-Solve] Token을 받아오던 중 문제가 발생했습니다! :>> ${ 30 | chrome.runtime.lastError!.message 31 | }`, 32 | ); 33 | return; 34 | } 35 | 36 | const credential = GoogleAuthProvider.credential(null, token); 37 | console.log('[Pro-Solve] Firebase Google Oauth credential :>> ', credential); 38 | signInWithCredential(auth, credential) 39 | .then(firebaseAuth => { 40 | console.log('[Pro-Solve] Firebase 사용자 인증 정보 :>>', firebaseAuth); 41 | }) 42 | .catch(error => { 43 | if (error.code === 'auth/invalid-credential') { 44 | chrome.identity.removeCachedAuthToken({ token }, getGoogleAuthCredential); 45 | } 46 | }); 47 | }); 48 | }; 49 | 50 | const GoogleLoginButtonStyle = styled.button` 51 | width: 100%; 52 | height: 2.5rem; 53 | background-color: ${({ theme }) => theme.color.skyBlue}; 54 | margin-top: 0.4rem; 55 | font-size: 1.2rem; 56 | font-family: 'Noto Sans KR', sans-serif; 57 | font-weight: 500; 58 | color: ${({ theme }) => theme.color.white}; 59 | & > svg { 60 | width: 1.35rem; 61 | height: 1.35rem; 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /src/components/shared/code/Code.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import CodeMirror from './CodeMirror'; 4 | import SubmissionDetail from './SubmissionDetail'; 5 | import { Solution } from '@src/types/solution'; 6 | 7 | const Code = ({ solution }: { solution: Solution }) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | const CodeStyle = styled.div` 17 | margin-bottom: 4rem; 18 | `; 19 | 20 | export default React.memo(Code); 21 | -------------------------------------------------------------------------------- /src/components/shared/code/CodeMirror.tsx: -------------------------------------------------------------------------------- 1 | import styled, { createGlobalStyle } from 'styled-components'; 2 | import CopyClipBoardButton from '@src/components/shared/button/CopyClipBoardButton'; 3 | import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; 4 | import { Solution } from '@src/types/solution'; 5 | import js from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript'; 6 | import hybrid from 'react-syntax-highlighter/dist/esm/styles/hljs/hybrid'; 7 | 8 | interface SolutionProps { 9 | solution: Solution; 10 | } 11 | 12 | SyntaxHighlighter.registerLanguage('javascript', js); 13 | 14 | const GlobalStyle = createGlobalStyle` 15 | code { 16 | font-family: 'Inconsolata', sans-serif; 17 | } 18 | `; 19 | 20 | const CodeMirror = ({ solution }: SolutionProps) => { 21 | const { code, selectedLanguage } = solution; 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 40 | {code} 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | const CodeStyle = styled.div` 48 | width: 100%; 49 | position: relative; 50 | `; 51 | 52 | export default CodeMirror; 53 | -------------------------------------------------------------------------------- /src/components/shared/code/SubmissionDetail.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Solution } from '@src/types/solution'; 3 | import { formatDateToYmdhms } from '@src/utils/date/formatDateToYmdhms'; 4 | import '@src/styles/font.css'; 5 | import { formatTimestampToDate } from '@src/utils/date/formatTimestampToDate'; 6 | 7 | interface SolutionProps { 8 | solution: Solution; 9 | } 10 | 11 | const SubmissionDetail = ({ solution }: SolutionProps) => { 12 | const { isSuccess, passedTestCase, failedTestCase, selectedLanguage, uploadTime } = solution; 13 | const fireBaseTime = formatTimestampToDate(uploadTime); 14 | const formatDate = formatDateToYmdhms(fireBaseTime); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 27 | 33 | 34 | 35 | 36 | 37 | 41 | 45 | 46 | 47 |
22 | 성공한 테스트 케이스: 23 | 24 | {passedTestCase} / {passedTestCase + failedTestCase} 25 | 26 | 28 | 결과: 29 | 30 | {isSuccess ? '성공' : '실패'} 31 | 32 |
38 | 제출 날짜: 39 | {formatDate} 40 | 42 | 언어: 43 | {selectedLanguage} 44 |
48 |
49 | ); 50 | }; 51 | 52 | const SubmissionDetailStyle = styled.div` 53 | width: 100%; 54 | padding: 0.8rem 1.5rem; 55 | border: 1px solid ${({ theme }) => theme.color.grayishWhite}; 56 | border-radius: 0.2rem; 57 | font-family: 'Nanum Gothic', sans-serif; 58 | font-weight: 300; 59 | margin-bottom: 0.8rem; 60 | table { 61 | display: grid; 62 | gap: 0.8rem; 63 | width: 100%; 64 | } 65 | tbody { 66 | width: 100%; 67 | padding: 0.25rem; 68 | } 69 | tr { 70 | display: flex; 71 | justify-content: space-between; 72 | } 73 | 74 | ${({ theme }) => theme.media.tablet` 75 | tr { 76 | display: flex; 77 | flex-direction: column; 78 | flex-wrap: wrap; 79 | gap: 0.5rem; 80 | } 81 | 82 | td { 83 | display: flex; 84 | justify-content: space-between; 85 | } 86 | `} 87 | `; 88 | 89 | const BoldSpanStyle = styled.span` 90 | font-family: 'Nanum Gothic', sans-serif; 91 | font-weight: bold; 92 | color: ${({ theme }) => theme.color.darkGrey}; 93 | `; 94 | 95 | const ResultSpanStyle = styled(BoldSpanStyle)<{ result: string }>` 96 | font-size: '1.1rem'; 97 | color: ${({ result, theme }) => (result === 'true' ? theme.color.green : theme.color.coral)}; 98 | `; 99 | 100 | export default SubmissionDetail; 101 | -------------------------------------------------------------------------------- /src/components/shared/error/AsyncBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import ErrorBoundary from './ErrorBoundary'; 3 | 4 | type ErrorBoundaryProps = React.ComponentProps; 5 | interface AsyncBoundaryProps extends Omit { 6 | pendingFallback: React.ComponentProps['fallback']; 7 | rejectedFallback: ErrorBoundaryProps['renderFallback']; 8 | children: React.ReactNode; 9 | } 10 | 11 | const AsyncBoundary = ({ pendingFallback, rejectedFallback, children }: AsyncBoundaryProps) => { 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | }; 18 | 19 | export default AsyncBoundary; 20 | -------------------------------------------------------------------------------- /src/components/shared/error/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ErrorBoundaryProps { 4 | keys?: any; 5 | renderFallback: (args: { error: Error; reset: () => void }) => React.ReactNode; 6 | children: React.ReactNode; 7 | } 8 | 9 | const initialState = { error: null }; 10 | 11 | export default class ErrorBoundary extends React.PureComponent< 12 | ErrorBoundaryProps, 13 | typeof initialState 14 | > { 15 | constructor(props: ErrorBoundaryProps) { 16 | super(props); 17 | this.state = initialState; 18 | } 19 | 20 | static getDerivedStateFromError(error: Error) { 21 | return { error }; 22 | } 23 | 24 | componentDidUpdate(prev: ErrorBoundaryProps) { 25 | if (this.state.error === null) { 26 | return; 27 | } 28 | if (prev.keys !== this.props.keys) { 29 | this.resetErrorBoundary(); 30 | } 31 | } 32 | 33 | resetErrorBoundary = () => { 34 | this.setState(initialState); 35 | }; 36 | 37 | render() { 38 | const { children, renderFallback } = this.props; 39 | const { error } = this.state; 40 | 41 | if (error != null) { 42 | return renderFallback({ 43 | error, 44 | reset: this.resetErrorBoundary, 45 | }); 46 | } 47 | return children; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/shared/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import TransitionOut from '@src/components/shared/section/TransitionOut'; 4 | import Portal from './Portal'; 5 | 6 | interface ModalProps { 7 | isOpen: boolean; 8 | closeModal: () => void; 9 | onCloseCallback?: () => void; 10 | children: JSX.Element | JSX.Element[]; 11 | } 12 | 13 | const Modal: React.FC = ({ isOpen, closeModal, onCloseCallback, children }) => { 14 | const child = React.useRef(null); 15 | const childInstance = React.useMemo(() => { 16 | if (isOpen) { 17 | child.current = children; 18 | } 19 | return child.current; 20 | }, [children, isOpen]); 21 | 22 | return ( 23 | 24 | 25 | {childInstance} 26 | 27 | 28 | ); 29 | }; 30 | 31 | const fadeIn = keyframes` 32 | from { 33 | opacity: 0; 34 | transform: translate3d(0, 100%, 0); 35 | } 36 | to { 37 | opacity: 1; 38 | transform: translateZ(0); 39 | } 40 | `; 41 | 42 | const fadeOut = keyframes` 43 | from { 44 | opacity: 0; 45 | transform: translateZ(0); 46 | } 47 | to { 48 | opacity: 1; 49 | transform: translate3d(0, 100%, 0); 50 | } 51 | `; 52 | 53 | const ModalStyle = styled.div<{ isOpen: string }>` 54 | display: flex; 55 | justify-content: center; 56 | position: fixed; 57 | left: 50%; 58 | bottom: 2rem; 59 | z-index: 10000; 60 | animation: 300ms ${({ isOpen }) => (isOpen === 'true' ? fadeIn : fadeOut)} forwards; 61 | visibility: ${({ isOpen }) => (isOpen === 'true' ? 'visible' : 'hidden')}; 62 | `; 63 | 64 | export default Modal; 65 | -------------------------------------------------------------------------------- /src/components/shared/modal/Portal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | type Props = { 5 | children: JSX.Element | JSX.Element[]; 6 | domNode?: HTMLElement; 7 | }; 8 | 9 | const Portal = ({ children, domNode = document.body }: Props) => { 10 | const [mount, setMount] = React.useState(false); 11 | const body = React.useRef(null); 12 | 13 | React.useEffect(() => { 14 | body.current = domNode; 15 | setMount(true); 16 | }, [domNode]); 17 | 18 | if (mount) { 19 | return createPortal(children, body.current!); 20 | } 21 | return null; 22 | }; 23 | 24 | export default Portal; 25 | -------------------------------------------------------------------------------- /src/components/shared/section/CloseBoxOnOutside.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ComponentProps { 4 | onClose: () => void; 5 | children: JSX.Element | JSX.Element[]; 6 | } 7 | 8 | const CloseBoxOnOutside = ({ onClose, children }: ComponentProps) => { 9 | const wrapRef = React.useRef(null); 10 | useCloseBoxOnOutside({ ref: wrapRef, onClose }); 11 | 12 | return
{children}
; 13 | }; 14 | 15 | interface HookProps { 16 | ref: React.RefObject; 17 | onClose: () => void; 18 | } 19 | 20 | const useCloseBoxOnOutside = ({ ref, onClose }: HookProps) => { 21 | React.useEffect(() => { 22 | const handleClickOutside = (event: MouseEvent) => { 23 | if (ref.current && !ref.current.contains(event.target as Node)) { 24 | onClose(); 25 | } 26 | }; 27 | 28 | document.addEventListener('click', handleClickOutside); 29 | return () => { 30 | document.removeEventListener('click', handleClickOutside); 31 | }; 32 | }, [ref, onClose]); 33 | }; 34 | 35 | export default CloseBoxOnOutside; 36 | -------------------------------------------------------------------------------- /src/components/shared/section/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { uid } from 'react-uid'; 3 | 4 | interface PaginationProps { 5 | total: number; 6 | limit: number; 7 | unit: number; 8 | pageIdx: number; 9 | onChangePageIdx: (page: number) => void; 10 | } 11 | 12 | const Pagination = ({ total, unit, limit, pageIdx, onChangePageIdx }: PaginationProps) => { 13 | const numPages = Math.ceil(total / limit); 14 | const pageButtonList = getPaginationArray({ 15 | numPages, 16 | pageIdx, 17 | unit, 18 | }); 19 | 20 | return ( 21 | 22 | onChangePageIdx(pageIdx - 1)} 24 | disabled={pageIdx === 0} 25 | aria-label="이전 페이지" 26 | > 27 | < 28 | 29 | 30 | {pageButtonList.map((pageNum, idx) => ( 31 | onChangePageIdx(pageNum - 1)} 34 | aria-current={pageIdx + 1 === pageNum ? 'page' : null} 35 | > 36 | {pageNum} 37 | 38 | ))} 39 | 40 | onChangePageIdx(pageIdx + 1)} 42 | disabled={pageIdx === numPages - 1} 43 | aria-label="다음 페이지" 44 | > 45 | > 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default Pagination; 52 | 53 | type PaginationArrayProps = { 54 | numPages: number; 55 | pageIdx: number; 56 | unit: number; 57 | }; 58 | 59 | const getPaginationArray = ({ numPages, pageIdx, unit }: PaginationArrayProps) => { 60 | const pageButtonList = [...new Array(numPages)].map((_, idx) => idx + 1); 61 | 62 | const weight = pageIdx % unit; 63 | const initPage = pageIdx - weight; 64 | 65 | return pageButtonList.slice(initPage, initPage + unit); 66 | }; 67 | 68 | const NavStyle = styled.nav` 69 | display: flex; 70 | justify-content: center; 71 | align-items: center; 72 | height: 5rem; 73 | padding: 1rem; 74 | align-items: flex-end; 75 | `; 76 | 77 | const PageListStyle = styled.span` 78 | margin: 0px 0.5625rem; 79 | display: flex; 80 | -webkit-box-align: center; 81 | align-items: center; 82 | background-color: rgba(215, 226, 235, 0.5); 83 | border-radius: 0.375rem; 84 | `; 85 | 86 | const ButtonStyle = styled.button.attrs(() => ({ 87 | type: 'aria-current', 88 | }))` 89 | display: flex; 90 | justify-content: center; 91 | align-items: center; 92 | height: 1.75rem; 93 | padding: 0.3125rem 0.375rem; 94 | min-width: 1.75rem; 95 | text-align: center; 96 | font-size: 0.8125rem; 97 | color: rgb(120, 144, 160); 98 | white-space: nowrap; 99 | font-family: 'Inter', sans-serif; 100 | font-weight: 400; 101 | border-radius: 20%; 102 | &[aria-label] { 103 | border-radius: 50%; 104 | } 105 | &[disabled] { 106 | background-color: #f0f4f7; 107 | cursor: revert; 108 | transform: revert; 109 | } 110 | &[aria-current] { 111 | position: relative; 112 | z-index: 2; 113 | color: ${({ theme }) => theme.color.white}; 114 | box-shadow: rgba(0, 0, 0, 0.4) 0px 0.25rem 0.625rem; 115 | border-radius: 0.375rem; 116 | background-color: ${({ theme }) => theme.color.black}; 117 | } 118 | `; 119 | -------------------------------------------------------------------------------- /src/components/shared/section/TransitionOut.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface TransitionOutProps { 4 | isOpen: boolean; 5 | closeModal?: () => void; 6 | onCloseCallback?: () => void; 7 | children: React.ReactNode; 8 | duration?: number; 9 | } 10 | 11 | const TransitionOut = ({ 12 | isOpen, 13 | closeModal, 14 | onCloseCallback, 15 | children, 16 | duration = 3000, 17 | }: TransitionOutProps) => { 18 | React.useEffect(() => { 19 | const timer = setTimeout(() => { 20 | onCloseCallback?.(); 21 | closeModal?.(); 22 | }, duration); 23 | 24 | return () => timer && clearTimeout(timer); 25 | }, [isOpen, closeModal, duration, onCloseCallback]); 26 | 27 | return isOpen ? <>{children} : null; 28 | }; 29 | 30 | export default TransitionOut; 31 | -------------------------------------------------------------------------------- /src/components/shared/section/VirtualScroll.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useWindowScroll, useWindowSize } from 'react-use'; 3 | import styled from 'styled-components'; 4 | 5 | type Props = { 6 | children: JSX.Element[]; 7 | itemHeight: number; 8 | columnGap?: number; 9 | renderAhead?: number; 10 | }; 11 | 12 | const REM_SIZE = 16; 13 | 14 | const VirtualScroll = ({ children, itemHeight, columnGap = 0, renderAhead = 0 }: Props) => { 15 | const { y } = useWindowScroll(); 16 | const { height } = useWindowSize(); 17 | 18 | const scrollRef = React.useRef(null); 19 | const [viewportY, setViewportY] = React.useState(0); 20 | const offsetY = (y - viewportY) / REM_SIZE; 21 | React.useEffect(() => { 22 | const viewportY = scrollRef.current?.getBoundingClientRect().y ?? 0; 23 | setViewportY(viewportY); 24 | }, []); 25 | 26 | const containerHeight = (itemHeight + columnGap) * children.length; 27 | 28 | const startIndex = Math.max(Math.floor(offsetY / (itemHeight + columnGap)) - renderAhead, 0); 29 | 30 | const endIndex = Math.min( 31 | Math.ceil(height / REM_SIZE / (itemHeight + columnGap) + startIndex) + renderAhead, 32 | children.length, 33 | ); 34 | 35 | const visibleItem = children.slice( 36 | Math.max(startIndex, 0), 37 | Math.min(endIndex + 1, children.length), 38 | ); 39 | 40 | const translateY = Math.max((itemHeight + columnGap) * startIndex, columnGap); 41 | 42 | return ( 43 | 44 | {visibleItem} 45 | 46 | ); 47 | }; 48 | 49 | const ContainerStyle = styled.div<{ containerHeight: number }>` 50 | width: 100%; 51 | height: ${props => props.containerHeight}rem; 52 | will-change: transform; 53 | `; 54 | 55 | const ContentStyle = styled.div<{ translateY: number }>` 56 | transform: translateY(${props => props.translateY}rem); 57 | `; 58 | 59 | export default VirtualScroll; 60 | -------------------------------------------------------------------------------- /src/components/shared/select/CheckOption.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import CloseBoxOnOutside from '@src/components/shared/section/CloseBoxOnOutside'; 3 | import DropdownIcon from '@assets/icons/Dropdown.svg'; 4 | import '@src/styles/font.css'; 5 | 6 | interface CheckOptionProps { 7 | isOpen: boolean; 8 | value: string; 9 | onModalChange: (isOpen: boolean) => void; 10 | } 11 | 12 | const CheckOption = ({ isOpen, value, onModalChange }: CheckOptionProps) => { 13 | const onClose = () => onModalChange(false); 14 | const onOpen = () => onModalChange(true); 15 | 16 | return ( 17 | 18 | 19 | {value} 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | const CheckOptionStyle = styled.span<{ isOpen: boolean }>` 27 | z-index: 1; 28 | cursor: pointer; 29 | font-size: 1rem; 30 | font-family: 'Noto Sans KR', sans-serif; 31 | font-weight: 400; 32 | color: ${({ theme }) => theme.color.darkGrey}; 33 | background-color: ${({ isOpen, theme }) => 34 | isOpen ? theme.color.grayishWhite : theme.color.grey}; 35 | margin-bottom: 1rem; 36 | padding: 0.8rem 1.2rem; 37 | border-radius: 0.2rem; 38 | outline: ${({ isOpen, theme }) => isOpen && `${theme.color.grey} 2px solid`}; 39 | &:hover { 40 | background-color: ${({ theme }) => theme.color.grayishWhite}; 41 | } 42 | span { 43 | margin-right: 0.4rem; 44 | } 45 | `; 46 | 47 | export default CheckOption; 48 | -------------------------------------------------------------------------------- /src/components/shared/select/PartTitleSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRecoilState } from 'recoil'; 3 | import { uid } from 'react-uid'; 4 | 5 | import Select from '.'; 6 | import CheckOption from './CheckOption'; 7 | import { problemTitleOption } from '@src/store/select'; 8 | import { PartTitleSelectProps } from '@src/types/select'; 9 | 10 | const PartTitleSelect = ({ 11 | allSolvedCnt, 12 | partTitleList, 13 | onChangePageIdx, 14 | }: PartTitleSelectProps) => { 15 | const [isOpen, setIsOpen] = React.useState(false); 16 | const [selected, setSelected] = useRecoilState(problemTitleOption); 17 | console.log(partTitleList); 18 | 19 | React.useEffect(() => { 20 | if (selected === 'ALL') { 21 | setSelected(`전체 문제 (${allSolvedCnt})`); 22 | } 23 | }, []); 24 | 25 | const onChangePartTitle = React.useCallback((option: string) => { 26 | onChangePageIdx(0); 27 | setSelected(option); 28 | }, []); 29 | 30 | return ( 31 | 41 | ); 42 | }; 43 | 44 | export default PartTitleSelect; 45 | -------------------------------------------------------------------------------- /src/components/shared/select/SolutionSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRecoilState } from 'recoil'; 3 | import { uid } from 'react-uid'; 4 | 5 | import Select from '.'; 6 | import CheckOption from './CheckOption'; 7 | import { solutionOption } from '@src/store/select'; 8 | import { SOLUTION_LIST as options, SOLUTION_TYPE as filterState } from '@src/constants/solution'; 9 | import '@src/styles/font.css'; 10 | import { SolutionType } from '@src/types/select'; 11 | 12 | const SolutionSelect = () => { 13 | const [isOpen, setIsOpen] = React.useState(false); 14 | const [selected, setSelected] = useRecoilState(solutionOption); 15 | const selectedName = (filterState as SolutionType)[selected]; 16 | 17 | return ( 18 | 28 | ); 29 | }; 30 | 31 | export default SolutionSelect; 32 | -------------------------------------------------------------------------------- /src/components/shared/select/SortSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRecoilState } from 'recoil'; 3 | import { uid } from 'react-uid'; 4 | 5 | import Select from '.'; 6 | import CheckOption from './CheckOption'; 7 | import { sortedOption } from '@src/store/select'; 8 | import { SORT_LIST as options, SORT_TYPE as filterState } from '@src/constants/solution'; 9 | 10 | const SortSelect = () => { 11 | const [isOpen, setIsOpen] = React.useState(false); 12 | const [selected, setSelected] = useRecoilState(sortedOption); 13 | 14 | return ( 15 | 27 | ); 28 | }; 29 | 30 | export default SortSelect; 31 | -------------------------------------------------------------------------------- /src/components/shared/select/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { SelectProps, TriggerProps, MenuProps, ItemProps } from '@src/types/select'; 4 | import '@src/styles/font.css'; 5 | import { Children } from '@src/types/global'; 6 | 7 | const Select = ({ isOpen, trigger, children }: SelectProps) => { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | 16 | Select.Item = ({ onChangeDropdown, option, children }: ItemProps) => { 17 | const onPreventEvent = (event: React.MouseEvent) => event.preventDefault(); 18 | const onChangeOption = (event: React.MouseEvent) => 19 | onChangeDropdown(event.currentTarget.value); 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | 28 | const Dropdown = ({ children }: Children) => {children}; 29 | 30 | Dropdown.Trigger = ({ as }: TriggerProps) => <>{as}; 31 | 32 | Dropdown.Menu = ({ isOpen, children }: MenuProps) => ( 33 | {children} 34 | ); 35 | 36 | const ContainerStyle = styled.div` 37 | display: inline-flex; 38 | flex-direction: column; 39 | align-items: flex-end; 40 | position: relative; 41 | `; 42 | 43 | const MenuStyle = styled.ul<{ isOpen: boolean }>` 44 | visibility: ${({ isOpen }) => (isOpen ? 'visible' : 'hidden')}; 45 | display: flex; 46 | flex-direction: column; 47 | position: absolute; 48 | z-index: 10; 49 | top: 2.5rem; 50 | padding: 0.5rem 0rem; 51 | font-size: 1rem; 52 | color: ${({ theme }) => theme.color.darkGrey}; 53 | cursor: pointer; 54 | box-shadow: 0 0.25rem 0.5rem rgb(20 20 84 / 4%), 0 0.5rem 1.125rem rgb(20 20 84 / 8%), 55 | 0 1rem 2rem -0.125rem rgb(20 20 84 / 8%), 0 0 0 0.0625rem rgb(20 20 84 / 12%); 56 | border-radius: 0.25rem; 57 | line-height: 1.6; 58 | background-color: ${({ theme }) => theme.color.white}; 59 | max-height: 18.5rem; 60 | overflow: auto; 61 | `; 62 | 63 | export const ItemStyle = styled.button` 64 | padding: 0.5rem 1rem; 65 | font-size: 1rem; 66 | font-family: 'Noto Sans KR', sans-serif; 67 | font-weight: 400; 68 | color: ${({ theme }) => theme.color.darkGrey}; 69 | background-color: transparent; 70 | display: flex; 71 | `; 72 | 73 | export default Select; 74 | -------------------------------------------------------------------------------- /src/constants/level.ts: -------------------------------------------------------------------------------- 1 | const levels = [0, 1, 2, 3, 4, 5]; 2 | const levelsColor = [ 3 | 'rgb(33, 137, 255)', 4 | 'rgb(27, 186, 255)', 5 | 'rgb(71, 200, 76)', 6 | 'rgb(255, 168, 0)', 7 | 'rgb(255, 107, 24)', 8 | 'rgb(198, 88, 225)', 9 | ]; 10 | 11 | export { levels, levelsColor }; 12 | -------------------------------------------------------------------------------- /src/constants/profile.ts: -------------------------------------------------------------------------------- 1 | import { SelectNameType } from '@src/types/profile/profile-layout'; 2 | 3 | const NAV_LIST = ['MAIN', 'PROBLEM']; 4 | const NAV_TYPE = { 5 | MAIN: '개요', 6 | PROBLEM: '문제', 7 | }; 8 | 9 | const SORT_LIST: SelectNameType[] = ['level', 'finishedCount', 'acceptanceRate']; 10 | const SORT_TYPE = { 11 | level: '난이도', 12 | finishedCount: '완료한 사람', 13 | acceptanceRate: '정답률', 14 | }; 15 | 16 | const STATIST_HEAD = ['레벨', '푼 문제', '전체 문제', '백분위']; 17 | 18 | const PROBLEM_LIST = [ 19 | { item: 'level', name: '난이도' }, 20 | { item: 'title', name: '제목' }, 21 | { item: 'finished-count', name: '완료한 사람' }, 22 | { item: 'acceptance-rate', name: '정답률' }, 23 | ]; 24 | 25 | export { NAV_LIST, NAV_TYPE, SORT_LIST, SORT_TYPE, STATIST_HEAD, PROBLEM_LIST }; 26 | -------------------------------------------------------------------------------- /src/constants/solution.ts: -------------------------------------------------------------------------------- 1 | import { SolutionType, SortType } from '@src/types/select'; 2 | 3 | const SOLUTION_LIST = ['ALL', 'SUCCESS', 'FAILED']; 4 | const SOLUTION_TYPE = { 5 | ALL: '전체 풀이', 6 | SUCCESS: '성공한 풀이', 7 | FAILED: '실패한 풀이', 8 | } as SolutionType; 9 | 10 | const SORT_LIST = ['DESC', 'ASC']; 11 | const SORT_TYPE = { 12 | ASC: '오래된 풀이 순', 13 | DESC: '최신 풀이 순', 14 | } as SortType; 15 | 16 | export { SOLUTION_LIST, SOLUTION_TYPE, SORT_LIST, SORT_TYPE }; 17 | -------------------------------------------------------------------------------- /src/constants/url.ts: -------------------------------------------------------------------------------- 1 | const ALL_PROBLEM_URL = 2 | 'https://raw.githubusercontent.com/dev-red5/programmers-problems/main/problems.json'; 3 | 4 | const PROBLEM_URL = 5 | 'https://school.programmers.co.kr/api/v1/school/challenges/?statuses[]=solved&page='; 6 | 7 | const SOLVING_PROBLEM_URL = 'https://school.programmers.co.kr/learn/courses/30/lessons/'; 8 | 9 | const USER_INFO_URL = 'https://school.programmers.co.kr/api/v1/users/current'; 10 | 11 | export { ALL_PROBLEM_URL, PROBLEM_URL, SOLVING_PROBLEM_URL, USER_INFO_URL }; 12 | -------------------------------------------------------------------------------- /src/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app'; 2 | import { getAuth } from 'firebase/auth'; 3 | import { getFirestore } from 'firebase/firestore'; 4 | 5 | const firebaseConfig = { 6 | apiKey: process.env.API_KEY, 7 | authDomain: process.env.AUTH_DOMAIN, 8 | projectId: process.env.PROJECT_ID, 9 | storageBucket: process.env.STORAGE_BUCKET, 10 | messagingSenderId: process.env.MESSAGING_SENDER_ID, 11 | appId: process.env.APP_ID, 12 | }; 13 | 14 | const firebaseApp = initializeApp(firebaseConfig); 15 | const auth = getAuth(firebaseApp); 16 | 17 | const db = getFirestore(firebaseApp); 18 | 19 | export { firebaseApp, auth, db }; 20 | -------------------------------------------------------------------------------- /src/hooks/popup/useAuth.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { auth } from '@src/firebase'; 3 | 4 | import { useIsLoaded } from '../useIsLoaded'; 5 | 6 | const useAuth = () => { 7 | const [userEmail, setUserEmail] = React.useState(''); 8 | const { isLoaded, setIsLoaded } = useIsLoaded(); 9 | 10 | auth.onAuthStateChanged(firebaseUser => { 11 | if (firebaseUser) { 12 | setUserEmail(firebaseUser.email as string); 13 | } 14 | setIsLoaded(true); 15 | }); 16 | 17 | return { isLoaded, userEmail }; 18 | }; 19 | 20 | export { useAuth }; 21 | -------------------------------------------------------------------------------- /src/hooks/profile/index.ts: -------------------------------------------------------------------------------- 1 | import { useUserEmail } from './useUserEmail'; 2 | import { useProblems } from './useProblems'; 3 | 4 | export { useUserEmail, useProblems }; 5 | -------------------------------------------------------------------------------- /src/hooks/profile/useProblems.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useUserEmail } from './useUserEmail'; 3 | 4 | import { getAllProblemsList, getSolvedProblemList } from '@src/service/profile'; 5 | import { SolvedProblemType } from '@src/types/profile/profile-layout'; 6 | import { useIsLoaded } from '../useIsLoaded'; 7 | 8 | export const useProblems = () => { 9 | const { isLoggedIn, userEmail } = useUserEmail(); 10 | const { isLoaded, setIsLoaded } = useIsLoaded(); 11 | 12 | const [allProblems, setAllSolvedProblems] = React.useState([]); 13 | const [solvedProblems, setSolvedProblems] = React.useState([]); 14 | 15 | React.useEffect(() => { 16 | (async () => { 17 | if (!userEmail) { 18 | return; 19 | } 20 | 21 | const allProblems = await getAllProblemsList(); 22 | setAllSolvedProblems(allProblems); 23 | 24 | const solvedProblems = await getSolvedProblemList(userEmail, allProblems); 25 | setSolvedProblems(solvedProblems); 26 | 27 | setIsLoaded(true); 28 | })(); 29 | }, [userEmail]); 30 | 31 | return { 32 | isLoggedIn, 33 | isLoaded, 34 | allProblems, 35 | solvedProblems, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/hooks/profile/useUserEmail.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { setUserInfoStorage } from '@src/api/solution/setUserInfoStorage'; 4 | import { getUserEmail } from '@src/api/solution/getUserEmail'; 5 | 6 | export const useUserEmail = () => { 7 | const [isLoggedIn, setIsLoggedIn] = React.useState(true); 8 | const [userEmail, setUserEmail] = React.useState(''); 9 | 10 | React.useEffect(() => { 11 | setUserEmailCallback({ setIsLoggedIn, setUserEmail }); 12 | }, []); 13 | 14 | return { 15 | isLoggedIn, 16 | userEmail, 17 | }; 18 | }; 19 | 20 | interface UserEmailCallback { 21 | setIsLoggedIn: React.Dispatch>; 22 | setUserEmail: React.Dispatch>; 23 | } 24 | 25 | const setUserEmailCallback = async ({ setIsLoggedIn, setUserEmail }: UserEmailCallback) => { 26 | await setUserInfoStorage(); 27 | 28 | const userEmail = await getUserEmail(); 29 | if (!userEmail) { 30 | setIsLoggedIn(false); 31 | return; 32 | } 33 | 34 | setUserEmail(userEmail); 35 | }; 36 | -------------------------------------------------------------------------------- /src/hooks/solution/useAllSolution.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { getAllSolutions } from '@src/service/solution'; 4 | import { useIsLoaded } from '../useIsLoaded'; 5 | 6 | import { SolutionResponse } from '@src/types/solution'; 7 | import { ProblemInfo } from '@src/types/problem/problem'; 8 | 9 | const useAllSolution = (problemInfo: ProblemInfo) => { 10 | const { isLoaded, setIsLoaded } = useIsLoaded(); 11 | const [solutions, setSolutions] = React.useState({}); 12 | 13 | React.useEffect(() => { 14 | (async () => { 15 | const allSolutions = await getAllSolutions(problemInfo); 16 | setSolutions(allSolutions); 17 | setIsLoaded(true); 18 | })(); 19 | }, []); 20 | 21 | return { isLoaded, solutions }; 22 | }; 23 | 24 | export { useAllSolution }; 25 | -------------------------------------------------------------------------------- /src/hooks/useIsLoaded.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const useIsLoaded = () => { 4 | const [isLoaded, setIsLoaded] = React.useState(false); 5 | return { isLoaded, setIsLoaded }; 6 | }; 7 | 8 | export { useIsLoaded }; 9 | -------------------------------------------------------------------------------- /src/pages/background/createChromeTab.ts: -------------------------------------------------------------------------------- 1 | const createChromeTab = (url: string) => 2 | chrome.tabs.query({ active: true, currentWindow: true }, tabs => { 3 | const tabIndex = tabs[0]!.index; 4 | chrome.tabs.create({ url, index: tabIndex + 1 }); 5 | }); 6 | 7 | export { createChromeTab }; 8 | -------------------------------------------------------------------------------- /src/pages/background/createMemoTab.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@src/types/global'; 2 | import { createChromeTab } from './createChromeTab'; 3 | 4 | type Problem = { 5 | problemId: string; 6 | problemName: string; 7 | }; 8 | 9 | const createMemoTab = ({ request }: Message) => { 10 | const problem = request.href; 11 | const url = getSolutionsTabUrl(chrome.runtime.id, problem); 12 | 13 | createChromeTab(url); 14 | }; 15 | 16 | const getSolutionsTabUrl = (runtimeId: string, { problemId, problemName }: Problem) => 17 | `chrome-extension://${runtimeId}/memoTab.html?num=${problemId}&name=${problemName}`; 18 | 19 | export { createMemoTab }; 20 | -------------------------------------------------------------------------------- /src/pages/background/createSolutionsTab.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@src/types/global'; 2 | import { createChromeTab } from './createChromeTab'; 3 | 4 | type Problem = { 5 | problemId: string; 6 | problemName: string; 7 | selectedLanguage: string; 8 | }; 9 | 10 | const createSolutionsTab = ({ request }: Message) => { 11 | const problem = request.href; 12 | const url = getSolutionsTabUrl(chrome.runtime.id, problem); 13 | 14 | createChromeTab(url); 15 | }; 16 | 17 | const getSolutionsTabUrl = ( 18 | runtimeId: string, 19 | { problemId, problemName, selectedLanguage }: Problem, 20 | ) => 21 | `chrome-extension://${runtimeId}/solutionTab.html?num=${problemId}&name=${problemName}&language=${selectedLanguage}`; 22 | 23 | export { createSolutionsTab }; 24 | -------------------------------------------------------------------------------- /src/pages/background/createSuccessProblemTab.ts: -------------------------------------------------------------------------------- 1 | import { createChromeTab } from './createChromeTab'; 2 | 3 | const createSuccessProblemTab = async () => { 4 | const url = getAllSuccessProblemTabUrl(chrome.runtime.id); 5 | createChromeTab(url); 6 | }; 7 | 8 | const getAllSuccessProblemTabUrl = (runtimeId: string) => 9 | `chrome-extension://${runtimeId}/profileTab.html`; 10 | 11 | export { createSuccessProblemTab }; 12 | -------------------------------------------------------------------------------- /src/pages/background/getAllSolutions.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'firebase/auth'; 2 | import { collection, getDocs, query, where } from 'firebase/firestore'; 3 | import { db } from '@src/firebase'; 4 | import { getCurrentUser } from '@src/api/login/getCurrentUser'; 5 | import { Message } from '@src/types/global'; 6 | 7 | const getAllSolutions = async ({ request, sendResponse }: Message) => { 8 | const { selectedLanguage, problemId } = request.data; 9 | const { uid } = (await getCurrentUser()) as User; 10 | 11 | const codingTestRef = collection(db, 'codingTest', uid, problemId); 12 | const codingTestQuery = query(codingTestRef, where('selectedLanguage', '==', selectedLanguage)); 13 | 14 | const querySnapshot = await getDocs(codingTestQuery); 15 | const data = querySnapshot.docs.map(doc => ({ 16 | ...doc.data(), 17 | })); 18 | 19 | sendResponse({ status: true, data }); 20 | }; 21 | 22 | export { getAllSolutions }; 23 | -------------------------------------------------------------------------------- /src/pages/background/index.ts: -------------------------------------------------------------------------------- 1 | import { setUserInfoStorage } from '@src/api/solution/setUserInfoStorage'; 2 | 3 | import { postCurrentSolution } from './postCurrentSolution'; 4 | import { getAllSolutions } from './getAllSolutions'; 5 | import { createSolutionsTab } from './createSolutionsTab'; 6 | import { createSuccessProblemTab } from './createSuccessProblemTab'; 7 | import { createMemoTab } from './createMemoTab'; 8 | 9 | chrome.runtime.onInstalled.addListener(() => setUserInfoStorage()); 10 | 11 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 12 | const message = { request, sender, sendResponse }; 13 | 14 | if (request.method === 'postCurrentSolution') { 15 | postCurrentSolution(message).catch(error => { 16 | console.log('[Pro Solve] 로그인을 하지 않아 업로드가 되지 않습니다!', error); 17 | sendResponse({ status: false }); 18 | }); 19 | 20 | return true; 21 | } 22 | 23 | if (request.method === 'getAllSolutions') { 24 | getAllSolutions(message).catch(error => { 25 | console.log('[Pro Solve] 풀이를 가져오던 중 에러가 발생했습니다!', error); 26 | sendResponse({ status: false, message: error.message }); 27 | }); 28 | 29 | return true; 30 | } 31 | 32 | if (request.method === 'createSolutionsTab') { 33 | createSolutionsTab(message); 34 | } 35 | 36 | if (request.method === 'createMemoTab') { 37 | createMemoTab(message); 38 | } 39 | 40 | if (request.method === 'createSuccessProblemTab') { 41 | createSuccessProblemTab(); 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /src/pages/background/postCurrentSolution.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@src/firebase'; 2 | import { User } from 'firebase/auth'; 3 | import { doc, setDoc, Timestamp } from 'firebase/firestore'; 4 | import { getCurrentUser } from '@src/api/login/getCurrentUser'; 5 | import { Message } from '@src/types/global'; 6 | 7 | const postCurrentSolution = async ({ request, sendResponse }: Message) => { 8 | const { isSuccess, code, selectedLanguage, problemId, passedTestCase, failedTestCase } = 9 | request.data; 10 | const uploadTime = Timestamp.now(); 11 | const { uid } = (await getCurrentUser()) as User; 12 | 13 | const codingTestRef = doc(db, 'codingTest', uid, problemId, String(uploadTime)); 14 | await setDoc(codingTestRef, { 15 | isSuccess, 16 | code, 17 | passedTestCase, 18 | failedTestCase, 19 | selectedLanguage, 20 | uploadTime, 21 | }); 22 | 23 | console.log('[Pro Solve] 업로드 성공!'); 24 | sendResponse({ status: true }); 25 | }; 26 | 27 | export { postCurrentSolution }; 28 | -------------------------------------------------------------------------------- /src/pages/content/problemPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import styled from 'styled-components'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import { theme } from '@src/styles/theme'; 6 | import ProfileLogo from '@assets/icons/ProfileLogo.svg'; 7 | import Modal from '@src/components/shared/modal/Modal'; 8 | import RefreshRequestBox from '@src/components/shared/box/RefreshRequestBox'; 9 | 10 | const OpenSuccessTabButton = () => { 11 | const [isModalOpen, setIsModalOpen] = React.useState(false); 12 | const onOpenModal = () => setIsModalOpen(true); 13 | 14 | return ( 15 | <> 16 | setIsModalOpen(false)}> 17 | 18 | 새로고침을 해주세요! 19 | 20 | 21 | createSuccessProblemTab(onOpenModal)}> 22 | 23 | 24 | 나의 풀이 페이지 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | const createSuccessProblemTab = (onOpenModal: () => void) => { 32 | if (chrome.runtime?.id === undefined) { 33 | onOpenModal(); 34 | return; 35 | } 36 | 37 | chrome.runtime.sendMessage({ 38 | method: 'createSuccessProblemTab', 39 | }); 40 | }; 41 | 42 | const ProfileButtonStyle = styled.button` 43 | display: flex; 44 | justify-content: space-between; 45 | align-items: center; 46 | position: fixed; 47 | bottom: 3rem; 48 | right: 3rem; 49 | z-index: 10000; 50 | border: none; 51 | font-weight: 600; 52 | `; 53 | 54 | const ProfileInfoStyle = styled.div` 55 | display: flex; 56 | align-items: center; 57 | padding: 1.1rem; 58 | border-radius: 0.35rem; 59 | background: #12191c; 60 | color: white; 61 | &:hover { 62 | background: #00254f; 63 | } 64 | 65 | & > span { 66 | margin-left: 0.5rem; 67 | } 68 | `; 69 | 70 | const btn = document.createElement('a'); 71 | document.body.prepend(btn); 72 | ReactDOM.createRoot(btn as HTMLElement).render( 73 | 74 | 75 | 76 | 77 | , 78 | ); 79 | -------------------------------------------------------------------------------- /src/pages/content/solutionPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { ThemeProvider } from 'styled-components'; 4 | import { theme } from '@src/styles/theme'; 5 | import CreateSolutionsButton from '@src/components/shared/button/CreateSolutionsButton'; 6 | import CopyClipBoardButton from '@src/components/shared/button/CopyClipBoardButton'; 7 | 8 | (() => { 9 | createCodeClipboard(); 10 | createSolutionTabBtn(); 11 | })(); 12 | 13 | function createCodeClipboard() { 14 | const submissionList = document.querySelectorAll('div.submission__box'); 15 | submissionList.forEach(submission => { 16 | (submission as HTMLDivElement).style.position = 'relative'; 17 | const code = (submission.querySelector('td.rouge-code') as HTMLTableCellElement)?.innerText; 18 | 19 | const elem = document.createElement('div'); 20 | submission.appendChild(elem); 21 | ReactDOM.createRoot(elem as HTMLElement).render( 22 | 23 | 24 | 25 | 26 | , 27 | ); 28 | }); 29 | } 30 | 31 | function createSolutionTabBtn() { 32 | const languageRegex = /(?<=language=\s*)\w*/g; 33 | const problemIdRegex = /(?<=lessons\/\s*)\w+/g; 34 | 35 | const href = ([...document.querySelectorAll('.lesson-control-btn > a')] as HTMLAnchorElement[])[1] 36 | .href; 37 | const selectedLanguage = href.match(languageRegex)![0]; 38 | const problemId = href.match(problemIdRegex)![0]; 39 | const problemName = ([...document.querySelectorAll('ol.breadcrumb > li')] as HTMLElement[])[2] 40 | .innerText; 41 | 42 | const root = document.querySelector('div.result-tab > div#tab') as HTMLDivElement; 43 | const contentScript = document.createElement('div'); 44 | root.appendChild(contentScript); 45 | ReactDOM.createRoot(contentScript as HTMLElement).render( 46 | 47 | 48 | 53 | 54 | , 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/content/testPage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | printLoadingText, 3 | createShowSolutionsButton, 4 | uploadCurrentSolution, 5 | } from '@src/service/testPage/problemUpload'; 6 | import { createMemoTabButton } from '@src/service/testPage/memo'; 7 | 8 | // TODO: 문제 아이디어 아카이빙 탭 생성 기능 9 | // createMemoTabButton(); 10 | 11 | // 문제 업로드 기능 12 | import { setUserInfoStorage } from '@src/api/solution/setUserInfoStorage'; 13 | (async () => await setUserInfoStorage())(); 14 | 15 | const $submitBtn = document.querySelector('#submit-code') as HTMLButtonElement; 16 | const $modal = document.querySelector('.modal') as HTMLDivElement; 17 | const modalMutationOption = { 18 | childList: true, 19 | }; 20 | 21 | const modalMutationObserver = new MutationObserver(mutations => { 22 | if (!mutations.length) return; 23 | 24 | printLoadingText(); 25 | createShowSolutionsButton(); 26 | 27 | uploadCurrentSolution(); 28 | modalMutationObserver.disconnect(); 29 | }); 30 | 31 | const isUserLoggedIn = $submitBtn !== null; 32 | if (isUserLoggedIn) { 33 | $submitBtn.addEventListener('click', () => { 34 | modalMutationObserver.observe($modal, modalMutationOption); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/newTab/Solution.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { RecoilRoot } from 'recoil'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import styled from 'styled-components'; 6 | 7 | import { theme } from '@src/styles/theme'; 8 | import GlobalStyles from '@src/styles/global'; 9 | 10 | import { getQueryParams } from '@src/utils/location/getQueryParams'; 11 | import { useAllSolution } from '@src/hooks/solution/useAllSolution'; 12 | import { SelectedLanguage } from '@src/types/problem/problem'; 13 | 14 | import Header from '@src/components/domain/solution/Header'; 15 | import SelectList from '@src/components/domain/solution/SelectList'; 16 | import Content from '@src/components/domain/solution/Content'; 17 | 18 | const { num, name, language } = getQueryParams(); 19 | document.title = `프로솔브 - ${name}`; 20 | 21 | const SolutionTab = () => { 22 | const { isLoaded, solutions } = useAllSolution({ 23 | selectedLanguage: language as SelectedLanguage, 24 | problemId: num, 25 | }); 26 | 27 | return ( 28 | 29 |
30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default SolutionTab; 37 | 38 | const root = document.createElement('div'); 39 | document.body.appendChild(root); 40 | ReactDOM.createRoot(root as HTMLElement).render( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | , 49 | ); 50 | 51 | export const ContainerStyle = styled.div` 52 | height: 100vh; 53 | font-family: 'Noto Sans KR', sans-serif; 54 | `; 55 | -------------------------------------------------------------------------------- /src/pages/newTab/profile/Problems.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import { uid } from 'react-uid'; 4 | import { useRecoilState } from 'recoil'; 5 | 6 | import PartTitleSelect from '@src/components/shared/select/PartTitleSelect'; 7 | import Pagination from '@src/components/shared/section/Pagination'; 8 | 9 | import { sortOption } from '@src/store/profile'; 10 | import { BoxStyle } from '@src/styles/global'; 11 | import { ContentHeaderInfoStyle } from '@src/styles/global'; 12 | import { getFilteredSolvedProblems, getPartTitleListOfSolvedProblems } from '@src/service/profile'; 13 | 14 | import { levelsColor } from '@src/constants/level'; 15 | import { PROBLEM_LIST, SORT_LIST, SORT_TYPE } from '@src/constants/profile'; 16 | import { SOLVING_PROBLEM_URL as BASE_URL } from '@src/constants/url'; 17 | import { 18 | ProblemType, 19 | SortProps, 20 | SortItemProps, 21 | SortType, 22 | SortItemType, 23 | ProblemTableProps, 24 | ContentProps, 25 | } from '@src/types/profile/profile-problems'; 26 | import { SolvedProblemType } from '@src/types/profile/profile-layout'; 27 | import Book from '@assets/icons/Book.svg'; 28 | import ArrowUp from '@assets/icons/ArrowUp.svg'; 29 | import ArrowDown from '@assets/icons/ArrowDown.svg'; 30 | import NoContent from '@assets/images/noContent.png'; 31 | import '@src/styles/font.css'; 32 | 33 | export default function Problems({ solvedProblems }: { solvedProblems: SolvedProblemType }) { 34 | const allSolvedCnt = solvedProblems.length; 35 | const filteredSolvedProblems = getFilteredSolvedProblems(solvedProblems); 36 | const partTitleList = getPartTitleListOfSolvedProblems(solvedProblems); 37 | 38 | const [pageIdx, setPageIdx] = React.useState(0); 39 | const onChangePageIdx = React.useCallback((pageIdx: number) => { 40 | setPageIdx(pageIdx); 41 | }, []); 42 | 43 | const limit = 10; 44 | const offset = pageIdx * limit; 45 | 46 | return ( 47 | 48 | 49 | 54 | 55 | 60 | 67 | 68 | 69 | ); 70 | } 71 | 72 | Problems.Header = () => { 73 | return ( 74 | 75 | 76 | 유저가 푼 문제 77 | 78 | ); 79 | }; 80 | 81 | Problems.Sort = ({ allSolvedCnt, onChangePageIdx, partTitleList }: SortProps) => ( 82 | 83 | 84 | 정렬 — 85 | {SORT_LIST.map((item, idx) => ( 86 | 87 | ))} 88 | 89 | 94 | 95 | ); 96 | 97 | Problems.SortItem = ({ item, onChangePageIdx }: SortItemProps) => { 98 | const [sortType, setSortType] = useRecoilState(sortOption); 99 | const itemName = (SORT_TYPE as SortItemType)[item]; 100 | const { type, isAscending } = sortType as SortType; 101 | 102 | const onChangeSortRule = () => { 103 | if (type === item) { 104 | onChangePageIdx(0); 105 | return setSortType({ type, isAscending: !isAscending }); 106 | } 107 | 108 | onChangePageIdx(0); 109 | setSortType({ 110 | type: item, 111 | isAscending: true, 112 | }); 113 | }; 114 | 115 | if (type === undefined || type !== item) { 116 | return ( 117 | 118 | {itemName} 119 | 120 | ); 121 | } 122 | 123 | return ( 124 | 125 | {itemName} 126 | {isAscending && } 127 | {isAscending || } 128 | 129 | ); 130 | }; 131 | 132 | Problems.Content = ({ children, solvedProblems }: ContentProps) => { 133 | if (solvedProblems.length === 0) { 134 | return ( 135 | 136 | 137 | 아직 성공한 풀이가 없습니다. 138 | 139 | ); 140 | } 141 | 142 | return <>{children}; 143 | }; 144 | 145 | Problems.ItemList = ({ start, end, solvedProblems }: ProblemTableProps) => { 146 | return ( 147 | 148 | 149 | 150 | 151 | ); 152 | }; 153 | 154 | Problems.TableHead = () => ( 155 | 156 | 157 | {PROBLEM_LIST.map(({ item, name }, idx) => ( 158 | 159 | {name} 160 | 161 | ))} 162 | 163 | 164 | ); 165 | 166 | Problems.TableBody = ({ start, end, solvedProblems }: ProblemTableProps) => { 167 | const paginatedProblems = solvedProblems.slice(start, end); 168 | return ( 169 | 170 | {paginatedProblems.map((problem, idx) => ( 171 | 172 | ))} 173 | 174 | ); 175 | }; 176 | 177 | Problems.TableCell = ({ problem }: { problem: ProblemType }) => { 178 | const { id, title, partTitle, level, finishedCount, acceptanceRate } = problem; 179 | const levelColor = levelsColor[level]; 180 | const problemUrl = BASE_URL + id; 181 | 182 | return ( 183 | 184 | 185 | Lv. {level} 186 | 187 | 188 | 189 | {title} 190 | {partTitle} 191 | 192 | 193 | {finishedCount}명 194 | {acceptanceRate}% 195 | 196 | ); 197 | }; 198 | 199 | const HeaderStyle = styled.div` 200 | display: flex; 201 | align-items: center; 202 | gap: 0.5rem; 203 | `; 204 | 205 | const SortContainerStyle = styled.div` 206 | white-space: nowrap; 207 | height: 3rem; 208 | vertical-align: middle; 209 | display: flex; 210 | justify-content: space-between; 211 | align-items: baseline; 212 | font-family: 'Noto Sans KR', sans-serif; 213 | font-weight: 500; 214 | margin: 1rem 0; 215 | `; 216 | 217 | const SortStyle = styled.ul` 218 | display: flex; 219 | justify-content: center; 220 | align-items: center; 221 | `; 222 | 223 | const SortItemStyle = styled.span` 224 | text-align: left; 225 | display: inline-block; 226 | margin-right: 1ch; 227 | transition: background-color 0.3s ease; 228 | user-select: none; 229 | text-decoration: none; 230 | background: none; 231 | border-radius: 32px; 232 | font-weight: 400; 233 | cursor: pointer; 234 | padding: 0.5rem 1rem; 235 | `; 236 | 237 | const SortTextItemStyle = styled(SortItemStyle)` 238 | padding: 0; 239 | cursor: default; 240 | `; 241 | 242 | const SortSelectedItemStyle = styled(SortItemStyle)<{ selected: boolean }>` 243 | display: flex; 244 | align-items: center; 245 | font-weight: ${({ selected }) => selected && 700}; 246 | background-color: ${({ selected }) => selected && '#dddfe0'}; 247 | &:hover { 248 | background-color: ${({ theme }) => theme.color.whiter}; 249 | } 250 | `; 251 | 252 | const NoContentStyle = styled.div` 253 | width: 100%; 254 | display: flex; 255 | align-items: center; 256 | justify-content: center; 257 | padding: 1rem 0 2rem 0; 258 | flex-direction: column; 259 | gap: 1rem; 260 | font-family: 'Noto Sans KR', sans-serif; 261 | img { 262 | width: 5rem; 263 | } 264 | `; 265 | 266 | const ItemTableStyle = styled.table` 267 | width: 100%; 268 | table-layout: fixed; 269 | border-collapse: collapse; 270 | padding: 0 1rem; 271 | `; 272 | 273 | const tableSectionCss = css` 274 | tr { 275 | border-bottom: 0.0625rem solid rgb(215, 226, 235); 276 | } 277 | `; 278 | const ItemTableHeadStyle = styled.thead` 279 | ${tableSectionCss} 280 | `; 281 | const ItemTableBodyStyle = styled.tbody` 282 | ${tableSectionCss} 283 | tr:hover { 284 | background-color: #f9fafc; 285 | } 286 | `; 287 | 288 | const tableItemCss = css` 289 | padding: 0.5625rem 0px; 290 | text-align: center; 291 | font-weight: 700; 292 | line-height: 150%; 293 | font-size: 1rem; 294 | color: ${({ theme }) => theme.color.black}; 295 | font-weight: 500; 296 | text-align: center; 297 | `; 298 | 299 | const ItemTableThStyle = styled.th<{ item: string }>` 300 | ${tableItemCss} 301 | font-family: 'Noto Sans KR', sans-serif; 302 | ${({ item }) => { 303 | if (item === 'level') { 304 | return css` 305 | width: 6rem; 306 | text-align: center; 307 | `; 308 | } 309 | if (item === 'finished-count') { 310 | return css` 311 | text-align: right; 312 | width: 8rem; 313 | `; 314 | } 315 | if (item === 'acceptance-rate') { 316 | return css` 317 | text-align: right; 318 | width: 8rem; 319 | transform: translate(-10px, 0px); 320 | `; 321 | } 322 | }}; 323 | `; 324 | 325 | const ItemTableTdStyle = styled.td<{ item: string; levelColor?: string }>` 326 | ${tableItemCss} 327 | font-family: 'Inter', sans-serif; 328 | font-weight: 400; 329 | font-size: 1rem; 330 | vertical-align: middle; 331 | ${({ item, levelColor }) => { 332 | if (item === 'level') { 333 | return css` 334 | color: ${levelColor}; 335 | font-weight: 600; 336 | `; 337 | } 338 | if (item === 'title') { 339 | return css` 340 | a { 341 | display: block; 342 | white-space: nowrap; 343 | overflow: hidden; 344 | text-overflow: ellipsis; 345 | } 346 | small { 347 | display: block; 348 | white-space: nowrap; 349 | overflow: hidden; 350 | text-overflow: ellipsis; 351 | margin-top: 0.0625rem; 352 | font-size: 0.75rem; 353 | color: rgb(120, 144, 160); 354 | line-height: 150%; 355 | } 356 | `; 357 | } 358 | if (item === 'finishedCount') { 359 | return css` 360 | font-size: 0.875rem; 361 | color: rgb(38, 55, 71); 362 | text-align: right; 363 | `; 364 | } 365 | if (item === 'acceptanceRate') { 366 | return css` 367 | font-size: 0.875rem; 368 | color: rgb(38, 55, 71); 369 | text-align: right; 370 | transform: translate(-10px, 0px); 371 | `; 372 | } 373 | }} 374 | `; 375 | -------------------------------------------------------------------------------- /src/pages/newTab/profile/ProfileTab.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import 'chart.js/auto'; 3 | 4 | import { navOption } from '@src/store/profile'; 5 | import { NavType, ContentType } from '@src/types/profile/profile-tab'; 6 | import { Children } from '@src/types/global'; 7 | import { LoaderStyle } from '@src/styles/global'; 8 | import Spinner from '@assets/icons/BlackSpinner.svg'; 9 | 10 | export default function ProfileTab({ children }: Children) { 11 | return {children}; 12 | } 13 | 14 | ProfileTab.Content = ({ children, isLoaded, isLoggedIn }: ContentType) => { 15 | if (isLoggedIn === false) { 16 | return ( 17 | 18 | 프로그래머스가 로그아웃 상태라 문제 정보를 받아오지 못했습니다! 19 | 로그인을 해주세요! 20 | 21 | ); 22 | } 23 | 24 | if (!isLoaded) { 25 | return ( 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | return <>{children}; 33 | }; 34 | 35 | const ContainerStyle = styled.div` 36 | background-color: ${({ theme }) => theme.color.whiter}; 37 | min-width: 768px; 38 | user-select: none; 39 | `; 40 | 41 | const LogoutStyle = styled.div` 42 | display: flex; 43 | flex-direction: column; 44 | text-align: center; 45 | gap: 0.6rem; 46 | line-height: 1.65rem; 47 | font-size: 1.1rem; 48 | font-family: 'Noto Sans KR'; 49 | font-weight: 300; 50 | color: ${({ theme }) => theme.color.darkGrey}; 51 | position: absolute; 52 | top: 50%; 53 | left: 50%; 54 | transform: translate(-50%, -50%); 55 | `; 56 | -------------------------------------------------------------------------------- /src/pages/newTab/profile/Statistics.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { uid } from 'react-uid'; 3 | import 'chart.js/auto'; 4 | import { Doughnut } from 'react-chartjs-2'; 5 | 6 | import { getPercentile } from '@src/utils/getPercentile'; 7 | 8 | import { BoxStyle, BoldTextStyle } from '@src/styles/global'; 9 | import { Children } from '@src/types/global'; 10 | import { ContentHeaderInfoStyle } from '@src/styles/global'; 11 | import { STATIST_HEAD } from '@src/constants/profile'; 12 | import { levels, levelsColor } from '@src/constants/level'; 13 | import Chart from '@assets/icons/Chart.svg'; 14 | import '@src/styles/font.css'; 15 | 16 | import { getChartInfoList, getProblemsCnt, getProblemsLevelList } from '@src/service/profile'; 17 | 18 | import { 19 | DoughnutType, 20 | ChartInfo, 21 | ChartInfoList, 22 | ProblemCntType, 23 | } from '@src/types/profile/profile-statistics'; 24 | import { ProblemsType } from '@src/types/profile/profile-layout'; 25 | 26 | export default function Statistics({ allProblems, solvedProblems }: ProblemsType) { 27 | const problemCnt = getProblemsCnt({ allProblems, solvedProblems }); 28 | const solvedLevelCnt = getProblemsLevelList(solvedProblems); 29 | const chartInfoList = getChartInfoList({ allProblems, solvedProblems }); 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | Statistics.Header = ({ problemCnt }: { problemCnt: ProblemCntType }) => { 43 | const { allCnt, solvedCnt } = problemCnt; 44 | return ( 45 | <> 46 | 47 | 48 | 난이도 분포 49 | 50 | 51 | {allCnt} 52 | 문제 중 53 | {solvedCnt} 54 | 문제 성공 55 | 56 | 57 | ! 58 | 전체 문제 정보는 6시간마다 업데이트됩니다. 59 | 60 | 61 | ); 62 | }; 63 | 64 | Statistics.Content = ({ children }: Children) => ( 65 | {children} 66 | ); 67 | 68 | Statistics.Doughnut = ({ problemCnt, solvedLevelCnt }: DoughnutType) => { 69 | const levelsName = levels.map(level => `Lv. ${level}`); 70 | const options = { maintainAspectRatio: false, responsive: false }; 71 | const data = { 72 | labels: levelsName, 73 | datasets: [ 74 | { 75 | labels: levelsName, 76 | data: solvedLevelCnt, 77 | backgroundColor: levelsColor, 78 | fill: true, 79 | }, 80 | ], 81 | }; 82 | 83 | const { allCnt, solvedCnt } = problemCnt; 84 | const [int, decimal] = getPercentile({ allCnt, solvedCnt }).split('.'); 85 | return ( 86 | 87 | 88 | {solvedCnt} 89 | 90 | {int} 91 | {decimal === '0' ? '' : `.${decimal}`}% 92 | 93 | 문제 성공 94 | 95 | 96 | 97 | ); 98 | }; 99 | 100 | Statistics.Table = ({ chartInfoList }: { chartInfoList: ChartInfoList }) => { 101 | return ( 102 | 103 | 104 | 105 | 106 | ); 107 | }; 108 | 109 | Statistics.TableHead = () => { 110 | return ( 111 | 112 | 113 | {STATIST_HEAD.map((info, idx) => ( 114 | {info} 115 | ))} 116 | 117 | 118 | ); 119 | }; 120 | 121 | Statistics.TableBody = ({ chartInfoList }: { chartInfoList: ChartInfoList }) => { 122 | return ( 123 | 124 | {chartInfoList.map((chart, idx) => ( 125 | 126 | ))} 127 | 128 | ); 129 | }; 130 | 131 | Statistics.TableCell = ({ chart }: { chart: ChartInfo }) => { 132 | const { level, color, allCnt, solvedCnt } = chart; 133 | const [int, decimal] = getPercentile({ allCnt, solvedCnt }).split('.'); 134 | const percentile = decimal === '0' ? `${int}` : `${int}.${decimal}`; 135 | 136 | return ( 137 | 138 | 139 | Lv. {level} 140 | 141 | 142 | {solvedCnt} 143 | 144 | 145 | {allCnt} 146 | 147 | 148 | {percentile} % 149 | 150 | 151 | ); 152 | }; 153 | 154 | const StatisticsContentStyle = styled.div` 155 | display: flex; 156 | justify-content: space-between; 157 | align-items: center; 158 | padding: 0.5rem 2rem; 159 | font-family: 'Noto Sans KR', sans-serif; 160 | ${({ theme }) => theme.media.desktop` 161 | display:flex; 162 | flex-direction: column; 163 | gap: 5rem; 164 | `} 165 | `; 166 | 167 | const SolvedHeaderStyle = styled.div` 168 | padding: 0.75rem 0; 169 | font-size: 1.5rem; 170 | `; 171 | 172 | const UpdateInfoStyle = styled.div` 173 | font-size: 0.9rem; 174 | color: #8a8f95; 175 | font-weight: 400; 176 | display: flex; 177 | align-items: center; 178 | gap: 0.3rem; 179 | `; 180 | 181 | const UpdateInfoIconStyle = styled.span` 182 | background-color: #8a8f95; 183 | width: 1rem; 184 | height: 1rem; 185 | display: inline-block; 186 | border-radius: 50%; 187 | color: white; 188 | text-align: center; 189 | vertical-align: middle; 190 | font-size: 0.8rem; 191 | transform: translate(0px, -1px); 192 | line-height: 0.9rem; 193 | `; 194 | 195 | const DoughnutWrapperStyle = styled.div` 196 | position: relative; 197 | position: relative; 198 | display: flex; 199 | justify-content: center; 200 | align-items: center; 201 | margin: 2rem; 202 | `; 203 | 204 | const TableStyle = styled.table` 205 | width: 100%; 206 | min-width: 400px; 207 | height: 15rem; 208 | ${({ theme }) => theme.media.desktop` 209 | margin-bottom: 2rem; 210 | `} 211 | `; 212 | 213 | const TableHeadStyle = styled.thead` 214 | display: table-header-group; 215 | tr { 216 | display: table-row; 217 | } 218 | td { 219 | font-size: 1rem; 220 | font-weight: 500; 221 | text-align: center; 222 | border-bottom: 1px solid #dddfe0; 223 | padding: 1rem; 224 | } 225 | `; 226 | 227 | const TableBodyStyle = styled.tbody` 228 | display: table-row-group; 229 | font-size: 1.1rem; 230 | font-weight: 400; 231 | color: ${({ theme }) => theme.color.darkGrey}; 232 | `; 233 | 234 | const TableCellStyle = styled.tr` 235 | tr { 236 | display: table-row; 237 | } 238 | td { 239 | display: table-cell; 240 | vertical-align: middle; 241 | border-bottom: 1px solid #dddfe0; 242 | padding: 1rem; 243 | text-align: center; 244 | } 245 | b { 246 | color: ${({ color }) => color}; 247 | font-weight: 700; 248 | } 249 | `; 250 | 251 | const CenterTextStyle = styled.div` 252 | display: flex; 253 | flex-direction: column; 254 | justify-content: center; 255 | align-items: center; 256 | line-height: 1.5; 257 | position: absolute; 258 | top: 57%; 259 | left: 50%; 260 | transform: translate(-50%, -50%); 261 | font-size: 2.5rem; 262 | user-select: none; 263 | &:not(:hover) { 264 | div:first-child { 265 | display: inline-block; 266 | } 267 | div:nth-child(2) { 268 | display: none; 269 | } 270 | } 271 | &:hover { 272 | div:first-child { 273 | display: none; 274 | } 275 | div:nth-child(2) { 276 | display: inline-block; 277 | } 278 | } 279 | `; 280 | 281 | const ProblemCntStyle = styled.div` 282 | font-weight: 500; 283 | `; 284 | 285 | const ProblemPercentStyle = styled.div` 286 | font-weight: 500; 287 | span:last-child { 288 | font-size: 1.2rem; 289 | font-weight: 400; 290 | } 291 | `; 292 | 293 | const SolvedStyle = styled.span` 294 | font-size: 1rem; 295 | font-weight: 400; 296 | color: ${({ theme }) => theme.color.darkGrey}; 297 | `; 298 | -------------------------------------------------------------------------------- /src/pages/newTab/profile/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { RecoilRoot, useRecoilValue } from 'recoil'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import styled from 'styled-components'; 6 | 7 | import ProfileTab from './ProfileTab'; 8 | import Problems from './Problems'; 9 | import Statistics from './Statistics'; 10 | 11 | import { theme } from '@src/styles/theme'; 12 | import GlobalStyles from '@src/styles/global'; 13 | import { navOption } from '@src/store/profile'; 14 | import { useProblems } from '@src/hooks/profile'; 15 | import '@src/styles/font.css'; 16 | 17 | import Header from '@src/components/domain/profile/Header'; 18 | import NavBar from '@src/components/domain/profile/NavBar'; 19 | 20 | const ProfileTabLayout = () => { 21 | const { isLoggedIn, isLoaded, allProblems, solvedProblems } = useProblems(); 22 | const selectedNavOption = useRecoilValue(navOption); 23 | 24 | return ( 25 | 26 |
27 | 28 | 29 | {selectedNavOption === 'MAIN' && ( 30 | 31 | )} 32 | {selectedNavOption === 'PROBLEM' && } 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | const ContainerStyle = styled.div` 40 | background-color: ${({ theme }) => theme.color.whiter}; 41 | min-width: 768px; 42 | user-select: none; 43 | `; 44 | 45 | const FooterStyle = styled.div` 46 | height: 2rem; 47 | `; 48 | 49 | const root = document.createElement('div'); 50 | root.style.cssText = ` 51 | background-color: ${theme.color.whiter}; 52 | height: 100vh; 53 | `; 54 | document.body.appendChild(root); 55 | ReactDOM.createRoot(root as HTMLElement).render( 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | , 64 | ); 65 | -------------------------------------------------------------------------------- /src/pages/popup/Footer.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import GithubLogo from '@assets/images/github.png'; 3 | 4 | const Footer = () => { 5 | return ( 6 | 7 | 8 | 9 | 10 | Github 바로가기 11 | 12 | ); 13 | }; 14 | 15 | export default Footer; 16 | 17 | const FooterStyle = styled.a` 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | img { 22 | width: 1.35rem; 23 | height: 1.35rem; 24 | } 25 | span { 26 | font-size: 1rem; 27 | margin-right: 0.3rem; 28 | font-weight: 400; 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /src/pages/popup/Login.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import GoogleLoginButton from '@src/components/shared/button/GoogleLoginButton'; 3 | 4 | interface LoginProps { 5 | isLoaded: boolean; 6 | userEmail: string; 7 | } 8 | 9 | const Login = ({ isLoaded, userEmail }: LoginProps) => { 10 | if (!isLoaded) { 11 | return ( 12 | 13 |
로그인 여부 확인하는 데 약 3초 정도 시간이 걸립니다.
14 |
잠시만 기다려주세요 ...
15 |
16 | ); 17 | } 18 | 19 | return ( 20 | <> 21 | {userEmail === '' && ( 22 | 23 | 로그인이 되어있지 않습니다. 로그인을 해주세요! 24 | 25 | 26 | )} 27 | {userEmail === '' || ( 28 | 29 | 이메일 주소: 30 | {userEmail} 31 | 32 | )} 33 | 34 | ); 35 | }; 36 | 37 | export default Login; 38 | 39 | const LoadingStyle = styled.div` 40 | display: grid; 41 | grid-row-gap: 0.3rem; 42 | div { 43 | font-size: 0.8rem; 44 | color: ${({ theme }) => theme.color.darkGrey}; 45 | font-weight: 400; 46 | } 47 | `; 48 | 49 | const UserInfoStyle = styled.div` 50 | font-size: 1.1rem; 51 | gap: 1rem; 52 | font-weight: 500; 53 | span:first-child { 54 | color: ${({ theme }) => theme.color.darkGrey}; 55 | margin-right: 0.5rem; 56 | } 57 | span:last-child { 58 | color: ${({ theme }) => theme.color.deepBlue}; 59 | } 60 | `; 61 | 62 | const LoginStyle = styled.div` 63 | & > span { 64 | font-size: 0.8rem; 65 | font-weight: 400; 66 | color: ${({ theme }) => theme.color.darkGrey}; 67 | } 68 | `; 69 | -------------------------------------------------------------------------------- /src/pages/popup/Title.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Title = () => { 4 | return ( 5 | 6 |

프로솔브

7 | 프로그래머스 풀이를 저장하는 크롬 익스텐션 8 |
9 | ); 10 | }; 11 | 12 | const TitleStyle = styled.div` 13 | display: flex; 14 | flex-direction: column; 15 | margin-bottom: 1rem; 16 | h1 { 17 | font-size: 2.3rem; 18 | font-weight: 500; 19 | padding-bottom: 0.8rem; 20 | } 21 | span { 22 | font-size: 1rem; 23 | font-weight: 400; 24 | color: ${({ theme }) => theme.color.darkGrey}; 25 | } 26 | `; 27 | 28 | export default Title; 29 | -------------------------------------------------------------------------------- /src/pages/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { ThemeProvider } from 'styled-components'; 4 | import styled from 'styled-components'; 5 | import '@src/styles/font.css'; 6 | 7 | import { theme } from '@src/styles/theme'; 8 | import GlobalStyles from '@src/styles/global'; 9 | import { useAuth } from '@src/hooks/popup/useAuth'; 10 | 11 | import Title from './Title'; 12 | import Login from './Login'; 13 | import Footer from './Footer'; 14 | 15 | const Popup = () => { 16 | const { isLoaded, userEmail } = useAuth(); 17 | return ( 18 | 19 | 20 | 21 | <Login isLoaded={isLoaded} userEmail={userEmail} /> 22 | </ContentStyle> 23 | <Footer /> 24 | </ContainerStyle> 25 | ); 26 | }; 27 | 28 | export default Popup; 29 | 30 | export const ContainerStyle = styled.div` 31 | display: flex; 32 | flex-direction: column; 33 | width: 25rem; 34 | height: 15rem; 35 | padding: 1.5rem 2rem; 36 | gap: 1rem; 37 | font-family: 'Noto Sans KR', sans-serif; 38 | `; 39 | 40 | export const ContentStyle = styled.div` 41 | height: 100%; 42 | `; 43 | 44 | const root = document.createElement('div'); 45 | document.body.appendChild(root); 46 | ReactDOM.createRoot(root as HTMLElement).render( 47 | <React.StrictMode> 48 | <ThemeProvider theme={theme}> 49 | <GlobalStyles /> 50 | <Popup /> 51 | </ThemeProvider> 52 | </React.StrictMode>, 53 | ); 54 | -------------------------------------------------------------------------------- /src/service/profile/filterSolvedProblemsByPartTitle.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil'; 2 | 3 | import { SolvedProblemType } from '@src/types/profile/profile-layout'; 4 | import { problemTitleOption } from '@src/store/select'; 5 | 6 | const filterSolvedProblemsByPartTitle = (solvedProblems: SolvedProblemType) => { 7 | const selectedPartTitle = useRecoilValue(problemTitleOption); 8 | 9 | if (selectedPartTitle.includes('전체 문제')) return solvedProblems; 10 | return solvedProblems.filter(({ partTitle }) => selectedPartTitle.includes(partTitle)); 11 | }; 12 | 13 | export { filterSolvedProblemsByPartTitle }; 14 | -------------------------------------------------------------------------------- /src/service/profile/getAllProblemsList.ts: -------------------------------------------------------------------------------- 1 | import { getJSON } from '@src/utils/fetchRequest'; 2 | import { ALL_PROBLEM_URL as url } from '@src/constants/url'; 3 | import { SolvedProblemType } from '@src/types/profile/profile-layout'; 4 | 5 | const getAllProblemsList = async () => await getJSON<SolvedProblemType>({ url }); 6 | 7 | export { getAllProblemsList }; 8 | -------------------------------------------------------------------------------- /src/service/profile/getChartInfoList.ts: -------------------------------------------------------------------------------- 1 | import { levels, levelsColor } from '@src/constants/level'; 2 | import { ProblemsType } from '@src/types/profile/profile-layout'; 3 | 4 | import { getProblemsLevelList } from './getProblemsLevelList'; 5 | 6 | const getChartInfoList = ({ allProblems, solvedProblems }: ProblemsType) => { 7 | const problemsCnt = getProblemsLevelList(allProblems); 8 | const solvedCnt = getProblemsLevelList(solvedProblems); 9 | 10 | return levels.map((level, idx) => ({ 11 | level, 12 | color: levelsColor[idx], 13 | allCnt: problemsCnt[idx], 14 | solvedCnt: solvedCnt[idx], 15 | })); 16 | }; 17 | 18 | export { getChartInfoList }; 19 | -------------------------------------------------------------------------------- /src/service/profile/getFilteredSolvedProblems.ts: -------------------------------------------------------------------------------- 1 | import { SolvedProblemType } from '@src/types/profile/profile-layout'; 2 | import { sortSolvedProblems } from './sortSolvedProblems'; 3 | import { filterSolvedProblemsByPartTitle } from './filterSolvedProblemsByPartTitle'; 4 | 5 | const getFilteredSolvedProblems = (solvedProblems: SolvedProblemType) => { 6 | const sortedSolvedProblems = sortSolvedProblems(solvedProblems); 7 | return filterSolvedProblemsByPartTitle(sortedSolvedProblems); 8 | }; 9 | 10 | export { getFilteredSolvedProblems }; 11 | -------------------------------------------------------------------------------- /src/service/profile/getPartTitleListOfSolvedProblems.ts: -------------------------------------------------------------------------------- 1 | import { SolvedProblemType } from '@src/types/profile/profile-layout'; 2 | 3 | const getPartTitleListOfSolvedProblems = (solvedProblems: SolvedProblemType) => { 4 | const problemsTitleMap = solvedProblems.reduce<Record<string, number>>( 5 | (partTitleList, { partTitle }) => { 6 | partTitleList[partTitle] = (partTitleList[partTitle] ?? 0) + 1; 7 | return partTitleList; 8 | }, 9 | {}, 10 | ); 11 | 12 | const partTitleList = Object.entries(problemsTitleMap) 13 | .sort(([prevTitle, prevCnt], [currTitle, currCnt]) => currCnt - prevCnt) 14 | .map(([title, cnt]) => `${title} (${cnt})`); 15 | 16 | const allProblemTitle = `전체 문제 (${solvedProblems.length})`; 17 | return [allProblemTitle, ...partTitleList]; 18 | }; 19 | 20 | export { getPartTitleListOfSolvedProblems }; 21 | -------------------------------------------------------------------------------- /src/service/profile/getProblemsCnt.ts: -------------------------------------------------------------------------------- 1 | import { ProblemsType } from '@src/types/profile/profile-layout'; 2 | 3 | const getProblemsCnt = ({ allProblems, solvedProblems }: ProblemsType) => ({ 4 | allCnt: allProblems.length, 5 | solvedCnt: solvedProblems.length, 6 | }); 7 | 8 | export { getProblemsCnt }; 9 | -------------------------------------------------------------------------------- /src/service/profile/getProblemsLevelList.ts: -------------------------------------------------------------------------------- 1 | import { SolvedProblemType, LevelListFunc } from '@src/types/profile/profile-layout'; 2 | 3 | const getProblemsLevelList: LevelListFunc = (problems: SolvedProblemType) => 4 | problems.reduce((prev, { level }) => { 5 | prev[level] += 1; 6 | return prev; 7 | }, new Array(6).fill(0)); 8 | 9 | export { getProblemsLevelList }; 10 | -------------------------------------------------------------------------------- /src/service/profile/getSolvedProblemList.ts: -------------------------------------------------------------------------------- 1 | import { getSuccessProblemsIdListStorage } from '@src/api/solution/getUserInfoStorage'; 2 | import { SolvedProblemType, ProblemType } from '@src/types/profile/profile-layout'; 3 | 4 | const getSolvedProblemList = async (userEmail: string, allProblems: SolvedProblemType) => { 5 | const solvedProblemIdList = await getSuccessProblemsIdListStorage(userEmail); 6 | 7 | return allProblems.reduce((prev: SolvedProblemType, curr: ProblemType) => { 8 | solvedProblemIdList.forEach(problem => { 9 | if (problem === curr.id) { 10 | prev.push(curr); 11 | } 12 | }); 13 | return prev; 14 | }, []); 15 | }; 16 | 17 | export { getSolvedProblemList }; 18 | -------------------------------------------------------------------------------- /src/service/profile/index.ts: -------------------------------------------------------------------------------- 1 | import { filterSolvedProblemsByPartTitle } from './filterSolvedProblemsByPartTitle'; 2 | import { getAllProblemsList } from './getAllProblemsList'; 3 | import { getSolvedProblemList } from './getSolvedProblemList'; 4 | import { getChartInfoList } from './getChartInfoList'; 5 | import { getFilteredSolvedProblems } from './getFilteredSolvedProblems'; 6 | import { getPartTitleListOfSolvedProblems } from './getPartTitleListOfSolvedProblems'; 7 | import { getProblemsCnt } from './getProblemsCnt'; 8 | import { getProblemsLevelList } from './getProblemsLevelList'; 9 | import { sortSolvedProblems } from './sortSolvedProblems'; 10 | 11 | export { 12 | filterSolvedProblemsByPartTitle, 13 | getAllProblemsList, 14 | getSolvedProblemList, 15 | getChartInfoList, 16 | getFilteredSolvedProblems, 17 | getPartTitleListOfSolvedProblems, 18 | getProblemsCnt, 19 | getProblemsLevelList, 20 | sortSolvedProblems, 21 | }; 22 | -------------------------------------------------------------------------------- /src/service/profile/sortSolvedProblems.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil'; 2 | 3 | import { SolvedProblemType, SortType } from '@src/types/profile/profile-layout'; 4 | import { sortOption } from '@src/store/profile'; 5 | 6 | const sortSolvedProblems = (solvedProblems: SolvedProblemType) => { 7 | const sortType = useRecoilValue(sortOption); 8 | const { type, isAscending } = sortType as SortType; 9 | 10 | return solvedProblems.sort((prevProblem, currProblem) => { 11 | if (isAscending) return prevProblem[type] - currProblem[type]; 12 | return currProblem[type] - prevProblem[type]; 13 | }); 14 | }; 15 | 16 | export { sortSolvedProblems }; 17 | -------------------------------------------------------------------------------- /src/service/solution/filteredSolutions.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil'; 2 | import { solutionOption, sortedOption } from '@src/store/select'; 3 | import { SolutionList } from '@src/types/solution'; 4 | import { formatTimestampToDate } from '@src/utils/date/formatTimestampToDate'; 5 | 6 | export const filteredSolutions = (solutions: SolutionList) => { 7 | solutions = solutions || []; 8 | 9 | const selectedSolutionType = useRecoilValue(solutionOption); 10 | const selectedSortType = useRecoilValue(sortedOption); 11 | 12 | solutions!.sort(({ uploadTime: prevUploadTime }, { uploadTime: currUploadTime }) => { 13 | const prevDate = formatTimestampToDate(prevUploadTime).valueOf(); 14 | const currDate = formatTimestampToDate(currUploadTime).valueOf(); 15 | 16 | if (selectedSortType === 'ASC') { 17 | return prevDate - currDate; 18 | } 19 | return currDate - prevDate; 20 | }); 21 | 22 | if (selectedSolutionType === 'SUCCESS') { 23 | return solutions.filter(({ isSuccess }) => isSuccess); 24 | } 25 | if (selectedSolutionType === 'FAILED') { 26 | return solutions.filter(({ isSuccess }) => !isSuccess); 27 | } 28 | return solutions; 29 | }; 30 | -------------------------------------------------------------------------------- /src/service/solution/getAllSolutions.ts: -------------------------------------------------------------------------------- 1 | import { SolutionResponse } from '@src/types/solution'; 2 | 3 | interface HrefProps { 4 | selectedLanguage: string; 5 | problemId: string; 6 | } 7 | 8 | type GetAllSolutionFn = ({ selectedLanguage, problemId }: HrefProps) => Promise<SolutionResponse>; 9 | const getAllSolutions: GetAllSolutionFn = async ({ selectedLanguage, problemId }: HrefProps) => { 10 | console.log(`[Pro Solve] 문제 번호:>> ${problemId} 선택한 언어:>> ${selectedLanguage}`); 11 | 12 | const allSolutions = await new Promise<SolutionResponse>(resolve => { 13 | chrome.runtime.sendMessage( 14 | { 15 | method: 'getAllSolutions', 16 | data: { 17 | problemId, 18 | selectedLanguage, 19 | }, 20 | }, 21 | (response: SolutionResponse) => { 22 | resolve(response); 23 | console.log('[Pro Solve] 풀이한 코드 List :>>', response); 24 | }, 25 | ); 26 | }); 27 | 28 | return allSolutions; 29 | }; 30 | 31 | export { getAllSolutions }; 32 | -------------------------------------------------------------------------------- /src/service/solution/index.ts: -------------------------------------------------------------------------------- 1 | import { filteredSolutions } from './filteredSolutions'; 2 | import { getAllSolutions } from './getAllSolutions'; 3 | 4 | export { filteredSolutions, getAllSolutions }; 5 | -------------------------------------------------------------------------------- /src/service/testPage/getProblemInfo.ts: -------------------------------------------------------------------------------- 1 | export const getProblemInfo = () => { 2 | const $selectedLanguage = ( 3 | document.querySelector('div.editor > ul > li.nav-item > a') as HTMLAnchorElement 4 | ).getAttribute('data-language')!; 5 | const $problemId = ( 6 | document.querySelector('div.main > div.lesson-content') as HTMLDivElement 7 | ).getAttribute('data-lesson-id')!; 8 | const $problemName = ( 9 | document.querySelector('li.algorithm-title') as HTMLLIElement 10 | ).textContent!.trim(); 11 | 12 | return { 13 | $selectedLanguage, 14 | $problemId, 15 | $problemName, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/service/testPage/memo/CreateMemoListButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Modal from '@src/components/modal/Modal'; 4 | import RefreshRequestBox from '@src/components/box/RefreshRequestBox'; 5 | 6 | interface ButtonProps { 7 | problemId: string; 8 | problemName: string; 9 | } 10 | 11 | const CreateMemoListButton = (href: ButtonProps) => { 12 | const [isModalOpen, setIsModalOpen] = React.useState(false); 13 | 14 | const createMemoTab = () => { 15 | if (chrome.runtime?.id === undefined) { 16 | return setIsModalOpen(true); 17 | } 18 | 19 | chrome.runtime.sendMessage({ 20 | method: 'createMemoTab', 21 | href, 22 | }); 23 | }; 24 | 25 | return ( 26 | <> 27 | <Modal isOpen={isModalOpen} closeModal={() => setIsModalOpen(false)}> 28 | <RefreshRequestBox>새로고침을 해주세요!</RefreshRequestBox> 29 | </Modal> 30 | <ButtonStyle onClick={createMemoTab}>아이디어 아카이빙</ButtonStyle> 31 | </> 32 | ); 33 | }; 34 | 35 | const ButtonStyle = styled.button` 36 | background-color: ${({ theme }) => theme.color.darkGrey}; 37 | color: ${({ theme }) => theme.color.white}; 38 | border-radius: 0.25rem; 39 | border: none; 40 | padding: 0.125rem 0.375rem; 41 | font-size: 0.75rem; 42 | line-height: 1.5rem; 43 | font-weight: 500; 44 | transition: color 0.08s ease-in-out, background-color 0.08s ease-in-out, 45 | border-color 0.08s ease-in-out, box-shadow 0.08s ease-in-out; 46 | `; 47 | 48 | export default CreateMemoListButton; 49 | -------------------------------------------------------------------------------- /src/service/testPage/memo/createMemoTabButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { theme } from '@src/styles/theme'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import { getProblemInfo } from '../getProblemInfo'; 6 | import CreateMemoListButton from '@src/components/domain/testPage/CreateMemoListButton'; 7 | 8 | export const createMemoTabButton = () => { 9 | const { $problemId, $problemName } = getProblemInfo(); 10 | 11 | const btn = document.createElement('a'); 12 | const root = document.querySelector('div.challenge-settings') as HTMLDivElement; 13 | root.prepend(btn); 14 | ReactDOM.createRoot(btn as HTMLElement).render( 15 | <React.StrictMode> 16 | <ThemeProvider theme={theme}> 17 | <CreateMemoListButton problemId={$problemId} problemName={$problemName} /> 18 | </ThemeProvider> 19 | </React.StrictMode>, 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/service/testPage/memo/index.ts: -------------------------------------------------------------------------------- 1 | import { createMemoTabButton } from './createMemoTabButton'; 2 | export { createMemoTabButton }; 3 | -------------------------------------------------------------------------------- /src/service/testPage/problemUpload/createShowSolutionsButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { theme } from '@src/styles/theme'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import CreateSolutionsButton from '@src/components/shared/button/CreateSolutionsButton'; 6 | import { getProblemInfo } from '../getProblemInfo'; 7 | 8 | export const createShowSolutionsButton = () => { 9 | const { $selectedLanguage, $problemId, $problemName } = getProblemInfo(); 10 | 11 | const btn = document.createElement('a'); 12 | const root = document.querySelector('div.modal-footer') as HTMLDivElement; 13 | root.appendChild(btn); 14 | ReactDOM.createRoot(btn as HTMLElement).render( 15 | <React.StrictMode> 16 | <ThemeProvider theme={theme}> 17 | <CreateSolutionsButton 18 | selectedLanguage={$selectedLanguage} 19 | problemId={$problemId} 20 | problemName={$problemName} 21 | /> 22 | </ThemeProvider> 23 | </React.StrictMode>, 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/service/testPage/problemUpload/index.tsx: -------------------------------------------------------------------------------- 1 | import { printLoadingText } from './printLoadingText'; 2 | import { createShowSolutionsButton } from './createShowSolutionsButton'; 3 | import { uploadCurrentSolution } from './uploadCurrentSolution'; 4 | import { printRequestOfRefresh } from './printRequestOfRefresh'; 5 | import { parsingDomNodeToUpload } from './parsingDomNodeToUpload'; 6 | import { printIsUploadSuccess } from './printIsUploadSuccess'; 7 | 8 | export { 9 | printLoadingText, 10 | createShowSolutionsButton, 11 | uploadCurrentSolution, 12 | printRequestOfRefresh, 13 | parsingDomNodeToUpload, 14 | printIsUploadSuccess, 15 | }; 16 | -------------------------------------------------------------------------------- /src/service/testPage/problemUpload/parsingDomNodeToUpload.ts: -------------------------------------------------------------------------------- 1 | export const parsingDomNodeToUpload = () => { 2 | const solutionResult = (document.querySelector('div.modal-header > h4') as HTMLHeadElement) 3 | .textContent; 4 | const isSuccess = solutionResult!.includes('정답'); 5 | 6 | const code = (document.querySelector('textarea#code') as HTMLTextAreaElement).value; 7 | const selectedLanguage = ( 8 | document.querySelector('div.editor > ul > li.nav-item > a') as HTMLAnchorElement 9 | ).getAttribute('data-language'); 10 | const problemId = ( 11 | document.querySelector('div.main > div.lesson-content') as HTMLDivElement 12 | ).getAttribute('data-lesson-id'); 13 | const passedTestCase = document.querySelectorAll('td.result.passed').length; 14 | const failedTestCase = document.querySelectorAll('td.result.failed').length; 15 | 16 | return { 17 | isSuccess, 18 | code, 19 | selectedLanguage, 20 | problemId, 21 | passedTestCase, 22 | failedTestCase, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/service/testPage/problemUpload/printIsUploadSuccess.ts: -------------------------------------------------------------------------------- 1 | import { theme } from '@src/styles/theme'; 2 | 3 | export const printIsUploadSuccess = (uploadResult: boolean) => { 4 | const $modalUploadResult = document.querySelector('div.modal-upload') as HTMLElement; 5 | 6 | if (uploadResult) { 7 | $modalUploadResult.textContent = '업로드 성공!'; 8 | $modalUploadResult.style.color = theme.color.blue; 9 | return; 10 | } 11 | 12 | $modalUploadResult.textContent = '업로드 중 에러가 발생했습니다. 로그인 여부를 확인해주세요!'; 13 | $modalUploadResult.style.color = theme.color.red; 14 | }; 15 | -------------------------------------------------------------------------------- /src/service/testPage/problemUpload/printLoadingText.ts: -------------------------------------------------------------------------------- 1 | import { theme } from '@src/styles/theme'; 2 | 3 | export const printLoadingText = () => { 4 | const $modalContent = document.querySelector('div.modal-body') as HTMLDivElement; 5 | $modalContent.innerHTML = `<span>Pro Solve 익스텐션이 결과를 저장합니다.<br />모달 창을 닫으셔도 됩니다.</span>`; 6 | 7 | const modalUploadResult = document.createElement('div'); 8 | modalUploadResult.className = 'modal-upload'; 9 | modalUploadResult.textContent = 'Loading...'; 10 | modalUploadResult.style.color = theme.color.deepBlue; 11 | $modalContent.append(modalUploadResult); 12 | }; 13 | -------------------------------------------------------------------------------- /src/service/testPage/problemUpload/printRequestOfRefresh.ts: -------------------------------------------------------------------------------- 1 | import { theme } from '@src/styles/theme'; 2 | 3 | export const printRequestOfRefresh = () => { 4 | console.log( 5 | '[Pro Solve] Pro Solve 익스텐션의 세부사항을 변경해 reload되었습니다. 새로고침을 해주세요.', 6 | ); 7 | const $modalContent = document.querySelector('div.modal-body') as HTMLDivElement; 8 | const $modalUploadResult = document.querySelector('div.modal-upload') as HTMLElement; 9 | $modalUploadResult.remove(); 10 | 11 | $modalContent.innerHTML = `<span>Pro Solve 익스텐션의 세부사항을 변경하셨네요!<br />업로드를 하려면 페이지를 새로고침 해주세요.</span>`; 12 | $modalContent.style.color = theme.color.red; 13 | }; 14 | -------------------------------------------------------------------------------- /src/service/testPage/problemUpload/uploadCurrentSolution.ts: -------------------------------------------------------------------------------- 1 | import { printRequestOfRefresh, parsingDomNodeToUpload, printIsUploadSuccess } from '.'; 2 | import { addSolvedProblemId } from '@src/api/solution/addSolvedProblemId'; 3 | 4 | export const uploadCurrentSolution = async () => { 5 | console.log('[Pro Solve] 제출한 코드 업로드를 시작합니다.'); 6 | if (chrome.runtime?.id === undefined) { 7 | printRequestOfRefresh(); 8 | return; 9 | } 10 | 11 | const data = parsingDomNodeToUpload(); 12 | const { problemId, isSuccess } = data; 13 | addSolvedProblemId(Number(problemId), isSuccess); 14 | 15 | const uploadResult = await new Promise<boolean>(resolve => { 16 | chrome.runtime.sendMessage({ method: 'postCurrentSolution', data }, response => { 17 | resolve(response.status); 18 | console.log('[Pro Solve] 코드 업로드 성공 여부 :>>', response.status); 19 | }); 20 | }); 21 | 22 | printIsUploadSuccess(uploadResult); 23 | }; 24 | -------------------------------------------------------------------------------- /src/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-redo/pro-solve/997b73da3e238c1c72ebabb3b719668faa770e33/src/static/icon.png -------------------------------------------------------------------------------- /src/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "프로솔브(Pro-Solve)", 3 | "description": "제출한 모든 프로그래머스 풀이를 확인할 수 있게 해주는 크롬 익스텐션", 4 | "version": "1.1.1", 5 | "manifest_version": 3, 6 | "icons": { 7 | "16": "icon.png", 8 | "48": "icon.png", 9 | "128": "icon.png" 10 | }, 11 | "action": { 12 | "default_popup": "popup.html", 13 | "default_title": "프로솔브(Pro-Solve)", 14 | "default_icon": "icon.png" 15 | }, 16 | "permissions": ["identity", "storage"], 17 | "background": { 18 | "service_worker": "script/background.js" 19 | }, 20 | "host_permissions": ["https://programmers.co.kr/"], 21 | "web_accessible_resources": [ 22 | { 23 | "resources": ["*.png", "*.eot", "*.woff", "*.woff2", "*.ttf", "*.svg"], 24 | "matches": ["<all_urls>"] 25 | } 26 | ], 27 | "externally_connectable": { 28 | "matches": ["https://programmers.co.kr/"] 29 | }, 30 | "content_scripts": [ 31 | { 32 | "matches": ["https://school.programmers.co.kr/learn/courses/30/lessons/*"], 33 | "exclude_matches": [ 34 | "https://school.programmers.co.kr/learn/courses/30/lessons/*/questions", 35 | "https://school.programmers.co.kr/learn/courses/30/lessons/*/solution_groups*" 36 | ], 37 | "js": ["script/testContent.js"] 38 | }, 39 | { 40 | "matches": ["https://school.programmers.co.kr/learn/courses/30/lessons/*/solution_groups*"], 41 | "js": ["script/solutionContent.js"] 42 | }, 43 | { 44 | "matches": ["https://school.programmers.co.kr/learn/challenges*"], 45 | "exclude_matches": ["https://school.programmers.co.kr/learn/courses/30/lessons/*"], 46 | "js": ["script/problemContent.js"] 47 | } 48 | ], 49 | "oauth2": { 50 | "client_id": "708749417674-v6i3dioqb3p3dtn41msur718e86n1r1c.apps.googleusercontent.com", 51 | "scopes": ["https://www.googleapis.com/auth/userinfo.email"] 52 | }, 53 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAka3fpzN2kY1bHPcKtZM9ir+6lpszjVSlCWvYMSnKID2urEQS4FcBKl3WDwheTBOJNDBYoCUwccT75Nl5b3uJYZ3VY8Y0g6OYby9JfHBGnA8vafNX1YxzV6rmXrTfk8hA0odrrTdR9SiwTvbu1u7DjNPpvhU2U3Bc1Lkgs8gjpvllAQVMnXBymAKFHUTSskCI15wmJ6ze9/0fmUsE4AO5zRJDwhWGvt5f+H1XHcR+S6np6EIUvBFNZFNxW4Wc0DKU0MQ5bsp0uuyDnJgqOlK7JwIs8DX6qtby941aR1T/LCaF/IRhFMKZWPMcM5zqFVK8KqLa5b99IHW9bR/BXClSRQIDAQAB" 54 | } 55 | -------------------------------------------------------------------------------- /src/store/modal.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const modalIsOpen = atom<boolean>({ 4 | key: 'Modal', 5 | default: false, 6 | }); 7 | -------------------------------------------------------------------------------- /src/store/profile.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | import { SortType } from '@src/types/profile/profile-problems'; 3 | 4 | export const navOption = atom<string>({ 5 | key: 'Nav/Option', 6 | default: 'MAIN', 7 | }); 8 | 9 | export const sortOption = atom<SortType>({ 10 | key: 'Nav/Sort', 11 | default: { 12 | type: 'level', 13 | isAscending: true, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/store/select.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | const solutionOption = atom<string>({ 4 | key: 'Select/Solution', 5 | default: 'SUCCESS', 6 | }); 7 | 8 | const sortedOption = atom<string>({ 9 | key: 'Select/Sort', 10 | default: 'DESC', 11 | }); 12 | 13 | const problemTitleOption = atom<string>({ 14 | key: 'Select/ProblemTitle', 15 | default: 'ALL', 16 | }); 17 | 18 | export { solutionOption, sortedOption, problemTitleOption }; 19 | -------------------------------------------------------------------------------- /src/stories/components/shared/section/Pagination.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentStoryFn, ComponentMeta } from '@storybook/react'; 2 | import { action } from '@storybook/addon-actions'; 3 | 4 | import Pagination from '@src/components/shared/section/Pagination'; 5 | 6 | export default { 7 | title: 'Component/Shared/Section/Pagination', 8 | component: Pagination, 9 | argTypes: { 10 | total: { 11 | control: { type: 'number', min: 0 }, 12 | defaultValue: 100, 13 | description: '총 데이터의 개수', 14 | }, 15 | limit: { 16 | control: { type: 'number', min: 1 }, 17 | defaultValue: 10, 18 | description: '한 페이지당 보여줄 아이템의 개수', 19 | }, 20 | unit: { 21 | control: { type: 'number', min: 1 }, 22 | defaultValue: 5, 23 | description: '페이지 버튼 그룹의 크기', 24 | }, 25 | pageIdx: { 26 | control: { type: 'number', min: 0 }, 27 | defaultValue: 0, 28 | description: '현재 선택된 페이지의 인덱스 (0부터 시작)', 29 | }, 30 | onChangePageIdx: { 31 | control: null, 32 | description: '페이지 버튼을 클릭했을 때 페이지 인덱스를 변경하는 함수', 33 | }, 34 | }, 35 | parameters: { 36 | docs: { 37 | description: { 38 | component: `이 페이지네이션 컴포넌트는 총 데이터의 개수, 한 페이지당 보여줄 아이템의 개수, 페이지 버튼 그룹의 크기 등을 설정하여 페이지네이션 UI를 보여줍니다. <br /> 39 | 페이지 버튼 그룹의 크기가 클수록 한 번에 보여지는 페이지 버튼이 많아집니다. <br /> 40 | 페이지 버튼을 클릭하면 onChangePageIdx 콜백 함수가 호출되며, 페이지 인덱스를 변경하여 다른 페이지를 보여줄 수 있습니다.`, 41 | }, 42 | }, 43 | }, 44 | } as ComponentMeta<typeof Pagination>; 45 | 46 | const Template: ComponentStoryFn<typeof Pagination> = args => <Pagination {...args} />; 47 | export const FirstPage = Template.bind({}); 48 | FirstPage.args = { 49 | pageIdx: 0, 50 | total: 100, 51 | limit: 10, 52 | unit: 5, 53 | onChangePageIdx: action('onChangePageIdx'), 54 | }; 55 | FirstPage.parameters = { 56 | docs: { 57 | description: { 58 | story: '첫 번째 페이지를 보여주는 페이지네이션입니다.', 59 | }, 60 | }, 61 | }; 62 | 63 | export const Disabled = Template.bind({}); 64 | Disabled.args = { 65 | total: 0, 66 | limit: 10, 67 | unit: 5, 68 | pageIdx: 0, 69 | onChangePageIdx: action('onChangePageIdx'), 70 | }; 71 | Disabled.parameters = { 72 | docs: { 73 | description: { 74 | story: '데이터가 없는 경우 비활성화된 페이지네이션입니다.', 75 | }, 76 | }, 77 | }; 78 | 79 | export const LastPage = Template.bind({}); 80 | LastPage.args = { 81 | total: 100, 82 | limit: 10, 83 | unit: 5, 84 | pageIdx: 9, 85 | onChangePageIdx: action('onChangePageIdx'), 86 | }; 87 | LastPage.parameters = { 88 | docs: { 89 | description: { 90 | story: '마지막 페이지를 보여주는 페이지네이션입니다.', 91 | }, 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /src/stories/components/shared/select/CheckOption.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryFn } from '@storybook/react'; 2 | import { action } from '@storybook/addon-actions'; 3 | 4 | import CheckOption from '@src/components/shared/select/CheckOption'; 5 | 6 | export default { 7 | title: 'Component/Shared/Select/CheckOption', 8 | component: CheckOption, 9 | argTypes: { 10 | isOpen: { 11 | control: { type: 'boolean' }, 12 | defaultValue: false, 13 | description: 'Dropdown이 열려있는지 여부를 나타내는 boolean 값', 14 | }, 15 | value: { 16 | control: { type: 'string' }, 17 | description: 'Dropdown 내부에 표시할 텍스트', 18 | }, 19 | onModalChange: { 20 | control: null, 21 | description: 'Dropdown의 열림/닫힘 상태를 변경할 때 호출되는 함수', 22 | }, 23 | }, 24 | decorators: [ 25 | Story => ( 26 | <div 27 | style={{ 28 | margin: '1rem', 29 | }} 30 | > 31 | <Story /> 32 | </div> 33 | ), 34 | ], 35 | parameters: { 36 | docs: { 37 | description: { 38 | component: `CheckOption은 Select 컴포넌트의 선택지를 표시하는데 자주 사용되는 컴포넌트입니다. <br /> 39 | 만약 Dropdown이 열려있다면(isOpen이 true) 컴포넌트의 배경 색상이 짙어집니다. 40 | `, 41 | }, 42 | }, 43 | }, 44 | } as ComponentMeta<typeof CheckOption>; 45 | 46 | const Template: ComponentStoryFn<typeof CheckOption> = args => <CheckOption {...args} />; 47 | export const Open = Template.bind({}); 48 | Open.args = { 49 | isOpen: true, 50 | value: '전체 문제', 51 | onModalChange: action('onModalChange'), 52 | }; 53 | Open.parameters = { 54 | docs: { 55 | description: { 56 | story: 'Dropdown이 열려있을 때의 CheckOption 컴포넌트입니다.', 57 | }, 58 | }, 59 | }; 60 | 61 | export const Close = Template.bind({}); 62 | Close.args = { 63 | isOpen: false, 64 | value: '전체 문제', 65 | onModalChange: action('onModalChange'), 66 | }; 67 | Close.parameters = { 68 | docs: { 69 | description: { 70 | story: 'Dropdown이 닫혀있을 때의 CheckOption 컴포넌트입니다.', 71 | }, 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /src/stories/components/shared/select/SortSelect.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, Story } from '@storybook/react'; 2 | 3 | import React from 'react'; 4 | import { uid } from 'react-uid'; 5 | 6 | import Select from '@src/components/shared/select'; 7 | import SortSelect from '@src/components/shared/select/SortSelect'; 8 | import CheckOption from '@src/components/shared/select/CheckOption'; 9 | import { SORT_LIST as options, SORT_TYPE as filterState } from '@src/constants/solution'; 10 | 11 | export default { 12 | title: 'Component/Shared/Select/SortSelect', 13 | argTypes: { 14 | isOpen: { 15 | control: { type: 'boolean' }, 16 | default: false, 17 | description: '사용자가 선택한 언어', 18 | }, 19 | selected: { 20 | control: { type: 'string' }, 21 | default: 'ASC', 22 | description: '문제 이름', 23 | }, 24 | setIsOpen: { 25 | control: null, 26 | description: '데이터 로딩 여부', 27 | }, 28 | setSelected: { 29 | control: null, 30 | description: '데이터 로딩 여부', 31 | }, 32 | }, 33 | component: SortSelect, 34 | }; 35 | 36 | interface TemplateInterface { 37 | isOpen: boolean; 38 | selected: string; 39 | setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; 40 | setSelected: React.Dispatch<React.SetStateAction<string>>; 41 | } 42 | 43 | const Template = ({ isOpen, selected, setIsOpen, setSelected }: TemplateInterface) => ( 44 | <div 45 | style={{ 46 | height: '20rem', 47 | margin: '2rem', 48 | }} 49 | > 50 | <Select 51 | isOpen={isOpen} 52 | trigger={ 53 | <CheckOption isOpen={isOpen} value={filterState[selected]} onModalChange={setIsOpen} /> 54 | } 55 | > 56 | {options.map((option: string, index: number) => ( 57 | <Select.Item key={uid(index)} option={option} onChangeDropdown={setSelected}> 58 | {filterState[option]} 59 | </Select.Item> 60 | ))} 61 | </Select> 62 | </div> 63 | ); 64 | 65 | export const SortSelectASC: Story<TemplateInterface> = (args: TemplateInterface) => ( 66 | <Template {...args} /> 67 | ); 68 | SortSelectASC.args = { 69 | isOpen: true, 70 | selected: 'ASC', 71 | }; 72 | 73 | export const SortSelectDESC: Story<TemplateInterface> = (args: TemplateInterface) => ( 74 | <Template {...args} /> 75 | ); 76 | SortSelectDESC.args = { 77 | isOpen: true, 78 | selected: 'DESC', 79 | }; 80 | 81 | export const SortSelectClose: Story<TemplateInterface> = (args: TemplateInterface) => ( 82 | <Template {...args} /> 83 | ); 84 | 85 | SortSelectClose.args = { 86 | isOpen: false, 87 | selected: 'ASC', 88 | }; 89 | -------------------------------------------------------------------------------- /src/stories/pages/solution/Content.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryFn } from '@storybook/react'; 2 | 3 | import Content from '@src/components/domain/solution/Content'; 4 | import { loginMockData, logoutMockData } from './mock-data'; 5 | 6 | export default { 7 | title: 'Page/SolutionTab/Content', 8 | component: Content, 9 | } as ComponentMeta<typeof Content>; 10 | 11 | const Template: ComponentStoryFn<typeof Content> = args => <Content {...args} />; 12 | export const LoginCase = Template.bind({}); 13 | LoginCase.args = { 14 | isLoaded: true, 15 | solutions: loginMockData, 16 | }; 17 | LoginCase.parameters = { 18 | docs: { 19 | description: { 20 | story: `사용자가 로그인을 했고 데이터가 받아와졌을 때의 Content 컴포넌트입니다. <br /> 21 | 사용자가 푼 풀이가 있을 시 풀이 정보를 보여주고, 없을 시 아직 풀이한 문제가 없다는 문구를 띄웁니다. 22 | `, 23 | }, 24 | }, 25 | }; 26 | 27 | export const LoadingCase = Template.bind({}); 28 | LoadingCase.args = { 29 | isLoaded: false, 30 | solutions: loginMockData, 31 | }; 32 | LoadingCase.parameters = { 33 | docs: { 34 | description: { 35 | story: '아직 데이터가 받아오지 않았을 때의 Content 컴포넌트입니다. 로딩중 스피너를 띄웁니다.', 36 | }, 37 | }, 38 | }; 39 | 40 | export const LogoutCase = Template.bind({}); 41 | LogoutCase.args = { 42 | isLoaded: true, 43 | solutions: logoutMockData, 44 | }; 45 | LogoutCase.parameters = { 46 | docs: { 47 | description: { 48 | story: '사용자가 로그아웃일 때의 Content 컴포넌트입니다. 로그인을 하라는 문구를 띄웁니다.', 49 | }, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/stories/pages/solution/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryFn } from '@storybook/react'; 2 | import Header from '@src/components/domain/solution/Header'; 3 | 4 | export default { 5 | title: 'Page/SolutionTab/Header', 6 | component: Header, 7 | } as ComponentMeta<typeof Header>; 8 | 9 | const Template: ComponentStoryFn<typeof Header> = args => <Header {...args} />; 10 | export const Default = Template.bind({}); 11 | Default.args = { 12 | selectedLanguage: 'javascript', 13 | problemName: '옹알이', 14 | }; 15 | -------------------------------------------------------------------------------- /src/stories/pages/solution/SelectList.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryFn } from '@storybook/react'; 2 | import SelectList from '@src/components/domain/solution/SelectList'; 3 | 4 | export default { 5 | title: 'Page/SolutionTab/SelectList', 6 | component: SelectList, 7 | argTypes: { 8 | isLoaded: { 9 | control: { type: 'boolean' }, 10 | defaultValue: true, 11 | description: '사용자가 선택한 언어', 12 | }, 13 | }, 14 | parameters: { 15 | docs: { 16 | description: { 17 | component: `SolutionTab 컴포넌트는 사용자가 제출한 풀이들을 보여주는 페이지로, 사용자가 선택한 언어로 푼 풀이들을 보여주며 Select 컴포넌트들을 이용해 필터링을 할 수 있습니다. <br /> 18 | 각 풀이에는 문제 이름, 사용자 이름, 코드, 작성일 등이 포함되어 있습니다. 또한, 풀이의 성공 여부를 색으로 구분하여 표시하여 사용자가 한눈에 풀이 상태를 파악할 수 있도록 합니다.`, 19 | }, 20 | }, 21 | }, 22 | } as ComponentMeta<typeof SelectList>; 23 | 24 | const Template: ComponentStoryFn<typeof SelectList> = args => <SelectList {...args} />; 25 | export const LoginCase = Template.bind({}); 26 | LoginCase.args = { 27 | isLoaded: true, 28 | }; 29 | LoginCase.parameters = { 30 | docs: { 31 | description: { 32 | story: `사용자가 로그인을 했고 데이터가 받아와졌을 때의 SelectList 컴포넌트 입니다. <br /> 33 | 풀이 성공 여부에 따라 필터링하는 SolutionSelect와 시간순 정렬을 하는 SortSelect를 렌더링합니다. 34 | `, 35 | }, 36 | }, 37 | }; 38 | 39 | export const LoadingCase = Template.bind({}); 40 | LoadingCase.args = { 41 | isLoaded: false, 42 | }; 43 | LoadingCase.parameters = { 44 | docs: { 45 | description: { 46 | story: `아직 데이터가 받아오지 않았을 때의 SelectList 컴포넌트입니다. <br /> 47 | 아무것도 렌더링하지 않습니다. 48 | `, 49 | }, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/stories/pages/solution/SolutionTab.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, Story } from '@storybook/react'; 2 | 3 | import SolutionTab from '@src/pages/newTab/Solution'; 4 | import Header from '@src/components/domain/solution/Header'; 5 | import SelectList from '@src/components/domain/solution/SelectList'; 6 | import Content from '@src/components/domain/solution/Content'; 7 | import { ContainerStyle } from '@src/pages/newTab/Solution'; 8 | 9 | import { SolutionResponse } from '@src/types/solution'; 10 | import { loginMockData, logoutMockData } from './mock-data'; 11 | 12 | export default { 13 | title: 'Page/SolutionTab/Page', 14 | component: SolutionTab, 15 | argTypes: { 16 | selectedLanguage: { 17 | control: { type: 'string' }, 18 | description: '사용자가 선택한 언어', 19 | }, 20 | problemName: { 21 | control: { type: 'string' }, 22 | description: '문제 이름', 23 | }, 24 | isLoaded: { 25 | control: { type: 'boolean' }, 26 | defaultValue: true, 27 | description: '데이터 로딩 여부', 28 | }, 29 | solutions: { 30 | control: null, 31 | defaultValue: loginMockData, 32 | description: '사용자가 제출한 풀이 리스트', 33 | }, 34 | }, 35 | parameters: { 36 | docs: { 37 | description: { 38 | component: `SolutionTab 컴포넌트는 사용자가 제출한 풀이들을 보여주는 페이지로, 사용자가 선택한 언어로 푼 풀이들을 보여주며 Select 컴포넌트들을 이용해 필터링을 할 수 있습니다. <br /> 39 | 각 풀이에는 문제 이름, 사용자 이름, 코드, 작성일 등이 포함되어 있습니다. 또한, 풀이의 성공 여부를 색으로 구분하여 표시하여 사용자가 한눈에 풀이 상태를 파악할 수 있도록 합니다.`, 40 | }, 41 | }, 42 | }, 43 | } as ComponentMeta<typeof SolutionTab>; 44 | 45 | interface TemplateInterface { 46 | selectedLanguage: string; 47 | problemName: string; 48 | isLoaded: boolean; 49 | solutions: SolutionResponse; 50 | } 51 | 52 | const Template = ({ selectedLanguage, problemName, isLoaded, solutions }: TemplateInterface) => ( 53 | <ContainerStyle> 54 | <Header selectedLanguage={selectedLanguage} problemName={problemName} /> 55 | <SelectList isLoaded={isLoaded} /> 56 | <Content isLoaded={isLoaded} solutions={solutions} /> 57 | </ContainerStyle> 58 | ); 59 | 60 | export const SolutionTabLoginCase: Story<TemplateInterface> = (args: TemplateInterface) => ( 61 | <Template {...args} /> 62 | ); 63 | SolutionTabLoginCase.args = { 64 | selectedLanguage: 'javascript', 65 | problemName: '옹알이', 66 | isLoaded: true, 67 | solutions: loginMockData, 68 | }; 69 | 70 | export const SolutionTabLoadingCase: Story<TemplateInterface> = (args: TemplateInterface) => ( 71 | <Template {...args} /> 72 | ); 73 | SolutionTabLoadingCase.args = { 74 | selectedLanguage: 'javascript', 75 | problemName: '옹알이', 76 | isLoaded: false, 77 | solutions: loginMockData, 78 | }; 79 | 80 | export const SolutionTabLogoutCase: Story<TemplateInterface> = (args: TemplateInterface) => ( 81 | <Template {...args} /> 82 | ); 83 | SolutionTabLogoutCase.args = { 84 | selectedLanguage: 'javascript', 85 | problemName: '옹알이', 86 | isLoaded: true, 87 | solutions: logoutMockData, 88 | }; 89 | -------------------------------------------------------------------------------- /src/stories/pages/solution/mock-data.ts: -------------------------------------------------------------------------------- 1 | const solutionList = [ 2 | { 3 | selectedLanguage: 'javascript', 4 | uploadTime: { 5 | seconds: 1677320311, 6 | nanoseconds: 996000000, 7 | }, 8 | isSuccess: true, 9 | code: 'function solution(babbling) {\n var answer = 0;\n const regex = /^(aya|ye|woo|ma)+$/;\n\n babbling.forEach(word => {\n if (regex.test(word)) answer++; \n })\n\n return answer;\n}\n', 10 | passedTestCase: 17, 11 | failedTestCase: 0, 12 | }, 13 | { 14 | passedTestCase: 12, 15 | code: 'const WORDS = ["aya", "ye", "woo", "ma"];\n\nfunction solution(babbling) {\n let answer = 0;\n for (let word of babbling) { \n for (let idx=0; idx<WORDS.length; idx++) {\n word = word.replaceAll(WORDS[idx], "O");\n }\n \n const wordBabble = [...word.split(\'\')\n .reduce((set, elem) => {\n set.add(elem);\n return set;\n }, new Set())];\n const isWordBabble = wordBabble.length === 1 && wordBabble[0] === \'O\';\n \n if (isWordBabble) { answer += 1; }\n }\n \n return answer;\n}\n\n// "aya", "ye", "woo", "ma" 만 발음 가능\n// return input으로 주어지는 babbling에서 발음 가능한 단어의 개수\n\n// Algorithm Flow\n// ', 16 | isSuccess: true, 17 | failedTestCase: 0, 18 | uploadTime: { 19 | seconds: 1673518579, 20 | nanoseconds: 595000000, 21 | }, 22 | selectedLanguage: 'javascript', 23 | }, 24 | { 25 | isSuccess: true, 26 | code: 'const WORDS = ["aya", "ye", "woo", "ma"];\n\nfunction solution(babbling) {\n let answer = 0;\n for (let word of babbling) { \n for (let idx=0; idx<WORDS.length; idx++) {\n word = word.replaceAll(WORDS[idx], "O");\n }\n \n const wordBabble = [...word.split(\'\')\n .reduce((set, elem) => {\n set.add(elem);\n return set;\n }, new Set())];\n const isWordBabble = wordBabble.length === 1 && wordBabble[0] === \'O\';\n \n if (isWordBabble) { answer += 1; }\n }\n \n return answer;\n}\n\n// "aya", "ye", "woo", "ma" 만 발음 가능\n// return input으로 주어지는 babbling에서 발음 가능한 단어의 개수\n\n// Algorithm Flow\n// ', 27 | uploadTime: { 28 | seconds: 1672325192, 29 | nanoseconds: 128000000, 30 | }, 31 | selectedLanguage: 'javascript', 32 | passedTestCase: 12, 33 | failedTestCase: 0, 34 | }, 35 | ]; 36 | 37 | export const loginMockData = { 38 | status: true, 39 | data: solutionList, 40 | }; 41 | 42 | export const logoutMockData = { 43 | status: false, 44 | }; 45 | -------------------------------------------------------------------------------- /src/styles/font.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap'); 3 | 4 | @font-face { 5 | font-family: 'Nanum Gothic'; 6 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumGothic/NanumGothic-Regular.ttf.woff2) 7 | format('woff2'); 8 | font-weight: 400; 9 | font-style: normal; 10 | } 11 | @font-face { 12 | font-family: 'Nanum Gothic'; 13 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumGothic/NanumGothic-Bold.ttf.woff2) 14 | format('woff2'); 15 | font-weight: 700; 16 | font-style: normal; 17 | } 18 | @font-face { 19 | font-family: 'Nanum Gothic'; 20 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumGothic/NanumGothic-ExtraBold.ttf.woff2) 21 | format('woff2'); 22 | font-weight: 800; 23 | font-style: normal; 24 | } 25 | @font-face { 26 | font-family: 'SCDream'; 27 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream1.otf) 28 | format('opentype'); 29 | font-weight: 100; 30 | font-style: normal; 31 | } 32 | @font-face { 33 | font-family: 'SCDream'; 34 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream2.otf) 35 | format('opentype'); 36 | font-weight: 200; 37 | font-style: normal; 38 | } 39 | @font-face { 40 | font-family: 'SCDream'; 41 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream3.otf) 42 | format('opentype'); 43 | font-weight: 300; 44 | font-style: normal; 45 | } 46 | @font-face { 47 | font-family: 'SCDream'; 48 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream4.otf) 49 | format('opentype'); 50 | font-weight: 400; 51 | font-style: normal; 52 | } 53 | @font-face { 54 | font-family: 'SCDream'; 55 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream5.otf) 56 | format('opentype'); 57 | font-weight: 500; 58 | font-style: normal; 59 | } 60 | @font-face { 61 | font-family: 'SCDream'; 62 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream6.otf) 63 | format('opentype'); 64 | font-weight: 600; 65 | font-style: normal; 66 | } 67 | @font-face { 68 | font-family: 'SCDream'; 69 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream7.otf) 70 | format('opentype'); 71 | font-weight: 700; 72 | font-style: normal; 73 | } 74 | @font-face { 75 | font-family: 'SCDream'; 76 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream8.otf) 77 | format('opentype'); 78 | font-weight: 800; 79 | font-style: normal; 80 | } 81 | @font-face { 82 | font-family: 'SCDream'; 83 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/S-Core_Dream_OTF/SCDream9.otf) 84 | format('opentype'); 85 | font-weight: 900; 86 | font-style: normal; 87 | } 88 | @font-face { 89 | font-family: 'NanumSquareRound'; 90 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumSquareRound/NanumSquareRoundL.ttf) 91 | format('truetype'); 92 | font-weight: 300; 93 | font-style: normal; 94 | } 95 | @font-face { 96 | font-family: 'NanumSquareRound'; 97 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumSquareRound/NanumSquareRoundR.ttf) 98 | format('truetype'); 99 | font-weight: 400; 100 | font-style: normal; 101 | } 102 | @font-face { 103 | font-family: 'NanumSquareRound'; 104 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumSquareRound/NanumSquareRoundB.ttf) 105 | format('truetype'); 106 | font-weight: 700; 107 | font-style: normal; 108 | } 109 | @font-face { 110 | font-family: 'NanumSquareRound'; 111 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumSquareRound/NanumSquareRoundEB.ttf) 112 | format('truetype'); 113 | font-weight: 800; 114 | font-style: normal; 115 | } 116 | @font-face { 117 | font-family: 'NanumSquareRound'; 118 | src: url(https://cdn.jsdelivr.net/gh/naen-nae/fonts@purge-cache-for-subsets/files/NanumSquareRound/NanumSquareRoundEB.ttf) 119 | format('truetype'); 120 | font-weight: 800; 121 | font-style: normal; 122 | } 123 | @font-face { 124 | font-family: 'Inconsolata'; 125 | font-style: normal; 126 | font-weight: 600; 127 | font-stretch: 100%; 128 | font-display: swap; 129 | src: url(https://fonts.gstatic.com/s/inconsolata/v31/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lpp7c8WRL2l2eY.woff2) 130 | format('woff2'); 131 | unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, 132 | U+1EA0-1EF9, U+20AB; 133 | } 134 | -------------------------------------------------------------------------------- /src/styles/global.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { createGlobalStyle } from 'styled-components'; 3 | import reset from 'styled-reset'; 4 | 5 | const GlobalStyles = createGlobalStyle` 6 | ${reset} 7 | * { 8 | box-sizing:border-box; 9 | outline:none; 10 | border:none; 11 | } 12 | html, body, #root { 13 | height: 100%; 14 | } 15 | a { 16 | text-decoration:none; 17 | color: black; 18 | } 19 | button { 20 | cursor: pointer; 21 | padding: 0; 22 | } 23 | `; 24 | 25 | const CenterContainer = styled.div` 26 | position: absolute; 27 | top: 50%; 28 | left: 50%; 29 | transform: translate(-50%, -50%); 30 | `; 31 | 32 | const GNBStyle = styled.div` 33 | display: flex; 34 | align-items: center; 35 | position: sticky; 36 | z-index: 100; 37 | top: 0; 38 | height: 3rem; 39 | padding: 0.375rem 1rem; 40 | background-color: ${({ theme }) => theme.color.jetBlack}; 41 | color: ${({ theme }) => theme.color.greyBlue}; 42 | font-family: 'Noto Sans KR', sans-serif; 43 | font-weight: 400; 44 | user-select: none; 45 | & > img { 46 | width: 1.5rem; 47 | height: 1.5rem; 48 | } 49 | & > div { 50 | display: flex; 51 | align-items: center; 52 | margin-left: 0.85rem; 53 | padding: 0.375rem 0; 54 | gap: 0.3rem; 55 | } 56 | `; 57 | 58 | const LoaderStyle = styled.div` 59 | height: 100vh; 60 | & > svg { 61 | position: absolute; 62 | top: 50%; 63 | left: 50%; 64 | transform: translate(-50%, -50%); 65 | } 66 | `; 67 | 68 | const BoldTextStyle = styled.span` 69 | font-weight: 600; 70 | `; 71 | 72 | const BoxStyle = styled.div` 73 | margin: 2rem; 74 | background-color: ${({ theme }) => theme.color.white}; 75 | box-shadow: 0 0 #0000, 0 0 #0000, 0px 1px 3px rgba(0, 0, 0, 0.04), 76 | 0px 6px 16px rgba(0, 0, 0, 0.12); 77 | border-radius: 0.5rem; 78 | padding: 1.5rem; 79 | font-family: 'Noto Sans KR', sans-serif; 80 | user-select: none; 81 | `; 82 | 83 | const ContentHeaderInfoStyle = styled.div` 84 | display: flex; 85 | align-items: center; 86 | gap: 0.5rem; 87 | color: ${({ theme }) => theme.color.darkGrey}; 88 | font-size: 1.05rem; 89 | `; 90 | 91 | export default GlobalStyles; 92 | export { CenterContainer, GNBStyle, BoldTextStyle, BoxStyle, LoaderStyle, ContentHeaderInfoStyle }; 93 | -------------------------------------------------------------------------------- /src/styles/theme/color.ts: -------------------------------------------------------------------------------- 1 | const color = { 2 | white: '#FFFFFF', 3 | whiter: '#f7f8fa', 4 | grayishWhite: '#d8e2ea', 5 | lightBlack: '#1A1A1A', 6 | jetBlack: '#121212', 7 | black: '#000000', 8 | lightGrey: '#8b939e', 9 | grey: '#E9ECF3', 10 | steelGrey: '#9b9da1', 11 | darkGrey: '#292D32', 12 | blue: '#0078FF', 13 | skyBlue: '#4181EB', 14 | deepBlue: '#365A90', 15 | greyBlue: '#9aa8b6', 16 | indigo: '#0c151c', 17 | red: '#FF0000', 18 | coral: '#F85149', 19 | green: '#39D353', 20 | }; 21 | 22 | export default color; 23 | -------------------------------------------------------------------------------- /src/styles/theme/index.ts: -------------------------------------------------------------------------------- 1 | import color from './color'; 2 | import media from './media'; 3 | 4 | export const theme = { 5 | color, 6 | media, 7 | }; 8 | 9 | export type Theme = typeof theme; 10 | -------------------------------------------------------------------------------- /src/styles/theme/media.ts: -------------------------------------------------------------------------------- 1 | import { CSSProp, css } from 'styled-components'; 2 | 3 | type MediaQueryProps = { 4 | mobile: number; 5 | tablet: number; 6 | desktop: number; 7 | }; 8 | 9 | const sizes: MediaQueryProps = { 10 | mobile: 580, 11 | tablet: 768, 12 | desktop: 1284, 13 | }; 14 | 15 | type BackQuoteArgs = string[]; 16 | 17 | const media = { 18 | mobile: (literals: TemplateStringsArray, ...args: BackQuoteArgs): CSSProp => 19 | css` 20 | @media only screen and (max-width: ${sizes.mobile}px) { 21 | ${css(literals, ...args)} 22 | } 23 | `, 24 | tablet: (literals: TemplateStringsArray, ...args: BackQuoteArgs): CSSProp => 25 | css` 26 | @media only screen and (max-width: ${sizes.tablet}px) { 27 | ${css(literals, ...args)} 28 | } 29 | `, 30 | desktop: (literals: TemplateStringsArray, ...args: BackQuoteArgs): CSSProp => 31 | css` 32 | @media only screen and (max-width: ${sizes.desktop}px) { 33 | ${css(literals, ...args)} 34 | } 35 | `, 36 | } as Record<keyof typeof sizes, (l: TemplateStringsArray, ...p: BackQuoteArgs) => CSSProp>; 37 | 38 | export default media; 39 | -------------------------------------------------------------------------------- /src/types/file-extension.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React = require('react'); 3 | const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>; 4 | export default ReactComponent; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const content: string; 9 | export default content; 10 | } 11 | 12 | declare module '*.png' { 13 | const content: string; 14 | export default content; 15 | } 16 | 17 | declare module '*.json' { 18 | const content: string; 19 | export default content; 20 | } 21 | 22 | declare module '*.woff' { 23 | const content: string; 24 | export default content; 25 | } 26 | 27 | declare module '*.otf' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | declare module '*.ttf' { 33 | const value: import('expo-font').FontSource; 34 | export default value; 35 | } 36 | -------------------------------------------------------------------------------- /src/types/global.ts: -------------------------------------------------------------------------------- 1 | type Message = { 2 | request: any; 3 | sender: chrome.runtime.MessageSender; 4 | sendResponse: (response?: unknown) => void; 5 | }; 6 | 7 | type Children = { 8 | children: JSX.Element | JSX.Element[]; 9 | }; 10 | 11 | export { Message, Children }; 12 | -------------------------------------------------------------------------------- /src/types/library.d.ts: -------------------------------------------------------------------------------- 1 | import Chrome from 'chrome'; 2 | import { Theme } from '../styles/theme'; 3 | import { CSSProp } from 'styled-components'; 4 | 5 | declare namespace chrome { 6 | export default Chrome; 7 | } 8 | 9 | declare module 'styled-components' { 10 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 11 | export interface DefaultTheme extends Theme {} 12 | } 13 | 14 | declare module 'react' { 15 | interface Attributes { 16 | css?: CSSProp | CSSObject; 17 | } 18 | } 19 | 20 | declare module 'recoil'; 21 | declare module 'react-uid'; 22 | declare module 'storybook'; 23 | -------------------------------------------------------------------------------- /src/types/problem/problem.ts: -------------------------------------------------------------------------------- 1 | type SelectedLanguage = 2 | | 'c' 3 | | 'cpp' 4 | | 'csharp' 5 | | 'go' 6 | | 'java' 7 | | 'javascript' 8 | | 'kotlin' 9 | | 'python' 10 | | 'python3' 11 | | 'ruby' 12 | | 'scala' 13 | | 'swift' 14 | | 'mysql' 15 | | 'oracle'; 16 | 17 | interface ProblemInfo { 18 | selectedLanguage: SelectedLanguage; 19 | problemId: string; 20 | } 21 | 22 | export { SelectedLanguage, ProblemInfo }; 23 | -------------------------------------------------------------------------------- /src/types/profile/profile-layout.ts: -------------------------------------------------------------------------------- 1 | interface ProblemType { 2 | [key: string]: number | string; 3 | id: number; 4 | title: string; 5 | partTitle: string; 6 | level: number; 7 | finishedCount: number; 8 | acceptanceRate: number; 9 | status: string; 10 | } 11 | 12 | type SolvedProblemType = ProblemType[]; 13 | 14 | type ProblemsType = { 15 | allProblems: SolvedProblemType; 16 | solvedProblems: SolvedProblemType; 17 | }; 18 | 19 | interface ProblemCntType { 20 | allCnt: number; 21 | solvedCnt: number; 22 | } 23 | 24 | type SortType = { 25 | [key: string]: string | boolean; 26 | type: SelectNameType; 27 | isAscending: SelectSortType; 28 | }; 29 | 30 | type SelectNameType = 'level' | 'finishedCount' | 'acceptanceRate'; 31 | type SelectSortType = boolean; 32 | 33 | type SortItemType = { 34 | [key: string]: string; 35 | level: string; 36 | finishedCount: string; 37 | acceptanceRate: string; 38 | }; 39 | 40 | type LevelListFunc = (problems: SolvedProblemType) => number[]; 41 | 42 | export { 43 | ProblemType, 44 | SolvedProblemType, 45 | ProblemsType, 46 | ProblemCntType, 47 | SortType, 48 | SelectNameType, 49 | SortItemType, 50 | LevelListFunc, 51 | SelectSortType, 52 | }; 53 | -------------------------------------------------------------------------------- /src/types/profile/profile-problems.ts: -------------------------------------------------------------------------------- 1 | import { SelectSortType } from './profile-layout'; 2 | 3 | interface ProblemType { 4 | id: number; 5 | title: string; 6 | partTitle: string; 7 | level: number; 8 | finishedCount: number; 9 | acceptanceRate: number; 10 | status: string; 11 | } 12 | 13 | type SolvedProblemType = ProblemType[]; 14 | 15 | type SelectNameType = 'level' | 'finishedCount' | 'acceptanceRate'; 16 | 17 | interface SortProps { 18 | allSolvedCnt: number; 19 | onChangePageIdx: (page: number) => void; 20 | partTitleList: Array<string>; 21 | } 22 | 23 | interface SortItemProps { 24 | item: SelectNameType; 25 | onChangePageIdx: (page: number) => void; 26 | } 27 | 28 | interface SortType { 29 | [key: string]: string | boolean; 30 | type: SelectNameType; 31 | isAscending: SelectSortType; 32 | } 33 | 34 | interface SortItemType { 35 | [key: string]: string; 36 | level: string; 37 | finishedCount: string; 38 | acceptanceRate: string; 39 | } 40 | 41 | interface ProblemTableProps { 42 | start: number; 43 | end: number; 44 | solvedProblems: SolvedProblemType; 45 | } 46 | 47 | interface ContentProps { 48 | children: JSX.Element | JSX.Element[]; 49 | solvedProblems: SolvedProblemType; 50 | } 51 | 52 | export { 53 | ProblemType, 54 | SelectNameType, 55 | SortProps, 56 | SortItemProps, 57 | SortType, 58 | SortItemType, 59 | ProblemTableProps, 60 | ContentProps, 61 | }; 62 | -------------------------------------------------------------------------------- /src/types/profile/profile-statistics.ts: -------------------------------------------------------------------------------- 1 | interface ProblemCntType { 2 | allCnt: number; 3 | solvedCnt: number; 4 | } 5 | 6 | type DoughnutType = { 7 | problemCnt: ProblemCntType; 8 | solvedLevelCnt: number[]; 9 | }; 10 | 11 | interface ChartInfo { 12 | level: number; 13 | color: string; 14 | allCnt: number; 15 | solvedCnt: number; 16 | } 17 | 18 | type ChartInfoList = ChartInfo[]; 19 | 20 | export { ProblemCntType, DoughnutType, ChartInfo, ChartInfoList }; 21 | -------------------------------------------------------------------------------- /src/types/profile/profile-tab.ts: -------------------------------------------------------------------------------- 1 | type NavType = { 2 | [key: string]: string; 3 | MAIN: string; 4 | PROBLEM: string; 5 | }; 6 | 7 | type ContentType = { 8 | children: React.ReactNode; 9 | isLoaded: boolean; 10 | isLoggedIn: boolean; 11 | }; 12 | 13 | export { NavType, ContentType }; 14 | -------------------------------------------------------------------------------- /src/types/select.ts: -------------------------------------------------------------------------------- 1 | interface SelectProps { 2 | isOpen: boolean; 3 | trigger: React.ReactNode; 4 | children: JSX.Element[]; 5 | } 6 | 7 | interface TriggerProps { 8 | as: React.ReactNode; 9 | } 10 | 11 | interface MenuProps { 12 | isOpen: boolean; 13 | children: JSX.Element[]; 14 | } 15 | 16 | interface ItemProps { 17 | onChangeDropdown: (selected: string) => void; 18 | children: string; 19 | option: string; 20 | } 21 | 22 | interface SolutionType { 23 | [key: string]: string; 24 | ALL: string; 25 | SUCCESS: string; 26 | FAILED: string; 27 | } 28 | 29 | interface PartTitleSelectProps { 30 | allSolvedCnt: number; 31 | partTitleList: Array<string>; 32 | onChangePageIdx: (page: number) => void; 33 | } 34 | 35 | interface SortType { 36 | [key: string]: string; 37 | ASC: string; 38 | DESC: string; 39 | } 40 | 41 | export { 42 | SelectProps, 43 | TriggerProps, 44 | MenuProps, 45 | ItemProps, 46 | PartTitleSelectProps, 47 | SolutionType, 48 | SortType, 49 | }; 50 | -------------------------------------------------------------------------------- /src/types/solution.ts: -------------------------------------------------------------------------------- 1 | interface Solution { 2 | code: string; 3 | failedTestCase: number; 4 | passedTestCase: number; 5 | isSuccess: boolean; 6 | selectedLanguage: string; 7 | uploadTime: { 8 | nanoseconds: number; 9 | seconds: number; 10 | }; 11 | } 12 | 13 | type SolutionList = Solution[]; 14 | 15 | interface SolutionResponse { 16 | status?: boolean; 17 | data?: SolutionList; 18 | message?: string; 19 | } 20 | 21 | export { Solution, SolutionList, SolutionResponse }; 22 | -------------------------------------------------------------------------------- /src/utils/date/formatDateToYmdhms.ts: -------------------------------------------------------------------------------- 1 | const formatDateToYmdhms = (date: Date) => { 2 | const ymdRegex = /(\d\w*)/g; 3 | const [yyyy, mm, dd] = date.toLocaleDateString().match(ymdRegex)!; 4 | const [amPm, hhmmss] = date.toLocaleTimeString().split(' '); 5 | const [h, m] = hhmmss.split(':'); 6 | 7 | return `${yyyy}년 ${mm}월 ${dd}일 ${amPm} ${h}시 ${m}분`; 8 | }; 9 | 10 | export { formatDateToYmdhms }; 11 | -------------------------------------------------------------------------------- /src/utils/date/formatTimestampToDate.ts: -------------------------------------------------------------------------------- 1 | interface TimestampProps { 2 | seconds: number; 3 | nanoseconds: number; 4 | } 5 | 6 | const formatTimestampToDate = ({ seconds, nanoseconds }: TimestampProps) => 7 | new Date(seconds * 1000 + nanoseconds / 1000000); 8 | 9 | export { formatTimestampToDate }; 10 | -------------------------------------------------------------------------------- /src/utils/fetchRequest.ts: -------------------------------------------------------------------------------- 1 | type FetchParams = { 2 | method?: string; 3 | headers?: Record<string, string>; 4 | body?: unknown; 5 | }; 6 | 7 | interface ErrorHandling { 8 | [status: number]: () => void; 9 | } 10 | 11 | interface RequestProps { 12 | url: string; 13 | fetchParams?: FetchParams; 14 | handleError?: ErrorHandling; 15 | } 16 | 17 | const defaultJSONHeaders = { 18 | Accept: 'application/json', 19 | }; 20 | 21 | const defaultHTMLHeaders = { 22 | Accept: 'text/html', 23 | }; 24 | 25 | const parseJson = <T>(response: Response): Promise<T> => response.json(); 26 | const parseHtml = (response: Response) => response.text(); 27 | 28 | const request = async (url: string, fetchParams: FetchParams): Promise<Response> => { 29 | const newFetchParams = { 30 | ...fetchParams, 31 | method: fetchParams?.method?.toUpperCase() ?? 'GET', 32 | headers: { 33 | ...(fetchParams?.headers ?? {}), 34 | ...defaultJSONHeaders, 35 | }, 36 | body: fetchParams?.body ? JSON.stringify(fetchParams.body) : undefined, 37 | }; 38 | 39 | const response = await fetch(url, newFetchParams); 40 | if (response.ok) { 41 | return Promise.resolve(response); 42 | } 43 | 44 | throw new Error(); 45 | }; 46 | 47 | const getJSON = async <T>({ 48 | url, 49 | fetchParams = {}, 50 | handleError = {}, 51 | }: RequestProps): Promise<T> => { 52 | return request(url, fetchParams) 53 | .then(parseJson) 54 | .catch(error => handleError[error.status]?.()) as Promise<T>; 55 | }; 56 | 57 | const getHTML = async <T>({ 58 | url, 59 | fetchParams = {}, 60 | handleError = {}, 61 | }: RequestProps): Promise<T> => { 62 | const newFetchParams = { 63 | ...fetchParams, 64 | method: fetchParams?.method?.toUpperCase() ?? 'GET', 65 | headers: { 66 | ...(fetchParams?.headers ?? {}), 67 | ...defaultHTMLHeaders, 68 | }, 69 | body: fetchParams?.body ? JSON.stringify(fetchParams.body) : undefined, 70 | }; 71 | 72 | return request(url, newFetchParams) 73 | .then(parseHtml) 74 | .catch(error => handleError[error.status]?.()) as Promise<T>; 75 | }; 76 | 77 | export { getJSON, getHTML }; 78 | -------------------------------------------------------------------------------- /src/utils/getPercentile.ts: -------------------------------------------------------------------------------- 1 | import { ProblemCntType } from '@src/types/profile/profile-statistics'; 2 | 3 | const getPercentile = ({ allCnt, solvedCnt }: ProblemCntType) => 4 | String(((solvedCnt / allCnt) * 100).toFixed(1)); 5 | 6 | export { getPercentile }; 7 | -------------------------------------------------------------------------------- /src/utils/location/getQueryParams.ts: -------------------------------------------------------------------------------- 1 | const getQueryParams = () => { 2 | const urlParams = new URLSearchParams(window.location.search); 3 | return Object.fromEntries(urlParams.entries()); 4 | }; 5 | 6 | export { getQueryParams }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["./src/*"], 7 | "@assets/*": ["./assets/*"] 8 | }, 9 | "allowSyntheticDefaultImports": true, 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "typeRoots": ["./node_modules/@types", "./src/types"], 12 | "strict": true, 13 | "jsx": "react-jsx", 14 | "target": "esnext", 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "noImplicitAny": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "removeComments": false, 21 | "preserveConstEnums": true, 22 | "sourceMap": true, 23 | "skipLibCheck": true 24 | }, 25 | "paths": { 26 | "chrome": ["node_modules/@types/chrome"] 27 | }, 28 | "include": ["src"], 29 | "exclude": ["node_modules", ".github"] 30 | } 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); 3 | 4 | const commonConfig = require('./build/webpack.common.js'); 5 | 6 | const optionalPlugin = pluginsArg => { 7 | const optionalPlugin = [pluginsArg].filter(Boolean); 8 | 9 | return optionalPlugin.map(pluginsArg => 10 | require(`./build/optionalPlugin/webpack.${pluginsArg}.js`), 11 | ); 12 | }; 13 | 14 | module.exports = env => { 15 | const envConfig = require(`./build/webpack.${env.env}.js`); 16 | const smp = new SpeedMeasurePlugin(); 17 | 18 | const mergedConfig = smp.wrap( 19 | merge(commonConfig, envConfig, ...optionalPlugin(env.optionalPlugin)), 20 | ); 21 | 22 | return mergedConfig; 23 | }; 24 | --------------------------------------------------------------------------------