├── .github └── FUNDING.yml ├── LICENSE ├── README.md ├── assets └── logo.jpeg ├── background.js ├── content-script.js ├── images ├── icon-128.png ├── icon-16.png ├── icon-32.png └── icon-64.png ├── manifest.json ├── popup ├── components.json ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.cjs ├── public │ └── logo.png ├── src │ ├── components │ │ ├── App.tsx │ │ ├── Daily.tsx │ │ ├── EditButton.tsx │ │ ├── Footer.tsx │ │ ├── InputForm.tsx │ │ ├── LeetCode.tsx │ │ ├── Links.tsx │ │ ├── Logo.tsx │ │ ├── Stats.tsx │ │ ├── Streak.tsx │ │ ├── Welcome.tsx │ │ └── ui │ │ │ ├── Spinner.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── form.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ └── progress.tsx │ ├── context │ │ └── userContext.tsx │ ├── data │ │ └── links.tsx │ ├── hooks │ │ └── leetpush.ts │ ├── index.css │ ├── lib │ │ ├── leetpush.api.ts │ │ └── utils.ts │ ├── main.tsx │ ├── types │ │ └── leetpush.interface.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── style.css /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: husamahmud 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hüsam 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 | # LeetPush Chrome Extension 2 | 3 | ![License](https://img.shields.io/github/license/husamahmud/LeetPush) 4 | ![Version](https://img.shields.io/chrome-web-store/v/gmagfdabfjaipjgdfgddjgongeemkalf) 5 | ![JavaScript](https://img.shields.io/badge/JavaScript-blue?logo=javascript) 6 | ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) 7 | ![Express](https://img.shields.io/badge/Express-black?logo=express&logoColor=white) 8 | ![GraphQL](https://img.shields.io/badge/GraphQL-E10098?logo=GraphQL&logoColor=white) 9 | ![React](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwind-css&logoColor=white) 10 | ![Stars](https://img.shields.io/github/stars/husamahmud/LeetPush) 11 | 12 | 13 | 14 | 15 | ## What is LeetPush? 16 | 17 | LeetPush is a powerful Chrome extension designed for LeetCode enthusiasts who 18 | wish to automate the process of pushing their coding solutions directly to their 19 | GitHub repositories. 20 | 21 | ### Features: 22 | 23 | - One-Click Push: Automatically push your solved problems from LeetCode to 24 | GitHub. 25 | - Easy Configuration: Set up once with your GitHub token and repository details, 26 | and you're all set. 27 | - Seamless Integration: Integrates directly into the LeetCode interface for a 28 | smooth workflow. 29 | - Customization: Choose which solutions to push and manage different 30 | repositories if needed. 31 | 32 | ### Installation and Usage: 33 | 34 | - Download LeetPush from the Chrome Web Store: [**Link**](https://chromewebstore.google.com/detail/leetpush/gmagfdabfjaipjgdfgddjgongeemkalf?hl=en-GB&authuser=0) 35 | - Generate a personal access token on GitHub with repository 36 | access. [**Link**](https://scribehow.com/shared/Generating_a_personal_access_token_on_GitHub__PUPxxuxIRQmlg1MUE-2zig) 37 | - Solve a problem on LeetCode and click on the `Push` button. 38 | - Enter your GitHub username, repository name, and the access token. 39 | - Click on `Push` to push the solution to your GitHub repository. 40 | 41 | ### Explanation Video 42 | 43 | [![Watch the Explanation Video](http://img.youtube.com/vi/7psCr_Pu7GA/0.jpg)](https://www.youtube.com/watch?v=7psCr_Pu7GA) 44 | 45 | ### Contributing: 46 | 47 | We welcome contributions! Feel free to open issues or pull requests to help 48 | improve the extension. 49 | 50 | ### License: 51 | 52 | [MIT](LICENSE) 53 | -------------------------------------------------------------------------------- /assets/logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeetPushExtension/LeetPush/1f5082c463092837a34de908c7d5456917604096/assets/logo.jpeg -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => { 2 | if (changeInfo.status === 'complete') { 3 | const tab = await chrome.tabs.get(tabId) 4 | 5 | // Only inject on LeetCode pages 6 | if (tab.url?.includes('leetcode.com')) { 7 | chrome.scripting.executeScript({ 8 | target: { tabId }, 9 | files: ['content-script.js'], 10 | }) 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /content-script.js: -------------------------------------------------------------------------------- 1 | ; (() => { 2 | // Constants 3 | const BASE_URL = "https://api.github.com/repos" 4 | const FILE_EXTENSIONS = { 5 | C: ".c", 6 | "C++": ".cpp", 7 | "C#": ".cs", 8 | Dart: ".dart", 9 | Elixir: ".ex", 10 | Erlang: ".erl", 11 | Go: ".go", 12 | Java: ".java", 13 | JavaScript: ".js", 14 | Kotlin: ".kt", 15 | PHP: ".php", 16 | Python: ".py", 17 | Python3: ".py", 18 | Racket: ".rkt", 19 | Ruby: ".rb", 20 | Rust: ".rs", 21 | Scala: ".scala", 22 | Swift: ".swift", 23 | TypeScript: ".ts", 24 | MySQL: ".sql", 25 | PostgreSQL: ".sql", 26 | Oracle: ".sql", 27 | "MS SQL Server": ".tsql", 28 | Pandas: ".py", 29 | } 30 | 31 | const LOCAL_STORAGE_KEYS = { 32 | C: "c", 33 | "C++": "cpp", 34 | "C#": "csharp", 35 | Dart: "dart", 36 | Elixir: "elixir", 37 | Erlang: "erlang", 38 | Go: "golang", 39 | Java: "java", 40 | JavaScript: "javascript", 41 | Kotlin: "kotlin", 42 | PHP: "php", 43 | Python: "python", 44 | Python3: "python3", 45 | Racket: "racket", 46 | Ruby: "ruby", 47 | Rust: "rust", 48 | Scala: "scala", 49 | Swift: "swift", 50 | TypeScript: "typeScript", 51 | MySQL: "mysql", 52 | Oracle: "oraclesql", 53 | PostgreSQL: "postgresql", 54 | "MS SQL Server": "mssql", 55 | Pandas: "pythondata", 56 | } 57 | 58 | const DATABASE_LANGUAGES = ["MySQL", "Oracle", "PostgreSQL", "MS SQL Server", "Pandas"] 59 | 60 | // Platform detection 61 | const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform) 62 | 63 | // Default keyboard shortcuts based on platform 64 | const DEFAULT_SHORTCUT = isMac 65 | ? { key: "p", modifier: "meta" } 66 | : { 67 | key: "p", 68 | modifier: "ctrl", 69 | } 70 | 71 | // Shortcut display text 72 | const getShortcutDisplayText = (shortcut) => { 73 | const modifierSymbol = 74 | shortcut.modifier === "meta" 75 | ? "⌘" 76 | : shortcut.modifier === "alt" 77 | ? "⌥" 78 | : shortcut.modifier === "shift" 79 | ? "⇧" 80 | : shortcut.modifier === "ctrl" 81 | ? "Ctrl+" 82 | : "" 83 | return `${modifierSymbol}${shortcut.key.toUpperCase()}` 84 | } 85 | 86 | // Get keyboard shortcut from localStorage or use default 87 | const getKeyboardShortcut = () => { 88 | const savedShortcut = localStorage.getItem("keyboard-shortcut") 89 | if (savedShortcut) { 90 | try { 91 | return JSON.parse(savedShortcut) 92 | } catch (e) { 93 | console.error("Error parsing saved shortcut:", e) 94 | return DEFAULT_SHORTCUT 95 | } 96 | } 97 | return DEFAULT_SHORTCUT 98 | } 99 | 100 | let KEYBOARD_SHORTCUT = getKeyboardShortcut() 101 | let SHORTCUT_DISPLAY = getShortcutDisplayText(KEYBOARD_SHORTCUT) 102 | 103 | // DOM Selectors 104 | const SELECTORS = { 105 | problemName: "div.flex.items-start.justify-between.gap-4 > div.flex.items-start.gap-2 > div > a", 106 | solutionLanguage: 107 | "div.w-full.flex-1.overflow-y-auto > div > div:nth-child(3) > div.flex.items-center.justify-between.pb-2 > div", 108 | accepted: 109 | "div.text-green-s.dark\\:text-dark-green-s.flex.flex-1.items-center.gap-2.text-\\[16px\\].font-medium.leading-6 > span", 110 | parentDiv: 111 | "div.flex.justify-between.py-1.pl-3.pr-1 > div.relative.flex.overflow-hidden.rounded.bg-fill-tertiary.dark\\:bg-fill-tertiary.\\!bg-transparent > div.flex-none.flex > div:nth-child(2)", 112 | parentDivCodeEditor: 113 | "#ide-top-btns > div:nth-child(1) > div > div > div:nth-child(2) > div > div:nth-child(2) > div > div:last-child", 114 | codeBlock: "div.px-4.py-3 > div > pre > code", 115 | performanceMetrics: 116 | "div.flex.items-center.justify-between.gap-2 > div > div.rounded-sd.flex.min-w-\\[275px\\].flex-1.cursor-pointer.flex-col.px-4.py-3.text-xs > div:nth-child(2) > span.font-semibold", 117 | } 118 | 119 | // Main initialization 120 | document.addEventListener("DOMContentLoaded", initLeetPush) 121 | 122 | function initLeetPush() { 123 | // Only run on submission pages with accepted solutions 124 | if (isSubmissionPage() && hasAcceptedSolution()) { 125 | injectButtons() 126 | extractProblemInfo() 127 | registerKeyboardShortcut() 128 | } 129 | } 130 | 131 | // Helper functions 132 | function isSubmissionPage() { 133 | return window.location.href.includes("submissions") 134 | } 135 | 136 | function hasAcceptedSolution() { 137 | return !!document.querySelector(SELECTORS.accepted) 138 | } 139 | 140 | function sleep(ms) { 141 | return new Promise((resolve) => setTimeout(resolve, ms)) 142 | } 143 | 144 | // Register keyboard shortcut 145 | function registerKeyboardShortcut() { 146 | document.addEventListener("keydown", (event) => { 147 | // Check if the shortcut matches the configured one 148 | if ( 149 | (KEYBOARD_SHORTCUT.modifier === "meta" && event.metaKey) || 150 | (KEYBOARD_SHORTCUT.modifier === "alt" && event.altKey) || 151 | (KEYBOARD_SHORTCUT.modifier === "shift" && event.shiftKey) || 152 | (KEYBOARD_SHORTCUT.modifier === "ctrl" && event.ctrlKey) 153 | ) { 154 | if (event.key.toLowerCase() === KEYBOARD_SHORTCUT.key.toLowerCase()) { 155 | event.preventDefault() 156 | handlePushClick() 157 | } 158 | } 159 | }) 160 | } 161 | 162 | // Button injection 163 | function injectButtons() { 164 | const parentDiv = document.querySelector(SELECTORS.parentDiv) 165 | const parentDivCodeEditor = document.querySelector(SELECTORS.parentDivCodeEditor) 166 | 167 | if (parentDiv) { 168 | injectButtonsToParent( 169 | parentDiv, 170 | "leetpush-div-edit", 171 | "leetpush-btn-edit", 172 | "Edit", 173 | "leetpush-div", 174 | "leetpush-btn", 175 | `Push (${SHORTCUT_DISPLAY})`, 176 | false, 177 | ) 178 | } 179 | 180 | if (parentDivCodeEditor) { 181 | injectButtonsToParent( 182 | parentDivCodeEditor, 183 | "leetpush-div-edit-CodeEditor", 184 | "leetpush-btn-edit-CodeEditor", 185 | "Edit", 186 | "leetpush-div-CodeEditor", 187 | "leetpush-btn-CodeEditor", 188 | `Push (${SHORTCUT_DISPLAY})`, 189 | true, 190 | ) 191 | } 192 | } 193 | 194 | function injectButtonsToParent( 195 | parent, 196 | editContainerId, 197 | editButtonId, 198 | editText, 199 | pushContainerId, 200 | pushButtonId, 201 | pushText, 202 | isCodeEditor, 203 | ) { 204 | // Don't inject if already present 205 | if (document.getElementById(editContainerId) || document.getElementById(pushContainerId)) { 206 | return 207 | } 208 | 209 | const editButton = createButton(editContainerId, editButtonId, editText, () => { 210 | localStorage.removeItem("branch") 211 | handlePushClick() 212 | }) 213 | 214 | const pushButton = createButton(pushContainerId, pushButtonId, pushText, handlePushClick) 215 | 216 | // Check if this is CodeEditor layout 217 | if (isCodeEditor) { 218 | // For CodeEditor layout, ADD dividers and icons 219 | const divider1 = document.createElement("div") 220 | divider1.style.backgroundColor = "#0f0f0f" 221 | divider1.style.width = "1px" 222 | divider1.style.height = "100%" 223 | divider1.style.flexShrink = "0" 224 | 225 | const divider2 = document.createElement("div") 226 | divider2.style.backgroundColor = "#0f0f0f" 227 | divider2.style.width = "1px" 228 | divider2.style.height = "100%" 229 | divider2.style.flexShrink = "0" 230 | 231 | parent.appendChild(divider1) 232 | parent.appendChild(editButton) 233 | parent.appendChild(divider2) 234 | parent.appendChild(pushButton) 235 | } else { 236 | // For toolbar layout, don't add dividers 237 | parent.appendChild(editButton) 238 | parent.appendChild(pushButton) 239 | } 240 | } 241 | 242 | function createButton(containerId, buttonId, text, clickHandler) { 243 | const container = document.createElement("div") 244 | container.id = containerId 245 | 246 | const button = document.createElement("button") 247 | button.id = buttonId 248 | button.textContent = text 249 | button.addEventListener("click", clickHandler) 250 | 251 | container.appendChild(button) 252 | return container 253 | } 254 | 255 | // Problem info extraction 256 | async function extractProblemInfo() { 257 | try { 258 | const probNameElement = document.querySelector(SELECTORS.problemName) 259 | if (!probNameElement) { 260 | console.error("Problem name element not found") 261 | return null 262 | } 263 | 264 | const probNameText = probNameElement.textContent?.trim() || "" 265 | if (!probNameText) { 266 | console.error("Problem name is empty") 267 | return null 268 | } 269 | 270 | const probNum = probNameText.split(".")[0]?.trim() || "" 271 | const probName = 272 | probNameText 273 | .replace(/^\d+\./, "") 274 | .trim() 275 | .replaceAll(" ", "-") || "" 276 | 277 | if (!probNum || !probName) { 278 | console.error("Invalid problem number or name:", { probNum, probName }) 279 | return null 280 | } 281 | 282 | // Get solution language 283 | const langElement = document.querySelector(SELECTORS.solutionLanguage).childNodes[2] 284 | if (!langElement) { 285 | console.error("Language element not found") 286 | return null 287 | } 288 | 289 | const solutionLangText = langElement.textContent?.trim() || "" 290 | if (!solutionLangText || !FILE_EXTENSIONS[solutionLangText]) { 291 | console.error("Invalid solution language:", solutionLangText) 292 | return null 293 | } 294 | 295 | const fileExt = FILE_EXTENSIONS[solutionLangText] 296 | const fileName = `${probName}${fileExt}` 297 | 298 | // Get solution from localStorage or DOM 299 | const solutionsId = localStorage.key(0)?.split("_")[1] || "" 300 | let solution = localStorage.getItem(`${probNum}_${solutionsId}_${LOCAL_STORAGE_KEYS[solutionLangText]}`) 301 | 302 | if (!solution) { 303 | const codeElement = document.querySelector(SELECTORS.codeBlock) 304 | solution = codeElement?.textContent || "" 305 | } else { 306 | solution = solution.replace(/\\n/g, "\n").replace(/ {2}/g, " ").replace(/"/g, "") 307 | } 308 | 309 | if (!solution) { 310 | console.error("Solution not found") 311 | return null 312 | } 313 | 314 | // Store in sessionStorage with proper values 315 | sessionStorage.setItem("fileName", fileName) 316 | sessionStorage.setItem("solution", solution) 317 | 318 | // Get performance metrics and create commit message 319 | let commitMsg = "" 320 | if (DATABASE_LANGUAGES.includes(solutionLangText)) { 321 | const metrics = document.querySelectorAll(SELECTORS.performanceMetrics) 322 | const queryRuntimeText = metrics[1]?.textContent || "N/A" 323 | commitMsg = `[${probNum}] [Time Beats: ${queryRuntimeText}] - LeetPush` 324 | } else { 325 | const metrics = document.querySelectorAll(SELECTORS.performanceMetrics) 326 | const runtimeText = metrics[1]?.textContent || "N/A" 327 | const memoryText = metrics[3]?.textContent || "N/A" 328 | commitMsg = `[${probNum}] [Time Beats: ${runtimeText}] [Memory Beats: ${memoryText}] - LeetPush` 329 | } 330 | 331 | sessionStorage.setItem("commitMsg", commitMsg) 332 | 333 | // Log successful extraction 334 | console.log("Problem info extracted successfully:", { 335 | probNum, 336 | probName, 337 | fileName, 338 | solution: solution.substring(0, 50) + "...", 339 | commitMsg, 340 | language: solutionLangText, 341 | }) 342 | 343 | return { 344 | probNum, 345 | probName, 346 | fileName, 347 | solution, 348 | commitMsg, 349 | language: solutionLangText, 350 | } 351 | } catch (error) { 352 | console.error("Error extracting problem info:", error) 353 | return null 354 | } 355 | } 356 | 357 | // Modal creation and handling 358 | function createConfigModal() { 359 | const modal = document.createElement("div") 360 | modal.id = "lp-modal" 361 | modal.innerHTML = ` 362 |
363 |
364 |

LeetPush

365 |
366 |
367 | 368 | 369 |
370 |
371 | 372 | 373 |
374 |
375 | 376 | 377 |
378 |
379 | 380 |
381 | 387 | + 388 | 389 |
390 |
391 |
392 | 393 |
394 |
395 | 396 | 397 |
398 |
399 | 400 | 401 |
402 |
403 |
404 |
405 | 406 |
407 |
408 | 409 | 410 |
411 |
412 | 413 | 414 |
415 |
416 |
417 | 418 |
419 |
420 | ` 421 | 422 | // Add event listeners 423 | modal.querySelector("#lp-close-btn button")?.addEventListener("click", () => { 424 | document.body.removeChild(modal) 425 | }) 426 | 427 | // Close modal when clicking outside the modal container 428 | modal.addEventListener("click", (event) => { 429 | if (event.target === modal) { 430 | document.body.removeChild(modal) 431 | } 432 | }) 433 | 434 | document.addEventListener("keydown", (event) => { 435 | if (event.key === "Escape" && document.body.contains(modal)) { 436 | document.body.removeChild(modal) 437 | } 438 | }) 439 | 440 | modal.querySelector("#lp-form")?.addEventListener("submit", async (event) => { 441 | event.preventDefault() 442 | await saveConfig(modal) 443 | }) 444 | 445 | return modal 446 | } 447 | 448 | async function saveConfig(modal) { 449 | const repoUrlInput = modal.querySelector("#repo-url") 450 | const tokenInput = modal.querySelector("#token") 451 | const branchInput = modal.querySelector('input[name="branch-name"]:checked') 452 | const separateFolderInput = modal.querySelector('input[name="daily-challenge"]:checked') 453 | const customDirInput = modal.querySelector("#custom-dir") 454 | const shortcutModifierInput = modal.querySelector("#shortcut-modifier") 455 | const shortcutKeyInput = modal.querySelector("#shortcut-key") 456 | 457 | if (!repoUrlInput || !tokenInput || !branchInput || !separateFolderInput) return 458 | 459 | // Validate GitHub URL format 460 | const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/ 461 | if (!githubUrlPattern.test(repoUrlInput.value)) { 462 | alert("Please enter a valid GitHub repository URL (https://github.com/username/repository).") 463 | return 464 | } 465 | 466 | // Validate GitHub token (basic format check) 467 | if (!tokenInput.value.startsWith("ghp_") && !tokenInput.value.startsWith("github_pat_")) { 468 | alert('Please enter a valid GitHub token. It should start with "ghp_" or "github_pat_".') 469 | return 470 | } 471 | 472 | const repoUrl = repoUrlInput.value.endsWith(".git") ? repoUrlInput.value.slice(0, -4) : repoUrlInput.value 473 | const token = tokenInput.value 474 | const branch = branchInput.value 475 | const separateFolder = separateFolderInput.value 476 | const customDir = customDirInput?.value || "" 477 | 478 | // Save shortcut if provided 479 | if (shortcutModifierInput && shortcutKeyInput && shortcutKeyInput.value) { 480 | const shortcutKey = shortcutKeyInput.value.toLowerCase() 481 | const shortcutModifier = shortcutModifierInput.value 482 | 483 | KEYBOARD_SHORTCUT = { key: shortcutKey, modifier: shortcutModifier } 484 | SHORTCUT_DISPLAY = getShortcutDisplayText(KEYBOARD_SHORTCUT) 485 | 486 | localStorage.setItem("keyboard-shortcut", JSON.stringify(KEYBOARD_SHORTCUT)) 487 | 488 | // Update button labels with new shortcut 489 | updateButtonLabels() 490 | } 491 | 492 | // Save to localStorage 493 | localStorage.setItem("repo", repoUrl) 494 | localStorage.setItem("token", token) 495 | localStorage.setItem("branch", branch) 496 | localStorage.setItem("separate-folder", separateFolder) 497 | localStorage.setItem("custom-dir", customDir) 498 | 499 | document.body.removeChild(modal) 500 | 501 | // Update README and description 502 | try { 503 | await updateRepoDescription(token, repoUrl, branch) 504 | } catch (error) { 505 | console.error("Error setting up repository metadata:", error) 506 | showError("Initial repository setup failed. You may need to check your repository permissions.") 507 | } 508 | } 509 | 510 | function updateButtonLabels() { 511 | const buttons = [document.querySelector("#leetpush-btn"), document.querySelector("#leetpush-btn-CodeEditor")] 512 | 513 | buttons.forEach((button) => { 514 | if (button) { 515 | button.textContent = `Push (${SHORTCUT_DISPLAY})` 516 | } 517 | }) 518 | } 519 | 520 | function showConfigModal() { 521 | const modal = createConfigModal() 522 | 523 | // Pre-fill with existing values if available 524 | const token = localStorage.getItem("token") 525 | const repo = localStorage.getItem("repo") 526 | const branch = localStorage.getItem("branch") 527 | const separateFolder = localStorage.getItem("separate-folder") 528 | const customDir = localStorage.getItem("custom-dir") 529 | 530 | // Pre-fill shortcut values 531 | const shortcutModifierInput = modal.querySelector("#shortcut-modifier") 532 | const shortcutKeyInput = modal.querySelector("#shortcut-key") 533 | 534 | if (shortcutModifierInput) { 535 | shortcutModifierInput.value = KEYBOARD_SHORTCUT.modifier 536 | } 537 | 538 | if (shortcutKeyInput) { 539 | shortcutKeyInput.value = KEYBOARD_SHORTCUT.key.toUpperCase() 540 | } 541 | 542 | if (token) modal.querySelector("#token").value = token 543 | if (repo) modal.querySelector("#repo-url").value = repo 544 | if (branch) modal.querySelector(`#branch-${branch}`).checked = true 545 | if (separateFolder) modal.querySelector(`#separate-folder-${separateFolder}`).checked = true 546 | if (customDir) modal.querySelector("#custom-dir").value = customDir 547 | 548 | document.body.appendChild(modal) 549 | } 550 | 551 | // Function to show error messages to the user 552 | function showError(message) { 553 | alert(`LeetPush Error: ${message}`) 554 | } 555 | 556 | // GitHub API interactions 557 | async function handlePushClick() { 558 | const config = getGithubConfig() 559 | 560 | if (!isConfigComplete(config)) { 561 | showConfigModal() 562 | return 563 | } 564 | 565 | const pushBtn = getPushButton() 566 | if (!pushBtn) { 567 | console.error("Push button not found") 568 | showError("Interface error: Push button not found. Please refresh the page and try again.") 569 | return 570 | } 571 | 572 | // Force a refresh of problem info 573 | const problemInfo = await extractProblemInfo() 574 | if (!problemInfo) { 575 | console.error("Failed to extract problem info") 576 | showError( 577 | "Failed to extract problem information. This may happen if the page structure has changed or if you're not on a solution page.", 578 | ) 579 | return 580 | } 581 | 582 | const fileName = problemInfo.fileName 583 | const solution = problemInfo.solution 584 | const commitMsg = problemInfo.commitMsg 585 | 586 | if (!fileName || !solution || !commitMsg) { 587 | console.error("Missing required data:", { 588 | fileName, 589 | hasSolution: !!solution, 590 | hasCommitMsg: !!commitMsg, 591 | }) 592 | 593 | if (!fileName) { 594 | showError("Failed to generate a valid file name for your solution.") 595 | } else if (!solution) { 596 | showError("Failed to extract your solution code. Please try again.") 597 | } else { 598 | showError("Failed to generate commit message. Please try again.") 599 | } 600 | return 601 | } 602 | 603 | const [userName, repoName] = config.repo.split("/").slice(3, 5) 604 | if (!userName || !repoName) { 605 | console.error("Invalid repository URL:", config.repo) 606 | showError( 607 | "Invalid repository URL format. Please check your settings and provide a valid GitHub repository URL (e.g., https://github.com/username/repository).", 608 | ) 609 | showConfigModal() 610 | return 611 | } 612 | 613 | pushBtn.disabled = true 614 | pushBtn.textContent = "Loading..." 615 | pushBtn.classList.add("loading") 616 | 617 | try { 618 | const result = await pushToGithub( 619 | userName, 620 | repoName, 621 | config.branch, 622 | fileName, 623 | solution, 624 | commitMsg, 625 | config.token, 626 | config.separateFolder, 627 | config.customDir, 628 | ) 629 | 630 | pushBtn.classList.remove("loading") 631 | pushBtn.classList.add("success") 632 | pushBtn.textContent = "Done" 633 | await sleep(2000) 634 | pushBtn.disabled = false 635 | pushBtn.classList.remove("success") 636 | pushBtn.textContent = `Push (${SHORTCUT_DISPLAY})` 637 | 638 | // Update statistics 639 | const solutionsPushed = Number.parseInt(localStorage.getItem("solutions-pushed") || "0") + 1 640 | localStorage.setItem("solutions-pushed", solutionsPushed.toString()) 641 | 642 | // Check if it's a daily challenge 643 | try { 644 | const [, dailyProblemNum] = await getDailyChallenge() 645 | 646 | if (problemInfo && dailyProblemNum === problemInfo.probNum) { 647 | const dailyChallenges = Number.parseInt(localStorage.getItem("daily-challenges") || "0") + 1 648 | localStorage.setItem("daily-challenges", dailyChallenges.toString()) 649 | } 650 | } catch (error) { 651 | console.error("Error checking daily challenge:", error) 652 | // Non-critical error, don't show to user 653 | } 654 | } catch (error) { 655 | console.error("Failed to push solution:", error) 656 | showError(error.message || "Unknown error occurred while pushing solution") 657 | 658 | pushBtn.classList.remove("loading") 659 | pushBtn.classList.add("error") 660 | pushBtn.textContent = "Error" 661 | await sleep(2000) 662 | pushBtn.disabled = false 663 | pushBtn.classList.remove("error") 664 | pushBtn.textContent = `Push (${SHORTCUT_DISPLAY})` 665 | } 666 | } 667 | 668 | function getGithubConfig() { 669 | return { 670 | token: localStorage.getItem("token") || "", 671 | repo: localStorage.getItem("repo") || "", 672 | branch: localStorage.getItem("branch") || "", 673 | separateFolder: localStorage.getItem("separate-folder") || "", 674 | customDir: localStorage.getItem("custom-dir") || "", 675 | } 676 | } 677 | 678 | function isConfigComplete(config) { 679 | return !!(config.token && config.repo && config.branch && config.separateFolder !== null) 680 | } 681 | 682 | function getPushButton() { 683 | return document.querySelector("#leetpush-btn") || document.querySelector("#leetpush-btn-CodeEditor") 684 | } 685 | 686 | async function pushToGithub( 687 | userName, 688 | repoName, 689 | branch, 690 | fileName, 691 | content, 692 | commitMsg, 693 | token, 694 | separateFolder, 695 | customDir, 696 | ) { 697 | try { 698 | // Check if fileName is valid 699 | if (!fileName || fileName.trim() === "") { 700 | console.error("Invalid file name:", fileName) 701 | throw new Error("Invalid file name. Please try again with a different solution.") 702 | } 703 | 704 | // Check if content is valid 705 | if (!content || content.trim() === "") { 706 | console.error("Invalid content:", content) 707 | throw new Error("No solution content found. Please make sure your solution is visible on the page.") 708 | } 709 | 710 | // Debug logs to track values 711 | console.log("Debug - fileName:", fileName) 712 | console.log("Debug - commitMsg:", commitMsg) 713 | console.log("Debug - content length:", content.length) 714 | 715 | // Determine file path 716 | let filePath = fileName 717 | 718 | if (customDir) { 719 | filePath = `${customDir}/${fileName}` 720 | } else if (separateFolder === "yes") { 721 | try { 722 | const [date, dailyProblemNum] = await getDailyChallenge() 723 | const problemInfo = await extractProblemInfo() 724 | 725 | if (problemInfo && dailyProblemNum === problemInfo.probNum) { 726 | const splitDate = date.split("-") 727 | const dailyFolder = `DCP-${splitDate[1]}-${splitDate[0].slice(2)}` 728 | filePath = `${dailyFolder}/${fileName}` 729 | } 730 | } catch (error) { 731 | console.error("Error processing daily challenge folder:", error) 732 | // Continue without separate folder if there's an error 733 | } 734 | } 735 | 736 | // Ensure filePath is not empty 737 | if (!filePath || filePath.trim() === "") { 738 | console.error("Invalid file path:", filePath) 739 | throw new Error("Failed to generate a valid file path. Please check your target directory settings.") 740 | } 741 | 742 | console.log("Debug - final filePath:", filePath) 743 | 744 | // Push file to repo 745 | return await pushFileToRepo(userName, repoName, filePath, branch, content, commitMsg, token) 746 | } catch (error) { 747 | console.error("Error in pushToGithub:", error) 748 | throw error // Propagate the error to be handled by the caller 749 | } 750 | } 751 | 752 | async function pushFileToRepo(userName, repoName, filePath, branch, content, commitMsg, token) { 753 | if (!filePath || !content || !commitMsg) { 754 | console.error("Missing required parameters:", { 755 | filePath, 756 | hasContent: !!content, 757 | hasCommitMsg: !!commitMsg, 758 | }) 759 | throw new Error("Missing required file information. Please try again.") 760 | } 761 | 762 | const apiUrl = `${BASE_URL}/${userName}/${repoName}/contents/${filePath}` 763 | console.log("API URL:", apiUrl) 764 | 765 | try { 766 | // Check if repo exists 767 | const repoCheckResponse = await fetch(`${BASE_URL}/${userName}/${repoName}`, { 768 | headers: { 769 | Authorization: `Bearer ${token}`, 770 | }, 771 | }) 772 | console.log("Repo check response:", repoCheckResponse) 773 | 774 | if (!repoCheckResponse.ok) { 775 | const errorData = await repoCheckResponse.json() 776 | console.error("Repository check error:", errorData) 777 | 778 | // Handle different error cases with specific messages 779 | if (repoCheckResponse.status === 404) { 780 | throw new Error( 781 | `Repository not found: ${userName}/${repoName}. Please check if the repository exists and your token has access to it.`, 782 | ) 783 | } else if (repoCheckResponse.status === 401) { 784 | throw new Error( 785 | "Authentication failed. Your GitHub token may be invalid or expired. Please generate a new token.", 786 | ) 787 | } else if (repoCheckResponse.status === 403) { 788 | throw new Error("Access forbidden. Your token may not have sufficient permissions to access this repository.") 789 | } else { 790 | throw new Error(`Repository access error: ${errorData.message || "Unknown error"}`) 791 | } 792 | } 793 | 794 | // Prepare request body with proper content encoding 795 | const encodedContent = btoa(unescape(encodeURIComponent(content))) 796 | const requestBody = { 797 | message: commitMsg, 798 | content: encodedContent, 799 | branch: branch, 800 | } 801 | 802 | // Check if file exists and get the latest SHA 803 | let fileExistsRes 804 | try { 805 | fileExistsRes = await fetch(`${apiUrl}?ref=${branch}`, { 806 | headers: { 807 | Authorization: `Bearer ${token}`, 808 | }, 809 | }) 810 | console.log("File exists response:", fileExistsRes) 811 | } catch (error) { 812 | console.error("Error checking if file exists:", error) 813 | throw new Error("Network error while checking if file already exists in repository.") 814 | } 815 | 816 | if (fileExistsRes.ok) { 817 | try { 818 | const existingFileData = await fileExistsRes.json() 819 | if (existingFileData && existingFileData.sha) { 820 | requestBody.sha = existingFileData.sha 821 | console.log("Found existing file SHA:", existingFileData.sha) 822 | } 823 | } catch (error) { 824 | console.error("Error parsing existing file data:", error) 825 | throw new Error("Error processing existing file data from GitHub.") 826 | } 827 | } else if (fileExistsRes.status !== 404) { 828 | // 404 is expected if file doesn't exist yet 829 | const errorData = await fileExistsRes.json() 830 | console.error("Error checking file existence:", errorData) 831 | 832 | if (fileExistsRes.status === 403) { 833 | throw new Error( 834 | 'Permission denied. Make sure your token has "contents: write" permission on this repository.', 835 | ) 836 | } else if (fileExistsRes.status === 401) { 837 | throw new Error("Authentication failed. Your GitHub token may be invalid or expired.") 838 | } else { 839 | throw new Error(`Error checking file: ${errorData.message || "Unknown error"}`) 840 | } 841 | } 842 | 843 | // Make the API call to push the file 844 | let response = await fetch(apiUrl, { 845 | method: "PUT", 846 | headers: { 847 | "Content-Type": "application/json", 848 | Authorization: `Bearer ${token}`, 849 | }, 850 | body: JSON.stringify(requestBody), 851 | }) 852 | console.log("Response status:", response.status) 853 | 854 | // Handle SHA mismatch (409 conflict) error - retry up to 3 times 855 | let retryCount = 0 856 | const maxRetries = 3 857 | 858 | while (response.status === 409 && retryCount < maxRetries) { 859 | console.log( 860 | `SHA mismatch detected (attempt ${retryCount + 1}/${maxRetries}), fetching updated SHA and retrying...`, 861 | ) 862 | retryCount++ 863 | 864 | try { 865 | // Get the latest SHA again 866 | const latestShaRes = await fetch(`${apiUrl}?ref=${branch}`, { 867 | headers: { 868 | Authorization: `Bearer ${token}`, 869 | }, 870 | }) 871 | 872 | if (!latestShaRes.ok) { 873 | console.error(`Failed to get latest SHA on retry ${retryCount}, status: ${latestShaRes.status}`) 874 | continue // Skip to next retry attempt 875 | } 876 | 877 | const latestFileData = await latestShaRes.json() 878 | if (latestFileData && latestFileData.sha) { 879 | // Update the SHA in the request body 880 | requestBody.sha = latestFileData.sha 881 | console.log(`Using updated SHA on retry ${retryCount}: ${latestFileData.sha}`) 882 | 883 | // Retry the request with the updated SHA 884 | response = await fetch(apiUrl, { 885 | method: "PUT", 886 | headers: { 887 | "Content-Type": "application/json", 888 | Authorization: `Bearer ${token}`, 889 | }, 890 | body: JSON.stringify(requestBody), 891 | }) 892 | console.log(`Retry ${retryCount} response status:`, response.status) 893 | 894 | if (response.ok) { 895 | break // Success, exit the retry loop 896 | } 897 | } else { 898 | console.error("Failed to get valid SHA from response:", latestFileData) 899 | break 900 | } 901 | } catch (retryError) { 902 | console.error(`Error during retry ${retryCount}:`, retryError) 903 | break // Exit retry loop on error 904 | } 905 | 906 | // Small delay between retries to avoid rate limiting 907 | await sleep(500) 908 | } 909 | 910 | if (!response.ok) { 911 | let errorMessage = `GitHub API Error: ${response.status}` 912 | try { 913 | const errorData = await response.json() 914 | errorMessage += ` - ${errorData.message || "Unknown error"}` 915 | console.error("GitHub API error details:", errorData) 916 | } catch (jsonError) { 917 | console.error("Failed to parse error response:", jsonError) 918 | } 919 | throw new Error(errorMessage) 920 | } 921 | 922 | const responseData = await response.json() 923 | console.log("File successfully pushed:", responseData.content.html_url) 924 | return true 925 | } catch (error) { 926 | console.error("Error pushing file to repo:", error) 927 | // Don't show alert here, just propagate the error to be handled by the caller 928 | throw error 929 | } 930 | } 931 | 932 | async function updateRepoDescription(token, repo, branch) { 933 | const [userName, repoName] = repo.split("/").slice(3, 5) 934 | const description = "This repository is managed by LeetPush extension: https://github.com/husamahmud/LeetPush" 935 | 936 | try { 937 | await fetch(`${BASE_URL}/${userName}/${repoName}`, { 938 | method: "PATCH", 939 | headers: { 940 | "Content-Type": "application/json", 941 | Authorization: `Bearer ${token}`, 942 | }, 943 | body: JSON.stringify({ description }), 944 | }) 945 | } catch (error) { 946 | console.error("Error updating repo metadata:", error) 947 | } 948 | } 949 | 950 | async function getDailyChallenge() { 951 | const response = await fetch("https://leetcode.com/graphql", { 952 | method: "POST", 953 | headers: { 954 | "Content-Type": "application/json", 955 | }, 956 | body: JSON.stringify({ 957 | query: `{ 958 | activeDailyCodingChallengeQuestion { 959 | date 960 | question { 961 | frontendQuestionId: questionFrontendId 962 | } 963 | } 964 | }`, 965 | }), 966 | }) 967 | 968 | const data = await response.json() 969 | return [ 970 | data.data.activeDailyCodingChallengeQuestion.date, 971 | data.data.activeDailyCodingChallengeQuestion.question.frontendQuestionId, 972 | ] 973 | } 974 | 975 | // Initialize on page load 976 | initLeetPush() 977 | 978 | // Re-initialize on DOM changes (for single-page apps) 979 | const observer = new MutationObserver((mutations) => { 980 | if (isSubmissionPage() && hasAcceptedSolution()) { 981 | const hasButtons = document.getElementById("leetpush-btn") || document.getElementById("leetpush-btn-CodeEditor") 982 | if (!hasButtons) { 983 | initLeetPush() 984 | } 985 | } 986 | }) 987 | 988 | observer.observe(document.body, { childList: true, subtree: true }) 989 | })() 990 | -------------------------------------------------------------------------------- /images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeetPushExtension/LeetPush/1f5082c463092837a34de908c7d5456917604096/images/icon-128.png -------------------------------------------------------------------------------- /images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeetPushExtension/LeetPush/1f5082c463092837a34de908c7d5456917604096/images/icon-16.png -------------------------------------------------------------------------------- /images/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeetPushExtension/LeetPush/1f5082c463092837a34de908c7d5456917604096/images/icon-32.png -------------------------------------------------------------------------------- /images/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeetPushExtension/LeetPush/1f5082c463092837a34de908c7d5456917604096/images/icon-64.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "LeetPush", 4 | "version": "1.7.4", 5 | "description": "Effortlessly capture and push LeetCode solutions to your GitHub repository", 6 | "background": { 7 | "service_worker": "background.js", 8 | "type": "module" 9 | }, 10 | "action": { 11 | "default_popup": "dist/index.html", 12 | "default_icon": { 13 | "16": "images/icon-16.png", 14 | "32": "images/icon-32.png", 15 | "64": "images/icon-64.png", 16 | "128": "images/icon-128.png" 17 | } 18 | }, 19 | "content_scripts": [ 20 | { 21 | "matches": [ 22 | "https://*.leetcode.com/*" 23 | ], 24 | "js": [ 25 | "content-script.js" 26 | ], 27 | "css": [ 28 | "style.css" 29 | ] 30 | } 31 | ], 32 | "permissions": [ 33 | "scripting", 34 | "storage" 35 | ], 36 | "host_permissions": [ 37 | "https://*.leetcode.com/*", 38 | "https://api.github.com/*" 39 | ], 40 | "icons": { 41 | "16": "images/icon-16.png", 42 | "32": "images/icon-32.png", 43 | "64": "images/icon-64.png", 44 | "128": "images/icon-128.png" 45 | } 46 | } -------------------------------------------------------------------------------- /popup/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /popup/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config({ 8 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 9 | files: ['**/*.{ts,tsx}'], 10 | ignores: ['dist'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | }, 15 | plugins: { 16 | 'react-hooks': reactHooks, 17 | 'react-refresh': reactRefresh, 18 | }, 19 | rules: { 20 | ...reactHooks.configs.recommended.rules, 21 | 'react-refresh/only-export-components': [ 22 | 'warn', 23 | { allowConstantExport: true }, 24 | ], 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 10 | LeetPush 11 | 12 | 13 |
14 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /popup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "popup", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^3.9.0", 14 | "@radix-ui/react-label": "^2.1.0", 15 | "@radix-ui/react-progress": "^1.1.0", 16 | "@radix-ui/react-slot": "^1.1.0", 17 | "@radix-ui/react-tooltip": "^1.1.2", 18 | "@tanstack/react-query": "^5.51.23", 19 | "@tanstack/react-query-devtools": "^5.51.23", 20 | "antd": "^5.20.1", 21 | "axios": "^1.7.3", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.1", 24 | "lucide-react": "^0.427.0", 25 | "react": "^18.3.1", 26 | "react-dom": "^18.3.1", 27 | "react-hook-form": "^7.52.2", 28 | "react-icons": "^5.3.0", 29 | "tailwind-merge": "^2.4.0", 30 | "tailwindcss-animate": "^1.0.7", 31 | "zod": "^3.23.8" 32 | }, 33 | "devDependencies": { 34 | "@eslint/js": "^9.8.0", 35 | "@types/node": "^22.2.0", 36 | "@types/react": "^18.3.3", 37 | "@types/react-dom": "^18.3.0", 38 | "@vitejs/plugin-react-swc": "^3.5.0", 39 | "autoprefixer": "^10.4.20", 40 | "eslint": "^9.8.0", 41 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 42 | "eslint-plugin-react-refresh": "^0.4.9", 43 | "globals": "^15.9.0", 44 | "postcss": "^8.4.41", 45 | "prettier": "^3.3.3", 46 | "prettier-plugin-tailwindcss": "^0.6.6", 47 | "tailwindcss": "^3.4.9", 48 | "typescript": "^5.5.3", 49 | "typescript-eslint": "^8.0.0", 50 | "vite": "^5.4.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /popup/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /popup/prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['prettier-plugin-tailwindcss'], 3 | tailwindConfig: './tailwind.config.js', 4 | singleQuote: true, 5 | semi: false, 6 | trailingComma: 'all', 7 | tabWidth: 2, 8 | printWidth: 85, 9 | jsxBracketSameLine: true, 10 | } 11 | -------------------------------------------------------------------------------- /popup/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeetPushExtension/LeetPush/1f5082c463092837a34de908c7d5456917604096/popup/public/logo.png -------------------------------------------------------------------------------- /popup/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import Links from '@/components/Links.tsx' 2 | import Logo from '@/components/Logo.tsx' 3 | import LeetCode from '@/components/LeetCode.tsx' 4 | import Footer from '@/components/Footer.tsx' 5 | import InputForm from '@/components/InputForm.tsx' 6 | import { useContext } from 'react' 7 | import { UserContext } from '@/context/userContext.tsx' 8 | import EditButton from '@/components/EditButton.tsx' 9 | 10 | export default function App() { 11 | const { username } = useContext(UserContext) 12 | 13 | return ( 14 |
15 | 16 | 17 | {username ? ( 18 | 19 | ) : ( 20 | 21 | )} 22 | {username && } 23 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /popup/src/components/Daily.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from '@/components/ui/badge' 2 | 3 | import { getDifficultyColor } from '@/lib/utils.ts' 4 | import { DailyProblemI } from '@/types/leetpush.interface.ts' 5 | 6 | export default function Daily({ data }: { data: DailyProblemI }) { 7 | return ( 8 |
9 |
10 |
11 |
12 |

Daily 13 | Problem

14 | 21 | {data.question.title} 22 | 23 | 26 | {data.question.difficulty} 27 | 28 |
29 | 30 |
31 | {data.question.topicTags.map((tag) => ( 32 | 34 | {tag.name} 35 | 36 | ))} 37 |
38 |
39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /popup/src/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button.tsx' 2 | import { UserContext } from '@/context/userContext.tsx' 3 | import { useContext } from 'react' 4 | 5 | export default function EditButton() { 6 | const { setUsername } = useContext(UserContext) 7 | 8 | return ( 9 |
10 | 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /popup/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { FaXTwitter, FaLinkedinIn } from 'react-icons/fa6' 2 | 3 | export default function Footer() { 4 | return ( 5 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /popup/src/components/InputForm.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, FormEvent, useState } from 'react' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { Input } from '@/components/ui/input' 5 | 6 | import { UserContext } from '@/context/userContext.tsx' 7 | import { fetchUserStats } from '@/lib/leetpush.api.ts' 8 | 9 | export default function InputForm() { 10 | const { username, setUsername } = useContext(UserContext) 11 | const [loading, setLoading] = useState(false) 12 | const [error, setError] = useState('') 13 | 14 | const handleSubmit = async (e: FormEvent) => { 15 | e.preventDefault() 16 | setLoading(true) 17 | 18 | const username = e.currentTarget.username.value 19 | if (!username) { 20 | setError('Please enter a username') 21 | setLoading(false) 22 | return 23 | } 24 | 25 | try { 26 | await fetchUserStats(username) 27 | 28 | setUsername(username) 29 | setError('') 30 | } catch (error) { 31 | if (error instanceof Error) { 32 | if (error.message === 'User not found') { 33 | setError('User not found') 34 | } else { 35 | setError('Failed to fetch user stats') 36 | } 37 | } else { 38 | setError('An unknown error occurred') 39 | } 40 | } finally { 41 | setLoading(false) 42 | } 43 | } 44 | 45 | return ( 46 |
47 |
49 | 54 | 58 |
59 | 60 | {error &&

{error}

} 61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /popup/src/components/LeetCode.tsx: -------------------------------------------------------------------------------- 1 | import Welcome from '@/components/Welcome.tsx' 2 | import Daily from '@/components/Daily.tsx' 3 | import Spinner from '@/components/ui/Spinner.tsx' 4 | import Stats from '@/components/Stats.tsx' 5 | import Streak from '@/components/Streak.tsx' 6 | 7 | import { 8 | useDailyProblem, 9 | useUserStats, 10 | useUserStreak, 11 | } from '@/hooks/leetpush.ts' 12 | import { 13 | DailyProblemI, 14 | UserStatsI, 15 | UserStreakI, 16 | } from '@/types/leetpush.interface.ts' 17 | import { useContext } from 'react' 18 | import { UserContext } from '@/context/userContext.tsx' 19 | 20 | export default function LeetCode() { 21 | const { username } = useContext(UserContext) 22 | 23 | const { 24 | data: dailyProblemData, 25 | error: dailyProblemError, 26 | isLoading: isDailyProblemLoading, 27 | } = useDailyProblem() 28 | 29 | const { 30 | data: userStatsData, 31 | error: userStatsError, 32 | isLoading: isUserStatsLoading, 33 | } = useUserStats(username) 34 | 35 | const { 36 | data: userStreakData, 37 | error: userStreakError, 38 | isLoading: isUserStreakLoadin, 39 | } = useUserStreak(username) 40 | 41 | const isLoading = isDailyProblemLoading || isUserStatsLoading || isUserStreakLoadin 42 | const error = dailyProblemError || userStatsError || userStreakError 43 | const totalProblems = userStatsData?.acSubmissionNum[0]?.count 44 | 45 | return ( 46 |
47 | {isLoading 48 | ? 49 | : error ? ( 50 |
51 |

Error: {error.message}

52 |
53 | ) : ( 54 |
55 | 57 | 58 | 59 | 60 |
61 | )} 62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /popup/src/components/Links.tsx: -------------------------------------------------------------------------------- 1 | import { LINKS } from '@/data/links.tsx' 2 | 3 | export default function Links() { 4 | return ( 5 |
6 | {LINKS.map(({ link, icon }) => ( 7 | 12 | {icon} 13 | 14 | ))} 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /popup/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export default function Logo() { 2 | return ( 3 |
4 | leetpush logo 5 |

6 | LeetPush 7 |

8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /popup/src/components/Stats.tsx: -------------------------------------------------------------------------------- 1 | import { UserStatsI } from '@/types/leetpush.interface.ts' 2 | 3 | export default function Stats({ data }: { data: UserStatsI }) { 4 | const { allQuestionsCount, acSubmissionNum } = data 5 | 6 | const allQuestions = { 7 | all: allQuestionsCount[0].count, 8 | easy: allQuestionsCount[1].count, 9 | medium: allQuestionsCount[2].count, 10 | hard: allQuestionsCount[3].count, 11 | } 12 | 13 | const acSubmissions = { 14 | all: acSubmissionNum[0].count, 15 | easy: acSubmissionNum[1].count, 16 | medium: acSubmissionNum[2].count, 17 | hard: acSubmissionNum[3].count, 18 | } 19 | 20 | const easyPercentage: number = (acSubmissions.easy / allQuestions.easy) * 100 21 | const mediumPercentage: number = (acSubmissions.medium / allQuestions.medium) * 100 22 | const hardPercentage: number = (acSubmissions.hard / allQuestions.hard) * 100 23 | 24 | return ( 25 |
26 |
27 |

28 | Easy 29 | 30 | {acSubmissions.easy}/{allQuestions.easy} 31 | 32 |

33 | 34 |

35 | Medium 36 | 37 | {acSubmissions.medium}/{allQuestions.medium} 38 | 39 |

40 | 41 |

42 | Hard 43 | 44 | {acSubmissions.hard}/{allQuestions.hard} 45 | 46 |

47 |
48 | 49 |
50 |
51 |
53 |
54 | 55 |
56 |
58 |
59 | 60 |
61 |
63 |
64 |
65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /popup/src/components/Streak.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | import { UserStreakI } from '@/types/leetpush.interface.ts' 4 | import { formatStreak, getDayColor, streakEmoji } from '@/lib/utils.ts' 5 | 6 | export default function Streak({ data }: { data: UserStreakI }) { 7 | const endRef = useRef(null) 8 | const streakArray = formatStreak(data.fullSubmissionArray) 9 | 10 | useEffect(() => { 11 | if (endRef.current && streakArray.length > 0) { 12 | endRef.current.scrollLeft = endRef.current.scrollWidth 13 | } 14 | }, [streakArray]) 15 | 16 | return ( 17 |
18 |

19 | Your Max 20 | Streak: {data.maxStreak} {streakEmoji(96)} 21 |

22 | 23 |
25 | {streakArray.map((entry, i) => ( 26 |
28 |
29 | {entry.days.map((daysEntry, i) => ( 30 |
33 | ))} 34 |
35 |

{entry.month}

36 |
37 | ))} 38 |
39 |
40 | ) 41 | } 42 | 43 | 44 | //res.map(entry => { 45 | // console.log(entry.month) 46 | // 47 | // entry.days.map(dayEntry => { 48 | // console.log(dayEntry) 49 | // }) 50 | // 51 | // console.log('---') 52 | // }) 53 | -------------------------------------------------------------------------------- /popup/src/components/Welcome.tsx: -------------------------------------------------------------------------------- 1 | interface WelcomeProps { 2 | username: string 3 | totalProblems: number | undefined 4 | } 5 | 6 | export default function Welcome({ username, totalProblems }: WelcomeProps) { 7 | return ( 8 |
9 |

10 | Hi, {username} 11 |

12 | 13 |

14 | Total 15 | Solved: {totalProblems} 16 |

17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /popup/src/components/ui/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from 'antd' 2 | import { LoadingOutlined } from '@ant-design/icons' 3 | 4 | export default function Spinner() { 5 | return ( 6 |
7 | } 9 | size="large" /> 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /popup/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /popup/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /popup/src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |