├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ └── feature.md ├── pull_request_template.md └── workflows │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE.md ├── README.md ├── jest.config.js ├── libdefs ├── ambient.d.ts ├── font.d.ts ├── img.d.ts ├── intl.d.ts ├── react-awesome-loaders.d.ts └── react.d.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── fonts │ │ ├── Inter-Black.ttf │ │ ├── Inter-Bold.ttf │ │ ├── Inter-ExtraBold.ttf │ │ ├── Inter-ExtraLight.ttf │ │ ├── Inter-Light.ttf │ │ ├── Inter-Medium.ttf │ │ ├── Inter-Regular.ttf │ │ ├── Inter-SemiBold.ttf │ │ ├── Inter-Thin.ttf │ │ └── Inter-Variable.ttf │ └── images │ │ ├── abstract-shape.png │ │ ├── allday.png │ │ ├── artemis.png │ │ ├── confetti.png │ │ ├── dayhawk.png │ │ ├── delorean.png │ │ ├── dr-strange.png │ │ ├── drakeNope.png │ │ ├── drakeYeah.png │ │ ├── fireworks.png │ │ ├── groot.png │ │ ├── joey.png │ │ ├── left-tree.png │ │ ├── logo.svg │ │ ├── ninja.png │ │ ├── niteowl.png │ │ ├── office.png │ │ ├── og-image.png │ │ ├── panda.png │ │ ├── philanthropist.png │ │ ├── reflection.svg │ │ ├── right-tree.png │ │ ├── stars.png │ │ ├── sunburst.png │ │ ├── tony-stark.png │ │ ├── unwrapped.svg │ │ ├── unwrappedBg.png │ │ └── village.png ├── favicon.ico ├── next.svg └── vercel.svg ├── scripts ├── build.sh ├── server-init.sh ├── utils.sh └── zip.sh ├── sentry.client.config.ts ├── sentry.edge.config.ts ├── sentry.server.config.ts ├── src ├── analytics │ ├── __tests__ │ │ ├── contribution-analytics.tests.ts │ │ ├── pr-analytics.tests.ts │ │ └── utils.tests.ts │ ├── contribution-analytics.ts │ ├── pr-analytics.ts │ ├── test-utils │ │ ├── factories.ts │ │ └── types.ts │ └── utils.ts ├── api-helpers │ ├── archive.ts │ ├── auth-supplementary.ts │ ├── card-data-adapter.ts │ ├── exapi-sdk │ │ ├── github.ts │ │ └── types.ts │ ├── fonts.ts │ ├── general.ts │ ├── image-gen.ts │ ├── node-events.ts │ ├── persistance.ts │ ├── unrwrapped-aggregator.ts │ ├── vercel-cover-generator.tsx │ └── vercel-generator.tsx ├── assets │ ├── growth.svg │ ├── logo.svg │ ├── popdevs │ │ ├── d3js │ │ │ ├── authreview.png │ │ │ └── changes.png │ │ ├── dan │ │ │ ├── oss.png │ │ │ └── streak.png │ │ ├── dhh │ │ │ ├── contribs.png │ │ │ └── zen.png │ │ ├── kcd │ │ │ └── contribs.png │ │ ├── linus │ │ │ ├── streak.png │ │ │ └── village.png │ │ ├── matz │ │ │ └── contribs.png │ │ ├── nodejs │ │ │ └── oss.png │ │ ├── tailwind │ │ │ └── drake.png │ │ └── vitalik │ │ │ └── daynight.png │ └── unwrapped-logo.svg ├── components │ ├── AppLoadingStateWrapper.tsx │ ├── AuthActions.tsx │ ├── Description.tsx │ ├── IndexPageSection.tsx │ ├── LoaderWithFacts.tsx │ ├── MouseScrollAnim │ │ ├── MouseScrollAnim.module.css │ │ └── MouseScrollAnim.tsx │ ├── PopDevsMasonry.tsx │ ├── ShareButton.tsx │ ├── SwiperCarousel.tsx │ ├── ThrownCards.tsx │ ├── TrackingConsent.tsx │ ├── TrustNotice.tsx │ ├── UserEmailInputCard.tsx │ └── templates │ │ ├── AuthoredReviewed.tsx │ │ ├── CodeReviews.tsx │ │ ├── Contributions.tsx │ │ ├── Dependants.tsx │ │ ├── Guardian.tsx │ │ ├── IntroCard.tsx │ │ ├── Leader.tsx │ │ ├── OSSContribs.tsx │ │ ├── Pioneer.tsx │ │ ├── RootCard.tsx │ │ ├── Streak.tsx │ │ ├── TimeOfTheDay.tsx │ │ ├── ZenNinja.tsx │ │ └── index.tsx ├── constants │ ├── all-pregen-cards.ts │ ├── events.ts │ └── general.ts ├── contexts │ └── AppContext.tsx ├── hooks │ ├── useImageDownloader.ts │ ├── useImageDownloaderAsPdfHook.ts │ └── usePrebuiltToasts.ts ├── mocks │ └── github.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── _error.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── download.ts │ │ ├── followup-user-email.ts │ │ ├── get-all-images │ │ │ └── index.ts │ │ ├── get_cover │ │ │ ├── [...params].ts │ │ │ └── public │ │ │ │ └── [username].ts │ │ ├── github │ │ │ ├── contribution_summary.ts │ │ │ ├── improvement_metrics.ts │ │ │ ├── pull_requests.ts │ │ │ ├── unwrapped.ts │ │ │ └── user.ts │ │ ├── preview │ │ │ ├── authoredReviewed.tsx │ │ │ ├── codeReviewers.tsx │ │ │ ├── contributions.tsx │ │ │ ├── dependants.tsx │ │ │ ├── guardian.tsx │ │ │ ├── index.tsx │ │ │ ├── intro.tsx │ │ │ ├── leader.tsx │ │ │ ├── ninja.tsx │ │ │ ├── oss.tsx │ │ │ ├── pioneer.tsx │ │ │ ├── streak.tsx │ │ │ ├── timebased │ │ │ │ ├── allday.tsx │ │ │ │ ├── day.tsx │ │ │ │ └── night.tsx │ │ │ └── zen.tsx │ │ └── shared │ │ │ ├── [username] │ │ │ └── [cardname] │ │ │ │ └── [...hash].ts │ │ │ └── public │ │ │ └── [username] │ │ │ └── [cardname] │ │ │ └── index.ts │ ├── index.tsx │ ├── previews.tsx │ ├── stats-unwrapped.tsx │ └── view │ │ ├── [username] │ │ └── [...hash].tsx │ │ └── public │ │ └── [username].tsx ├── styles │ ├── fonts.ts │ ├── globals.css │ └── swiper.css ├── types │ ├── api-responses.ts │ ├── cards.ts │ └── images.ts └── utils │ ├── axios.ts │ ├── datatype.ts │ ├── datetime.ts │ ├── enum.ts │ ├── errors.ts │ ├── logger.ts │ ├── number.ts │ ├── persistence │ ├── file-system.ts │ └── s3.ts │ └── stringHelpers.ts ├── tailwind.config.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # .estlintignore file 2 | dist 3 | .next 4 | build 5 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: ['next/core-web-vitals', 'plugin:react/recommended'], 8 | overrides: [ 9 | { 10 | env: { 11 | node: true 12 | }, 13 | files: ['.eslintrc.{js,cjs}'], 14 | parserOptions: { 15 | sourceType: 'script' 16 | } 17 | } 18 | ], 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module' 22 | }, 23 | plugins: ['react', 'prettier', 'unused-imports', '@stylistic', 'react-hooks'], 24 | rules: { 25 | 'react/react-in-jsx-scope': 'off', 26 | '@next/next/no-img-element': 'off', 27 | '@typescript-eslint/explicit-function-return-type': 'off', 28 | '@typescript-eslint/triple-slash-reference': 'off', 29 | 'prettier/prettier': 'warn', 30 | 'no-unused-vars': 'off', // or "@typescript-eslint/no-unused-vars": "off", 31 | 'unused-imports/no-unused-imports': 'warn', 32 | 'unused-imports/no-unused-vars': [ 33 | 'warn', 34 | { 35 | vars: 'all', 36 | varsIgnorePattern: '^_', 37 | args: 'after-used', 38 | argsIgnorePattern: '^_' 39 | } 40 | ], 41 | 'react/no-unknown-property': ['error', { ignore: ['tw'] }], 42 | '@stylistic/padding-line-between-statements': [ 43 | 'error', 44 | { 45 | blankLine: 'always', 46 | prev: ['*'], 47 | next: ['interface', 'type', 'export'] 48 | } 49 | ], 50 | 'react-hooks/exhaustive-deps': 'warn' 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Create a report to help us improve 4 | labels: bug 5 | --- 6 | 7 | ### Description: 8 | 9 | 10 | 11 | ### Steps to reproduce: 12 | 13 | 1. 14 | 2. 15 | 3. 16 | 17 | ### Expected behavior: 18 | 19 | 20 | 21 | ### Actual behavior: 22 | 23 | 24 | 25 | ### Additional context 26 | 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Create a feature request 4 | labels: enhancement 5 | --- 6 | 7 | ## Why do we need this ? 8 | 9 | 10 | 11 | ## Acceptance Criteria 12 | 13 | 14 | 15 | - [ ] TODO 1 16 | - [ ] TODO 2 17 | - [ ] TODO 3 18 | 19 | ## Further Comments / References 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Issue(s) 2 | 3 | 4 | 5 | ## Acceptance Criteria fulfillment 6 | 7 | 8 | 9 | - [ ] Task 1 10 | - [ ] Task 2 11 | - [ ] Task 3 12 | 13 | ## Proposed changes (including videos or screenshots) 14 | 15 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # package lock 10 | package-lock.json 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | # Sentry Config File 42 | .sentryclirc 43 | 44 | /unwrapped-cards 45 | /**/no-commit* 46 | /**/nocommit* 47 | /**/no-commit*/** 48 | /**/nocommit*/** 49 | /artifacts -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn tsc 5 | yarn lint 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist 3 | .next 4 | build 5 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 80, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSameLine": false 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "gruntfuggly.todo-tree", 4 | "stringham.copy-with-imports", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editor.labelFormat": "short", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | }, 6 | "eslint.validate": ["javascript", "typescript"], 7 | "tsEssentialPlugins.autoImport.changeSorting": { 8 | "createContext": ["react"] 9 | }, 10 | "tsEssentialPlugins.patchOutline": true, 11 | "editor.linkedEditing": true, 12 | "tsEssentialPlugins.enableMethodSnippets": false, 13 | "eslint.debug": false, 14 | "eslint.format.enable": true, 15 | "eslint.ignoreUntitled": true, 16 | "eslint.lintTask.enable": true, 17 | "eslint.codeActionsOnSave.mode": "all", 18 | "eslint.workingDirectories": [ 19 | { "mode": "auto" } 20 | ], 21 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 22 | "tailwindCSS.classAttributes": [ 23 | "class", 24 | "className", 25 | "ngClass", 26 | "tw" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2023 Middleware Software 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. -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | moduleNameMapper: { 6 | '@/(.*)': '/src/$1' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /libdefs/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare type AnyFunction = (...args: any[]) => any | Promise; 2 | declare type AnyAsyncFunction = (...args: any[]) => Promise; 3 | 4 | declare type ID = string; 5 | declare type Timestamp = string; 6 | declare type DateString = string; 7 | 8 | declare namespace NodeJS { 9 | export interface ProcessEnv { 10 | NEXT_PUBLIC_APP_ENVIRONMENT: 'production' | 'development'; 11 | NEXT_PUBLIC_APP_URL: string; 12 | NEXTAUTH_URL: string; 13 | NEXTAUTH_SECRET: string; 14 | GITHUB_ID: string; 15 | GITHUB_SECRET: string; 16 | INTERNAL_API_BASE_URL: string; 17 | TOKEN_ENC_PUB_KEY: string; 18 | TOKEN_ENC_PRI_KEY: string; 19 | SENTRY_DSN: string; 20 | AWS_ACCESS_KEY_ID: string; 21 | AWS_SECRET_ACCESS_KEY: string; 22 | AWS_REGION: string; 23 | UNWRAPPED_PERSISTENCE_BUCKET_NAME: string; 24 | NEXT_PUBLIC_MIXPANEL: string; 25 | ZAPIER_WEBHOOK_URL: string; 26 | NEXT_PUBLIC_GA: string; 27 | GLOBAL_GH_PAT: string; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /libdefs/font.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.ttf' { 2 | const content: any; 3 | 4 | export default content; 5 | } 6 | -------------------------------------------------------------------------------- /libdefs/img.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: any; 3 | 4 | export default content; 5 | } 6 | declare module '*.png' { 7 | const content: any; 8 | 9 | export default content; 10 | } 11 | -------------------------------------------------------------------------------- /libdefs/intl.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Intl { 2 | type Key = 3 | | 'calendar' 4 | | 'collation' 5 | | 'currency' 6 | | 'numberingSystem' 7 | | 'timeZone' 8 | | 'unit'; 9 | 10 | function supportedValuesOf(input: Key): string[]; 11 | } 12 | -------------------------------------------------------------------------------- /libdefs/react-awesome-loaders.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-awesome-loaders'; 2 | -------------------------------------------------------------------------------- /libdefs/react.d.ts: -------------------------------------------------------------------------------- 1 | import 'react'; 2 | 3 | declare module 'react' { 4 | interface HTMLAttributes extends AriaAttributes, DOMAttributes { 5 | tw?: string; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { loadEnvConfig } = require('@next/env'); 2 | const { withSentryConfig } = require('@sentry/nextjs'); 3 | 4 | loadEnvConfig('.'); 5 | 6 | console.info('BUILD ENV:', process.env.NEXT_PUBLIC_APP_ENVIRONMENT); 7 | 8 | /** @type {import('next').NextConfig} */ 9 | const nextConfig = { 10 | reactStrictMode: true, 11 | webpack(config) { 12 | config.module.rules.push({ 13 | test: /\.svg$/, 14 | use: ['@svgr/webpack'] 15 | }); 16 | 17 | return config; 18 | }, 19 | async rewrites() { 20 | return [ 21 | { 22 | source: '/shared/:path*', 23 | destination: '/api/shared/:path*' 24 | }, 25 | { 26 | source: '/api/tunnel/mixpanel/:path*', 27 | destination: 'https://api-js.mixpanel.com/:path*' 28 | } 29 | ]; 30 | } 31 | }; 32 | module.exports = nextConfig; 33 | 34 | if (process.env.NEXT_PUBLIC_APP_ENVIRONMENT !== 'development') { 35 | module.exports = withSentryConfig( 36 | module.exports, 37 | { 38 | // For all available options, see: 39 | // https://github.com/getsentry/sentry-webpack-plugin#options 40 | 41 | // Suppresses source map uploading logs during build 42 | silent: true, 43 | org: 'middlewarehq', 44 | project: 'unwrapped' 45 | }, 46 | { 47 | // For all available options, see: 48 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ 49 | 50 | // Upload a larger set of source maps for prettier stack traces (increases build time) 51 | widenClientFileUpload: true, 52 | 53 | // Transpiles SDK to be compatible with IE11 (increases bundle size) 54 | transpileClientSDK: true, 55 | 56 | // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) 57 | tunnelRoute: '/monitoring', 58 | 59 | // Hides source maps from generated client bundles 60 | hideSourceMaps: true, 61 | 62 | // Automatically tree-shake Sentry logger statements to reduce bundle size 63 | disableLogger: true, 64 | 65 | // Enables automatic instrumentation of Vercel Cron Monitors. 66 | // See the following for more information: 67 | // https://docs.sentry.io/product/crons/ 68 | // https://vercel.com/docs/cron-jobs 69 | automaticVercelMonitors: true 70 | } 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "middleware-unwrapped", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "start": "next start", 8 | "lint": "eslint --fix .", 9 | "prepare": "husky install", 10 | "build": "./scripts/build.sh", 11 | "zip": "./scripts/zip.sh", 12 | "box-server-start-wo-install": "./scripts/server-init.sh", 13 | "box-server-start": "yarn install && yarn box-server-start-wo-install", 14 | "qc": "yarn tsc && yarn lint" 15 | }, 16 | "dependencies": { 17 | "@sentry/nextjs": "^7.86.0", 18 | "@types/bcryptjs": "^2.4.6", 19 | "@vercel/og": "^0.5.20", 20 | "archiver": "^6.0.1", 21 | "aws-sdk": "^2.1515.0", 22 | "axios": "^1.6.2", 23 | "bcryptjs": "^2.4.3", 24 | "chalk": "^5.3.0", 25 | "date-fns": "^2.30.0", 26 | "file-saver": "^2.0.5", 27 | "jspdf": "^2.5.1", 28 | "jszip": "^3.10.1", 29 | "mixpanel": "^0.18.0", 30 | "mixpanel-browser": "^2.48.1", 31 | "next": "14.0.3", 32 | "next-auth": "^4.24.5", 33 | "next-seo": "^6.4.0", 34 | "nextjs-progressbar": "^0.0.16", 35 | "pluralize": "^8.0.0", 36 | "ramda": "^0.29.1", 37 | "react": "^18", 38 | "react-awesome-loaders": "^0.1.37", 39 | "react-confetti": "^6.1.0", 40 | "react-dom": "^18", 41 | "react-hot-toast": "^2.4.1", 42 | "react-icons": "^4.12.0", 43 | "react-responsive-masonry": "^2.1.7", 44 | "react-simple-typewriter": "^5.0.1", 45 | "react-tooltip": "^5.25.0", 46 | "swiper": "^11.0.5", 47 | "ts-node": "^10.9.2", 48 | "usehooks-ts": "^2.9.1", 49 | "uuid": "^9.0.1" 50 | }, 51 | "devDependencies": { 52 | "@stylistic/eslint-plugin": "^1.5.0", 53 | "@svgr/webpack": "^8.1.0", 54 | "@types/archiver": "^6.0.2", 55 | "@types/file-saver": "^2.0.7", 56 | "@types/jest": "^29.5.11", 57 | "@types/mixpanel-browser": "^2.47.5", 58 | "@types/node": "^20", 59 | "@types/pluralize": "^0.0.33", 60 | "@types/ramda": "^0.29.9", 61 | "@types/react": "^18", 62 | "@types/react-dom": "^18", 63 | "@types/react-responsive-masonry": "^2.1.3", 64 | "@types/uuid": "^9.0.7", 65 | "@typescript-eslint/eslint-plugin": "^6.4.0", 66 | "autoprefixer": "^10.0.1", 67 | "eslint": "^8.0.1", 68 | "eslint-config-next": "14.0.3", 69 | "eslint-plugin-import": "^2.25.2", 70 | "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", 71 | "eslint-plugin-prettier": "^5.0.1", 72 | "eslint-plugin-promise": "^6.0.0", 73 | "eslint-plugin-react": "^7.33.2", 74 | "eslint-plugin-unused-imports": "^3.0.0", 75 | "husky": "^8.0.3", 76 | "jest": "^29.7.0", 77 | "pm2": "^5.3.0", 78 | "postcss": "^8", 79 | "prettier": "^3.1.0", 80 | "tailwindcss": "^3.3.0", 81 | "ts-jest": "^29.1.1", 82 | "typescript": "^5.3.3" 83 | }, 84 | "engines": { 85 | "node": ">=20.0.0" 86 | }, 87 | "resolutions": { 88 | "node-sass": "^9.0.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /public/assets/fonts/Inter-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-Black.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-ExtraBold.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-ExtraLight.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-Light.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-Medium.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-Thin.ttf -------------------------------------------------------------------------------- /public/assets/fonts/Inter-Variable.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/fonts/Inter-Variable.ttf -------------------------------------------------------------------------------- /public/assets/images/abstract-shape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/abstract-shape.png -------------------------------------------------------------------------------- /public/assets/images/allday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/allday.png -------------------------------------------------------------------------------- /public/assets/images/artemis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/artemis.png -------------------------------------------------------------------------------- /public/assets/images/confetti.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/confetti.png -------------------------------------------------------------------------------- /public/assets/images/dayhawk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/dayhawk.png -------------------------------------------------------------------------------- /public/assets/images/delorean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/delorean.png -------------------------------------------------------------------------------- /public/assets/images/dr-strange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/dr-strange.png -------------------------------------------------------------------------------- /public/assets/images/drakeNope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/drakeNope.png -------------------------------------------------------------------------------- /public/assets/images/drakeYeah.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/drakeYeah.png -------------------------------------------------------------------------------- /public/assets/images/fireworks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/fireworks.png -------------------------------------------------------------------------------- /public/assets/images/groot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/groot.png -------------------------------------------------------------------------------- /public/assets/images/joey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/joey.png -------------------------------------------------------------------------------- /public/assets/images/left-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/left-tree.png -------------------------------------------------------------------------------- /public/assets/images/ninja.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/ninja.png -------------------------------------------------------------------------------- /public/assets/images/niteowl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/niteowl.png -------------------------------------------------------------------------------- /public/assets/images/office.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/office.png -------------------------------------------------------------------------------- /public/assets/images/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/og-image.png -------------------------------------------------------------------------------- /public/assets/images/panda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/panda.png -------------------------------------------------------------------------------- /public/assets/images/philanthropist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/philanthropist.png -------------------------------------------------------------------------------- /public/assets/images/reflection.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/assets/images/right-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/right-tree.png -------------------------------------------------------------------------------- /public/assets/images/stars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/stars.png -------------------------------------------------------------------------------- /public/assets/images/sunburst.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/sunburst.png -------------------------------------------------------------------------------- /public/assets/images/tony-stark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/tony-stark.png -------------------------------------------------------------------------------- /public/assets/images/unwrapped.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/unwrappedBg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/unwrappedBg.png -------------------------------------------------------------------------------- /public/assets/images/village.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/assets/images/village.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/public/favicon.ico -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | DIR=$(dirname $0) 5 | 6 | source $DIR/utils.sh 7 | catch_force_exit 8 | is_project_root 9 | 10 | NEXT_MANUAL_SIG_HANDLE=true 11 | yarn run next build 12 | 13 | echo "EXITED $?" 14 | 15 | rm -rf .next/cache 16 | yarn run zip -------------------------------------------------------------------------------- /scripts/server-init.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | DIR=$(dirname $0) 5 | 6 | source $DIR/utils.sh 7 | is_project_root 8 | 9 | install_yarn_cmd_if_not_exists pm2 10 | # install_if_missing_vector 11 | 12 | set +e 13 | pm2 stop MHQ_HTTP_SERVER || true 14 | set -e 15 | 16 | NEXT_MANUAL_SIG_HANDLE=true 17 | pm2 start "yarn start" --name MHQ_HTTP_SERVER --time --max-memory-restart 2G 18 | pm2 save -------------------------------------------------------------------------------- /scripts/utils.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | function catch_force_exit() { 5 | # stty -echoctl 6 | trap force_exit INT 7 | 8 | force_exit() { 9 | echo "Force exiting..." 10 | } 11 | } 12 | 13 | function is_project_root() { 14 | if [ -f ./package.json ] && [ -f ./next.config.js ]; then 15 | return 0 16 | else 17 | echo "You must run this command from the project root."; 18 | exit 1 19 | fi 20 | } 21 | 22 | function install_yarn_cmd_if_not_exists() { 23 | if ! command -v yarn; then 24 | sudo npm i -g yarn 25 | fi 26 | 27 | if ! command -v $1; then 28 | yarn global add $1 29 | if ! command -v $1; then 30 | export PATH=$PATH:$(yarn global bin); 31 | if ! command -v $1; then echo "$1 command not found. exiting..."; fi 32 | fi 33 | else 34 | echo "Command check for \"$1\" passed" 35 | fi 36 | } 37 | 38 | function install_if_missing_vector() { 39 | if ! command -v vector; then 40 | curl --proto '=https' --tlsv1.2 -sSf https://sh.vector.dev | bash 41 | fi 42 | } -------------------------------------------------------------------------------- /scripts/zip.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | DIR=$(dirname $0) 5 | 6 | source $DIR/utils.sh 7 | is_project_root 8 | 9 | echo "Starting artifact creation" 10 | 11 | rm -rf artifacts 12 | mkdir artifacts 13 | tar -czf \ 14 | artifacts/artifact.tar.gz \ 15 | package.json \ 16 | yarn.lock \ 17 | .next \ 18 | next.config.js \ 19 | .env* \ 20 | public \ 21 | scripts 22 | 23 | 24 | echo "Completed artifact creation" -------------------------------------------------------------------------------- /sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the client. 2 | // The config you add here will be used whenever a users loads a page in their browser. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | if (process.env.NEXT_PUBLIC_APP_ENVIRONMENT !== 'development') { 8 | Sentry.init({ 9 | dsn: process.env.SENTRY_DSN, 10 | 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1, 13 | 14 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 15 | debug: false, 16 | 17 | replaysOnErrorSampleRate: 1.0, 18 | 19 | // This sets the sample rate to be 10%. You may want this to be 100% while 20 | // in development and sample at a lower rate in production 21 | replaysSessionSampleRate: 0.1, 22 | 23 | // You can remove this option if you're not planning to use the Sentry Session Replay feature: 24 | integrations: [ 25 | new Sentry.Replay({ 26 | // Additional Replay configuration goes in here, for example: 27 | maskAllText: true, 28 | blockAllMedia: true 29 | }) 30 | ] 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). 2 | // The config you add here will be used whenever one of the edge features is loaded. 3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 5 | 6 | import * as Sentry from '@sentry/nextjs'; 7 | 8 | if (process.env.NEXT_PUBLIC_APP_ENVIRONMENT !== 'development') { 9 | Sentry.init({ 10 | dsn: process.env.SENTRY_DSN, 11 | 12 | // Adjust this value in production, or use tracesSampler for greater control 13 | tracesSampleRate: 1, 14 | 15 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 16 | debug: false 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | if (process.env.NEXT_PUBLIC_APP_ENVIRONMENT !== 'development') { 8 | Sentry.init({ 9 | dsn: process.env.SENTRY_DSN, 10 | 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1, 13 | 14 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 15 | debug: false 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/analytics/__tests__/utils.tests.ts: -------------------------------------------------------------------------------- 1 | import { getTopNKeys } from '../utils'; 2 | 3 | test('getTopNKeys returns empty list for empty KeyValueObject Key and n=null', () => { 4 | expect(getTopNKeys({})).toStrictEqual([]); 5 | }); 6 | 7 | test('getTopNKeys returns empty list for empty KeyValueObject Key and n=x', () => { 8 | expect(getTopNKeys({}, 0)).toStrictEqual([]); 9 | expect(getTopNKeys({}, 100)).toStrictEqual([]); 10 | }); 11 | 12 | test('getTopNKeys returns list of all keys in desc order of values for KeyValueObject Key and n<0', () => { 13 | expect(getTopNKeys({ samad: 100, jayant: 0, eeshan: 2 }, -1)).toStrictEqual([ 14 | 'samad', 15 | 'eeshan', 16 | 'jayant' 17 | ]); 18 | expect( 19 | getTopNKeys({ samad: 100, jayant: 0, eeshan: 2, dhruv: 50 }, -1) 20 | ).toStrictEqual(['samad', 'dhruv', 'eeshan', 'jayant']); 21 | }); 22 | 23 | test('getTopNKeys returns list of all keys in desc order of values for KeyValueObject Key and n<0', () => { 24 | expect(getTopNKeys({ samad: 100, jayant: 0, eeshan: 2 }, -1)).toStrictEqual([ 25 | 'samad', 26 | 'eeshan', 27 | 'jayant' 28 | ]); 29 | expect( 30 | getTopNKeys({ samad: 100, jayant: 0, eeshan: 2, dhruv: 50 }, -1) 31 | ).toStrictEqual(['samad', 'dhruv', 'eeshan', 'jayant']); 32 | }); 33 | 34 | test('getTopNKeys returns list of all keys in desc order of values for KeyValueObject Key and n>=0', () => { 35 | expect( 36 | getTopNKeys( 37 | { samad: 100, jayant: 0, eeshan: 2, dhruv: 50, amogh: 400, sherub: 20 }, 38 | 0 39 | ) 40 | ).toStrictEqual([]); 41 | 42 | expect( 43 | getTopNKeys( 44 | { samad: 100, jayant: 0, eeshan: 2, dhruv: 50, amogh: 400, sherub: 20 }, 45 | 1 46 | ) 47 | ).toStrictEqual(['amogh']); 48 | 49 | expect( 50 | getTopNKeys( 51 | { samad: 100, jayant: 0, eeshan: 2, dhruv: 50, amogh: 400, sherub: 20 }, 52 | 3 53 | ) 54 | ).toStrictEqual(['amogh', 'samad', 'dhruv']); 55 | 56 | expect( 57 | getTopNKeys( 58 | { samad: 100, jayant: 0, eeshan: 2, dhruv: 50, amogh: 400, sherub: 20 }, 59 | 100 60 | ) 61 | ).toStrictEqual(['amogh', 'samad', 'dhruv', 'sherub', 'eeshan', 'jayant']); 62 | }); 63 | -------------------------------------------------------------------------------- /src/analytics/contribution-analytics.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ContributionDay, 3 | GithubContributionCalendar, 4 | GraphQLRepositoryContributionData, 5 | RepositoryContributionData 6 | } from '@/api-helpers/exapi-sdk/types'; 7 | import { getMonth, parseISO } from 'date-fns'; 8 | import { clone, sum } from 'ramda'; 9 | 10 | export const getContributionDaysList = ( 11 | contributionCalendar: GithubContributionCalendar 12 | ) => { 13 | return contributionCalendar.weeks 14 | .map((weekData) => weekData.contributionDays) 15 | .flat(); 16 | }; 17 | 18 | export const getMonthWiseContributionCount = ( 19 | contributionCalendar?: GithubContributionCalendar 20 | ) => { 21 | let monthNumberToContributionsCountMap: any = {}; 22 | for (let i = 0; i < 12; i++) monthNumberToContributionsCountMap[i] = 0; 23 | 24 | if (!contributionCalendar) { 25 | return monthNumberToContributionsCountMap; 26 | } 27 | 28 | const contributions: ContributionDay[] = 29 | getContributionDaysList(contributionCalendar); 30 | 31 | for (let contribution of contributions) { 32 | let monthNumber = getMonth(parseISO(contribution.date)); 33 | monthNumberToContributionsCountMap[monthNumber] += 34 | contribution.contributionCount; 35 | } 36 | 37 | return monthNumberToContributionsCountMap; 38 | }; 39 | 40 | export const getWeekWiseContributionCount = ( 41 | contributionCalendar?: GithubContributionCalendar 42 | ) => { 43 | return ( 44 | contributionCalendar?.weeks.map((w) => 45 | sum(w.contributionDays.map((d) => d.contributionCount)) 46 | ) || [] 47 | ); 48 | }; 49 | 50 | export const getLongestContributionStreak = ( 51 | contributionCalendar?: GithubContributionCalendar 52 | ): number => { 53 | if (!contributionCalendar) return 0; 54 | 55 | const contributions: ContributionDay[] = 56 | getContributionDaysList(contributionCalendar); 57 | 58 | let currentContributionStreakLength = 0; 59 | let maxContributionStreakLength = 0; 60 | 61 | for (let contribution of contributions) { 62 | if (!contribution.contributionCount) { 63 | maxContributionStreakLength = Math.max( 64 | currentContributionStreakLength, 65 | maxContributionStreakLength 66 | ); 67 | currentContributionStreakLength = 0; 68 | continue; 69 | } 70 | 71 | currentContributionStreakLength += 1; 72 | } 73 | 74 | maxContributionStreakLength = Math.max( 75 | currentContributionStreakLength, 76 | maxContributionStreakLength 77 | ); 78 | 79 | return maxContributionStreakLength; 80 | }; 81 | 82 | export const getRepoWiseOpensourceContributionsCount = ( 83 | repoContributionData: GraphQLRepositoryContributionData, 84 | author: string 85 | ): Array => { 86 | const collection = repoContributionData.data.user.contributionsCollection; 87 | 88 | if (!collection) return []; 89 | 90 | const flattenedRepoContributionsList = Object.values(collection).flat(); 91 | 92 | const publicRepoContributions = flattenedRepoContributionsList.filter( 93 | (repoData) => !repoData.repository.isPrivate 94 | ); 95 | 96 | const opensourceRepoContributions = publicRepoContributions.filter( 97 | (repoData) => repoData.repository.owner.login !== author 98 | ); 99 | 100 | let repoNameToFinalContributionData: Record< 101 | string, 102 | RepositoryContributionData 103 | > = {}; 104 | 105 | for (let repoContributionData of opensourceRepoContributions) { 106 | const completeRepoName = `${repoContributionData.repository.owner.login}/${repoContributionData.repository.name}`; 107 | if (completeRepoName in repoNameToFinalContributionData) { 108 | repoNameToFinalContributionData[ 109 | completeRepoName 110 | ].contributions.totalCount += 111 | repoContributionData.contributions.totalCount; 112 | } else { 113 | repoNameToFinalContributionData[completeRepoName] = 114 | clone(repoContributionData); 115 | } 116 | } 117 | 118 | return Object.values(repoNameToFinalContributionData).sort( 119 | (repoData1, repoData2) => 120 | repoData2.contributions.totalCount - repoData1.contributions.totalCount 121 | ); 122 | }; 123 | 124 | export const getContributionPercentile = ( 125 | userContributionCount: number 126 | ): number => { 127 | const contributionCounts = [ 128 | 153714, 76460, 40475, 19702, 10534, 6203, 4405, 3264, 2196, 1561, 1029, 765, 129 | 604, 490, 217, 76, 51, 36, 10, 6, 3, 2, 1 130 | ]; 131 | const percentiles = [ 132 | 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 3, 4, 5, 10, 20, 133 | 25, 30, 50, 60, 70, 80, 90 134 | ]; 135 | let percentile = 0.001; 136 | for (let i in contributionCounts) { 137 | if (userContributionCount > contributionCounts[i]) break; 138 | 139 | percentile = percentiles[i]; 140 | } 141 | return percentile; 142 | }; 143 | -------------------------------------------------------------------------------- /src/analytics/test-utils/factories.ts: -------------------------------------------------------------------------------- 1 | import { Review, ReviewEdge } from '@/api-helpers/exapi-sdk/types'; 2 | import { PullRequestGeneratorParams, ReviewGeneratorParams } from './types'; 3 | 4 | export const getPullRequest = ({ 5 | authorLogin = 'samad-yar-khan', 6 | repoOwner = 'middlewarehq', 7 | repoName = 'unwrapped', 8 | createdAt = '2023-02-01T14:04:37Z', 9 | mergedAt = '2023-02-03T14:04:37Z', 10 | additions = 0, 11 | deletions = 0, 12 | reviews = [] 13 | }: PullRequestGeneratorParams): any => { 14 | return { 15 | author: { 16 | login: authorLogin 17 | }, 18 | repository: { 19 | name: repoName, 20 | owner: { 21 | login: repoOwner 22 | } 23 | }, 24 | createdAt, 25 | mergedAt, 26 | additions, 27 | deletions, 28 | reviews: { 29 | edges: reviews.map(getReviewEdge) 30 | } 31 | }; 32 | }; 33 | 34 | export const getReview = (args: ReviewGeneratorParams): Review => { 35 | const defaults = { 36 | reviewerLogin: 'dhruv', 37 | createdAt: '2023-02-01T14:04:37Z', 38 | state: 'COMMENTED' as Review['state'] 39 | }; 40 | 41 | const { createdAt, reviewerLogin, state } = { ...defaults, ...args }; 42 | 43 | return { 44 | author: { 45 | login: reviewerLogin 46 | }, 47 | createdAt, 48 | state 49 | }; 50 | }; 51 | 52 | export const getReviewEdge = (review: Review): ReviewEdge => { 53 | return { 54 | node: review 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /src/analytics/test-utils/types.ts: -------------------------------------------------------------------------------- 1 | import { Review } from '@/api-helpers/exapi-sdk/types'; 2 | 3 | export interface PullRequestGeneratorParams { 4 | authorLogin?: string; 5 | repoOwner?: string; 6 | repoName?: string; 7 | createdAt?: string; 8 | mergedAt?: string; 9 | additions?: number; 10 | deletions?: number; 11 | reviews?: Review[]; 12 | } 13 | 14 | export interface ReviewGeneratorParams { 15 | reviewerLogin?: string; 16 | createdAt?: string; 17 | state?: Review['state']; 18 | } 19 | -------------------------------------------------------------------------------- /src/analytics/utils.ts: -------------------------------------------------------------------------------- 1 | export const getTopNKeys = ( 2 | obj: Record, 3 | n?: number 4 | ): string[] => { 5 | let keyValueArray: [string, number][] = Object.entries(obj); 6 | keyValueArray.sort((a, b) => b[1] - a[1]); 7 | 8 | if (n === null || n === undefined || n < 0) { 9 | return keyValueArray.map((arr) => arr[0]); 10 | } 11 | 12 | return keyValueArray.slice(0, n).map((arr) => arr[0]); 13 | }; 14 | -------------------------------------------------------------------------------- /src/api-helpers/archive.ts: -------------------------------------------------------------------------------- 1 | import archiver from 'archiver'; 2 | import chalk from 'chalk'; 3 | import { ImagesWithBuffers } from '@/types/images'; 4 | 5 | export async function archiveFiles( 6 | fileBuffers: Omit[] 7 | ): Promise { 8 | console.info(chalk.yellow('Archiving images...')); 9 | return new Promise((resolve, reject) => { 10 | const archive = archiver('zip', { zlib: { level: 9 } }); 11 | const bufferArray: Buffer[] = []; 12 | 13 | archive.on('data', (buffer: Buffer) => { 14 | bufferArray.push(buffer); 15 | }); 16 | 17 | archive.on('end', () => { 18 | const zipBuffer = Buffer.concat(bufferArray); 19 | resolve(zipBuffer); 20 | }); 21 | 22 | archive.on('error', (err) => reject(err)); 23 | 24 | Promise.all( 25 | fileBuffers.map(({ fileName, data }) => { 26 | archive.append(data, { name: fileName }); 27 | }) 28 | ); 29 | 30 | archive.finalize(); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/api-helpers/auth-supplementary.ts: -------------------------------------------------------------------------------- 1 | import { splitEvery } from 'ramda'; 2 | 3 | import { privateDecrypt, publicEncrypt } from 'crypto'; 4 | 5 | const CHUNK_SIZE = 127; 6 | 7 | export const enc = (data?: string) => { 8 | const key = process.env.TOKEN_ENC_PUB_KEY; 9 | try { 10 | return data 11 | ? splitEvery(CHUNK_SIZE, data).map((chunk) => 12 | publicEncrypt(key, Buffer.from(chunk)).toString('base64') 13 | ) 14 | : null; 15 | } catch (e) { 16 | console.error(e); 17 | return null; 18 | } 19 | }; 20 | 21 | export const dec = (_chunks: string) => { 22 | const chunks = _chunks.split(','); 23 | const key = process.env.TOKEN_ENC_PRI_KEY; 24 | return chunks 25 | .map((chunk) => privateDecrypt(key, Buffer.from(chunk, 'base64'))) 26 | .join(''); 27 | }; 28 | -------------------------------------------------------------------------------- /src/api-helpers/card-data-adapter.ts: -------------------------------------------------------------------------------- 1 | import { RepositoryContributionData } from '@/api-helpers/exapi-sdk/types'; 2 | import { AuthoredReviewedData } from '@/components/templates/AuthoredReviewed'; 3 | import { ContributionsData } from '@/components/templates/Contributions'; 4 | import { GuardianData } from '@/components/templates/Guardian'; 5 | import { IntroCardProps } from '@/components/templates/IntroCard'; 6 | import { 7 | GitHubDataResponse, 8 | GithubRepositoryContributionData 9 | } from '../types/api-responses'; 10 | import { TimeOfTheDayData } from '@/components/templates/TimeOfTheDay'; 11 | import { CardTypes } from '@/types/cards'; 12 | import { ZenNinjaData } from '@/components/templates/ZenNinja'; 13 | import { StreakData } from '@/components/templates/Streak'; 14 | import { CodeReviewsData } from '@/components/templates/CodeReviews'; 15 | import { OSSContribsData } from '@/components/templates/OSSContribs'; 16 | import { sum } from 'ramda'; 17 | import { Username } from '@/components/templates/index'; 18 | import { DependantsData } from '@/components/templates/Dependants'; 19 | 20 | export const getDataFromGithubResponse = (data: GitHubDataResponse) => { 21 | const guardian: GuardianData | null = 22 | data.reviewed_prs_with_requested_changes_count > 2 23 | ? { 24 | numberOfTimes: data.reviewed_prs_with_requested_changes_count 25 | } 26 | : null; 27 | 28 | const contributions: ContributionsData | null = 29 | data.total_contributions > 0 30 | ? { 31 | contributions: data.total_contributions, 32 | percentile: data.contribution_percentile 33 | } 34 | : null; 35 | 36 | const totalAuthored = data.authored_monthly_pr_counts.reduce( 37 | (acc, curr) => acc + curr, 38 | 0 39 | ); 40 | const totalReviewed = data.reviewed_monthly_pr_counts.reduce( 41 | (acc, curr) => acc + curr, 42 | 0 43 | ); 44 | 45 | const totalPrs = totalAuthored + totalReviewed; 46 | 47 | const authoredVsReviewedPRs: AuthoredReviewedData | null = 48 | totalPrs > 10 49 | ? { 50 | authoredPrs: totalAuthored, 51 | reviewedPrs: totalReviewed 52 | } 53 | : null; 54 | 55 | const timeBasedData: TimeOfTheDayData | null = data.total_contributions 56 | ? { 57 | prsDuringDay: data.prs_opened_during_day, 58 | totalPrs: data.prs_opened_during_day + data.prs_opened_during_night, 59 | productiveDay: data.weekday_with_max_opened_prs as string, 60 | productiveHour: data.hour_with_max_opened_prs as number 61 | } 62 | : null; 63 | 64 | const zenOrNinja: ZenNinjaData | null = 65 | data.total_contributions > 50 66 | ? { trends: data.weekly_contributions } 67 | : null; 68 | 69 | const contributionStreak: StreakData | null = 70 | data.longest_streak > 3 71 | ? { 72 | streak: data.longest_streak 73 | } 74 | : null; 75 | 76 | const codeReviewerStats: CodeReviewsData | null = data.top_reviewers?.length 77 | ? { 78 | topReviewer: data.top_reviewers[0], 79 | totalReviewers: data.top_reviewers.length 80 | } 81 | : null; 82 | 83 | const total_oss = sum( 84 | data.oss_contributions.map((c) => c.contributions_count) 85 | ); 86 | 87 | const ossContribsData: OSSContribsData | null = 88 | total_oss > 50 89 | ? { 90 | contribs: data.oss_contributions 91 | } 92 | : null; 93 | 94 | const userReviewers: DependantsData | null = 95 | data.top_reviewers.length >= 2 96 | ? { 97 | userAvatar: data.user.avatar_url, 98 | dependants: data.top_reviewers, 99 | username: data.user.login 100 | } 101 | : null; 102 | 103 | const nonIntroCardData = { 104 | [CardTypes.GUARDIAN_OF_PROD]: guardian, 105 | [CardTypes.YOUR_CONTRIBUTIONS]: contributions, 106 | [CardTypes.PR_REVIEWED_VS_AUTHORED]: authoredVsReviewedPRs, 107 | [CardTypes.DAY_NIGHT_CYCLE]: timeBasedData, 108 | [CardTypes.ZEN_OR_NINJA]: zenOrNinja, 109 | [CardTypes.CONTRIBUTION_STREAK]: contributionStreak, 110 | [CardTypes.TOP_REVIEWERS]: codeReviewerStats, 111 | [CardTypes.OSS_CONTRIBUTION]: ossContribsData, 112 | [CardTypes.IT_TAKES_A_VILLAGE]: userReviewers 113 | }; 114 | 115 | const hasAnyData = !!Object.values(nonIntroCardData).filter(Boolean).length; 116 | 117 | const intro: IntroCardProps | null = hasAnyData 118 | ? { 119 | username: data.user.login, 120 | year: new Date().getFullYear() 121 | } 122 | : null; 123 | 124 | return { 125 | username: data.user.login, 126 | [CardTypes.UNWRAPPED_INTRO]: intro, 127 | ...nonIntroCardData 128 | } as Record< 129 | CardTypes, 130 | | IntroCardProps 131 | | GuardianData 132 | | ContributionsData 133 | | AuthoredReviewedData 134 | | TimeOfTheDayData 135 | | ZenNinjaData 136 | | StreakData 137 | | CodeReviewsData 138 | | OSSContribsData 139 | | DependantsData 140 | | null 141 | > & 142 | Username; 143 | }; 144 | 145 | export const getGithubRepositoryContributionData = ( 146 | repoData: RepositoryContributionData 147 | ): GithubRepositoryContributionData => { 148 | return { 149 | org_name: repoData.repository.owner.login, 150 | repo_name: repoData.repository.name, 151 | org_avatar_url: repoData.repository.owner.avatarUrl, 152 | contributions_count: repoData.contributions.totalCount 153 | }; 154 | }; 155 | -------------------------------------------------------------------------------- /src/api-helpers/exapi-sdk/types.ts: -------------------------------------------------------------------------------- 1 | export interface Review { 2 | author: { 3 | login: string; 4 | }; 5 | createdAt: string; 6 | state: 'CHANGES_REQUESTED' | 'APPROVED' | 'COMMENTED' | 'DISMISSED'; 7 | } 8 | 9 | export interface PullRequest { 10 | author: { 11 | login: string; 12 | }; 13 | repository: { 14 | name: string; 15 | owner: { 16 | login: string; 17 | }; 18 | }; 19 | createdAt: string; 20 | mergedAt: string; 21 | additions: number; 22 | deletions: number; 23 | reviews: { 24 | edges: ReviewEdge[]; 25 | }; 26 | } 27 | 28 | export interface ReviewEdge { 29 | node: Review; 30 | } 31 | 32 | export interface PageInfo { 33 | hasNextPage: boolean; 34 | endCursor: string; 35 | } 36 | 37 | export interface PullRequestEdge { 38 | cursor: string; 39 | node: PullRequest; 40 | } 41 | 42 | export interface SearchResponse { 43 | edges: PullRequestEdge[]; 44 | pageInfo: PageInfo; 45 | } 46 | 47 | export interface GraphQLResponse { 48 | data: { 49 | search: SearchResponse; 50 | }; 51 | } 52 | 53 | export interface GithubUser { 54 | login: string; 55 | id: number; 56 | node_id: string; 57 | avatar_url: string; 58 | gravatar_id: string; 59 | url: string; 60 | html_url: string; 61 | followers_url: string; 62 | following_url: string; 63 | gists_url: string; 64 | starred_url: string; 65 | subscriptions_url: string; 66 | organizations_url: string; 67 | repos_url: string; 68 | events_url: string; 69 | received_events_url: string; 70 | type: string; 71 | site_admin: boolean; 72 | name: string; 73 | company: string | null; 74 | blog: string; 75 | location: string; 76 | email: string | null; 77 | hireable: boolean | null; 78 | bio: string; 79 | twitter_username: string | null; 80 | public_repos: number; 81 | public_gists: number; 82 | followers: number; 83 | following: number; 84 | created_at: string; 85 | updated_at: string; 86 | } 87 | 88 | export interface ContributionDay { 89 | contributionCount: number; 90 | date: string; 91 | } 92 | 93 | export interface GithubWeeklyContributionData { 94 | contributionDays: ContributionDay[]; 95 | } 96 | 97 | export interface GithubContributionCalendar { 98 | totalContributions: number; 99 | weeks: GithubWeeklyContributionData[]; 100 | } 101 | 102 | export interface GithubContributionsCollection { 103 | contributionCalendar: GithubContributionCalendar; 104 | } 105 | 106 | export interface GitHubMetricsResponse { 107 | user: { 108 | contributionsCollection?: GithubContributionsCollection; 109 | }; 110 | } 111 | 112 | export interface GraphQLContributionCalendarMetricsResponse { 113 | data: GitHubMetricsResponse; 114 | } 115 | 116 | export interface GithubContributionSummaryCollection { 117 | totalCommitContributions: number; 118 | totalIssueContributions: number; 119 | totalPullRequestContributions: number; 120 | totalPullRequestReviewContributions: number; 121 | totalRepositoriesWithContributedPullRequests: number; 122 | totalRepositoriesWithContributedIssues: number; 123 | totalRepositoriesWithContributedPullRequestReviews: number; 124 | totalRepositoriesWithContributedCommits: number; 125 | } 126 | 127 | export interface GraphQLContributionSummaryResponse { 128 | data: { 129 | user: { 130 | contributionsCollection?: GithubContributionSummaryCollection; 131 | }; 132 | }; 133 | } 134 | 135 | export interface GraphQLRepositoryContributionData { 136 | data: { 137 | user: { 138 | contributionsCollection?: { 139 | issueContributionsByRepository: RepositoryContributionData[]; 140 | commitContributionsByRepository: RepositoryContributionData[]; 141 | pullRequestContributionsByRepository: RepositoryContributionData[]; 142 | pullRequestReviewContributionsByRepository: RepositoryContributionData[]; 143 | }; 144 | }; 145 | }; 146 | } 147 | 148 | export interface RepositoryContributionData { 149 | repository: { 150 | name: string; 151 | owner: { 152 | login: string; 153 | avatarUrl?: string; 154 | }; 155 | isPrivate: boolean; 156 | isFork: boolean; 157 | }; 158 | contributions: { 159 | totalCount: number; 160 | }; 161 | } 162 | -------------------------------------------------------------------------------- /src/api-helpers/fonts.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | export const getInterFonts = async ( 5 | env?: 'browser' | 'node' 6 | ): Promise => { 7 | // fetch works in browser only, not in node, vice-versa with fs 8 | if (env === 'browser') { 9 | const fonts = await Promise.all([ 10 | fetch( 11 | new URL('public/assets/fonts/Inter-Thin.ttf', import.meta.url) 12 | ).then((res) => res.arrayBuffer()), 13 | fetch( 14 | new URL('public/assets/fonts/Inter-ExtraLight.ttf', import.meta.url) 15 | ).then((res) => res.arrayBuffer()), 16 | fetch( 17 | new URL('public/assets/fonts/Inter-Light.ttf', import.meta.url) 18 | ).then((res) => res.arrayBuffer()), 19 | fetch( 20 | new URL('public/assets/fonts/Inter-Regular.ttf', import.meta.url) 21 | ).then((res) => res.arrayBuffer()), 22 | fetch( 23 | new URL('public/assets/fonts/Inter-Medium.ttf', import.meta.url) 24 | ).then((res) => res.arrayBuffer()), 25 | fetch( 26 | new URL('public/assets/fonts/Inter-SemiBold.ttf', import.meta.url) 27 | ).then((res) => res.arrayBuffer()), 28 | fetch( 29 | new URL('public/assets/fonts/Inter-Bold.ttf', import.meta.url) 30 | ).then((res) => res.arrayBuffer()), 31 | fetch( 32 | new URL('public/assets/fonts/Inter-ExtraBold.ttf', import.meta.url) 33 | ).then((res) => res.arrayBuffer()), 34 | fetch( 35 | new URL('public/assets/fonts/Inter-Black.ttf', import.meta.url) 36 | ).then((res) => res.arrayBuffer()) 37 | ]); 38 | 39 | return fonts; 40 | } else { 41 | const Thin = fs.promises.readFile( 42 | path.join(process.cwd(), 'public', 'assets', 'fonts', 'Inter-Thin.ttf') 43 | ); 44 | const ExtraLight = fs.promises.readFile( 45 | path.join( 46 | process.cwd(), 47 | 'public', 48 | 'assets', 49 | 'fonts', 50 | 'Inter-ExtraLight.ttf' 51 | ) 52 | ); 53 | const Light = fs.promises.readFile( 54 | path.join(process.cwd(), 'public', 'assets', 'fonts', 'Inter-Light.ttf') 55 | ); 56 | const Regular = fs.promises.readFile( 57 | path.join(process.cwd(), 'public', 'assets', 'fonts', 'Inter-Regular.ttf') 58 | ); 59 | const Medium = fs.promises.readFile( 60 | path.join(process.cwd(), 'public', 'assets', 'fonts', 'Inter-Medium.ttf') 61 | ); 62 | const SemiBold = fs.promises.readFile( 63 | path.join( 64 | process.cwd(), 65 | 'public', 66 | 'assets', 67 | 'fonts', 68 | 'Inter-SemiBold.ttf' 69 | ) 70 | ); 71 | const Bold = fs.promises.readFile( 72 | path.join(process.cwd(), 'public', 'assets', 'fonts', 'Inter-Bold.ttf') 73 | ); 74 | const ExtraBold = fs.promises.readFile( 75 | path.join( 76 | process.cwd(), 77 | 'public', 78 | 'assets', 79 | 'fonts', 80 | 'Inter-ExtraBold.ttf' 81 | ) 82 | ); 83 | const Black = fs.promises.readFile( 84 | path.join(process.cwd(), 'public', 'assets', 'fonts', 'Inter-Black.ttf') 85 | ); 86 | 87 | return await Promise.all([ 88 | Thin, 89 | ExtraLight, 90 | Light, 91 | Regular, 92 | Medium, 93 | SemiBold, 94 | Bold, 95 | ExtraBold, 96 | Black 97 | ]); 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /src/api-helpers/general.ts: -------------------------------------------------------------------------------- 1 | export function arrayBufferToBuffer(arrayBuffer: ArrayBuffer): Buffer { 2 | const uint8Array = new Uint8Array(arrayBuffer); 3 | const nodeBuffer = Buffer.from(uint8Array); 4 | return nodeBuffer; 5 | } 6 | 7 | export const abbreviateNumber = (value: number): string => { 8 | if (value > 1000) { 9 | return String(Number((value / 1000).toFixed(2))) + 'K'; 10 | } 11 | return String(value); 12 | }; 13 | 14 | export type ParamsObject = { [key: string]: number | string }; 15 | 16 | export function generateParamUrl( 17 | baseURL: string, 18 | params: ParamsObject 19 | ): string { 20 | const paramString = Object.entries(params) 21 | .map( 22 | ([key, value]) => 23 | `${encodeURIComponent(key)}=${encodeURIComponent(value)}` 24 | ) 25 | .join('&'); 26 | 27 | return `${baseURL}?${paramString}`; 28 | } 29 | 30 | export function rgbToHex(r: number, g: number, b: number): string { 31 | const toHex = (value: number): string => { 32 | const hex = value.toString(16); 33 | return hex.length === 1 ? '0' + hex : hex; 34 | }; 35 | 36 | const hexR = toHex(r); 37 | const hexG = toHex(g); 38 | const hexB = toHex(b); 39 | 40 | return `#${hexR}${hexG}${hexB}`; 41 | } 42 | 43 | export function darkenHexColor(hex: string, factor: number): string { 44 | // Remove the '#' from the beginning of the hex code (if present) 45 | const cleanHex = hex.replace(/^#/, ''); 46 | 47 | // Parse the hex values into RGB 48 | const bigint = parseInt(cleanHex, 16); 49 | const r = (bigint >> 16) & 255; 50 | const g = (bigint >> 8) & 255; 51 | const b = bigint & 255; 52 | 53 | // Apply darkness factor to each RGB component 54 | const darkenedR = Math.max(0, Math.round(r * (1 - factor))); 55 | const darkenedG = Math.max(0, Math.round(g * (1 - factor))); 56 | const darkenedB = Math.max(0, Math.round(b * (1 - factor))); 57 | 58 | // Convert the darkened RGB values back to hex 59 | const darkenedHex = ((darkenedR << 16) + (darkenedG << 8) + darkenedB) 60 | .toString(16) 61 | .padStart(6, '0'); 62 | 63 | // Return the darkened hex color with the '#' prefix 64 | return `#${darkenedHex}`; 65 | } 66 | -------------------------------------------------------------------------------- /src/api-helpers/image-gen.ts: -------------------------------------------------------------------------------- 1 | import { GitHubDataResponse } from '../types/api-responses'; 2 | import chalk from 'chalk'; 3 | import { createImageUsingVercel } from './vercel-generator'; 4 | import { getDataFromGithubResponse } from '@/api-helpers/card-data-adapter'; 5 | import { CardTypes, sequence } from '../types/cards'; 6 | import { ImagesWithBuffers } from '@/types/images'; 7 | import { logException } from '@/utils/logger'; 8 | 9 | export const generateImages = async ( 10 | data: GitHubDataResponse, 11 | customSequence?: CardTypes[] 12 | ): Promise => { 13 | try { 14 | console.info(chalk.yellow('Generating images...')); 15 | 16 | const cardSequence = customSequence || sequence; 17 | 18 | const adaptedData = getDataFromGithubResponse(data); 19 | const cardsToBeGenerated = cardSequence.filter((card) => 20 | Boolean(adaptedData[card]) 21 | ); 22 | 23 | const imageFileBuffers = await Promise.all( 24 | cardsToBeGenerated.map((cardName) => 25 | createImageUsingVercel( 26 | { ...adaptedData[cardName], username: data.user.login }, 27 | cardName 28 | ).catch(() => null) 29 | ) 30 | ); 31 | 32 | return imageFileBuffers.filter(Boolean) as ImagesWithBuffers[]; 33 | } catch (error) { 34 | console.error('Error in generateImages:', error); 35 | logException('Error in generateImages', { originalException: error }); 36 | throw new Error('Image generation failed'); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/api-helpers/node-events.ts: -------------------------------------------------------------------------------- 1 | import Mixpanel from 'mixpanel'; 2 | 3 | import { flattenObj } from '@/utils/datatype'; 4 | import { objectEnum } from '@/utils/enum'; 5 | 6 | enum TrackEventEnum { 7 | FOLLOWUP_USER_EMAIL 8 | } 9 | 10 | export const TrackNodeEvents = objectEnum(TrackEventEnum); 11 | 12 | const isDev = process.env.NEXT_PUBLIC_APP_ENVIRONMENT === 'development'; 13 | 14 | const mixpanel = Mixpanel.init(process.env.NEXT_PUBLIC_MIXPANEL, { 15 | keepAlive: false, 16 | debug: isDev 17 | }); 18 | 19 | export const nodeTrack = (ev: keyof typeof TrackNodeEvents, props: any = {}) => 20 | mixpanel.track(ev, { 21 | ...flattenObj(props), 22 | appBuiltAt: process.env.NEXT_PUBLIC_BUILD_TIME, 23 | environment: process.env.NEXT_PUBLIC_APP_ENVIRONMENT, 24 | nodeEnv: process.env.NODE_ENV 25 | }); 26 | -------------------------------------------------------------------------------- /src/api-helpers/persistance.ts: -------------------------------------------------------------------------------- 1 | import { ImagesWithBuffers } from '@/types/images'; 2 | import { 3 | deleteS3Directory, 4 | fetchFileFromS3Directory, 5 | fetchImagesFromS3Directory, 6 | uploadImagesToS3 7 | } from '../utils/persistence/s3'; 8 | import { 9 | deleteLocalDirectory, 10 | fetchImageFromLocalDirectory, 11 | fetchImagesFromLocalDirectory, 12 | saveImagesToLocalDirectory 13 | } from '../utils/persistence/file-system'; 14 | 15 | const awsCredentialExists = 16 | process.env.AWS_ACCESS_KEY_ID && 17 | process.env.AWS_SECRET_ACCESS_KEY && 18 | process.env.AWS_REGION; 19 | 20 | const bucketName = process.env.UNWRAPPED_PERSISTENCE_BUCKET_NAME; 21 | 22 | export const saveCards = async ( 23 | userLogin: string, 24 | imageFiles: ImagesWithBuffers[], 25 | isPublic: boolean = true 26 | ): Promise => { 27 | if (awsCredentialExists && bucketName) { 28 | const prefix = isPublic ? `public/${userLogin}` : `${userLogin}`; 29 | await uploadImagesToS3(bucketName, prefix, imageFiles); 30 | } else { 31 | const prefix = isPublic 32 | ? `${process.cwd()}/unwrapped-cards/public/${userLogin}/` 33 | : `${process.cwd()}/unwrapped-cards/${userLogin}/`; 34 | await saveImagesToLocalDirectory(prefix, imageFiles); 35 | } 36 | }; 37 | 38 | export const fetchSavedCards = async ( 39 | userLogin: string, 40 | isPublic: boolean = true 41 | ): Promise => { 42 | if (awsCredentialExists && bucketName) { 43 | const prefix = isPublic ? `public/${userLogin}` : `${userLogin}`; 44 | return await fetchImagesFromS3Directory(bucketName, prefix); 45 | } else { 46 | const prefix = isPublic 47 | ? `${process.cwd()}/unwrapped-cards/public/${userLogin}/` 48 | : `${process.cwd()}/unwrapped-cards/${userLogin}/`; 49 | return await fetchImagesFromLocalDirectory(prefix); 50 | } 51 | }; 52 | 53 | export const deleteSaveCards = async ( 54 | userLogin: string, 55 | isPublic: boolean = true 56 | ): Promise => { 57 | if (awsCredentialExists && bucketName) { 58 | const prefix = isPublic ? `public/${userLogin}` : `${userLogin}`; 59 | await deleteS3Directory(bucketName, prefix); 60 | } else { 61 | const prefix = isPublic 62 | ? `${process.cwd()}/unwrapped-cards/public/${userLogin}/` 63 | : `${process.cwd()}/unwrapped-cards/${userLogin}/`; 64 | await deleteLocalDirectory(prefix); 65 | } 66 | }; 67 | 68 | export const fetchSavedCard = async ( 69 | userLogin: string, 70 | cardName: string, 71 | isPublic: boolean = true 72 | ): Promise => { 73 | if (awsCredentialExists && bucketName) { 74 | const prefix = isPublic 75 | ? `public/${userLogin}/${cardName}.png` 76 | : `${userLogin}/${cardName}.png`; 77 | return await fetchFileFromS3Directory(bucketName, prefix); 78 | } else { 79 | const prefix = isPublic 80 | ? `${process.cwd()}/unwrapped-cards/public/${userLogin}/${cardName}.png` 81 | : `${process.cwd()}/unwrapped-cards/${userLogin}/${cardName}.png`; 82 | return await fetchImageFromLocalDirectory(prefix); 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /src/api-helpers/vercel-cover-generator.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from '@vercel/og'; 2 | 3 | import { 4 | COVER_HEIGHT, 5 | INTER_FONT_STYLES, 6 | COVER_SCALE_FACTOR, 7 | websiteUrl 8 | } from '../constants/general'; 9 | import { getInterFonts } from '@/api-helpers/fonts'; 10 | import { COVER_WIDTH } from '@/constants/general'; 11 | import LogoSvg from '@/assets/unwrapped-logo.svg'; 12 | import { arrayBufferToBuffer } from './general'; 13 | 14 | export const createCoverUsingVercel = async ( 15 | username: string, 16 | images: string[] 17 | ): Promise => { 18 | const interFonts = await getInterFonts('node'); 19 | try { 20 | const generatedImage = new ImageResponse( 21 | ( 22 |
37 | 47 |
48 | 49 | 54 | 59 |
60 |
61 | 66 |
72 |

73 | {username} / 2023 74 |

75 |
76 |
82 |

83 | unwrapped.dev 84 |

85 |
86 |
87 |
88 | ), 89 | { 90 | width: parseInt(COVER_WIDTH) * COVER_SCALE_FACTOR, 91 | height: parseInt(COVER_HEIGHT) * COVER_SCALE_FACTOR, 92 | fonts: INTER_FONT_STYLES.map((fontData, index) => ({ 93 | name: 'Inter', 94 | data: interFonts[index], 95 | weight: fontData.weight 96 | })) 97 | } 98 | ); 99 | 100 | const imageArrayBuffer = await generatedImage.arrayBuffer(); 101 | const imageBuffer = arrayBufferToBuffer(imageArrayBuffer); 102 | 103 | return imageBuffer; 104 | } catch (error) { 105 | console.error(error); 106 | throw new Error(`Image creation failed for cover`); 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /src/api-helpers/vercel-generator.tsx: -------------------------------------------------------------------------------- 1 | import { ImagesWithBuffers } from '@/types/images'; 2 | import { ImageResponse } from '@vercel/og'; 3 | import { arrayBufferToBuffer } from '@/api-helpers/general'; 4 | 5 | import { CardTypes } from '../types/cards'; 6 | import { 7 | CARD_HEIGHT, 8 | CARD_WIDTH, 9 | INTER_FONT_STYLES 10 | } from '../constants/general'; 11 | import { CardTemplate, CardTemplateData } from '../components/templates'; 12 | import { getInterFonts } from './fonts'; 13 | import { SCALE_FACTOR } from '@/constants/general'; 14 | import { logException } from '@/utils/logger'; 15 | 16 | export const createImageUsingVercel = async ( 17 | data: CardTemplateData['data'], 18 | cardType: CardTypes, 19 | env: 'node' | 'browser' = 'node' 20 | ): Promise => { 21 | const fileName = `${cardType.toLowerCase()}.png`; 22 | try { 23 | const interFonts = await getInterFonts(env); 24 | 25 | const generatedImage = new ImageResponse( 26 | , 27 | { 28 | width: parseInt(CARD_WIDTH) * SCALE_FACTOR, 29 | height: parseInt(CARD_HEIGHT) * SCALE_FACTOR, 30 | fonts: INTER_FONT_STYLES.map((fontData, index) => ({ 31 | name: 'Inter', 32 | data: interFonts[index], 33 | style: 'normal', 34 | weight: fontData.weight 35 | })) 36 | } 37 | ); 38 | 39 | let imageCopy = generatedImage.clone(); 40 | let imageArrayBuffer; 41 | 42 | try { 43 | imageArrayBuffer = await generatedImage.arrayBuffer(); 44 | } catch (error) { 45 | logException(`Error converting image to array buffer for ${cardType}`, { 46 | originalException: error 47 | }); 48 | throw new Error(`Image buffer creation failed for ${cardType}`); 49 | } 50 | 51 | const imageBuffer = arrayBufferToBuffer(imageArrayBuffer); 52 | 53 | return { 54 | data: imageBuffer, 55 | fileName, 56 | image: imageCopy 57 | }; 58 | } catch (error) { 59 | logException(`Error in createImageUsingVercel for ${cardType}:`, { 60 | originalException: error 61 | }); 62 | throw new Error(`Image creation failed for ${cardType}`); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/assets/popdevs/d3js/authreview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/d3js/authreview.png -------------------------------------------------------------------------------- /src/assets/popdevs/d3js/changes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/d3js/changes.png -------------------------------------------------------------------------------- /src/assets/popdevs/dan/oss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/dan/oss.png -------------------------------------------------------------------------------- /src/assets/popdevs/dan/streak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/dan/streak.png -------------------------------------------------------------------------------- /src/assets/popdevs/dhh/contribs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/dhh/contribs.png -------------------------------------------------------------------------------- /src/assets/popdevs/dhh/zen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/dhh/zen.png -------------------------------------------------------------------------------- /src/assets/popdevs/kcd/contribs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/kcd/contribs.png -------------------------------------------------------------------------------- /src/assets/popdevs/linus/streak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/linus/streak.png -------------------------------------------------------------------------------- /src/assets/popdevs/linus/village.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/linus/village.png -------------------------------------------------------------------------------- /src/assets/popdevs/matz/contribs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/matz/contribs.png -------------------------------------------------------------------------------- /src/assets/popdevs/nodejs/oss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/nodejs/oss.png -------------------------------------------------------------------------------- /src/assets/popdevs/tailwind/drake.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/tailwind/drake.png -------------------------------------------------------------------------------- /src/assets/popdevs/vitalik/daynight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/unwrapped/9656fa5a817eb731eb7c1b4ef5f30a613b4089f9/src/assets/popdevs/vitalik/daynight.png -------------------------------------------------------------------------------- /src/components/AppLoadingStateWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { LoaderWithFacts } from '@/components/LoaderWithFacts'; 3 | import { useSession } from 'next-auth/react'; 4 | import { useIsClient } from 'usehooks-ts'; 5 | 6 | export const AppLoadingStateWrapper: React.FC<{ children: ReactNode }> = ({ 7 | children 8 | }) => { 9 | const { status } = useSession(); 10 | const isClient = useIsClient(); 11 | 12 | return ( 13 |
14 | {status === 'loading' && isClient ? ( 15 |
16 | 17 |
18 | ) : ( 19 | children 20 | )} 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/AuthActions.tsx: -------------------------------------------------------------------------------- 1 | import { signIn, signOut, useSession } from 'next-auth/react'; 2 | import { useState } from 'react'; 3 | import { useRouter } from 'next/router'; 4 | import { track } from '@/constants/events'; 5 | 6 | /** 7 | * DISABLE_PUBLIC_ONLY_CONTRIBUTIONS 8 | * Because this isn't implemented yet 9 | */ 10 | const DISABLE_PUBLIC_ONLY_CONTRIBUTIONS = false; 11 | 12 | export const AuthActions = () => { 13 | const { status } = useSession(); 14 | const router = useRouter(); 15 | const [showPrivate, setShowPrivate] = useState(false); 16 | const [username, setUsername] = useState(''); 17 | 18 | return ( 19 |
20 | {status === 'authenticated' ? ( 21 |
22 | 31 | 40 |
41 | ) : showPrivate ? ( 42 | <> 43 | 52 | 53 | ) : ( 54 |
55 |
{ 58 | e.preventDefault(); 59 | }} 60 | > 61 | setUsername(e.target.value)} 68 | /> 69 | 77 |
78 |
79 | )} 80 | {status !== 'authenticated' && ( 81 |
82 |
83 | setShowPrivate(!showPrivate)} 89 | disabled={DISABLE_PUBLIC_ONLY_CONTRIBUTIONS} 90 | /> 91 | 97 |
98 |
99 | )} 100 |
101 | 102 | What's stopping you? 103 | 104 | { 106 | track('SEE_HOW_WE_MANAGE_YOUR_TRUST_CLICKED'); 107 | document 108 | .getElementById('trust-notice') 109 | ?.scrollIntoView({ behavior: 'smooth' }); 110 | }} 111 | className="cursor-pointer text-purple-400 text-sm" 112 | > 113 | See how we manage trust 🔒 {'->'} 114 | 115 | { 117 | track('SEE_HOW_2023_WAS_FOR_TOP_DEVS_CLICKED'); 118 | document 119 | .getElementById('popular-devs') 120 | ?.scrollIntoView({ behavior: 'smooth' }); 121 | }} 122 | className="cursor-pointer text-purple-400 text-sm" 123 | > 124 | See how 2023 was for top devs 🧑‍💻 {'->'} 125 | 126 |
127 |
128 | ); 129 | }; 130 | -------------------------------------------------------------------------------- /src/components/Description.tsx: -------------------------------------------------------------------------------- 1 | import { Typewriter } from 'react-simple-typewriter'; 2 | 3 | export const Description = () => ( 4 |
5 | 6 | A yearly recap of your GitHub, like 7 | line + '...')} 14 | loop 15 | cursor 16 | cursorStyle="|" 17 | cursorBlinking 18 | /> 19 | 20 | If you're a dev, you'll ❤️ it 21 | 22 | 26 | by Middleware {'->'} 27 | 28 | 29 |
30 | ); 31 | -------------------------------------------------------------------------------- /src/components/IndexPageSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, PropsWithChildren } from 'react'; 2 | 3 | export const IndexPageSection: FC = ({ children }) => { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/LoaderWithFacts.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { ScatterBoxLoader } from 'react-awesome-loaders'; 3 | import { randInt } from '@/utils/number'; 4 | import { useIsClient } from 'usehooks-ts'; 5 | 6 | const openSourceStats = [ 7 | "Did you know: Rust has been the consistently most loved language in the last 3 years of StackOverflow's dev survey", 8 | 'Did you know: The JS ecosystem has more than 4x the number of packages than the next one (Python)', 9 | 'Did you know: There are more lines of code written, than number of stars in the milky way galaxy', 10 | "Did you know: JavaScript is the most popular programming language for the ninth year in a row according to SO's dev survey", 11 | 'Did you know: In 2023, women accounted for 23% of the annual dev workforce', 12 | 'Did you know: In 2023, 65k new gen AI projects were created on GitHub' 13 | ]; 14 | 15 | export const LoaderWithFacts = () => { 16 | const [currentIndex, setCurrentIndex] = useState( 17 | randInt(openSourceStats.length - 1) 18 | ); 19 | const [currentText, setCurrentText] = useState(''); 20 | 21 | useEffect(() => { 22 | const intervalId = setInterval(() => { 23 | const newIndex = (currentIndex + 1) % openSourceStats.length; 24 | setCurrentIndex(newIndex); 25 | setCurrentText(openSourceStats[newIndex]); 26 | }, 6000); 27 | 28 | return () => { 29 | clearInterval(intervalId); 30 | }; 31 | }, [currentIndex]); 32 | 33 | const isClient = useIsClient(); 34 | 35 | useEffect(() => { 36 | setCurrentText(openSourceStats[currentIndex]); 37 | }, [currentIndex]); 38 | 39 | if (!isClient) return null; 40 | 41 | return ( 42 |
43 |
44 |

{currentText}

45 |
46 | 47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/MouseScrollAnim/MouseScrollAnim.module.css: -------------------------------------------------------------------------------- 1 | .mouse { 2 | width: 1em; 3 | height: 2em; 4 | border: 1.2px solid white; 5 | border-radius: 100000vw; 6 | position: relative; 7 | } 8 | 9 | .mouse::before { 10 | content: ''; 11 | width: 0.25em; 12 | height: 0.25em; 13 | position: absolute; 14 | top: 0.37em; 15 | left: 50%; 16 | transform: translateX(-50%); 17 | background-color: white; 18 | border-radius: 50%; 19 | opacity: 1; 20 | animation: wheel 1.8s infinite; 21 | -webkit-animation: wheel 1.8s infinite; 22 | } 23 | 24 | @keyframes wheel { 25 | to { 26 | opacity: 0; 27 | top: 1.4em; 28 | } 29 | } -------------------------------------------------------------------------------- /src/components/MouseScrollAnim/MouseScrollAnim.tsx: -------------------------------------------------------------------------------- 1 | import css from './MouseScrollAnim.module.css'; 2 | 3 | export const MouseScrollAnim = ({ 4 | fontSize = '30px' 5 | }: { 6 | fontSize?: string | number; 7 | }) =>
; 8 | -------------------------------------------------------------------------------- /src/components/PopDevsMasonry.tsx: -------------------------------------------------------------------------------- 1 | import Masonry, { ResponsiveMasonry } from 'react-responsive-masonry'; 2 | import { pregenFiles, devOrgMap } from '@/constants/all-pregen-cards'; 3 | import { useMediaQuery } from 'usehooks-ts'; 4 | 5 | const urls = await Promise.all( 6 | pregenFiles.map((url) => 7 | import('../assets/' + url).then((m) => m.default.src) 8 | ) 9 | ); 10 | 11 | const devList = pregenFiles.map((line) => line.split('/')[1]); 12 | const devOrgList = devList.map( 13 | (dev) => devOrgMap[dev as keyof typeof devOrgMap] 14 | ); 15 | 16 | export const PopDevsMasonry = () => { 17 | const is500 = useMediaQuery('(min-width: 500px)'); 18 | const is750 = useMediaQuery('(min-width: 750px)'); 19 | const getMarginForIndex = (i: number) => { 20 | if (i === 0) return is750 ? '240px' : is500 ? '120px' : 0; 21 | if (i === 1) return is750 ? '120px' : 0; 22 | return 0; 23 | }; 24 | return ( 25 | <> 26 | 30 |
31 | 32 | Check out what popular dev leaders have been up to... 33 | 34 | 35 | 36 | {urls.map((url, i) => ( 37 |
42 |
43 | {devOrgList[i]} 44 |
45 | 46 |
47 | ))} 48 |
49 |
50 |
51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/ThrownCards.tsx: -------------------------------------------------------------------------------- 1 | import linusVillage from '@/assets/popdevs/linus/village.png'; 2 | import danOss from '@/assets/popdevs/dan/oss.png'; 3 | import adamDrake from '@/assets/popdevs/tailwind/drake.png'; 4 | 5 | const goToPopDevs = () => 6 | document 7 | .getElementById('popular-devs') 8 | ?.scrollIntoView({ behavior: 'smooth' }); 9 | 10 | export const ThrownCards = () => ( 11 |
12 | 18 | 24 | 30 |
31 | ); 32 | -------------------------------------------------------------------------------- /src/components/TrackingConsent.tsx: -------------------------------------------------------------------------------- 1 | import { ALLOW_TRACKING_KEY } from '@/constants/events'; 2 | import { useCallback } from 'react'; 3 | import toast from 'react-hot-toast'; 4 | import { useLocalStorage } from 'usehooks-ts'; 5 | 6 | export const useTrackingConsent = () => { 7 | const [trackingAllowed, setTrackingAllowed] = useLocalStorage( 8 | ALLOW_TRACKING_KEY, 9 | null 10 | ); 11 | 12 | const updatedTrackingState = useCallback( 13 | (allowed: boolean) => { 14 | toast.dismiss(ALLOW_TRACKING_KEY); 15 | setTrackingAllowed(allowed); 16 | }, 17 | [setTrackingAllowed] 18 | ); 19 | 20 | return useCallback(() => { 21 | if (trackingAllowed || trackingAllowed !== null) return; 22 | return toast.custom( 23 | (t) => { 24 | return ( 25 |
31 |
32 | This website uses analytics to ensure you get the best experience. 33 |
34 |
35 | 42 | 49 |
50 |
51 | ); 52 | }, 53 | { 54 | duration: 20000, 55 | id: ALLOW_TRACKING_KEY, 56 | position: 'bottom-center' 57 | } 58 | ); 59 | }, [trackingAllowed, updatedTrackingState]); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/TrustNotice.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export const TrustNotice = () => { 4 | const [expanded, toggle] = useState(false); 5 | return ( 6 |
7 | 8 | Trust us? You don't have to! 9 | 10 | toggle(!expanded)} 13 | > 14 | {expanded ? 'Alright, gotcha (collapse)' : 'Click to know why'} 15 | 16 | {expanded ? ( 17 | <> 18 |
19 | Core tenets: 20 | 21 | {'->'} We're not sending your data anywhere without your 22 | consent. 23 | 24 | 25 | {'->'} We do store anonymized{' '} 26 | product usage stats, with your consent. 27 | 28 |
29 | 30 | We could say "your privacy is important to us" and all 31 | that... 32 | 33 | But you won't believe that, and neither should you. 34 | 35 | We think the best way to show that, is to simply point you to the 36 |
37 | 43 | unwrapped repo {'->'} 44 | 45 |
46 | 47 | ) : null} 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/templates/AuthoredReviewed.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from './RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export type AuthoredReviewedData = { 7 | reviewedPrs: number; 8 | authoredPrs: number; 9 | }; 10 | 11 | const colors = { reviewed: '#D25C55', authored: '#9A1F17' }; 12 | 13 | export const AuthoredReviewed: FC = ({ 14 | authoredPrs, 15 | reviewedPrs, 16 | username 17 | }) => { 18 | const joey = `${websiteUrl}/assets/images/joey.png`; 19 | return ( 20 | 21 |
22 |
23 |

Shipping code is

24 |

about having, giving,

25 |

sharing, and receiving

26 |
27 |
28 | 29 |
30 |
31 |
32 |
35 |

Authored PRs ({authoredPrs})

36 |
37 |
38 |
41 |

Reviewed PRs ({reviewedPrs})

42 |
43 |
44 |
45 |

For PRs, you were more

46 | {reviewedPrs >= authoredPrs ? ( 47 |

about giving, than receiving

48 | ) : ( 49 |

about receiving, than giving

50 | )} 51 |

this year

52 |
53 |
54 | 60 | 61 | ); 62 | }; 63 | 64 | interface PieChartProps { 65 | reviewedPrs: number; 66 | authoredPrs: number; 67 | } 68 | 69 | const PieChart: React.FC = ({ authoredPrs, reviewedPrs }) => { 70 | const data = [authoredPrs, reviewedPrs]; 71 | const total = authoredPrs + reviewedPrs; 72 | 73 | let startAngle = 0; 74 | 75 | return ( 76 |
77 | 83 | {data.map((value, index) => { 84 | const percentage = (value / total) * 100; 85 | const endAngle = startAngle + (percentage * 360) / 100; 86 | 87 | const startX = Math.cos((startAngle * Math.PI) / 180) * 100 + 100; 88 | const startY = Math.sin((startAngle * Math.PI) / 180) * 100 + 100; 89 | const endX = Math.cos((endAngle * Math.PI) / 180) * 100 + 100; 90 | const endY = Math.sin((endAngle * Math.PI) / 180) * 100 + 100; 91 | 92 | const largeArcFlag = percentage > 50 ? 1 : 0; 93 | 94 | const path = `M 100 100 L ${startX} ${startY} A 100 100 0 ${largeArcFlag} 1 ${endX} ${endY} Z`; 95 | 96 | startAngle = endAngle; 97 | 98 | return ( 99 | 106 | ); 107 | })} 108 | 109 |
110 | ); 111 | }; 112 | -------------------------------------------------------------------------------- /src/components/templates/CodeReviews.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export type CodeReviewsData = { 7 | totalReviewers: number; 8 | topReviewer: string; 9 | }; 10 | 11 | export const CodeReviews: FC = ({ 12 | topReviewer, 13 | totalReviewers, 14 | username 15 | }) => { 16 | const drakeNope = `${websiteUrl}/assets/images/drakeNope.png`; 17 | const drakeYeah = `${websiteUrl}/assets/images/drakeYeah.png`; 18 | return ( 19 | 20 |
21 | 22 |
23 |

Having a lot of

24 |

Insta followers

25 |
26 |
27 |
28 |
29 |

30 | {totalReviewers} dev{totalReviewers === 1 ? '' : 's'} 31 |

32 |
33 |
34 |

Have reviewed your code

35 |

36 | #1 being @{topReviewer} 37 |

38 |

Take a moment to thank them!

39 |
40 |
41 |
42 |
43 |

Having a lot of

44 |

code reviewers

45 |
46 | 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/templates/Contributions.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { abbreviateNumber } from '@/api-helpers/general'; 5 | import { Username } from '@/components/templates/index'; 6 | 7 | export type ContributionsData = { 8 | contributions: number; 9 | percentile: number | null | undefined; 10 | }; 11 | 12 | export const Contributions: FC = ({ 13 | contributions, 14 | percentile, 15 | username 16 | }) => { 17 | const artemis = `${websiteUrl}/assets/images/artemis.png`; 18 | return ( 19 | 20 |
21 |
22 |

Higher than

23 |

Artemis 1

24 |
25 |
26 |

{abbreviateNumber(contributions)}

27 | contribs 28 |
29 |
30 |

Made by you in the

31 |

last year

32 |
33 |
34 |
35 | {percentile && ( 36 |
37 |

That puts you in

38 |

the top {percentile}%

39 |
40 | )} 41 |
42 |
43 | 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/templates/Guardian.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export type GuardianData = { 7 | numberOfTimes: number; 8 | }; 9 | 10 | export const Guardian: FC = ({ 11 | numberOfTimes, 12 | username 13 | }) => { 14 | const groot = `${websiteUrl}/assets/images/groot.png`; 15 | return ( 16 | 17 |
18 |
19 |

Guardian of the

20 |

Repository!

21 |
22 |
23 |

{numberOfTimes}

24 | times 25 |
26 |
27 |

You’ve stopped

28 |

bad code from

29 |

ending up in

30 |

production

31 |
32 |
33 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/templates/IntroCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from './RootCard'; 3 | import { CARD_HEIGHT, CARD_WIDTH, websiteUrl } from '@/constants/general'; 4 | 5 | export type IntroCardProps = { 6 | year: number; 7 | username: string; 8 | }; 9 | 10 | export const IntroCard: FC = ({ year, username }) => { 11 | const reflection = `${websiteUrl}/assets/images/reflection.svg`; 12 | 13 | return ( 14 | 15 | reflection 22 | reflection 30 |
31 |

@{username}'s

32 |

{year}

33 |

Unwrapped

34 |

Let's go!

35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/templates/Leader.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export type GuardianData = { 7 | numberOfTimes: number; 8 | }; 9 | 10 | export const Leader: FC = ({ username }) => { 11 | const drStrange = `${websiteUrl}/assets/images/dr-strange.png`; 12 | return ( 13 | 14 |
15 |
16 |
17 |

Mission 2024

18 |
19 |

Leader

20 |
21 |
22 |

Guide the way in

23 |

reclaiming control

24 |

of time for the

25 |

makers

26 |
27 |
28 | 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/templates/OSSContribs.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { GithubRepositoryContributionData } from '@/types/api-responses'; 5 | import pluralize from 'pluralize'; 6 | import { Username } from '@/components/templates/index'; 7 | 8 | export type OSSContribsData = { 9 | contribs: GithubRepositoryContributionData[]; 10 | }; 11 | 12 | export const OSSContribs: FC = ({ 13 | contribs, 14 | username 15 | }) => { 16 | const img = `${websiteUrl}/assets/images/philanthropist.png`; 17 | 18 | return ( 19 | 20 |
21 |
22 |

You know, I'm something of

23 |

a philanthropist myself

24 |
25 |
26 |

27 | {contribs.length} {pluralize('repo', contribs.length)} 28 |

29 |
30 |
31 |

Had open source contribs

32 |

by you this year

33 |
34 |
35 |

36 | Top {contribs.slice(0, 3).length} 37 |

38 | {contribs.slice(0, 3).map((contrib, i) => ( 39 |
40 | {contrib.org_name} 41 | 42 | {contrib.repo_name} 43 | 44 |
45 | ))} 46 |
47 |
48 | 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/templates/Pioneer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export type GuardianData = { 7 | numberOfTimes: number; 8 | }; 9 | 10 | export const Pioneer: FC = ({ username }) => { 11 | const tonyStark = `${websiteUrl}/assets/images/tony-stark.png`; 12 | return ( 13 | 14 |
15 |
16 |
17 |

Mission 2024

18 |
19 |

Pioneer

20 |
21 |
22 |

Lead the charge

23 |

at the bleeding edge

24 |

of the development

25 |

universe

26 |
27 |
28 | 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/templates/Streak.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export type StreakData = { 7 | streak: number; 8 | }; 9 | 10 | export const Streak: FC = ({ streak, username }) => { 11 | const office = `${websiteUrl}/assets/images/delorean.png`; 12 | return ( 13 | 14 |
15 |
16 |

Breaks?

17 |

Where we're going

18 |

we don't need breaks

19 |
20 |
21 |

{streak}

22 | days 23 |
24 |
25 |

Continuously.

26 |

You’ve shipped code.

27 |

Every.

28 |

Single.

29 |

Day.

30 |
31 |
32 | 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/templates/TimeOfTheDay.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from './RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | const TIME_OF_DAY_THRESHOLD = 0.4; 6 | 7 | export type TimeOfTheDayData = { 8 | prsDuringDay: number; 9 | totalPrs: number; 10 | productiveDay: string; 11 | productiveHour: number; 12 | }; 13 | 14 | export const TimeOfTheDay: FC = ({ 15 | prsDuringDay, 16 | totalPrs, 17 | username, 18 | productiveDay, 19 | productiveHour 20 | }) => { 21 | const prsDuringNight = totalPrs - prsDuringDay; 22 | const isNightOwl = prsDuringNight / totalPrs >= TIME_OF_DAY_THRESHOLD; 23 | const isDayHawk = prsDuringDay / totalPrs >= TIME_OF_DAY_THRESHOLD; 24 | const isRoundTheClock = isNightOwl && isDayHawk; 25 | 26 | const niteOwl = `${websiteUrl}/assets/images/niteowl.png`; 27 | const dayHawk = `${websiteUrl}/assets/images/dayhawk.png`; 28 | const allDay = `${websiteUrl}/assets/images/allday.png`; 29 | 30 | if (isRoundTheClock) { 31 | return ( 32 | 33 |
34 | 35 | 36 | 37 | 41 |
42 | 48 |
49 | ); 50 | } else if (isDayHawk) { 51 | return ( 52 | 53 |
54 | 55 | 56 | 57 | 61 |
62 | 68 |
69 | ); 70 | } else { 71 | return ( 72 | 73 |
74 | 75 | 76 | 77 | 81 |
82 | 88 |
89 | ); 90 | } 91 | }; 92 | 93 | const CardSubtitle = ({ text }: { text: string }) => { 94 | return ( 95 |
96 |

{text}

97 |
98 | ); 99 | }; 100 | 101 | const CardTitle = ({ text }: { text: string }) => { 102 | const splitText = text.split('~'); 103 | return ( 104 |
105 |

{splitText[0]}

106 |

{splitText[1]}

107 |
108 | ); 109 | }; 110 | 111 | const CardSubText = ({ text }: { text: string }) => { 112 | return ( 113 |
114 |

{text}

115 |
116 | ); 117 | }; 118 | 119 | const ProductiveTimes = ({ 120 | productiveDay, 121 | productiveHour 122 | }: { 123 | productiveDay: string; 124 | productiveHour: number; 125 | }) => { 126 | return ( 127 |
128 | {productiveHour !== -1 ?

Most Active Hour

: null} 129 | {productiveHour !== -1 ? ( 130 |

{getAmPm(productiveHour)}

131 | ) : null} 132 | {productiveDay ?

Most Active Day

: null} 133 | {productiveDay ? ( 134 |

{productiveDay}

135 | ) : null} 136 |
137 | ); 138 | }; 139 | 140 | const getAmPm = (hour: number): string => { 141 | if (hour === 0 || hour === 24) return '12am'; 142 | if (hour === 12) return '12pm'; 143 | if (hour >= 12) { 144 | return (hour % 12) + 'pm'; 145 | } else { 146 | return (hour % 12) + 'am'; 147 | } 148 | }; 149 | -------------------------------------------------------------------------------- /src/components/templates/ZenNinja.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { RootCard } from '@/components/templates/RootCard'; 3 | import { websiteUrl } from '../../constants/general'; 4 | import { Username } from '@/components/templates/index'; 5 | import { mean } from 'ramda'; 6 | 7 | export type ZenNinjaData = { 8 | trends: number[]; 9 | }; 10 | 11 | export const ZenNinja: FC = ({ trends, username }) => { 12 | const panda = `${websiteUrl}/assets/images/panda.png`; 13 | const ninja = `${websiteUrl}/assets/images/ninja.png`; 14 | 15 | const isNinja = isSpiky(trends); 16 | 17 | if (isNinja) { 18 | return ( 19 | 20 |
21 |
22 |

You execute

23 |

and deliver like a

24 |
25 |
26 |

Ninja

27 |
28 |
29 | 30 |

31 | Your contribs graph in 2023. Spiky! 32 |

33 |
34 |
35 |

When you ship,

36 |

you ship entire

37 |

features in one go

38 |
39 |
40 | 46 |
47 | ); 48 | } else 49 | return ( 50 | 51 |
52 |
53 |

Inner peace? That’s cool!

54 |

Inner piece of what?

55 |
56 |
57 |

Zen

58 |
59 |
60 | 61 |

62 | Your contribs graph in 2023. Pretty consistent! 63 |

64 |
65 |
66 |

You deliver...

67 |

consistently,

68 |

and regularly

69 |
70 |
71 | 77 |
78 | ); 79 | }; 80 | 81 | interface LineGraphProps { 82 | data: number[]; 83 | color: string; 84 | } 85 | 86 | const LineGraph: React.FC = ({ data, color }) => { 87 | const height = 80; 88 | const minY = 0; 89 | const maxY = Math.max(...data); 90 | 91 | const getYCoordinate = (value: number) => 92 | ((value - minY) / (maxY - minY)) * height; 93 | 94 | const getPathData = () => { 95 | return data.map( 96 | (value, index) => 97 | `${(index * 1000) / data.length}, ${height - getYCoordinate(value)}` 98 | ); 99 | }; 100 | 101 | return ( 102 |
103 | {/* @ts-ignore */} 104 | 105 | 113 | 114 |
115 | ); 116 | }; 117 | 118 | export default LineGraph; 119 | 120 | function getPercentile(argarr: number[], percentile: number): number { 121 | const arr = [...argarr]; 122 | // Sort the array in ascending order 123 | const sortedArr = arr.sort((a, b) => a - b); 124 | 125 | // Calculate the index of the percentile element 126 | const index = ((sortedArr.length - 1) * percentile) / 100; 127 | 128 | // Check if the index is an integer 129 | if (Number.isInteger(index)) { 130 | // If it is, return the element at that index 131 | return sortedArr[index]; 132 | } else { 133 | // If not, interpolate between the two surrounding elements 134 | const lowerIndex = Math.floor(index); 135 | const upperIndex = Math.ceil(index); 136 | const weight = index - lowerIndex; 137 | return ( 138 | sortedArr[lowerIndex] * (1 - weight) + sortedArr[upperIndex] * weight 139 | ); 140 | } 141 | } 142 | 143 | function isSpiky(data: number[]): boolean { 144 | const avg = mean(data); 145 | const p90 = getPercentile(data, 90); 146 | const hi = Math.max(...data); 147 | const lo = Math.min(...data); 148 | const diff = Math.abs(p90 - avg); 149 | const range = hi - lo; 150 | const perc = (diff * 100) / range; 151 | return perc > 15; 152 | } 153 | -------------------------------------------------------------------------------- /src/components/templates/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { CardTypes } from '../../types/cards'; 3 | import { IntroCard, IntroCardProps } from './IntroCard'; 4 | import { TimeOfTheDay, TimeOfTheDayData } from './TimeOfTheDay'; 5 | import { Guardian, GuardianData } from '@/components/templates/Guardian'; 6 | import { 7 | AuthoredReviewed, 8 | AuthoredReviewedData 9 | } from '@/components/templates/AuthoredReviewed'; 10 | import { Dependants, DependantsData } from './Dependants'; 11 | import { 12 | Contributions, 13 | ContributionsData 14 | } from '@/components/templates/Contributions'; 15 | import { ZenNinja, ZenNinjaData } from '@/components/templates/ZenNinja'; 16 | import { Streak, StreakData } from '@/components/templates/Streak'; 17 | import { CodeReviews, CodeReviewsData } from './CodeReviews'; 18 | import { OSSContribs, OSSContribsData } from './OSSContribs'; 19 | import { Pioneer } from './Pioneer'; 20 | import { Leader } from './Leader'; 21 | 22 | export type Username = { username: string }; 23 | 24 | export type CardTemplateData = { 25 | cardType: CardTypes; 26 | data?: 27 | | (( 28 | | IntroCardProps 29 | | TimeOfTheDayData 30 | | GuardianData 31 | | AuthoredReviewedData 32 | | DependantsData 33 | | ContributionsData 34 | | ZenNinjaData 35 | | StreakData 36 | | CodeReviewsData 37 | | OSSContribsData 38 | ) & 39 | Username) 40 | | Username 41 | | null; 42 | }; 43 | 44 | export const CardTemplate: FC = ({ cardType, data }) => { 45 | switch (cardType) { 46 | case CardTypes.UNWRAPPED_INTRO: 47 | return ; 48 | case CardTypes.GUARDIAN_OF_PROD: 49 | return ; 50 | case CardTypes.PR_REVIEWED_VS_AUTHORED: 51 | return ( 52 | 53 | ); 54 | case CardTypes.IT_TAKES_A_VILLAGE: 55 | return ; 56 | case CardTypes.YOUR_CONTRIBUTIONS: 57 | return ; 58 | case CardTypes.ZEN_OR_NINJA: 59 | return ; 60 | case CardTypes.OSS_CONTRIBUTION: 61 | return ; 62 | case CardTypes.CONTRIBUTION_STREAK: 63 | return ; 64 | case CardTypes.TOP_REVIEWERS: 65 | return ; 66 | case CardTypes.PIONEER: 67 | return ; 68 | case CardTypes.LEADER: 69 | return ; 70 | default: 71 | return ; 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /src/constants/all-pregen-cards.ts: -------------------------------------------------------------------------------- 1 | export const pregenFiles = [ 2 | 'popdevs/nodejs/oss.png', 3 | 'popdevs/vitalik/daynight.png', 4 | 'popdevs/linus/streak.png', 5 | 'popdevs/linus/village.png', 6 | 'popdevs/dhh/contribs.png', 7 | 'popdevs/dhh/zen.png', 8 | 'popdevs/dan/oss.png', 9 | 'popdevs/dan/streak.png', 10 | 'popdevs/matz/contribs.png', 11 | 'popdevs/tailwind/drake.png', 12 | 'popdevs/d3js/authreview.png', 13 | 'popdevs/d3js/changes.png', 14 | 'popdevs/kcd/contribs.png' 15 | ]; 16 | 17 | export const devOrgMap = { 18 | nodejs: 'Ryan Dahl - Nodejs, Deno', 19 | vitalik: 'Vitalik Buterin - Etherium', 20 | linus: 'Linus Torvalds - Linux', 21 | dhh: 'David H. Hansson - RoR, Basecamp', 22 | dan: 'Dan Abramov - React', 23 | matz: 'Yukihiro Matsumoto - Ruby', 24 | tailwind: 'Adam Wathan - TailwindCSS', 25 | d3js: 'Mike Bostock - D3JS', 26 | kcd: 'Kent C. Dodds - Educator' 27 | }; 28 | -------------------------------------------------------------------------------- /src/constants/events.ts: -------------------------------------------------------------------------------- 1 | import mixpanel from 'mixpanel-browser'; 2 | 3 | import { flattenObj } from '@/utils/datatype'; 4 | import { objectEnum } from '@/utils/enum'; 5 | 6 | enum TrackEventEnum { 7 | WINDOW_FOCUS, 8 | WINDOW_BLUR, 9 | WINDOW_UNLOAD, 10 | CREATE_UNWRAPPED_IMAGES_CLICKED, 11 | UNWRAP_YOUR_YEAR_CLICKED, 12 | LOGIN_CLICKED, 13 | SIGN_OUT_CLICKED, 14 | ZIP_DOWNLOAD_CLICKED, 15 | PDF_DOWNLOAD_CLICKED, 16 | LINKEDIN_SHARE_CLICKED, 17 | SINGLE_IMAGE_SHARE_CLICKED, 18 | NEXT_IMAGE_CLICKED, 19 | PREV_IMAGE_CLICKED, 20 | SEE_HOW_WE_MANAGE_YOUR_TRUST_CLICKED, 21 | SEE_HOW_2023_WAS_FOR_TOP_DEVS_CLICKED, 22 | SINGLE_IMAGE_PUBLIC_LINK_COPIED, 23 | WISH_TO_CREATE_YOUR_OWN_CLICKED 24 | } 25 | 26 | export const ALLOW_TRACKING_KEY = 'ALLOW_TRACKING'; 27 | 28 | export const TrackEvents = objectEnum(TrackEventEnum); 29 | 30 | export const track = (ev: keyof typeof TrackEvents, props: any = {}) => { 31 | const allowTracking = localStorage.getItem(ALLOW_TRACKING_KEY) === 'true'; 32 | if (!allowTracking) return; 33 | mixpanel.track(ev, flattenObj(props)); 34 | }; 35 | -------------------------------------------------------------------------------- /src/constants/general.ts: -------------------------------------------------------------------------------- 1 | // Environment variables 2 | export const DEV = 'development'; 3 | 4 | export const PROD = 'production'; 5 | 6 | export const websiteUrl = process.env.NEXTAUTH_URL; 7 | 8 | // Card dimensions 9 | export const CARD_WIDTH = '400px'; 10 | 11 | export const CARD_HEIGHT = '600px'; 12 | 13 | export const SCALE_FACTOR = 3; 14 | 15 | // Cover dimensions 16 | 17 | export const COVER_WIDTH = '1200px'; 18 | 19 | export const COVER_HEIGHT = '630px'; 20 | 21 | export const COVER_SCALE_FACTOR = 2; 22 | 23 | // Fonts 24 | export type FontStyles = { 25 | weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; 26 | name: 27 | | 'Black' 28 | | 'Bold' 29 | | 'ExtraBold' 30 | | 'ExtraLight' 31 | | 'Light' 32 | | 'Medium' 33 | | 'Regular' 34 | | 'SemiBold' 35 | | 'Thin'; 36 | }; 37 | 38 | export const INTER_FONT_STYLES: FontStyles[] = [ 39 | { weight: 100, name: 'Thin' }, 40 | { weight: 200, name: 'ExtraLight' }, 41 | { weight: 300, name: 'Light' }, 42 | { weight: 400, name: 'Regular' }, 43 | { weight: 500, name: 'Medium' }, 44 | { weight: 600, name: 'SemiBold' }, 45 | { weight: 700, name: 'Bold' }, 46 | { weight: 800, name: 'ExtraBold' }, 47 | { weight: 900, name: 'Black' } 48 | ]; 49 | 50 | // Card colors 51 | export const cardColorsMap = { 52 | red: '#F49783', 53 | orange: '#FB8500', 54 | teal: '#58C3DC', 55 | purple: '#A870F7', 56 | indigo: '#596CD0', 57 | grey: '#B7B7B7', 58 | pink: '#EF90D4', 59 | pearlGreen: '#71C99A', 60 | lightGreen: '#1CB0B0', 61 | coralPink: '#E16666', 62 | babyBlue: '#67B8F3', 63 | yellow: '#E2A300', 64 | green: '#71CB7F', 65 | midnight: '#442773' 66 | }; 67 | 68 | export const HASH_LENGTH = 6; 69 | 70 | export const DayOfWeek = [ 71 | 'sunday', 72 | 'monday', 73 | 'tuesday', 74 | 'wednesday', 75 | 'thursday', 76 | 'friday', 77 | 'saturday' 78 | ] as const; 79 | -------------------------------------------------------------------------------- /src/contexts/AppContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, createContext } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { useSession } from 'next-auth/react'; 4 | import { logException } from '@/utils/logger'; 5 | import { useIsClient } from 'usehooks-ts'; 6 | 7 | interface AppStateInterface {} 8 | 9 | const AppStateContext = createContext({} as AppStateInterface); 10 | 11 | interface AppStateProviderInterface { 12 | children: React.ReactNode; 13 | value?: Partial; 14 | } 15 | 16 | export const AppStateProvider = ({ children }: AppStateProviderInterface) => { 17 | const router = useRouter(); 18 | const isClient = useIsClient(); 19 | useSession({ 20 | required: isClient, 21 | onUnauthenticated() { 22 | router.pathname !== '/' && 23 | !router.pathname.startsWith('/view') && 24 | router.push('/'); 25 | } 26 | }); 27 | 28 | return ( 29 | {children} 30 | ); 31 | }; 32 | 33 | export const useAppState = () => { 34 | const context = useContext(AppStateContext); 35 | if (!context) { 36 | logException('useGlobalState must be used within a GlobalStateContext'); 37 | throw new Error('useGlobalState must be used within a GlobalStateContext'); 38 | } 39 | return context; 40 | }; 41 | -------------------------------------------------------------------------------- /src/hooks/useImageDownloader.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip'; 2 | import { saveAs } from 'file-saver'; 3 | import { useCallback } from 'react'; 4 | import { UpdatedImageFile } from '@/types/images'; 5 | import { logException } from '@/utils/logger'; 6 | import toast from 'react-hot-toast'; 7 | 8 | interface DownloadImagesProps { 9 | images: UpdatedImageFile | UpdatedImageFile[]; 10 | } 11 | const downloadSingleImage = (image: UpdatedImageFile) => { 12 | const fileName = image.fileName; 13 | return fetch(image.data) 14 | .then((response) => response.blob()) 15 | .then((blob) => saveAs(blob, fileName)) 16 | .catch((error) => 17 | logException('Error downloading image:', { originalException: error }) 18 | ); 19 | }; 20 | 21 | const downloadMultipleImages = (images: UpdatedImageFile[]) => { 22 | const zip = new JSZip(); 23 | const promises = images.map((image) => { 24 | const fileName = image.fileName; 25 | 26 | return fetch(image.data) 27 | .then((response) => response.blob()) 28 | .then((blob) => zip.file(fileName, blob)); 29 | }); 30 | 31 | return Promise.all(promises) 32 | .then(() => zip.generateAsync({ type: 'blob' })) 33 | .then((content) => saveAs(content, 'unwrapped-images.zip')) 34 | .catch((error) => 35 | logException('Error zipping images:', { originalException: error }) 36 | ); 37 | }; 38 | 39 | export const useImageDownloader = () => { 40 | const downloadImages = useCallback(({ images }: DownloadImagesProps) => { 41 | if (Array.isArray(images)) { 42 | return downloadMultipleImages(images); 43 | } else { 44 | return downloadSingleImage(images); 45 | } 46 | }, []); 47 | 48 | return ({ images }: DownloadImagesProps) => 49 | toast.promise( 50 | downloadImages({ images }), 51 | { 52 | loading: 'Processing Images', 53 | success: 'Downloaded', 54 | error: 'Error while processing' 55 | }, 56 | { 57 | success: { 58 | duration: 5000 59 | } 60 | } 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/hooks/useImageDownloaderAsPdfHook.ts: -------------------------------------------------------------------------------- 1 | import jsPDF from 'jspdf'; 2 | import { rgbToHex, darkenHexColor } from '@/api-helpers/general'; 3 | import { UpdatedImageFile } from '@/types/images'; 4 | import toast from 'react-hot-toast'; 5 | 6 | interface DownloadImagesAsPdfProps { 7 | images: UpdatedImageFile[]; 8 | } 9 | 10 | const IMAGE_HEIGHT = 600; 11 | const IMAGE_WIDTH = 400; 12 | const IMAGE_MARGIN = 20; 13 | 14 | const PAGE_HEIGHT = IMAGE_HEIGHT + IMAGE_MARGIN + IMAGE_MARGIN; 15 | const PAGE_WIDTH = IMAGE_WIDTH + IMAGE_MARGIN + IMAGE_MARGIN; 16 | 17 | const downloadImagesAsPdf = async ({ images }: DownloadImagesAsPdfProps) => { 18 | const pdf = new jsPDF({ 19 | unit: 'px', 20 | format: [PAGE_WIDTH, PAGE_HEIGHT], 21 | userUnit: 300, 22 | compress: true 23 | }); 24 | const colorsCodesByImageOrder = await Promise.all( 25 | images.map((item) => item.data).map(getColorFromImage) 26 | ); 27 | 28 | images.forEach(async (imageUrl, index) => { 29 | if (index !== 0) { 30 | pdf.addPage(); 31 | } 32 | const color = colorsCodesByImageOrder[index].split(','); 33 | const hexCode = rgbToHex( 34 | parseInt(color![0]), 35 | parseInt(color![1]), 36 | parseInt(color![2]) 37 | ); 38 | const darkenHexCode = darkenHexColor(hexCode, 0.2); 39 | pdf.setFillColor(hexCode); 40 | pdf.setDrawColor(darkenHexCode); 41 | pdf.rect(0, 0, PAGE_WIDTH, PAGE_HEIGHT, 'F'); 42 | pdf.addImage( 43 | imageUrl.data, 44 | 'JPEG', 45 | IMAGE_MARGIN, 46 | IMAGE_MARGIN, 47 | IMAGE_WIDTH, 48 | IMAGE_HEIGHT 49 | ); 50 | pdf.setLineWidth(10); 51 | pdf.roundedRect( 52 | IMAGE_MARGIN, 53 | IMAGE_MARGIN, 54 | IMAGE_WIDTH, 55 | IMAGE_HEIGHT, 56 | 10, 57 | 10, 58 | 'S' 59 | ); 60 | pdf.clip(); 61 | }); 62 | await pdf.save('middleware-unwrapped.pdf', { returnPromise: true }); 63 | }; 64 | 65 | export const useImageDownloaderAsPdf = () => { 66 | return ({ images }: DownloadImagesAsPdfProps) => 67 | toast.promise( 68 | downloadImagesAsPdf({ images }), 69 | { 70 | loading: 'Processing PDF', 71 | success: 'Downloaded', 72 | error: 'Error while processing' 73 | }, 74 | { 75 | position: 'top-right', 76 | success: { 77 | duration: 3000 78 | } 79 | } 80 | ); 81 | }; 82 | 83 | async function getColorFromImage(imageUrl: string): Promise { 84 | return new Promise((resolve, reject) => { 85 | const img = new Image(); 86 | img.crossOrigin = 'Anonymous'; // Enable CORS if the image is hosted on a different domain 87 | 88 | img.onload = () => { 89 | const canvas = document.createElement('canvas'); 90 | canvas.width = img.width; 91 | canvas.height = img.height; 92 | 93 | const context = canvas.getContext('2d'); 94 | if (!context) { 95 | reject('Unable to get 2D context for canvas'); 96 | return; 97 | } 98 | 99 | context.drawImage(img, 0, 0); 100 | const pixelData = context.getImageData(1, 1, 1, 1).data; 101 | resolve(`${pixelData[0]}, ${pixelData[1]}, ${pixelData[2]}`); 102 | }; 103 | 104 | img.onerror = (error) => { 105 | reject(error); 106 | }; 107 | 108 | img.src = imageUrl; 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /src/hooks/usePrebuiltToasts.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import toast from 'react-hot-toast'; 3 | 4 | export const usePrebuiltToasts = () => { 5 | const InvalidEmailToast = useCallback( 6 | () => 7 | toast.error('Invalid email address', { 8 | id: 'invalid-email' 9 | }), 10 | [] 11 | ); 12 | const emailIsRequiredToast = useCallback( 13 | () => 14 | toast.error('Email is required', { 15 | id: 'email-is-required' 16 | }), 17 | [] 18 | ); 19 | const noImagesToast = useCallback( 20 | () => 21 | toast.error( 22 | 'Looks like they had too little activity to show anything...', 23 | { 24 | duration: 6000, 25 | icon: '🤔', 26 | id: 'no-images-found' 27 | } 28 | ), 29 | [] 30 | ); 31 | const invalidUrlToast = useCallback( 32 | () => 33 | toast.error('Invalid URL', { 34 | duration: 6000, 35 | icon: '😢', 36 | id: 'invalid-url' 37 | }), 38 | [] 39 | ); 40 | const unauthenticatedToast = useCallback( 41 | () => 42 | toast.error( 43 | "Seems like you aren't authenticated. Taking you back home.", 44 | { 45 | id: 'unauthenticated' 46 | } 47 | ), 48 | [] 49 | ); 50 | 51 | const somethingWentWrongToast = useCallback( 52 | () => 53 | toast.error('Something went wrong. Please try again.', { 54 | id: 'something-went-wrong' 55 | }), 56 | [] 57 | ); 58 | 59 | return { 60 | InvalidEmailToast, 61 | emailIsRequiredToast, 62 | noImagesToast, 63 | invalidUrlToast, 64 | unauthenticatedToast, 65 | somethingWentWrongToast 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | import { SessionProvider } from 'next-auth/react'; 4 | import { AppStateProvider } from '@/contexts/AppContext'; 5 | import { Toaster } from 'react-hot-toast'; 6 | import { inter } from '@/styles/fonts'; 7 | import { useRouter } from 'next/router'; 8 | import mixpanel from 'mixpanel-browser'; 9 | import { useEffect } from 'react'; 10 | import { track, ALLOW_TRACKING_KEY } from '@/constants/events'; 11 | import { useLocalStorage } from 'usehooks-ts'; 12 | import Script from 'next/script'; 13 | import '@/styles/swiper.css'; 14 | import 'react-tooltip/dist/react-tooltip.css'; 15 | import Head from 'next/head'; 16 | import { DefaultSeo } from 'next-seo'; 17 | import NextNProgress from 'nextjs-progressbar'; 18 | import { AppLoadingStateWrapper } from '@/components/AppLoadingStateWrapper'; 19 | 20 | export default function App({ 21 | Component, 22 | pageProps: { session, ...pageProps } 23 | }: AppProps) { 24 | const router = useRouter(); 25 | 26 | const [trackingAllowed] = useLocalStorage( 27 | ALLOW_TRACKING_KEY, 28 | null 29 | ); 30 | 31 | useEffect(() => { 32 | const isDev = process.env.NEXT_PUBLIC_APP_ENVIRONMENT === 'development'; 33 | 34 | mixpanel.init(process.env.NEXT_PUBLIC_MIXPANEL, { 35 | debug: isDev, 36 | api_host: '/api/tunnel/mixpanel', 37 | secure_cookie: true 38 | }); 39 | 40 | if (!trackingAllowed) return; 41 | 42 | const handleRouteChange = (url: string) => { 43 | mixpanel.track(router.pathname, { 44 | url, 45 | pathPattern: router.asPath, 46 | environment: process.env.NEXT_PUBLIC_APP_ENVIRONMENT 47 | }); 48 | }; 49 | 50 | if (router.isReady) handleRouteChange(router.asPath); 51 | 52 | const onFocus = () => track('WINDOW_FOCUS'); 53 | const onBlur = () => track('WINDOW_BLUR'); 54 | const onUnload = () => track('WINDOW_UNLOAD'); 55 | 56 | window.addEventListener('focus', onFocus); 57 | window.addEventListener('blur', onBlur); 58 | window.addEventListener('beforeunload', onUnload); 59 | router.events.on('routeChangeComplete', handleRouteChange); 60 | return () => { 61 | window.removeEventListener('focus', onFocus); 62 | window.removeEventListener('blur', onBlur); 63 | window.removeEventListener('beforeunload', onUnload); 64 | router.events.off('routeChangeComplete', handleRouteChange); 65 | }; 66 | }, [ 67 | router.asPath, 68 | router.events, 69 | router.isReady, 70 | router.pathname, 71 | trackingAllowed 72 | ]); 73 | 74 | return ( 75 | <> 76 | 94 | 95 | 96 | 100 | 104 | 105 |
106 | 107 | 108 | 109 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | {Boolean(process.env.NEXT_PUBLIC_GA) && ( 122 | <> 123 | 135 | 136 | )} 137 |
138 | 139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /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 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/nextjs'; 2 | import Error from 'next/error'; 3 | 4 | const CustomErrorComponent = (props: any) => { 5 | return ; 6 | }; 7 | 8 | CustomErrorComponent.getInitialProps = async (contextData: any) => { 9 | // In case this is running in a serverless function, await this in order to give Sentry 10 | // time to send the error before the lambda exits 11 | await Sentry.captureUnderscoreErrorException(contextData); 12 | 13 | // This will contain the status code of the response 14 | return Error.getInitialProps(contextData); 15 | }; 16 | 17 | export default CustomErrorComponent; 18 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { enc } from '@/api-helpers/auth-supplementary'; 2 | import { addMonths } from 'date-fns'; 3 | import NextAuth, { 4 | type AuthOptions, 5 | type Profile, 6 | type Account 7 | } from 'next-auth'; 8 | import GithubProvider from 'next-auth/providers/github'; 9 | import { type NextApiRequest, type NextApiResponse } from 'next/types'; 10 | 11 | const getRemainingCookies = (key: string, res: NextApiResponse) => 12 | ((res.getHeader('set-cookie') || []) as string[]).filter( 13 | (cookie) => !cookie.startsWith(key) 14 | ); 15 | 16 | const unsafeCookieAttrs = ['Secure', 'Path=/'].join(';'); 17 | const cookieAttrs = `${unsafeCookieAttrs};HttpOnly`; 18 | const cookieDeleteAttr = 'Expires=Thu, 01 Jan 1970 00:00:00 GMT'; 19 | 20 | export const GH_COOKIE_ATTR = 'ghct'; 21 | 22 | export const setCookie = ( 23 | key: string, 24 | value: any, 25 | res: NextApiResponse, 26 | secure: boolean = true, 27 | expires?: string 28 | ) => { 29 | res.setHeader('set-cookie', [ 30 | ...getRemainingCookies(key, res), 31 | `${key}=${String(value)};${secure ? cookieAttrs : unsafeCookieAttrs}${ 32 | expires ? `;Expires=${expires}` : '' 33 | }` 34 | ]); 35 | }; 36 | 37 | export const deleteCookie = ( 38 | key: string, 39 | res: NextApiResponse, 40 | secure: boolean = true 41 | ) => { 42 | res.setHeader('set-cookie', [ 43 | ...getRemainingCookies(key, res), 44 | `${key}=;${secure ? cookieAttrs : unsafeCookieAttrs};${cookieDeleteAttr}` 45 | ]); 46 | }; 47 | 48 | export const nextAuthConfig = ( 49 | req: NextApiRequest, 50 | res: NextApiResponse 51 | ): AuthOptions => ({ 52 | // Configure one or more authentication providers 53 | providers: [ 54 | GithubProvider({ 55 | clientId: process.env.GITHUB_ID, 56 | clientSecret: process.env.GITHUB_SECRET, 57 | authorization: 58 | 'https://github.com/login/oauth/authorize?scope=read:user+user:email+repo', 59 | name: 'github' 60 | }) 61 | ], 62 | callbacks: { 63 | async signIn({ account }: { profile?: Profile; account: Account | null }) { 64 | switch (account?.provider?.split('-')[0]) { 65 | case 'github': { 66 | if (!account?.access_token) return false; 67 | const loginDate = new Date(); 68 | setCookie( 69 | GH_COOKIE_ATTR, 70 | enc(account.access_token), 71 | res, 72 | true, 73 | addMonths(loginDate, 2).toUTCString() 74 | ); 75 | return true; 76 | } 77 | default: { 78 | console.warn( 79 | `UNHANDLED_SIGN_IN_HANDLER: ${ 80 | account?.provider || 'Unknown Provider' 81 | }` 82 | ); 83 | return false; 84 | } 85 | } 86 | } 87 | } 88 | }); 89 | 90 | export default async function auth(req: NextApiRequest, res: NextApiResponse) { 91 | if (req?.url?.startsWith('/api/auth/signout')) { 92 | deleteCookie(GH_COOKIE_ATTR, res); 93 | } 94 | 95 | return await NextAuth(req, res, nextAuthConfig(req, res)); 96 | } 97 | -------------------------------------------------------------------------------- /src/pages/api/download.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { generateImages } from '@/api-helpers/image-gen'; 4 | import { archiveFiles } from '@/api-helpers/archive'; 5 | // import { getCardLinksFromGithubData } from '@/api-helpers/general'; 6 | import { fetchGithubUnwrappedData } from '@/api-helpers/unrwrapped-aggregator'; 7 | import { dec } from '@/api-helpers/auth-supplementary'; 8 | import { fetchSavedCards, saveCards } from '@/api-helpers/persistance'; 9 | import { fetchUser, fetchUserByLogin } from '@/api-helpers/exapi-sdk/github'; 10 | import { 11 | bcryptGen, 12 | extractFilenameWithoutExtension 13 | } from '@/utils/stringHelpers'; 14 | import { logException } from '@/utils/logger'; 15 | import { ImageAPIResponse } from '@/types/images'; 16 | 17 | const fetchAndDownloadImageBuffer = async ( 18 | req: NextApiRequest, 19 | res: NextApiResponse 20 | ) => { 21 | let token = req.cookies.ghct; 22 | const timezone = (req.headers['x-timezone'] as string) || 'UTC'; 23 | 24 | const isPublic = req.query.ispublic === 'true'; 25 | 26 | if (!token && !req.query.username) { 27 | return res.status(403).json({ 28 | message: 'GitHub Access token not found.' 29 | }); 30 | } 31 | 32 | if (isPublic) { 33 | token = process.env.GLOBAL_GH_PAT; 34 | } else if (token) { 35 | token = dec(token); 36 | } else { 37 | return res.status(403).json({ 38 | message: 'GitHub Access token not found.' 39 | }); 40 | } 41 | 42 | try { 43 | const user = req.query.username 44 | ? await fetchUserByLogin(token, req.query.username as string) 45 | : await fetchUser(token); 46 | 47 | let imageBuffer = req.query.recache 48 | ? [] 49 | : await fetchSavedCards(user.login, isPublic); 50 | 51 | if (!imageBuffer?.length) { 52 | const data = await fetchGithubUnwrappedData(token, timezone, user); 53 | imageBuffer = await generateImages(data); 54 | saveCards(user.login, imageBuffer, isPublic); 55 | } 56 | 57 | if (req.query.format === 'archive') { 58 | const zippedData = await archiveFiles( 59 | imageBuffer.map(({ data, fileName }) => ({ data, fileName })) 60 | ); 61 | const fileName = 'middleware_unwrapped.zip'; 62 | res.setHeader( 63 | 'Content-Disposition', 64 | `attachment; filename=${encodeURIComponent(fileName)}` 65 | ); 66 | res.setHeader('Cache-Control', 'no-cache'); 67 | res.setHeader('Content-Type', 'application/zip'); 68 | res.send(zippedData); 69 | } else { 70 | const username = user.login; 71 | const userNameHash = bcryptGen(username); 72 | const shareUrl = isPublic 73 | ? `/view/public/${user.login}` 74 | : `/view/${user.login}/${userNameHash}`; 75 | 76 | const imageData = imageBuffer.map(({ data, fileName }) => { 77 | const file = extractFilenameWithoutExtension(fileName); 78 | const hash = bcryptGen(username + file); 79 | 80 | return { 81 | fileName, 82 | url: isPublic 83 | ? `/shared/public/${username}/${file}` 84 | : `/shared/${username}/${file}/${hash}`, 85 | data: `data:image/png;base64,${data.toString('base64')}` 86 | }; 87 | }); 88 | res.setHeader('Content-Type', 'application/json'); 89 | res.send({ 90 | shareAllUrl: shareUrl, 91 | data: imageData 92 | } as ImageAPIResponse); 93 | } 94 | console.info(chalk.green('Successfully sent buffer to client')); 95 | } catch (error: any) { 96 | logException('Error fetching or sending buffer', { 97 | originalException: error 98 | }); 99 | console.info(chalk.red('Error fetching or sending buffer:'), error); 100 | res.status(error.status || 500).json({ message: error.message }); 101 | } 102 | }; 103 | 104 | export default fetchAndDownloadImageBuffer; 105 | -------------------------------------------------------------------------------- /src/pages/api/followup-user-email.ts: -------------------------------------------------------------------------------- 1 | import { dec } from '@/api-helpers/auth-supplementary'; 2 | import { nodeTrack } from '@/api-helpers/node-events'; 3 | import { handleCatch, handleThen } from '@/utils/axios'; 4 | import axios from 'axios'; 5 | import { NextApiRequest, NextApiResponse } from 'next'; 6 | import { identity, pickBy } from 'ramda'; 7 | 8 | const userEmailInput = async (req: NextApiRequest, res: NextApiResponse) => { 9 | if (!req.body.email) { 10 | return res.status(400).send({ success: false, error: 'Email is required' }); 11 | } 12 | 13 | const token = req.cookies.ghct && dec(req.cookies.ghct); 14 | const userDetails = (token && (await getUserData(token))) as 15 | | UserData 16 | | undefined; 17 | const username = userDetails?.login; 18 | const fullName = userDetails?.name; 19 | 20 | const payload: ZapierPayload = pickBy(identity, { 21 | email: req.body.email, 22 | username: req.body.saveUsername && username, 23 | fullName: req.body.saveFullName && fullName 24 | }); 25 | 26 | nodeTrack('FOLLOWUP_USER_EMAIL', payload); 27 | const zapierResponse = await sendToZapier(payload); 28 | 29 | return res.send({ status: zapierResponse.status }); 30 | }; 31 | 32 | export default userEmailInput; 33 | 34 | type UserData = { 35 | login: string; 36 | name: string; 37 | }; 38 | 39 | export async function getUserData(accessToken: string): Promise { 40 | return axios 41 | .get('https://api.github.com/user', { 42 | headers: { 43 | Authorization: `Bearer ${accessToken}` 44 | } 45 | }) 46 | .then(handleThen) 47 | .catch(handleCatch); 48 | } 49 | 50 | interface ZapierPayload { 51 | email: string; 52 | username?: string; 53 | fullName?: string; 54 | } 55 | 56 | interface ZapierResponse { 57 | attempt: string; 58 | id: string; 59 | request_id: string; 60 | status: string; 61 | } 62 | 63 | export async function sendToZapier( 64 | payload: ZapierPayload 65 | ): Promise { 66 | const zapierWebhookUrl = process.env.ZAPIER_WEBHOOK_URL; 67 | return axios 68 | .post(zapierWebhookUrl, { 69 | ...payload, 70 | gh_login: payload.username, 71 | name: payload.fullName 72 | }) 73 | .then(handleThen) 74 | .catch(handleCatch); 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/api/get-all-images/index.ts: -------------------------------------------------------------------------------- 1 | // api to check if hash is valid, accepts username and hash, return true or false 2 | 3 | import { 4 | bcryptGen, 5 | extractFilenameWithoutExtension 6 | } from '@/utils/stringHelpers'; 7 | import { NextApiRequest, NextApiResponse } from 'next'; 8 | import { fetchSavedCards } from '@/api-helpers/persistance'; 9 | import { logException } from '@/utils/logger'; 10 | 11 | const checkHash = async (req: NextApiRequest, res: NextApiResponse) => { 12 | let { username, hash } = req.query; 13 | if (!username) { 14 | return res.status(400).json({ 15 | message: 'Username or hash not found.' 16 | }); 17 | } 18 | const isPublic = req.query.ispublic === 'true'; 19 | let isValid = isPublic; 20 | 21 | if (!isValid) { 22 | const userNameHash = bcryptGen(username as string); 23 | isValid = userNameHash === (hash as string); 24 | } 25 | 26 | if (isValid) { 27 | try { 28 | const imageData = await fetchSavedCards(username as string, isPublic); 29 | const imageDataWithURL = imageData.map((image) => { 30 | const filename = extractFilenameWithoutExtension(image.fileName); 31 | const hash = bcryptGen((username as string) + filename); 32 | return { 33 | ...image, 34 | url: `/shared/${username}/${filename}/${hash}`, 35 | data: `data:image/png;base64,${image.data.toString('base64')}` 36 | }; 37 | }); 38 | 39 | res.status(200).json({ isValid, data: imageDataWithURL }); 40 | } catch (e) { 41 | logException('Error fetching from share-all data from s3', { 42 | originalException: e 43 | }); 44 | res.status(400).json({ isValid, data: null }); 45 | } 46 | } else res.status(400).json({ isValid, data: null }); 47 | }; 48 | 49 | export default checkHash; 50 | -------------------------------------------------------------------------------- /src/pages/api/get_cover/[...params].ts: -------------------------------------------------------------------------------- 1 | import { fetchSavedCards } from '@/api-helpers/persistance'; 2 | import { createCoverUsingVercel } from '@/api-helpers/vercel-cover-generator'; 3 | import { CardTypes } from '@/types/cards'; 4 | import { 5 | bcryptGen, 6 | extractFilenameWithoutExtension 7 | } from '@/utils/stringHelpers'; 8 | import { NextApiRequest, NextApiResponse } from 'next'; 9 | 10 | const fetchAndDownloadImageBuffer = async ( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) => { 14 | const [username, hash] = req.query.params as string[]; 15 | 16 | if (!username || !hash) { 17 | return res.status(400).json({ 18 | message: 'Invalid parameters, must pass valid username and hash.' 19 | }); 20 | } 21 | 22 | const generatedHash = bcryptGen(username); 23 | 24 | if (generatedHash !== hash) { 25 | return res.status(400).json({ 26 | message: 'Invalid parameters, must pass valid hash.' 27 | }); 28 | } 29 | const imageData = await fetchSavedCards(username, false); 30 | 31 | const images = imageData 32 | .sort((a, b) => { 33 | const indexA = priority.indexOf( 34 | extractFilenameWithoutExtension(a.fileName).toUpperCase() as CardTypes 35 | ); 36 | const indexB = priority.indexOf( 37 | extractFilenameWithoutExtension(b.fileName).toUpperCase() as CardTypes 38 | ); 39 | 40 | return indexA - indexB; 41 | }) 42 | .slice(0, 3) 43 | .map((image) => { 44 | const file = extractFilenameWithoutExtension(image.fileName); 45 | const uniqueHash = bcryptGen(username + file); 46 | const domain = process.env.NEXTAUTH_URL; 47 | return `${domain}/shared/${username}/${file}/${uniqueHash}`; 48 | }); 49 | 50 | const getCoverImage = await createCoverUsingVercel(username, images); 51 | res.setHeader('Content-Type', 'image/jpeg'); 52 | res.setHeader('Content-Length', getCoverImage.length); 53 | res.send(getCoverImage); 54 | }; 55 | 56 | export default fetchAndDownloadImageBuffer; 57 | 58 | const priority = [ 59 | CardTypes.YOUR_CONTRIBUTIONS, 60 | CardTypes.DAY_NIGHT_CYCLE, 61 | CardTypes.PR_REVIEWED_VS_AUTHORED, 62 | CardTypes.IT_TAKES_A_VILLAGE, 63 | CardTypes.TOP_REVIEWERS, 64 | CardTypes.CONTRIBUTION_STREAK, 65 | CardTypes.GUARDIAN_OF_PROD, 66 | CardTypes.ZEN_OR_NINJA, 67 | CardTypes.OSS_CONTRIBUTION, 68 | CardTypes.PRODUCTION_BREAKING, 69 | CardTypes.UNWRAPPED_INTRO 70 | ]; 71 | -------------------------------------------------------------------------------- /src/pages/api/get_cover/public/[username].ts: -------------------------------------------------------------------------------- 1 | import { fetchSavedCards } from '@/api-helpers/persistance'; 2 | import { createCoverUsingVercel } from '@/api-helpers/vercel-cover-generator'; 3 | import { CardTypes } from '@/types/cards'; 4 | import { extractFilenameWithoutExtension } from '@/utils/stringHelpers'; 5 | import { NextApiRequest, NextApiResponse } from 'next'; 6 | 7 | const fetchAndDownloadImageBuffer = async ( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) => { 11 | const username = req.query.username as string; 12 | 13 | if (!username) { 14 | return res.status(400).json({ 15 | message: 'Invalid parameters, must pass valid username and hash.' 16 | }); 17 | } 18 | 19 | const imageData = await fetchSavedCards(username, true); 20 | 21 | const images = imageData 22 | .sort((a, b) => { 23 | const indexA = priority.indexOf( 24 | extractFilenameWithoutExtension(a.fileName).toUpperCase() as CardTypes 25 | ); 26 | const indexB = priority.indexOf( 27 | extractFilenameWithoutExtension(b.fileName).toUpperCase() as CardTypes 28 | ); 29 | 30 | return indexA - indexB; 31 | }) 32 | .slice(0, 3) 33 | .map((image) => { 34 | const file = extractFilenameWithoutExtension(image.fileName); 35 | const domain = process.env.NEXTAUTH_URL; 36 | return `${domain}/shared/public/${username}/${file}`; 37 | }); 38 | 39 | const getCoverImage = await createCoverUsingVercel(username, images); 40 | res.setHeader('Content-Type', 'image/jpeg'); 41 | res.setHeader('Content-Length', getCoverImage.length); 42 | res.send(getCoverImage); 43 | }; 44 | 45 | export default fetchAndDownloadImageBuffer; 46 | 47 | const priority = [ 48 | CardTypes.YOUR_CONTRIBUTIONS, 49 | CardTypes.DAY_NIGHT_CYCLE, 50 | CardTypes.PR_REVIEWED_VS_AUTHORED, 51 | CardTypes.IT_TAKES_A_VILLAGE, 52 | CardTypes.TOP_REVIEWERS, 53 | CardTypes.CONTRIBUTION_STREAK, 54 | CardTypes.GUARDIAN_OF_PROD, 55 | CardTypes.ZEN_OR_NINJA, 56 | CardTypes.OSS_CONTRIBUTION, 57 | CardTypes.PRODUCTION_BREAKING, 58 | CardTypes.UNWRAPPED_INTRO 59 | ]; 60 | -------------------------------------------------------------------------------- /src/pages/api/github/contribution_summary.ts: -------------------------------------------------------------------------------- 1 | import { dec } from '@/api-helpers/auth-supplementary'; 2 | import { 3 | fetchUser, 4 | fetchUserContributionSummaryMetrics 5 | } from '@/api-helpers/exapi-sdk/github'; 6 | import { NextApiRequest, NextApiResponse } from 'next'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | const token = dec(req.cookies.ghct || ''); 13 | 14 | if (!token) { 15 | return res.status(403).json({ 16 | message: 'GitHub Access token not found.' 17 | }); 18 | } 19 | 20 | try { 21 | const user = await fetchUser(token); 22 | 23 | const contributionSummary = await fetchUserContributionSummaryMetrics( 24 | user.login, 25 | token 26 | ); 27 | 28 | res.status(200).json({ 29 | contributionSummary 30 | }); 31 | } catch (e: any) { 32 | console.error(e); 33 | res.status(400).send({ message: e.message }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/api/github/improvement_metrics.ts: -------------------------------------------------------------------------------- 1 | import { dec } from '@/api-helpers/auth-supplementary'; 2 | import { fetchImprovementMetricsData } from '@/api-helpers/unrwrapped-aggregator'; 3 | 4 | import { NextApiRequest, NextApiResponse } from 'next'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | let token = req.cookies.ghct; 11 | 12 | if (!token) { 13 | return res.status(403).json({ 14 | message: 'GitHub Access token not found.' 15 | }); 16 | } 17 | 18 | token = dec(token); 19 | 20 | try { 21 | const improvementMetricsData = await fetchImprovementMetricsData(token); 22 | 23 | res.status(200).json(improvementMetricsData); 24 | } catch (e: any) { 25 | console.error(e); 26 | res.status(400).send({ message: e.message }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/api/github/pull_requests.ts: -------------------------------------------------------------------------------- 1 | import { getPRListAndMonthlyCountsFromGqlResponse } from '@/analytics/pr-analytics'; 2 | import { dec } from '@/api-helpers/auth-supplementary'; 3 | import { 4 | fetchAllPullRequests, 5 | fetchAllReviewedPRs, 6 | fetchUserGitHubContributionCalendarMetrics, 7 | fetchUser 8 | } from '@/api-helpers/exapi-sdk/github'; 9 | import { NextApiRequest, NextApiResponse } from 'next'; 10 | 11 | export default async function handler( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ) { 15 | const token = dec(req.cookies.ghct || ''); 16 | 17 | if (!token) { 18 | return res.status(403).json({ 19 | message: 'GitHub Access token not found.' 20 | }); 21 | } 22 | 23 | try { 24 | const user = await fetchUser(token); 25 | 26 | const [pr_authored_data, pr_reviewed_data, user_metrics] = 27 | await Promise.all([ 28 | fetchAllPullRequests(user.login, token), 29 | fetchAllReviewedPRs(user.login, token), 30 | fetchUserGitHubContributionCalendarMetrics(user.login, token) 31 | ]); 32 | 33 | const [authored_prs, authored_monthly_counts] = 34 | getPRListAndMonthlyCountsFromGqlResponse(pr_authored_data); 35 | const [reviewed_prs, reviewed_monthly_counts] = 36 | getPRListAndMonthlyCountsFromGqlResponse(pr_reviewed_data); 37 | 38 | res.status(200).json({ 39 | authored_prs, 40 | authored_monthly_counts, 41 | reviewed_prs, 42 | reviewed_monthly_counts, 43 | user_metrics 44 | }); 45 | } catch (e: any) { 46 | console.error(e); 47 | res.status(400).send({ message: e.message }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/pages/api/github/unwrapped.ts: -------------------------------------------------------------------------------- 1 | import { dec } from '@/api-helpers/auth-supplementary'; 2 | 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | import { fetchGithubUnwrappedData } from '@/api-helpers/unrwrapped-aggregator'; 5 | import { fetchUser, fetchUserByLogin } from '@/api-helpers/exapi-sdk/github'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | let token = req.cookies.ghct; 12 | const timezone = (req.headers['x-timezone'] as string) || 'UTC'; 13 | 14 | if (!token && !req.query.username) { 15 | return res.status(403).json({ 16 | message: 'GitHub Access token not found.' 17 | }); 18 | } 19 | 20 | if (!token) { 21 | token = process.env.GLOBAL_GH_PAT; 22 | } else { 23 | token = dec(token); 24 | } 25 | 26 | try { 27 | const user = req.query.username 28 | ? await fetchUserByLogin(token, req.query.username as string) 29 | : await fetchUser(token); 30 | const unwrappedData = await fetchGithubUnwrappedData(token, timezone, user); 31 | 32 | res.status(200).json(unwrappedData); 33 | } catch (e: any) { 34 | console.error(e); 35 | res.status(400).send({ message: e.message }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/api/github/user.ts: -------------------------------------------------------------------------------- 1 | import { dec } from '@/api-helpers/auth-supplementary'; 2 | 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | import { fetchUser } from '@/api-helpers/exapi-sdk/github'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | let token = req.cookies.ghct; 11 | 12 | if (!token) { 13 | return res.status(403).json({ 14 | message: 'GitHub Access token not found.' 15 | }); 16 | } 17 | 18 | token = dec(token); 19 | 20 | try { 21 | const user = await fetchUser(token); 22 | 23 | res.status(200).json({ user }); 24 | } catch (e: any) { 25 | console.error(e); 26 | res.status(400).send({ message: e.message }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/api/preview/authoredReviewed.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { AuthoredReviewedData } from '@/components/templates/AuthoredReviewed'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: AuthoredReviewedData & Username = { 11 | authoredPrs: 82, 12 | reviewedPrs: 18, 13 | username: 'jayantbh' 14 | }; 15 | 16 | const generateUsingVercel = async () => { 17 | return ( 18 | await createImageUsingVercel( 19 | data, 20 | CardTypes.PR_REVIEWED_VS_AUTHORED, 21 | 'browser' 22 | ) 23 | ).image; 24 | }; 25 | 26 | export default generateUsingVercel; 27 | -------------------------------------------------------------------------------- /src/pages/api/preview/codeReviewers.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { CodeReviewsData } from '../../../components/templates/CodeReviews'; 4 | import { Username } from '@/components/templates'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: CodeReviewsData & Username = { 11 | topReviewer: 'jason', 12 | totalReviewers: 6, 13 | username: 'jayantbh' 14 | }; 15 | 16 | const generateUsingVercel = async () => { 17 | return ( 18 | await createImageUsingVercel(data, CardTypes.TOP_REVIEWERS, 'browser') 19 | ).image; 20 | }; 21 | 22 | export default generateUsingVercel; 23 | -------------------------------------------------------------------------------- /src/pages/api/preview/contributions.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { ContributionsData } from '@/components/templates/Contributions'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: ContributionsData & Username = { 11 | contributions: 20312, 12 | percentile: 2, 13 | username: 'jayantbh' 14 | }; 15 | 16 | const generateUsingVercel = async () => { 17 | return ( 18 | await createImageUsingVercel(data, CardTypes.YOUR_CONTRIBUTIONS, 'browser') 19 | ).image; 20 | }; 21 | 22 | export default generateUsingVercel; 23 | -------------------------------------------------------------------------------- /src/pages/api/preview/dependants.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { DependantsData } from '../../../components/templates/Dependants'; 4 | 5 | export const config = { 6 | runtime: 'edge' 7 | }; 8 | 9 | const mockGithubDependants = [ 10 | 'jayantbhjandoiahdoahdlal', 11 | 'amoghjalan', 12 | 'dhruvagarwal', 13 | 'shivam-bit', 14 | 'e-for-eshaan', 15 | 'sidmohanty11', 16 | 'Sing-Li', 17 | 'adnanhashmi09', 18 | 'axonasif', 19 | 'Palanikannan1437', 20 | 'Dnouv', 21 | 'dkurt' 22 | ]; 23 | 24 | const mockGithubUser = 'eshaan007'; 25 | 26 | const data: DependantsData = { 27 | dependants: mockGithubDependants, 28 | username: mockGithubUser, 29 | userAvatar: 'https://avatars.githubusercontent.com/u/70485812?v=4' 30 | }; 31 | 32 | const generateUsingVercel = async () => { 33 | return ( 34 | await createImageUsingVercel(data, CardTypes.IT_TAKES_A_VILLAGE, 'browser') 35 | ).image; 36 | }; 37 | 38 | export default generateUsingVercel; 39 | -------------------------------------------------------------------------------- /src/pages/api/preview/guardian.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { GuardianData } from '../../../components/templates/Guardian'; 4 | import { Username } from '@/components/templates/index'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: GuardianData & Username = { 11 | numberOfTimes: 50, 12 | username: 'jayantbh' 13 | }; 14 | 15 | const generateUsingVercel = async () => { 16 | return ( 17 | await createImageUsingVercel(data, CardTypes.GUARDIAN_OF_PROD, 'browser') 18 | ).image; 19 | }; 20 | 21 | export default generateUsingVercel; 22 | -------------------------------------------------------------------------------- /src/pages/api/preview/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IntroCard } from '../../../components/templates/IntroCard'; 3 | 4 | const AllCards = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default AllCards; 13 | -------------------------------------------------------------------------------- /src/pages/api/preview/intro.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { IntroCardProps } from '../../../components/templates/IntroCard'; 4 | import { Username } from '@/components/templates'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: IntroCardProps & Username = { 11 | username: 'jayantbh', 12 | year: 2023 13 | }; 14 | 15 | const generateUsingVercel = async () => { 16 | return ( 17 | await createImageUsingVercel(data, CardTypes.UNWRAPPED_INTRO, 'browser') 18 | ).image; 19 | }; 20 | 21 | export default generateUsingVercel; 22 | -------------------------------------------------------------------------------- /src/pages/api/preview/leader.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { Username } from '@/components/templates/index'; 4 | 5 | export const config = { 6 | runtime: 'edge' 7 | }; 8 | 9 | const data: Username = { 10 | username: 'jayantbh' 11 | }; 12 | 13 | const generateUsingVercel = async () => { 14 | return (await createImageUsingVercel(data, CardTypes.LEADER, 'browser')) 15 | .image; 16 | }; 17 | 18 | export default generateUsingVercel; 19 | -------------------------------------------------------------------------------- /src/pages/api/preview/ninja.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { ZenNinjaData } from '../../../components/templates/ZenNinja'; 4 | import { Username } from '@/components/templates'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: ZenNinjaData & Username = { 11 | trends: [48, 69, 9, 8, 5, 7, 1, 67, 43, 10, 12, 7], 12 | username: 'jayantbh' 13 | }; 14 | 15 | const generateUsingVercel = async () => { 16 | return (await createImageUsingVercel(data, CardTypes.ZEN_OR_NINJA, 'browser')) 17 | .image; 18 | }; 19 | 20 | export default generateUsingVercel; 21 | -------------------------------------------------------------------------------- /src/pages/api/preview/oss.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { OSSContribsData } from '@/components/templates/OSSContribs'; 4 | import { Username } from '@/components/templates'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: OSSContribsData & Username = { 11 | // up and down values for the graph 12 | contribs: [ 13 | { 14 | contributions_count: 20, 15 | org_name: 'middlewarehq', 16 | repo_name: 'web-manager-dash', 17 | org_avatar_url: 'https://github.com/middlewarehq.png' 18 | }, 19 | { 20 | contributions_count: 10, 21 | org_name: 'middlewarehq', 22 | repo_name: 'monorepo', 23 | org_avatar_url: 'https://github.com/middlewarehq.png' 24 | }, 25 | { 26 | contributions_count: 5, 27 | org_name: 'middlewarehq', 28 | repo_name: 'unwrapped', 29 | org_avatar_url: 'https://github.com/middlewarehq.png' 30 | } 31 | ], 32 | username: 'jayantbh' 33 | }; 34 | 35 | const generateUsingVercel = async () => { 36 | return ( 37 | await createImageUsingVercel(data, CardTypes.OSS_CONTRIBUTION, 'browser') 38 | ).image; 39 | }; 40 | 41 | export default generateUsingVercel; 42 | -------------------------------------------------------------------------------- /src/pages/api/preview/pioneer.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { Username } from '@/components/templates/index'; 4 | 5 | export const config = { 6 | runtime: 'edge' 7 | }; 8 | 9 | const data: Username = { 10 | username: 'jayantbh' 11 | }; 12 | 13 | const generateUsingVercel = async () => { 14 | return (await createImageUsingVercel(data, CardTypes.PIONEER, 'browser')) 15 | .image; 16 | }; 17 | 18 | export default generateUsingVercel; 19 | -------------------------------------------------------------------------------- /src/pages/api/preview/streak.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { StreakData } from '../../../components/templates/Streak'; 4 | import { Username } from '@/components/templates'; 5 | 6 | export const config = { 7 | runtime: 'edge' 8 | }; 9 | 10 | const data: StreakData & Username = { 11 | streak: 50, 12 | username: 'jayantbh' 13 | }; 14 | 15 | const generateUsingVercel = async () => { 16 | return ( 17 | await createImageUsingVercel(data, CardTypes.CONTRIBUTION_STREAK, 'browser') 18 | ).image; 19 | }; 20 | 21 | export default generateUsingVercel; 22 | -------------------------------------------------------------------------------- /src/pages/api/preview/timebased/allday.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../../types/cards'; 3 | 4 | export const config = { 5 | runtime: 'edge' 6 | }; 7 | 8 | const data = { 9 | prsDuringDay: 2000, 10 | totalPrs: 4000, 11 | username: 'jayantbh', 12 | productiveDay: 'monday', 13 | productiveHour: 8 14 | }; 15 | 16 | const generateUsingVercel = async () => { 17 | return ( 18 | await createImageUsingVercel(data, CardTypes.DAY_NIGHT_CYCLE, 'browser') 19 | ).image; 20 | }; 21 | 22 | export default generateUsingVercel; 23 | -------------------------------------------------------------------------------- /src/pages/api/preview/timebased/day.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../../types/cards'; 3 | 4 | export const config = { 5 | runtime: 'edge' 6 | }; 7 | 8 | const data = { 9 | prsDuringDay: 3000, 10 | totalPrs: 4000, 11 | username: 'jayantbh', 12 | productiveDay: 'tuesday', 13 | productiveHour: 8 14 | }; 15 | 16 | const generateUsingVercel = async () => { 17 | return ( 18 | await createImageUsingVercel(data, CardTypes.DAY_NIGHT_CYCLE, 'browser') 19 | ).image; 20 | }; 21 | 22 | export default generateUsingVercel; 23 | -------------------------------------------------------------------------------- /src/pages/api/preview/timebased/night.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../../types/cards'; 3 | 4 | export const config = { 5 | runtime: 'edge' 6 | }; 7 | 8 | const data = { 9 | prsDuringDay: 1000, 10 | totalPrs: 4000, 11 | productiveHour: 19, 12 | productiveDay: 'tuesday', 13 | username: 'eshaan-yadav' 14 | }; 15 | 16 | const generateUsingVercel = async () => { 17 | return ( 18 | await createImageUsingVercel(data, CardTypes.DAY_NIGHT_CYCLE, 'browser') 19 | ).image; 20 | }; 21 | 22 | export default generateUsingVercel; 23 | -------------------------------------------------------------------------------- /src/pages/api/preview/zen.tsx: -------------------------------------------------------------------------------- 1 | import { createImageUsingVercel } from '@/api-helpers/vercel-generator'; 2 | import { CardTypes } from '../../../types/cards'; 3 | import { ZenNinjaData } from '../../../components/templates/ZenNinja'; 4 | import { Username } from '@/components/templates'; 5 | import { randInt } from '@/utils/number'; 6 | 7 | export const config = { 8 | runtime: 'edge' 9 | }; 10 | 11 | const data: ZenNinjaData & Username = { 12 | trends: Array.from({ length: 12 }, () => randInt(-1, 1) + 20), 13 | username: 'jayantbh' 14 | }; 15 | 16 | const generateUsingVercel = async () => { 17 | return (await createImageUsingVercel(data, CardTypes.ZEN_OR_NINJA, 'browser')) 18 | .image; 19 | }; 20 | 21 | export default generateUsingVercel; 22 | -------------------------------------------------------------------------------- /src/pages/api/shared/[username]/[cardname]/[...hash].ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { fetchSavedCard } from '@/api-helpers/persistance'; 4 | import { bcryptGen } from '@/utils/stringHelpers'; 5 | 6 | const fetchAndDownloadImageBuffer = async ( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) => { 10 | let username = req.query.username as string; 11 | let cardName = req.query.cardname as string; 12 | 13 | const generatedHash = bcryptGen(username + cardName); 14 | const linkHash = (req.query.hash as string[]).join('/') as string; 15 | 16 | if (generatedHash !== linkHash) { 17 | return res.status(400).json({ 18 | message: 'Invalid parameters, must pass valid hash.' 19 | }); 20 | } 21 | 22 | if (!username) { 23 | return res.status(400).json({ 24 | message: 'Invalid parameters, must pass valid username.' 25 | }); 26 | } 27 | 28 | try { 29 | const cachedCardBuffer = await fetchSavedCard(username, cardName, false); 30 | 31 | if (!cachedCardBuffer) { 32 | return res.status(404).json({ 33 | message: `Card not found for user ${username} and card name ${cardName}` 34 | }); 35 | } 36 | 37 | res.setHeader('Content-Type', 'image/jpeg'); 38 | res.setHeader('Content-Length', cachedCardBuffer.data.length); 39 | res.send(cachedCardBuffer.data); 40 | 41 | console.info(chalk.green('Successfully sent buffer to client')); 42 | } catch (error: any) { 43 | console.info(chalk.red('Error fetching or sending buffer:'), error); 44 | res.status(error.status || 500).json({ message: error.message }); 45 | } 46 | }; 47 | 48 | export default fetchAndDownloadImageBuffer; 49 | -------------------------------------------------------------------------------- /src/pages/api/shared/public/[username]/[cardname]/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { fetchSavedCard } from '@/api-helpers/persistance'; 4 | 5 | const fetchAndDownloadImageBuffer = async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) => { 9 | let username = req.query.username as string; 10 | let cardName = req.query.cardname as string; 11 | 12 | if (!username) { 13 | return res.status(400).json({ 14 | message: 'Invalid parameters, must pass valid username.' 15 | }); 16 | } 17 | 18 | try { 19 | const cachedCardBuffer = await fetchSavedCard(username, cardName, true); 20 | 21 | if (!cachedCardBuffer) { 22 | return res.status(404).json({ 23 | message: `Card not found for user ${username} and card name ${cardName}` 24 | }); 25 | } 26 | 27 | res.setHeader('Content-Type', 'image/jpeg'); 28 | res.setHeader('Content-Length', cachedCardBuffer.data.length); 29 | res.send(cachedCardBuffer.data); 30 | 31 | console.info(chalk.green('Successfully sent buffer to client')); 32 | } catch (error: any) { 33 | console.info(chalk.red('Error fetching or sending buffer:'), error); 34 | res.status(error.status || 500).json({ message: error.message }); 35 | } 36 | }; 37 | 38 | export default fetchAndDownloadImageBuffer; 39 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { major } from '@/styles/fonts'; 2 | import { MouseScrollAnim } from '@/components/MouseScrollAnim/MouseScrollAnim'; 3 | import { useEffect, useState } from 'react'; 4 | import dynamic from 'next/dynamic'; 5 | 6 | import LogoSvg from '@/assets/unwrapped-logo.svg'; 7 | import { useTrackingConsent } from '@/components/TrackingConsent'; 8 | import { IndexPageSection } from '@/components/IndexPageSection'; 9 | import { TrustNotice } from '@/components/TrustNotice'; 10 | import { ThrownCards } from '@/components/ThrownCards'; 11 | import { Description } from '@/components/Description'; 12 | import { AuthActions } from '@/components/AuthActions'; 13 | 14 | const PopDevsMasonry = dynamic(() => 15 | import('@/components/PopDevsMasonry').then((m) => m.PopDevsMasonry) 16 | ); 17 | 18 | const TRACKING_CONSENT_BANNER_DELAY = 2000; 19 | 20 | export default function Home() { 21 | const [showTrackingBanner, setShowTrackingBanner] = useState(false); 22 | const showTrackingConsent = useTrackingConsent(); 23 | 24 | useEffect(() => { 25 | if (!showTrackingBanner) return; 26 | showTrackingConsent(); 27 | }, [showTrackingBanner, showTrackingConsent]); 28 | 29 | useEffect(() => { 30 | const timeoutId = setTimeout( 31 | () => setShowTrackingBanner(true), 32 | TRACKING_CONSENT_BANNER_DELAY 33 | ); 34 | return () => clearTimeout(timeoutId); 35 | }, []); 36 | 37 | return ( 38 |
39 | 48 | 49 | 50 |
51 | 55 | UNWRAPPED 56 | 57 | 61 | 2023 62 | 63 | 64 | 65 | 70 | Unwrapped by Middleware - Your Dev year 2023 unwrapped | Product Hunt 77 | 78 |
79 |
80 | 81 |
82 |
83 | 84 | 85 | 86 | 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/pages/previews.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CARD_HEIGHT, CARD_WIDTH } from '../constants/general'; 3 | import Image from 'next/image'; 4 | import { ShareButton } from '@/components/ShareButton'; 5 | import { useIsClient } from 'usehooks-ts'; 6 | 7 | const Previews = () => { 8 | const isClient = useIsClient(); 9 | 10 | const links = [ 11 | `/api/preview/intro`, 12 | `/api/preview/timebased/allday`, 13 | `/api/preview/timebased/night`, 14 | `/api/preview/timebased/day`, 15 | `/api/preview/guardian`, 16 | `/api/preview/authoredReviewed`, 17 | `/api/preview/dependants`, 18 | `/api/preview/contributions`, 19 | `/api/preview/zen`, 20 | `/api/preview/ninja`, 21 | `/api/preview/streak`, 22 | `/api/preview/codeReviewers`, 23 | `/api/preview/oss` 24 | ]; 25 | 26 | if (!isClient) return; 27 | 28 | return ( 29 |
30 | 34 | Download all 35 | 36 |
37 | {links.map((link, index) => { 38 | return ( 39 | 53 | ); 54 | })} 55 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default Previews; 62 | -------------------------------------------------------------------------------- /src/pages/view/public/[username].tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import type { GetServerSideProps } from 'next'; 3 | import { handleRequest } from '@/utils/axios'; 4 | import { LoaderWithFacts } from '@/components/LoaderWithFacts'; 5 | import SwiperCarousel from '@/components/SwiperCarousel'; 6 | import { useImageDownloader } from '@/hooks/useImageDownloader'; 7 | import Confetti from 'react-confetti'; 8 | import { ImageJson } from '@/types/images'; 9 | import { useRouter } from 'next/router'; 10 | import { NextSeo } from 'next-seo'; 11 | import Link from 'next/link'; 12 | import { track } from '@/constants/events'; 13 | import { usePrebuiltToasts } from '@/hooks/usePrebuiltToasts'; 14 | 15 | export default function StatsUnwrapped() { 16 | const router = useRouter(); 17 | const { noImagesToast, invalidUrlToast } = usePrebuiltToasts(); 18 | const [isLoading, setIsLoading] = useState(true); 19 | const downloadImage = useImageDownloader(); 20 | 21 | const userName = router.query.username as string; 22 | const [images, setImages] = useState(null); 23 | const [shareAll, setShareAll] = useState(''); 24 | 25 | useEffect(() => { 26 | if (!userName) return; 27 | setIsLoading(true); 28 | handleRequest<{ 29 | shareAllUrl: string; 30 | data: ImageJson[]; 31 | }>('/api/download', { 32 | method: 'GET', 33 | params: { 34 | username: userName, 35 | ispublic: true 36 | } 37 | }) 38 | .then((res) => { 39 | if (!res.data.length) { 40 | router.replace('/'); 41 | return noImagesToast(); 42 | } 43 | const imageData: ImageJson[] = res.data.map((image) => ({ 44 | url: `${process.env.NEXT_PUBLIC_APP_URL}${image.url}`, 45 | fileName: image.fileName, 46 | data: image.data 47 | })); 48 | setImages(imageData); 49 | setShareAll(process.env.NEXT_PUBLIC_APP_URL + res.shareAllUrl); 50 | }) 51 | .catch((_) => { 52 | router.replace('/'); 53 | return invalidUrlToast(); 54 | }) 55 | .finally(() => { 56 | setIsLoading(false); 57 | }); 58 | }, [userName, router, noImagesToast, invalidUrlToast]); 59 | 60 | const Header = () => ( 61 | <> 62 | 80 | 81 | ); 82 | 83 | if (isLoading) { 84 | return ( 85 | <> 86 |
87 |
88 | 89 |
90 | 91 | ); 92 | } 93 | return ( 94 | <> 95 |
96 |
97 |
98 |

99 | Unwrap{' '} 100 | {userName}'s{' '} 101 | GitHub journey of 2023! 🎉 102 |

103 |
104 | {images?.length && } 105 | {images?.length && ( 106 |
107 | 114 |
115 | )} 116 | 117 | track('WISH_TO_CREATE_YOUR_OWN_CLICKED')} 120 | > 121 | Wish to create another? Click here {'->'} 122 | 123 | 124 | 129 | Unwrapped by Middleware - Your Dev year 2023 unwrapped | Product Hunt 136 | 137 |
138 | 139 | ); 140 | } 141 | 142 | export const getServerSideProps = (async (_ctx) => { 143 | return { props: {} }; 144 | }) satisfies GetServerSideProps; 145 | -------------------------------------------------------------------------------- /src/styles/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter, Major_Mono_Display } from 'next/font/google'; 2 | 3 | const interFont = Inter({ subsets: ['latin'] }); 4 | const majorFont = Major_Mono_Display({ subsets: ['latin'], weight: '400' }); 5 | 6 | export const inter = interFont.className; 7 | 8 | export const major = majorFont.className; 9 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: #14183B; 22 | } 23 | 24 | @keyframes spin { 25 | 0% { transform: rotate(0deg); } 26 | 100% { transform: rotate(360deg); } 27 | } -------------------------------------------------------------------------------- /src/styles/swiper.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --swiper-navigation-color: #9333ea; 3 | --swiper-pagination-bullet-inactive-color: #cccccc; 4 | --swiper-pagination-color: #9333ea; 5 | } 6 | 7 | .swiper-container { 8 | position: relative; 9 | width: calc(100% + 32px); 10 | margin: 0 auto; 11 | margin-left: -16px; 12 | display: flex; 13 | align-items: center; 14 | min-height: 60vh; 15 | } 16 | 17 | .swiper-container .swiper { 18 | padding: 1.25rem; 19 | width: 100%; 20 | } 21 | 22 | .swiper-slide-img { 23 | position: relative; 24 | width: 100%; 25 | overflow: hidden; 26 | aspect-ratio: 2/3; 27 | border-radius: 1rem; 28 | } 29 | 30 | .email-input-card { 31 | background-color: #a083f7; 32 | background-image: url(/assets/images/reflection.svg); 33 | } 34 | 35 | .swiper-slide-img img { 36 | width: 100%; 37 | height: 100%; 38 | position: absolute; 39 | inset: 0; 40 | object-fit: cover; 41 | z-index: -1; 42 | transition: 0.3s ease-in-out; 43 | border-radius: 1rem; 44 | } 45 | 46 | .swiper-pagination { 47 | position: relative !important; 48 | margin-top: 10px!important; 49 | } 50 | .swiper-pagination-bullet { 51 | border-radius: 0!important; 52 | width: 1.5rem!important; 53 | height: 0.25rem!important; 54 | } 55 | 56 | .prev-arrow, .next-arrow, .share-active-image { 57 | visibility: hidden; 58 | } 59 | 60 | .swiper-slide-active { 61 | position: relative; 62 | overflow: visible; 63 | } 64 | 65 | .swiper-slide-active .prev-arrow, .swiper-slide-active .next-arrow, .swiper-slide-active .share-active-image { 66 | visibility:visible; 67 | position: absolute; 68 | filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4)); 69 | } 70 | 71 | .prev-arrow, .next-arrow{ 72 | top: 50%; 73 | padding: 0.05rem; 74 | background-color: white; 75 | border-radius: 50%; 76 | border-width: 0.2rem; 77 | border-style: solid; 78 | border-color: #14183B; 79 | fill: #14183b; 80 | z-index: 1000; 81 | position: absolute; 82 | 83 | } 84 | 85 | 86 | .share-active-image{ 87 | top:-2.5%; 88 | right: -5%; 89 | padding: 0.4rem; 90 | background-color: white; 91 | border-radius: 50%; 92 | } 93 | 94 | @media screen and (min-width: 93.75rem) { 95 | .swiper { 96 | width: 85%; 97 | } 98 | } 99 | 100 | -------------------------------------------------------------------------------- /src/types/api-responses.ts: -------------------------------------------------------------------------------- 1 | import { GithubUser } from '@/api-helpers/exapi-sdk/types'; 2 | 3 | export type GithubData = { 4 | data: { 5 | name: string; 6 | username: string; 7 | year: number; 8 | avatar: string; 9 | total_contributions: number; 10 | contribution_percentile: number; 11 | global_contributions: number; 12 | contribution_streak: number; 13 | commit_history: { 14 | january: number; 15 | february: number; 16 | march: number; 17 | april: number; 18 | may: number; 19 | june: number; 20 | july: number; 21 | august: number; 22 | september: number; 23 | october: number; 24 | november: number; 25 | }; 26 | commits_during_day: number; 27 | total_commits: number; 28 | frequent_reviewers_and_reviewees: string[]; 29 | prs_with_revert: number; 30 | reviewed_prs_not_reverted: number; 31 | frequent_reviewers: string[]; 32 | reviewer_lag: number; 33 | reviewee_lag: number; 34 | authored_prs: number; 35 | reviewed_prs: number; 36 | prod_breaks: number; 37 | oss_contributions: number; 38 | reviewed_prs_with_requested_changes_count: number; 39 | }; 40 | }; 41 | 42 | export type Plan = { 43 | name: string; 44 | space: number; 45 | collaborators: number; 46 | private_repos: number; 47 | }; 48 | 49 | export type GitHubDataResponse = { 50 | user: GithubUser; 51 | authored_monthly_pr_counts: number[]; 52 | reviewed_monthly_pr_counts: number[]; 53 | total_contributions: number; 54 | total_additions: number; 55 | total_deletions: number; 56 | top_reviewed_contributors: string[]; 57 | top_reviewers: string[]; 58 | monthly_contributions: Record; 59 | weekly_contributions: number[]; 60 | longest_streak: number; 61 | oss_contributions: GithubRepositoryContributionData[]; 62 | prs_opened_during_day: number; 63 | prs_opened_during_night: number; 64 | contribution_percentile: number; 65 | global_contributions: number; 66 | reviewed_prs_with_requested_changes_count: number; 67 | total_commit_contributions?: number; 68 | total_pr_contributions?: number; 69 | total_review_contributions?: number; 70 | total_issue_contributions?: number; 71 | hour_with_max_opened_prs?: number; 72 | weekday_with_max_opened_prs?: string; 73 | }; 74 | 75 | export type GithubRepositoryContributionData = { 76 | org_name: string; 77 | repo_name: string; 78 | org_avatar_url?: string; 79 | contributions_count: number; 80 | }; 81 | 82 | export type UserImprovementMetrics = { 83 | first_response_time_sum: number; 84 | rework_time_sum: number; 85 | }; 86 | -------------------------------------------------------------------------------- /src/types/cards.ts: -------------------------------------------------------------------------------- 1 | export enum CardTypes { 2 | UNWRAPPED_INTRO = 'UNWRAPPED_INTRO', 3 | YOUR_CONTRIBUTIONS = 'YOUR_CONTRIBUTIONS', 4 | CONTRIBUTION_STREAK = 'CONTRIBUTION_STREAK', 5 | ZEN_OR_NINJA = 'ZEN_OR_NINJA', 6 | DAY_NIGHT_CYCLE = 'DAY_NIGHT_CYCLE', 7 | IT_TAKES_A_VILLAGE = 'IT_TAKES_A_VILLAGE', 8 | GUARDIAN_OF_PROD = 'GUARDIAN_OF_PROD', 9 | TOP_REVIEWERS = 'TOP_REVIEWERS', 10 | PR_TIME_LAGS = 'PR_TIME_LAGS', 11 | PR_REVIEWED_VS_AUTHORED = 'PR_REVIEWED_VS_AUTHORED', 12 | PRODUCTION_BREAKING = 'PRODUCTION_BREAKING', 13 | OSS_CONTRIBUTION = 'OSS_CONTRIBUTION', 14 | PIONEER = 'PIONEER', 15 | LEADER = 'LEADER' 16 | } 17 | 18 | export const sequence = [ 19 | CardTypes.UNWRAPPED_INTRO, 20 | CardTypes.YOUR_CONTRIBUTIONS, 21 | CardTypes.CONTRIBUTION_STREAK, 22 | CardTypes.DAY_NIGHT_CYCLE, 23 | CardTypes.ZEN_OR_NINJA, 24 | CardTypes.IT_TAKES_A_VILLAGE, 25 | CardTypes.GUARDIAN_OF_PROD, 26 | CardTypes.TOP_REVIEWERS, 27 | CardTypes.PR_TIME_LAGS, 28 | CardTypes.PR_REVIEWED_VS_AUTHORED, 29 | CardTypes.PRODUCTION_BREAKING, 30 | CardTypes.OSS_CONTRIBUTION 31 | ]; 32 | -------------------------------------------------------------------------------- /src/types/images.ts: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from '@vercel/og'; 2 | 3 | export type ImageJson = { 4 | fileName: string; 5 | data: string; 6 | url: string; 7 | }; 8 | 9 | export type ImagesWithBuffers = { 10 | fileName: string; 11 | data: Buffer; 12 | image?: ImageResponse; 13 | }; 14 | 15 | export type ImagesWithB64 = { 16 | fileName: string; 17 | data: string; 18 | image?: ImageResponse; 19 | }; 20 | 21 | export type UpdatedImageFile = { 22 | fileName: string; 23 | data: string; 24 | url: string; 25 | }; 26 | 27 | export type ImageAPIResponse = { 28 | data: UpdatedImageFile[]; 29 | shareAllUrl: string; 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import { logException } from '@/utils/logger'; 3 | 4 | export const internal = axios.create({ 5 | baseURL: process.env.INTERNAL_API_BASE_URL 6 | }); 7 | 8 | export const handleRequest = ( 9 | url: string, 10 | params: AxiosRequestConfig = { method: 'get' } 11 | ): Promise => 12 | internal({ 13 | url, 14 | ...params, 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | 'x-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone 18 | } 19 | }) 20 | .then(handleThen) 21 | .catch(handleCatch); 22 | 23 | export const handleThen = (r: AxiosResponse) => r.data; 24 | 25 | export const handleCatch = (err: AxiosError) => { 26 | logException(err); 27 | throw err.response; 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/datatype.ts: -------------------------------------------------------------------------------- 1 | export const isObj = (o: any) => o?.constructor === Object; 2 | 3 | export const flattenObj = (obj: any, parentKey = '', map = {}) => { 4 | if (!isObj(obj)) return obj; 5 | 6 | for (let key in obj) { 7 | if (isObj(obj[key])) flattenObj(obj[key], `${parentKey}${key}__`, map); 8 | // @ts-ignore 9 | else map[`${parentKey}${key}`] = obj[key]; 10 | } 11 | 12 | return map; 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/datetime.ts: -------------------------------------------------------------------------------- 1 | import { secondsToMinutes, secondsToHours } from 'date-fns'; 2 | import pluralize from 'pluralize'; 3 | 4 | export const getDurationString = ( 5 | secs?: number, 6 | options: { 7 | placeHolderTxtForNullValue?: string; 8 | /** How many parts of the time to show? At least 1 will always be shown. Max will be shown in the time. */ 9 | segments?: number; 10 | /** If time is less than 60s, it'll return `< 1m`, else it'll return `42s` or something */ 11 | showLt60AsSecs?: boolean; 12 | longForm?: boolean; 13 | } = {} 14 | ) => { 15 | options = Object.assign( 16 | { segments: 2, showLt60AsSecs: true } as typeof options, 17 | options 18 | ); 19 | 20 | const segments = Number(options?.segments); 21 | const longForm = Boolean(options?.longForm); 22 | 23 | if (!secs || secs < 0) return options.placeHolderTxtForNullValue || null; 24 | if (secs < 60) 25 | return options?.showLt60AsSecs ? `${Math.ceil(secs)}s` : '< 1m'; 26 | 27 | const mins = secondsToMinutes(secs); 28 | if (!mins) return null; 29 | const remainingSecs = Math.floor(secs % 60); 30 | const remainingSecsText = longForm 31 | ? `${remainingSecs} ${pluralize('second', remainingSecs)}` 32 | : 's'; 33 | 34 | if (mins < 60) 35 | return [`${mins}${longForm ? ` ${pluralize('minute', mins)}` : 'm'}`] 36 | .concat(segments > 1 && remainingSecs ? remainingSecsText : '') 37 | .filter(Boolean) 38 | .join(longForm ? ', ' : ' '); 39 | 40 | const hours = secondsToHours(secs); 41 | const remainingMins = Math.floor(mins % 60); 42 | const remainingMinsText = longForm 43 | ? `${remainingMins} ${pluralize('minute', remainingMins)}` 44 | : 'm'; 45 | 46 | if (hours < 24) 47 | return [`${hours}${longForm ? ` ${pluralize('hour', hours)}` : 'h'}`] 48 | .concat(segments > 1 && remainingMins ? remainingMinsText : '') 49 | .concat(segments > 2 && remainingSecs ? remainingSecsText : '') 50 | .filter(Boolean) 51 | .join(longForm ? ', ' : ' '); 52 | 53 | const days = Math.floor(hours / 24); 54 | const remainingHours = hours % 24; 55 | const remainingHoursText = longForm 56 | ? `${remainingHours} ${pluralize('hour', remainingHours)}` 57 | : 'h'; 58 | 59 | if (days < 7) { 60 | return [`${days}${longForm ? ` ${pluralize('day', days)}` : 'd'}`] 61 | .concat(segments > 1 && remainingHours ? remainingHoursText : '') 62 | .concat(segments > 2 && remainingMins ? remainingMinsText : '') 63 | .concat(segments > 3 && remainingSecs ? remainingSecsText : '') 64 | .filter(Boolean) 65 | .join(longForm ? ', ' : ' '); 66 | } 67 | 68 | const weeks = Math.floor(days / 7); 69 | const remainingDays = days % 7; 70 | const remainingDaysText = longForm 71 | ? `${remainingDays} ${pluralize('day', remainingDays)}` 72 | : 'h'; 73 | 74 | return [`${weeks}${longForm ? ` ${pluralize('week', weeks)}` : 'w'}`] 75 | .concat(segments > 1 && remainingDays ? remainingDaysText : '') 76 | .concat(segments > 2 && remainingHours ? remainingHoursText : '') 77 | .concat(segments > 3 && remainingMins ? remainingMinsText : '') 78 | .concat(segments > 4 && remainingSecs ? remainingSecsText : '') 79 | .filter(Boolean) 80 | .join(longForm ? ', ' : ' '); 81 | }; 82 | -------------------------------------------------------------------------------- /src/utils/enum.ts: -------------------------------------------------------------------------------- 1 | export const objectEnum = (EnumArg: EnumT) => { 2 | type EnumKeys = keyof typeof EnumArg; 3 | // @ts-ignore 4 | return Object.keys(EnumArg).reduce( 5 | // @ts-ignore 6 | (obj, key) => ({ ...obj, [key]: key }), 7 | {} as { [Property in EnumKeys]: Property } 8 | ); 9 | }; 10 | 11 | export const objectEnumFromFn = (enumFn: () => EnumT) => 12 | objectEnum(enumFn()); 13 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export class ResponseError extends Error { 2 | status: number; 3 | constructor(msg: string, status?: number) { 4 | super(); 5 | this.name = 'ResponseError'; 6 | this.status = status || 400; 7 | this.message = msg; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { captureException } from '@sentry/nextjs'; 2 | import { toString } from 'ramda'; 3 | 4 | type CaptureCtx = Parameters[1]; 5 | 6 | type CaptureExc = ReturnType; 7 | 8 | export function logException(exc: string, message?: string): CaptureExc; 9 | 10 | export function logException(exc: Error, message?: string): CaptureExc; 11 | 12 | export function logException(exc: string, context?: CaptureCtx): CaptureExc; 13 | 14 | export function logException(exc: Error, context?: CaptureCtx): CaptureExc; 15 | 16 | export function logException( 17 | exc: string | Error, 18 | msgOrCtx?: string | CaptureCtx, 19 | ctx?: CaptureCtx 20 | ): CaptureExc | void { 21 | const message = typeof msgOrCtx === 'string' ? msgOrCtx : undefined; 22 | const context = typeof msgOrCtx === 'object' ? msgOrCtx : ctx; 23 | const name = (() => { 24 | try { 25 | return typeof exc === 'string' 26 | ? exc 27 | : exc instanceof Error 28 | ? exc.message 29 | : toString(exc); 30 | } catch (e) { 31 | return 'Something went wrong (even while trying to process the error)'; 32 | } 33 | })(); 34 | const stack = (() => { 35 | try { 36 | return exc instanceof Error ? exc.stack : undefined; 37 | } catch (e) { 38 | return 'Something went wrong (even while trying to process the error)'; 39 | } 40 | })(); 41 | 42 | const err = new Error(message || name); 43 | err.name = name; 44 | if (stack) err.stack = stack; 45 | 46 | if (process.env.NEXT_PUBLIC_APP_ENVIRONMENT !== 'production') 47 | return console.error(err); 48 | 49 | return captureException(err, { 50 | ...context, 51 | extra: { 52 | // @ts-ignore because sentry wasn't exporting the correct types to make this possible otherwise 53 | ...context?.extra, 54 | internal_original_exception: exc, 55 | ...(exc instanceof Error 56 | ? { 57 | internal_original_exception_name: exc.name, 58 | internal_original_exception_message: exc.message, 59 | internal_original_exception_stack: exc.stack, 60 | stack 61 | } 62 | : {}) 63 | } 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/number.ts: -------------------------------------------------------------------------------- 1 | export function randInt(num1: number, num2: number = 0): number { 2 | if (num1 > num2) { 3 | const temp = num1; 4 | num1 = num2; 5 | num2 = temp; 6 | } 7 | 8 | return Math.floor(Math.random() * (num2 - num1 + 1)) + num1; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/persistence/file-system.ts: -------------------------------------------------------------------------------- 1 | import { ImagesWithBuffers } from '@/types/images'; 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import { logException } from '../logger'; 5 | 6 | async function directoryExists(localDirectory: string): Promise { 7 | try { 8 | const stats = await fs.stat(localDirectory); 9 | return stats.isDirectory(); 10 | } catch (error) { 11 | return false; 12 | } 13 | } 14 | 15 | async function ensureDirectoryExists(localDirectory: string) { 16 | try { 17 | await fs.mkdir(localDirectory, { recursive: true }); 18 | console.info(`Directory created: ${localDirectory}`); 19 | } catch (error: any) { 20 | if (error.code === 'EEXIST') { 21 | console.error(`Directory already exists: ${localDirectory}`); 22 | } else { 23 | logException(`Error creating directory: ${error.message}`, { 24 | originalException: error 25 | }); 26 | } 27 | } 28 | } 29 | 30 | export const saveImagesToLocalDirectory = async ( 31 | localDirectory: string, 32 | imageFiles: ImagesWithBuffers[] 33 | ): Promise => { 34 | try { 35 | await ensureDirectoryExists(localDirectory); 36 | 37 | const savePromises = imageFiles.map(async (imageFile) => { 38 | const filePath = path.join(localDirectory, imageFile.fileName); 39 | await fs.writeFile(filePath, imageFile.data); 40 | }); 41 | 42 | await Promise.all(savePromises); 43 | } catch (error: any) { 44 | logException(`Error saving images to local directory: ${error.message}`, { 45 | originalException: error 46 | }); 47 | throw new Error(`Error saving images to local directory: ${error.message}`); 48 | } 49 | }; 50 | 51 | export const fetchImagesFromLocalDirectory = async ( 52 | localDirectory: string 53 | ): Promise => { 54 | try { 55 | await ensureDirectoryExists(localDirectory); 56 | const files = await fs.readdir(localDirectory); 57 | const images: ImagesWithBuffers[] = await Promise.all( 58 | files.map(async (fileName) => { 59 | const filePath = path.join(localDirectory, fileName); 60 | const data = await fs.readFile(filePath); 61 | return { 62 | fileName, 63 | data 64 | }; 65 | }) 66 | ); 67 | 68 | return images; 69 | } catch (error: any) { 70 | logException( 71 | `Error fetching images from local directory: ${error.message}`, 72 | { originalException: error } 73 | ); 74 | throw new Error( 75 | `Error fetching images from local directory: ${error.message}` 76 | ); 77 | } 78 | }; 79 | 80 | export const fetchImageFromLocalDirectory = async ( 81 | filePath: string 82 | ): Promise => { 83 | try { 84 | const data = await fs.readFile(filePath); 85 | return { 86 | fileName: filePath, 87 | data 88 | }; 89 | } catch (error: any) { 90 | logException( 91 | `Error fetching single image from local directory: ${error.message}`, 92 | { originalException: error } 93 | ); 94 | throw new Error( 95 | `Error fetching single image from local directory: ${error.message}` 96 | ); 97 | } 98 | }; 99 | 100 | export const deleteLocalDirectory = async ( 101 | localDirectory: string 102 | ): Promise => { 103 | try { 104 | const directory_exists = await directoryExists(localDirectory); 105 | if (!directory_exists) return; 106 | 107 | await fs.rmdir(localDirectory, { recursive: true }); 108 | } catch (error: any) { 109 | logException(`Error deleting local directory: ${error.message}`, { 110 | originalException: error 111 | }); 112 | throw new Error(`Error deleting local directory: ${error.message}`); 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /src/utils/persistence/s3.ts: -------------------------------------------------------------------------------- 1 | import { ImagesWithBuffers } from '@/types/images'; 2 | import { S3 } from 'aws-sdk'; 3 | import { logException } from '../logger'; 4 | 5 | const s3 = new S3({ 6 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 7 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 8 | region: process.env.AWS_REGION 9 | }); 10 | 11 | export const uploadImagesToS3 = async ( 12 | bucketName: string, 13 | prefix: string, 14 | imageFiles: ImagesWithBuffers[] 15 | ): Promise => { 16 | const uploadPromises = imageFiles.map(async (imageFile) => { 17 | const uploadParams = { 18 | Bucket: bucketName, 19 | Key: `${prefix}/${imageFile.fileName}`, 20 | Body: imageFile.data, 21 | Prefix: prefix 22 | }; 23 | 24 | try { 25 | await s3.upload(uploadParams).promise(); 26 | } catch (error: any) { 27 | logException(`Error uploading image to S3: ${error.message}`, { 28 | originalException: error 29 | }); 30 | throw new Error(`Error uploading image to S3: ${error.message}`); 31 | } 32 | }); 33 | 34 | await Promise.all(uploadPromises); 35 | }; 36 | 37 | export const fetchImagesFromS3Directory = async ( 38 | bucketName: string, 39 | directory: string 40 | ): Promise => { 41 | const listObjectsParams = { 42 | Bucket: bucketName, 43 | Prefix: directory 44 | }; 45 | 46 | try { 47 | const data = await s3.listObjectsV2(listObjectsParams).promise(); 48 | 49 | const images: ImagesWithBuffers[] = await Promise.all( 50 | (data.Contents || []).map(async (item) => { 51 | const imageBuffer = await s3 52 | .getObject({ Bucket: bucketName, Key: item.Key || '' }) 53 | .promise(); 54 | return { 55 | fileName: item.Key || '', 56 | data: imageBuffer.Body as Buffer 57 | }; 58 | }) 59 | ); 60 | 61 | return images; 62 | } catch (error: any) { 63 | logException(`Error fetching images from S3: ${error.message}`, { 64 | originalException: error 65 | }); 66 | throw new Error(`Error fetching images from S3: ${error.message}`); 67 | } 68 | }; 69 | 70 | export const fetchFileFromS3Directory = async ( 71 | bucketName: string, 72 | key: string 73 | ): Promise => { 74 | try { 75 | const imageBuffer = await s3 76 | .getObject({ Bucket: bucketName, Key: key }) 77 | .promise(); 78 | 79 | return { 80 | fileName: key, 81 | data: imageBuffer.Body as Buffer 82 | }; 83 | } catch (error: any) { 84 | logException(`Error fetching file from S3: ${error.message}`, { 85 | originalException: error 86 | }); 87 | throw new Error(`Error fetching file from S3: ${error.message}`); 88 | } 89 | }; 90 | 91 | export const deleteS3Directory = async ( 92 | bucketName: string, 93 | directory: string 94 | ): Promise => { 95 | const listObjectsParams = { 96 | Bucket: bucketName, 97 | Prefix: directory 98 | }; 99 | 100 | try { 101 | const data = await s3.listObjectsV2(listObjectsParams).promise(); 102 | 103 | if (data.Contents && data.Contents.length > 0) { 104 | const deleteObjectsParams = { 105 | Bucket: bucketName, 106 | Delete: { 107 | Objects: data.Contents.map((item) => ({ Key: item.Key || '' })) 108 | } 109 | }; 110 | 111 | await s3.deleteObjects(deleteObjectsParams).promise(); 112 | } 113 | } catch (error: any) { 114 | logException(`Error deleting directory from S3: ${error.message}`, { 115 | originalException: error 116 | }); 117 | throw new Error(`Error deleting directory from S3: ${error.message}`); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /src/utils/stringHelpers.ts: -------------------------------------------------------------------------------- 1 | import { hashSync } from 'bcryptjs'; 2 | import { HASH_LENGTH } from '@/constants/general'; 3 | 4 | export const capitalize = (sentence?: string): string => { 5 | if (!sentence) return ''; 6 | return sentence.replace(/\b\w/g, (match) => match.toUpperCase()); 7 | }; 8 | 9 | export const bcryptGen = (username: string): string => { 10 | const salt = process.env.HASHING_SALT as string; 11 | if (!salt) return ''; 12 | const hash = hashSync(username, salt); 13 | return btoa(hash.slice(-HASH_LENGTH)); 14 | }; 15 | 16 | export const extractFilenameWithoutExtension = (input: string): string => { 17 | const parts = input.split('/'); 18 | const filenameWithExtension = parts[parts.length - 1]; 19 | const filenameParts = filenameWithExtension.split('.'); 20 | const filenameWithoutExtension = filenameParts[0]; 21 | return filenameWithoutExtension || ''; 22 | }; 23 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 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 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))' 15 | }, 16 | fontFamily: { 17 | custom: ['Inter', 'sans-serif'] 18 | } 19 | } 20 | }, 21 | plugins: [] 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "paths": { 17 | "@/*": ["./src/*"] 18 | } 19 | }, 20 | "include": [ 21 | "next-env.d.ts", 22 | "**/*.ts", 23 | "**/*.tsx", 24 | ".next/types/**/*.ts", 25 | "./**.js" 26 | ], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------