├── .eslintrc.cjs
├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .husky
└── commit-msg
├── .npmrc
├── .vscode
└── extensions.json
├── LICENSE
├── README.md
├── __tests__
├── chrome.ts
└── setup.ts
├── commitlint.config.js
├── illustration
├── 1x
│ ├── 1400x560.png
│ ├── 440x280.png
│ ├── 920x680.png
│ ├── GitHub.png
│ ├── logo-text.png
│ ├── screenshot-1.png
│ ├── screenshot-2.png
│ ├── screenshot-3.png
│ └── screenshot-4.png
├── cat.png
├── leetcode-night.ai
├── logo.ai
├── logo.png
└── text.ai
├── manifest.config.ts
├── package.json
├── pnpm-lock.yaml
├── public
└── _locales
│ ├── en
│ └── messages.json
│ ├── ja
│ └── messages.json
│ ├── ko
│ └── messages.json
│ ├── zh_CN
│ └── messages.json
│ └── zh_TW
│ └── messages.json
├── scripts
├── PackageJsonReaderWriter.js
├── bump.js
├── openReleasePage.js
└── removeUnnecessaryAssets.js
├── src
├── Globals.d.ts
├── assets
│ └── img
│ │ ├── icon-128.png
│ │ ├── logo-text.png
│ │ └── logo.png
├── constants.ts
├── options.ts
├── pages
│ ├── Content
│ │ ├── InsertDislikeCount.ts
│ │ ├── InsertYoutubeLink.ts
│ │ ├── StyleInjector.ts
│ │ ├── darkSide2023.ts
│ │ ├── dom.ts
│ │ ├── fetchLikesAndDislikes.ts
│ │ ├── index.ts
│ │ ├── leetcode-version.ts
│ │ ├── resetCode.ts
│ │ ├── resetCode2023.ts
│ │ ├── selector.ts
│ │ ├── slug.ts
│ │ ├── styles
│ │ │ ├── dark-theme
│ │ │ │ ├── _ant-modal.sass
│ │ │ │ ├── _ant-select-dropdown.sass
│ │ │ │ ├── _ant-select.sass
│ │ │ │ ├── _colors.sass
│ │ │ │ ├── _description.sass
│ │ │ │ ├── _editor.sass
│ │ │ │ ├── _error-container.sass
│ │ │ │ ├── _loading.sass
│ │ │ │ ├── _logo.sass
│ │ │ │ ├── _navbar.sass
│ │ │ │ ├── _question-fast-picker.sass
│ │ │ │ ├── _solution.sass
│ │ │ │ ├── _submission.sass
│ │ │ │ ├── _submit-table.sass
│ │ │ │ └── style.module.sass
│ │ │ ├── hide-logo-2023
│ │ │ │ └── style.module.sass
│ │ │ ├── hide-logo
│ │ │ │ └── style.module.sass
│ │ │ ├── icon.png
│ │ │ ├── invert-image-2023
│ │ │ │ └── style.module.sass
│ │ │ ├── invert-image
│ │ │ │ └── style.module.sass
│ │ │ └── loading-mascot
│ │ │ │ ├── _cat.sass
│ │ │ │ └── style.module.sass
│ │ ├── toggleEnabled.ts
│ │ ├── toggleEnabledMascot.ts
│ │ ├── toggleHideLogo.ts
│ │ ├── toggleHideLogo2023.ts
│ │ ├── toggleInvertImageColor.ts
│ │ └── toggleInvertImageColor2023.ts
│ └── Popup
│ │ ├── App.test.tsx
│ │ ├── App.tsx
│ │ ├── components
│ │ ├── BlockLink.tsx
│ │ ├── Chip.tsx
│ │ ├── DailyChallengeQuestionCard.tsx
│ │ ├── ExtraFeatureOptions.test.tsx
│ │ ├── ExtraFeatureOptions.tsx
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ ├── HighlightText.tsx
│ │ ├── KofiLink.tsx
│ │ ├── LanguageSelect.test.tsx
│ │ ├── LanguageSelect.tsx
│ │ ├── Layout.tsx
│ │ ├── LeetcodeVersionBadge.tsx
│ │ ├── LeetcodeVersionDescription.test.tsx
│ │ ├── LeetcodeVersionDescription.tsx
│ │ ├── Link.tsx
│ │ ├── Logo.tsx
│ │ ├── NoResult.tsx
│ │ ├── OptionSwitch.tsx
│ │ ├── OptionTitle.tsx
│ │ ├── Options.test.tsx
│ │ ├── Options.tsx
│ │ ├── PremiumBadge.tsx
│ │ ├── QuestionCard.tsx
│ │ ├── QuestionDateIcon.tsx
│ │ ├── QuestionDifficultyText.tsx
│ │ ├── QuestionKeywordInput.tsx
│ │ ├── QuestionMetaChips.tsx
│ │ ├── QuestionStatusIcon.tsx
│ │ ├── SearchQuestion.test.tsx
│ │ ├── SearchQuestion.tsx
│ │ ├── Spacer.tsx
│ │ ├── StyleOptions.test.tsx
│ │ ├── StyleOptions.tsx
│ │ ├── Switch.tsx
│ │ └── Tabs.tsx
│ │ ├── hooks
│ │ ├── useChromeStorage.test.ts
│ │ ├── useChromeStorage.ts
│ │ ├── useChromeStorageListener.test.ts
│ │ ├── useChromeStorageListener.ts
│ │ ├── useDailyChallengeQuestion.ts
│ │ ├── useEnableDarkTheme.ts
│ │ ├── useLeetCodeVersion.ts
│ │ ├── useQuestions.ts
│ │ ├── useSelectedIndex.test.ts
│ │ └── useSelectedIndex.ts
│ │ ├── i18n.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── langs
│ │ ├── en.json
│ │ ├── ja.json
│ │ ├── ko.json
│ │ ├── zh-Hans.json
│ │ └── zh-Hant.json
│ │ ├── modules
│ │ ├── apis.ts
│ │ └── themes.ts
│ │ ├── popup.html
│ │ └── types
│ │ ├── DailyChallengeQuestion.ts
│ │ ├── Nullish.ts
│ │ └── Question.ts
├── storage.ts
└── tests
│ ├── e2e
│ ├── cookie.json.example
│ ├── dailyChallenge.test.ts
│ ├── darkTheme2023.test.ts
│ ├── darkTheme2023DynamicLayout.test.ts
│ ├── footer.test.ts
│ ├── insertDislikeCount2023DynamicLayout.test.ts
│ ├── insertYoutubeLink2023.test.ts
│ ├── insertYoutubeLink2023DynamicLayout.test.ts
│ ├── resetCode2023.test.ts
│ └── resetCode2023DynamicLayout.test.ts
│ └── helpers
│ ├── mockDailyChallengeQuestion.ts
│ ├── mockQuestions.ts
│ ├── puppeteer.ts
│ ├── sleep.ts
│ ├── useOptions.ts
│ ├── usePagination2023.ts
│ ├── useResetCode.ts
│ ├── useYoutubeLink.ts
│ └── waitUntil.ts
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.mts
└── vitest.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: [
7 | 'standard-with-typescript',
8 | 'plugin:react/recommended',
9 | 'plugin:react/jsx-runtime',
10 | ],
11 | overrides: [
12 | {
13 | files: ['**/?(*.)+(spec|test).[jt]s?(x)'],
14 | extends: ['plugin:testing-library/react'],
15 | },
16 | ],
17 | parser: '@typescript-eslint/parser',
18 | parserOptions: {
19 | ecmaFeatures: { jsx: true },
20 | ecmaVersion: 'latest',
21 | sourceType: 'module',
22 | project: ['./tsconfig.json', './tsconfig.node.json'],
23 | },
24 | plugins: [
25 | 'react',
26 | '@typescript-eslint',
27 | 'react-hooks',
28 | ],
29 | settings: {
30 | react: {
31 | version: 'detect',
32 | },
33 | },
34 | ignorePatterns: ['dist'],
35 | rules: {
36 | '@typescript-eslint/comma-dangle': ['error', {
37 | arrays: 'always-multiline',
38 | objects: 'always-multiline',
39 | imports: 'always-multiline',
40 | exports: 'always-multiline',
41 | functions: 'only-multiline',
42 | generics: 'ignore',
43 | }],
44 | '@typescript-eslint/member-delimiter-style': ['error', {
45 | multiline: {
46 | delimiter: 'comma',
47 | requireLast: true,
48 | },
49 | singleline: {
50 | delimiter: 'comma',
51 | requireLast: false,
52 | },
53 | multilineDetection: 'brackets',
54 | }],
55 | '@typescript-eslint/explicit-function-return-type': ['off'],
56 | '@typescript-eslint/promise-function-async': ['off'],
57 | '@typescript-eslint/no-floating-promises': ['off'],
58 | '@typescript-eslint/strict-boolean-expressions': ['off'],
59 | '@typescript-eslint/no-misused-promises': ['error', {
60 | checksVoidReturn: false,
61 | }],
62 | '@typescript-eslint/no-confusing-void-expression': ['off'],
63 | '@typescript-eslint/no-extraneous-class': ['off'],
64 | '@typescript-eslint/no-dynamic-delete': ['off'],
65 |
66 | 'react-hooks/rules-of-hooks': 'error',
67 | 'react-hooks/exhaustive-deps': 'warn',
68 |
69 | 'no-console': ['warn', { allow: ['warn', 'error'] }],
70 | 'no-process-env': ['error'],
71 |
72 | 'react/jsx-indent': ['warn', 2, {
73 | checkAttributes: true,
74 | indentLogicalExpressions: true,
75 | }],
76 | 'react/prop-types': ['off'],
77 | 'react/jsx-indent-props': ['warn', 2],
78 | 'react/jsx-closing-bracket-location': ['warn', 'tag-aligned'],
79 | 'jsx-quotes': ['warn', 'prefer-double'],
80 | 'react/jsx-curly-spacing': ['warn', { when: 'never', children: true }],
81 | 'react/jsx-tag-spacing': ['warn', {
82 | closingSlash: 'never',
83 | beforeSelfClosing: 'always',
84 | afterOpening: 'never',
85 | beforeClosing: 'never',
86 | }],
87 | 'react/self-closing-comp': 'warn',
88 | 'react/jsx-fragments': 'warn',
89 | 'react/jsx-equals-spacing': [2, 'never'],
90 | 'testing-library/no-node-access': ['error', { allowContainerFirstChild: true }],
91 | },
92 | }
93 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | pull_request:
9 | branches:
10 | - master
11 |
12 | jobs:
13 | lint-and-test:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@master
18 |
19 | - uses: pnpm/action-setup@v4.0.0
20 | with:
21 | version: 9.6.0
22 |
23 | - uses: actions/setup-node@v4
24 | with:
25 | node-version: '20'
26 |
27 | - name: Install Dependencies
28 | run: pnpm i
29 |
30 | - name: Run Lints
31 | run: pnpm run lint
32 |
33 | - name: Build
34 | run: pnpm run build
35 |
36 | - name: Test
37 | run: pnpm run test:unit
38 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | build-and-deploy:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@master
14 |
15 | - uses: pnpm/action-setup@v4.0.0
16 | with:
17 | version: 9.6.0
18 |
19 | - uses: actions/setup-node@v2
20 | with:
21 | node-version: '20'
22 |
23 | - name: Install Dependencies
24 | run: pnpm i
25 |
26 | - name: Run Lints
27 | run: pnpm run lint
28 |
29 | - name: Build
30 | run: pnpm run build
31 |
32 | - name: Test
33 | run: pnpm run test:unit
34 |
35 | - name: Compress Dist Folder
36 | run: zip -r build.zip dist
37 |
38 | - name: Get Tag Name
39 | run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
40 |
41 | - name: Create Release
42 | id: create_release
43 | uses: ncipollo/release-action@v1.10.0
44 | with:
45 | tag: ${{ github.ref }}
46 | name: ${{ env.TAG }}
47 | draft: true
48 | omitDraftDuringUpdate: true
49 | artifacts: build.zip
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 | dist
12 |
13 | # misc
14 | .DS_Store
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 | .history
20 |
21 | # secrets
22 | secrets.*.js
23 |
24 | .vscode/settings.json
25 | src/tests/e2e/cookie.json
26 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | PATH="/usr/local/bin:$PATH"
5 |
6 | npx --no -- commitlint --edit "${1}"
7 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "streetsidesoftware.code-spell-checker",
5 | "lokalise.i18n-ally"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Michael Xieyang Liu
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | A browser extension that enables dark mode & extra features on LeetCode.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | # LeetCode Night
25 |
26 | ## Features
27 |
28 | - Dark theme for question page
29 | - Shortcut to Daily LeetCoding challenge
30 | - Quick navigation by question number or keyword
31 | - Automatically reset to default code definition
32 | - Show YouTube link shortcut
33 | - Show Dislike Count
34 | - Invert image color
35 | - Hide logo
36 |
37 | ## Installation
38 |
39 | ### Download from Chrome Web Store
40 |
41 |
42 |
43 |
44 |
45 | ### Manual Download
46 |
47 | Download latest `build.zip` from [releases](https://github.com/ngseke/leetcode-night/releases) and unzip.
48 |
49 |
50 | Installation Steps
51 |
52 | 1. Access [chrome://extensions/](chrome://extensions/)
53 | 2. Check `Developer mode`
54 | 3. Click on `Load unpacked extension`
55 | 4. Select the extracted folder for use
56 |
57 |
58 |
59 | ## Screenshot
60 |
61 |
62 |
63 |
64 |
65 |
66 | ---
67 |
68 | ## 安裝
69 |
70 | ### 從 Chrome 線上應用程式商店下載
71 |
72 |
73 |
74 |
75 |
76 | ### 手動下載
77 |
78 | 從 [releases](https://github.com/ngseke/leetcode-night/releases) 下載最新版 `build.zip` 並解壓縮。
79 |
80 |
81 | 安裝步驟
82 |
83 | 1. 進入 [chrome://extensions/](chrome://extensions/)
84 | 2. 勾選右上角的 `開發人員模式`
85 | 3. 點擊左上角的 `載入未封裝項目`
86 | 4. 選擇已解壓縮的 `build/` 目錄
87 |
88 |
--------------------------------------------------------------------------------
/__tests__/chrome.ts:
--------------------------------------------------------------------------------
1 | export function mockChrome () {
2 | const mockedChrome = {
3 | storage: {
4 | sync: {
5 | get: vi.fn().mockResolvedValue({}),
6 | set: vi.fn(),
7 | },
8 | local: {
9 | get: vi.fn().mockResolvedValue({}),
10 | set: vi.fn(),
11 | },
12 | onChanged: {
13 | addListener: vi.fn(),
14 | removeListener: vi.fn(),
15 | },
16 | },
17 | runtime: {
18 | getURL: vi.fn().mockReturnValue(''),
19 | },
20 | }
21 | vi.stubGlobal('chrome', mockedChrome)
22 | return mockedChrome
23 | }
24 |
25 | beforeEach(() => {
26 | // To ensure that Chrome is mocked in every test
27 | vi.stubGlobal('chrome', undefined)
28 | })
29 |
--------------------------------------------------------------------------------
/__tests__/setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom'
2 | import '../src/pages/Popup/i18n'
3 |
4 | vi.spyOn(console, 'warn')
5 | vi.spyOn(console, 'error')
6 |
7 | Element.prototype.scrollIntoView = vi.fn()
8 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | }
4 |
--------------------------------------------------------------------------------
/illustration/1x/1400x560.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/1400x560.png
--------------------------------------------------------------------------------
/illustration/1x/440x280.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/440x280.png
--------------------------------------------------------------------------------
/illustration/1x/920x680.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/920x680.png
--------------------------------------------------------------------------------
/illustration/1x/GitHub.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/GitHub.png
--------------------------------------------------------------------------------
/illustration/1x/logo-text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/logo-text.png
--------------------------------------------------------------------------------
/illustration/1x/screenshot-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/screenshot-1.png
--------------------------------------------------------------------------------
/illustration/1x/screenshot-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/screenshot-2.png
--------------------------------------------------------------------------------
/illustration/1x/screenshot-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/screenshot-3.png
--------------------------------------------------------------------------------
/illustration/1x/screenshot-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/1x/screenshot-4.png
--------------------------------------------------------------------------------
/illustration/cat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/cat.png
--------------------------------------------------------------------------------
/illustration/leetcode-night.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/leetcode-night.ai
--------------------------------------------------------------------------------
/illustration/logo.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/logo.ai
--------------------------------------------------------------------------------
/illustration/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/logo.png
--------------------------------------------------------------------------------
/illustration/text.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/illustration/text.ai
--------------------------------------------------------------------------------
/manifest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineManifest } from '@crxjs/vite-plugin'
2 | import packageJson from './package.json'
3 |
4 | const { version } = packageJson
5 |
6 | const key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsPcJJyNn/5+SF3R/k2Tns8YYq+tMEBfezf6mjNGfTdV2ukGopBpQRvwrNgoLX6Oaug8r7zpNyeAvDXtKBUC9qr4kxEwNiMU2tuIsV0PZg1H8FzKnQL6msP8yVlZJhKl9Olv3tK7MTTByaR7upP8iQZNBjPest4zRGxkJfOfJRSJpPuwWi1XC2tLeSo6lNz19l5kxfJBxtTHS9Y9P48o2A+9OMZtR64sR2eYxE/E6NHC/vQ4507Ao6x0dhO4eKl9KyV1u8b5ZQjavfVz2zYeF/yQw6VdzysR3W5Yd3BOTDvaDqMdSW+CtrZwTF4lNC+zJzvSIfKMQ4Ow/+Thg2XUe8wIDAQAB'
7 |
8 | export default defineManifest(async () => ({
9 | manifest_version: 3,
10 | version,
11 | key,
12 |
13 | default_locale: 'en',
14 | name: '__MSG_appName__',
15 | description: '__MSG_appDesc__',
16 |
17 | icons: {
18 | 128: 'src/assets/img/icon-128.png',
19 | },
20 | action: {
21 | default_popup: 'src/pages/Popup/popup.html',
22 | default_icon: 'src/assets/img/icon-128.png',
23 | },
24 | content_scripts: [
25 | {
26 | matches: ['https://leetcode.com/problems/*'],
27 | js: ['src/pages/Content/index.ts'],
28 | run_at: 'document_start',
29 | },
30 | ],
31 | permissions: ['storage'],
32 | }))
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "leetcode-night",
3 | "version": "0.6.4",
4 | "description": "A browser extension that enables dark mode & extra features on LeetCode",
5 | "license": "MIT",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/ngseke/leetcode-night"
9 | },
10 | "engines": {
11 | "node": ">=18",
12 | "pnpm": ">=8",
13 | "npm": "please-use-pnpm",
14 | "yarn": "please-use-pnpm"
15 | },
16 | "scripts": {
17 | "dev": "vite",
18 | "build": "vite build && node scripts/removeUnnecessaryAssets.js",
19 | "lint": "tsc --noEmit && eslint . --ext .ts,.tsx,.js,.cjs,.jsx",
20 | "lint:fix": "eslint --fix . --ext .ts,.tsx,.js,.cjs,.jsx",
21 | "bump": "npm run lint && node ./scripts/bump.js",
22 | "test": "vitest",
23 | "test:unit": "vitest --exclude=src/tests/e2e",
24 | "test:e2e": "vitest --poolOptions.threads.singleThread=true src/tests/e2e",
25 | "prepare": "husky install",
26 | "preversion": "npm run lint"
27 | },
28 | "dependencies": {
29 | "@types/styled-components": "^5.1.23",
30 | "axios": "^0.26.1",
31 | "clsx": "^1.1.1",
32 | "debounce": "^2.0.0",
33 | "fuzzysort": "^2.0.4",
34 | "graphql": "^16.3.0",
35 | "graphql-request": "^4.2.0",
36 | "i18next": "^21.6.16",
37 | "i18next-browser-languagedetector": "^6.1.4",
38 | "lodash-es": "^4.17.21",
39 | "react": "^18.2.0",
40 | "react-dom": "^18.2.0",
41 | "react-i18next": "^11.16.7",
42 | "react-use-storage-state": "^1.0.3",
43 | "styled-components": "^6.1.8",
44 | "tocas": "^4.0.2",
45 | "url-join": "^5.0.0"
46 | },
47 | "devDependencies": {
48 | "@commitlint/cli": "^17.0.2",
49 | "@commitlint/config-conventional": "^17.0.2",
50 | "@crxjs/vite-plugin": "2.0.0-beta.26",
51 | "@testing-library/jest-dom": "^6.4.1",
52 | "@testing-library/react": "^14.2.1",
53 | "@testing-library/user-event": "^14.5.2",
54 | "@types/chrome": "^0.0.177",
55 | "@types/fs-extra": "^11.0.4",
56 | "@types/lodash-es": "^4.17.12",
57 | "@types/react": "^18.2.55",
58 | "@types/react-dom": "^18.2.18",
59 | "@typescript-eslint/eslint-plugin": "^6.21.0",
60 | "@typescript-eslint/parser": "6.21.0",
61 | "@vitejs/plugin-react": "^4.2.0",
62 | "eslint": "^8.8.0",
63 | "eslint-config-react-app": "^7.0.0",
64 | "eslint-config-standard-with-typescript": "^40.0.0",
65 | "eslint-plugin-flowtype": "^8.0.3",
66 | "eslint-plugin-import": "^2.25.4",
67 | "eslint-plugin-n": "^15.0.0",
68 | "eslint-plugin-promise": "^6.0.0",
69 | "eslint-plugin-react": "^7.28.0",
70 | "eslint-plugin-react-hooks": "^4.3.0",
71 | "eslint-plugin-testing-library": "^6.2.0",
72 | "fs-extra": "^10.0.0",
73 | "husky": "^8.0.1",
74 | "inquirer": "^8.2.4",
75 | "jsdom": "^24.0.0",
76 | "open": "^8.4.0",
77 | "puppeteer": "^21.10.0",
78 | "sass": "^1.50.0",
79 | "typescript": "^5.2.2",
80 | "vite": "^4.2.0",
81 | "vitest": "^1.2.2"
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/public/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": {
3 | "message": "LeetCode Night"
4 | },
5 | "appDesc": {
6 | "message": "Enable dark mode on LeetCode. Additional features include Instant Question Navigation, YouTube Link Shortcut and Auto Reset Code."
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/public/_locales/ja/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": {
3 | "message": "LeetCode Night リートコードナイト"
4 | },
5 | "appDesc": {
6 | "message": "LeetCode でダークモードと追加機能を有効にしましょう。追加機能には、デイリーチャレンジショートカット、即時問題ナビゲーション、YouTube リンクショートカット、コード自動リセットが含まれます。"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/public/_locales/ko/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": {
3 | "message": "LeetCode Night"
4 | },
5 | "appDesc": {
6 | "message": "LeetCode에서 다크 모드와 추가 기능을 활성화하세요. 추가 기능으로는 데일리 챌린지 단축키, 즉시 문제 탐색, YouTube 링크 단축키 및 코드 자동 리셋이 포함됩니다."
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/public/_locales/zh_CN/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": {
3 | "message": "LeetCode Night"
4 | },
5 | "appDesc": {
6 | "message": "深夜刷题必备良伴 - 在 LeetCode 启用深色模式。包含附加功能如「每日 LeetCoding 挑战快捷」、「极速题目搜索」、「YouTube 链接快捷」和「自动重置代码」。"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/public/_locales/zh_TW/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": {
3 | "message": "LeetCode Night"
4 | },
5 | "appDesc": {
6 | "message": "深夜刷題必備良伴 - 在 LeetCode 啟用深色模式。包含附加功能如「每日 LeetCoding 挑戰捷徑」、「極速題目搜尋」、「YouTube 連結捷徑」和「自動重置程式碼」"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/scripts/PackageJsonReaderWriter.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | module.exports = class PackageJsonReaderWriter {
6 | static fileName = 'package.json'
7 |
8 | static read () {
9 | const content = require(path.join('..', this.fileName))
10 | return content
11 | }
12 |
13 | static write (content) {
14 | fs.writeFileSync(this.fileName, content)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/scripts/bump.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | /* eslint-disable @typescript-eslint/no-var-requires */
3 | const { execSync } = require('child_process')
4 | const openReleasePage = require('./openReleasePage')
5 |
6 | const execWithStdio = (command) => execSync(command, { stdio: 'inherit' })
7 |
8 | async function bump () {
9 | const commitMessage = 'chore: bump version v%s'
10 | execWithStdio(`npx bumpp --commit "${commitMessage}"`)
11 |
12 | await openReleasePage()
13 |
14 | console.log('\n✅ All Done!')
15 | }
16 |
17 | bump()
18 |
--------------------------------------------------------------------------------
/scripts/openReleasePage.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const open = require('open')
3 | const PackageJsonReaderWriter = require('./PackageJsonReaderWriter')
4 |
5 | module.exports = async function openReleasePage () {
6 | const { default: urlJoin } = await import('url-join')
7 | const package = PackageJsonReaderWriter.read()
8 | const url = urlJoin(package.repository.url, 'releases')
9 | await open(url)
10 | }
11 |
--------------------------------------------------------------------------------
/scripts/removeUnnecessaryAssets.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | /* eslint-disable @typescript-eslint/no-var-requires */
3 | const { execSync } = require('child_process')
4 |
5 | const execWithStdio = (command) => execSync(command, { stdio: 'inherit' })
6 |
7 | async function removeUnnecessaryAssets () {
8 | execWithStdio('rm dist/assets/*.svg')
9 | console.log('✅ Removed all `dist/assets/*.svg` successfully!')
10 | }
11 |
12 | removeUnnecessaryAssets()
13 |
--------------------------------------------------------------------------------
/src/Globals.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.css'
2 | declare module '*.sass'
3 | declare module '*.sass?inline'
4 |
--------------------------------------------------------------------------------
/src/assets/img/icon-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/src/assets/img/icon-128.png
--------------------------------------------------------------------------------
/src/assets/img/logo-text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/src/assets/img/logo-text.png
--------------------------------------------------------------------------------
/src/assets/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/src/assets/img/logo.png
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | import packageJson from '../package.json'
2 |
3 | export const VERSION = packageJson.version
4 |
5 | export const EXTENSION_ID = 'aaokgipfeeeciodnffigjfiafledhcii'
6 |
7 | export const POWERED_BY_TEXT = '🪄 Powered by LeetCode Night'
8 |
9 | export const TEST_IDS = {
10 | questionsTabButton: 'questionsTabButton',
11 | optionsTabButton: 'optionsTabButton',
12 | questionsTab: 'questionsTab',
13 | optionsTab: 'optionsTab',
14 | dailyChallengeQuestionCard: 'dailyChallengeQuestionCard',
15 | languageSelect: 'languageSelect',
16 | questionCard: 'questionCard',
17 | questionCardHiddenLink: 'questionCardHiddenLink',
18 | } as const
19 |
--------------------------------------------------------------------------------
/src/options.ts:
--------------------------------------------------------------------------------
1 | export const OPTIONS = {
2 | INVERT_IMAGE_COLOR: {
3 | key: 'invertImageColor',
4 | icon: 'image',
5 | default: true,
6 | },
7 | MASCOT: {
8 | key: 'mascot',
9 | icon: 'cat',
10 | default: true,
11 | },
12 | HIDE_LOGO: {
13 | key: 'hideLogo',
14 | icon: 'eye-slash',
15 | default: false,
16 | },
17 | } as const
18 |
19 | export type OptionKey = typeof OPTIONS[keyof typeof OPTIONS]['key']
20 |
21 | export const DEFAULT_OPTIONS = Object.values(OPTIONS)
22 | .reduce((prev, option) => ({
23 | ...prev,
24 | [option.key]: option.default,
25 | }), {}) as OptionsForm
26 |
27 | export type OptionsForm = Record
28 |
--------------------------------------------------------------------------------
/src/pages/Content/InsertDislikeCount.ts:
--------------------------------------------------------------------------------
1 | import { $, $x } from './dom'
2 | import { getSlug } from './slug'
3 | import { type LikesAndDislikes, fetchLikesAndDislikes } from './fetchLikesAndDislikes'
4 | import { POWERED_BY_TEXT } from '../../constants'
5 |
6 | function formatNumber (value: number) {
7 | return new Intl.NumberFormat('en-US').format(value)
8 | }
9 |
10 | export const datasetKey = 'leetcode_night_insert_dislike_count'
11 |
12 | export const containerDatasetValue = 'container'
13 | export const likeTextDatasetValue = 'likeText'
14 | export const customLikeTextDatasetValue = 'customLikeText'
15 | export const customDislikeTextDatasetValue = 'customDislikeText'
16 |
17 | class InsertDislikeCount2023DynamicLayout {
18 | slug: string | null = null
19 |
20 | selectElement (datasetValue: string, parent?: Element) {
21 | const selector = `[data-${datasetKey}=${datasetValue}]`
22 | return (
23 | parent
24 | ? parent.querySelector(selector)
25 | : $(selector)
26 | ) as HTMLElement
27 | }
28 |
29 | selectHandledContainer () {
30 | return this.selectElement(containerDatasetValue)
31 | }
32 |
33 | selectLikeText () {
34 | const container = this.selectHandledContainer()
35 | return this.selectElement(likeTextDatasetValue, container)
36 | }
37 |
38 | selectCustomLikeText () {
39 | const container = this.selectHandledContainer()
40 | return this.selectElement(customLikeTextDatasetValue, container)
41 | }
42 |
43 | selectCustomDislikeText () {
44 | const container = this.selectHandledContainer()
45 | return this.selectElement(customDislikeTextDatasetValue, container)
46 | }
47 |
48 | handle ({ likes, dislikes }: LikesAndDislikes) {
49 | const container = selectLikeDislikeContainer()
50 | if (!container) return
51 |
52 | container.dataset[datasetKey] = containerDatasetValue
53 |
54 | const [likeButton, dislikeButton] = container.querySelectorAll('button')
55 | if (!(likeButton && dislikeButton)) return
56 |
57 | const likeText = likeButton.querySelector(`${iconSelector} ~ div`) as HTMLElement
58 | if (!likeText) return
59 |
60 | // Create "custom like text" and insert it after the original one
61 | const customLikeText = likeText.cloneNode(true) as HTMLElement
62 | customLikeText.textContent = formatNumber(likes)
63 |
64 | likeText.after(customLikeText)
65 |
66 | // Insert the "custom dislike text"
67 | const customDislikeText = likeText.cloneNode(true) as HTMLElement
68 | customDislikeText.textContent = formatNumber(dislikes)
69 | customDislikeText.title = POWERED_BY_TEXT
70 | customDislikeText.style.lineHeight = '0' // Adjust the style so it won't expand the height of the button
71 |
72 | const dislikeIcon = dislikeButton.querySelector(iconSelector) as HTMLElement
73 | dislikeIcon?.after(customDislikeText)
74 |
75 | // Hide the "original like text"
76 | likeText.style.display = 'none'
77 |
78 | // Mark modified elements with dataset
79 | likeText.dataset[datasetKey] = likeTextDatasetValue
80 | customLikeText.dataset[datasetKey] = customLikeTextDatasetValue
81 | customDislikeText.dataset[datasetKey] = customDislikeTextDatasetValue
82 | }
83 |
84 | /**
85 | * Call this function when the container is modified by LeetCode Night.
86 | * This will edit the text content of existing elements without inserting new
87 | * ones.
88 | */
89 | modifyHandled ({ likes, dislikes }: LikesAndDislikes) {
90 | const container = this.selectHandledContainer()
91 | if (!container) return
92 |
93 | const customLikeText = this.selectCustomLikeText()
94 | const customDislikeText = this.selectCustomDislikeText()
95 | if (!(customLikeText && customDislikeText)) return
96 |
97 | customLikeText.textContent = formatNumber(likes)
98 | customDislikeText.textContent = formatNumber(dislikes)
99 | }
100 |
101 | async tryHandle () {
102 | const slug = getSlug()
103 | if (!slug) return
104 |
105 | const handledContainer = this.selectHandledContainer()
106 |
107 | const likesAndDislikes = await fetchLikesAndDislikes(slug)
108 | if (handledContainer) {
109 | if (slug !== this.slug) {
110 | this.modifyHandled(likesAndDislikes)
111 | this.slug = slug
112 | }
113 | return
114 | }
115 |
116 | this.handle(likesAndDislikes)
117 | }
118 |
119 | /** Revert all operations done in `handle()` */
120 | destroy () {
121 | const container = this.selectHandledContainer()
122 | if (!container) return
123 |
124 | // Restore the display of "original like text"
125 | const likeText = this.selectLikeText()
126 | if (likeText) likeText.style.display = ''
127 |
128 | // Remove custom elements
129 | const customLikeText = this.selectCustomLikeText()
130 | customLikeText?.remove()
131 |
132 | const customDislikeText = this.selectCustomDislikeText()
133 | customDislikeText?.remove()
134 |
135 | // Un-mark the container
136 | delete container.dataset[datasetKey]
137 | }
138 | }
139 |
140 | function selectLikeDislikeContainer () {
141 | return $x(`
142 | //*[
143 | @class="mr-1 flex overflow-hidden rounded-lg bg-fill-secondary dark:bg-fill-secondary" and
144 | .//*[@data-icon="thumbs-up"]
145 | ]
146 | `)[0]
147 | }
148 |
149 | const iconSelector = '.relative.text-\\[14px\\].leading-\\[normal\\].p-\\[1px\\].before\\:block.before\\:h-3\\.5.before\\:w-3\\.5'
150 |
151 | let isStarted = false
152 |
153 | const instances = [
154 | new InsertDislikeCount2023DynamicLayout(),
155 | ]
156 |
157 | const handleMutation = () => {
158 | if (!isStarted) return
159 | instances.forEach(instance => instance.tryHandle())
160 | }
161 |
162 | const destroyAll = () => {
163 | instances.forEach(instance => instance.destroy())
164 | }
165 |
166 | const observer = new MutationObserver(() => handleMutation())
167 |
168 | export async function startInsertDislikeCountObserver () {
169 | if (isStarted) return
170 |
171 | isStarted = true
172 | handleMutation()
173 | observer.observe(document.documentElement, {
174 | subtree: true,
175 | childList: true,
176 | })
177 | }
178 |
179 | export async function stopInsertDislikeCountObserver () {
180 | isStarted = false
181 | observer.disconnect()
182 | destroyAll()
183 | }
184 |
--------------------------------------------------------------------------------
/src/pages/Content/InsertYoutubeLink.ts:
--------------------------------------------------------------------------------
1 | import { $$, $x, createElement } from './dom'
2 | import { POWERED_BY_TEXT } from '../../constants'
3 |
4 | type SelectContainer = () => Node
5 | type RenderLink = (href: string) => HTMLAnchorElement
6 | type GetTitle = () => string | null
7 |
8 | export const insertYoutubeLinkDatasetKey = 'leetcode_night_insert_youtube_link'
9 |
10 | class InsertYoutubeLink {
11 | selectContainer: SelectContainer
12 | renderLink: RenderLink
13 | getTitle: GetTitle
14 |
15 | currentTitle: string | undefined
16 |
17 | constructor (dependencies: {
18 | selectContainer: SelectContainer,
19 | renderLink: RenderLink,
20 | getTitle: GetTitle,
21 | }) {
22 | this.selectContainer = dependencies.selectContainer
23 | this.renderLink = dependencies.renderLink
24 | this.getTitle = dependencies.getTitle
25 | }
26 |
27 | generateYoutubeUrl (keyword: string) {
28 | return `https://www.youtube.com/results?search_query=${encodeURIComponent(keyword)}`
29 | }
30 |
31 | selectLinks () {
32 | return $$(`[data-${insertYoutubeLinkDatasetKey}]`)
33 | }
34 |
35 | tryInsert () {
36 | const title = this.getTitle()
37 | if (!title) return
38 |
39 | const container = this.selectContainer()
40 | if (!container) return
41 |
42 | if (title === this.currentTitle) return
43 | this.currentTitle = title
44 |
45 | this.removeAll()
46 | const url = this.generateYoutubeUrl(title)
47 | const newLink = this.renderLink(url)
48 | newLink.dataset[insertYoutubeLinkDatasetKey] = ''
49 | container.appendChild(newLink)
50 | }
51 |
52 | removeAll () {
53 | const existedLink = this.selectLinks()
54 | existedLink.forEach(link => link.remove())
55 | }
56 |
57 | destroy () {
58 | this.currentTitle = undefined
59 | this.removeAll()
60 | }
61 | }
62 |
63 | function renderLink (attributes: {
64 | className?: string,
65 | style?: string,
66 | href?: string,
67 | }) {
68 | function createYoutubeLinkChildren () {
69 | const iconHtml = `
70 |
71 | `
72 | const textHtml = 'YouTube'
73 |
74 | return createElement(`
75 |
76 | ${iconHtml}
77 | ${textHtml}
78 |
79 | `)
80 | }
81 |
82 | const link = document.createElement('a')
83 | Object.assign(link, {
84 | target: '_blank',
85 | title: POWERED_BY_TEXT,
86 | ...attributes,
87 | })
88 | link.style.order = '99'
89 |
90 | link.append(createYoutubeLinkChildren())
91 |
92 | return link
93 | }
94 |
95 | const insertYoutubeLink2022 = new InsertYoutubeLink({
96 | selectContainer: () => $x('//*[contains(@class, "css-10o4wqw")]')[0],
97 | renderLink: (href: string) => renderLink({
98 | className: 'btn__r7r7 css-1rdgofi',
99 | style: 'color: inherit;',
100 | href,
101 | }),
102 | getTitle: () => $x('//*[@data-cy="question-title"]')[0]?.textContent,
103 | })
104 |
105 | const insertYoutubeLink2023 = new InsertYoutubeLink({
106 | selectContainer: () => {
107 | const shareIconPath = 'M12.44 7.586a.6.6 0 10-1.2 0h1.2zm-6.734 5.035l.117-.588-.117.588zm-2.628-1.405l.424-.424-.424.424zM1.673 8.588l.588-.117-.588.117zm.292-2.966l.554.23-.554-.23zm1.89-2.304l-.333-.499.334.5zm2.853-.265a.6.6 0 000-1.2v1.2zm.25 3.434a.6.6 0 00.848.849l-.849-.849zm5.154-3.457a.6.6 0 10-.849-.849l.849.849zm-2.82-1.197a.6.6 0 000 1.2v-1.2zm2.546.6h.6a.6.6 0 00-.6-.6v.6zm-.6 2.566a.6.6 0 001.2 0h-1.2zm.003 2.587c0 .897-.266 1.774-.764 2.52l.998.666c.63-.943.966-2.052.966-3.186h-1.2zm-.764 2.52a4.533 4.533 0 01-2.035 1.669l.46 1.108a5.733 5.733 0 002.573-2.111l-.998-.667zm-2.035 1.669a4.534 4.534 0 01-2.619.258L5.59 13.21a5.733 5.733 0 003.313-.327l-.46-1.108zm-2.619.258a4.533 4.533 0 01-2.321-1.241l-.849.849A5.733 5.733 0 005.59 13.21l.234-1.177zm-2.321-1.241a4.533 4.533 0 01-1.24-2.321l-1.178.234a5.734 5.734 0 001.57 2.936l.848-.849zm-1.24-2.321a4.533 4.533 0 01.257-2.62l-1.108-.459a5.733 5.733 0 00-.327 3.313l1.177-.234zm.257-2.62a4.533 4.533 0 011.67-2.034l-.667-.998a5.733 5.733 0 00-2.111 2.573l1.108.46zm1.67-2.034a4.533 4.533 0 012.519-.764v-1.2a5.733 5.733 0 00-3.186.966l.667.998zm3.617 3.519l4.306-4.306-.849-.849-4.306 4.306.849.849zm1.487-4.303h2.545v-1.2H9.293v1.2zm1.945-.6v2.566h1.2V2.433h-1.2z'
108 |
109 | return $x(`
110 | //*[
111 | @class="mt-3 flex items-center space-x-4" and
112 | .//*[@d="${shareIconPath}"]
113 | ]
114 | `)[0]
115 | },
116 | renderLink: (href: string) => renderLink({
117 | className: 'flex cursor-pointer items-center space-x-1 rounded px-1 py-[3px] hover:bg-fill-3 dark:hover:bg-dark-fill-3 text-gray-6 dark:text-dark-gray-6 font-medium text-xs',
118 | href,
119 | }),
120 | getTitle: () => $x(`
121 | //a[
122 | @class="mr-2 text-label-1 dark:text-dark-label-1 hover:text-label-1 dark:hover:text-dark-label-1 text-lg font-medium" and
123 | contains(@href, "/problems/")
124 | ]
125 | `)[0]?.textContent,
126 | })
127 |
128 | const insertYoutubeLink2023DynamicLayout = new InsertYoutubeLink({
129 | selectContainer: () => {
130 | const difficultyXpath = `
131 | .//*[
132 | contains(@class, 'text-caption') and (
133 | contains(@class, 'text-difficulty-easy') or
134 | contains(@class, 'text-difficulty-medium') or
135 | contains(@class, 'text-difficulty-hard')
136 | )
137 | `
138 |
139 | return $x(`
140 | //*[@class="flexlayout__tab"]
141 | //*[@class="flex gap-1" and ${difficultyXpath}]]
142 | `)[0]
143 | },
144 | renderLink: (href: string) => renderLink({
145 | className: 'relative inline-flex items-center justify-center text-caption px-2 py-1 gap-1 rounded-full bg-fill-secondary cursor-pointer transition-colors hover:bg-fill-primary hover:text-text-primary text-sd-secondary-foreground hover:opacity-80',
146 | href,
147 | }),
148 | getTitle: () => $x(`
149 | //a[
150 | @class="no-underline hover:text-blue-s dark:hover:text-dark-blue-s truncate cursor-text whitespace-normal hover:!text-[inherit]" and
151 | contains(@href, "/problems/")
152 | ]
153 | `)[0]?.textContent,
154 | })
155 |
156 | let isStarted = false
157 |
158 | const insertYoutubeLinkInstances = [
159 | insertYoutubeLink2022,
160 | insertYoutubeLink2023,
161 | insertYoutubeLink2023DynamicLayout,
162 | ]
163 |
164 | const destroyAll = () => {
165 | insertYoutubeLinkInstances
166 | .forEach(instance => instance.destroy())
167 | }
168 |
169 | function handleMutation () {
170 | insertYoutubeLinkInstances
171 | .forEach(instance => instance.tryInsert())
172 | }
173 |
174 | const observer = new MutationObserver(handleMutation)
175 |
176 | export async function startInsertYoutubeLinkObserver () {
177 | if (isStarted) return
178 | isStarted = true
179 |
180 | handleMutation()
181 | observer.observe(document.documentElement, {
182 | subtree: true,
183 | childList: true,
184 | })
185 | }
186 |
187 | export async function stopInsertYoutubeLinkObserver () {
188 | isStarted = false
189 | observer.disconnect()
190 | destroyAll()
191 | }
192 |
--------------------------------------------------------------------------------
/src/pages/Content/StyleInjector.ts:
--------------------------------------------------------------------------------
1 | export class StyleInjector {
2 | isInjected = false
3 | content: string
4 | $el: HTMLStyleElement
5 |
6 | constructor (content: unknown) {
7 | this.content = String(content)
8 | this.$el = document.createElement('style')
9 | this.$el.textContent = this.content
10 | }
11 |
12 | get target () {
13 | return document.documentElement
14 | }
15 |
16 | inject () {
17 | if (this.isInjected) return
18 | this.target.appendChild(this.$el)
19 | this.isInjected = true
20 | }
21 |
22 | eject () {
23 | if (!this.isInjected) return
24 | this.target.removeChild(this.$el)
25 | this.isInjected = false
26 | }
27 |
28 | toggle (value: boolean) {
29 | if (value) {
30 | this.inject()
31 | } else {
32 | this.eject()
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/Content/darkSide2023.ts:
--------------------------------------------------------------------------------
1 | import { waitForElement } from './selector'
2 |
3 | export const getIsDarkSide2023 = async () => {
4 | await waitForElement('body')
5 | const html = await waitForElement('html')
6 | const isDark = html.classList.contains('dark')
7 | return isDark
8 | }
9 |
10 | export const setIsDarkSide2023 = async (isDark: boolean) => {
11 | const possibleKeys = ['lc-dark-side', 'lc-theme']
12 | const newValue = isDark ? 'dark' : 'light'
13 |
14 | possibleKeys.forEach(key => {
15 | const event = new StorageEvent('storage', { key, newValue })
16 | window.dispatchEvent(event)
17 | })
18 | }
19 |
20 | export const onChangeIsDarkSide2023 = (
21 | onChange: (isDarkSide: boolean) => void
22 | ) => {
23 | const observer = new MutationObserver(async () => {
24 | onChange(await getIsDarkSide2023())
25 | })
26 |
27 | const html = document.querySelector('html')
28 |
29 | if (html) {
30 | observer.observe(html, {
31 | attributes: true,
32 | attributeFilter: ['class'],
33 | })
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/Content/dom.ts:
--------------------------------------------------------------------------------
1 | export function createElement (html: string) {
2 | const container = document.createElement('div')
3 | container.innerHTML = html.trim()
4 | if (!container.firstChild) throw new Error()
5 | return container.firstChild as HTMLElement
6 | }
7 |
8 | export function $x (xpath: string) {
9 | const snapshot = document.evaluate(
10 | xpath, document, null,
11 | XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null
12 | )
13 | return [...Array(snapshot.snapshotLength)]
14 | .map((_, i) => snapshot.snapshotItem(i) as HTMLElement)
15 | }
16 |
17 | export function $ (selector: string): HTMLElement | null {
18 | return document.querySelector(selector)
19 | }
20 |
21 | export function $$ (selector: string) {
22 | return [...document.querySelectorAll(selector)] as HTMLElement[]
23 | }
24 |
--------------------------------------------------------------------------------
/src/pages/Content/fetchLikesAndDislikes.ts:
--------------------------------------------------------------------------------
1 | import { request, gql } from 'graphql-request'
2 |
3 | export interface LikesAndDislikes {
4 | likes: number,
5 | dislikes: number,
6 | }
7 |
8 | const cache = new Map()
9 |
10 | export async function fetchLikesAndDislikes (titleSlug: string) {
11 | const cached = cache.get(titleSlug)
12 | if (cached) return cached
13 |
14 | const query = gql`
15 | query questionTitle($titleSlug: String!) {
16 | question(titleSlug: $titleSlug) {
17 | likes
18 | dislikes
19 | }
20 | }
21 | `
22 | const data = await request(
23 | 'https://leetcode.com/graphql/',
24 | query,
25 | { titleSlug }
26 | )
27 |
28 | const likesAndDislikes = data.question as LikesAndDislikes
29 | cache.set(titleSlug, likesAndDislikes)
30 |
31 | return likesAndDislikes
32 | }
33 |
--------------------------------------------------------------------------------
/src/pages/Content/index.ts:
--------------------------------------------------------------------------------
1 | import { loadIsEnabled, loadOptions, loadIsAutoResetCodeEnabled, saveIsEnabled, loadIsInsertYoutubeLinkEnabled, saveLeetcodeVersion, loadIsInsertDislikeCountEnabled } from '../../storage'
2 |
3 | import { toggleEnabled } from './toggleEnabled'
4 | import { toggleInvertImageColor } from './toggleInvertImageColor'
5 | import { toggleInvertImageColor2023 } from './toggleInvertImageColor2023'
6 | import { toggleEnabledMascot } from './toggleEnabledMascot'
7 | import { toggleHideLogo } from './toggleHideLogo'
8 | import { toggleHideLogo2023 } from './toggleHideLogo2023'
9 |
10 | import { resetCode } from './resetCode'
11 | import { resetCode2023 } from './resetCode2023'
12 | import { detectLeetcodeVersion } from './leetcode-version'
13 | import { onChangeIsDarkSide2023, setIsDarkSide2023 } from './darkSide2023'
14 | import { startInsertYoutubeLinkObserver, stopInsertYoutubeLinkObserver } from './InsertYoutubeLink'
15 | import { startInsertDislikeCountObserver, stopInsertDislikeCountObserver } from './InsertDislikeCount'
16 |
17 | async function toggleInvertImageColorByVersion (value: boolean) {
18 | const version = await detectLeetcodeVersion()
19 | if (version === '2022') {
20 | toggleInvertImageColor(value)
21 | } else {
22 | toggleInvertImageColor2023(value)
23 | }
24 | }
25 |
26 | async function toggleHideLogoByVersion (value: boolean) {
27 | const version = await detectLeetcodeVersion()
28 | if (version === '2022') {
29 | toggleHideLogo(value)
30 | } else {
31 | toggleHideLogo2023(value)
32 | }
33 | }
34 |
35 | async function applyOptions () {
36 | const options = await loadOptions()
37 | toggleInvertImageColorByVersion(options?.invertImageColor ?? false)
38 | toggleEnabledMascot(options?.mascot ?? false)
39 | toggleHideLogoByVersion(options?.hideLogo ?? false)
40 | }
41 |
42 | async function toggleEnabledByVersion () {
43 | const isEnabled = await loadIsEnabled()
44 | const version = await detectLeetcodeVersion()
45 |
46 | if (version === '2022') {
47 | toggleEnabled(isEnabled)
48 | } else {
49 | setIsDarkSide2023(isEnabled)
50 | }
51 | }
52 |
53 | async function tryResetCodeByVersion () {
54 | const isEnabled = await loadIsAutoResetCodeEnabled()
55 | if (!isEnabled) return
56 |
57 | const version = await detectLeetcodeVersion()
58 | if (version === '2022') {
59 | resetCode()
60 | } else {
61 | resetCode2023()
62 | }
63 | }
64 |
65 | async function startOrStopInsertYoutubeLinkObserver () {
66 | const isEnabled = await loadIsInsertYoutubeLinkEnabled()
67 |
68 | if (isEnabled) startInsertYoutubeLinkObserver()
69 | else stopInsertYoutubeLinkObserver()
70 | }
71 |
72 | async function startOrStopInsertDislikeCountObserver () {
73 | const isEnabled = await loadIsInsertDislikeCountEnabled()
74 |
75 | if (isEnabled) startInsertDislikeCountObserver()
76 | else stopInsertDislikeCountObserver()
77 | }
78 |
79 | async function init () {
80 | async function startOrStopTasks () {
81 | toggleEnabledByVersion()
82 | applyOptions()
83 | startOrStopInsertYoutubeLinkObserver()
84 | startOrStopInsertDislikeCountObserver()
85 | }
86 |
87 | chrome.storage.onChanged.addListener(startOrStopTasks)
88 | startOrStopTasks()
89 |
90 | tryResetCodeByVersion()
91 | onChangeIsDarkSide2023(saveIsEnabled)
92 | saveLeetcodeVersion(await detectLeetcodeVersion())
93 | }
94 |
95 | init()
96 |
--------------------------------------------------------------------------------
/src/pages/Content/leetcode-version.ts:
--------------------------------------------------------------------------------
1 | import { waitDOMContentLoaded, waitForElement } from './selector'
2 |
3 | export type LeetcodeVersion = '2022' | '2023' | '2023-dynamic-layout'
4 |
5 | async function waitLeetcode2023PageLoaded () {
6 | await waitForElement('#qd-content')
7 | }
8 |
9 | export async function detectLeetcodeVersion (): Promise {
10 | await waitDOMContentLoaded()
11 |
12 | const isNextApp = Boolean(
13 | document.querySelector('head meta[data-next-head]')
14 | )
15 |
16 | if (isNextApp) {
17 | await waitLeetcode2023PageLoaded()
18 | const hasDynamicLayoutClass = Boolean(
19 | document.querySelector('.flexlayout__layout')
20 | )
21 | if (hasDynamicLayoutClass) return '2023-dynamic-layout'
22 | return '2023'
23 | }
24 | return '2022'
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/Content/resetCode.ts:
--------------------------------------------------------------------------------
1 | import { waitForElement } from './selector'
2 |
3 | async function selectResetButton () {
4 | return await waitForElement(
5 | '[data-cy="code-area"] button[icon="return"]'
6 | ) as HTMLButtonElement
7 | }
8 |
9 | async function selectDialogConfirmButton () {
10 | return await waitForElement(
11 | '.ant-modal-body button:nth-child(2)'
12 | ) as HTMLButtonElement
13 | }
14 |
15 | export async function resetCode () {
16 | const resetButton = await selectResetButton()
17 | resetButton.click()
18 |
19 | const confirmButton = await selectDialogConfirmButton()
20 | confirmButton.click()
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/Content/resetCode2023.ts:
--------------------------------------------------------------------------------
1 | import { waitForElementsByXpath } from './selector'
2 |
3 | async function selectResetButton () {
4 | const resetIconPath = 'M5.725 9.255h2.843a1 1 0 110 2H3.2a1 1 0 01-1-1V4.887a1 1 0 012 0v3.056l2.445-2.297a9.053 9.053 0 11-2.142 9.415 1 1 0 011.886-.665 7.053 7.053 0 1010.064-8.515 7.063 7.063 0 00-8.417 1.202L5.725 9.255z'
5 |
6 | const dynamicLayoutResetIconPath = 'M40 224c-13.3 0-24-10.7-24-24V56c0-13.3 10.7-24 24-24s24 10.7 24 24v80.1l20-23.5C125 63.4 186.9 32 256 32c123.7 0 224 100.3 224 224s-100.3 224-224 224c-50.4 0-97-16.7-134.4-44.8c-10.6-8-12.7-23-4.8-33.6s23-12.7 33.6-4.8C179.8 418.9 216.3 432 256 432c97.2 0 176-78.8 176-176s-78.8-176-176-176c-54.3 0-102.9 24.6-135.2 63.4l-.1 .2 0 0L93.1 176H184c13.3 0 24 10.7 24 24s-10.7 24-24 24H40z'
7 |
8 | const [button] = await waitForElementsByXpath(`
9 | //*[@id="editor"]
10 | //button[
11 | .//*[
12 | @d="${resetIconPath}" or
13 | @d="${dynamicLayoutResetIconPath}"
14 | ]
15 | ]`
16 | )
17 |
18 | return button as HTMLButtonElement
19 | }
20 |
21 | async function selectDialogConfirmButton () {
22 | const message = 'Your current code will be discarded and reset to the default code!'
23 | const [button] = await waitForElementsByXpath(`
24 | //*[@role="dialog" and contains(., "${message}")]
25 | //button[contains(., "Confirm")]`
26 | )
27 |
28 | return button as HTMLButtonElement
29 | }
30 |
31 | const sleep = (ms = 300) => new Promise((resolve) => setTimeout(resolve, ms))
32 |
33 | export async function resetCode2023 () {
34 | const resetButton = await selectResetButton()
35 | resetButton.click()
36 |
37 | await sleep()
38 | const confirmButton = await selectDialogConfirmButton()
39 | confirmButton.click()
40 | }
41 |
--------------------------------------------------------------------------------
/src/pages/Content/selector.ts:
--------------------------------------------------------------------------------
1 | import { $x } from './dom'
2 |
3 | export function waitForElement (selector: string): Promise {
4 | return new Promise(resolve => {
5 | const element = document.querySelector(selector)
6 | if (element) {
7 | return resolve(element)
8 | }
9 |
10 | const observer = new MutationObserver(() => {
11 | const element = document.querySelector(selector)
12 |
13 | if (element) {
14 | resolve(element)
15 | observer.disconnect()
16 | }
17 | })
18 |
19 | observer.observe(document.documentElement, {
20 | childList: true,
21 | subtree: true,
22 | })
23 | })
24 | }
25 |
26 | export function waitForElementsByXpath (xpath: string): Promise {
27 | return new Promise(resolve => {
28 | const elements = $x(xpath)
29 | if (elements.length) {
30 | return resolve(elements)
31 | }
32 |
33 | const observer = new MutationObserver(() => {
34 | const elements = $x(xpath)
35 | if (elements.length) {
36 | resolve(elements)
37 | observer.disconnect()
38 | }
39 | })
40 |
41 | observer.observe(document.documentElement, {
42 | childList: true,
43 | subtree: true,
44 | })
45 | })
46 | }
47 |
48 | export function waitDOMContentLoaded () {
49 | return new Promise((resolve) => {
50 | const eventName = 'DOMContentLoaded'
51 | const handlerLoaded = () => {
52 | document.removeEventListener(eventName, handlerLoaded)
53 | resolve()
54 | }
55 | document.addEventListener(eventName, handlerLoaded)
56 | if (document.readyState !== 'loading') handlerLoaded()
57 | })
58 | }
59 |
--------------------------------------------------------------------------------
/src/pages/Content/slug.ts:
--------------------------------------------------------------------------------
1 | export function getSlug () {
2 | const url = location.href
3 | const pattern = /problems\/([^/]+)/
4 | const slug = pattern.exec(url)?.[1]
5 |
6 | return slug
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_ant-modal.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | .ant-modal
4 | &,
5 | .ant-modal-title,
6 | [class*="setting-title"]
7 | color: colors.$grey-8
8 |
9 | [class*="setting-description"]
10 | color: colors.$grey-6
11 |
12 | .ant-modal-content,
13 | .ant-modal-header,
14 | [class*="lc-modal-footer"],
15 | [class*="lc-modal-with-type"],
16 | [class*="lc-modal-with-title-header"]
17 | background: colors.$dsw-layer-2
18 |
19 | [class*="modal-close-btn"]
20 | color: colors.$grey-6
21 | &:hover
22 | color: colors.$grey-8
23 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_ant-select-dropdown.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | .ant-select-dropdown
4 | color: colors.$grey-8
5 | background-color: colors.$dsw-overlay-3
6 | ul.ant-select-dropdown-menu
7 | border-color: colors.$grey-4
8 | .ant-select-dropdown-menu-item
9 | color: colors.$grey-8
10 | &[class*="selected"], &[class*="active"]
11 | background-color: colors.$dsw-layer-1
12 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_ant-select.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | .ant-select
4 | background-color: transparent
5 | &, &:hover
6 | border-color: colors.$grey-4 !important
7 | &, &:hover
8 | .ant-select-arrow,
9 | .ant-select-arrow-icon
10 | color: colors.$grey-4 !important
11 | .ant-select-selection-selected-value
12 | color: colors.$grey-8
13 | &:hover
14 | opacity: .8
15 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_colors.sass:
--------------------------------------------------------------------------------
1 | $grey-0: rgb(20,20,20)
2 | $grey-1: rgb(56,56,56)
3 | $grey-2: rgb(60,60,60)
4 | $grey-3: rgb(66,66,66)
5 | $grey-4: rgb(74,74,74)
6 | $grey-5: rgb(92,92,92)
7 | $grey-6: rgb(138,138,138)
8 | $grey-7: rgb(179,179,179)
9 | $grey-8: rgb(219,219,219)
10 | $grey-9: rgb(255,255,255)
11 | $primary-0: rgb(0,15.372,32.13)
12 | $primary-1: rgb(0,20.984,43.86)
13 | $primary-2: rgb(0,37.82,79.05)
14 | $primary-3: rgb(0,60.268,125.97)
15 | $primary-4: rgb(0,88.328,184.62)
16 | $primary-5: rgb(0,122,255)
17 | $primary-6: rgb(51,148.6,255)
18 | $primary-7: rgb(102,175.2,255)
19 | $primary-8: rgb(153,201.8,255)
20 | $primary-9: rgb(204,228.4,255)
21 | $green-0: rgb(12.3923,25.1977,5.23231)
22 | $green-1: rgb(16.4769,33.5031,6.95692)
23 | $green-2: rgb(28.7308,58.4192,12.1308)
24 | $green-3: rgb(45.0692,91.6408,19.0292)
25 | $green-4: rgb(65.4923,133.168,27.6523)
26 | $green-5: rgb(90,183,38)
27 | $green-6: rgb(123,197.4,81.4)
28 | $green-7: rgb(156,211.8,124.8)
29 | $green-8: rgb(189,226.2,168.2)
30 | $green-9: rgb(222,240.6,211.6)
31 | $red-0: rgb(29.81,5.45,4.87)
32 | $red-1: rgb(40.82,8.9,8.14)
33 | $red-2: rgb(73.85,19.25,17.95)
34 | $red-3: rgb(117.89,33.05,31.03)
35 | $red-4: rgb(172.94,50.3,47.38)
36 | $red-5: rgb(239,71,67)
37 | $red-6: rgb(242.2,107.8,104.6)
38 | $red-7: rgb(245.4,144.6,142.2)
39 | $red-8: rgb(248.6,181.4,179.8)
40 | $red-9: rgb(251.8,218.2,217.4)
41 | $blue-0: rgb(3.25,15.41,32.13)
42 | $blue-1: rgb(6.5,22.2305,43.86)
43 | $blue-2: rgb(16.25,42.6921,79.05)
44 | $blue-3: rgb(29.25,69.9742,125.97)
45 | $blue-4: rgb(45.5,104.077,184.62)
46 | $blue-5: rgb(65,145,255)
47 | $blue-6: rgb(103,167,255)
48 | $blue-7: rgb(141,189,255)
49 | $blue-8: rgb(179,211,255)
50 | $blue-9: rgb(217,233,255)
51 | $gold-0: rgb(24.735,19.37,9.945)
52 | $gold-1: rgb(34.17,27.14,14.79)
53 | $gold-2: rgb(62.475,50.45,29.325)
54 | $gold-3: rgb(100.215,81.53,48.705)
55 | $gold-4: rgb(147.39,120.38,72.93)
56 | $gold-5: rgb(204,167,102)
57 | $gold-6: rgb(214.2,184.6,132.6)
58 | $gold-7: rgb(224.4,202.2,163.2)
59 | $gold-8: rgb(234.6,219.8,193.8)
60 | $gold-9: rgb(244.8,237.4,224.4)
61 | $yellow-0: rgb(32.13,19.5095,1.25)
62 | $yellow-1: rgb(43.86,26.9563,2.5)
63 | $yellow-2: rgb(79.05,49.297,6.25)
64 | $yellow-3: rgb(125.97,79.0844,11.25)
65 | $yellow-4: rgb(184.62,116.319,17.5)
66 | $yellow-5: rgb(255,161,25)
67 | $yellow-6: rgb(255,179.8,71)
68 | $yellow-7: rgb(255,198.6,117)
69 | $yellow-8: rgb(255,217.4,163)
70 | $yellow-9: rgb(255,236.2,209)
71 | $light-grey-0: rgb(255,255,255)
72 | $light-grey-1: rgb(247,247,247)
73 | $light-grey-2: rgb(240,240,240)
74 | $light-grey-3: rgb(229,229,229)
75 | $light-grey-4: rgb(223,223,223)
76 | $light-grey-5: rgb(191,191,191)
77 | $light-grey-6: rgb(140,140,140)
78 | $light-grey-7: rgb(89,89,89)
79 | $light-grey-8: rgb(38,38,38)
80 | $light-grey-9: rgb(0,0,0)
81 | $light-primary-0: rgb(242.25,248.35,255)
82 | $light-primary-1: rgb(204,228.4,255)
83 | $light-primary-2: rgb(153,201.8,255)
84 | $light-primary-3: rgb(102,175.2,255)
85 | $light-primary-4: rgb(51,148.6,255)
86 | $light-primary-5: rgb(0,122,255)
87 | $light-primary-6: rgb(0,97.6,204)
88 | $light-primary-7: rgb(0,73.2,153)
89 | $light-primary-8: rgb(0,48.8,102)
90 | $light-primary-9: rgb(0,30.256,63.24)
91 | $light-green-0: rgb(246.75,251.4,244.15)
92 | $light-green-1: rgb(222,240.6,211.6)
93 | $light-green-2: rgb(189,226.2,168.2)
94 | $light-green-3: rgb(156,211.8,124.8)
95 | $light-green-4: rgb(123,197.4,81.4)
96 | $light-green-5: rgb(90,183,38)
97 | $light-green-6: rgb(72.6923,147.808,30.6923)
98 | $light-green-7: rgb(55.3846,112.615,23.3846)
99 | $light-green-8: rgb(38.0769,77.4231,16.0769)
100 | $light-green-9: rgb(24.9231,50.6769,10.5231)
101 | $light-red-0: rgb(254.2,245.8,245.6)
102 | $light-red-1: rgb(251.8,218.2,217.4)
103 | $light-red-2: rgb(248.6,181.4,179.8)
104 | $light-red-3: rgb(245.4,144.6,142.2)
105 | $light-red-4: rgb(242.2,107.8,104.6)
106 | $light-red-5: rgb(239,71,67)
107 | $light-red-6: rgb(191,54.5,51.25)
108 | $light-red-7: rgb(143,38,35.5)
109 | $light-red-8: rgb(95,21.5,19.75)
110 | $light-red-9: rgb(58.52,8.96,7.78)
111 | $light-blue-0: rgb(245.5,249.5,255)
112 | $light-blue-1: rgb(217,233,255)
113 | $light-blue-2: rgb(179,211,255)
114 | $light-blue-3: rgb(141,189,255)
115 | $light-blue-4: rgb(103,167,255)
116 | $light-blue-5: rgb(65,145,255)
117 | $light-blue-6: rgb(48.75,114.118,204)
118 | $light-blue-7: rgb(32.5,83.2368,153)
119 | $light-blue-8: rgb(16.25,52.3553,102)
120 | $light-blue-9: rgb(3.9,28.8853,63.24)
121 | $light-gold-0: rgb(252.45,250.6,247.35)
122 | $light-gold-1: rgb(244.8,237.4,224.4)
123 | $light-gold-2: rgb(234.6,219.8,193.8)
124 | $light-gold-3: rgb(224.4,202.2,163.2)
125 | $light-gold-4: rgb(214.2,184.6,132.6)
126 | $light-gold-5: rgb(204,167,102)
127 | $light-gold-6: rgb(162.563,132.5,79.6875)
128 | $light-gold-7: rgb(121.125,98,57.375)
129 | $light-gold-8: rgb(79.6875,63.5,35.0625)
130 | $light-gold-9: rgb(48.195,37.28,18.105)
131 | $light-yellow-0: rgb(255,250.3,243.5)
132 | $light-yellow-1: rgb(255,236.2,209)
133 | $light-yellow-2: rgb(255,217.4,163)
134 | $light-yellow-3: rgb(255,198.6,117)
135 | $light-yellow-4: rgb(255,179.8,71)
136 | $light-yellow-5: rgb(255,161,25)
137 | $light-yellow-6: rgb(204,128.289,18.75)
138 | $light-yellow-7: rgb(153,95.5783,12.5)
139 | $light-yellow-8: rgb(102,62.8674,6.25)
140 | $light-yellow-9: rgb(63.24,38.0071,1.5)
141 | $brand-orange: rgb(255,161,22)
142 | $brand-black: rgb(0,0,0)
143 | $dsw-black: rgb(255,255,255)
144 | $dsw-black-light: rgb(0,0,0)
145 | $dsw-white: rgb(0,0,0)
146 | $dsw-white-light: rgb(255,255,255)
147 | $dsw-base: rgb(255,255,255)
148 | $dsw-base-light: rgb(33,40,53)
149 | $dsw-paper: rgb(24,26,31)
150 | $dsw-paper-light: rgb(255,255,255)
151 | $dsw-brand-base: rgb(255,255,255)
152 | $dsw-brand-base-light: rgb(0,0,0)
153 | $dsw-brand-orange: rgb(255,161,22)
154 | $dsw-brand-orange-light: rgb(255,161,22)
155 | $dsw-brand-black: rgb(0,0,0)
156 | $dsw-brand-black-light: rgb(0,0,0)
157 | $dsw-brand-grey: rgb(179,179,179)
158 | $dsw-brand-grey-light: rgb(179,179,179)
159 | $dsw-base-grey-1: rgb(56,56,56)
160 | $dsw-base-grey-1-light: rgb(247,247,247)
161 | $dsw-base-grey-2: rgb(60,60,60)
162 | $dsw-base-grey-2-light: rgb(240,240,240)
163 | $dsw-base-grey-3: rgb(66,66,66)
164 | $dsw-base-grey-3-light: rgb(229,229,229)
165 | $dsw-base-grey-4: rgb(74,74,74)
166 | $dsw-base-grey-4-light: rgb(223,223,223)
167 | $dsw-base-grey-5: rgb(92,92,92)
168 | $dsw-base-grey-5-light: rgb(191,191,191)
169 | $dsw-base-grey-6: rgb(138,138,138)
170 | $dsw-base-grey-6-light: rgb(140,140,140)
171 | $dsw-base-grey-7: rgb(179,179,179)
172 | $dsw-base-grey-7-light: rgb(89,89,89)
173 | $dsw-base-grey-8: rgb(219,219,219)
174 | $dsw-base-grey-8-light: rgb(38,38,38)
175 | $dsw-pink: rgb(255,55,95)
176 | $dsw-pink-light: rgb(255,45,85)
177 | $dsw-purple: rgb(191,90,242)
178 | $dsw-purple-light: rgb(175,82,222)
179 | $dsw-olive: rgb(0,184,163)
180 | $dsw-olive-light: rgb(0,175,155)
181 | $dsw-teal: rgb(100,210,255)
182 | $dsw-teal-light: rgb(90,200,250)
183 | $dsw-yellow: rgb(255,192,30)
184 | $dsw-yellow-light: rgb(255,184,0)
185 | $dsw-red-standard: rgb(239,71,67)
186 | $dsw-red-standard-light: rgb(239,71,67)
187 | $dsw-red-4: rgb(243.8,126.2,123.4)
188 | $dsw-red-4-light: rgb(167.3,49.7,46.9)
189 | $dsw-red-3: rgb(241.4,98.6,95.2)
190 | $dsw-red-3-light: rgb(203.15,60.35,56.95)
191 | $dsw-red-2: rgba(239,71,67,0.5)
192 | $dsw-red-2-light: rgba(239,71,67,0.3)
193 | $dsw-red-1: rgba(239,71,67,0.25)
194 | $dsw-red-1-light: rgba(239,71,67,0.15)
195 | $dsw-red-0: rgba(239,71,67,0.18)
196 | $dsw-red-0-light: rgba(239,71,67,0.08)
197 | $dsw-green-standard: rgb(44,187,93)
198 | $dsw-green-standard-light: rgb(45,181,93)
199 | $dsw-green-4: rgb(107.3,207.4,141.6)
200 | $dsw-green-4-light: rgb(31.5,126.7,65.1)
201 | $dsw-green-3: rgb(75.65,197.2,117.3)
202 | $dsw-green-3-light: rgb(38.25,153.85,79.05)
203 | $dsw-green-2: rgba(44,187,93,0.5)
204 | $dsw-green-2-light: rgba(45,181,93,0.3)
205 | $dsw-green-1: rgba(44,187,93,0.25)
206 | $dsw-green-1-light: rgba(45,181,93,0.15)
207 | $dsw-green-0: rgba(44,187,93,0.18)
208 | $dsw-green-0-light: rgba(45,181,93,0.08)
209 | $dsw-blue-standard: rgb(10,132,255)
210 | $dsw-blue-standard-light: rgb(0,122,255)
211 | $dsw-blue-4: rgb(83.5,168.9,255)
212 | $dsw-blue-4-light: rgb(0,85.4,178.5)
213 | $dsw-blue-3: rgb(46.75,150.45,255)
214 | $dsw-blue-3-light: rgb(0,103.7,216.75)
215 | $dsw-blue-2: rgba(10,132,255,0.5)
216 | $dsw-blue-2-light: rgba(0,122,255,0.3)
217 | $dsw-blue-1: rgba(10,132,255,0.25)
218 | $dsw-blue-1-light: rgba(0,122,255,0.15)
219 | $dsw-blue-0: rgba(10,132,255,0.18)
220 | $dsw-blue-0-light: rgba(0,122,255,0.08)
221 | $dsw-primary-standard: rgb(10,132,255)
222 | $dsw-primary-standard-light: rgb(0,122,255)
223 | $dsw-primary-4: rgb(83.5,168.9,255)
224 | $dsw-primary-4-light: rgb(0,85.4,178.5)
225 | $dsw-primary-3: rgb(46.75,150.45,255)
226 | $dsw-primary-3-light: rgb(0,103.7,216.75)
227 | $dsw-primary-2: rgba(10,132,255,0.5)
228 | $dsw-primary-2-light: rgba(0,122,255,0.3)
229 | $dsw-primary-1: rgba(10,132,255,0.25)
230 | $dsw-primary-1-light: rgba(0,122,255,0.15)
231 | $dsw-primary-0: rgba(10,132,255,0.18)
232 | $dsw-primary-0-light: rgba(0,122,255,0.08)
233 | $dsw-label-primary: rgb(255,255,255)
234 | $dsw-label-primary-light: rgb(38,38,38)
235 | $dsw-label-primary-reverse: rgb(38,38,38)
236 | $dsw-label-primary-reverse-light: rgb(255,255,255)
237 | $dsw-label-secondary: rgba(239,241,246,0.75)
238 | $dsw-label-secondary-light: rgba(38,38,38,0.75)
239 | $dsw-label-tertiary: rgba(239,242,246,0.6)
240 | $dsw-label-tertiary-light: rgba(60,60,67,0.6)
241 | $dsw-label-quaternary: rgba(235,235,245,0.3)
242 | $dsw-label-quaternary-light: rgba(60,60,67,0.3)
243 | $dsw-fill-primary: rgba(255,255,255,0.17)
244 | $dsw-fill-primary-light: rgba(0,10,32,0.12)
245 | $dsw-fill-secondary: rgba(255,255,255,0.14)
246 | $dsw-fill-secondary-light: rgba(0,10,32,0.1)
247 | $dsw-fill-tertiary: rgba(255,255,255,0.1)
248 | $dsw-fill-tertiary-light: rgba(0,10,32,0.05)
249 | $dsw-fill-quaternary: rgba(255,255,255,0.07)
250 | $dsw-fill-quaternary-light: rgba(0,10,32,0.03)
251 | $dsw-border: rgba(247,250,255,0.24)
252 | $dsw-border-light: rgba(0,10,32,0.14)
253 | $dsw-border-1: rgba(247,250,255,0.24)
254 | $dsw-border-1-light: rgba(0,10,32,0.14)
255 | $dsw-border-2: rgba(247,250,255,0.18)
256 | $dsw-border-2-light: rgba(0,10,32,0.11)
257 | $dsw-border-3: rgba(247,250,255,0.1)
258 | $dsw-border-3-light: rgba(0,10,32,0.05)
259 | $dsw-divider-primary: rgb(36,36,36)
260 | $dsw-divider-primary-light: rgb(229,229,229)
261 | $dsw-divider-secondary: rgb(61,61,61)
262 | $dsw-divider-secondary-light: rgb(240,240,240)
263 | $dsw-success: rgb(44,187,93)
264 | $dsw-success-light: rgb(45,181,93)
265 | $dsw-danger: rgb(239,71,67)
266 | $dsw-danger-light: rgb(239,71,67)
267 | $dsw-warning: rgb(255,192,30)
268 | $dsw-warning-light: rgb(255,184,0)
269 | $dsw-difficulty-easy: rgb(0,184,163)
270 | $dsw-difficulty-easy-light: rgb(0,175,155)
271 | $dsw-difficulty-medium: rgb(255,192,30)
272 | $dsw-difficulty-medium-light: rgb(255,184,0)
273 | $dsw-difficulty-hard: rgb(255,55,95)
274 | $dsw-difficulty-hard-light: rgb(255,45,85)
275 | $dsw-data-olive-standard: rgb(67,233,118)
276 | $dsw-data-olive-standard-light: rgb(37,227,95)
277 | $dsw-data-olive-tinted: rgb(0,223,210)
278 | $dsw-data-olive-tinted-light: rgb(65,241,230)
279 | $dsw-data-yellow-standard: rgb(255,192,30)
280 | $dsw-data-yellow-standard-light: rgb(255,199,0)
281 | $dsw-data-yellow-tinted: rgb(240,242,108)
282 | $dsw-data-yellow-tinted-light: rgb(254,248,110)
283 | $dsw-data-pink-standard: rgb(234,16,94)
284 | $dsw-data-pink-standard-light: rgb(246,67,99)
285 | $dsw-data-pink-tinted: rgb(239,59,178)
286 | $dsw-data-pink-tinted-light: rgb(255,116,208)
287 | $dsw-overlay: rgba(0,0,0,0.5)
288 | $dsw-overlay-light: rgba(0,0,0,0.5)
289 | $dsw-overlay-1: rgb(26,26,26)
290 | $dsw-overlay-1-light: rgb(247,248,250)
291 | $dsw-overlay-2: rgb(40,40,40)
292 | $dsw-overlay-2-light: rgb(255,255,255)
293 | $dsw-overlay-3: rgb(48,48,48)
294 | $dsw-overlay-3-light: rgb(255,255,255)
295 | $dsw-overlay-4: rgb(54,54,54)
296 | $dsw-overlay-4-light: rgb(255,255,255)
297 | $dsw-layer-bg: rgb(26,26,26)
298 | $dsw-layer-bg-light: rgb(247,248,250)
299 | $dsw-layer-1: rgb(40,40,40)
300 | $dsw-layer-1-light: rgb(255,255,255)
301 | $dsw-layer-2: rgb(48,48,48)
302 | $dsw-layer-2-light: rgb(255,255,255)
303 | $dsw-layer-3: rgb(54,54,54)
304 | $dsw-layer-3-light: rgb(255,255,255)
305 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_description.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | [data-cy="description-content"]
4 | [class*="question-content"]
5 | color: colors.$grey-8
6 | & ~ div > div:nth-child(2)
7 | color: colors.$grey-8
8 |
9 | [class*="question-content"] + div > div > div > div:nth-child(2)
10 | color: colors.$grey-8
11 |
12 | [data-cy="question-title"],
13 | [data-cy="question-title"] ~ div
14 | color: colors.$grey-8
15 |
16 | [class*="css-"]
17 | border-color: colors.$grey-3
18 |
19 | pre, code
20 | background-color: colors.$dsw-fill-tertiary
21 | pre
22 | color: colors.$grey-9
23 | code
24 | color: colors.$grey-7
25 |
26 | button
27 | transition: .2s all
28 | &:hover
29 | color: inherit !important
30 | opacity: .8
31 |
32 | // Menu part (e.g. "Related Topics", "Show Hint")
33 | [class*="description"]
34 | =text-and-icon-except-lock-icon
35 | &, svg:not([class*="lock-"])
36 | @content
37 |
38 | [class*="header"]
39 | border-color: colors.$grey-2
40 | ~ [class*="css-"]
41 | background: transparent
42 | > [class*="css-"]:first-child
43 | +text-and-icon-except-lock-icon
44 | color: colors.$grey-6
45 | &:hover
46 | +text-and-icon-except-lock-icon
47 | color: colors.$grey-9
48 |
49 | [class*="topic-tag"] > span
50 | color: colors.$grey-8
51 | background-color: colors.$dsw-fill-tertiary
52 |
53 | [class*="question"] [class*="title"]
54 | color: colors.$grey-7
55 |
56 | .css-dgkgi6
57 | color: unset
58 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_editor.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | .CodeMirror-gutters
4 | background-color: colors.$dsw-paper !important
5 |
6 | [class*="resize-bar__"]
7 | color: colors.$grey-5
8 | background: colors.$dsw-layer-2
9 | &::before
10 | background: colors.$grey-4
11 |
12 | [class*="code-area"]
13 | // Top and left border of the code area
14 | > [class*="content__"] > [class*="wrapper__"] > [class*="container__"],
15 | > [class*="result__"]
16 | border-color: colors.$grey-2
17 | box-shadow: inset 0 -1px colors.$grey-2
18 |
19 | // Toolbar
20 | > [class*="content__"] > [class*="wrapper__"] > div:first-child
21 | background-color: colors.$dsw-layer-2
22 |
23 | // Right icon buttons
24 | button[class*="tool-button"]
25 | color: colors.$grey-6
26 | &:hover
27 | color: colors.$grey-5
28 |
29 | // "Autocomplete" button
30 | button[type="ghost"]
31 | & > [class*="-StyledSpan"]
32 | color: colors.$grey-6
33 | &:hover
34 | background-color: rgba(colors.$grey-9, 0.04)
35 |
36 | // Expanded result
37 | > [class*="result__"] > [class*="panel__"] > [class*="header__"]
38 | color: colors.$grey-9
39 | background-color: colors.$dsw-layer-1
40 | border-color: colors.$grey-2
41 | .reset-code-btn
42 | color: rgb(132,136,142) !important
43 |
44 | // Test case textarea
45 | #testcase-editor
46 | color: colors.$grey-9
47 | background-color: transparent
48 | [class*='editor-container']
49 | background-color: colors.$grey-0
50 | border-color: colors.$grey-3
51 | .ace_cursor
52 | color: colors.$grey-9
53 | // Selected line of tree visualizer
54 | .viewer-data-marker
55 | background-color: colors.$grey-1
56 |
57 | [data-key="runcode-result-content"]
58 | // Skeleton screen of "Run code result"
59 | svg rect
60 | fill: colors.$grey-1 !important
61 | [data-cy="run-code-finished"]
62 | [class*='-ValueContainer']
63 | background-color: colors.$grey-1
64 | border-color: colors.$grey-3
65 | color: colors.$grey-9
66 | del
67 | background: colors.$red-4 !important
68 | ins
69 | background: colors.$green-4 !important
70 |
71 | // footer
72 | > [class*="container__"]
73 | background-color: colors.$dsw-layer-2
74 | border-top-color: colors.$grey-4
75 | button
76 | color: colors.$grey-8 !important
77 | [class*="contribute__"]
78 | color: colors.$grey-6
79 | button[data-cy="run-code-btn"]
80 | &:hover, &:disabled
81 | background-color: inherit !important
82 | border-color: inherit !important
83 | opacity: .8
84 | &:disabled
85 | opacity: .3
86 |
87 | // "Your previous code was restored from your local storage." buttons
88 | [class*="reset-code-btn__"],
89 | [class*="header__"] > [class*="icon-wrapper"]
90 | color: colors.$grey-6
91 | &:hover
92 | color: colors.$grey-8 !important
93 |
94 | // Tree visualizer
95 | [class*="visualizer__"]
96 | background: colors.$grey-0
97 | [class*="data-structure-viewer"]
98 | circle
99 | fill: colors.$grey-0
100 | stroke: colors.$grey-6
101 | line
102 | stroke: colors.$grey-5
103 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_error-container.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | [class*="ErrorContainer"]
4 | background-color: colors.$red-1
5 | [class*="Error"],
6 | [class*="CollapseTrigger"]
7 | color: colors.$red-5
8 | [class*="Mask"]
9 | background: linear-gradient(rgba(colors.$grey-0, 0), rgba(colors.$grey-0, 0.75))
10 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_loading.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | #initial-loading
4 | background-color: colors.$dsw-overlay-1 !important
5 | .spinner > .bounce
6 | background-color: colors.$grey-2
7 |
8 | [data-cy="question-loading-container"]
9 | background: colors.$dsw-overlay-1 !important
10 | [class*="wrapper"]
11 | color: colors.$grey-2
12 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_logo.sass:
--------------------------------------------------------------------------------
1 | $logo: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEsAAABYCAYAAAC9DArfAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAaVSURBVHgB7ZzPb9xEFMe/46SoN4pEQi8I8xc0e+TUjSpxq7J74QbZ/Qdoc+CI6pyp1PTKZdNy6mlTCkKCQ7aHcuGQrYTEBbSGXmiD6AqBQEnx4721TTdJt+s3Y+8POR/JWmvjdeyPx+OZN28MzDhEdIWXXYp5mqyv4JSjsJQWjeYaTokZIyqlirIjpYaysYMyoxAl9FBWlKLKK8tClLCLssEnvU52rKNM8AnXyI4AZYJPeIXihqaWLZQJPmHfUtQ2ykQiqkd69lAmXETxcg5lwUFUT36LsiClgk5FjScRtUd6erMkysNkaPGii0H9+Qj48t0Ott9YodY5HzOAQcFwyRBRDc1vcPAH8HUd+P374W+7vLctRIf3TbMfYgoUKoviAF0ALV9cOi7qKIbbWv8ebk5aWmG3obWoBx++XNRg56YB75UefbY80WhpISXLWtR3HwM/fAolIaKD1UmUstxLlrWoh9dtRAk+l7Jdai0VPoiRqyyKQyYBtIioh5/AARZm9oq+LXO7DVlUjT/a0OIu6iiGtswH+xsogFxkUTyOJ1FLXd9Nbjupp/Kny/VYPe96zPk2pF7bTtRPd4oSJazE9Vi+jVmnkpV0RUSUr/qhNA2kLSVPMtBd/uwhin7mw+kP/u6ZC7xe4bWL6n0fJzIbpvk4l2ChtSwnUd+818Hf+9yo/K0zbnNqvV7FgncFhBps8RCY959swhErWdaipCR1rzdN5aMOlHDTgBuiuMGHbBnTog53lZou9ZhaFsUBOIlY+tAR8rJqjAlhCbXOcxMhsrlIz4/BoQGrquATUXYlylGUYJq/8sl6q3wgHdghDdg9urVkNYSmKlkUD2pWoSNEDqJOHEtrOeBLbd8ItajHMsuyCrUA8nQTUV0UAFf+V+F5N2BLFK1mecikZLoNWZQcUAN6ChMl8IlyfMt7G3Hp1eMZVckcKyvpGF+FnmaRolL+r8dAFv/LVDVbv1SWdQQB2GBR25gQIsys71cQ0U0UyEhZDqI2WdRUhtdNc/8q10OaTrSqNL5QlnWoJRYVYIrE9RhJVykcv7UuZ+LE09A61DIDoobJ0IDtmvUnFSg4IsuhG3OTRdk8BAqHu0lbfP+sP+8mUZ871zdN80kAF8huxHgbc4CEnXML2fBJN0hPubJaUixKVbmyWlIsSlWvlKIE0iVt9KhMWS3DUJwzdSoqA9IorSq2D/IOtcwTi7ysZdw2ZFG3kAPtdru6uLgo3akqZpc+n+/O4eHhZr1eD+ULKVl+xh/fRQ7cu3fvGouyCSJOGknAa8ix8sX15QuNLOcZWMk/DTBf+MnFHcjK2gTow5GFhYUA84nPd0RVM2DhLIvrgAuYU6IoquaaRTOOeW/Iiqww47ZVOMKy7mN+2dPIck4W8zxvG/NJuLa2dldkPcz4g3XX2+jy5csd/nDOOZgw/WfPnq3KisjKGocWUc4BPhYWsPQmlPHvKSAPtB0WVUkbpSYpLU8VO6jk2eVJG3yzRiroBPT8jRxZKGccK4VPvko6Si+sRzrKK4zsJnaXWtgu6SltLN52kncphL1oRLqBeH6gFmk31WclkspjhHzxztT4DC8OvjDouE6/e2EyG8UvmLDJqgtRQJaflsE8Hs9ICoJ/7E8hImzwaLRVbG5k5t+8CotFSQrCS7KaI6qY5r66BzEyRJMkedj043xedmkKo0CZRAmDFHE9Y3NK56WEZRaVEh28xvWXKqA5Nvg3DyVMLWrAWfXTO1OkdJaF2YkS/lGHyTOHlWdRmLUowrb2FhRUMfhZEmZfoqgP8qwCkOoBi1kQ5iQq4oeOpINb4DKFLsAUnpLuovTtqxTrobCkhDWhx4eUsM8v1aCEWsu1aYkSnMYNk4kBdsLOv9Om20utLHmeg3zQ28ttPtr2tEQJeU0ob0Db+X70FZevRrqHDiSXIqKhkSbvHDx6i1dq2mkjQ0eWmyhhETkgJYyFyWp2YWdeHd5DFTKI6x2/di7XMl9RQm7D9+pb8q9fUBz5ixJyzXVQCfvxDoqhGFFC7okhiTCZ5jG6hSwvv3j8LfKnOFFCIVk0yTzDVRwc61LIS8TkVSrFvPwiLFKUkMvTcBSDyUZvVndxdskfiHr8IBaWP11EXt22ZZ6VQmUJSSw84CfdFRRBhE3nSUsZKVxWSjKlLeBVq9cEnNwht81ooVl0aRpmYrJShqRZvGcmnv7GnztF1k2jmLisYQbvmZGX9JA0Snnc0Rj/6AYkD4guH2VXWvea1woUwX8DS2Flo786hQAAAABJRU5ErkJggg==')
2 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_navbar.sass:
--------------------------------------------------------------------------------
1 | @use 'logo' as *
2 | @use 'colors'
3 |
4 | #leetcode-navbar
5 | background-color: colors.$dsw-layer-1
6 | border-bottom-color: colors.$grey-2
7 | ul:not(#mobile-nav-links, #mobile-nav-links ~ *)
8 | li:not([role="none"], [role="menuitem"])
9 | > a, #headlessui-menu-button-1
10 | color: colors.$grey-8 !important
11 |
12 | .ant-dropdown-link
13 | svg
14 | fill: colors.$grey-8 !important
15 |
16 | // "Daily Challenge" icon default color
17 | .popover-wrapper a
18 | .text-text-secondary, .text-text-secondary
19 | color: colors.$grey-8
20 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_question-fast-picker.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | [class*="question-fast-picker-wrapper"]
4 | background-color: colors.$dsw-layer-2
5 | border-top-color: colors.$grey-4
6 | button
7 | background-color: transparent !important
8 | color: colors.$grey-8 !important
9 | box-shadow: inset 0 0 0 1px colors.$grey-4 !important
10 | [class*="shuffle-icon"],
11 | [class*="handler-icon"],
12 | [class*="handler-text"]
13 | color: colors.$grey-8 !important
14 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_solution.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | #solution
4 | color: colors.$grey-8
5 | hr
6 | background-color: colors.$grey-2
7 | [class*="nav__"]
8 | border-color: colors.$grey-2
9 | h2, h3, h4
10 | color: colors.$grey-9
11 | *:not(pre) > code
12 | color: colors.$grey-7
13 | background-color: colors.$dsw-fill-quaternary
14 | border-color: colors.$grey-2
15 | .discuss-markdown-container
16 | color: colors.$grey-7
17 | [class*="action__"]
18 | &, & svg
19 | color: colors.$grey-6
20 | transition: none
21 | &:hover
22 | &, & svg
23 | color: colors.$grey-5 !important
24 | [class*="read-more__"]
25 | background: linear-gradient(transparent, colors.$dsw-paper)
26 | [class*="pagination-container__"]
27 | .ant-pagination-item,
28 | .ant-pagination-prev .ant-pagination-item-link,
29 | .ant-pagination-next .ant-pagination-item-link
30 | color: colors.$grey-7
31 | background-color: colors.$grey-0
32 | box-shadow: inset 0 0 0 1px rgba(colors.$grey-8,0.2)
33 | .ant-pagination-item-active
34 | color: colors.$primary-4
35 | [class*="loading-cover__"]
36 | background-color: colors.$dsw-paper
37 |
38 | [class*="header__"]
39 | background-color: rgb(40, 42, 46) !important
40 | border-color: colors.$grey-2
41 | label
42 | color: colors.$grey-8 !important
43 |
44 | [class*="comment__"],
45 | [class*="action-area__"]
46 | border-color: colors.$grey-3
47 | textarea
48 | background-color: transparent
49 | blockquote
50 | border: 0
51 | border-left: 4px solid colors.$grey-4
52 | color: colors.$grey-6
53 | background-color: transparent
54 | border-radius: 0
55 | padding-top: 8px
56 | padding-bottom: 8px
57 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_submission.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | [class*="submissions"]
4 | [class*="result-container__"]
5 | border-color: colors.$grey-2
6 | [class*="container"], span
7 | [class*="info__"], span
8 | color: colors.$grey-8
9 | [class*="container__"] rect
10 | fill: colors.$grey-1 !important
11 | [class*='addl-success-info']
12 | color: colors.$grey-8
13 | a[class*="detail__"]
14 | color: colors.$grey-6
15 |
16 | // "Last executed input" (when runtime error)
17 | [class*="-Field"]
18 | color: colors.$grey-7 !important
19 | // Code block
20 | & + div
21 | border-color: colors.$grey-3
22 | background-color: colors.$grey-1
23 | [class*="-Value"]
24 | color: colors.$grey-9
25 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/_submit-table.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | .ant-table-body
4 | table
5 | th, td
6 | border-color: colors.$grey-3 !important
7 | th
8 | color: white !important
9 | background: black !important
10 | tr:nth-child(2n) > td
11 | color: colors.$grey-7 !important
12 | background: black !important
13 | tr:nth-child(2n + 1) > td
14 | color: colors.$grey-7 !important
15 | background: colors.$dsw-paper !important
16 |
17 | // "No Data" placeholder
18 | .ant-table-placeholder
19 | background-color: colors.$dsw-base-grey-1
20 | border-color: colors.$grey-3
21 | .ant-empty-normal
22 | color: colors.$grey-6
23 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/dark-theme/style.module.sass:
--------------------------------------------------------------------------------
1 | @use 'colors'
2 |
3 | @use 'loading'
4 | @use 'description'
5 | @use 'editor'
6 | @use 'submission'
7 | @use 'submit-table'
8 | @use 'solution'
9 | @use 'question-fast-picker'
10 | @use 'navbar'
11 | @use 'error-container'
12 | @use 'ant-modal'
13 | @use 'ant-select'
14 | @use 'ant-select-dropdown'
15 |
16 | body
17 | color: colors.$grey-8
18 | background-color: colors.$dsw-layer-1
19 |
20 | [class*="TabViewHeader"]
21 | background: colors.$dsw-layer-2
22 |
23 | // Bottom divider
24 | &::after
25 | border-bottom-color: colors.$grey-2
26 |
27 | [class*="TabHeaderContainer"]
28 | color: colors.$grey-6
29 | background-color: colors.$dsw-layer-2
30 |
31 | [data-cy="description"],
32 | [data-cy="solution"],
33 | [data-cy="discuss"],
34 | [data-cy="submissions"],
35 | [data-cy="custom-testcase"],
36 | [data-cy="runcode-result"],
37 | [data-cy="debugger"]
38 | color: inherit
39 | background-color: transparent
40 |
41 | // Short divider
42 | &::before, &::after
43 | background: colors.$grey-2
44 |
45 | // Active tab
46 | // HACK: might be unstable
47 | &.css-19j86kk-TabHeader,
48 | &.css-qndcpp-TabHeader
49 | color: colors.$grey-8
50 | background-color: colors.$dsw-layer-1
51 |
52 | [class*="tab-pane__"]
53 | border-color: colors.$grey-2
54 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/hide-logo-2023/style.module.sass:
--------------------------------------------------------------------------------
1 | nav
2 | ul > a[href="/"]
3 | width: 0
4 | margin: 0
5 | opacity: 0
6 | transform: scale(0)
7 | transition: all .2s
8 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/hide-logo/style.module.sass:
--------------------------------------------------------------------------------
1 | #leetcode-navbar
2 | div:nth-child(1) > a[href="/"]
3 | width: 0
4 | margin: 0
5 | opacity: 0
6 | transform: scale(0)
7 | transition: all .2s
8 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngseke/leetcode-night/35f1b4a36475497769a15704b841b64be34f1ba2/src/pages/Content/styles/icon.png
--------------------------------------------------------------------------------
/src/pages/Content/styles/invert-image-2023/style.module.sass:
--------------------------------------------------------------------------------
1 | [data-track-load='description_content']
2 | img:not([alt="company"])
3 | filter: invert(1)
4 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/invert-image/style.module.sass:
--------------------------------------------------------------------------------
1 | [data-cy="description-content"], #solution
2 | img
3 | filter: invert(1)
4 |
--------------------------------------------------------------------------------
/src/pages/Content/styles/loading-mascot/style.module.sass:
--------------------------------------------------------------------------------
1 | @use 'cat' as *
2 |
3 | #initial-loading
4 | &::after
5 | $scale: .4
6 | content: ''
7 | background-image: $cat
8 | background-size: contain
9 | background-repeat: no-repeat
10 | position: fixed
11 | width: 400px * $scale
12 | height: 400px * $scale
13 | right: 0
14 | bottom: 0
15 |
--------------------------------------------------------------------------------
/src/pages/Content/toggleEnabled.ts:
--------------------------------------------------------------------------------
1 | import styles from './styles/dark-theme/style.module.sass?inline'
2 | import { StyleInjector } from './StyleInjector'
3 |
4 | const styleInjector = new StyleInjector(styles)
5 |
6 | export const toggleEnabled = (value: boolean) => styleInjector.toggle(value)
7 |
--------------------------------------------------------------------------------
/src/pages/Content/toggleEnabledMascot.ts:
--------------------------------------------------------------------------------
1 | import styles from './styles/loading-mascot/style.module.sass?inline'
2 | import { StyleInjector } from './StyleInjector'
3 |
4 | const styleInjector = new StyleInjector(styles)
5 |
6 | export const toggleEnabledMascot = (value: boolean) => styleInjector.toggle(value)
7 |
--------------------------------------------------------------------------------
/src/pages/Content/toggleHideLogo.ts:
--------------------------------------------------------------------------------
1 | import styles from './styles/hide-logo/style.module.sass?inline'
2 | import { StyleInjector } from './StyleInjector'
3 |
4 | const styleInjector = new StyleInjector(styles)
5 |
6 | export const toggleHideLogo = (value: boolean) => styleInjector.toggle(value)
7 |
--------------------------------------------------------------------------------
/src/pages/Content/toggleHideLogo2023.ts:
--------------------------------------------------------------------------------
1 | import styles from './styles/hide-logo-2023/style.module.sass?inline'
2 | import { StyleInjector } from './StyleInjector'
3 |
4 | const styleInjector = new StyleInjector(styles)
5 |
6 | export const toggleHideLogo2023 = (value: boolean) => styleInjector.toggle(value)
7 |
--------------------------------------------------------------------------------
/src/pages/Content/toggleInvertImageColor.ts:
--------------------------------------------------------------------------------
1 | import styles from './styles/invert-image/style.module.sass?inline'
2 | import { StyleInjector } from './StyleInjector'
3 |
4 | const styleInjector = new StyleInjector(styles)
5 |
6 | export const toggleInvertImageColor = (value: boolean) => styleInjector.toggle(value)
7 |
--------------------------------------------------------------------------------
/src/pages/Content/toggleInvertImageColor2023.ts:
--------------------------------------------------------------------------------
1 | import styles from './styles/invert-image-2023/style.module.sass?inline'
2 | import { StyleInjector } from './StyleInjector'
3 |
4 | const styleInjector = new StyleInjector(styles)
5 |
6 | export const toggleInvertImageColor2023 = (value: boolean) => styleInjector.toggle(value)
7 |
--------------------------------------------------------------------------------
/src/pages/Popup/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react'
2 | import { App } from './App'
3 | import { mockChrome } from '../../../__tests__/chrome'
4 | import { TEST_IDS } from '../../constants'
5 | import userEvent from '@testing-library/user-event'
6 | import * as useEnableDarkThemeHooks from './hooks/useEnableDarkTheme'
7 |
8 | describe('Popup app', () => {
9 | vi.spyOn(useEnableDarkThemeHooks, 'useEnableDarkTheme')
10 | .mockReturnValue([true, vi.fn()])
11 |
12 | async function clickQuestionsTabButton () {
13 | await userEvent.click(
14 | screen.getByTestId(TEST_IDS.questionsTabButton)
15 | )
16 | }
17 |
18 | async function clickOptionsTabButton () {
19 | await userEvent.click(
20 | screen.getByTestId(TEST_IDS.optionsTabButton)
21 | )
22 | }
23 |
24 | test('should switch to selected tab', async () => {
25 | mockChrome()
26 | render()
27 |
28 | await clickOptionsTabButton()
29 | expect(screen.getByTestId(TEST_IDS.optionsTab))
30 |
31 | await clickQuestionsTabButton()
32 | expect(screen.getByTestId(TEST_IDS.questionsTab))
33 |
34 | await clickOptionsTabButton()
35 | expect(screen.getByTestId(TEST_IDS.optionsTab))
36 |
37 | expect(console.error).toBeCalledTimes(0)
38 | expect(console.warn).toBeCalledTimes(0)
39 | })
40 |
41 | test('should remember the previously selected tab', async () => {
42 | mockChrome()
43 | const { rerender } = render()
44 |
45 | await clickOptionsTabButton()
46 | rerender()
47 | expect(screen.getByTestId(TEST_IDS.optionsTab))
48 |
49 | await clickQuestionsTabButton()
50 | rerender()
51 | expect(screen.getByTestId(TEST_IDS.questionsTab))
52 | })
53 | })
54 |
--------------------------------------------------------------------------------
/src/pages/Popup/App.tsx:
--------------------------------------------------------------------------------
1 | import { Footer } from './components/Footer'
2 | import { SearchQuestion } from './components/SearchQuestion'
3 | import { useTabs, Tabs } from './components/Tabs'
4 | import { Layout } from './components/Layout'
5 | import { Header } from './components/Header'
6 | import { Options } from './components/Options'
7 |
8 | export function App () {
9 | const { tab, setTab, isTabOptions, isTabQuestions } = useTabs()
10 |
11 | return (
12 |
14 |
15 |
16 | >}
17 | body={<>
18 | {isTabQuestions && }
19 | {isTabOptions && }
20 | >}
21 | footer={}
22 | />
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/BlockLink.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren } from 'react'
2 | import styled from 'styled-components'
3 | import { type Nullish } from '../types/Nullish'
4 | import { Link } from './Link'
5 |
6 | const Wrapper = styled.div({
7 | display: 'inline-block',
8 | position: 'relative',
9 | })
10 |
11 | const InvisibleLink = styled(Link)({
12 | display: 'block',
13 | position: 'absolute',
14 | top: 0,
15 | right: 0,
16 | bottom: 0,
17 | left: 0,
18 | zIndex: 2,
19 | })
20 |
21 | type BlockLinkProps = PropsWithChildren<{
22 | href: Nullish,
23 | 'data-testid'?: string,
24 | 'aria-label'?: string,
25 | }>
26 |
27 | export function BlockLink (props: BlockLinkProps) {
28 | const { children, href } = props
29 |
30 | return (
31 |
32 |
36 | {children}
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Chip.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren } from 'react'
2 |
3 | export function Chip ({ children }: PropsWithChildren>) {
4 | return (
5 |
6 | {children}
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/DailyChallengeQuestionCard.tsx:
--------------------------------------------------------------------------------
1 | import { type DailyChallengeQuestion } from '../types/DailyChallengeQuestion'
2 | import { type Nullish } from '../types/Nullish'
3 | import { BlockLink } from './BlockLink'
4 | import { QuestionDateIcon } from './QuestionDateIcon'
5 | import { QuestionMetaChips } from './QuestionMetaChips'
6 | import { Spacer } from './Spacer'
7 | import styled from 'styled-components'
8 | import { useTranslation } from 'react-i18next'
9 | import { TEST_IDS } from '../../../constants'
10 |
11 | const Wrapper = styled.div({
12 | display: 'flex',
13 | alignItems: 'center',
14 | })
15 |
16 | const IconWrapper = styled.div({
17 | flex: 'none',
18 | display: 'flex',
19 | justifyContent: 'center',
20 | minWidth: '8rem',
21 | })
22 |
23 | const ContentWrapper = styled.div({
24 | flex: '1',
25 | paddingLeft: 0,
26 | })
27 |
28 | interface DailyChallengeQuestionCardProps {
29 | question: DailyChallengeQuestion,
30 | link: Nullish,
31 | }
32 |
33 | export function DailyChallengeQuestionCard (
34 | { question, link }: DailyChallengeQuestionCardProps
35 | ) {
36 | const { t } = useTranslation()
37 |
38 | const title = (() => {
39 | if (!question) return null
40 | const { frontendQuestionId: id, title } = question.question
41 |
42 | return `${id}. ${title}`
43 | })()
44 |
45 | const acceptanceText = `${question.question.acRate.toFixed(1)}%`
46 |
47 | const level = ({
48 | Easy: 1,
49 | Medium: 2,
50 | Hard: 3,
51 | } as const)[question.question.difficulty]
52 |
53 | const isFinished = question.userStatus === 'Finish'
54 |
55 | return (
56 |
61 |
62 |
63 |
64 |
65 | {question.date}
66 |
67 |
68 |
69 |
70 | {t('title.dailyChallenge')}
71 |
72 |
73 | {title}
74 |
75 |
76 |
77 |
82 |
83 |
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/ExtraFeatureOptions.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react'
2 | import userEvent from '@testing-library/user-event'
3 | import { Options } from './Options'
4 | import * as useChromeStorageHooks from '../hooks/useChromeStorage'
5 | import { storageDefaultValues } from '../../../storage'
6 |
7 | describe('ExtraFeatureOptions', () => {
8 | const mockedSetValue = vi.fn()
9 | vi.spyOn(useChromeStorageHooks, 'useChromeStorage')
10 | .mockImplementation((key) => {
11 | const mockedValue = storageDefaultValues[key]
12 | return [mockedValue, mockedSetValue]
13 | })
14 |
15 | beforeEach(() => {
16 | vi.clearAllMocks()
17 | })
18 |
19 | function selectCheckbox (label: string) {
20 | return screen.queryByLabelText(label, { exact: false })
21 | }
22 |
23 | test('should toggle extra feature options', async () => {
24 | render()
25 |
26 | async function assertChanged (label: string) {
27 | const checkbox = selectCheckbox(label)
28 | if (!checkbox) throw new Error('Failed to select the switch')
29 |
30 | const originalValue = checkbox.checked
31 | await userEvent.click(checkbox)
32 |
33 | expect(mockedSetValue).toHaveBeenLastCalledWith(!originalValue)
34 | }
35 |
36 | await assertChanged('Auto Reset Code')
37 | await assertChanged('Show YouTube Link Shortcut')
38 | await assertChanged('Show Dislike Count')
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/ExtraFeatureOptions.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { AUTO_RESET_CODE_ENABLED_STORAGE_KEY, INSERT_DISLIKE_COUNT_STORAGE_KEY, INSERT_YOUTUBE_LINK_STORAGE_KEY } from '../../../storage'
3 | import { OptionSwitch } from './OptionSwitch'
4 |
5 | export function ExtraFeatureOptions () {
6 | const { t } = useTranslation()
7 |
8 | return (
9 |
10 |
14 | {t('option.autoResetCode')}
15 |
16 |
20 | {t('option.insertYoutubeLink')}
21 |
22 |
26 | {t('option.insertDislikeCount')}
27 | BETA
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 | import { VERSION } from '../../../constants'
3 | import { Link } from './Link'
4 |
5 | const links = [
6 | {
7 | title: 'GitHub',
8 | url: 'https://github.com/ngseke/leetcode-night',
9 | },
10 | {
11 | title: 'Issues',
12 | url: 'https://github.com/ngseke/leetcode-night/issues',
13 | },
14 | {
15 | title: 'Chrome Web Store',
16 | url: 'https://chromewebstore.google.com/detail/leetcode-night/aaokgipfeeeciodnffigjfiafledhcii',
17 | },
18 | {
19 | title: `v${VERSION}`,
20 | url: 'https://github.com/ngseke/leetcode-night/releases',
21 | },
22 | ]
23 |
24 | const Wrapper = styled.footer.attrs({
25 | className: 'ts-content',
26 | })({})
27 |
28 | export function Footer () {
29 | return (
30 |
31 |
32 | {
33 | links.map(({ title, url }, key) => (
34 | {title}
35 | ))
36 | }
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 | import { useEnableDarkTheme } from '../hooks/useEnableDarkTheme'
3 | import { Logo } from './Logo'
4 | import { colors } from '../modules/themes'
5 |
6 | const Wrapper = styled.div({
7 | overflow: 'hidden',
8 | })
9 |
10 | const Content = styled.div.attrs({
11 | className: 'ts-content is-tertiary',
12 | })({
13 | position: 'relative',
14 | height: '80px',
15 | display: 'flex',
16 | alignItems: 'center',
17 | })
18 |
19 | const Moon = styled.span.attrs({
20 | children: '🌕',
21 | })<{ $show: boolean }>(({ $show: show }) => ({
22 | position: 'absolute',
23 | right: 20,
24 | top: '50%',
25 | fontSize: '3rem',
26 | lineHeight: 1,
27 | transition: 'transform .4s .1s, text-shadow .6s .1s',
28 | transform: `translateY(${show ? '-50%' : '120%'})`,
29 | textShadow: `${colors.leetcodeNight} 0 0 ${show ? '10px' : 0}`,
30 | }))
31 |
32 | export function Header () {
33 | const [isDarkThemeEnabled] = useEnableDarkTheme()
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/HighlightText.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 | import { colors } from '../modules/themes'
3 |
4 | export const HighlightText = styled.span.attrs({
5 | className: 'ts-text is-heavy',
6 | })({
7 | color: colors.leetcodeNight,
8 | textShadow: 'rgba(255, 208, 25, 0.7) 0 0 2px',
9 | })
10 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/KofiLink.tsx:
--------------------------------------------------------------------------------
1 | export function KofiLink () {
2 | return (
3 |
8 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/LanguageSelect.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react'
2 | import { LanguageSelect } from './LanguageSelect'
3 |
4 | describe('LanguageSelect', () => {
5 | test('should render language options', async () => {
6 | const getOption = (name: string) => {
7 | return screen.getByRole('option', { name })
8 | }
9 | render()
10 |
11 | expect(getOption('English').selected).toBe(true)
12 | expect(getOption('正體中文')).toBeInTheDocument()
13 | expect(getOption('简体中文')).toBeInTheDocument()
14 | expect(getOption('日本語')).toBeInTheDocument()
15 | expect(getOption('한국어')).toBeInTheDocument()
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/LanguageSelect.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { TEST_IDS } from '../../../constants'
3 |
4 | const languages = [
5 | { name: 'English', value: 'en' },
6 | { name: '正體中文', value: 'zh-Hant' },
7 | { name: '简体中文', value: 'zh-Hans' },
8 | { name: '日本語', value: 'ja' },
9 | { name: '한국어', value: 'ko' },
10 | ]
11 |
12 | export function LanguageSelect () {
13 | const { i18n } = useTranslation()
14 |
15 | return (
16 |
17 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 | import { type ReactNode } from 'react'
3 |
4 | const Wrapper = styled.div({ height: '100%' })
5 |
6 | type LayoutProps = Record<'header' | 'body' | 'footer', ReactNode>
7 |
8 | export function Layout ({ header, body, footer }: LayoutProps) {
9 | return (
10 |
11 |
12 | {header}
13 |
14 |
15 |
16 | {body}
17 |
18 |
19 |
20 | {footer}
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/LeetcodeVersionBadge.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { type LeetcodeVersion } from '../../Content/leetcode-version'
3 | import { type Nullish } from '../types/Nullish'
4 |
5 | export function LeetcodeVersionBadge ({ version }: {
6 | version: Nullish,
7 | }) {
8 | const { t } = useTranslation()
9 | if (!version) return <>>
10 |
11 | const isOldVersion = version === '2022'
12 | const badgeText = isOldVersion
13 | ? t('option.oldVersionLeetcode')
14 | : t('option.newVersionLeetcode')
15 |
16 | return (
17 |
18 | {badgeText}
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/LeetcodeVersionDescription.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react'
2 | import { LeetcodeVersionDescription } from './LeetcodeVersionDescription'
3 |
4 | describe('LeetcodeVersionDescription', () => {
5 | test('LeetcodeVersionDescription', () => {
6 | const { container, rerender } = render(
7 |
8 | )
9 | expect(container.firstChild).toBeNull()
10 |
11 | rerender(
12 |
13 | )
14 | expect(container.firstChild).not.toBeNull()
15 |
16 | rerender(
17 |
18 | )
19 | expect(container.firstChild).not.toBeNull()
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/LeetcodeVersionDescription.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { type LeetcodeVersion } from '../../Content/leetcode-version'
3 | import { type Nullish } from '../types/Nullish'
4 |
5 | export function LeetcodeVersionDescription ({ version }: {
6 | version: Nullish,
7 | }) {
8 | const { t } = useTranslation()
9 | const isOldVersion = version === '2022'
10 | if (!version || isOldVersion) return <>>
11 |
12 | return (
13 |
17 | {t('option.enableDarkTheme2023Description')}
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Link.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, type ComponentProps } from 'react'
2 | import styled from 'styled-components'
3 |
4 | const AttributedLink = styled.a.attrs({
5 | target: '_blank',
6 | rel: 'noopener noreferrer',
7 | })({})
8 |
9 | export const Link = forwardRef>(
10 | (props, ref?) =>
11 | )
12 |
13 | Link.displayName = 'Link'
14 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '../modules/themes'
2 |
3 | export function Logo () {
4 | return (
5 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/NoResult.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 |
3 | export function NoResult () {
4 | const { t } = useTranslation()
5 |
6 | return (
7 |
11 |
12 | {t('status.noResult')}
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/OptionSwitch.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from './Switch'
2 | import { type StorageKey } from '../../../storage'
3 | import { useChromeStorage } from '../hooks/useChromeStorage'
4 | import { type PropsWithChildren } from 'react'
5 |
6 | type OptionSwitchProps = PropsWithChildren<{
7 | storageKey: StorageKey,
8 | icon: string,
9 | }>
10 |
11 | export function OptionSwitch (
12 | { storageKey, icon, children }: OptionSwitchProps
13 | ) {
14 | const [value, setValue] = useChromeStorage(storageKey)
15 |
16 | if (!(typeof value === 'boolean' || value == null)) {
17 | return <>This component only supports boolean option!>
18 | }
19 |
20 | return (
21 |
22 | {children}
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/OptionTitle.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren } from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Title = styled.h2.attrs({ className: 'ts-header is-large is-start-icon' })({
5 | marginBottom: '1rem',
6 | })
7 |
8 | export type OptionTitleProps = PropsWithChildren<{
9 | icon: string,
10 | }>
11 |
12 | export function OptionTitle ({ children, icon }: OptionTitleProps) {
13 | return (
14 |
15 |
16 | {children}
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Options.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react'
2 | import userEvent from '@testing-library/user-event'
3 | import { Options } from './Options'
4 | import { mockChrome } from '../../../../__tests__/chrome'
5 | import { TEST_IDS } from '../../../constants'
6 |
7 | describe('Options', () => {
8 | test('should switch UI language', async () => {
9 | mockChrome()
10 | render()
11 |
12 | function assertTitle (name: string) {
13 | expect(screen.getByRole('heading', { name })).toBeInTheDocument()
14 | }
15 |
16 | async function selectOption (name: string) {
17 | const select = screen.getByTestId(TEST_IDS.languageSelect)
18 | await userEvent.selectOptions(select, name)
19 | }
20 |
21 | assertTitle('Language')
22 |
23 | await selectOption('正體中文')
24 | assertTitle('語言')
25 |
26 | await selectOption('简体中文')
27 | assertTitle('语言')
28 |
29 | await selectOption('日本語')
30 | assertTitle('言語')
31 |
32 | await selectOption('한국어')
33 | assertTitle('언어')
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Options.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { ExtraFeatureOptions } from './ExtraFeatureOptions'
3 | import { LanguageSelect } from './LanguageSelect'
4 | import { OptionTitle } from './OptionTitle'
5 | import { StyleOptions } from './StyleOptions'
6 | import { KofiLink } from './KofiLink'
7 | import { TEST_IDS } from '../../../constants'
8 |
9 | const Divider = () =>
10 |
11 | export function Options () {
12 | const { t } = useTranslation()
13 |
14 | return (
15 |
16 |
{t('title.style')}
17 |
18 |
19 |
20 |
{t('title.extraFeature')}
21 |
22 |
23 |
24 |
{t('title.language')}
25 |
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/PremiumBadge.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | const StyledSvg = styled.svg({
4 | verticalAlign: 'middle',
5 | marginLeft: '.5rem',
6 | })
7 |
8 | export function PremiumBadge () {
9 | return
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/QuestionCard.tsx:
--------------------------------------------------------------------------------
1 | import { type ReactNode, useEffect, useRef, type PropsWithChildren } from 'react'
2 | import { type Question } from '../types/Question'
3 | import { QuestionMetaChips } from './QuestionMetaChips'
4 | import { Link } from './Link'
5 | import clsx from 'clsx'
6 | import { HighlightText } from './HighlightText'
7 | import styled from 'styled-components'
8 | import { PremiumBadge } from './PremiumBadge'
9 | import { TEST_IDS } from '../../../constants'
10 |
11 | const Wrapper = styled.div({
12 | display: 'flex',
13 | })
14 |
15 | const IconWrapper = styled.div({
16 | flex: 'none',
17 | display: 'flex',
18 | height: '100%',
19 | minWidth: '2.75rem',
20 | paddingTop: '0.5rem',
21 | })
22 |
23 | const ContentWrapper = styled.div({
24 | flex: '1',
25 | })
26 |
27 | interface QuestionCardProps {
28 | question: Question,
29 | customTitle?: ReactNode,
30 | isMatchedByQuestionId?: boolean,
31 | active?: boolean,
32 | }
33 |
34 | const QuestionIdText = ({ children, active }: PropsWithChildren<{
35 | active?: boolean,
36 | }>) => {
37 | if (active) return {children}
38 | return {children}
39 | }
40 |
41 | export function QuestionCard ({
42 | question,
43 | customTitle,
44 | isMatchedByQuestionId,
45 | active,
46 | }: QuestionCardProps) {
47 | const acceptanceText = (() => {
48 | const { stat } = question
49 | return `${((stat.total_acs / stat.total_submitted) * 100).toFixed(1)}%`
50 | })()
51 |
52 | const { url } = question
53 |
54 | const ref = useRef(null)
55 | useEffect(() => {
56 | if (!active) return
57 | ref.current?.scrollIntoView({ block: 'nearest' })
58 | }, [active])
59 |
60 | const isPremium = question.paid_only
61 |
62 | return (
63 |
72 |
73 |
74 |
80 |
81 |
82 |
83 |
87 |
88 | {question.stat.frontend_question_id}.{' '}
89 |
90 | {customTitle ?? question.title}
91 | {isPremium &&
}
92 |
93 |
94 |
95 |
100 |
101 |
102 |
103 |
104 | )
105 | }
106 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/QuestionDateIcon.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { type PropsWithChildren } from 'react'
3 | import styled from 'styled-components'
4 |
5 | type QuestionStatusIconProps = PropsWithChildren<{
6 | isFinished: boolean,
7 | }>
8 |
9 | const Wrapper = styled.div({
10 | display: 'flex',
11 | flexDirection: 'column',
12 | alignItems: 'center',
13 | })
14 |
15 | const Icon = styled.span({
16 | lineHeight: 1,
17 | fontSize: '2.5rem',
18 | marginBottom: '.5rem',
19 | })
20 |
21 | export function QuestionDateIcon ({ children, isFinished }: QuestionStatusIconProps) {
22 | return (
23 |
27 |
33 | {children}
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/QuestionDifficultyText.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '../modules/themes'
2 | import { type Difficulty } from '../types/Question'
3 |
4 | interface QuestionDifficultyTextProps {
5 | level: Difficulty['level'],
6 | }
7 |
8 | export function QuestionDifficultyText ({ level }: QuestionDifficultyTextProps) {
9 | const { color, name } = {
10 | 1: { name: 'Easy', color: colors.green },
11 | 2: { name: 'Medium', color: colors.yellow },
12 | 3: { name: 'Hard', color: colors.red },
13 | }[level]
14 |
15 | return (
16 |
17 | {name}
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/QuestionKeywordInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { useTranslation } from 'react-i18next'
3 |
4 | interface QuestionNumberInputProps {
5 | value: string,
6 | onChange: (value: string) => void,
7 | onKeyArrowUp?: () => void,
8 | onKeyArrowDown?: () => void,
9 | loading: boolean,
10 | error?: unknown,
11 | }
12 |
13 | export function QuestionKeywordInput (
14 | { value, onChange, onKeyArrowUp, onKeyArrowDown, loading, error }: QuestionNumberInputProps
15 | ) {
16 | const { t } = useTranslation()
17 |
18 | return (
19 |
24 |
30 | onChange(e.target.value)}
36 | onKeyDown={e => {
37 | if (e.nativeEvent.isComposing) return
38 | const actions: Record void> = {
39 | ArrowUp: () => onKeyArrowUp?.(),
40 | ArrowDown: () => onKeyArrowDown?.(),
41 | }
42 | actions[e.key]?.()
43 | }}
44 | />
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/QuestionMetaChips.tsx:
--------------------------------------------------------------------------------
1 | import { type Question, type Difficulty } from '../types/Question'
2 | import { Chip } from './Chip'
3 | import { QuestionDifficultyText } from './QuestionDifficultyText'
4 | import { QuestionStatusIcon } from './QuestionStatusIcon'
5 |
6 | interface QuestionMetaChipsProps {
7 | status: Question['status'],
8 | level: Difficulty['level'],
9 | acceptance: string,
10 | }
11 |
12 | export function QuestionMetaChips (
13 | { status, level, acceptance }: QuestionMetaChipsProps
14 | ) {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {acceptance}
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/QuestionStatusIcon.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { colors } from '../modules/themes'
3 | import { type Question } from '../types/Question'
4 |
5 | interface QuestionStatusIconProps {
6 | status: Question['status'],
7 | }
8 |
9 | export function QuestionStatusIcon ({ status }: QuestionStatusIconProps) {
10 | const { name, icon, color } = (() => {
11 | if (!status) return { name: 'Todo', icon: 'minus', color: colors.gray }
12 |
13 | return {
14 | ac: { name: 'Solved', icon: 'check', color: colors.green },
15 | notac: { name: 'Attempted', icon: 'wave-square', color: colors.yellow },
16 | }[status]
17 | })()
18 |
19 | return (
20 |
21 |
25 | {name}
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/SearchQuestion.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, waitFor } from '@testing-library/react'
2 | import { SearchQuestion } from './SearchQuestion'
3 | import * as apis from '../modules/apis'
4 | import { mockDailyChallengeQuestion } from '../../../tests/helpers/mockDailyChallengeQuestion'
5 | import { TEST_IDS } from '../../../constants'
6 | import { mockQuestions } from '../../../tests/helpers/mockQuestions'
7 | import userEvent from '@testing-library/user-event'
8 |
9 | describe('SearchQuestion', () => {
10 | vi.spyOn(apis, 'fetchDailyChallengeQuestion')
11 | .mockResolvedValue(mockDailyChallengeQuestion)
12 | vi.spyOn(apis, 'fetchQuestions')
13 | .mockResolvedValue(mockQuestions)
14 |
15 | function selectInput () {
16 | return screen.getByLabelText('Search by Question Number or Keyword')
17 | }
18 |
19 | async function typeInput (value: string) {
20 | await userEvent.type(selectInput(), value)
21 | }
22 |
23 | async function clearInput () {
24 | for (let i = 0; i < 10; i++) {
25 | await userEvent.type(selectInput(), '{backspace}')
26 | }
27 | }
28 |
29 | function selectResults () {
30 | return screen.getAllByTestId(TEST_IDS.questionCard)
31 | }
32 |
33 | test('should display daily question card', async () => {
34 | render()
35 | await waitFor(() => {})
36 |
37 | const card = screen.getByTestId(TEST_IDS.dailyChallengeQuestionCard)
38 |
39 | expect(card).toBeInTheDocument()
40 | expect(screen.getByText('Daily Challenge')).toBeInTheDocument()
41 | expect(screen.getByText('Medium')).toBeInTheDocument()
42 | expect(screen.getByText('2024-02-08')).toBeInTheDocument()
43 | expect(screen.getByText('53.9%')).toBeInTheDocument()
44 | const link = screen.getByRole('link', { name: 'Daily Challenge Question' })
45 | expect(link).toBeInTheDocument()
46 | expect(link).toHaveAttribute('href', 'https://leetcode.com/problems/perfect-squares')
47 | })
48 |
49 | test('should display exactly matched result by question id', async () => {
50 | render()
51 | await waitFor(() => {})
52 |
53 | await typeInput('1')
54 |
55 | expect(selectResults()[0].textContent).contain('1.')
56 | expect(selectResults()[0].textContent).contain('Two Sum')
57 |
58 | await typeInput('0')
59 | expect(selectResults()[0].textContent).contain('10.')
60 | expect(selectResults()[0].textContent).contain('Regular Expression Matching')
61 |
62 | await typeInput('0')
63 | expect(selectResults()[0].textContent).contain('100.')
64 | expect(selectResults()[0].textContent).contain('Same Tree')
65 |
66 | await clearInput()
67 |
68 | await typeInput('69')
69 | expect(selectResults()[0].textContent).contain('69.')
70 | expect(selectResults()[0].textContent).contain('Sqrt(x)')
71 | })
72 |
73 | test('should display matched results by keywords', async () => {
74 | render()
75 | await waitFor(() => {})
76 |
77 | await typeInput('adDbInAry')
78 | expect(selectResults()[0].textContent).contain('67.')
79 | expect(selectResults()[0].textContent).contain('Add Binary')
80 | await clearInput()
81 |
82 | await typeInput('mws')
83 | expect(selectResults().length).toBeGreaterThan(1)
84 | expect(selectResults()[0].textContent).contain('76.')
85 | expect(selectResults()[0].textContent).contain('Minimum Window Substring')
86 | })
87 |
88 | test('should display no result message', async () => {
89 | render()
90 | await waitFor(() => {})
91 |
92 | await typeInput('99999')
93 | expect(screen.getByText('No Results Found')).toBeInTheDocument()
94 | await clearInput()
95 |
96 | await typeInput('zzzzzzzzzz')
97 | expect(screen.getByText('No Results Found')).toBeInTheDocument()
98 | })
99 |
100 | test('should open question page on pressing enter', async () => {
101 | render()
102 | await waitFor(() => {})
103 |
104 | const hiddenLink = screen.getByTestId(TEST_IDS.questionCardHiddenLink)
105 | const spyOnHiddenLinkClick = vi.spyOn(hiddenLink, 'click')
106 |
107 | await typeInput('{enter}')
108 | expect(hiddenLink.getAttribute('href'))
109 | .toBe('https://leetcode.com/problems/perfect-squares')
110 | expect(spyOnHiddenLinkClick).toBeCalledTimes(1)
111 |
112 | await typeInput('twosum')
113 | await typeInput('{enter}')
114 | expect(hiddenLink.getAttribute('href'))
115 | .toBe('https://leetcode.com/problems/two-sum')
116 | expect(spyOnHiddenLinkClick).toBeCalledTimes(2)
117 | })
118 |
119 | test('should select item by keyboard up/down key', async () => {
120 | render()
121 | await waitFor(() => {})
122 |
123 | await typeInput('substring')
124 | expect(selectResults().length).toBe(4)
125 |
126 | const hiddenLink = screen.getByTestId(TEST_IDS.questionCardHiddenLink)
127 | const spyOnHiddenLinkClick = vi.spyOn(hiddenLink, 'click')
128 |
129 | for (let i = 0; i <= 3; i++) {
130 | expect(selectResults()[i].className).contain('is-active')
131 | await typeInput('{arrowdown}')
132 | }
133 |
134 | expect(selectResults()[0].className).contain('is-active')
135 |
136 | for (let i = 3; i >= 0; i--) {
137 | await typeInput('{arrowup}')
138 | expect(selectResults()[i].className).contain('is-active')
139 | }
140 |
141 | await typeInput('{enter}')
142 | expect(hiddenLink.getAttribute('href'))
143 | .toBe('https://leetcode.com/problems/minimum-window-substring')
144 | expect(spyOnHiddenLinkClick).toBeCalledTimes(1)
145 | })
146 | })
147 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/SearchQuestion.tsx:
--------------------------------------------------------------------------------
1 | import { type SyntheticEvent, useRef, useState } from 'react'
2 | import styled from 'styled-components'
3 | import { useDailyChallengeQuestion } from '../hooks/useDailyChallengeQuestion'
4 | import { useQuestions } from '../hooks/useQuestions'
5 | import { DailyChallengeQuestionCard } from './DailyChallengeQuestionCard'
6 | import { Link } from './Link'
7 | import { QuestionCard } from './QuestionCard'
8 | import { QuestionKeywordInput } from './QuestionKeywordInput'
9 | import { HighlightText } from './HighlightText'
10 | import fuzzysort from 'fuzzysort'
11 | import { Spacer } from './Spacer'
12 | import { useSelectedIndex } from '../hooks/useSelectedIndex'
13 | import { NoResult } from './NoResult'
14 | import { TEST_IDS } from '../../../constants'
15 |
16 | const HiddenLink = styled(Link)({ display: 'none' })
17 |
18 | const Wrapper = styled.div.attrs({
19 | className: 'ts-content',
20 | })({
21 | display: 'flex',
22 | flexDirection: 'column',
23 | height: '100%',
24 | paddingBottom: 0,
25 | })
26 |
27 | const FixedForm = styled.form({
28 | flex: 'none',
29 | })
30 |
31 | const ScrollableContent = styled.div({
32 | flex: 1,
33 | overflow: 'auto',
34 | })
35 |
36 | export function SearchQuestion () {
37 | const [keyword, setKeyword] = useState('')
38 | const {
39 | isLoadingQuestions,
40 | matchedQuestionById,
41 | matchedQuestionResultsByKeyword,
42 | isNotFound,
43 | } = useQuestions(keyword)
44 |
45 | const questionLinkRef = useRef(null)
46 |
47 | const {
48 | dailyChallengeQuestion,
49 | dailyChallengeQuestionUrl,
50 | } = useDailyChallengeQuestion()
51 |
52 | const shouldDirectToDailyChallenge = !!(!keyword && dailyChallengeQuestion)
53 |
54 | const totalLength =
55 | (matchedQuestionById ? 1 : 0) +
56 | (matchedQuestionResultsByKeyword?.length ?? 0)
57 |
58 | const {
59 | selectedIndex,
60 | increaseSelectedIndex,
61 | decreaseSelectedIndex,
62 | } = useSelectedIndex(totalLength)
63 |
64 | const selectedQuestion = (() => {
65 | if (matchedQuestionById && !selectedIndex) {
66 | return matchedQuestionById
67 | }
68 |
69 | const keywordResultIndex = selectedIndex + (matchedQuestionById ? -1 : 0)
70 | return matchedQuestionResultsByKeyword?.[keywordResultIndex]?.obj
71 | })()
72 |
73 | const submitLink = shouldDirectToDailyChallenge
74 | ? dailyChallengeQuestionUrl
75 | : selectedQuestion?.url
76 |
77 | const handleSubmit = (e: SyntheticEvent) => {
78 | e.preventDefault()
79 | if (submitLink) questionLinkRef.current?.click()
80 | }
81 |
82 | return (
83 |
84 |
85 |
90 |
97 |
98 |
99 |
100 |
101 | {
102 | shouldDirectToDailyChallenge &&
103 |
107 | }
108 | {
109 | matchedQuestionById &&
110 |
115 | }
116 | {
117 | Boolean(matchedQuestionResultsByKeyword?.length) && <>
118 | {
119 | matchedQuestionResultsByKeyword?.map((result) => (
120 | (
126 | {match}
127 | ))
128 | }
129 | />
130 | ))
131 | }
132 | >
133 | }
134 | {isNotFound && }
135 |
136 |
137 | )
138 | }
139 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Spacer.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 |
3 | interface SpacerProps {
4 | small?: boolean,
5 | }
6 |
7 | export function Spacer ({ small }: SpacerProps) {
8 | return (
9 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/StyleOptions.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react'
2 | import userEvent from '@testing-library/user-event'
3 | import { Options } from './Options'
4 | import * as useChromeStorageHooks from '../hooks/useChromeStorage'
5 | import * as useLeetCodeVersionHooks from '../hooks/useLeetCodeVersion'
6 | import { storageDefaultValues } from '../../../storage'
7 |
8 | describe('StyleOptions', () => {
9 | const mockedSetValue = vi.fn()
10 | vi.spyOn(useChromeStorageHooks, 'useChromeStorage')
11 | .mockImplementation((key) => {
12 | const mockedValue = storageDefaultValues[key]
13 | return [mockedValue, mockedSetValue]
14 | })
15 | const spyOnUseLeetCodeVersion =
16 | vi.spyOn(useLeetCodeVersionHooks, 'useLeetCodeVersion')
17 |
18 | beforeEach(() => {
19 | vi.clearAllMocks()
20 | spyOnUseLeetCodeVersion.mockReturnValue(['2023-dynamic-layout' as const])
21 | })
22 |
23 | function selectCheckbox (label: string) {
24 | return screen.queryByLabelText(label, { exact: false })
25 | }
26 |
27 | test('should toggle enable dark theme', async () => {
28 | render()
29 |
30 | const checkbox = selectCheckbox('Enable Dark Theme')
31 | if (!checkbox) throw new Error('Failed to select the switch')
32 |
33 | const originalValue = checkbox.checked
34 | await userEvent.click(checkbox)
35 |
36 | expect(mockedSetValue).toHaveBeenLastCalledWith(!originalValue)
37 | })
38 |
39 | test('should toggle style options', async () => {
40 | spyOnUseLeetCodeVersion.mockReturnValue(['2022' as const])
41 |
42 | render()
43 |
44 | async function assertChanged (label: string, key: string) {
45 | const checkbox = selectCheckbox(label)
46 | if (!checkbox) throw new Error('Failed to select the switch')
47 |
48 | const originalValue = checkbox.checked
49 | await userEvent.click(checkbox)
50 |
51 | expect(mockedSetValue).toHaveBeenLastCalledWith(
52 | expect.objectContaining({ [key]: !originalValue })
53 | )
54 | }
55 |
56 | await assertChanged('Show Mascot', 'mascot')
57 | await assertChanged('Invert Image Color', 'invertImageColor')
58 | await assertChanged('Hide LeetCode Logo', 'hideLogo')
59 | })
60 |
61 | test('should hide mascot option if the version of LeetCode is not 2022', () => {
62 | const label = 'Show Mascot'
63 |
64 | spyOnUseLeetCodeVersion.mockReturnValue(['2023-dynamic-layout' as const])
65 | const { rerender } = render()
66 | expect(selectCheckbox(label)).not.toBeInTheDocument()
67 |
68 | spyOnUseLeetCodeVersion.mockReturnValue(['2023' as const])
69 | rerender()
70 | expect(selectCheckbox(label)).not.toBeInTheDocument()
71 |
72 | spyOnUseLeetCodeVersion.mockReturnValue(['2022' as const])
73 | rerender()
74 | expect(selectCheckbox(label)).toBeInTheDocument()
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/StyleOptions.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { type OptionKey, OPTIONS } from '../../../options'
3 | import { OPTIONS_STORAGE_KEY } from '../../../storage'
4 | import { useEnableDarkTheme } from '../hooks/useEnableDarkTheme'
5 | import { Switch } from './Switch'
6 | import { useLeetCodeVersion } from '../hooks/useLeetCodeVersion'
7 | import { LeetcodeVersionDescription } from './LeetcodeVersionDescription'
8 | import { LeetcodeVersionBadge } from './LeetcodeVersionBadge'
9 | import { useChromeStorage } from '../hooks/useChromeStorage'
10 |
11 | const options = [
12 | OPTIONS.MASCOT,
13 | OPTIONS.INVERT_IMAGE_COLOR,
14 | OPTIONS.HIDE_LOGO,
15 | ]
16 |
17 | export function StyleOptions () {
18 | const { t } = useTranslation()
19 | const [leetcodeVersion] = useLeetCodeVersion()
20 |
21 | const [isDarkThemeEnabled, setIsDarkThemeEnabled] = useEnableDarkTheme()
22 | const [storageOptions, setStorageOptions] =
23 | useChromeStorage(OPTIONS_STORAGE_KEY)
24 |
25 | const handleChange = (key: OptionKey) =>
26 | (checked: boolean) => {
27 | if (!storageOptions) return
28 | setStorageOptions({ ...storageOptions, [key]: checked })
29 | }
30 |
31 | const isHidden = (key: OptionKey) => {
32 | if (key === 'mascot') return leetcodeVersion !== '2022'
33 | return false
34 | }
35 |
36 | return (
37 |
38 |
39 |
44 | {t('option.enableDarkTheme')}
45 |
46 |
47 |
48 |
49 |
50 | {options?.map(({ key, icon }) => (
51 | !isHidden(key) && (
52 |
58 | {t(`option.${key}`)}
59 |
60 | )
61 | ))}
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Switch.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { type Nullish } from '../types/Nullish'
3 | import { type PropsWithChildren } from 'react'
4 |
5 | type SwitchProps = PropsWithChildren<{
6 | checked: Nullish,
7 | onChange: (checked: boolean) => void,
8 | icon?: string,
9 | disabled?: boolean,
10 | }>
11 |
12 | export function Switch (
13 | { children, disabled, checked, onChange, icon }: SwitchProps
14 | ) {
15 | return (
16 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/pages/Popup/components/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import type React from 'react'
3 | import { useEffect } from 'react'
4 | import { useTranslation } from 'react-i18next'
5 | import useStorageState from 'react-use-storage-state'
6 | import { TEST_IDS } from '../../../constants'
7 |
8 | export const tabs = {
9 | questions: {
10 | icon: 'file-lines',
11 | testId: TEST_IDS.questionsTabButton,
12 | },
13 | options: {
14 | icon: 'gear',
15 | testId: TEST_IDS.optionsTabButton,
16 | },
17 | }
18 |
19 | export type TabKey = keyof typeof tabs
20 |
21 | const tabKeys: readonly TabKey[] = ['questions', 'options']
22 |
23 | const initialTab = tabKeys[0]
24 |
25 | export function useTabs () {
26 | const [tab, setTab] = useStorageState('tab', initialTab)
27 |
28 | const isTabQuestions = tab === 'questions'
29 | const isTabOptions = tab === 'options'
30 |
31 | useEffect(function handleInvalidTab () {
32 | const isValid = Object.keys(tabs).includes(tab)
33 | if (!isValid) setTab(initialTab)
34 | }, [setTab, tab])
35 |
36 | return { tab, setTab, isTabOptions, isTabQuestions }
37 | }
38 |
39 | interface TabsProps {
40 | value: TabKey,
41 | onChange: (value: TabKey) => void,
42 | }
43 |
44 | export function Tabs ({ value, onChange }: TabsProps) {
45 | const { t } = useTranslation()
46 |
47 | const handleClick = (key: TabKey) =>
48 | (e: React.SyntheticEvent) => {
49 | e.preventDefault()
50 | onChange(key)
51 | }
52 |
53 | return (
54 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useChromeStorage.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook, waitFor } from '@testing-library/react'
2 | import * as hooks from './useChromeStorageListener'
3 | import * as storage from '../../../storage'
4 | import { useChromeStorage } from './useChromeStorage'
5 | import { ENABLED_STORAGE_KEY } from '../../../storage'
6 |
7 | describe('useChromeStorage', () => {
8 | let emitChanges: hooks.UseChromeStorageListenerHandler | undefined
9 | vi.spyOn(hooks, 'useChromeStorageListener')
10 | .mockImplementation((handler) => { emitChanges = handler })
11 |
12 | const spyOnGetStorage = vi.spyOn(storage, 'getStorage')
13 | const spyOnSetStorage = vi.spyOn(storage, 'setStorage')
14 |
15 | beforeEach(() => {
16 | vi.clearAllMocks()
17 | spyOnGetStorage.mockImplementation(async () => { return true })
18 | spyOnSetStorage.mockImplementation(async () => {})
19 | })
20 |
21 | const key = ENABLED_STORAGE_KEY
22 |
23 | test('should get initial value from chrome storage', async () => {
24 | const { result } = renderHook(() => useChromeStorage(key))
25 | await waitFor(() => {})
26 |
27 | expect(spyOnGetStorage).toBeCalledTimes(1)
28 | expect(result.current[0]).toBe(true)
29 | })
30 |
31 | test('should update value when storage listener is emitted', async () => {
32 | const { result } = renderHook(() => useChromeStorage(key))
33 | await waitFor(() => {})
34 |
35 | const changes1 = { [key]: { newValue: false, oldValue: true } }
36 | act(() => emitChanges?.(changes1, 'sync'))
37 | expect(result.current[0]).toBe(false)
38 |
39 | const changes2 = { [key]: { newValue: true, oldValue: false } }
40 | act(() => emitChanges?.(changes2, 'sync'))
41 | expect(result.current[0]).toBe(true)
42 |
43 | const anotherKey = storage.AUTO_RESET_CODE_ENABLED_STORAGE_KEY
44 | const anotherChanges = { [anotherKey]: { newValue: true, oldValue: false } }
45 |
46 | act(() => emitChanges?.(anotherChanges, 'sync'))
47 | expect(result.current[0]).toBe(true)
48 | })
49 |
50 | test('should not update if the two objects are equal', async () => {
51 | const objectValue = {
52 | invertImageColor: true,
53 | mascot: true,
54 | hideLogo: true,
55 | }
56 | spyOnGetStorage.mockImplementation(async () => ({ ...objectValue }))
57 |
58 | const key = storage.OPTIONS_STORAGE_KEY
59 | const { result } = renderHook(() => useChromeStorage(key))
60 | await waitFor(() => {})
61 |
62 | expect(result.current[0]).toMatchObject({ ...objectValue })
63 | const previousValue = result.current[0]
64 |
65 | const change1 = {
66 | [key]: {
67 | newValue: { ...objectValue },
68 | oldValue: { ...objectValue },
69 | },
70 | }
71 | act(() => emitChanges?.(change1, 'sync'))
72 |
73 | expect(result.current[0]).toBe(previousValue)
74 |
75 | const change2 = {
76 | [key]: {
77 | newValue: { invertImageColor: false, mascot: false, hideLogo: true },
78 | oldValue: { ...objectValue },
79 | },
80 | }
81 | act(() => emitChanges?.(change2, 'sync'))
82 |
83 | expect(result.current[0]).not.toBe(previousValue)
84 | expect(result.current[0]).toMatchObject(
85 | { invertImageColor: false, mascot: false, hideLogo: true }
86 | )
87 | })
88 |
89 | test('should save value to the chrome storage', async () => {
90 | const { result } = renderHook(() => useChromeStorage(key))
91 | await waitFor(() => {})
92 |
93 | await act(async () => await result.current[1](false))
94 | expect(spyOnSetStorage).toBeCalledWith(key, false)
95 | })
96 |
97 | test('should not save if the two values/objects are equal', async () => {
98 | const { result, rerender } = renderHook(
99 | (key: storage.StorageKey) => useChromeStorage(key),
100 | { initialProps: key }
101 | )
102 | await waitFor(() => {})
103 |
104 | await act(async () => await result.current[1](true))
105 | expect(spyOnSetStorage).not.toBeCalled()
106 | await act(async () => await result.current[1](false))
107 | expect(spyOnSetStorage).toBeCalled()
108 | spyOnSetStorage.mockClear()
109 |
110 | const objectValue = {
111 | invertImageColor: true,
112 | mascot: true,
113 | hideLogo: true,
114 | }
115 | spyOnGetStorage.mockImplementation(async () => ({ ...objectValue }))
116 | rerender(storage.OPTIONS_STORAGE_KEY)
117 | await waitFor(() => {})
118 |
119 | await act(async () => await result.current[1]({ ...objectValue }))
120 | expect(spyOnSetStorage).not.toBeCalled()
121 |
122 | await act(async () => await result.current[1]({ ...objectValue, hideLogo: false }))
123 | expect(spyOnSetStorage).toBeCalled()
124 | })
125 | })
126 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useChromeStorage.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { type StorageKey, type StorageSchema, setStorage, getStorage } from '../../../storage'
3 | import { isEqual } from 'lodash-es'
4 | import { useChromeStorageListener } from './useChromeStorageListener'
5 |
6 | export function useChromeStorage (key: Key) {
7 | type Value = StorageSchema[Key]
8 |
9 | const [value, setValue] = useState(null)
10 |
11 | async function saveValue (newValue: Value) {
12 | if (value == null || newValue == null) return
13 | if (isEqual(value, newValue)) return
14 |
15 | setValue(newValue)
16 | await setStorage(key, newValue)
17 | }
18 |
19 | useEffect(function init () {
20 | async function load () {
21 | setValue(await getStorage(key))
22 | }
23 | load()
24 | }, [key])
25 |
26 | useChromeStorageListener((changes) => {
27 | if (!(key in changes)) return
28 | const { newValue, oldValue } = changes[key]
29 | if (isEqual(newValue, oldValue)) return
30 |
31 | setValue(newValue)
32 | })
33 |
34 | return [value, saveValue] as const
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useChromeStorageListener.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react'
2 | import { mockChrome } from '../../../../__tests__/chrome'
3 | import { type UseChromeStorageListenerHandler, useChromeStorageListener } from './useChromeStorageListener'
4 | import { AUTO_RESET_CODE_ENABLED_STORAGE_KEY, ENABLED_STORAGE_KEY } from '../../../storage'
5 |
6 | describe('useChromeStorageListener', () => {
7 | let emit: UseChromeStorageListenerHandler | undefined
8 | const key = ENABLED_STORAGE_KEY
9 |
10 | beforeEach(() => {
11 | const mockedChrome = mockChrome()
12 | mockedChrome.storage.onChanged.addListener
13 | .mockImplementation((callback) => { emit = callback })
14 | mockedChrome.storage.onChanged.removeListener
15 | .mockImplementation(() => { emit = undefined })
16 | })
17 |
18 | test('add and remove storage event listener', () => {
19 | const handler = vi.fn()
20 | const { unmount } = renderHook(
21 | () => useChromeStorageListener(handler, key)
22 | )
23 |
24 | const changes1 = { [key]: { newValue: true, oldValue: false } }
25 | emit?.(changes1, 'sync')
26 | expect(handler).toBeCalledWith(changes1, 'sync')
27 | handler.mockReset()
28 |
29 | const changes2 = { [key]: { newValue: false, oldValue: true } }
30 | emit?.(changes2, 'local')
31 | expect(handler).toBeCalledWith(changes2, 'local')
32 | handler.mockReset()
33 |
34 | unmount()
35 | emit?.(changes1, 'sync')
36 | expect(handler).not.toBeCalled()
37 | })
38 |
39 | test('should not call the handler if the key is not matched', () => {
40 | const handler = vi.fn()
41 |
42 | renderHook(() => useChromeStorageListener(handler, key))
43 |
44 | const anotherKey = AUTO_RESET_CODE_ENABLED_STORAGE_KEY
45 | const changes = { [anotherKey]: { newValue: true, oldValue: false } }
46 | emit?.(changes, 'sync')
47 | expect(handler).not.toBeCalled()
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useChromeStorageListener.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { type StorageKey } from '../../../storage'
3 |
4 | export type UseChromeStorageListenerHandler = Parameters[0]
5 |
6 | export function useChromeStorageListener (
7 | handler: UseChromeStorageListenerHandler,
8 | key?: StorageKey
9 | ) {
10 | useEffect(() => {
11 | const changeHandler: UseChromeStorageListenerHandler = (changes, ...rest) => {
12 | if (!key || key in changes) {
13 | handler(changes, ...rest)
14 | }
15 | }
16 |
17 | chrome.storage.onChanged.addListener(changeHandler)
18 | return () => {
19 | chrome.storage.onChanged.removeListener(changeHandler)
20 | }
21 | }, [handler, key])
22 | }
23 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useDailyChallengeQuestion.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useStorageState } from 'react-use-storage-state'
3 | import { fetchDailyChallengeQuestion } from '../modules/apis'
4 | import { type DailyChallengeQuestion } from '../types/DailyChallengeQuestion'
5 |
6 | export function useDailyChallengeQuestion () {
7 | const [dailyChallengeQuestion, setDailyChallengeQuestion] =
8 | useStorageState('dailyChallenge', null)
9 |
10 | useEffect(() => {
11 | fetchDailyChallengeQuestion().then(setDailyChallengeQuestion)
12 | }, [setDailyChallengeQuestion])
13 |
14 | const dailyChallengeQuestionUrl = dailyChallengeQuestion
15 | ? `https://leetcode.com/problems/${dailyChallengeQuestion.question.titleSlug}`
16 | : null
17 |
18 | return {
19 | dailyChallengeQuestion,
20 | dailyChallengeQuestionUrl,
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useEnableDarkTheme.ts:
--------------------------------------------------------------------------------
1 | import { ENABLED_STORAGE_KEY } from '../../../storage'
2 | import { useChromeStorage } from './useChromeStorage'
3 |
4 | export function useEnableDarkTheme () {
5 | return useChromeStorage(ENABLED_STORAGE_KEY)
6 | }
7 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useLeetCodeVersion.ts:
--------------------------------------------------------------------------------
1 | import { LEETCODE_VERSION_STORAGE_KEY } from '../../../storage'
2 | import { useChromeStorage } from './useChromeStorage'
3 |
4 | export function useLeetCodeVersion () {
5 | const [version] = useChromeStorage(LEETCODE_VERSION_STORAGE_KEY)
6 | return [version]
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useQuestions.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo } from 'react'
2 | import useStorageState from 'react-use-storage-state'
3 | import { fetchQuestions } from '../modules/apis'
4 | import { type Question, type QuestionMap } from '../types/Question'
5 | import fuzzysort from 'fuzzysort'
6 |
7 | export function useQuestions (keyword: string) {
8 | const [questions, setQuestions] =
9 | useStorageState | null>('questions', null)
10 |
11 | const questionMap = useMemo(() => {
12 | const map: QuestionMap = {}
13 |
14 | // HACK: keeping this array check for backward compatibility
15 | if (!Array.isArray(questions)) return null
16 |
17 | questions.forEach((question) => {
18 | map[question.stat.frontend_question_id] = question
19 | })
20 |
21 | return map
22 | }, [questions])
23 |
24 | useEffect(() => {
25 | fetchQuestions().then(setQuestions)
26 | }, [setQuestions])
27 |
28 | const isLoadingQuestions = !questions
29 |
30 | const matchedQuestionById = questionMap?.[Number(keyword)]
31 |
32 | const matchedQuestionResultsByKeyword = useMemo(() => {
33 | if (!questions) return null
34 | const results = fuzzysort.go(keyword, questions, {
35 | key: 'title',
36 | limit: 20,
37 | })
38 |
39 | return results
40 | }, [keyword, questions])
41 |
42 | const isNotFound = Boolean(
43 | keyword &&
44 | questions &&
45 | !matchedQuestionById &&
46 | !matchedQuestionResultsByKeyword?.length
47 | )
48 |
49 | return {
50 | questions,
51 | isLoadingQuestions,
52 | matchedQuestionById,
53 | matchedQuestionResultsByKeyword,
54 | isNotFound,
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useSelectedIndex.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react'
2 | import { useSelectedIndex } from './useSelectedIndex'
3 |
4 | describe('useSelectedIndex', () => {
5 | test('increase and decrease selected index', () => {
6 | const { result } = renderHook(() => useSelectedIndex(3))
7 |
8 | expect(result.current.selectedIndex).toBe(0)
9 | act(() => { result.current.increaseSelectedIndex() })
10 | expect(result.current.selectedIndex).toBe(1)
11 | act(() => { result.current.increaseSelectedIndex() })
12 | expect(result.current.selectedIndex).toBe(2)
13 | act(() => { result.current.increaseSelectedIndex() })
14 | expect(result.current.selectedIndex).toBe(0)
15 |
16 | act(() => { result.current.decreaseSelectedIndex() })
17 | expect(result.current.selectedIndex).toBe(2)
18 | act(() => { result.current.decreaseSelectedIndex() })
19 | expect(result.current.selectedIndex).toBe(1)
20 | })
21 |
22 | test('reset selected index', () => {
23 | const { result } = renderHook(() => useSelectedIndex(10))
24 |
25 | act(() => {
26 | for (let i = 0; i < 5; i++) {
27 | result.current.increaseSelectedIndex()
28 | }
29 | })
30 | expect(result.current.selectedIndex).toBe(5)
31 |
32 | act(() => { result.current.resetSelectedIndex() })
33 | expect(result.current.selectedIndex).toBe(0)
34 | })
35 |
36 | test('should reset index when length changes', () => {
37 | const { result, rerender } = renderHook(
38 | (args: Parameters) => useSelectedIndex(...args),
39 | { initialProps: [5] }
40 | )
41 |
42 | act(() => {
43 | for (let i = 0; i < 3; i++) {
44 | result.current.increaseSelectedIndex()
45 | }
46 | })
47 | expect(result.current.selectedIndex).toBe(3)
48 |
49 | rerender([3])
50 | expect(result.current.selectedIndex).toBe(0)
51 | })
52 | })
53 |
--------------------------------------------------------------------------------
/src/pages/Popup/hooks/useSelectedIndex.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react'
2 | import { type Nullish } from '../types/Nullish'
3 |
4 | export function useSelectedIndex (length?: Nullish) {
5 | const [selectedIndex, setSelectedIndex] = useState(-1)
6 |
7 | const resetSelectedIndex = useCallback(() => {
8 | setSelectedIndex(length ? 0 : -1)
9 | }, [length])
10 |
11 | const increaseSelectedIndex = useCallback(() => {
12 | if (!length) return resetSelectedIndex()
13 | setSelectedIndex(index => (index + 1 + length) % length)
14 | }, [length, resetSelectedIndex])
15 |
16 | const decreaseSelectedIndex = useCallback(() => {
17 | if (!length) return resetSelectedIndex()
18 | setSelectedIndex(index => (index >= 1) ? index - 1 : length - 1)
19 | }, [length, resetSelectedIndex])
20 |
21 | useEffect(() => resetSelectedIndex(), [length, resetSelectedIndex])
22 |
23 | return {
24 | selectedIndex,
25 | resetSelectedIndex,
26 | increaseSelectedIndex,
27 | decreaseSelectedIndex,
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/pages/Popup/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next'
2 | import LanguageDetector from 'i18next-browser-languagedetector'
3 | import { initReactI18next } from 'react-i18next'
4 |
5 | import translationEN from './langs/en.json'
6 | import translationZhHant from './langs/zh-Hant.json'
7 | import translationZhHans from './langs/zh-Hans.json'
8 | import translationJa from './langs/ja.json'
9 | import translationKo from './langs/ko.json'
10 |
11 | i18n
12 | .use(LanguageDetector)
13 | .use(initReactI18next)
14 | .init({
15 | resources: {
16 | en: { translation: translationEN },
17 | 'zh-Hant': { translation: translationZhHant },
18 | 'zh-Hans': { translation: translationZhHans },
19 | ja: { translation: translationJa },
20 | ko: { translation: translationKo },
21 | },
22 | fallbackLng: {
23 | 'zh-TW': ['zh-Hant', 'en'],
24 | 'zh-HK': ['zh-Hant', 'en'],
25 | 'zh-MO': ['zh-Hant', 'en'],
26 |
27 | 'zh-CN': ['zh-Hans', 'en'],
28 | 'zh-SG': ['zh-Hans', 'en'],
29 | 'zh-MY': ['zh-Hans', 'en'],
30 |
31 | 'ja-JP': ['ja', 'en'],
32 | ja: ['ja', 'en'],
33 |
34 | 'ko-KR': ['ko', 'en'],
35 | ko: ['ko', 'en'],
36 |
37 | default: ['en'],
38 | },
39 | interpolation: {
40 | escapeValue: false,
41 | },
42 | })
43 |
--------------------------------------------------------------------------------
/src/pages/Popup/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color-scheme: dark;
3 | }
4 |
5 | body {
6 | width: 450px;
7 | min-height: 550px;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | position: relative;
11 | user-select: none;
12 | overflow: hidden;
13 | font-size: 15px;
14 | line-height: 1.75;
15 | font-family: sans-serif;
16 | }
17 |
18 | #app-container {
19 | height: 100%;
20 | }
21 |
--------------------------------------------------------------------------------
/src/pages/Popup/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import { App } from './App'
4 |
5 | import 'tocas/dist/tocas.min.css'
6 | import './index.css'
7 |
8 | import './i18n'
9 | const root = document.getElementById('app-container')
10 |
11 | if (!root) throw new Error('Failed to select the root!')
12 |
13 | ReactDOM.createRoot(root).render(
14 |
15 |
16 | ,
17 | )
18 |
--------------------------------------------------------------------------------
/src/pages/Popup/langs/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "tab": {
3 | "questions": "Questions",
4 | "options": "Options"
5 | },
6 | "title": {
7 | "questions": "Questions",
8 | "dailyChallenge": "Daily Challenge",
9 | "style": "Style",
10 | "extraFeature": "Extra Feature",
11 | "language": "Language"
12 | },
13 | "option": {
14 | "enableDarkTheme": "Enable Dark Theme",
15 | "enableDarkTheme2023Description": "You're using the new version of LeetCode, which now officially supports dark mode. This extension will simply switch to its built-in dark theme without performing additional actions.",
16 | "oldVersionLeetcode": "LeetCode Old Version",
17 | "newVersionLeetcode": "LeetCode New Version ",
18 | "invertImageColor": "Invert Image Color",
19 | "mascot": "Show Mascot",
20 | "hideLogo": "Hide LeetCode Logo",
21 | "autoResetCode": "Auto Reset Code",
22 | "autoResetCodeDescription": "Always reset to default code definition.",
23 | "insertYoutubeLink": "Show YouTube Link Shortcut",
24 | "insertDislikeCount": "Show Dislike Count"
25 | },
26 | "status": {
27 | "noResult": "No Results Found"
28 | },
29 | "input": {
30 | "questionKeyword": {
31 | "placeholder": "Search by Question Number or Keyword"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/pages/Popup/langs/ja.json:
--------------------------------------------------------------------------------
1 | {
2 | "tab": {
3 | "questions": "質問",
4 | "options": "オプション"
5 | },
6 | "title": {
7 | "questions": "質問",
8 | "dailyChallenge": "デイリーチャレンジ",
9 | "style": "スタイル",
10 | "extraFeature": "追加機能",
11 | "language": "言語"
12 | },
13 | "option": {
14 | "enableDarkTheme": "ダークテーマを有効にする",
15 | "enableDarkTheme2023Description": "新バージョンの LeetCode を使用しています。これは公式にダークモードをサポートしています。この拡張機能は追加の操作をせずに内蔵のダークテーマに切り替えます。",
16 | "oldVersionLeetcode": "LeetCode 旧バージョン",
17 | "newVersionLeetcode": "LeetCode 新バージョン",
18 | "invertImageColor": "画像の色を反転する",
19 | "mascot": "マスコットを表示する",
20 | "hideLogo": "LeetCode ロゴを隠す",
21 | "autoResetCode": "コードの自動リセット",
22 | "autoResetCodeDescription": "常にデフォルトのコード定義にリセット。",
23 | "insertYoutubeLink": "YouTube リンクショートカットを表示する",
24 | "insertDislikeCount": "低く評価カウンターを表示する"
25 | },
26 | "status": {
27 | "noResult": "結果が見つかりません"
28 | },
29 | "input": {
30 | "questionKeyword": {
31 | "placeholder": "質問番号またはキーワードで検索"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/pages/Popup/langs/ko.json:
--------------------------------------------------------------------------------
1 | {
2 | "tab": {
3 | "questions": "문제",
4 | "options": "옵션"
5 | },
6 | "title": {
7 | "questions": "문제",
8 | "dailyChallenge": "일일 챌린지",
9 | "style": "스타일",
10 | "extraFeature": "추가 기능",
11 | "language": "언어"
12 | },
13 | "option": {
14 | "enableDarkTheme": "다크 테마 사용",
15 | "enableDarkTheme2023Description": "새로운 버전의 LeetCode를 사용 중입니다. 이제 공식적으로 다크 모드를 지원합니다. 이 확장 프로그램은 추가 작업 없이 내장된 다크 테마로 전환됩니다.",
16 | "oldVersionLeetcode": "LeetCode 구버전",
17 | "newVersionLeetcode": "LeetCode 신버전",
18 | "invertImageColor": "이미지 색상 반전",
19 | "mascot": "마스코트 표시",
20 | "hideLogo": "LeetCode 로고 숨기기",
21 | "autoResetCode": "자동 코드 리셋",
22 | "autoResetCodeDescription": "항상 기본 코드 정의로 리셋합니다.",
23 | "insertYoutubeLink": "YouTube 링크 바로가기 표시",
24 | "insertDislikeCount": "싫어요 카운트 표시"
25 | },
26 | "status": {
27 | "noResult": "결과를 찾을 수 없음"
28 | },
29 | "input": {
30 | "questionKeyword": {
31 | "placeholder": "문제 번호나 키워드로 검색"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/pages/Popup/langs/zh-Hans.json:
--------------------------------------------------------------------------------
1 | {
2 | "tab": {
3 | "questions": "题目",
4 | "options": "设置"
5 | },
6 | "title": {
7 | "questions": "题目",
8 | "dailyChallenge": "每日挑战",
9 | "style": "样式",
10 | "extraFeature": "额外功能",
11 | "language": "语言"
12 | },
13 | "option": {
14 | "enableDarkTheme": "启用深色主题",
15 | "enableDarkTheme2023Description": "你正在使用官方支持深色模式的新版 LeetCode,这个扩展功能将会直接切换到其内建的深色主题,不会进行额外操作。",
16 | "oldVersionLeetcode": "旧版 LeetCode",
17 | "newVersionLeetcode": "新版 LeetCode",
18 | "invertImageColor": "反转图片颜色",
19 | "mascot": "显示吉祥物",
20 | "hideLogo": "隐藏 LeetCode Logo",
21 | "autoResetCode": "自动重置代码",
22 | "autoResetCodeDescription": "总是将代码重置成初始定义",
23 | "insertYoutubeLink": "显示 YouTube 链接快捷方式",
24 | "insertDislikeCount": "显示不喜欢计数器"
25 | },
26 | "status": {
27 | "noResult": "找不到结果"
28 | },
29 | "input": {
30 | "questionKeyword": {
31 | "placeholder": "以题目编号或关键词搜索"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/pages/Popup/langs/zh-Hant.json:
--------------------------------------------------------------------------------
1 | {
2 | "tab": {
3 | "questions": "題目",
4 | "options": "設定"
5 | },
6 | "title": {
7 | "questions": "題目",
8 | "dailyChallenge": "每日挑戰",
9 | "style": "樣式",
10 | "extraFeature": "額外功能",
11 | "language": "語言"
12 | },
13 | "option": {
14 | "enableDarkTheme": "啟用深色主題",
15 | "enableDarkTheme2023Description": "你正在使用官方支援深色模式的新版 LeetCode,這個擴充功能將會直接切換到其內建的深色主題,不會進行額外操作。",
16 | "oldVersionLeetcode": "舊版 LeetCode",
17 | "newVersionLeetcode": "新版 LeetCode",
18 | "invertImageColor": "反轉圖片顏色",
19 | "mascot": "顯示吉祥物",
20 | "hideLogo": "隱藏 LeetCode Logo",
21 | "autoResetCode": "自動重置程式碼",
22 | "autoResetCodeDescription": "總是將程式碼重置成初始定義",
23 | "insertYoutubeLink": "顯示 YouTube 連結捷徑",
24 | "insertDislikeCount": "顯示不喜歡計數器"
25 | },
26 | "status": {
27 | "noResult": "找不到結果"
28 | },
29 | "input": {
30 | "questionKeyword": {
31 | "placeholder": "以題目編號或關鍵詞搜尋"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/pages/Popup/modules/apis.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { request, gql } from 'graphql-request'
3 | import { type DailyChallengeQuestion } from '../types/DailyChallengeQuestion'
4 | import { type Question, type RawQuestion } from '../types/Question'
5 |
6 | export function getQuestionUrl (titleSlug: string) {
7 | return `https://leetcode.com/problems/${titleSlug}`
8 | }
9 |
10 | export async function fetchQuestions () {
11 | const { data } = await axios.get<{ stat_status_pairs: RawQuestion[] }>(
12 | 'https://leetcode.com/api/problems/all/'
13 | )
14 |
15 | const extendedQuestions =
16 | data?.stat_status_pairs.map(question => ({
17 | ...question,
18 | title: question.stat.question__title,
19 | url: getQuestionUrl(question.stat.question__title_slug),
20 | }))
21 |
22 | return extendedQuestions as readonly Question[]
23 | }
24 |
25 | export async function fetchDailyChallengeQuestion () {
26 | const query = gql`
27 | query questionOfToday {
28 | activeDailyCodingChallengeQuestion {
29 | date
30 | userStatus
31 | question {
32 | acRate
33 | difficulty
34 | freqBar
35 | frontendQuestionId: questionFrontendId
36 | isFavor
37 | status
38 | title
39 | titleSlug
40 | }
41 | }
42 | }
43 | `
44 | const data = await request('https://leetcode.com/graphql/', query)
45 | return data.activeDailyCodingChallengeQuestion as DailyChallengeQuestion
46 | }
47 |
--------------------------------------------------------------------------------
/src/pages/Popup/modules/themes.ts:
--------------------------------------------------------------------------------
1 | export const colors = {
2 | leetcodeNight: '#ffd019',
3 |
4 | green: '#51B5A3',
5 | yellow: '#F6C149',
6 | red: '#EB4B63',
7 | gray: '#5C5C5C',
8 | } as const
9 |
--------------------------------------------------------------------------------
/src/pages/Popup/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | LeetCode Night
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/pages/Popup/types/DailyChallengeQuestion.ts:
--------------------------------------------------------------------------------
1 | import { type QuestionStatus } from './Question'
2 |
3 | type UserStatus = 'NotStart' | 'Finish'
4 |
5 | export interface DailyChallengeQuestion {
6 | date: string,
7 | question: {
8 | acRate: number,
9 | difficulty: 'Easy' | 'Medium' | 'Hard',
10 | freqBar: null,
11 | frontendQuestionId: string,
12 | isFavor: boolean,
13 | status: QuestionStatus,
14 | title: string,
15 | titleSlug: string,
16 | },
17 | userStatus: UserStatus,
18 | }
19 |
--------------------------------------------------------------------------------
/src/pages/Popup/types/Nullish.ts:
--------------------------------------------------------------------------------
1 | export type Nullish = T | null | undefined
2 |
--------------------------------------------------------------------------------
/src/pages/Popup/types/Question.ts:
--------------------------------------------------------------------------------
1 | export interface Stat {
2 | frontend_question_id: number,
3 | is_new_question: boolean,
4 | question__hide: boolean,
5 | question__title: string,
6 | question__title_slug: string,
7 | question_id: number,
8 | total_acs: number,
9 | total_submitted: number,
10 | }
11 |
12 | export interface Difficulty {
13 | level: 1 | 2 | 3,
14 | }
15 |
16 | export type QuestionStatus = null | 'ac' | 'notac'
17 |
18 | export interface RawQuestion {
19 | difficulty: Difficulty,
20 | frequency: number,
21 | paid_only: boolean,
22 | progress: number,
23 | stat: Stat,
24 | status: QuestionStatus,
25 | }
26 |
27 | export interface Question extends RawQuestion {
28 | title: string,
29 | url: string,
30 | }
31 |
32 | export type QuestionMap = Record
33 |
--------------------------------------------------------------------------------
/src/storage.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_OPTIONS, type OptionsForm } from './options'
2 | import { type LeetcodeVersion } from './pages/Content/leetcode-version'
3 | import { type Nullish } from './pages/Popup/types/Nullish'
4 |
5 | export const ENABLED_STORAGE_KEY = 'enabled'
6 | export const LEETCODE_VERSION_STORAGE_KEY = 'leetcodeVersion'
7 | export const OPTIONS_STORAGE_KEY = 'options'
8 | export const AUTO_RESET_CODE_ENABLED_STORAGE_KEY = 'autoResetCodeEnabled'
9 | export const INSERT_YOUTUBE_LINK_STORAGE_KEY = 'insertYoutubeLinkEnabled'
10 | export const INSERT_DISLIKE_COUNT_STORAGE_KEY = 'insertDislikeCountEnabled'
11 |
12 | export interface StorageSchema {
13 | [ENABLED_STORAGE_KEY]: boolean,
14 | [LEETCODE_VERSION_STORAGE_KEY]: Nullish,
15 | [OPTIONS_STORAGE_KEY]: OptionsForm,
16 | [AUTO_RESET_CODE_ENABLED_STORAGE_KEY]: boolean,
17 | [INSERT_YOUTUBE_LINK_STORAGE_KEY]: boolean,
18 | [INSERT_DISLIKE_COUNT_STORAGE_KEY]: boolean,
19 | }
20 |
21 | export const storageDefaultValues: StorageSchema = {
22 | [ENABLED_STORAGE_KEY]: true,
23 | [LEETCODE_VERSION_STORAGE_KEY]: null,
24 | [OPTIONS_STORAGE_KEY]: DEFAULT_OPTIONS,
25 | [AUTO_RESET_CODE_ENABLED_STORAGE_KEY]: false,
26 | [INSERT_YOUTUBE_LINK_STORAGE_KEY]: true,
27 | [INSERT_DISLIKE_COUNT_STORAGE_KEY]: true,
28 | }
29 |
30 | export type StorageKey = keyof StorageSchema
31 |
32 | const localStorageKeys = new Set([
33 | LEETCODE_VERSION_STORAGE_KEY,
34 | ])
35 |
36 | export async function getStorage <
37 | Key extends StorageKey
38 | > (key: Key): Promise {
39 | const value = localStorageKeys.has(key)
40 | ? (await chrome.storage.local.get(key))[key]
41 | : (await chrome.storage.sync.get(key))[key]
42 |
43 | return value ?? structuredClone(storageDefaultValues[key])
44 | }
45 |
46 | export async function setStorage<
47 | Key extends StorageKey
48 | > (key: Key, value: StorageSchema[Key]) {
49 | if (localStorageKeys.has(key)) {
50 | await chrome.storage.local.set({ [key]: value })
51 | } else {
52 | await chrome.storage.sync.set({ [key]: value })
53 | }
54 | }
55 |
56 | export const loadIsEnabled = () => {
57 | return getStorage(ENABLED_STORAGE_KEY)
58 | }
59 |
60 | export const saveIsEnabled = (isEnabled: boolean) => {
61 | return setStorage(ENABLED_STORAGE_KEY, isEnabled)
62 | }
63 |
64 | export const loadLeetcodeVersion = () => {
65 | return getStorage(LEETCODE_VERSION_STORAGE_KEY)
66 | }
67 |
68 | export const saveLeetcodeVersion = (version: LeetcodeVersion) => {
69 | return setStorage(LEETCODE_VERSION_STORAGE_KEY, version)
70 | }
71 |
72 | export const loadOptions = () => {
73 | return getStorage(OPTIONS_STORAGE_KEY)
74 | }
75 |
76 | export const saveOptions = (options: OptionsForm) => {
77 | return setStorage(OPTIONS_STORAGE_KEY, options)
78 | }
79 |
80 | export const loadIsAutoResetCodeEnabled = () => {
81 | return getStorage(AUTO_RESET_CODE_ENABLED_STORAGE_KEY)
82 | }
83 |
84 | export const saveIsAutoResetCodeEnabled = (isEnabled: boolean) => {
85 | return setStorage(AUTO_RESET_CODE_ENABLED_STORAGE_KEY, isEnabled)
86 | }
87 |
88 | export const loadIsInsertYoutubeLinkEnabled = () => {
89 | return getStorage(INSERT_YOUTUBE_LINK_STORAGE_KEY)
90 | }
91 |
92 | export const saveIsInsertYoutubeLinkEnabled = (isEnabled: boolean) => {
93 | return setStorage(INSERT_YOUTUBE_LINK_STORAGE_KEY, isEnabled)
94 | }
95 |
96 | export const loadIsInsertDislikeCountEnabled = () => {
97 | return getStorage(INSERT_DISLIKE_COUNT_STORAGE_KEY)
98 | }
99 |
100 | export const saveIsInsertDislikeCountEnabled = (isEnabled: boolean) => {
101 | return setStorage(INSERT_DISLIKE_COUNT_STORAGE_KEY, isEnabled)
102 | }
103 |
--------------------------------------------------------------------------------
/src/tests/e2e/cookie.json.example:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "LEETCODE_SESSION",
4 | "value": ""
5 | }
6 | ]
7 |
--------------------------------------------------------------------------------
/src/tests/e2e/dailyChallenge.test.ts:
--------------------------------------------------------------------------------
1 | import { TEST_IDS } from '../../constants'
2 | import { useBrowserAndPages } from '../helpers/puppeteer'
3 |
4 | describe('Daily Challenge', () => {
5 | const { getBrowser, getPopupPage } =
6 | useBrowserAndPages({ leetcodeVersion: '2023-dynamic-layout' })
7 |
8 | test('should render the card', async () => {
9 | const browser = getBrowser()
10 | const popupPage = await getPopupPage()
11 |
12 | const card = await popupPage.waitForSelector(
13 | `[data-testid=${TEST_IDS.dailyChallengeQuestionCard}]`
14 | )
15 |
16 | const textContent = await card?.evaluate((card) => card.textContent)
17 | expect(textContent).toMatch(/\d{4}-\d{2}-\d{2}/)
18 | expect(textContent).toMatch(/\d{1,5}\. (.+)/)
19 | expect(textContent).toMatch(/Easy|Medium|Hard/)
20 |
21 | const link = await card?.$('a')
22 | await link?.click()
23 | const target = await browser.waitForTarget(
24 | target => target.opener() === popupPage.target()
25 | )
26 | const questionPage = await target.page()
27 | const questionPageUrl = questionPage?.url()
28 |
29 | expect(questionPageUrl).toMatch(/https:\/\/leetcode.com\/problems\/(.+)/)
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/src/tests/e2e/darkTheme2023.test.ts:
--------------------------------------------------------------------------------
1 | import { useBrowserAndPages } from '../helpers/puppeteer'
2 | import { useOptions } from '../helpers/useOptions'
3 |
4 | describe('[2023] Dark theme', () => {
5 | const optionLabel = 'Enable Dark Theme'
6 |
7 | const { getPage, getPopupPage } =
8 | useBrowserAndPages({ leetcodeVersion: '2023' })
9 |
10 | const { toggleOptionSwitch } = useOptions({ getPopupPage })
11 |
12 | async function getColorScheme () {
13 | const page = await getPage()
14 | const colorScheme = await page.evaluate(() => (
15 | document.documentElement.style.colorScheme
16 | ))
17 | return colorScheme
18 | }
19 |
20 | test('should toggle built-in dark theme', async () => {
21 | expect(await getColorScheme()).toBe('dark')
22 |
23 | await toggleOptionSwitch(optionLabel, false)
24 | expect(await getColorScheme()).toBe('light')
25 |
26 | await toggleOptionSwitch(optionLabel, true)
27 | expect(await getColorScheme()).toBe('dark')
28 |
29 | await toggleOptionSwitch(optionLabel, false)
30 | expect(await getColorScheme()).toBe('light')
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/src/tests/e2e/darkTheme2023DynamicLayout.test.ts:
--------------------------------------------------------------------------------
1 | import { useBrowserAndPages } from '../helpers/puppeteer'
2 | import { useOptions } from '../helpers/useOptions'
3 |
4 | describe('[2023 Dynamic Layout] Dark theme', () => {
5 | const optionLabel = 'Enable Dark Theme'
6 |
7 | const { getPage, getPopupPage } =
8 | useBrowserAndPages({ leetcodeVersion: '2023-dynamic-layout' })
9 |
10 | const { toggleOptionSwitch } = useOptions({ getPopupPage })
11 |
12 | async function getColorScheme () {
13 | const page = await getPage()
14 | const colorScheme = await page.evaluate(() => (
15 | document.documentElement.style.colorScheme
16 | ))
17 | return colorScheme
18 | }
19 |
20 | test('should toggle built-in dark theme', async () => {
21 | expect(await getColorScheme()).toBe('dark')
22 |
23 | await toggleOptionSwitch(optionLabel, false)
24 | expect(await getColorScheme()).toBe('light')
25 |
26 | await toggleOptionSwitch(optionLabel, true)
27 | expect(await getColorScheme()).toBe('dark')
28 |
29 | await toggleOptionSwitch(optionLabel, false)
30 | expect(await getColorScheme()).toBe('light')
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/src/tests/e2e/footer.test.ts:
--------------------------------------------------------------------------------
1 | import { useBrowserAndPages } from '../helpers/puppeteer'
2 |
3 | describe('Footer', () => {
4 | const { getBrowser, getPopupPage } =
5 | useBrowserAndPages({ leetcodeVersion: '2023-dynamic-layout' })
6 |
7 | test('links in footer should be valid', async () => {
8 | const browser = getBrowser()
9 | const popupPage = await getPopupPage()
10 |
11 | const footer = await popupPage.waitForSelector('footer')
12 | const links = await footer?.$$('a') ?? []
13 |
14 | expect(links.length).toBeGreaterThan(0)
15 |
16 | for (const link of links) {
17 | expect(await link.evaluate((link) => link.getAttribute('target')))
18 | .toBe('_blank')
19 |
20 | const url = await link.evaluate((link) => link.getAttribute('href'))
21 | expect(url).toBeTruthy()
22 |
23 | const newPage = await browser.newPage()
24 | const response = await newPage.goto(url ?? '')
25 | expect(response?.status()).toBe(200)
26 |
27 | await newPage.close()
28 | }
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/src/tests/e2e/insertDislikeCount2023DynamicLayout.test.ts:
--------------------------------------------------------------------------------
1 | import { type ElementHandle } from 'puppeteer'
2 | import { useBrowserAndPages } from '../helpers/puppeteer'
3 | import { useOptions } from '../helpers/useOptions'
4 | import { usePagination2023 } from '../helpers/usePagination2023'
5 | import { customDislikeTextDatasetValue, customLikeTextDatasetValue, datasetKey, likeTextDatasetValue } from '../../pages/Content/InsertDislikeCount'
6 | import { waitUntil } from '../helpers/waitUntil'
7 |
8 | describe('[2023 Dynamic Layout] Insert dislike count', () => {
9 | const optionLabel = 'Show Dislike Count'
10 |
11 | const { getPage, getPopupPage } =
12 | useBrowserAndPages({ leetcodeVersion: '2023-dynamic-layout' })
13 |
14 | const { toggleOptionSwitch } = useOptions({ getPopupPage })
15 |
16 | const { goToNextPage, goToPreviousPage } = usePagination2023({ getPage })
17 |
18 | async function selectElement (datasetValue: string) {
19 | const page = await getPage()
20 | const selector = `[data-${datasetKey}=${datasetValue}]`
21 |
22 | return await page.$(selector)
23 | }
24 |
25 | async function getCount (element: ElementHandle) {
26 | const rawText = await element?.evaluate((text) => text.textContent)
27 | if (!rawText) return null
28 |
29 | const count = +rawText.trim().replaceAll(',', '')
30 | return count
31 | }
32 |
33 | async function getLikeCount () {
34 | const element = await selectElement(customLikeTextDatasetValue)
35 | if (!element) return
36 | return await getCount(element)
37 | }
38 |
39 | async function getDislikeCount () {
40 | const element = await selectElement(customDislikeTextDatasetValue)
41 | if (!element) return
42 | return await getCount(element)
43 | }
44 |
45 | async function getIsOriginalLikeCountVisible () {
46 | const element = await selectElement(likeTextDatasetValue)
47 | return await element?.isVisible()
48 | }
49 |
50 | test('disable and enable insert dislike count', async () => {
51 | expect(await getIsOriginalLikeCountVisible()).toBe(false)
52 | expect(await getDislikeCount()).toBeGreaterThan(0)
53 |
54 | await toggleOptionSwitch(optionLabel, false)
55 | expect(await getIsOriginalLikeCountVisible()).toBe(true)
56 | expect(await getDislikeCount()).toBeFalsy()
57 |
58 | await toggleOptionSwitch(optionLabel, true)
59 | expect(await getIsOriginalLikeCountVisible()).toBe(false)
60 | expect(await getDislikeCount()).toBeGreaterThan(0)
61 |
62 | await toggleOptionSwitch(optionLabel, false)
63 | expect(await getIsOriginalLikeCountVisible()).toBe(true)
64 | expect(await getDislikeCount()).toBeFalsy()
65 | })
66 |
67 | test('update like and dislike count after navigation', async () => {
68 | await toggleOptionSwitch(optionLabel, true)
69 |
70 | const likeCount = await getLikeCount()
71 | const dislikeCount = await getDislikeCount()
72 | expect(likeCount).toBeGreaterThan(0)
73 | expect(dislikeCount).toBeGreaterThan(0)
74 |
75 | await goToNextPage()
76 | await waitUntil(async () => !(
77 | await getLikeCount() === likeCount ||
78 | await getDislikeCount() === dislikeCount
79 | ))
80 |
81 | const anotherLikeCount = await getLikeCount()
82 | const anotherDislikeCount = await getDislikeCount()
83 | expect(anotherLikeCount).toBeGreaterThan(0)
84 | expect(anotherDislikeCount).toBeGreaterThan(0)
85 | expect(anotherLikeCount).not.toBe(likeCount)
86 | expect(anotherDislikeCount).not.toBe(dislikeCount)
87 |
88 | await goToPreviousPage()
89 | await waitUntil(async () => !(
90 | await getLikeCount() === anotherLikeCount ||
91 | await getDislikeCount() === anotherDislikeCount
92 | ))
93 |
94 | expect(await getLikeCount()).not.toBe(anotherLikeCount)
95 | expect(await getDislikeCount()).not.toBe(anotherDislikeCount)
96 | })
97 | })
98 |
--------------------------------------------------------------------------------
/src/tests/e2e/insertYoutubeLink2023.test.ts:
--------------------------------------------------------------------------------
1 | import { useBrowserAndPages } from '../helpers/puppeteer'
2 | import { useOptions } from '../helpers/useOptions'
3 | import { useYoutubeLink } from '../helpers/useYoutubeLink'
4 | import { usePagination2023 } from '../helpers/usePagination2023'
5 |
6 | describe('[2023 Split View] Insert YouTube Link', () => {
7 | const optionLabel = 'Show YouTube Link Shortcut'
8 |
9 | const { getBrowser, getPage, getPageQuestionTitle, getPopupPage } =
10 | useBrowserAndPages({ leetcodeVersion: '2023' })
11 |
12 | const { toggleOptionSwitch } = useOptions({ getPopupPage })
13 |
14 | const { assertLink } = useYoutubeLink({
15 | getBrowser,
16 | getPage,
17 | selectLinks,
18 | getPageQuestionTitle,
19 | })
20 |
21 | const { goToNextPage, goToPreviousPage } = usePagination2023({ getPage })
22 |
23 | async function selectLinks () {
24 | const page = await getPage()
25 |
26 | const metaList = await page.waitForXPath(`//div[
27 | @class="mt-3 flex items-center space-x-4" and
28 | //*[text()[contains(., 'Easy')]]
29 | ]`)
30 |
31 | if (!metaList) throw new Error('Failed to select meta list')
32 |
33 | const links = await metaList.$$(`xpath/a[
34 | contains(@href, 'youtube.com') and
35 | contains(., 'YouTube')
36 | ]`)
37 |
38 | return links
39 | }
40 |
41 | test('disable and enable insert YouTube link', async () => {
42 | expect(await selectLinks()).toHaveLength(1)
43 |
44 | await toggleOptionSwitch(optionLabel, false)
45 | expect(await selectLinks()).toHaveLength(0)
46 |
47 | await toggleOptionSwitch(optionLabel, true)
48 | expect(await selectLinks()).toHaveLength(1)
49 |
50 | await toggleOptionSwitch(optionLabel, false)
51 | expect(await selectLinks()).toHaveLength(0)
52 | })
53 |
54 | test('insert YouTube link after navigation', async () => {
55 | await toggleOptionSwitch(optionLabel, true)
56 | await assertLink()
57 |
58 | await goToNextPage()
59 | await assertLink()
60 |
61 | await goToPreviousPage()
62 | await assertLink()
63 | })
64 | })
65 |
--------------------------------------------------------------------------------
/src/tests/e2e/insertYoutubeLink2023DynamicLayout.test.ts:
--------------------------------------------------------------------------------
1 | import { useBrowserAndPages } from '../helpers/puppeteer'
2 | import { useOptions } from '../helpers/useOptions'
3 | import { useYoutubeLink } from '../helpers/useYoutubeLink'
4 | import { usePagination2023 } from '../helpers/usePagination2023'
5 |
6 | describe('[2023 Dynamic Layout] Insert YouTube Link', () => {
7 | const optionLabel = 'Show YouTube Link Shortcut'
8 |
9 | const { getBrowser, getPage, getPageQuestionTitle, getPopupPage } =
10 | useBrowserAndPages({ leetcodeVersion: '2023-dynamic-layout' })
11 |
12 | const { toggleOptionSwitch } = useOptions({ getPopupPage })
13 |
14 | const { assertLink } = useYoutubeLink({
15 | getBrowser,
16 | getPage,
17 | selectLinks,
18 | getPageQuestionTitle,
19 | })
20 |
21 | const { goToNextPage, goToPreviousPage } = usePagination2023({ getPage })
22 |
23 | async function selectLinks () {
24 | const page = await getPage()
25 |
26 | const metaList = await page.waitForXPath(`//div[
27 | @class="flex gap-1" and
28 | //*[text()[contains(., 'Easy')]]
29 | ]`)
30 |
31 | if (!metaList) throw new Error('Failed to select meta list')
32 |
33 | const links = await metaList.$$(`xpath/a[
34 | contains(@href, 'youtube.com') and
35 | contains(., 'YouTube')
36 | ]`)
37 |
38 | return links
39 | }
40 |
41 | test('disable and enable insert YouTube link', async () => {
42 | expect(await selectLinks()).toHaveLength(1)
43 |
44 | await toggleOptionSwitch(optionLabel, false)
45 | expect(await selectLinks()).toHaveLength(0)
46 |
47 | await toggleOptionSwitch(optionLabel, true)
48 | expect(await selectLinks()).toHaveLength(1)
49 |
50 | await toggleOptionSwitch(optionLabel, false)
51 | expect(await selectLinks()).toHaveLength(0)
52 | })
53 |
54 | test('insert YouTube link after navigation', async () => {
55 | await toggleOptionSwitch(optionLabel, true)
56 | await assertLink()
57 |
58 | await goToNextPage()
59 | await assertLink()
60 |
61 | await goToPreviousPage()
62 | await assertLink()
63 | })
64 | })
65 |
--------------------------------------------------------------------------------
/src/tests/e2e/resetCode2023.test.ts:
--------------------------------------------------------------------------------
1 | import { useBrowserAndPages } from '../helpers/puppeteer'
2 | import { useOptions } from '../helpers/useOptions'
3 | import { useResetCode } from '../helpers/useResetCode'
4 |
5 | describe('[2023 Dynamic Layout] Reset code', () => {
6 | const { getPage, getPopupPage } = useBrowserAndPages({
7 | leetcodeVersion: '2023',
8 | })
9 |
10 | const { toggleOptionSwitch } = useOptions({ getPopupPage })
11 |
12 | const { assertResetCode } = useResetCode({ getPage, toggleOptionSwitch })
13 |
14 | test('should reset code when entering the page', async () => {
15 | await assertResetCode()
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/src/tests/e2e/resetCode2023DynamicLayout.test.ts:
--------------------------------------------------------------------------------
1 | import { useBrowserAndPages } from '../helpers/puppeteer'
2 | import { useOptions } from '../helpers/useOptions'
3 | import { useResetCode } from '../helpers/useResetCode'
4 |
5 | describe('[2023] Reset code', () => {
6 | const { getPage, getPopupPage } = useBrowserAndPages({
7 | leetcodeVersion: '2023-dynamic-layout',
8 | })
9 |
10 | const { toggleOptionSwitch } = useOptions({ getPopupPage })
11 |
12 | const { assertResetCode } = useResetCode({ getPage, toggleOptionSwitch })
13 |
14 | test('should reset code when entering the page', async () => {
15 | await assertResetCode()
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/src/tests/helpers/mockDailyChallengeQuestion.ts:
--------------------------------------------------------------------------------
1 | export const mockDailyChallengeQuestion = {
2 | date: '2024-02-08',
3 | userStatus: 'NotStart',
4 | question: {
5 | acRate: 53.85032333412031,
6 | difficulty: 'Medium',
7 | freqBar: null,
8 | frontendQuestionId: '279',
9 | isFavor: false,
10 | status: null,
11 | title: 'Perfect Squares',
12 | titleSlug: 'perfect-squares',
13 | },
14 | } as const
15 |
--------------------------------------------------------------------------------
/src/tests/helpers/puppeteer.ts:
--------------------------------------------------------------------------------
1 | import puppeteer, { type Page, type Browser } from 'puppeteer'
2 | import { EXTENSION_ID } from '../../constants'
3 | import { type LeetcodeVersion } from '../../pages/Content/leetcode-version'
4 | import fs from 'fs-extra'
5 |
6 | function getMockCookie () {
7 | try {
8 | return fs.readJsonSync('./src/tests/e2e/cookie.json')
9 | } catch (err) {
10 | return []
11 | }
12 | }
13 |
14 | const extensionPath = 'dist'
15 |
16 | export async function createBrowser () {
17 | const browser = await puppeteer.launch({
18 | headless: 'new',
19 | // headless: false,
20 | args: [
21 | `--disable-extensions-except=${extensionPath}`,
22 | `--load-extension=${extensionPath}`,
23 | ],
24 | })
25 |
26 | return browser
27 | }
28 |
29 | export async function createPage (
30 | browser: Browser,
31 | { leetcodeVersion }: {
32 | leetcodeVersion: LeetcodeVersion,
33 | }
34 | ) {
35 | const slug = 'two-sum'
36 | const url = `https://leetcode.com/problems/${slug}/description/`
37 |
38 | const page = await browser.newPage()
39 | await page.setViewport({ width: 1400, height: 900 })
40 | await page.goto(url)
41 | await page.setCookie(...getMockCookie())
42 | await page.evaluate(function skipGuide () {
43 | localStorage.setItem('dynamicIdeLayoutGuide', 'true')
44 | localStorage.setItem('QD_SHOWN_DYNAMIC_LAYOUT_MODAL', 'true')
45 | })
46 | if (leetcodeVersion === '2023') {
47 | await page.evaluate(() => {
48 | localStorage.setItem('dynamic-layout-disabled', 'true')
49 | })
50 | }
51 |
52 | await page.reload({ waitUntil: 'networkidle2' })
53 |
54 | return page
55 | }
56 |
57 | export async function createPopupPage (browser: Browser) {
58 | const url = `chrome-extension://${EXTENSION_ID}/src/pages/Popup/popup.html`
59 |
60 | const page = await browser.newPage()
61 | await page.setViewport({ width: 1400, height: 900 })
62 | await page.goto(url, { waitUntil: 'networkidle2' })
63 |
64 | return page
65 | }
66 |
67 | export function useBrowserAndPages ({ leetcodeVersion }: {
68 | leetcodeVersion: LeetcodeVersion,
69 | }) {
70 | let browser: Browser | undefined
71 | let page: Page | undefined
72 | let popupPage: Page | undefined
73 |
74 | beforeEach(async () => {
75 | browser = await createBrowser()
76 | })
77 |
78 | afterEach(async () => {
79 | await browser?.close()
80 |
81 | browser = undefined
82 | page = undefined
83 | popupPage = undefined
84 | })
85 |
86 | /** Return the same `browser` in each test */
87 | function getBrowser () {
88 | if (!browser) throw new Error()
89 | return browser
90 | }
91 |
92 | /** Return the same `page` in each test */
93 | async function getPage () {
94 | if (!browser) throw new Error()
95 | if (!page) {
96 | page = await createPage(browser, { leetcodeVersion })
97 | }
98 | await page.bringToFront()
99 | return page
100 | }
101 |
102 | async function getPageQuestionTitle () {
103 | const page = await getPage()
104 | const headTitle = await page.evaluate(() => document.title)
105 | const questionTitle = headTitle.replaceAll(' - LeetCode', '').trim()
106 |
107 | return questionTitle
108 | }
109 |
110 | /** Return the same `popupPage` in each test */
111 | async function getPopupPage () {
112 | if (!browser) throw new Error()
113 | if (!popupPage) {
114 | popupPage = await createPopupPage(browser)
115 | }
116 | await popupPage.bringToFront()
117 | return popupPage
118 | }
119 |
120 | return {
121 | getBrowser,
122 | getPage,
123 | getPageQuestionTitle,
124 | getPopupPage,
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/tests/helpers/sleep.ts:
--------------------------------------------------------------------------------
1 | export async function sleep (ms?: number) {
2 | await new Promise(resolve => setTimeout(resolve, ms))
3 | }
4 |
--------------------------------------------------------------------------------
/src/tests/helpers/useOptions.ts:
--------------------------------------------------------------------------------
1 | import { type ElementHandle, type Page } from 'puppeteer'
2 | import { TEST_IDS } from '../../constants'
3 | import { sleep } from './sleep'
4 |
5 | export function useOptions ({ getPopupPage }: {
6 | getPopupPage: () => Promise,
7 | }) {
8 | async function toggleOptionSwitch (label: string, value: boolean) {
9 | const popupPage = await getPopupPage()
10 |
11 | const optionsTab = await popupPage.$(`[data-testid=${TEST_IDS.optionsTabButton}]`)
12 | await optionsTab?.click()
13 |
14 | const checkbox = await popupPage.waitForXPath(
15 | `//label[contains(., '${label}')]/input`
16 | ) as ElementHandle
17 |
18 | const isChecked = await checkbox.evaluate(
19 | (checkbox) => (checkbox as HTMLInputElement).checked
20 | )
21 |
22 | if (isChecked === value) return
23 |
24 | await checkbox.click()
25 | await sleep(300)
26 | }
27 |
28 | return {
29 | toggleOptionSwitch,
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/tests/helpers/usePagination2023.ts:
--------------------------------------------------------------------------------
1 | import { type ElementHandle, type Page } from 'puppeteer'
2 | import { sleep } from './sleep'
3 | import { waitUntil } from './waitUntil'
4 |
5 | export function usePagination2023 ({ getPage }: {
6 | getPage: () => Promise,
7 | }) {
8 | async function getPreviousAndNextLink () {
9 | const page = await getPage()
10 |
11 | const [previousLink, nextLink] = await page.$x(`
12 | //nav//*[contains(.,'Problem List')]
13 | //a[contains(@class,'group-hover:text-lc-icon-primary')]
14 | `) as Array>
15 |
16 | return { previousLink, nextLink }
17 | }
18 |
19 | async function clickLink (link: ElementHandle) {
20 | const page = await getPage()
21 |
22 | // Need to hover first so it can fetch necessary data before clicking
23 | await link.hover()
24 | await waitUntil(async () => (
25 | await link.evaluate((link) => link.getAttribute('href'))
26 | ))
27 | const currentTitle = await page.evaluate(() => document.title)
28 |
29 | await link.click()
30 | await waitUntil(async () => (
31 | await page.evaluate(() => document.title) !== currentTitle
32 | ))
33 | await sleep(1000)
34 | }
35 |
36 | async function goToNextPage () {
37 | const { nextLink } = await getPreviousAndNextLink()
38 | await clickLink(nextLink)
39 | }
40 |
41 | async function goToPreviousPage () {
42 | const { previousLink } = await getPreviousAndNextLink()
43 | await clickLink(previousLink)
44 | }
45 |
46 | return {
47 | goToNextPage,
48 | goToPreviousPage,
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/tests/helpers/useResetCode.ts:
--------------------------------------------------------------------------------
1 | import { type Page } from 'puppeteer'
2 | import { waitUntil } from './waitUntil'
3 | import { sleep } from './sleep'
4 |
5 | export function useResetCode ({
6 | getPage,
7 | toggleOptionSwitch,
8 | }: {
9 | getPage: () => Promise,
10 | toggleOptionSwitch: (label: string, value: boolean) => Promise,
11 | }) {
12 | const optionLabel = 'Auto Reset Code'
13 | const customCode = 'test\n'.repeat(10)
14 |
15 | async function assertResetCode () {
16 | const page = await getPage()
17 |
18 | async function selectFirstLine () {
19 | await waitUntil(async () => (
20 | (await page.$x('//*[@class="line-numbers" and contains(., "6")]'))[0]
21 | ))
22 |
23 | const firstLine = await page.waitForSelector('.view-line')
24 | if (!firstLine) throw new Error()
25 |
26 | return firstLine
27 | }
28 |
29 | async function typeInEditor () {
30 | const firstLine = await selectFirstLine()
31 |
32 | await firstLine.click({ count: 4 })
33 | await firstLine.press('Backspace')
34 | await firstLine.type(customCode, { delay: 10 })
35 | await sleep(300)
36 | }
37 |
38 | await typeInEditor()
39 | await page.reload({ waitUntil: 'networkidle2' })
40 |
41 | async function getFirstLineText () {
42 | const firstLine = await selectFirstLine()
43 | return await firstLine.evaluate((element) => element.textContent)
44 | }
45 |
46 | expect(await getFirstLineText()).toBe('test')
47 |
48 | await toggleOptionSwitch(optionLabel, true)
49 |
50 | await page.bringToFront()
51 | expect(await getFirstLineText()).toBe('test')
52 |
53 | await page.reload({ waitUntil: 'networkidle2' })
54 | await sleep(1000)
55 | expect(await getFirstLineText()).not.toBe('test')
56 | }
57 |
58 | return {
59 | assertResetCode,
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/tests/helpers/useYoutubeLink.ts:
--------------------------------------------------------------------------------
1 | import { type ElementHandle, type Browser, type Page } from 'puppeteer'
2 |
3 | export function useYoutubeLink ({
4 | getBrowser,
5 | getPage,
6 | selectLinks,
7 | getPageQuestionTitle,
8 | }: {
9 | getBrowser: () => Browser,
10 | getPage: () => Promise,
11 | selectLinks: () => Promise>>,
12 | getPageQuestionTitle: () => Promise,
13 | }) {
14 | async function assertLink () {
15 | const browser = getBrowser()
16 | const page = await getPage()
17 |
18 | // It should only render exact one YouTube link
19 | const links = await selectLinks()
20 | expect(links).toHaveLength(1)
21 |
22 | // Click the link and assert the opened tab
23 | await links[0].click()
24 | const target = await browser.waitForTarget(
25 | target => target.opener() === page.target()
26 | )
27 | const youtubePage = await target.page()
28 | const youtubePageUrl = youtubePage?.url()
29 |
30 | const title = await getPageQuestionTitle()
31 | expect(youtubePageUrl).toContain('https://www.youtube.com/results?search_query=')
32 | expect(youtubePageUrl).toContain(encodeURIComponent(title))
33 |
34 | await youtubePage?.close()
35 | }
36 |
37 | return {
38 | assertLink,
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/tests/helpers/waitUntil.ts:
--------------------------------------------------------------------------------
1 | import { sleep } from './sleep'
2 |
3 | export async function waitUntil (callback: () => unknown) {
4 | while (!await callback()) {
5 | await sleep(300)
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "noEmit": false,
20 | "jsx": "react-jsx",
21 | "outDir": "dist",
22 | "types": [
23 | "vitest/globals",
24 | "@types/chrome"
25 | ]
26 | },
27 | "include": [
28 | "src/**/*.ts",
29 | "src/**/*.tsx",
30 | "__tests__/**/*.ts"
31 | ],
32 | "exclude": [
33 | "dist",
34 | "node_modules"
35 | ],
36 | "references": [{ "path": "./tsconfig.node.json" }]
37 | }
38 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strictNullChecks": true
9 | },
10 | "include": [
11 | ".eslintrc.cjs",
12 | "vite.config.mts",
13 | "manifest.config.ts",
14 | "vitest.config.ts",
15 | "commitlint.config.js",
16 | "package.json",
17 | "scripts/**/*.js"
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/vite.config.mts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import { crx } from '@crxjs/vite-plugin'
4 | import manifest from './manifest.config'
5 |
6 | export default defineConfig({
7 | plugins: [
8 | react(),
9 | crx({
10 | manifest,
11 | }),
12 | ],
13 | server: {
14 | port: 5173,
15 | strictPort: true,
16 | hmr: { port: 5173 },
17 | },
18 | css: {
19 | modules: {
20 | // prevent Vite from hashing CSS id
21 | generateScopedName: (name) => name,
22 | },
23 | },
24 | })
25 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 | import react from '@vitejs/plugin-react'
3 |
4 | export default defineConfig({
5 | plugins: [react() as any],
6 | test: {
7 | setupFiles: [
8 | './__tests__/setup.ts',
9 | ],
10 | globals: true,
11 | environment: 'jsdom',
12 | testTimeout: 30000,
13 | retry: 2,
14 | sequence: {
15 | hooks: 'list',
16 | },
17 | },
18 | })
19 |
--------------------------------------------------------------------------------