├── .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 | [](https://chrome.google.com/webstore/detail/%ED%94%84%EB%A1%9C%EC%86%94%EB%B8%8Cpro-solve/pjffalefhahlellpckbbiehmbljjhihl)
4 | 
5 | 
6 | 
7 | [](https://github.com/dev-red5/programmers-problems/actions/workflows/sync-problems.yaml)
8 | [](https://opensource.org/licenses/MIT)
9 |
10 | [](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 |
22 | 성공한 테스트 케이스:
23 |
24 | {passedTestCase} / {passedTestCase + failedTestCase}
25 |
26 |
27 |
28 | 결과:
29 |
30 | {isSuccess ? '성공' : '실패'}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | 제출 날짜:
39 | {formatDate}
40 |
41 |
42 | 언어:
43 | {selectedLanguage}
44 |
45 |
46 |
47 |
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 | }
34 | >
35 | {partTitleList.map((option: string, index: number) => (
36 |
37 | {option}
38 |
39 | ))}
40 |
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 | }
21 | >
22 | {options.map((option: string, index: number) => (
23 |
24 | {filterState[option]}
25 |
26 | ))}
27 |
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 |
19 | }
20 | >
21 | {options.map((option: string, index: number) => (
22 |
23 | {filterState[option]}
24 |
25 | ))}
26 |
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 |
22 |
23 |
24 |
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 |
48 |
49 |
50 |
51 |
52 | ,
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({ 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>(
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;
9 | const getAllSolutions: GetAllSolutionFn = async ({ selectedLanguage, problemId }: HrefProps) => {
10 | console.log(`[Pro Solve] 문제 번호:>> ${problemId} 선택한 언어:>> ${selectedLanguage}`);
11 |
12 | const allSolutions = await new Promise(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 | 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/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 |
16 |
17 |
18 |
19 | ,
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 |
16 |
17 |
22 |
23 | ,
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 = `Pro Solve 익스텐션이 결과를 저장합니다. 모달 창을 닫으셔도 됩니다. `;
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 = `Pro Solve 익스텐션의 세부사항을 변경하셨네요! 업로드를 하려면 페이지를 새로고침 해주세요. `;
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(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": [""]
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({
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({
5 | key: 'Nav/Option',
6 | default: 'MAIN',
7 | });
8 |
9 | export const sortOption = atom({
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({
4 | key: 'Select/Solution',
5 | default: 'SUCCESS',
6 | });
7 |
8 | const sortedOption = atom({
9 | key: 'Select/Sort',
10 | default: 'DESC',
11 | });
12 |
13 | const problemTitleOption = atom({
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를 보여줍니다.
39 | 페이지 버튼 그룹의 크기가 클수록 한 번에 보여지는 페이지 버튼이 많아집니다.
40 | 페이지 버튼을 클릭하면 onChangePageIdx 콜백 함수가 호출되며, 페이지 인덱스를 변경하여 다른 페이지를 보여줄 수 있습니다.`,
41 | },
42 | },
43 | },
44 | } as ComponentMeta;
45 |
46 | const Template: ComponentStoryFn = 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 |
31 |
32 |
33 | ),
34 | ],
35 | parameters: {
36 | docs: {
37 | description: {
38 | component: `CheckOption은 Select 컴포넌트의 선택지를 표시하는데 자주 사용되는 컴포넌트입니다.
39 | 만약 Dropdown이 열려있다면(isOpen이 true) 컴포넌트의 배경 색상이 짙어집니다.
40 | `,
41 | },
42 | },
43 | },
44 | } as ComponentMeta;
45 |
46 | const Template: ComponentStoryFn = 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>;
40 | setSelected: React.Dispatch>;
41 | }
42 |
43 | const Template = ({ isOpen, selected, setIsOpen, setSelected }: TemplateInterface) => (
44 |
50 |
54 | }
55 | >
56 | {options.map((option: string, index: number) => (
57 |
58 | {filterState[option]}
59 |
60 | ))}
61 |
62 |
63 | );
64 |
65 | export const SortSelectASC: Story = (args: TemplateInterface) => (
66 |
67 | );
68 | SortSelectASC.args = {
69 | isOpen: true,
70 | selected: 'ASC',
71 | };
72 |
73 | export const SortSelectDESC: Story = (args: TemplateInterface) => (
74 |
75 | );
76 | SortSelectDESC.args = {
77 | isOpen: true,
78 | selected: 'DESC',
79 | };
80 |
81 | export const SortSelectClose: Story = (args: TemplateInterface) => (
82 |
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;
10 |
11 | const Template: ComponentStoryFn = 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 컴포넌트입니다.
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;
8 |
9 | const Template: ComponentStoryFn = 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 컴포넌트들을 이용해 필터링을 할 수 있습니다.
18 | 각 풀이에는 문제 이름, 사용자 이름, 코드, 작성일 등이 포함되어 있습니다. 또한, 풀이의 성공 여부를 색으로 구분하여 표시하여 사용자가 한눈에 풀이 상태를 파악할 수 있도록 합니다.`,
19 | },
20 | },
21 | },
22 | } as ComponentMeta;
23 |
24 | const Template: ComponentStoryFn = args => ;
25 | export const LoginCase = Template.bind({});
26 | LoginCase.args = {
27 | isLoaded: true,
28 | };
29 | LoginCase.parameters = {
30 | docs: {
31 | description: {
32 | story: `사용자가 로그인을 했고 데이터가 받아와졌을 때의 SelectList 컴포넌트 입니다.
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 컴포넌트입니다.
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 컴포넌트들을 이용해 필터링을 할 수 있습니다.
39 | 각 풀이에는 문제 이름, 사용자 이름, 코드, 작성일 등이 포함되어 있습니다. 또한, 풀이의 성공 여부를 색으로 구분하여 표시하여 사용자가 한눈에 풀이 상태를 파악할 수 있도록 합니다.`,
40 | },
41 | },
42 | },
43 | } as ComponentMeta;
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 |
54 |
55 |
56 |
57 |
58 | );
59 |
60 | export const SolutionTabLoginCase: Story = (args: TemplateInterface) => (
61 |
62 | );
63 | SolutionTabLoginCase.args = {
64 | selectedLanguage: 'javascript',
65 | problemName: '옹알이',
66 | isLoaded: true,
67 | solutions: loginMockData,
68 | };
69 |
70 | export const SolutionTabLoadingCase: Story = (args: TemplateInterface) => (
71 |
72 | );
73 | SolutionTabLoadingCase.args = {
74 | selectedLanguage: 'javascript',
75 | problemName: '옹알이',
76 | isLoaded: false,
77 | solutions: loginMockData,
78 | };
79 |
80 | export const SolutionTabLogoutCase: Story = (args: TemplateInterface) => (
81 |
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 {\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 {\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 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>;
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;
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;
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;
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 = (response: Response): Promise => response.json();
26 | const parseHtml = (response: Response) => response.text();
27 |
28 | const request = async (url: string, fetchParams: FetchParams): Promise => {
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 ({
48 | url,
49 | fetchParams = {},
50 | handleError = {},
51 | }: RequestProps): Promise => {
52 | return request(url, fetchParams)
53 | .then(parseJson)
54 | .catch(error => handleError[error.status]?.()) as Promise;
55 | };
56 |
57 | const getHTML = async ({
58 | url,
59 | fetchParams = {},
60 | handleError = {},
61 | }: RequestProps): Promise => {
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;
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 |
--------------------------------------------------------------------------------