├── .all-contributorsrc ├── .env.example ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── new-feature.yml ├── semantic.yml └── workflows │ ├── code-check.yml │ └── tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── e2e │ ├── darkmode.spec.cy.ts │ └── homepage.spec.cy.ts └── support │ ├── commands.ts │ └── e2e.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── app │ └── favicon.ico ├── components │ ├── CardSkeleton.tsx │ ├── Dropdown.tsx │ ├── ExportDropdownButton.tsx │ ├── FormatStatsRender.tsx │ ├── Header.tsx │ ├── ReposFilters.tsx │ ├── RepositoryContributionsCard.tsx │ ├── RootLayout.test.tsx │ ├── RootLayout.tsx │ ├── ThemeSelector.test.tsx │ ├── ThemeSelector.tsx │ └── index.ts ├── graphql │ └── queries.ts ├── hooks │ ├── index.ts │ ├── useGitHubPullRequests.ts │ ├── useGitHubQuery.ts │ └── useHandleStateRepositories.ts ├── middleware.ts ├── mocks │ └── contribs.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── auth │ │ │ └── [...nextauth].ts │ ├── index.tsx │ ├── profile │ │ └── index.tsx │ └── stats │ │ └── [login].tsx ├── styles │ └── globals.css ├── types │ ├── export.ts │ ├── github.ts │ ├── next-auth.d.ts │ └── session.ts └── utils │ ├── compare.ts │ ├── downloadBlob.ts │ ├── exportAsImage.ts │ ├── exportAsJSON.ts │ ├── exportAsText.ts │ ├── generateText.ts │ └── index.ts ├── tailwind.config.js ├── test └── setup │ └── vitest-setup.ts ├── tsconfig.json └── vitest.config.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributors": [ 10 | { 11 | "login": "Balastrong", 12 | "name": "Leonardo Montini", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/7253929?v=4", 14 | "profile": "https://leonardomontini.dev/", 15 | "contributions": [ 16 | "projectManagement", 17 | "code" 18 | ] 19 | }, 20 | { 21 | "login": "theanantchoubey", 22 | "name": "Anant Choubey", 23 | "avatar_url": "https://avatars.githubusercontent.com/u/91460022?v=4", 24 | "profile": "https://bio.link/anantchoubey", 25 | "contributions": [ 26 | "doc", 27 | "bug", 28 | "code" 29 | ] 30 | }, 31 | { 32 | "login": "priyankarpal", 33 | "name": "Priyankar Pal ", 34 | "avatar_url": "https://avatars.githubusercontent.com/u/88102392?v=4", 35 | "profile": "http://priyank.live", 36 | "contributions": [ 37 | "doc", 38 | "code", 39 | "ideas" 40 | ] 41 | }, 42 | { 43 | "login": "piyushjha0409", 44 | "name": "Piyush Jha", 45 | "avatar_url": "https://avatars.githubusercontent.com/u/73685420?v=4", 46 | "profile": "https://github.com/piyushjha0409", 47 | "contributions": [ 48 | "code" 49 | ] 50 | }, 51 | { 52 | "login": "dimassibassem", 53 | "name": "Dimassi Bassem", 54 | "avatar_url": "https://avatars.githubusercontent.com/u/75867744?v=4", 55 | "profile": "https://www.bassemdimassi.tech/", 56 | "contributions": [ 57 | "design", 58 | "code" 59 | ] 60 | }, 61 | { 62 | "login": "jakubfronczyk", 63 | "name": "Jakub Fronczyk", 64 | "avatar_url": "https://avatars.githubusercontent.com/u/71935020?v=4", 65 | "profile": "http://jakubfronczyk.com", 66 | "contributions": [ 67 | "code" 68 | ] 69 | }, 70 | { 71 | "login": "black-arm", 72 | "name": "Antonio Basile", 73 | "avatar_url": "https://avatars.githubusercontent.com/u/68558867?v=4", 74 | "profile": "https://github.com/black-arm", 75 | "contributions": [ 76 | "code" 77 | ] 78 | }, 79 | { 80 | "login": "Agrimaagrawal", 81 | "name": "Agrima Agrawal", 82 | "avatar_url": "https://avatars.githubusercontent.com/u/84567933?v=4", 83 | "profile": "https://github.com/Agrimaagrawal", 84 | "contributions": [ 85 | "bug" 86 | ] 87 | }, 88 | { 89 | "login": "heshamsadi", 90 | "name": "hicham essaidi", 91 | "avatar_url": "https://avatars.githubusercontent.com/u/85809218?v=4", 92 | "profile": "https://www.linkedin.com/in/hicham-essaidi-840b11288/", 93 | "contributions": [ 94 | "code" 95 | ] 96 | }, 97 | { 98 | "login": "luckyklyist", 99 | "name": "Anupam", 100 | "avatar_url": "https://avatars.githubusercontent.com/u/35479077?v=4", 101 | "profile": "https://www.anupamac.me/", 102 | "contributions": [ 103 | "code" 104 | ] 105 | }, 106 | { 107 | "login": "thititongumpun", 108 | "name": "thititongumpun", 109 | "avatar_url": "https://avatars.githubusercontent.com/u/55313215?v=4", 110 | "profile": "http://thiti.wcydtt.co", 111 | "contributions": [ 112 | "code" 113 | ] 114 | }, 115 | { 116 | "login": "baranero", 117 | "name": "Jakub Baran", 118 | "avatar_url": "https://avatars.githubusercontent.com/u/94863094?v=4", 119 | "profile": "https://www.linkedin.com/in/jakub-baran-42a00522b/", 120 | "contributions": [ 121 | "code" 122 | ] 123 | }, 124 | { 125 | "login": "theflucs", 126 | "name": "Sabrina", 127 | "avatar_url": "https://avatars.githubusercontent.com/u/89919203?v=4", 128 | "profile": "https://github.com/theflucs", 129 | "contributions": [ 130 | "code", 131 | "bug" 132 | ] 133 | }, 134 | { 135 | "login": "K1ethoang", 136 | "name": "Kiet Hoang Gia", 137 | "avatar_url": "https://avatars.githubusercontent.com/u/88199151?v=4", 138 | "profile": "https://github.com/K1ethoang", 139 | "contributions": [ 140 | "code" 141 | ] 142 | }, 143 | { 144 | "login": "CBID2", 145 | "name": "Christine Belzie", 146 | "avatar_url": "https://avatars.githubusercontent.com/u/105683440?v=4", 147 | "profile": "https://www.biodrop.io/CBID2", 148 | "contributions": [ 149 | "review", 150 | "code", 151 | "a11y" 152 | ] 153 | } 154 | ], 155 | "contributorsPerLine": 7, 156 | "skipCi": true, 157 | "repoType": "github", 158 | "repoHost": "https://github.com", 159 | "projectName": "github-stats", 160 | "projectOwner": "DevLeonardoCommunity" 161 | } 162 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Simple login for local development 2 | DEV_GITHUB_TOKEN= 3 | 4 | # Github OAuth (optional, but recommended) 5 | #GITHUB_ID= 6 | #GITHUB_SECRET= 7 | 8 | # Next-Auth secret (required for local development) 9 | NEXTAUTH_SECRET=oYoOxbxGQmKOqbJmxf5h1RScYrC8DZ2BgL2OxT5w/C8= -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals"], 3 | "rules": { 4 | "@typescript-eslint/no-redeclare": [ 5 | "off", 6 | { 7 | "ignoreDeclarationMerge": true 8 | } 9 | ], 10 | "no-unused-vars": 1, 11 | "no-console": "warn" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please fill out the sections below to help everyone identify and fix the bug 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Describe your issue 13 | placeholder: When I click here this happens 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: steps 18 | attributes: 19 | label: Steps to reproduce 20 | placeholder: | 21 | 1. Go to page X 22 | 2. Click here 23 | 3. Click there 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: expected 28 | attributes: 29 | label: What was the expected result? 30 | placeholder: I expected this to happen 31 | - type: textarea 32 | id: screenshots 33 | attributes: 34 | label: Put here any screenshots or videos (optional) 35 | - type: dropdown 36 | id: assign 37 | attributes: 38 | label: "Would you like to work on this issue?" 39 | options: 40 | - "Yes" 41 | - type: markdown 42 | attributes: 43 | value: | 44 | Thanks for reporting this issue! We will get back to you as soon as possible. 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Questions 4 | url: https://discord.gg/5CceB5Y6Zt 5 | about: You can join the discussions on Discord. 6 | - name: Login does not work 7 | url: https://github.com/Balastrong/github-stats/blob/main/CONTRIBUTING.md 8 | about: Before opening a new issue, please make sure to read CONTRIBUTING.md 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-feature.yml: -------------------------------------------------------------------------------- 1 | name: New feature 2 | description: Suggest or request a new feature 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please fill out the sections below to properly describe the new feature you are suggesting. 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Describe the feature 13 | placeholder: A button in the screen X that allows to do Y 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: rationale 18 | attributes: 19 | label: It should be implemented because 20 | placeholder: It will allow to do Y that is needed for Z 21 | - type: textarea 22 | id: context 23 | attributes: 24 | label: Additional context 25 | placeholder: | 26 | Add any other context or screenshots about the feature request here. 27 | - type: dropdown 28 | id: assign 29 | attributes: 30 | label: "Would you like to work on this issue?" 31 | options: 32 | - "Yes" 33 | - type: markdown 34 | attributes: 35 | value: | 36 | Thanks for your suggestion! Let's see together if it can be implemented. 37 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | titleOnly: true 2 | -------------------------------------------------------------------------------- /.github/workflows/code-check.yml: -------------------------------------------------------------------------------- 1 | name: Check code style 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | run-checks: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup Node 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 18.17 18 | - run: npm ci 19 | - name: Checking format 20 | run: npm run format:check 21 | - name: Run lint 22 | run: npm run lint 23 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test runner 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | run-tests: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup Node 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 18.17 18 | - run: npm ci 19 | - name: Run tests 20 | run: npm run test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx,json,md}": "npm run format:fix", 3 | "*.{js,jsx,ts,tsx}": "eslint --max-warnings=0" 4 | } 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.17 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .all-contributorsrc 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [Discord](https://discord.gg/e2kYrpMcCt). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | 1. Fork the repository 4 | 2. Clone the repository to your local machine 5 | 3. Create a new branch 6 | 7 | ``` 8 | git checkout -b 9 | ``` 10 | 11 | 4. Make your changes 12 | 5. Commit and push your changes 13 | 14 | ``` 15 | git add . 16 | git commit -m "commit message" 17 | git push origin 18 | ``` 19 | 20 | 6. Create a pull request 21 | 7. Wait for the pull request to be reviewed and merged 22 | 23 | # How to Setup Environment Variables 24 | 25 | Duplicate and rename the file `.env.example` to `.env.local` and fill in the values. 26 | 27 | Just you need to add only `DEV_GITHUB_TOKEN` in `.env.local` file. 28 | 29 | ## GitHub Token 30 | 31 | 1. Go to `GitHub Settings` -> `Developer Settings` -> `Personal Access Tokens` -> `Token (classic)` -> `Generate new token` 32 | ![image](https://github.com/priyankarpal/ProjectsHut/assets/88102392/bcb319ec-0596-4dfc-ba88-097f591f18e4) 33 | 2. Give the `repo` permission, add token name and copy the token and paste in `.env.local` file. 34 | 35 | ## Setup GitHub OAuth App (Optional) 36 | 37 | 1. Go to `GitHub Developer Settings` -> `OAuth Apps` -> `New OAuth App` 38 | ![image](https://github.com/priyankarpal/ProjectsHut/assets/88102392/26c397a7-4c11-43a7-8dcd-28b4c901750d) 39 | 40 | 2. You have to create a new `OAuth App` and fill in the values as shown in the image below. 41 | ![image](https://github.com/priyankarpal/ProjectsHut/assets/88102392/26c397a7-4c11-43a7-8dcd-28b4c901750d) 42 | 3. Now you have to copy `CLIENT ID` & `CLIENT SECRETS` and paste in `.env.local` file. 43 | ![Group 1](https://github.com/priyankarpal/ProjectsHut/assets/88102392/c4f8c346-7aa7-4cb5-9f93-aa8200a3808f) 44 | 45 | **Note:** `Client ID` goes to `GITHUB_ID` and `Client SECRETS` goes into `GITHUB_SECRET`. 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Leonardo Montini 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 | # GitHub Stats 2 | 3 | 4 | 5 | [![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat-square)](#contributors-) 6 | 7 | 8 | 9 | Your GitHub contributions smartly organized and visualized - showcase meaningful metrics on your CV 10 | 11 | ## What's this? 12 | 13 | Before stating whether this tool is useful or not (it might be) let's disclose its primary goal: improving our skills. 14 | 15 | Why our? Because this tool is open source and everyone is more than welcome to contribute to it! 16 | 17 | You can grab an issue at any time, or join the [Discord](https://discord.gg/bqwyEa6We6) server to discuss the project and its future. Nothing is set in stone, so feel free to share your ideas and suggestions. 18 | 19 | ### Learn more 20 | 21 | Here's a video describing the project and its goals (on [YouTube](https://www.youtube.com/watch?v=ZM92XPdrOTk)) 22 | 23 | 24 | 25 | 26 | 27 | ## Technologies involved 28 | 29 | The app is currently based on [Next.js](https://nextjs.org/) with TypeScript and Tailwind CSS (actually with [DaisyUI](https://daisyui.com/), a Tailwind CSS component library). 30 | 31 | We manage some data, specifically from the [GitHub APIs](https://docs.github.com/en/graphql) using the [GraphQL](https://graphql.org/) endpoint and [React Query](https://tanstack.com/query/latest/). 32 | 33 | There's a login feature with [NextAuth](https://next-auth.js.org/) using GitHub as a provider. 34 | 35 | ### Coming soon 36 | 37 | The plan is to also add at some point some kind of user profile and settings, stored where? It's up to you to decide! It could be on MongoDB with an ORM like Prisma or something entirely different. A first start could be using localStorage to validate the concept and then decide which database to use. 38 | 39 | Testing will also be involved in the process, not sure if Vitest or Jest for component testing and either Cypress or Playwright for E2E testing. 40 | 41 | ## How to contribute? 42 | 43 | As mentioned in the beginning, you can grab an issue (write a comment first!) or join the [Discord](https://discord.gg/bqwyEa6We6) server so we can have a chat about the project. 44 | 45 | The goal of this project isn't the outcome itself but rather the process of building it, together! As a result, we'll end up having a nice tool to showcase our GitHub contributions and a project we can use as a reference when we need to implement something similar in other projects. 46 | 47 | Instructions on how to run the app locally can be found in [CONTRIBUTING.md](./CONTRIBUTING.md). 48 | 49 | Thanks for reading and happy coding! 50 | 51 | ## Contributors 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
Leonardo Montini
Leonardo Montini

📆 💻
Anant Choubey
Anant Choubey

📖 🐛 💻
Priyankar Pal
Priyankar Pal

📖 💻 🤔
Piyush Jha
Piyush Jha

💻
Dimassi Bassem
Dimassi Bassem

🎨 💻
Jakub Fronczyk
Jakub Fronczyk

💻
Antonio Basile
Antonio Basile

💻
Agrima Agrawal
Agrima Agrawal

🐛
hicham essaidi
hicham essaidi

💻
Anupam
Anupam

💻
thititongumpun
thititongumpun

💻
Jakub Baran
Jakub Baran

💻
Sabrina
Sabrina

💻 🐛
Kiet Hoang Gia
Kiet Hoang Gia

💻
Christine Belzie
Christine Belzie

👀 💻 ️️️️♿️
81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: {}, 5 | }); 6 | -------------------------------------------------------------------------------- /cypress/e2e/darkmode.spec.cy.ts: -------------------------------------------------------------------------------- 1 | describe("DarkMode test", () => { 2 | beforeEach(() => { 3 | cy.visit("http://localhost:3000"); 4 | cy.wait(3000); 5 | cy.get('data-testid="themeSelectorButton"').click(); 6 | }); 7 | 8 | it("should select light Mode", () => { 9 | cy.get('data-testid="light-mode-option"').click(); 10 | cy.get("html").should("have.data", "theme", "light"); 11 | }); 12 | 13 | it("should select dark mode", () => { 14 | cy.get('data-testid="dark-mode-option"').click(); 15 | cy.get("html").should("have.data", "theme", "custom-dark"); 16 | }); 17 | 18 | it("should select system preference", () => { 19 | cy.visit("http://localhost:3000", { 20 | onBeforeLoad(win) { 21 | cy.stub(win, "matchMedia") 22 | .withArgs("(prefers-color-scheme: dark)") 23 | .returns({ 24 | matches: true, 25 | assListener: () => {}, 26 | }); 27 | }, 28 | }); 29 | cy.wait(3000); 30 | cy.get('data-testid="themeSelectorButton"').click(); 31 | cy.get('data-testid="system-mode-option"').click(); 32 | cy.get("html").should("have.data", "theme", "custom-dark"); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /cypress/e2e/homepage.spec.cy.ts: -------------------------------------------------------------------------------- 1 | describe("template spec", () => { 2 | beforeEach(() => { 3 | cy.visit("http://localhost:3000"); 4 | }); 5 | 6 | it("has the Sign In button on the navbar", () => { 7 | cy.get(".navbar-end > .btn").contains("Sign in"); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "avatars.githubusercontent.com", 8 | }, 9 | ], 10 | }, 11 | }; 12 | 13 | module.exports = nextConfig; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-stats", 3 | "version": "0.1.0", 4 | "engines": { 5 | "node": "^18.17" 6 | }, 7 | "private": true, 8 | "scripts": { 9 | "dev": "next dev", 10 | "build": "next build", 11 | "start": "next start", 12 | "lint": "next lint", 13 | "test": "vitest", 14 | "format:check": "prettier --check .", 15 | "format:fix": "prettier --write .", 16 | "prepare": "husky install", 17 | "cypress": "cypress open" 18 | }, 19 | "dependencies": { 20 | "@octokit/graphql": "^7.0.2", 21 | "@types/node": "20.4.4", 22 | "@types/react": "18.2.15", 23 | "@types/react-dom": "18.2.7", 24 | "autoprefixer": "10.4.14", 25 | "eslint-config-next": "13.4.12", 26 | "html-to-image": "^1.11.11", 27 | "next": "13.4.12", 28 | "next-auth": "^4.22.3", 29 | "octokit": "^3.0.0", 30 | "postcss": "8.4.27", 31 | "react": "18.2.0", 32 | "react-dom": "18.2.0", 33 | "react-github-calendar": "^4.0.1", 34 | "react-icons": "^4.12.0", 35 | "react-loading-skeleton": "^3.3.1", 36 | "react-query": "^3.39.3", 37 | "react-toastify": "^9.1.3", 38 | "react-tooltip": "^5.21.5", 39 | "tailwindcss": "3.3.3", 40 | "typescript": "5.1.6" 41 | }, 42 | "devDependencies": { 43 | "@testing-library/react": "^14.0.0", 44 | "@vitejs/plugin-react": "^4.0.4", 45 | "cypress": "^13.2.0", 46 | "daisyui": "^3.3.1", 47 | "husky": "^8.0.0", 48 | "jsdom": "^22.1.0", 49 | "lint-staged": "^14.0.1", 50 | "prettier": "^3.0.2", 51 | "vitest": "^0.34.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevLeonardoCommunity/github-stats/2e274bde1c50595633514e98f12b1bef353ed4cb/src/app/favicon.ico -------------------------------------------------------------------------------- /src/components/CardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Skeleton from "react-loading-skeleton"; 3 | import "react-loading-skeleton/dist/skeleton.css"; 4 | 5 | export const CardSkeleton = () => { 6 | return ( 7 |
8 |

9 |
10 | 11 | 12 |
13 |
14 | 15 |
16 |

17 |
18 | {Array.from({ length: 5 }, (_, index) => ( 19 |
20 | 21 | 22 |
23 | ))} 24 |
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, HTMLProps, ReactElement, ReactNode } from "react"; 2 | 3 | export type DropdownProps = { 4 | position?: 5 | | "dropdown-top" 6 | | "dropdown-bottom" 7 | | "dropdown-left" 8 | | "dropdown-right"; 9 | align?: "dropdown-end"; 10 | renderButton: ReactElement; 11 | items: (HTMLProps & { 12 | "data-testid"?: string; 13 | onClick?: () => void; 14 | renderItem: ReactNode; 15 | })[]; 16 | }; 17 | 18 | export const closeDropdownOnItemClick = (): void => { 19 | const activeElement = document.activeElement as HTMLElement | null; 20 | if (activeElement && activeElement instanceof HTMLElement) { 21 | activeElement.blur(); 22 | } 23 | }; 24 | 25 | export const Dropdown: FC = ({ 26 | renderButton, 27 | items, 28 | position = "dropdown-bottom", 29 | align, 30 | }) => { 31 | return ( 32 |
33 |
38 | {renderButton} 39 |
40 |
    44 | {items.map(({ onClick, renderItem, ...liProps }, index) => { 45 | return ( 46 |
  • { 50 | onClick?.(); 51 | closeDropdownOnItemClick(); 52 | }} 53 | > 54 |

    {renderItem}

    55 |
  • 56 | ); 57 | })} 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/ExportDropdownButton.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Dropdown } from "@/components"; 3 | import { exportAsImage } from "@/utils"; 4 | 5 | type ExportDropdownButtonProps = { 6 | selector: string; 7 | filename?: string; 8 | }; 9 | 10 | export const ExportDropdownButton: FC = ({ 11 | selector, 12 | filename, 13 | }) => { 14 | return ( 15 | 18 | Export as image 19 | 20 | } 21 | items={[ 22 | { 23 | renderItem: "Download as PNG", 24 | onClick: () => { 25 | exportAsImage(selector, "download", filename); 26 | }, 27 | }, 28 | { 29 | renderItem: "Copy to Clipboard", 30 | onClick: () => { 31 | exportAsImage(selector, "clipboard", filename); 32 | }, 33 | }, 34 | ]} 35 | /> 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/FormatStatsRender.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useMemo } from "react"; 3 | import { 4 | ExportDropdownButton, 5 | RepositoryContributionsCard, 6 | } from "@/components"; 7 | import { 8 | PullRequestContributionsByRepository, 9 | RepositoryRenderFormat, 10 | } from "@/types/github"; 11 | import { exportAsJSON, exportAsText, generateText } from "@/utils"; 12 | 13 | type NoContributionsProps = { 14 | message: string; 15 | }; 16 | 17 | const NoContributions: FC = ({ message }) => ( 18 |
19 |

📃

20 |

{message}

21 |
22 | ); 23 | 24 | type FormatStatsRenderProps = { 25 | repositories: PullRequestContributionsByRepository[]; 26 | format: RepositoryRenderFormat; 27 | }; 28 | 29 | export const FormatStatsRender: FC = ({ 30 | repositories, 31 | format, 32 | }) => { 33 | const renderContent = useMemo(() => { 34 | if (repositories?.length === 0) { 35 | return ; 36 | } 37 | 38 | switch (format) { 39 | case "cards": 40 | return ( 41 | <> 42 | 43 |
44 | {repositories?.map(({ repository, contributions }, i) => ( 45 | 50 | ))} 51 |
52 | 53 | ); 54 | case "json": 55 | return ( 56 |
57 | 63 |
64 |
{JSON.stringify(repositories, null, 2)}
65 |
66 |
67 | ); 68 | 69 | case "text": 70 | return ( 71 |
72 | 78 |
79 |
{generateText(repositories)}
80 |
81 |
82 | ); 83 | default: 84 | return ; 85 | } 86 | }, [format, repositories]); 87 | 88 | return renderContent; 89 | }; 90 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { MAIN_LOGIN_PROVIDER } from "@/pages/api/auth/[...nextauth]"; 2 | import { signIn, signOut, useSession } from "next-auth/react"; 3 | import { ThemeSelector, Dropdown } from "@/components"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import { useRouter } from "next/router"; 7 | 8 | export const Header = () => { 9 | const { data: session, status } = useSession(); 10 | const router = useRouter(); 11 | 12 | const handleLogout = async () => { 13 | await signOut(); 14 | }; 15 | 16 | return ( 17 | <> 18 |
19 |
20 |
21 | 22 | GitHub Stats 23 | 24 |
25 |
26 |
    27 |
  • 28 | Home 29 |
  • 30 | {status === "authenticated" && ( 31 |
  • 32 | Stats 33 |
  • 34 | )} 35 |
36 |
37 |
38 | 39 | {status === "authenticated" ? ( 40 | 44 |
45 | {session.user.name 52 |
53 | 54 | } 55 | items={[ 56 | { 57 | renderItem: ( 58 | 59 | Settings 60 | Soon 61 | 62 | ), 63 | }, 64 | { 65 | onClick: () => { 66 | router.push("/profile"); 67 | }, 68 | renderItem: "Profile", 69 | }, 70 | { 71 | onClick: () => { 72 | handleLogout(); 73 | }, 74 | renderItem: "Logout", 75 | }, 76 | ]} 77 | /> 78 | ) : ( 79 | 85 | )} 86 |
87 |
88 |
89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/ReposFilters.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { 3 | PullRequestState, 4 | RepositoryOrder, 5 | RepositoryRenderFormat, 6 | } from "@/types/github"; 7 | 8 | type ReposFiltersProps = { 9 | searchQuery: string; 10 | setSearchQuery: React.Dispatch>; 11 | pullRequestState: PullRequestState; 12 | setpullRequestState: React.Dispatch>; 13 | repositoriesOrder: RepositoryOrder; 14 | setRepositoriesOrder: React.Dispatch>; 15 | baseYear: number; 16 | year: number; 17 | setYear: React.Dispatch>; 18 | format: RepositoryRenderFormat; 19 | setFormat: React.Dispatch>; 20 | hideOwnRepo: boolean; 21 | setHideOwnRepo: React.Dispatch>; 22 | }; 23 | 24 | export const ReposFilters: FC = ({ 25 | searchQuery, 26 | setSearchQuery, 27 | pullRequestState, 28 | setpullRequestState, 29 | repositoriesOrder, 30 | setRepositoriesOrder, 31 | baseYear, 32 | year, 33 | setYear, 34 | format, 35 | setFormat, 36 | hideOwnRepo, 37 | setHideOwnRepo, 38 | }) => { 39 | const YEARS_RANGE = 4; 40 | const FORMAT_OPTIONS = ["cards", "text", "json"] as const; 41 | 42 | const handleYearChange = (selectedYear: number) => { 43 | setYear(selectedYear); 44 | }; 45 | 46 | const handleFormatChange = (selectedFormat: RepositoryRenderFormat) => { 47 | setFormat(selectedFormat); 48 | }; 49 | 50 | const handleHideOwnRepoChange = () => { 51 | setHideOwnRepo((prevHideOwnRepo) => !prevHideOwnRepo); 52 | }; 53 | 54 | const handlePullRequestStateChange = (selectedState: PullRequestState) => { 55 | setpullRequestState(selectedState); 56 | }; 57 | 58 | const handleRepositoriesOrderChange = (selectedOrder: RepositoryOrder) => { 59 | setRepositoriesOrder(selectedOrder); 60 | }; 61 | 62 | return ( 63 |
64 |
65 |
Select Year
66 |
67 | {Array.from({ length: YEARS_RANGE }).map((_, i) => { 68 | const radioYear = baseYear - YEARS_RANGE + i + 1; 69 | return ( 70 | handleYearChange(radioYear)} 77 | checked={year === radioYear} 78 | /> 79 | ); 80 | })} 81 |
82 |
83 |
84 | 85 | setSearchQuery(e.target.value)} 91 | /> 92 |
93 |
94 | 95 | 107 |
108 |
109 | 110 | 124 |
125 |
126 | 127 |
128 | 135 | 136 |
137 |
138 | 139 |
140 |
Select Format
141 |
142 | {FORMAT_OPTIONS.map((formatOption: RepositoryRenderFormat) => ( 143 | handleFormatChange(formatOption)} 150 | checked={format === formatOption} 151 | /> 152 | ))} 153 |
154 |
155 |
156 | ); 157 | }; 158 | -------------------------------------------------------------------------------- /src/components/RepositoryContributionsCard.tsx: -------------------------------------------------------------------------------- 1 | import { Contributions, PullRequestNode, Repository } from "@/types/github"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import { FaCodeMerge } from "react-icons/fa6"; 5 | import { IoIosCloseCircleOutline } from "react-icons/io"; 6 | import { GoGitPullRequest } from "react-icons/go"; 7 | 8 | export const RepositoryContributionsCard = ({ 9 | repository, 10 | contributions: { totalCount, nodes }, 11 | }: { 12 | repository: Repository; 13 | contributions: Contributions; 14 | }) => { 15 | return ( 16 |
17 |
18 |
19 |
20 |
21 | {repository.owner.login} 28 |
32 | 39 |

40 | {repository.owner.login}/{repository.name} 41 |

42 | 43 |
44 |
48 |
49 | {totalCount} 50 |
51 |
52 |
53 |
54 |
55 | {nodes?.map( 56 | ({ pullRequest: { state, title, id, url } }: PullRequestNode) => ( 57 |
61 | 67 | {title} 68 | 69 | 78 | {state === "MERGED" ? ( 79 | 80 | ) : state === "CLOSED" ? ( 81 | 82 | ) : ( 83 | 84 | )} 85 | 86 |
87 | ) 88 | )} 89 |
90 |
91 |
92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/RootLayout.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { RootLayout } from "./RootLayout"; 3 | import { vi, describe, test, expect } from "vitest"; 4 | 5 | vi.mock("./Header", () => ({ 6 | Header: () =>
Header
, 7 | })); 8 | 9 | vi.mock("next/font/google", () => ({ 10 | Inter: () =>
GoogleFont
, 11 | })); 12 | 13 | describe("RootLayout", () => { 14 | test("renders the children", () => { 15 | render( 16 | 17 |
Test Content
18 |
19 | ); 20 | expect(screen.getByText("Test Content")).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import Head from "next/head"; 3 | import { Header } from "."; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const RootLayout = ({ children }: { children: React.ReactNode }) => { 8 | return ( 9 | <> 10 | 11 | GitHub Stats 12 | 13 |
14 |
{children}
15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/ThemeSelector.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import { describe, test, expect, vi } from "vitest"; 3 | import { ThemeSelector } from "./ThemeSelector"; 4 | 5 | vi.mock("next/font/google", () => ({ 6 | Inter: () =>
GoogleFont
, 7 | })); 8 | 9 | describe("ThemeSelector", () => { 10 | test("should change light Icon if click Dark mode", () => { 11 | render(); 12 | 13 | const button = screen.getByTestId("themeSelectorButton"); 14 | fireEvent.click(button); 15 | 16 | const darkModeItem = screen.getByTestId("dark-mode-option"); 17 | fireEvent.click(darkModeItem); 18 | 19 | const buttonEdited = screen.getByTestId("themeSelectorButton"); 20 | const darkModeSvg = buttonEdited.firstElementChild as SVGElement; 21 | const testidValue = darkModeSvg.dataset.testid; 22 | 23 | expect(testidValue).equal("dark-mode"); 24 | const theme = localStorage.getItem("theme"); 25 | expect(theme).equal("custom-dark"); 26 | 27 | const isDark = document.documentElement.classList.contains("dark"); 28 | expect(isDark).toBeTruthy(); 29 | }); 30 | 31 | test("should change to System Preference", () => { 32 | render(); 33 | 34 | const button = screen.getByTestId("themeSelectorButton"); 35 | fireEvent.click(button); 36 | const systemItem = screen.getByTestId("system-mode-option"); 37 | fireEvent.click(systemItem); 38 | 39 | const buttonEdited = screen.getByTestId("themeSelectorButton"); 40 | const systemSvg = buttonEdited.firstElementChild as SVGAElement; 41 | const testidValue = systemSvg.dataset.testid; 42 | 43 | expect(testidValue).equal("system-mode"); 44 | const theme = localStorage.getItem("theme"); 45 | expect(theme).toBeNull(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/ThemeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Dropdown } from "@/components"; 3 | 4 | type ThemeOptions = "custom-dark" | "light" | "system"; 5 | 6 | export function ThemeSelector() { 7 | const [selectedTheme, setSelectedTheme] = useState( 8 | undefined 9 | ); 10 | 11 | useEffect(() => { 12 | const theme = localStorage.getItem("theme") as ThemeOptions; 13 | if (theme) { 14 | setSelectedTheme(theme); 15 | } 16 | }, []); 17 | 18 | function onClick(theme: ThemeOptions) { 19 | setDocumentElement(theme); 20 | } 21 | 22 | const setDocumentElement = (theme: ThemeOptions) => { 23 | setSelectedTheme(theme); 24 | theme === "system" ? setSystemPreferenceTheme() : setTheme(theme); 25 | }; 26 | 27 | const buttonIcon = getButtonIconByOption(selectedTheme); 28 | 29 | return ( 30 | 39 | {buttonIcon} 40 | 41 | } 42 | items={[ 43 | { 44 | id: "light", 45 | "data-testid": "light-mode-option", 46 | renderItem: "Light Mode", 47 | onClick: () => { 48 | onClick("light"); 49 | }, 50 | }, 51 | { 52 | id: "custom-dark", 53 | "data-testid": "dark-mode-option", 54 | renderItem: "Dark Mode", 55 | onClick: () => { 56 | onClick("custom-dark"); 57 | }, 58 | }, 59 | { 60 | id: "system", 61 | "data-testid": "system-mode-option", 62 | renderItem: "System preference", 63 | onClick: () => { 64 | onClick("system"); 65 | }, 66 | }, 67 | ]} 68 | /> 69 | ); 70 | } 71 | 72 | const LightMode = () => ( 73 | 80 | 92 | 93 | ); 94 | 95 | const DarkMode = () => ( 96 | 103 | 108 | 109 | ); 110 | 111 | const SystemPreference = () => ( 112 | 119 | 123 | 124 | ); 125 | 126 | const getButtonIconByOption = (option: ThemeOptions | undefined) => { 127 | switch (option) { 128 | case "light": 129 | return ; 130 | 131 | case "custom-dark": 132 | return ; 133 | 134 | default: 135 | return ; 136 | } 137 | }; 138 | 139 | const setSystemPreferenceTheme = () => { 140 | const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 141 | setTheme(isDark ? "custom-dark" : "light"); 142 | localStorage.removeItem("theme"); 143 | }; 144 | 145 | const setTheme = (theme: "custom-dark" | "light") => { 146 | localStorage.theme = theme; 147 | document.documentElement.dataset.theme = theme; 148 | theme === "custom-dark" 149 | ? document.documentElement.classList.add("dark") 150 | : document.documentElement.classList.remove("dark"); 151 | }; 152 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ExportDropdownButton"; 2 | export * from "./Dropdown"; 3 | export * from "./ReposFilters"; 4 | export * from "./FormatStatsRender"; 5 | export * from "./RepositoryContributionsCard"; 6 | export * from "./CardSkeleton"; 7 | export * from "./Header"; 8 | export * from "./ThemeSelector"; 9 | export * from "./RootLayout"; 10 | -------------------------------------------------------------------------------- /src/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@/types/github"; 2 | 3 | export const pullRequestsQuery = ` 4 | query ($login: String!, $from: DateTime!) { 5 | user(login: $login) { 6 | contributionsCollection(from: $from) { 7 | pullRequestContributionsByRepository { 8 | contributions(last: 100) { 9 | totalCount 10 | nodes { 11 | pullRequest { 12 | id 13 | title 14 | state 15 | url 16 | } 17 | } 18 | } 19 | repository { 20 | owner { 21 | login 22 | avatarUrl 23 | } 24 | name 25 | stargazerCount 26 | } 27 | } 28 | } 29 | } 30 | } 31 | `; 32 | 33 | export const userProfile = ` 34 | query ($login: String!) { 35 | user(login: $login) { 36 | login 37 | avatarUrl 38 | bio 39 | name 40 | followers { 41 | totalCount 42 | } 43 | starsCount: repositories(first: 0, isFork: false) { 44 | totalCount 45 | } 46 | } 47 | } 48 | `; 49 | 50 | export type UserProfile = { 51 | user: User; 52 | }; 53 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useGitHubPullRequests"; 2 | export * from "./useGitHubQuery"; 3 | export * from "./useHandleStateRepositories"; 4 | -------------------------------------------------------------------------------- /src/hooks/useGitHubPullRequests.ts: -------------------------------------------------------------------------------- 1 | import { pullRequestsQuery } from "@/graphql/queries"; 2 | import { PullRequestContributionsByRepository } from "@/types/github"; 3 | import type { GraphQlQueryResponseData } from "@octokit/graphql"; 4 | import { useMemo } from "react"; 5 | import { useGitHubQuery } from "./useGitHubQuery"; 6 | 7 | export const useGitHubPullRequests = (year: number, login: string) => { 8 | const params = useMemo(() => { 9 | return { 10 | from: `${year}-01-01T00:00:00`, 11 | login, 12 | }; 13 | }, [year, login]); 14 | 15 | const { data, isLoading } = useGitHubQuery( 16 | pullRequestsQuery, 17 | params 18 | ); 19 | 20 | const repositories: PullRequestContributionsByRepository[] = 21 | data?.user?.contributionsCollection?.pullRequestContributionsByRepository; 22 | 23 | return { repositories, isLoading }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/useGitHubQuery.ts: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | import { Octokit } from "octokit"; 3 | import { useQuery } from "react-query"; 4 | 5 | export const useGitHubQuery = ( 6 | query: string, 7 | parameters?: Record 8 | ): { 9 | data?: T; 10 | isLoading: boolean; 11 | } => { 12 | const { data: session, status } = useSession(); 13 | 14 | const fetchData = async () => { 15 | if (status !== "authenticated") return; 16 | 17 | const gh = new Octokit({ 18 | auth: session.accessToken, 19 | }); 20 | 21 | return await gh.graphql(query, { 22 | ...parameters, 23 | login: parameters?.login ?? session.user.login, 24 | }); 25 | }; 26 | 27 | const queryResult = useQuery({ 28 | queryKey: ["GitHubQuery", status, parameters], 29 | queryFn: fetchData, 30 | refetchOnWindowFocus: false, 31 | staleTime: 60000, 32 | }); 33 | 34 | return { 35 | data: queryResult.data, 36 | isLoading: queryResult.isLoading || queryResult.isFetching, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/hooks/useHandleStateRepositories.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useSession } from "next-auth/react"; 3 | import { 4 | PullRequestContributionsByRepository, 5 | PullRequestState, 6 | RepositoryOrder, 7 | } from "@/types/github"; 8 | import { compareArrayString } from "@/utils/compare"; 9 | 10 | export const useHandleStateRepositories = ( 11 | repositories: PullRequestContributionsByRepository[], 12 | searchQuery: string, 13 | hideOwnRepo: boolean, 14 | pullRequestState: PullRequestState, 15 | orderState: RepositoryOrder 16 | ) => { 17 | const { data: session } = useSession(); 18 | 19 | const filteredRepositories = useMemo(() => { 20 | //Filter section 21 | const filterOutOwnRepos = ( 22 | repos: PullRequestContributionsByRepository[] 23 | ) => { 24 | return repos?.filter( 25 | (repoData) => repoData.repository.owner.login !== session?.user.login 26 | ); 27 | }; 28 | const filterReposBySearchQuery = ( 29 | repos: PullRequestContributionsByRepository[] 30 | ) => { 31 | const query = searchQuery.toLowerCase(); 32 | return repos?.filter((repoData) => 33 | repoData.repository.name.toLowerCase().includes(query) 34 | ); 35 | }; 36 | const filterReposByPullRequestState = ( 37 | repos: PullRequestContributionsByRepository[] 38 | ) => { 39 | return repos?.filter((repoData) => 40 | repoData.contributions.nodes.some( 41 | (contribution) => contribution.pullRequest.state === pullRequestState 42 | ) 43 | ); 44 | }; 45 | 46 | const filterRepos = (repos: PullRequestContributionsByRepository[]) => { 47 | let filteredRepos = repos; 48 | if (!searchQuery) { 49 | filteredRepos = hideOwnRepo ? filterOutOwnRepos(repos) : repos; 50 | } else { 51 | const filteredReposBySearchQuery = filterReposBySearchQuery(repos); 52 | filteredRepos = hideOwnRepo 53 | ? filterOutOwnRepos(filteredReposBySearchQuery) 54 | : filteredReposBySearchQuery; 55 | } 56 | 57 | filteredRepos = pullRequestState 58 | ? filterReposByPullRequestState(filteredRepos) 59 | : filteredRepos; 60 | 61 | return filteredRepos; 62 | }; 63 | /** */ 64 | 65 | //Order section 66 | const orderRepoByOwner = ( 67 | repos: PullRequestContributionsByRepository[] 68 | ) => { 69 | return [...repos].sort((a, b) => 70 | compareArrayString(a.repository.owner.login, b.repository.owner.login) 71 | ); 72 | }; 73 | const orderRepoByName = (repos: PullRequestContributionsByRepository[]) => { 74 | return [...repos].sort((a, b) => 75 | compareArrayString(a.repository.name, b.repository.name) 76 | ); 77 | }; 78 | const orderRepoByCountAscending = ( 79 | repos: PullRequestContributionsByRepository[] 80 | ) => { 81 | return [...repos].sort( 82 | (a, b) => a.contributions.totalCount - b.contributions.totalCount 83 | ); 84 | }; 85 | const orderRepoByCountDescending = ( 86 | repos: PullRequestContributionsByRepository[] 87 | ) => { 88 | return [...repos].sort( 89 | (a, b) => b.contributions.totalCount - a.contributions.totalCount 90 | ); 91 | }; 92 | 93 | const orderRepos = (repos: PullRequestContributionsByRepository[]) => { 94 | let orderedRepos = repos; 95 | if (orderState === "OWNER") { 96 | orderedRepos = 97 | orderedRepos !== undefined ? orderRepoByOwner(repos) : orderedRepos; 98 | } else if (orderState === "REPOSITORY") { 99 | orderedRepos = 100 | orderedRepos !== undefined ? orderRepoByName(repos) : orderedRepos; 101 | } else if (orderState === "PRASCENDING") { 102 | orderedRepos = 103 | orderedRepos !== undefined 104 | ? orderRepoByCountAscending(repos) 105 | : orderedRepos; 106 | } else if (orderState === "PRDESCENDING") { 107 | orderedRepos = 108 | orderedRepos !== undefined 109 | ? orderRepoByCountDescending(repos) 110 | : orderedRepos; 111 | } 112 | 113 | return orderedRepos; 114 | }; 115 | /** */ 116 | 117 | const filterAndOrderRepos = ( 118 | repos: PullRequestContributionsByRepository[] 119 | ) => { 120 | return orderRepos(filterRepos(repos)); 121 | }; 122 | 123 | return filterAndOrderRepos(repositories); 124 | }, [ 125 | repositories, 126 | searchQuery, 127 | hideOwnRepo, 128 | pullRequestState, 129 | orderState, 130 | session, 131 | ]); 132 | 133 | return filteredRepositories; 134 | }; 135 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | export { default } from "next-auth/middleware"; 2 | 3 | export const config = { matcher: ["/stats/:path*", "/profile"] }; 4 | -------------------------------------------------------------------------------- /src/mocks/contribs.json: -------------------------------------------------------------------------------- 1 | { 2 | "login": "Balastrong", 3 | "avatarUrl": "https://avatars.githubusercontent.com/u/7253929?v=4", 4 | "contributionsCollection": { 5 | "pullRequestContributionsByRepository": [ 6 | { 7 | "repository": { 8 | "name": "wrand", 9 | "description": "🎲 Extract one or more random elements from a weighted array (aka loot table or gacha)", 10 | "owner": { 11 | "login": "Balastrong" 12 | } 13 | }, 14 | "contributions": { 15 | "totalCount": 11, 16 | "nodes": [ 17 | { 18 | "pullRequest": { 19 | "state": "CLOSED", 20 | "title": "Add new Random Type" 21 | } 22 | }, 23 | { 24 | "pullRequest": { 25 | "state": "CLOSED", 26 | "title": "feat: add new random type" 27 | } 28 | }, 29 | { 30 | "pullRequest": { 31 | "state": "MERGED", 32 | "title": "chore: split actions" 33 | } 34 | }, 35 | { 36 | "pullRequest": { 37 | "state": "MERGED", 38 | "title": "fix: prettier path" 39 | } 40 | }, 41 | { 42 | "pullRequest": { 43 | "state": "MERGED", 44 | "title": "feat: remove items when picked" 45 | } 46 | }, 47 | { 48 | "pullRequest": { 49 | "state": "MERGED", 50 | "title": "fix: build and bump version" 51 | } 52 | }, 53 | { 54 | "pullRequest": { 55 | "state": "MERGED", 56 | "title": "chore: Bump to version 1.0.3" 57 | } 58 | }, 59 | { 60 | "pullRequest": { 61 | "state": "MERGED", 62 | "title": "feat: Add custom random" 63 | } 64 | }, 65 | { 66 | "pullRequest": { 67 | "state": "MERGED", 68 | "title": "feat: Improved error messages with reference to why and what went wrong" 69 | } 70 | }, 71 | { 72 | "pullRequest": { 73 | "state": "MERGED", 74 | "title": "feat: Add coverage action" 75 | } 76 | }, 77 | { 78 | "pullRequest": { 79 | "state": "MERGED", 80 | "title": "chore: Improved ci & added badges" 81 | } 82 | } 83 | ] 84 | } 85 | }, 86 | { 87 | "repository": { 88 | "name": "chess-stats-action", 89 | "description": "♟️ Automatically update your README.md with Chess.com games and stats - fully customizable", 90 | "owner": { 91 | "login": "Balastrong" 92 | } 93 | }, 94 | "contributions": { 95 | "totalCount": 6, 96 | "nodes": [ 97 | { 98 | "pullRequest": { 99 | "state": "MERGED", 100 | "title": "feat: add User-Agent header to get access after the new chess.com api policies" 101 | } 102 | }, 103 | { 104 | "pullRequest": { 105 | "state": "MERGED", 106 | "title": "test: reorganize tests and add coverage" 107 | } 108 | }, 109 | { 110 | "pullRequest": { 111 | "state": "MERGED", 112 | "title": "fix: username is now lowercase as the api fails otherwise" 113 | } 114 | }, 115 | { 116 | "pullRequest": { 117 | "state": "MERGED", 118 | "title": "Fix: add optional typing" 119 | } 120 | }, 121 | { 122 | "pullRequest": { 123 | "state": "MERGED", 124 | "title": "Refactor and getting ready for release v2" 125 | } 126 | }, 127 | { 128 | "pullRequest": { 129 | "state": "MERGED", 130 | "title": "Improved docs and actions" 131 | } 132 | } 133 | ] 134 | } 135 | }, 136 | { 137 | "repository": { 138 | "name": "qwik-ui", 139 | "description": "Qwik UI Components", 140 | "owner": { 141 | "login": "qwikifiers" 142 | } 143 | }, 144 | "contributions": { 145 | "totalCount": 6, 146 | "nodes": [ 147 | { 148 | "pullRequest": { 149 | "state": "MERGED", 150 | "title": "feat: add daisy slider component" 151 | } 152 | }, 153 | { 154 | "pullRequest": { 155 | "state": "MERGED", 156 | "title": "fix(accordion): removed duplicate class in Accordion" 157 | } 158 | }, 159 | { 160 | "pullRequest": { 161 | "state": "MERGED", 162 | "title": "docs(coding standards): define a coding standard for exposing components' props" 163 | } 164 | }, 165 | { 166 | "pullRequest": { 167 | "state": "MERGED", 168 | "title": "docs(daisy popover): replace Box component instead of Header in daisy popover demo" 169 | } 170 | }, 171 | { 172 | "pullRequest": { 173 | "state": "MERGED", 174 | "title": "docs(coding standards): add missing space in header" 175 | } 176 | }, 177 | { 178 | "pullRequest": { 179 | "state": "MERGED", 180 | "title": "fix(rating): add useId as key for each RatingIcon" 181 | } 182 | } 183 | ] 184 | } 185 | }, 186 | { 187 | "repository": { 188 | "name": "trello-card-numbers-plus", 189 | "description": "1️⃣ Show Trello card numbers with a fully customizable Chrome extension", 190 | "owner": { 191 | "login": "Balastrong" 192 | } 193 | }, 194 | "contributions": { 195 | "totalCount": 5, 196 | "nodes": [ 197 | { 198 | "pullRequest": { 199 | "state": "MERGED", 200 | "title": "feat: improve refresh trigger" 201 | } 202 | }, 203 | { 204 | "pullRequest": { 205 | "state": "MERGED", 206 | "title": "feat: pre release improvements 1.1.0" 207 | } 208 | }, 209 | { 210 | "pullRequest": { 211 | "state": "MERGED", 212 | "title": "feat: Add blacklist toggle button and tooltip" 213 | } 214 | }, 215 | { 216 | "pullRequest": { 217 | "state": "MERGED", 218 | "title": "feat: blacklist handle multiple boards" 219 | } 220 | }, 221 | { 222 | "pullRequest": { 223 | "state": "MERGED", 224 | "title": "feat: Implemented blacklist feature" 225 | } 226 | } 227 | ] 228 | } 229 | }, 230 | { 231 | "repository": { 232 | "name": "vscode-pull-request-github", 233 | "description": "GitHub Pull Requests for Visual Studio Code", 234 | "owner": { 235 | "login": "microsoft" 236 | } 237 | }, 238 | "contributions": { 239 | "totalCount": 4, 240 | "nodes": [ 241 | { 242 | "pullRequest": { 243 | "state": "MERGED", 244 | "title": "Change file mode for execute husky hook on MacOS" 245 | } 246 | }, 247 | { 248 | "pullRequest": { 249 | "state": "MERGED", 250 | "title": "Add x button to remove a label from a new PR" 251 | } 252 | }, 253 | { 254 | "pullRequest": { 255 | "state": "MERGED", 256 | "title": "Allow empty array to be pushed to remove the last label" 257 | } 258 | }, 259 | { 260 | "pullRequest": { 261 | "state": "MERGED", 262 | "title": "Allow empty labels array to be pushed to set-labels to remove all of them" 263 | } 264 | } 265 | ] 266 | } 267 | } 268 | ] 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { RootLayout } from "@/components"; 2 | import "@/styles/globals.css"; 3 | import { SessionProvider } from "next-auth/react"; 4 | import { AppProps } from "next/app"; 5 | import { QueryClient, QueryClientProvider } from "react-query"; 6 | import { SkeletonTheme } from "react-loading-skeleton"; 7 | import { ToastContainer } from "react-toastify"; 8 | import "react-toastify/dist/ReactToastify.css"; 9 | 10 | const queryClient = new QueryClient(); 11 | 12 | export default function App({ 13 | Component, 14 | pageProps: { session, ...pageProps }, 15 | }: AppProps) { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |