├── .env.development ├── .env.production ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── cute_alert │ ├── error.svg │ └── success.svg ├── icon.png ├── manifest.json └── popup.html ├── src ├── @types │ ├── Github.d.ts │ ├── Leetcode.d.ts │ ├── Message.d.ts │ ├── Payload.d.ts │ ├── Question.d.ts │ ├── StorageKey.d.ts │ ├── Submission.d.ts │ └── environment.d.ts ├── Auth.ts ├── Components │ ├── Stats.tsx │ └── Welcome.tsx ├── CuteAlert.ts ├── GHUser.ts ├── background.ts ├── content_script.tsx ├── css │ ├── cute_alert.css │ └── popup.css ├── fetch-util.ts ├── popup.tsx ├── queries.ts ├── repo-util.ts └── util.ts ├── tsconfig.json ├── tsconfig.test.json └── webpack ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.env.development: -------------------------------------------------------------------------------- 1 | FIND_ELEMENT_ALLOWED_RETRIES=20 2 | FIND_ELEMENT_EXPIRY_IN_MS=150 3 | GITHUB_API_HOST=https://api.github.com 4 | GITHUB_AUTH_CLIENT_ID="f0931287bb5d9d34de53", 5 | GITHUB_AUTH_CLIENT_SECRET="e4443e0ceb8e72e019d86d70624379b42b661c2b" 6 | LC_API_HOST=https://leetcode.com/graphql 7 | LC_QUERIES_GET_USER_INFO="query getUserProfile($username: String!) { matchedUser(username: $username) { username submitStats: submitStatsGlobal { acSubmissionNum { difficulty count submissions } } } }" 8 | LC_QUERIES_GET_QUESTION_OF_DAY="\n query questionOfToday {\n activeDailyCodingChallengeQuestion {\n date\n userStatus\n link\n question {\n acRate\n difficulty\n freqBar\n frontendQuestionId: questionFrontendId\n isFavor\n paidOnly: isPaidOnly\n status\n title\n titleSlug\n hasVideoSolution\n hasSolution\n topicTags {\n name\n id\n slug\n }\n }\n }\n}\n " 9 | LC_QUERIES_GET_SUBMISSION_DETAILS="\n query submissionDetails($submissionId: Int!) {\n submissionDetails(submissionId: $submissionId) {\n runtime\n runtimeDisplay\n runtimePercentile\n runtimeDistribution\n memory\n memoryDisplay\n memoryPercentile\n memoryDistribution\n code\n timestamp\n statusCode\n lang {\n name\n verboseName\n }\n question {\n questionId\n title\n titleSlug\n content\n difficulty\n questionFrontendId\n }\n notes\n topicTags {\n tagId\n slug\n name\n }\n runtimeError\n }\n}\n" 10 | CUTE_TOAST_TIMER=5000 -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | FIND_ELEMENT_ALLOWED_RETRIES=20 2 | FIND_ELEMENT_EXPIRY_IN_MS=150 3 | GITHUB_API_HOST=https://api.github.com 4 | GITHUB_AUTH_CLIENT_ID="f0931287bb5d9d34de53", 5 | GITHUB_AUTH_CLIENT_SECRET="e4443e0ceb8e72e019d86d70624379b42b661c2b" 6 | LC_API_HOST=https://leetcode.com/graphql 7 | LC_QUERIES_GET_USER_INFO="query getUserProfile($username: String!) { matchedUser(username: $username) { username submitStats: submitStatsGlobal { acSubmissionNum { difficulty count submissions } } } }" 8 | LC_QUERIES_GET_QUESTION_OF_DAY="\n query questionOfToday {\n activeDailyCodingChallengeQuestion {\n date\n userStatus\n link\n question {\n acRate\n difficulty\n freqBar\n frontendQuestionId: questionFrontendId\n isFavor\n paidOnly: isPaidOnly\n status\n title\n titleSlug\n hasVideoSolution\n hasSolution\n topicTags {\n name\n id\n slug\n }\n }\n }\n}\n " 9 | LC_QUERIES_GET_SUBMISSION_DETAILS="\n query submissionDetails($submissionId: Int!) {\n submissionDetails(submissionId: $submissionId) {\n runtime\n runtimeDisplay\n runtimePercentile\n runtimeDistribution\n memory\n memoryDisplay\n memoryPercentile\n memoryDistribution\n code\n timestamp\n statusCode\n lang {\n name\n verboseName\n }\n question {\n questionId\n title\n titleSlug\n content\n difficulty\n questionFrontendId\n }\n notes\n topicTags {\n tagId\n slug\n name\n }\n runtimeError\n }\n}\n" 10 | CUTE_TOAST_TIMER=5000 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules/ 3 | dist/ 4 | tmp/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "bracketSpacing": true, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "useTabs": true 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "files.eol": "\n", 4 | "json.schemas": [ 5 | { 6 | "fileMatch": [ 7 | "/manifest.json" 8 | ], 9 | "url": "http://json.schemastore.org/chrome-manifest" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "command": "npm", 6 | "tasks": [ 7 | { 8 | "label": "install", 9 | "type": "shell", 10 | "command": "npm", 11 | "args": ["install"] 12 | }, 13 | { 14 | "label": "update", 15 | "type": "shell", 16 | "command": "npm", 17 | "args": ["update"] 18 | }, 19 | { 20 | "label": "test", 21 | "type": "shell", 22 | "command": "npm", 23 | "args": ["run", "test"] 24 | }, 25 | { 26 | "label": "build", 27 | "type": "shell", 28 | "group": "build", 29 | "command": "npm", 30 | "args": ["run", "watch"] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tomofumi Chiba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitCode 2 | 3 | ## What is GitCode? 4 | 5 | GitCode is a chrome extension that automatically pushes your code to GitHub when you pass all tests on a Leetcode problem. 6 | 7 | ## Why GitCode? 8 | 9 | ### Background 10 | 11 | I was one of the contributors to the opensource project [Leethub](https://github.com/QasimWani/LeetHub). However, it appears that the original owner is not updating the extension as regularly and frequently as before. Consequently, there are many unresolved issues and pending PRs. Since the repo is not as active as before, I've decided to create my own extension that builds upon the foundation of LeetHub. Additionally, due to the radical change in the workflow, GitCode is more resilient to any code changes that Leetcode might introduce. 12 | 13 | ### Motivation 14 | 15 | 1. Recruiters _want_ to see your contributions to the Open Source community, be it through side projects, solving algorithms/data-structures, or contributing to existing OS projects. 16 | As of now, GitHub is developers' #1 portfolio. GitCode just makes it much easier (autonomous) to keep track of progress and contributions on the largest network of engineering community, GitHub. 17 | 2. There's no easy way of accessing your leetcode problems in one place! 18 | Moreover, pushing code manually to GitHub from Leetcode is very time consuming. So, why not just automate it entirely without spending a SINGLE additional second on it? 19 | 20 | ### Roadmap 21 | 22 | The objective of GitCode not only pushes Leetcode solutions to Github, but also pushes solutions from other platforms to Github. Ultimately, Github should be our only source of portfolio, allowing us to conveniently access ALL of our solutions in one place. 23 | 24 | ## Discord 25 | 26 | Join our [Discord channel](https://discord.gg/QrujRsZdcB) for discussions and opportunities to contribute. 27 | 28 | ## Local development 29 | 30 | ### Prerequisites 31 | 32 | - [node + npm](https://nodejs.org/) (Current Version) 33 | 34 | ### Install 35 | 36 | ``` 37 | npm install 38 | ``` 39 | 40 | ### Build 41 | 42 | ``` 43 | npm run build 44 | ``` 45 | 46 | ### Build in watch mode 47 | 48 | ``` 49 | npm run watch 50 | ``` 51 | 52 | ## Load extension to chrome 53 | 54 | Load `dist` directory 55 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['src'], 3 | transform: { 4 | '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }] 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitCode", 3 | "version": "1.0.0", 4 | "description": "GitCode", 5 | "main": "index.js", 6 | "scripts": { 7 | "watch": "webpack --config webpack/webpack.dev.js --watch", 8 | "build": "webpack --config webpack/webpack.prod.js", 9 | "clean": "rimraf dist", 10 | "test": "npx jest", 11 | "style": "prettier --write \"src/**/*.{ts,tsx}\"" 12 | }, 13 | "author": "Andy Su", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/andythsu/gitcode.git" 18 | }, 19 | "dependencies": { 20 | "@emotion/react": "^11.11.1", 21 | "@emotion/styled": "^11.11.0", 22 | "@fontsource/roboto": "^5.0.8", 23 | "@mui/icons-material": "^5.14.3", 24 | "@mui/material": "^5.14.4", 25 | "axios": "^1.4.0", 26 | "buffer": "^6.0.3", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0" 29 | }, 30 | "devDependencies": { 31 | "@types/chrome": "0.0.237", 32 | "@types/jest": "^29.5.2", 33 | "@types/react": "^18.2.12", 34 | "@types/react-dom": "^18.2.5", 35 | "copy-webpack-plugin": "^11.0.0", 36 | "css-loader": "^6.8.1", 37 | "dotenv-webpack": "^8.0.1", 38 | "file-loader": "^6.2.0", 39 | "glob": "^10.2.7", 40 | "jest": "^29.5.0", 41 | "prettier": "^2.8.8", 42 | "rimraf": "^5.0.1", 43 | "style-loader": "^3.3.3", 44 | "ts-jest": "^29.1.0", 45 | "ts-loader": "^9.4.3", 46 | "typescript": "^5.1.3", 47 | "webpack": "^5.86.0", 48 | "webpack-cli": "^5.1.4", 49 | "webpack-merge": "^5.9.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/cute_alert/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/cute_alert/success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andythsu/GitCode/79f7b08710b7590a45758833d5023afe4177acca/public/icon.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "GitCode", 4 | "description": "GitCode", 5 | "version": "4.0", 6 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm1EAuVVG3EUnIujuTfPal6NDgOBQOW255XP/kFpA7xatfMi+4JojkAzKm+MeROKC18OtuLfGaQHSCXQKSoDisu45SLdDYO/yBTVT3rDqpUAJcbi20rAxaa4wKQAij0p0IXIivZfIs1RUgkWktZa6C0iG4DbtWeHWmQvDH3RmY8Jwub8BNBBKj/iSlt6sLL47SB5osx2ik6mxOHE7qtA1/QdHFR/ZFl4BumvlVEbeGajWH8mQUgli7Rb1n33B8XPUJkhfwAyIBvJnpgC5gktL2JmitH+Bl6Didxo/fureUuphOlUtDcTloeTI/+GXmK3pwDagUGMQD1RBCOdPW+0VLQIDAQAB", 7 | "action": { 8 | "default_icon": "icon.png", 9 | "default_popup": "popup.html" 10 | }, 11 | "content_scripts": [ 12 | { 13 | "matches": ["https://leetcode.com/*"], 14 | "js": ["js/vendor.js", "js/content_script.js"], 15 | "css": ["css/cute_alert.css"], 16 | "run_at": "document_end" 17 | } 18 | ], 19 | "web_accessible_resources": [ 20 | { 21 | "resources": ["cute_alert/success.svg"], 22 | "matches": ["https://leetcode.com/*"] 23 | } 24 | ], 25 | "background": { 26 | "service_worker": "js/background.js" 27 | }, 28 | "permissions": ["storage", "identity", "scripting"], 29 | "host_permissions": [""] 30 | } 31 | -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Getting Started Extension's Popup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/@types/Github.d.ts: -------------------------------------------------------------------------------- 1 | export namespace Github { 2 | type User = { 3 | login: string; 4 | }; 5 | 6 | type RepoContent = { 7 | sha: string; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/@types/Leetcode.d.ts: -------------------------------------------------------------------------------- 1 | export namespace LC { 2 | type Profile = { 3 | userStatus: UserStatus; 4 | }; 5 | type UserStatus = { 6 | username: string; 7 | activeSessionId: number; 8 | }; 9 | type UserStats = { 10 | data: { 11 | matchedUser: { 12 | submitStats: { 13 | acSubmissionNum: Array<{ 14 | difficulty: string; 15 | count: number; 16 | submissions: number; 17 | }>; 18 | }; 19 | }; 20 | }; 21 | }; 22 | type QuestionOfDay = { 23 | data: { 24 | activeDailyCodingChallengeQuestion: { 25 | date: string; 26 | link: string; 27 | question: { 28 | acRate: number; 29 | difficulty: string; 30 | freqBar: number; 31 | frontendQuestionId: number; 32 | hasSolution: boolean; 33 | hasVideoSolution: boolean; 34 | isFavor: boolean; 35 | paidOnly: boolean; 36 | title: string; 37 | titleSlug: string; 38 | topicTags: Array<{ 39 | id: string; 40 | name: string; 41 | slug: string; 42 | }>; 43 | }; 44 | }; 45 | }; 46 | }; 47 | type SubmissionDetails = { 48 | data: { 49 | submissionDetails: { 50 | runtime: number; 51 | runtimeDisplay: string; 52 | runtimePercentile: number; 53 | runtimeDistribution: string; 54 | memory: number; 55 | memoryDisplay: string; 56 | memoryDistribution: string; 57 | memoryPercentile: number; 58 | code: string; 59 | timestamp: number; 60 | statusCode: number; 61 | lang: { 62 | name: string; 63 | verboseName: string; 64 | }; 65 | question: { 66 | questionId: string; 67 | title: string; 68 | titleSlug: string; 69 | content: string; 70 | difficulty: string; 71 | questionFrontendId: string; 72 | }; 73 | notes: string; 74 | // topicTags: any[]; // not sure how to type this yet 75 | runtimeError: null; 76 | }; 77 | }; 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/@types/Message.d.ts: -------------------------------------------------------------------------------- 1 | export type Message = { 2 | type: string; 3 | payload: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/@types/Payload.d.ts: -------------------------------------------------------------------------------- 1 | import { Question } from './Question'; 2 | import { Submission } from './Submission'; 3 | 4 | export namespace MessagePayload { 5 | type UploadCode = { 6 | submission: Submission; 7 | question: Question; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/@types/Question.d.ts: -------------------------------------------------------------------------------- 1 | export type Question = { 2 | questionNum: number; 3 | questionTitle: string; 4 | lang: string; 5 | content: string; 6 | titleSlug: string; 7 | }; 8 | -------------------------------------------------------------------------------- /src/@types/StorageKey.d.ts: -------------------------------------------------------------------------------- 1 | export type StorageKey = 2 | | 'access_token' 3 | | 'bound_repo' 4 | | 'gh_username' 5 | | 'numOfEasyQuestions' 6 | | 'numOfMediumQuestions' 7 | | 'numOfHardQuestions' 8 | | 'lc_username' 9 | | 'lc_profile'; 10 | -------------------------------------------------------------------------------- /src/@types/Submission.d.ts: -------------------------------------------------------------------------------- 1 | export type Submission = { 2 | runtime: string; 3 | runtimeFasterThan: string; 4 | memory: string; 5 | memoryLessThan: string; 6 | code: string; 7 | }; 8 | -------------------------------------------------------------------------------- /src/@types/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | FIND_ELEMENT_ALLOWED_RETRIES: string; 5 | FIND_ELEMENT_EXPIRY_IN_MS: string; 6 | GITHUB_API_HOST: string; 7 | GITHUB_AUTH_REDIRECT_URL: string; 8 | GITHUB_AUTH_CLIENT_ID: string; 9 | GITHUB_AUTH_CLIENT_SECRET: string; 10 | LC_API_HOST: string; 11 | LC_QUERIES_GET_USER_INFO: string; 12 | LC_QUERIES_GET_QUESTION_OF_DAY: string; 13 | LC_QUERIES_GET_SUBMISSION_DETAILS: string; 14 | CUTE_TOAST_TIMER: string; 15 | } 16 | } 17 | } 18 | 19 | export {}; 20 | -------------------------------------------------------------------------------- /src/Auth.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { getFromLocalStorage, saveToLocalStorage } from './util'; 3 | class Auth { 4 | private clientId = 'f0931287bb5d9d34de53'; 5 | private clientSecret = 'e4443e0ceb8e72e019d86d70624379b42b661c2b'; 6 | private accessToken = ''; 7 | private scopes = ['repo']; 8 | 9 | async authWithGithub(): Promise { 10 | const responseUrl = await chrome.identity.launchWebAuthFlow({ 11 | url: `https://github.com/login/oauth/authorize?scope=${this.scopes.join('%20')}&client_id=${ 12 | this.clientId 13 | }`, 14 | interactive: true 15 | }); 16 | if (responseUrl) { 17 | await this.setToken(responseUrl); 18 | } 19 | } 20 | 21 | async setToken(responseUrl: string): Promise { 22 | const url = new URL(responseUrl); 23 | const code = url.searchParams.get('code'); 24 | if (code) { 25 | try { 26 | const response = await axios.post( 27 | 'https://github.com/login/oauth/access_token', 28 | { 29 | client_id: this.clientId, 30 | client_secret: this.clientSecret, 31 | code: code 32 | }, 33 | { 34 | headers: { 35 | Accept: 'application/json' 36 | } 37 | } 38 | ); 39 | if (response.status === 200) { 40 | this.accessToken = response.data.access_token; 41 | this.saveAccessToken(); 42 | } else { 43 | throw new Error(`response.status is not 200. ${response}`); 44 | } 45 | } catch (e) { 46 | throw e; 47 | } 48 | } 49 | } 50 | 51 | saveAccessToken(): void { 52 | saveToLocalStorage('access_token', this.accessToken); 53 | } 54 | 55 | async getAccessToken(): Promise { 56 | if (this.accessToken) return this.accessToken; 57 | await getFromLocalStorage('access_token').then((data: string | null) => { 58 | if (data) { 59 | this.accessToken = data; 60 | } else { 61 | console.log('access_token not set in storage'); 62 | } 63 | }); 64 | return this.accessToken; 65 | } 66 | } 67 | 68 | export default Auth; 69 | -------------------------------------------------------------------------------- /src/Components/Stats.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Paper, styled } from '@mui/material'; 2 | import axios from 'axios'; 3 | import React, { useEffect, useState } from 'react'; 4 | // import config from '../../config/config.js'; 5 | import { LC } from '../@types/Leetcode'; 6 | import { getFromLocalStorage, saveToLocalStorage } from '../util'; 7 | 8 | type StatsProps = { 9 | lcProfile: LC.Profile | undefined; 10 | }; 11 | 12 | const Item = styled(Paper)(({ theme }) => ({ 13 | backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff', 14 | ...theme.typography.body2, 15 | padding: theme.spacing(1), 16 | textAlign: 'center', 17 | color: theme.palette.text.secondary 18 | })); 19 | 20 | export const Stats = ({ lcProfile }: StatsProps) => { 21 | const [easy, setEasy] = useState(0); 22 | const [medium, setMedium] = useState(0); 23 | const [hard, setHard] = useState(0); 24 | const [lcUsername, setLcUsername] = useState(''); 25 | 26 | useEffect(() => { 27 | getFromLocalStorage('numOfEasyQuestions').then((data) => { 28 | if (data) setEasy(parseInt(data)); 29 | }); 30 | getFromLocalStorage('numOfMediumQuestions').then((data) => { 31 | if (data) setMedium(parseInt(data)); 32 | }); 33 | getFromLocalStorage('numOfHardQuestions').then((data) => { 34 | if (data) setHard(parseInt(data)); 35 | }); 36 | getFromLocalStorage('lc_username').then((data) => { 37 | if (data) setLcUsername(data); 38 | }); 39 | }); 40 | 41 | useEffect(() => { 42 | if (!lcProfile) return; 43 | saveToLocalStorage('lc_username', lcProfile.userStatus.username); 44 | const query = { 45 | operationName: 'getUserProfile', 46 | query: process.env.LC_QUERIES_GET_USER_INFO, 47 | variables: { 48 | username: lcProfile.userStatus.username 49 | } 50 | }; 51 | axios 52 | .post(process.env.LC_API_HOST, query, { 53 | headers: { 54 | 'Content-Type': 'application/json' 55 | } 56 | }) 57 | .then((res) => { 58 | const data = res.data as LC.UserStats; 59 | const numOfEasyQuestions = data.data.matchedUser.submitStats.acSubmissionNum[1].count; 60 | const numOfMediumQuestions = data.data.matchedUser.submitStats.acSubmissionNum[2].count; 61 | const numOfHardQuestions = data.data.matchedUser.submitStats.acSubmissionNum[3].count; 62 | setEasy(numOfEasyQuestions); 63 | setMedium(numOfMediumQuestions); 64 | setHard(numOfHardQuestions); 65 | saveToLocalStorage('numOfEasyQuestions', numOfEasyQuestions); 66 | saveToLocalStorage('numOfMediumQuestions', numOfMediumQuestions); 67 | saveToLocalStorage('numOfHardQuestions', numOfHardQuestions); 68 | }); 69 | }, [lcProfile]); 70 | 71 | return ( 72 | <> 73 |

Currently logged in as {lcUsername} on Leetcode.

74 |

You have solved:

75 | 76 | 77 | 78 | Easy: {easy} 79 | 80 | 81 | 82 | 83 | Medium: {medium} 84 | 85 | 86 | 87 | 88 | Hard: {hard} 89 | 90 | 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/Components/Welcome.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { 3 | getFromLocalStorage, 4 | getFromPageLocalStorage, 5 | removeFromLocalStorage, 6 | saveToLocalStorage 7 | } from '../util'; 8 | import Button from '@mui/material/Button'; 9 | import TextField from '@mui/material/TextField'; 10 | import { GHUser } from '../GHUser'; 11 | import { createRepo, repoExists } from '../repo-util'; 12 | import { Typography } from '@mui/material'; 13 | import { Stats } from './Stats'; 14 | import { LC } from '../@types/Leetcode'; 15 | import axios from 'axios'; 16 | 17 | type WelcomeProps = { 18 | ghUser: GHUser; 19 | onLogOut: () => void; 20 | }; 21 | 22 | const getBoundRepository = async () => { 23 | return await getFromLocalStorage('bound_repo'); 24 | }; 25 | 26 | const getGhUsername = async () => { 27 | return await getFromLocalStorage('gh_username'); 28 | }; 29 | 30 | const onBindRepo = async ( 31 | inputRef: React.RefObject, 32 | ghUser: GHUser, 33 | setRepo: React.Dispatch> 34 | ) => { 35 | const val = inputRef.current?.value; 36 | if (!val) return; 37 | try { 38 | const repoExistsRes = await repoExists(val, ghUser); 39 | if (!repoExistsRes) { 40 | await createRepo(val, ghUser); 41 | } 42 | saveToLocalStorage('bound_repo', val); 43 | setRepo(val); 44 | } catch (e) { 45 | console.error(e); 46 | } 47 | }; 48 | 49 | const onRemoveRepo = async (setRepo: React.Dispatch>) => { 50 | await removeFromLocalStorage('bound_repo'); 51 | setRepo(''); 52 | }; 53 | 54 | export const Welcome = ({ ghUser, onLogOut }: WelcomeProps) => { 55 | const [repo, setRepo] = useState(''); 56 | const [ghUsername, setGhUsername] = useState(''); 57 | const [lcProfile, setLcProfile] = useState(); 58 | const [questionOfDay, setQuestionOfDay] = useState(); 59 | const bindRepoInputRef = useRef(null); 60 | 61 | useEffect(() => { 62 | getBoundRepository() 63 | .then((repo: string | null) => { 64 | if (repo) { 65 | setRepo(repo); 66 | } 67 | }) 68 | .catch(console.error); 69 | getGhUsername() 70 | .then((username: string | null) => { 71 | if (username) { 72 | setGhUsername(username); 73 | } 74 | }) 75 | .catch(console.error); 76 | 77 | const getLcProfileFromPage = async (): Promise => { 78 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 79 | const lcProfile: string | null = await getFromPageLocalStorage('GLOBAL_DATA:value', tab.id!); 80 | if (lcProfile) { 81 | saveToLocalStorage('lc_profile', lcProfile); 82 | } 83 | return lcProfile; 84 | }; 85 | 86 | getLcProfileFromPage() 87 | .then((profile) => { 88 | if (profile) { 89 | setLcProfile(JSON.parse(profile) as LC.Profile); 90 | } else { 91 | // try extension's local storage if current page doesn't have lc_profile 92 | getFromLocalStorage('lc_profile') 93 | .then((profile) => { 94 | if (profile) { 95 | setLcProfile(JSON.parse(profile) as LC.Profile); 96 | } 97 | }) 98 | .catch(console.error); 99 | } 100 | }) 101 | .catch(console.error); 102 | 103 | const questionOfDayQuery = { 104 | operationName: 'questionOfToday', 105 | query: process.env.LC_QUERIES_GET_QUESTION_OF_DAY, 106 | variables: {} 107 | }; 108 | 109 | axios 110 | .post(process.env.LC_API_HOST, questionOfDayQuery, { 111 | headers: { 112 | 'Content-Type': 'application/json' 113 | } 114 | }) 115 | .then((res) => { 116 | const data = res.data as LC.QuestionOfDay; 117 | if (data) { 118 | setQuestionOfDay(data); 119 | } 120 | }) 121 | .catch(console.error); 122 | }, []); 123 | 124 | return ( 125 | <> 126 | Welcome! 127 | {repo !== '' ? ( 128 | <> 129 | 130 | Currently bound repo: {ghUsername == '' ? repo : `${ghUsername}/${repo}`} 131 | 132 | 135 | 136 | ) : ( 137 | <> 138 | Name of the repo you want to bind to 139 | 140 | 143 | 144 | )} 145 | {lcProfile && } 146 |
147 | 148 | Question of today:{' '} 149 | 153 | {questionOfDay?.data.activeDailyCodingChallengeQuestion.question.titleSlug} 154 | 155 | 156 |
157 | 160 | 161 | ); 162 | }; 163 | -------------------------------------------------------------------------------- /src/CuteAlert.ts: -------------------------------------------------------------------------------- 1 | export const cuteToast = (toastConfig: { type: string; message: string }): Promise => { 2 | const { type, message } = toastConfig; 3 | return new Promise((resolve) => { 4 | const existingToast = document.querySelector('.toast-container'); 5 | 6 | if (existingToast) { 7 | existingToast.remove(); 8 | } 9 | 10 | const body = document.querySelector('body'); 11 | 12 | const scripts = document.getElementsByTagName('script'); 13 | 14 | let src = ''; 15 | 16 | for (let script of scripts) { 17 | if (script.src.includes('cute-alert.js')) { 18 | src = script.src.substring(0, script.src.lastIndexOf('/')); 19 | } 20 | } 21 | 22 | const svgSrc = chrome.runtime.getURL(`/cute_alert/${type}.svg`); 23 | const template = ` 24 |
25 |
26 |
27 | 28 | ${message} 29 |
X
30 |
31 |
34 |
35 |
36 | `; 37 | 38 | body!.insertAdjacentHTML('afterend', template); 39 | 40 | const toastContainer = document.querySelector('.toast-container')!; 41 | 42 | setTimeout(() => { 43 | toastContainer.remove(); 44 | resolve(); 45 | }, parseInt(process.env.CUTE_TOAST_TIMER)); 46 | 47 | const toastClose = document.querySelector('.toast-close')!; 48 | 49 | toastClose.addEventListener('click', () => { 50 | toastContainer.remove(); 51 | resolve(); 52 | }); 53 | }); 54 | }; 55 | -------------------------------------------------------------------------------- /src/GHUser.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios'; 2 | import { Github } from './@types/Github'; 3 | import { saveToLocalStorage } from './util'; 4 | 5 | export class GHUser { 6 | private accessToken: string = ''; 7 | private username: string = ''; 8 | init(accessToken: string): void { 9 | this.accessToken = accessToken; 10 | const reqConfig: AxiosRequestConfig = { 11 | headers: { 12 | Authorization: `Bearer ${this.accessToken}` 13 | } 14 | }; 15 | axios.get(`${process.env.GITHUB_API_HOST}/user`, reqConfig).then((response) => { 16 | const data = response.data as Github.User; 17 | this.username = data.login; 18 | saveToLocalStorage('gh_username', this.username); 19 | }); 20 | } 21 | 22 | getUsername(): string { 23 | return this.username; 24 | } 25 | 26 | getAccessToken(): string { 27 | return this.accessToken; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './@types/Message'; 2 | import { MessagePayload } from './@types/Payload'; 3 | import { uploadToGithub } from './repo-util'; 4 | 5 | const uploadCode = async ( 6 | payload: string, 7 | sendResponse: (response?: any) => void 8 | ): Promise => { 9 | try { 10 | const uploadCodePayload = JSON.parse(payload) as MessagePayload.UploadCode; 11 | await uploadToGithub(uploadCodePayload); 12 | sendResponse( 13 | JSON.stringify({ 14 | message: 'Gitcode: Successfully uploaded to Github', 15 | type: 'success' 16 | }) 17 | ); 18 | } catch (e) { 19 | console.error(e); 20 | sendResponse( 21 | JSON.stringify({ 22 | message: 'Gitcode: Failed to upload to Github', 23 | type: 'error' 24 | }) 25 | ); 26 | } 27 | }; 28 | 29 | const messageHandlers = new Map< 30 | string, 31 | (payload: string, sendResponse: (response?: any) => void) => Promise 32 | >(); 33 | messageHandlers.set( 34 | 'uploadCode', 35 | async (payload: string, sendResponse: (response?: any) => void) => { 36 | await uploadCode(payload, sendResponse); 37 | } 38 | ); 39 | 40 | chrome.runtime.onMessage.addListener( 41 | ( 42 | message: Message, 43 | sender: chrome.runtime.MessageSender, 44 | sendResponse: (response?: any) => void 45 | ) => { 46 | const { type, payload } = message; 47 | if (messageHandlers.has(type)) { 48 | const handler = messageHandlers.get(type)!; 49 | handler(payload, sendResponse); 50 | } 51 | return true; 52 | } 53 | ); 54 | -------------------------------------------------------------------------------- /src/content_script.tsx: -------------------------------------------------------------------------------- 1 | import { Message } from './@types/Message'; 2 | import { MessagePayload } from './@types/Payload'; 3 | import { LC } from './@types/Leetcode'; 4 | import { fetchLC } from './fetch-util'; 5 | import { cuteToast } from './CuteAlert'; 6 | import { isNewVersion } from './util'; 7 | 8 | const getElementByQuerySelectorWithTimeout = (query: string): Promise> => { 9 | return new Promise((resolve, reject) => { 10 | let count = 0; 11 | const EXPIRY_IN_MS = parseInt(process.env.FIND_ELEMENT_EXPIRY_IN_MS); 12 | const ALLOWED_RETRIES = parseInt(process.env.FIND_ELEMENT_ALLOWED_RETRIES); 13 | const timer = setInterval(() => { 14 | const elem = document.querySelectorAll(query); 15 | if (elem.length > 0) { 16 | clearTimeout(timer); 17 | resolve(elem); 18 | } else if (count > ALLOWED_RETRIES) { 19 | clearTimeout(timer); 20 | reject( 21 | new Error( 22 | `${query} was not loaded in ${(EXPIRY_IN_MS * ALLOWED_RETRIES) / 1000} seconds.` 23 | ) 24 | ); 25 | } 26 | count++; 27 | }, EXPIRY_IN_MS); 28 | }); 29 | }; 30 | 31 | const getElementByClassNameWithTimeout = (query: string): Promise> => { 32 | return new Promise((resolve, reject) => { 33 | let count = 0; 34 | const EXPIRY_IN_MS = parseInt(process.env.FIND_ELEMENT_EXPIRY_IN_MS); 35 | const ALLOWED_RETRIES = parseInt(process.env.FIND_ELEMENT_ALLOWED_RETRIES); 36 | const timer = setInterval(() => { 37 | const elem = document.getElementsByClassName(query); 38 | if (elem.length > 0) { 39 | clearTimeout(timer); 40 | resolve(elem); 41 | } else if (count > ALLOWED_RETRIES) { 42 | clearTimeout(timer); 43 | reject( 44 | new Error( 45 | `${query} was not loaded in ${(EXPIRY_IN_MS * ALLOWED_RETRIES) / 1000} seconds.` 46 | ) 47 | ); 48 | } 49 | count++; 50 | }, EXPIRY_IN_MS); 51 | }); 52 | }; 53 | 54 | const uploadCode = async (submissionDetails: LC.SubmissionDetails): Promise => { 55 | const { 56 | lang, 57 | question, 58 | memoryDisplay, 59 | memoryPercentile, 60 | runtimeDisplay, 61 | runtimePercentile, 62 | code 63 | } = submissionDetails.data.submissionDetails; 64 | const payload: string = JSON.stringify({ 65 | submission: { 66 | runtime: runtimeDisplay, 67 | runtimeFasterThan: runtimePercentile.toFixed(2), 68 | memory: memoryDisplay, 69 | memoryLessThan: memoryPercentile.toFixed(2), 70 | code 71 | }, 72 | question: { 73 | questionNum: parseInt(question.questionFrontendId), 74 | questionTitle: question.title, 75 | lang: lang.name, 76 | content: question.content, 77 | titleSlug: question.titleSlug 78 | } 79 | } as MessagePayload.UploadCode); 80 | const result: string = await chrome.runtime.sendMessage({ 81 | type: 'uploadCode', 82 | payload 83 | }); 84 | console.log(result); 85 | const { type, message } = JSON.parse(result) as UploadCodeResult; 86 | cuteToast({ 87 | type, 88 | message 89 | }); 90 | }; 91 | 92 | type UploadCodeResult = { 93 | type: string; 94 | message: string; 95 | }; 96 | 97 | chrome.runtime.onMessage.addListener( 98 | async ( 99 | message: Message, 100 | sender: chrome.runtime.MessageSender, 101 | sendResponse: (response?: any) => void 102 | ): Promise => { 103 | const { type, payload } = message; 104 | if (type === 'uploadCodeResult') { 105 | const { type, message } = JSON.parse(payload) as UploadCodeResult; 106 | cuteToast({ 107 | type, 108 | message 109 | }); 110 | } 111 | } 112 | ); 113 | 114 | const pullSuccessTag = ( 115 | isNewLc: boolean 116 | ): Promise | NodeListOf> => { 117 | return new Promise((resolve, reject) => { 118 | const timer = setInterval(async () => { 119 | let successTag: HTMLCollectionOf | NodeListOf; 120 | if (isNewLc) { 121 | successTag = document.querySelectorAll(`[data-e2e-locator="submission-result"]`); 122 | } else { 123 | successTag = document.getElementsByClassName('marked_as_success'); 124 | } 125 | if (successTag.length > 0) { 126 | clearInterval(timer); 127 | resolve(successTag); 128 | } 129 | }, 1000); 130 | }); 131 | }; 132 | 133 | async function main() { 134 | const isNewLc = isNewVersion(); 135 | console.log(isNewLc ? 'LC is using new version' : 'LC is using old version'); 136 | try { 137 | const successTag = await pullSuccessTag(isNewLc); 138 | if (!successTag[0]) throw new Error(`successTag[0] is not found. SuccessTag: ${successTag}`); 139 | 140 | let submissionId: string; 141 | 142 | if (isNewLc) { 143 | const urlSplits = window.location.href.split('/'); 144 | submissionId = urlSplits[urlSplits.length - 2]; // submissionId will be in the second last position 145 | } else { 146 | const detailsElem = successTag[0].parentElement?.getElementsByTagName('a')[0]; 147 | if (!detailsElem) return; 148 | const submissionLink = detailsElem.href; 149 | const slashIndexes = [...submissionLink.matchAll(/\//g)]; 150 | submissionId = detailsElem.href.substring( 151 | slashIndexes[slashIndexes.length - 2].index! + 1, 152 | slashIndexes[slashIndexes.length - 1].index! 153 | ); 154 | } 155 | 156 | console.log('submissionId', submissionId); 157 | 158 | const submissionDetailsQuery = { 159 | query: process.env.LC_QUERIES_GET_SUBMISSION_DETAILS, 160 | variables: { 161 | submissionId 162 | }, 163 | operationName: 'submissionDetails' 164 | }; 165 | 166 | const submissionDetails: LC.SubmissionDetails = await fetchLC(submissionDetailsQuery); 167 | 168 | await uploadCode(submissionDetails); 169 | } catch (e) { 170 | console.error(e); 171 | } finally { 172 | prevUrl = window.location.href; 173 | global(); 174 | } 175 | } 176 | 177 | let prevUrl = ''; 178 | 179 | function global() { 180 | // need to wrap main() in an interval otherwise it will fail in SPA 181 | const start = setInterval(() => { 182 | const currentUrl = window.location.href; 183 | console.log('currentUrl', currentUrl); 184 | if (currentUrl != prevUrl) { 185 | console.log('prevUrl', prevUrl); 186 | clearInterval(start); 187 | main(); 188 | } 189 | }, 2000); 190 | } 191 | 192 | global(); 193 | -------------------------------------------------------------------------------- /src/css/cute_alert.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Dosis:wght@800&display=swap'); 3 | 4 | .alert-wrapper { 5 | display: flex; 6 | width: 100%; 7 | height: 100%; 8 | align-items: center; 9 | justify-content: center; 10 | margin: 0px auto; 11 | padding: 0px auto; 12 | left: 0; 13 | top: 0; 14 | overflow: hidden; 15 | position: fixed; 16 | background: rgb(0, 0, 0, 0.3); 17 | z-index: 999999; 18 | } 19 | 20 | @keyframes open-frame { 21 | 0% { 22 | transform: scale(1); 23 | } 24 | 25% { 25 | transform: scale(0.95); 26 | } 27 | 50% { 28 | transform: scale(0.97); 29 | } 30 | 75% { 31 | transform: scale(0.93); 32 | } 33 | 100% { 34 | transform: scale(1); 35 | } 36 | } 37 | 38 | .alert-frame { 39 | background: #fff; 40 | min-height: 400px; 41 | width: 300px; 42 | box-shadow: 5px 5px 10px rgb(0, 0, 0, 0.2); 43 | border-radius: 10px; 44 | animation: open-frame 0.3s linear; 45 | } 46 | 47 | .alert-header { 48 | display: flex; 49 | flex-direction: row; 50 | height: 175px; 51 | border-top-left-radius: 5px; 52 | border-top-right-radius: 5px; 53 | } 54 | 55 | .alert-img { 56 | height: 80px; 57 | position: absolute; 58 | left: 0; 59 | right: 0; 60 | margin-left: auto; 61 | margin-right: auto; 62 | align-self: center; 63 | } 64 | 65 | .alert-close { 66 | width: 30px; 67 | height: 30px; 68 | display: flex; 69 | align-items: center; 70 | justify-content: center; 71 | font-family: 'Dosis', sans-serif; 72 | font-weight: 700; 73 | cursor: pointer; 74 | line-height: 30px; 75 | } 76 | 77 | .alert-close-default { 78 | color: rgb(0, 0, 0, 0.2); 79 | font-size: 16px; 80 | transition: color 0.5s; 81 | margin-left: auto; 82 | margin-right: 5px; 83 | margin-top: 5px; 84 | } 85 | 86 | .alert-close-circle { 87 | background: #e4eae7; 88 | color: #222; 89 | border-radius: 17.5px; 90 | margin-top: -15px; 91 | margin-right: -15px; 92 | font-size: 12px; 93 | transition: background 0.5s; 94 | margin-left: auto; 95 | } 96 | 97 | .alert-close-circle:hover { 98 | background: #fff; 99 | } 100 | 101 | .alert-close:hover { 102 | color: rgb(0, 0, 0, 0.5); 103 | } 104 | 105 | .alert-body { 106 | padding: 30px 30px; 107 | display: flex; 108 | flex-direction: column; 109 | text-align: center; 110 | } 111 | 112 | .alert-title { 113 | font-size: 18px !important; 114 | font-family: 'Open Sans', sans-serif; 115 | font-weight: 700; 116 | font-size: 15px; 117 | margin-bottom: 35px; 118 | color: #222; 119 | align-self: center; 120 | } 121 | 122 | .alert-message { 123 | font-size: 15px !important; 124 | color: #666; 125 | font-family: 'Open Sans', sans-serif; 126 | font-weight: 400; 127 | font-size: 15px; 128 | text-align: center; 129 | margin-bottom: 35px; 130 | line-height: 1.6; 131 | align-self: center; 132 | } 133 | 134 | .alert-button { 135 | min-width: 140px; 136 | height: 35px; 137 | border-radius: 20px; 138 | font-family: 'Open Sans', sans-serif; 139 | font-weight: 400; 140 | font-size: 15px; 141 | color: white; 142 | border: none; 143 | cursor: pointer; 144 | transition: background 0.5s; 145 | padding: 0 15px; 146 | align-self: center; 147 | display: inline-flex; 148 | align-items: center; 149 | justify-content: center; 150 | } 151 | 152 | .alert-button:focus { 153 | outline: 0; 154 | } 155 | 156 | .question-buttons { 157 | display: flex; 158 | flex-direction: row; 159 | justify-content: center; 160 | } 161 | 162 | .confirm-button { 163 | min-width: 100px; 164 | height: 35px; 165 | border-radius: 20px; 166 | font-family: 'Open Sans', sans-serif; 167 | font-weight: 400; 168 | font-size: 15px; 169 | color: white; 170 | border: none; 171 | cursor: pointer; 172 | transition: background 0.5s; 173 | padding: 0 15px; 174 | margin-right: 10px; 175 | display: inline-flex; 176 | align-items: center; 177 | justify-content: center; 178 | } 179 | 180 | .confirm-button:focus { 181 | outline: 0; 182 | } 183 | 184 | .cancel-button { 185 | min-width: 100px; 186 | height: 35px; 187 | border-radius: 20px; 188 | font-family: 'Open Sans', sans-serif; 189 | font-weight: 400; 190 | font-size: 15px; 191 | color: white; 192 | border: none; 193 | cursor: pointer; 194 | padding: 0; 195 | line-height: 1.6; 196 | transition: background 0.5s; 197 | padding: 0 15px; 198 | display: inline-flex; 199 | align-items: center; 200 | justify-content: center; 201 | } 202 | 203 | .cancel-button:focus { 204 | outline: 0; 205 | } 206 | 207 | @keyframes open-toast { 208 | 0% { 209 | transform: scaleX(1) scaleY(1); 210 | } 211 | 20%, 212 | 45% { 213 | transform: scaleX(1.35) scaleY(0.1); 214 | } 215 | 65% { 216 | transform: scaleX(0.8) scaleY(1.7); 217 | } 218 | 80% { 219 | transform: scaleX(0.6) scaleY(0.85); 220 | } 221 | 100% { 222 | transform: scaleX(1) scaleY(1); 223 | } 224 | } 225 | 226 | .toast-container { 227 | top: 15px; 228 | right: 15px; 229 | overflow: hidden; 230 | position: fixed; 231 | border-radius: 5px; 232 | box-shadow: 0 0 20px rgb(0, 0, 0, 0.2); 233 | animation: open-toast 0.3s linear; 234 | z-index: 999999; 235 | } 236 | 237 | .toast-frame { 238 | padding: 5px 15px; 239 | display: flex; 240 | min-width: 100px; 241 | height: 60px; 242 | border-top-left-radius: 10px; 243 | border-top-right-radius: 10px; 244 | align-items: center; 245 | flex-wrap: wrap; 246 | } 247 | 248 | .toast-img { 249 | height: 40%; 250 | } 251 | 252 | .toast-message { 253 | font-size: 11px !important; 254 | font-family: 'Open Sans', sans-serif; 255 | font-weight: 600; 256 | color: #fff; 257 | margin-left: 15px; 258 | } 259 | 260 | .toast-close { 261 | color: rgb(0, 0, 0, 0.2); 262 | font-family: 'Dosis', sans-serif; 263 | font-weight: 700; 264 | font-size: 16px; 265 | cursor: pointer; 266 | transition: color 0.5s; 267 | margin-left: 25px; 268 | } 269 | 270 | @keyframes timer { 271 | 0% { 272 | width: 100%; 273 | } 274 | 25% { 275 | width: 75%; 276 | } 277 | 50% { 278 | width: 50%; 279 | } 280 | 75% { 281 | width: 25%; 282 | } 283 | 100% { 284 | width: 1%; 285 | } 286 | } 287 | 288 | .toast-timer { 289 | width: 1%; 290 | height: 5px; 291 | } 292 | 293 | .toast-close:hover { 294 | color: rgb(0, 0, 0, 0.5); 295 | } 296 | 297 | .error-bg { 298 | background: #d85261; 299 | } 300 | 301 | .success-bg { 302 | background: #2dd284; 303 | } 304 | 305 | .warning-bg { 306 | background: #fada5e; 307 | } 308 | 309 | .question-bg { 310 | background: #779ecb; 311 | } 312 | 313 | .error-btn:hover { 314 | background: #e5a4b4; 315 | } 316 | 317 | .success-btn:hover { 318 | background: #6edaa4; 319 | } 320 | 321 | .warning-btn:hover { 322 | background: #fcecae; 323 | } 324 | 325 | .info-btn:hover { 326 | background: #c3e6fb; 327 | } 328 | 329 | .question-btn:hover { 330 | background: #bacee4; 331 | } 332 | 333 | .error-timer { 334 | background: #e5a4b4; 335 | } 336 | 337 | .success-timer { 338 | background: #6edaa4; 339 | } 340 | 341 | .warning-timer { 342 | background: #fcecae; 343 | } 344 | 345 | .info-timer { 346 | background: #c3e6fb; 347 | } 348 | 349 | .info-bg { 350 | background: #88cef7; 351 | } 352 | -------------------------------------------------------------------------------- /src/css/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 350px; 3 | text-align: center; 4 | } 5 | -------------------------------------------------------------------------------- /src/fetch-util.ts: -------------------------------------------------------------------------------- 1 | export const fetchLC = async (query: { query: string; variables: any; operationName: string }) => { 2 | const res = await fetch(process.env.LC_API_HOST, { 3 | method: 'POST', 4 | body: JSON.stringify(query), 5 | headers: { 6 | cookie: document.cookie, 7 | 'content-type': 'application/json' 8 | } 9 | }); 10 | 11 | return res.json(); 12 | }; 13 | -------------------------------------------------------------------------------- /src/popup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import Auth from './Auth'; 4 | import { Welcome } from './Components/Welcome'; 5 | import '@fontsource/roboto/300.css'; 6 | import '@fontsource/roboto/400.css'; 7 | import '@fontsource/roboto/500.css'; 8 | import '@fontsource/roboto/700.css'; 9 | import './css/popup.css'; 10 | import { GHUser } from './GHUser'; 11 | import { Button } from '@mui/material'; 12 | import GitHubIcon from '@mui/icons-material/GitHub'; 13 | import { removeFromLocalStorage } from './util'; 14 | 15 | type PopupProps = { 16 | auth: Auth; 17 | ghUser: GHUser; 18 | }; 19 | 20 | const authWithGithub = async ( 21 | auth: Auth, 22 | setLoggedIn: React.Dispatch> 23 | ): Promise => { 24 | await auth.authWithGithub(); 25 | setLoggedIn(true); 26 | }; 27 | 28 | const onLogOut = async ( 29 | setLoggedIn: React.Dispatch> 30 | ): Promise => { 31 | await removeFromLocalStorage('access_token'); 32 | setLoggedIn(false); 33 | }; 34 | 35 | const Popup = ({ auth, ghUser }: PopupProps) => { 36 | const [loggedIn, setLoggedIn] = useState(false); 37 | useEffect(() => { 38 | auth.getAccessToken().then((accessToken) => { 39 | if (accessToken) { 40 | setLoggedIn(true); 41 | ghUser.init(accessToken); 42 | } 43 | }); 44 | }, []); 45 | 46 | return ( 47 | <> 48 | {loggedIn ? ( 49 | await onLogOut(setLoggedIn)}> 50 | ) : ( 51 | 58 | )} 59 | 60 | ); 61 | }; 62 | 63 | const root = createRoot(document.getElementById('root')!); 64 | 65 | const auth = new Auth(); 66 | const ghUser = new GHUser(); 67 | 68 | root.render( 69 | 70 | 71 | 72 | ); 73 | -------------------------------------------------------------------------------- /src/queries.ts: -------------------------------------------------------------------------------- 1 | export const languageListQuery = ` 2 | query languageList { 3 | languageList { 4 | id 5 | name 6 | } 7 | } 8 | `; 9 | 10 | export const syncedCodeQuery = ` 11 | query syncedCode($questionId: Int!, $lang: Int!) { 12 | syncedCode(questionId: $questionId, lang: $lang) { 13 | timestamp 14 | code 15 | } 16 | } 17 | `; 18 | 19 | export const problemsetQuestionListQuery = ` 20 | query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { 21 | problemsetQuestionList: questionList(categorySlug: $categorySlug, limit: $limit, skip: $skip, filters: $filters) { 22 | total: totalNum 23 | questions: data { 24 | questionId 25 | title 26 | titleSlug 27 | content 28 | difficulty 29 | questionFrontendId 30 | } 31 | } 32 | } 33 | `; 34 | 35 | export const submissionListQuery = ` 36 | query submissionList($offset: Int!, $limit: Int!, $lastKey: String, $questionSlug: String!, $lang: Int, $status: Int) { 37 | questionSubmissionList(offset: $offset, limit: $limit, lastKey: $lastKey, questionSlug: $questionSlug, lang: $lang, status: $status) { 38 | lastKey 39 | hasNext 40 | submissions { 41 | id 42 | title 43 | titleSlug 44 | status 45 | statusDisplay 46 | lang 47 | langName 48 | runtime 49 | timestamp 50 | url 51 | isPending 52 | memory 53 | hasNotes 54 | notes 55 | flagType 56 | topicTags { 57 | id 58 | } 59 | } 60 | } 61 | } 62 | `; 63 | export const userInfoQuery = ` 64 | query getUserProfile($username: String!) { 65 | matchedUser(username: $username) { 66 | username submitStats: submitStatsGlobal { 67 | acSubmissionNum { 68 | difficulty 69 | count 70 | submissions 71 | } 72 | } 73 | } 74 | } 75 | `; 76 | export const questionOfDayQuery = ` 77 | query questionOfToday { 78 | activeDailyCodingChallengeQuestion { 79 | date 80 | userStatus 81 | link 82 | question { 83 | acRate 84 | difficulty 85 | freqBar 86 | frontendQuestionId: questionFrontendId 87 | isFavor 88 | paidOnly: isPaidOnly 89 | status 90 | title 91 | titleSlug 92 | hasVideoSolution 93 | hasSolution 94 | topicTags { 95 | name 96 | id 97 | slug 98 | } 99 | } 100 | } 101 | } 102 | `; 103 | export const submissionDetailsQuery = ` 104 | query submissionDetails($submissionId: Int!) { 105 | submissionDetails(submissionId: $submissionId) { 106 | runtime 107 | runtimeDisplay 108 | runtimePercentile 109 | runtimeDistribution 110 | memory 111 | memoryDisplay 112 | memoryPercentile 113 | memoryDistribution 114 | code 115 | timestamp 116 | statusCode 117 | lang { 118 | name 119 | verboseName 120 | } 121 | question { 122 | questionId 123 | title 124 | titleSlug 125 | content 126 | difficulty 127 | questionFrontendId 128 | } 129 | notes 130 | topicTags { 131 | tagId 132 | slug 133 | name 134 | } 135 | runtimeError 136 | } 137 | } 138 | `; 139 | -------------------------------------------------------------------------------- /src/repo-util.ts: -------------------------------------------------------------------------------- 1 | import { Github } from './@types/Github'; 2 | import { GHUser } from './GHUser'; 3 | import axios, { AxiosError, AxiosRequestConfig } from 'axios'; 4 | import { getFromLocalStorage } from './util'; 5 | import { MessagePayload } from './@types/Payload'; 6 | 7 | export const repoExists = async (repoName: string, ghUser: GHUser) => { 8 | const ghUsername = ghUser.getUsername(); 9 | const reqConfig: AxiosRequestConfig = { 10 | headers: { 11 | Authorization: `Bearer ${ghUser.getAccessToken()}`, 12 | Accept: 'application/vnd.github+json' 13 | } 14 | }; 15 | try { 16 | const result = await axios.get( 17 | `${process.env.GITHUB_API_HOST}/repos/${ghUsername}/${repoName}`, 18 | reqConfig 19 | ); 20 | return result.status === 200; 21 | } catch (e) { 22 | if (e instanceof AxiosError) { 23 | if ((e as AxiosError).response?.status === 404) { 24 | return false; 25 | } 26 | } 27 | throw e; 28 | } 29 | }; 30 | 31 | export const createRepo = async (repoName: string, ghUser: GHUser): Promise => { 32 | const reqConfig: AxiosRequestConfig = { 33 | headers: { 34 | Authorization: `Bearer ${ghUser.getAccessToken()}`, 35 | Accept: 'application/vnd.github+json' 36 | } 37 | }; 38 | const reqData = { 39 | name: repoName 40 | }; 41 | await axios.post(`${process.env.GITHUB_API_HOST}/user/repos`, reqData, reqConfig); 42 | }; 43 | 44 | const langToExtMap: Record = { 45 | python3: 'py', 46 | java: 'java' 47 | }; 48 | 49 | export const uploadToGithub = async ( 50 | uploadCodePayload: MessagePayload.UploadCode 51 | ): Promise => { 52 | const { questionNum, questionTitle, lang, content, titleSlug } = uploadCodePayload.question; 53 | const { runtime, runtimeFasterThan, memory, memoryLessThan, code } = uploadCodePayload.submission; 54 | 55 | const accessToken: string | null = await getFromLocalStorage('access_token'); 56 | const ghUsername: string | null = await getFromLocalStorage('gh_username'); 57 | const boundRepo: string | null = await getFromLocalStorage('bound_repo'); 58 | 59 | if (!accessToken) throw new Error(`Access token not found`); 60 | if (!ghUsername) throw new Error(`Github username not found`); 61 | if (!boundRepo) throw new Error(`Bound repo not found`); 62 | 63 | const folder = `${questionNum}-${titleSlug}`; 64 | const file = `${questionNum}. ${questionTitle}.${langToExtMap[lang] || lang}`; 65 | const readme = `README.md`; 66 | 67 | try { 68 | const upsertReadMe = await upsert( 69 | `${process.env.GITHUB_API_HOST}/repos/${ghUsername}/${boundRepo}/contents/${folder}/${readme}`, 70 | 'Readme', 71 | Buffer.from(content, 'utf8').toString('base64'), 72 | accessToken 73 | ); 74 | 75 | console.log('upsert readme', upsertReadMe); 76 | 77 | const upsertSolution = await upsert( 78 | `${process.env.GITHUB_API_HOST}/repos/${ghUsername}/${boundRepo}/contents/${folder}/${file}`, 79 | `Runtime: ${runtime} (${runtimeFasterThan}%), Memory: ${memory} (${memoryLessThan}%) - Gitcode`, 80 | Buffer.from(code, 'utf8').toString('base64'), 81 | accessToken 82 | ); 83 | 84 | console.log('upsert solution', upsertSolution); 85 | } catch (e) { 86 | throw e; 87 | } 88 | }; 89 | 90 | const upsert = async (url: string, message: string, content: string, accessToken: string) => { 91 | const fileExistsRes = await fetch(url, { 92 | method: 'GET', 93 | headers: { 94 | Accept: 'application/vnd.github+json', 95 | Authorization: `Bearer ${accessToken}` 96 | } 97 | }); 98 | 99 | let data: { message: string; content: string; sha?: string } = { 100 | message: message, 101 | content: content 102 | }; 103 | 104 | // we need to append file sha if we are updating an existing file in GH 105 | if (fileExistsRes.status === 200) { 106 | const repoContent: Github.RepoContent = await fileExistsRes.json(); 107 | data = { 108 | ...data, 109 | sha: repoContent.sha 110 | }; 111 | } 112 | 113 | const res = await fetch(url, { 114 | method: 'PUT', 115 | headers: { 116 | Accept: 'application/vnd.github+json', 117 | Authorization: `Bearer ${accessToken}` 118 | }, 119 | body: JSON.stringify(data) 120 | }); 121 | 122 | return res; 123 | }; 124 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { Github } from './@types/Github'; 2 | import { StorageKey } from './@types/StorageKey'; 3 | import { MessagePayload } from './@types/Payload'; 4 | 5 | export const saveToLocalStorage = (key: StorageKey, val: any): void => { 6 | chrome.storage.local.set({ [key]: val }, () => { 7 | if (chrome.runtime.lastError) { 8 | throw new Error(chrome.runtime.lastError.message); 9 | } 10 | console.log(`key: ${key} saved to local storage`); 11 | }); 12 | }; 13 | 14 | export const getFromLocalStorage = async (key: StorageKey): Promise => { 15 | const val = await chrome.storage.local.get(key); 16 | if (!val[key]) return null; 17 | return val[key] as string; 18 | }; 19 | 20 | export const removeFromLocalStorage = async (key: StorageKey): Promise => { 21 | await chrome.storage.local.remove(key); 22 | }; 23 | 24 | export const getFromPageLocalStorage = async ( 25 | key: string, 26 | tabId: number 27 | ): Promise => { 28 | const res = await chrome.scripting.executeScript({ 29 | target: { 30 | tabId 31 | }, 32 | func: (key: string) => { 33 | const item = localStorage.getItem(key); 34 | return item; 35 | }, 36 | args: [key] 37 | }); 38 | return res[0].result; 39 | }; 40 | 41 | export const isNewVersion = (): boolean => { 42 | const scripts = document.scripts; 43 | for (const script of scripts) { 44 | if (script.id === '__NEXT_DATA__') { 45 | return true; 46 | } 47 | } 48 | return false; 49 | }; 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es6", 5 | "moduleResolution": "bundler", 6 | "module": "ES6", 7 | "resolveJsonModule": true, 8 | "esModuleInterop": true, 9 | "sourceMap": false, 10 | // "rootDir": "src", 11 | "outDir": "dist/js", 12 | "noEmitOnError": true, 13 | "jsx": "react", 14 | "typeRoots": ["node_modules/@types", "./src/@types"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "node" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /webpack/webpack.common.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | const srcDir = path.join(__dirname, '..', 'src'); 5 | 6 | module.exports = { 7 | entry: { 8 | popup: path.join(srcDir, 'popup.tsx'), 9 | background: path.join(srcDir, 'background.ts'), 10 | content_script: path.join(srcDir, 'content_script.tsx') 11 | }, 12 | output: { 13 | path: path.join(__dirname, '../dist/js'), 14 | filename: '[name].js' 15 | }, 16 | optimization: { 17 | splitChunks: { 18 | name: 'vendor', 19 | chunks(chunk) { 20 | return chunk.name !== 'background'; 21 | } 22 | } 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.tsx?$/, 28 | use: 'ts-loader', 29 | exclude: /node_modules/ 30 | }, 31 | { 32 | test: /\.css$/, 33 | use: ['style-loader', 'css-loader'] 34 | } 35 | ] 36 | }, 37 | resolve: { 38 | extensions: ['.ts', '.tsx', '.js'], 39 | fallback: { 40 | buffer: require.resolve('buffer') 41 | } 42 | }, 43 | plugins: [ 44 | new webpack.ProvidePlugin({ 45 | Buffer: ['buffer', 'Buffer'] 46 | }), 47 | new CopyPlugin({ 48 | patterns: [{ from: '.', to: '../', context: 'public' }], 49 | options: {} 50 | }), 51 | new CopyPlugin({ 52 | patterns: [{ from: './css/*.css', to: '../', context: 'src' }] 53 | }) 54 | ] 55 | }; 56 | -------------------------------------------------------------------------------- /webpack/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const Dotenv = require('dotenv-webpack'); 4 | 5 | module.exports = merge(common, { 6 | devtool: 'inline-source-map', 7 | mode: 'development', 8 | plugins: [...common.plugins, new Dotenv({ path: '.env.development' })] 9 | }); 10 | -------------------------------------------------------------------------------- /webpack/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const Dotenv = require('dotenv-webpack'); 4 | 5 | module.exports = merge(common, { 6 | mode: 'production', 7 | plugins: [...common.plugins, new Dotenv({ path: '.env.production' })] 8 | }); 9 | --------------------------------------------------------------------------------