├── .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 |
signIn(MAIN_LOGIN_PROVIDER)} className="btn">
22 | Sign in
23 |
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 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/types/github.ts:
--------------------------------------------------------------------------------
1 | export type PullRequestContributionsByRepository = {
2 | contributions: Contributions;
3 | repository: Repository;
4 | };
5 |
6 | export type Contributions = {
7 | totalCount: number;
8 | nodes: PullRequestNode[];
9 | };
10 |
11 | export type PullRequestNode = {
12 | pullRequest: PullRequest;
13 | };
14 |
15 | export type PullRequest = {
16 | id: string;
17 | title: string;
18 | state: PullRequestState;
19 | url: string;
20 | };
21 |
22 | export type Repository = {
23 | owner: Owner;
24 | name: string;
25 | stargazerCount: number;
26 | };
27 |
28 | export type Owner = {
29 | login: string;
30 | avatarUrl: string;
31 | };
32 |
33 | export type User = {
34 | login: string;
35 | avatarUrl: string;
36 | bio: string;
37 | name: string;
38 | followers: {
39 | totalCount: number;
40 | };
41 | starsCount: {
42 | totalCount: number;
43 | };
44 | };
45 |
46 | export type RepositoryOrder =
47 | | "OWNER"
48 | | "REPOSITORY"
49 | | "PRASCENDING"
50 | | "PRDESCENDING";
51 | export type RepositoryRenderFormat = "cards" | "text" | "json";
52 | export type PullRequestState = "MERGED" | "CLOSED" | "OPEN";
53 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/utils/generateText.ts:
--------------------------------------------------------------------------------
1 | import { PullRequestContributionsByRepository } from "@/types/github";
2 |
3 | export const generateText = (
4 | repositories: PullRequestContributionsByRepository[]
5 | ): string => {
6 | let text = "List of repositories and their pull requests:\n\n";
7 |
8 | for (const repoData of repositories) {
9 | const repositoryName = repoData.repository.name;
10 | const ownerLogin = repoData.repository.owner.login;
11 | const stargazerCount = repoData.repository.stargazerCount;
12 | const avatarUrl = repoData.repository.owner.avatarUrl;
13 |
14 | text += `Repository: ${repositoryName}\n`;
15 | text += `Owner: ${ownerLogin}\n`;
16 | text += `Stargazers: ${stargazerCount}\n`;
17 | text += `Owner Avatar: ${avatarUrl}\n\n`;
18 |
19 | const contributions = repoData.contributions.nodes;
20 | text += "Contributions:\n";
21 | for (const contribution of contributions) {
22 | const prId = contribution.pullRequest.id;
23 | const prTitle = contribution.pullRequest.title;
24 | const prState = contribution.pullRequest.state;
25 | text += `- Pull Request: ${prTitle}\n`;
26 | text += ` ID: ${prId}\n`;
27 | text += ` State: ${prState}\n`;
28 | }
29 | text += "\n";
30 | }
31 | return text;
32 | };
33 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/utils/exportAsImage.ts:
--------------------------------------------------------------------------------
1 | import { toPng } from "html-to-image";
2 | import { toast } from "react-toastify";
3 | import { ExportOptions } from "@/types/export";
4 | import { downloadBlob } from ".";
5 |
6 | export const exportAsImage = async (
7 | selector: string,
8 | option: ExportOptions,
9 | filename?: string
10 | ) => {
11 | try {
12 | const dataURI = await toPng(
13 | document.querySelector(selector) as HTMLElement,
14 | {
15 | includeQueryParams: true,
16 | cacheBust: true,
17 | }
18 | );
19 |
20 | const image = new Image();
21 | image.src = dataURI;
22 | image.onload = () => {
23 | const canvas = document.createElement("canvas");
24 | canvas.width = image.width;
25 | canvas.height = image.height;
26 | const context = canvas.getContext("2d");
27 | context?.drawImage(image, 0, 0);
28 | canvas.toBlob((blob) => {
29 | if (blob) {
30 | switch (option) {
31 | case "download":
32 | downloadBlob(blob, filename ? `${filename}.png` : "image.png");
33 | break;
34 | case "clipboard":
35 | navigator.clipboard
36 | .write([new ClipboardItem({ "image/png": blob })])
37 | .then(() => toast.success("Image copied to clipboard"))
38 | .catch(() => toast.error("Failed to copy image to clipboard"));
39 | break;
40 | default:
41 | break;
42 | }
43 | }
44 | });
45 | canvas.remove();
46 | };
47 | } catch (error) {
48 | toast.error("Failed to export image");
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
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 | 
39 |
40 | 2. You have to create a new `OAuth App` and fill in the values as shown in the image below.
41 | 
42 | 3. Now you have to copy `CLIENT ID` & `CLIENT SECRETS` and paste in `.env.local` file.
43 | 
44 |
45 | **Note:** `Client ID` goes to `GITHUB_ID` and `Client SECRETS` goes into `GITHUB_SECRET`.
46 |
--------------------------------------------------------------------------------
/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/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import { GitHubUser } from "@/types/session";
2 | import NextAuth from "next-auth";
3 | import GithubProvider from "next-auth/providers/github";
4 | import CredentialsProvider from "next-auth/providers/credentials";
5 | import { Octokit } from "octokit";
6 |
7 | export const MAIN_LOGIN_PROVIDER =
8 | process.env.NODE_ENV === "development" ? undefined : "github";
9 |
10 | export default NextAuth({
11 | providers: [
12 | GithubProvider({
13 | clientId: process.env.GITHUB_ID!,
14 | clientSecret: process.env.GITHUB_SECRET!,
15 | profile(profile) {
16 | return {
17 | id: profile.id.toString(),
18 | name: profile.name ?? profile.login,
19 | image: profile.avatar_url,
20 | login: profile.login,
21 | } as GitHubUser;
22 | },
23 | }),
24 | CredentialsProvider({
25 | name: "Credentials",
26 | credentials: {},
27 | async authorize() {
28 | if (process.env.NODE_ENV !== "development")
29 | throw new Error("CredentialsProvider can only be used in dev mode");
30 |
31 | if (!process.env.DEV_GITHUB_TOKEN)
32 | throw new Error("No DEV_GITHUB_TOKEN env variable set");
33 |
34 | const gh = new Octokit({
35 | auth: process.env.DEV_GITHUB_TOKEN,
36 | });
37 |
38 | const { viewer } = await gh.graphql<{ viewer: any }>(`
39 | {
40 | viewer {
41 | id
42 | name
43 | login
44 | avatarUrl
45 | }
46 | }
47 | `);
48 |
49 | return {
50 | id: viewer.id,
51 | name: viewer.name,
52 | login: viewer.login,
53 | image: viewer.avatarUrl,
54 | accessToken: process.env.DEV_GITHUB_TOKEN,
55 | } as GitHubUser & { accessToken: string };
56 | },
57 | }),
58 | ],
59 | callbacks: {
60 | jwt({ token, user, account }) {
61 | if (account?.type === "credentials" && user) {
62 | const { accessToken, ...rest } = user as any;
63 |
64 | return {
65 | accessToken,
66 | user: rest,
67 | };
68 | }
69 |
70 | if (account && user) {
71 | return {
72 | accessToken: account.access_token,
73 | refreshToken: account.refresh_token,
74 | user,
75 | };
76 | }
77 |
78 | return token;
79 | },
80 | async redirect({ baseUrl }) {
81 | return `${baseUrl}/`;
82 | },
83 | session({ session, token }: any) {
84 | return {
85 | ...session,
86 | user: token.user,
87 | accessToken: token.accessToken,
88 | };
89 | },
90 | },
91 | });
92 |
--------------------------------------------------------------------------------
/src/pages/stats/[login].tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useSession } from "next-auth/react";
3 | import { useRouter } from "next/router";
4 | import { useGitHubPullRequests, useHandleStateRepositories } from "@/hooks";
5 | import { CardSkeleton, FormatStatsRender, ReposFilters } from "@/components";
6 | import {
7 | PullRequestState,
8 | RepositoryOrder,
9 | RepositoryRenderFormat,
10 | } from "@/types/github";
11 |
12 | export default function Stats() {
13 | const { data: session } = useSession();
14 | const router = useRouter();
15 | const { login } = router.query;
16 | const baseYear = new Date().getFullYear();
17 | const [year, setYear] = useState(baseYear);
18 | const [format, setFormat] = useState("cards");
19 | const [searchQuery, setSearchQuery] = useState("");
20 | const [pullRequestState, setpullRequestState] = useState(
21 | null!
22 | );
23 | const [repositoriesOrder, setRepositoriesOrder] = useState(
24 | null!
25 | );
26 | const [hideOwnRepo, setHideOwnRepo] = useState(false);
27 |
28 | const { repositories, isLoading } = useGitHubPullRequests(
29 | year,
30 | login as string
31 | );
32 |
33 | const filteredRepositories = useHandleStateRepositories(
34 | repositories,
35 | searchQuery,
36 | hideOwnRepo,
37 | pullRequestState,
38 | repositoriesOrder
39 | );
40 |
41 | return (
42 |
43 |
44 |
45 | {session?.user.name &&
46 | session?.user.login &&
47 | `${session.user.name} (${session.user.login})`}
48 |
49 |
50 |
65 | {isLoading ? (
66 |
67 | {Array.from({ length: 10 }, (_, index) => (
68 |
69 |
70 |
71 | ))}
72 |
73 | ) : (
74 |
78 | )}
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/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 |
exportAsJSON(repositories)}
60 | >
61 | Export as JSON
62 |
63 |
64 |
{JSON.stringify(repositories, null, 2)}
65 |
66 |
67 | );
68 |
69 | case "text":
70 | return (
71 |
72 |
exportAsText(generateText(repositories))}
75 | >
76 | Export as Text
77 |
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 |
89 | >
90 | );
91 | };
92 |
--------------------------------------------------------------------------------
/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 |
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/pages/profile/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | UserProfile,
4 | userProfile as userProfileQuery,
5 | } from "@/graphql/queries";
6 | import { useGitHubQuery } from "@/hooks";
7 | import Image from "next/image";
8 | import Link from "next/link";
9 | import GitHubCalendar from "react-github-calendar";
10 | import { Tooltip as ReactTooltip } from "react-tooltip";
11 | import { ExportDropdownButton } from "@/components";
12 |
13 | interface Activity {
14 | date: string;
15 | count: number;
16 | level: 0 | 1 | 2 | 3 | 4;
17 | }
18 |
19 | export default function Profile() {
20 | const { data } = useGitHubQuery(userProfileQuery);
21 | const [showActivities, setShowActivities] = useState(false);
22 |
23 | if (!data) return "Loading...";
24 |
25 | const selectLastHalfYear = (contributions: Activity[]) => {
26 | const shownMonths = 6;
27 |
28 | const startDate = new Date();
29 | startDate.setMonth(startDate.getMonth() - shownMonths);
30 | startDate.setHours(0, 0, 0, 0);
31 |
32 | return contributions.filter(
33 | (activity: Activity) =>
34 | startDate.getTime() <= new Date(activity.date).getTime()
35 | );
36 | };
37 |
38 | return (
39 |
40 |
41 |
45 |
46 |
47 | Activity Calendar
48 | setShowActivities(e.target.checked)}
53 | />
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
69 |
70 |
71 |
72 |
{data.user.name}
73 |
74 | Followers: {data.user.followers.totalCount}
75 |
76 |
77 | Stars Count: {data.user.starsCount.totalCount}
78 |
79 |
80 |
{data.user.bio}
81 |
82 |
87 | GitHub
88 |
89 |
90 | {showActivities && (
91 |
104 | React.cloneElement(block, {
105 | "data-tooltip-id": "react-tooltip",
106 | "data-tooltip-html": `${activity.count} activities on ${activity.date}`,
107 | })
108 | }
109 | />
110 | )}
111 |
112 |
113 |
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | Search
85 | setSearchQuery(e.target.value)}
91 | />
92 |
93 |
94 | State of PR
95 |
99 | handlePullRequestStateChange(e.target.value as PullRequestState)
100 | }
101 | >
102 |
103 | Open
104 | Merged
105 | Closed
106 |
107 |
108 |
109 | Order by
110 |
114 | handleRepositoriesOrderChange(e.target.value as RepositoryOrder)
115 | }
116 | >
117 | Owner
118 | Repository
119 | N° PR Ascending
120 |
121 | N° PR Descending
122 |
123 |
124 |
125 |
126 |
127 |
128 |
135 | Hide own repositories
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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GitHub Stats
2 |
3 |
4 |
5 | [](#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 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------