├── .nvmrc ├── .github ├── semantic.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── new-feature.yml │ └── bug-report.yml └── workflows │ ├── tests.yml │ └── code-check.yml ├── .prettierignore ├── src ├── types │ ├── export.ts │ ├── session.ts │ ├── next-auth.d.ts │ └── github.ts ├── app │ └── favicon.ico ├── middleware.ts ├── hooks │ ├── index.ts │ ├── useGitHubPullRequests.ts │ ├── useGitHubQuery.ts │ └── useHandleStateRepositories.ts ├── utils │ ├── exportAsText.ts │ ├── index.ts │ ├── compare.ts │ ├── downloadBlob.ts │ ├── exportAsJSON.ts │ ├── generateText.ts │ └── exportAsImage.ts ├── components │ ├── index.ts │ ├── RootLayout.tsx │ ├── RootLayout.test.tsx │ ├── ExportDropdownButton.tsx │ ├── CardSkeleton.tsx │ ├── Dropdown.tsx │ ├── ThemeSelector.test.tsx │ ├── FormatStatsRender.tsx │ ├── Header.tsx │ ├── RepositoryContributionsCard.tsx │ ├── ThemeSelector.tsx │ └── ReposFilters.tsx ├── styles │ └── globals.css ├── pages │ ├── _app.tsx │ ├── index.tsx │ ├── _document.tsx │ ├── api │ │ └── auth │ │ │ └── [...nextauth].ts │ ├── stats │ │ └── [login].tsx │ └── profile │ │ └── index.tsx ├── graphql │ └── queries.ts └── mocks │ └── contribs.json ├── .prettierrc ├── .husky └── pre-commit ├── cypress.config.ts ├── postcss.config.js ├── .lintstagedrc ├── .env.example ├── cypress ├── e2e │ ├── homepage.spec.cy.ts │ └── darkmode.spec.cy.ts └── support │ ├── e2e.ts │ └── commands.ts ├── .eslintrc ├── next.config.js ├── test └── setup │ └── vitest-setup.ts ├── .gitignore ├── vitest.config.js ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── tailwind.config.js ├── LICENSE ├── package.json ├── CONTRIBUTING.md ├── .all-contributorsrc ├── CODE_OF_CONDUCT.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.17 -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | titleOnly: true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .all-contributorsrc 2 | -------------------------------------------------------------------------------- /src/types/export.ts: -------------------------------------------------------------------------------- 1 | export type ExportOptions = "download" | "clipboard"; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevLeonardoCommunity/github-stats/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: {}, 5 | }); 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx,json,md}": "npm run format:fix", 3 | "*.{js,jsx,ts,tsx}": "eslint --max-warnings=0" 4 | } 5 | -------------------------------------------------------------------------------- /src/types/session.ts: -------------------------------------------------------------------------------- 1 | export type GitHubUser = { 2 | id: string; 3 | name: string; 4 | image: string; 5 | login: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | export { default } from "next-auth/middleware"; 2 | 3 | export const config = { matcher: ["/stats/:path*", "/profile"] }; 4 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useGitHubPullRequests"; 2 | export * from "./useGitHubQuery"; 3 | export * from "./useHandleStateRepositories"; 4 | -------------------------------------------------------------------------------- /src/utils/exportAsText.ts: -------------------------------------------------------------------------------- 1 | import { downloadBlob } from "."; 2 | 3 | export const exportAsText = (text: string) => { 4 | downloadBlob(new Blob([text], { type: "text/plain" }), "data.txt"); 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./exportAsImage"; 2 | export * from "./exportAsJSON"; 3 | export * from "./exportAsText"; 4 | export * from "./downloadBlob"; 5 | export * from "./generateText"; 6 | -------------------------------------------------------------------------------- /.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= -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/utils/compare.ts: -------------------------------------------------------------------------------- 1 | export const compareArrayString = ( 2 | itemNumberOne: string, 3 | itemNumberTwo: string 4 | ) => { 5 | if (itemNumberOne < itemNumberTwo) { 6 | return -1; 7 | } 8 | if (itemNumberOne > itemNumberTwo) { 9 | return 1; 10 | } 11 | return 0; 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/downloadBlob.ts: -------------------------------------------------------------------------------- 1 | export const downloadBlob = (blob: Blob, filename: string) => { 2 | const url = URL.createObjectURL(blob); 3 | const link = document.createElement("a"); 4 | link.href = url; 5 | link.download = filename; 6 | link.click(); 7 | URL.revokeObjectURL(url); 8 | }; 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { GitHubUser } from "./session"; 2 | 3 | declare module "next-auth" { 4 | // eslint-disable-next-line no-unused-vars 5 | interface Session { 6 | user: GitHubUser; 7 | accessToken: string; 8 | refreshToken: string; 9 | iat: number; 10 | exp: number; 11 | jti: string; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /test/setup/vitest-setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | Object.defineProperty(window, "matchMedia", { 4 | writable: true, 5 | value: vi.fn().mockImplementation((query) => ({ 6 | matches: false, 7 | media: query, 8 | onchange: null, // deprecated 9 | addEventListener: vi.fn(), 10 | removeEventListener: vi.fn(), 11 | dispatchEvent: vi.fn(), 12 | })), 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils/exportAsJSON.ts: -------------------------------------------------------------------------------- 1 | import { PullRequestContributionsByRepository } from "@/types/github"; 2 | import { downloadBlob } from "."; 3 | 4 | export const exportAsJSON = (data: PullRequestContributionsByRepository[]) => { 5 | const jsonStringData = JSON.stringify(data, null, 2); 6 | downloadBlob( 7 | new Blob([jsonStringData], { type: "application/json" }), 8 | "data.json" 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .light-mode { 7 | @apply bg-gradient-to-b from-light-start; 8 | } 9 | 10 | .dark-mode { 11 | @apply bg-gradient-to-b from-black to-black text-white; 12 | } 13 | } 14 | 15 | body, 16 | html { 17 | min-height: 100vh; 18 | } 19 | 20 | .hide-scrollbar::-webkit-scrollbar { 21 | display: none; 22 | } 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from "vite"; 5 | import react from "@vitejs/plugin-react"; 6 | import { resolve } from "node:path"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [react()], 11 | test: { 12 | globals: true, 13 | environment: "jsdom", 14 | setupFiles: ["test/setup/vitest-setup.ts"], 15 | }, 16 | resolve: { 17 | alias: [{ find: "@", replacement: resolve(__dirname, "./src") }], 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"], 28 | "ts-node": { 29 | "compilerOptions": { 30 | "module": "es2015", 31 | "moduleResolution": "node" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: "class", 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | "light-start": " #d6dbdc", 13 | }, 14 | }, 15 | }, 16 | plugins: [require("daisyui")], 17 | daisyui: { 18 | themes: [ 19 | "light", 20 | { 21 | "custom-dark": { 22 | primary: "#2d63bc", 23 | 24 | secondary: "#a78bfa", 25 | 26 | accent: "#1FB2A5", 27 | 28 | neutral: "#191D24", 29 | 30 | "base-100": "#2A303C", 31 | 32 | info: "#3ABFF8", 33 | 34 | success: "#36D399", 35 | 36 | warning: "#FBBD23", 37 | 38 | error: "#F87272", 39 | }, 40 | }, 41 | ], 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /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/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/index.tsx: -------------------------------------------------------------------------------- 1 | import { signIn, useSession } from "next-auth/react"; 2 | import Link from "next/link"; 3 | import { MAIN_LOGIN_PROVIDER } from "./api/auth/[...nextauth]"; 4 | 5 | export default function Home() { 6 | const { data: session, status } = useSession(); 7 | 8 | return ( 9 |
10 |

Showcase your GitHub stats

11 |

12 | Your contributions smartly organized 13 |
14 | Show your efforts to your friends (and in your CV) 15 |

16 | {status === "authenticated" ? ( 17 | 18 | Get Started 19 | 20 | ) : ( 21 | 24 | )} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /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/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/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |