├── .env.example
├── .eslintrc.json
├── .gitignore
├── README.md
├── components.json
├── env.d.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── api
│ │ └── auth
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── not-found.tsx
│ ├── page.tsx
│ └── wrapped
│ │ ├── [username]
│ │ └── page.tsx
│ │ └── error.tsx
└── lib
│ ├── interfaces
│ └── interfaces.ts
│ └── utils.ts
├── tailwind.config.ts
├── tsconfig.json
└── wrangler.toml
/.env.example:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Kylejeong2/Github-Wrapped/c3a516427ca7b22a414028a760710e2101c3f2f2/.env.example
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "plugin:eslint-plugin-next-on-pages/recommended"
5 | ],
6 | "plugins": [
7 | "eslint-plugin-next-on-pages"
8 | ]
9 | }
--------------------------------------------------------------------------------
/.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 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # wrangler files
39 | .wrangler
40 | .dev.vars
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GitHub Wrapped
2 |
3 | See your year in review on GitHub.
4 |
5 | By Kyle Jeong
6 |
7 | Follow me on [Twitter](https://twitter.com/kylejeong21) to support!
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | // Generated by Wrangler
2 | // by running `wrangler types --env-interface CloudflareEnv env.d.ts`
3 |
4 | interface CloudflareEnv {
5 | }
6 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';
2 |
3 | // Here we use the @cloudflare/next-on-pages next-dev module to allow us to use bindings during local development
4 | // (when running the application with `next dev`), for more information see:
5 | // https://github.com/cloudflare/next-on-pages/blob/main/internal-packages/next-dev/README.md
6 | if (process.env.NODE_ENV === 'development') {
7 | await setupDevPlatform();
8 | }
9 |
10 | /** @type {import('next').NextConfig} */
11 | const nextConfig = {
12 | images: {
13 | remotePatterns: [
14 | {
15 | protocol: 'https',
16 | hostname: 'github.com',
17 | pathname: '/**',
18 | },
19 | {
20 | protocol: 'https',
21 | hostname: 'avatars.githubusercontent.com',
22 | pathname: '/**',
23 | },
24 | ],
25 | },
26 | env: {
27 | OPENAI_API_KEY: process.env.OPENAI_API_KEY,
28 | GITHUB_TOKEN: process.env.GITHUB_TOKEN,
29 | },
30 | experimental: {},
31 | };
32 |
33 | export default nextConfig;
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "github-wrapped",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "pages:build": "npx @cloudflare/next-on-pages",
11 | "preview": "npm run pages:build && wrangler pages dev",
12 | "deploy": "npm run pages:build && wrangler pages deploy",
13 | "cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts"
14 | },
15 | "dependencies": {
16 | "@octokit/rest": "^21.0.2",
17 | "class-variance-authority": "^0.7.1",
18 | "clsx": "^2.1.1",
19 | "framer-motion": "^11.13.1",
20 | "html2canvas": "^1.4.1",
21 | "lucide-react": "^0.468.0",
22 | "next": "14.2.5",
23 | "octokit": "^4.0.2",
24 | "openai": "^4.76.0",
25 | "react": "^18",
26 | "react-dom": "^18",
27 | "react-github-calendar": "^4.5.1",
28 | "react-icons": "^5.4.0",
29 | "tailwind-merge": "^2.5.5",
30 | "tailwindcss-animate": "^1.0.7"
31 | },
32 | "devDependencies": {
33 | "@cloudflare/next-on-pages": "^1.13.6",
34 | "@cloudflare/workers-types": "^4.20241205.0",
35 | "@types/node": "^20",
36 | "@types/react": "^18",
37 | "@types/react-dom": "^18",
38 | "eslint": "^8",
39 | "eslint-config-next": "14.2.5",
40 | "eslint-plugin-next-on-pages": "^1.13.6",
41 | "postcss": "^8",
42 | "tailwindcss": "^3.4.1",
43 | "typescript": "^5",
44 | "vercel": "^39.1.3",
45 | "wrangler": "^3.93.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/api/auth/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { OpenAI } from "openai";
3 | import { Octokit } from "@octokit/rest";
4 | import { GraphQLResponse, UserStats } from "@/lib/interfaces/interfaces";
5 |
6 | export const runtime = 'edge';
7 |
8 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
9 | const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
10 |
11 | if (!OPENAI_API_KEY) {
12 | throw new Error('Missing OPENAI_API_KEY environment variable');
13 | }
14 |
15 | if (!GITHUB_TOKEN) {
16 | throw new Error('Missing GITHUB_TOKEN environment variable');
17 | }
18 |
19 | const octokit = new Octokit({
20 | auth: GITHUB_TOKEN
21 | });
22 |
23 | const openai = new OpenAI({
24 | apiKey: OPENAI_API_KEY,
25 | dangerouslyAllowBrowser: true
26 | });
27 |
28 | interface PullRequest {
29 | title: string;
30 | state: string;
31 | repo: string;
32 | date: string;
33 | url: string;
34 | merged: boolean;
35 | additions: number;
36 | deletions: number;
37 | }
38 |
39 | async function generateAIAnalysis(userData: { stats: UserStats }) {
40 | const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
41 | const monthlyCommits = userData.stats.monthlyCommits;
42 | const grindingMonth = userData.stats.grindingMonth;
43 |
44 | // Pick a random month that's not the grinding month
45 | const availableMonths = Object.entries(monthlyCommits)
46 | .filter(([month]) => month !== grindingMonth && monthlyCommits[month] > 0)
47 | .map(([month]) => month);
48 | const randomMonth = availableMonths[Math.floor(Math.random() * availableMonths.length)];
49 |
50 | const prompt = `As a witty developer advocate, create 2 short, impactful observations about this GitHub user's coding activity. Be creative and fun!
51 | Here's their data for 2024:
52 | - Total Repos: ${userData.stats.totalRepos}
53 | - Total Stars: ${userData.stats.totalStars}
54 | - Top Languages: ${userData.stats.topLanguages.map(([lang, count]) => `${lang} (${count} repos)`).join(', ')}
55 | - Total Commits: ${userData.stats.totalCommits}
56 | - Most Active Repository: ${userData.stats.mostActiveRepo}
57 | - Longest Streak: ${userData.stats.longestStreak} days
58 | - Grinding Month (${monthNames[parseInt(grindingMonth!) - 1]}): ${monthlyCommits[grindingMonth!]} commits
59 |
60 | Also, describe their coding style in ${monthNames[parseInt(randomMonth) - 1]} with exactly 3 powerful, single-word adjectives.
61 |
62 | Format the response like this:
63 | [ANALYSIS]
64 | • First witty observation with an emoji
65 | • Second witty observation with an emoji
66 |
67 | [MONTH_ANALYSIS]
68 | WORD1
69 | WORD2
70 | WORD3`;
71 |
72 | const response = await openai.chat.completions.create({
73 | model: "gpt-4o-mini",
74 | messages: [{ role: "user", content: prompt }],
75 | temperature: 0.9,
76 | max_tokens: 250,
77 | });
78 |
79 | return {
80 | analysis: response.choices[0].message.content,
81 | randomMonth,
82 | };
83 | }
84 |
85 | export async function POST(request: Request) {
86 | try {
87 | const { username } = await request.json() as { username: string };
88 |
89 | if (!username) {
90 | return NextResponse.json(
91 | { error: "Username is required" },
92 | { status: 400 }
93 | );
94 | }
95 |
96 | // Check if user exists and has starred the repo
97 | const [userResponse, starredResponse] = await Promise.all([
98 | octokit.users.getByUsername({ username }),
99 | octokit.activity.checkRepoIsStarredByAuthenticatedUser({
100 | owner: "Kylejeong2",
101 | repo: "Github-Wrapped",
102 | username,
103 | }).catch(() => ({ status: 404 })),
104 | ]);
105 |
106 | if (starredResponse.status === 404) {
107 | return NextResponse.json(
108 | { error: "NOT_STARRED" },
109 | { status: 403 }
110 | );
111 | }
112 |
113 | // Get user's contribution data using GraphQL
114 | const query = `
115 | query($username: String!) {
116 | user(login: $username) {
117 | contributionsCollection {
118 | contributionCalendar {
119 | totalContributions
120 | weeks {
121 | contributionDays {
122 | contributionCount
123 | date
124 | }
125 | }
126 | }
127 | pullRequestContributions(first: 100, orderBy: {direction: DESC}) {
128 | totalCount
129 | nodes {
130 | pullRequest {
131 | title
132 | state
133 | url
134 | createdAt
135 | merged
136 | additions
137 | deletions
138 | repository {
139 | name
140 | nameWithOwner
141 | url
142 | }
143 | }
144 | }
145 | }
146 | pullRequestReviewContributions(first: 100, orderBy: {direction: DESC}) {
147 | totalCount
148 | nodes {
149 | pullRequest {
150 | title
151 | state
152 | url
153 | createdAt
154 | merged
155 | repository {
156 | name
157 | nameWithOwner
158 | }
159 | }
160 | }
161 | }
162 | }
163 | repositories(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) {
164 | nodes {
165 | name
166 | description
167 | url
168 | stargazerCount
169 | primaryLanguage {
170 | name
171 | }
172 | defaultBranchRef {
173 | target {
174 | ... on Commit {
175 | history(first: 1) {
176 | totalCount
177 | }
178 | }
179 | }
180 | }
181 | }
182 | }
183 | }
184 | }
185 | `;
186 |
187 | const graphqlResponse = await octokit.graphql(query, { username }) as GraphQLResponse;
188 | const userData = graphqlResponse.user;
189 |
190 | // Process contribution data
191 | const contributionDays = userData.contributionsCollection.contributionCalendar.weeks
192 | .flatMap((week: any) => week.contributionDays)
193 | .filter((day: any) => new Date(day.date) >= new Date('2024-01-01'));
194 |
195 | const monthlyCommits: { [key: string]: number } = {};
196 | contributionDays.forEach((day: any) => {
197 | const month = new Date(day.date).getMonth() + 1;
198 | const monthKey = month.toString().padStart(2, '0');
199 | monthlyCommits[monthKey] = (monthlyCommits[monthKey] || 0) + day.contributionCount;
200 | });
201 |
202 | // Find the grinding month
203 | const grindingMonth = Object.entries(monthlyCommits)
204 | .sort(([, a], [, b]) => b - a)[0]?.[0];
205 |
206 | // Process the data
207 | const processedData = {
208 | profile: userResponse.data,
209 | stats: {
210 | totalRepos: userData.repositories.nodes.length,
211 | totalStars: userData.repositories.nodes.reduce((acc: number, repo: any) => acc + repo.stargazerCount, 0),
212 | topLanguages: processTopLanguages(userData.repositories.nodes),
213 | commitActivity: Object.fromEntries(
214 | contributionDays.map((day: any) => [day.date, day.contributionCount])
215 | ),
216 | totalCommits: userData.contributionsCollection.contributionCalendar.totalContributions,
217 | mostActiveRepo: getMostActiveRepo(userData.repositories.nodes),
218 | longestStreak: calculateStreak(contributionDays),
219 | currentStreak: calculateCurrentStreak(contributionDays),
220 | pullRequests: {
221 | created: userData.contributionsCollection.pullRequestContributions.totalCount,
222 | reviewed: userData.contributionsCollection.pullRequestReviewContributions.totalCount,
223 | recentPRs: userData.contributionsCollection.pullRequestContributions.nodes
224 | .filter((node: any) => new Date(node.pullRequest.createdAt) >= new Date('2024-01-01'))
225 | .map((node: any) => ({
226 | title: node.pullRequest.title,
227 | state: node.pullRequest.state.toLowerCase(),
228 | repo: node.pullRequest.repository.nameWithOwner,
229 | date: new Date(node.pullRequest.createdAt).toISOString().split('T')[0],
230 | url: node.pullRequest.url,
231 | merged: node.pullRequest.merged,
232 | additions: node.pullRequest.additions || 0,
233 | deletions: node.pullRequest.deletions || 0
234 | } as PullRequest))
235 | .slice(0, 10),
236 | recentReviews: userData.contributionsCollection.pullRequestReviewContributions.nodes
237 | .filter((node: any) => new Date(node.pullRequest.createdAt) >= new Date('2024-01-01'))
238 | .map((node: any) => ({
239 | title: node.pullRequest.title,
240 | state: node.pullRequest.state.toLowerCase(),
241 | repo: node.pullRequest.repository.nameWithOwner,
242 | date: new Date(node.pullRequest.createdAt).toISOString().split('T')[0],
243 | url: node.pullRequest.url,
244 | merged: node.pullRequest.merged
245 | }))
246 | .slice(0, 5),
247 | stats: {
248 | totalChanges: userData.contributionsCollection.pullRequestContributions.nodes
249 | .reduce((acc: number, node: any) =>
250 | acc + (node.pullRequest.additions || 0) + (node.pullRequest.deletions || 0), 0),
251 | mergedPRs: userData.contributionsCollection.pullRequestContributions.nodes
252 | .filter((node: any) => node.pullRequest.merged).length,
253 | averageChangesPerPR: Math.round(
254 | userData.contributionsCollection.pullRequestContributions.nodes
255 | .reduce((acc: number, node: any) =>
256 | acc + (node.pullRequest.additions || 0) + (node.pullRequest.deletions || 0), 0) /
257 | Math.max(1, userData.contributionsCollection.pullRequestContributions.nodes.length)
258 | )
259 | }
260 | },
261 | recentRepos: userData.repositories.nodes.slice(0, 6).map((repo: any) => ({
262 | name: repo.name,
263 | stars: repo.stargazerCount,
264 | language: repo.primaryLanguage?.name,
265 | description: repo.description,
266 | url: repo.url,
267 | })),
268 | monthlyCommits,
269 | grindingMonth,
270 | } as UserStats,
271 | };
272 |
273 | // Generate AI analysis
274 | const aiResult = await generateAIAnalysis(processedData);
275 | processedData.stats.aiAnalysis = aiResult.analysis || "";
276 | processedData.stats.randomMonthAnalysis = {
277 | month: aiResult.randomMonth,
278 | words: aiResult.analysis?.split('[MONTH_ANALYSIS]')[1]
279 | ?.trim()
280 | ?.split('\n')
281 | ?.filter(Boolean)
282 | ?.map(w => w.trim()) || [],
283 | };
284 |
285 | return NextResponse.json(processedData);
286 | } catch (error: any) {
287 | console.error("Error:", error);
288 | return NextResponse.json(
289 | { error: error.message || "Something went wrong" },
290 | { status: 500 }
291 | );
292 | }
293 | }
294 |
295 | function processTopLanguages(repos: any[]): [string, number][] {
296 | const languages = repos.reduce((acc: { [key: string]: number }, repo: any) => {
297 | if (repo.primaryLanguage?.name) {
298 | acc[repo.primaryLanguage.name] = (acc[repo.primaryLanguage.name] || 0) + 1;
299 | }
300 | return acc;
301 | }, {});
302 |
303 | return Object.entries(languages)
304 | .sort(([, a], [, b]) => b - a)
305 | .slice(0, 5);
306 | }
307 |
308 | function getMostActiveRepo(repos: any[]): string {
309 | return repos.reduce((max: any, repo: any) => {
310 | const commits = repo.defaultBranchRef?.target?.history?.totalCount || 0;
311 | return commits > (max.commits || 0) ? { name: repo.name, commits } : max;
312 | }, {}).name || "N/A";
313 | }
314 |
315 | function calculateStreak(days: any[]): number {
316 | let currentStreak = 0;
317 | let maxStreak = 0;
318 |
319 | for (let i = 0; i < days.length; i++) {
320 | if (days[i].contributionCount > 0) {
321 | currentStreak++;
322 | maxStreak = Math.max(maxStreak, currentStreak);
323 | } else {
324 | currentStreak = 0;
325 | }
326 | }
327 |
328 | return maxStreak;
329 | }
330 |
331 | function calculateCurrentStreak(days: any[]): number {
332 | let streak = 0;
333 | const today = new Date().toISOString().split('T')[0];
334 |
335 | for (let i = days.length - 1; i >= 0; i--) {
336 | if (days[i].date === today && days[i].contributionCount === 0) break;
337 | if (days[i].contributionCount > 0) {
338 | streak++;
339 | } else {
340 | break;
341 | }
342 | }
343 |
344 | return streak;
345 | }
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Kylejeong2/Github-Wrapped/c3a516427ca7b22a414028a760710e2101c3f2f2/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/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 | @layer utilities {
20 | .text-balance {
21 | text-wrap: balance;
22 | }
23 | }
24 |
25 | @layer base {
26 | :root {
27 | --background: 0 0% 100%;
28 | --foreground: 0 0% 3.9%;
29 | --card: 0 0% 100%;
30 | --card-foreground: 0 0% 3.9%;
31 | --popover: 0 0% 100%;
32 | --popover-foreground: 0 0% 3.9%;
33 | --primary: 0 0% 9%;
34 | --primary-foreground: 0 0% 98%;
35 | --secondary: 0 0% 96.1%;
36 | --secondary-foreground: 0 0% 9%;
37 | --muted: 0 0% 96.1%;
38 | --muted-foreground: 0 0% 45.1%;
39 | --accent: 0 0% 96.1%;
40 | --accent-foreground: 0 0% 9%;
41 | --destructive: 0 84.2% 60.2%;
42 | --destructive-foreground: 0 0% 98%;
43 | --border: 0 0% 89.8%;
44 | --input: 0 0% 89.8%;
45 | --ring: 0 0% 3.9%;
46 | --chart-1: 12 76% 61%;
47 | --chart-2: 173 58% 39%;
48 | --chart-3: 197 37% 24%;
49 | --chart-4: 43 74% 66%;
50 | --chart-5: 27 87% 67%;
51 | --radius: 0.5rem;
52 | }
53 | .dark {
54 | --background: 0 0% 3.9%;
55 | --foreground: 0 0% 98%;
56 | --card: 0 0% 3.9%;
57 | --card-foreground: 0 0% 98%;
58 | --popover: 0 0% 3.9%;
59 | --popover-foreground: 0 0% 98%;
60 | --primary: 0 0% 98%;
61 | --primary-foreground: 0 0% 9%;
62 | --secondary: 0 0% 14.9%;
63 | --secondary-foreground: 0 0% 98%;
64 | --muted: 0 0% 14.9%;
65 | --muted-foreground: 0 0% 63.9%;
66 | --accent: 0 0% 14.9%;
67 | --accent-foreground: 0 0% 98%;
68 | --destructive: 0 62.8% 30.6%;
69 | --destructive-foreground: 0 0% 98%;
70 | --border: 0 0% 14.9%;
71 | --input: 0 0% 14.9%;
72 | --ring: 0 0% 83.1%;
73 | --chart-1: 220 70% 50%;
74 | --chart-2: 160 60% 45%;
75 | --chart-3: 30 80% 55%;
76 | --chart-4: 280 65% 60%;
77 | --chart-5: 340 75% 55%;
78 | }
79 | }
80 |
81 | @layer base {
82 | * {
83 | @apply border-border;
84 | }
85 | body {
86 | @apply bg-background text-foreground;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "GitHub Wrapped",
9 | description: "See your year in review on GitHub.",
10 | };
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 |
{children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | export const runtime = "edge";
2 |
3 | export default function NotFound() {
4 | return (
5 | <>
6 | 404: This page could not be found.
7 |
8 |
9 |
14 |
15 | 404
16 |
17 |
18 |
This page could not be found.
19 |
20 |
21 |
22 | >
23 | );
24 | }
25 |
26 | const styles = {
27 | error: {
28 | fontFamily:
29 | 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
30 | height: "100vh",
31 | textAlign: "center",
32 | display: "flex",
33 | flexDirection: "column",
34 | alignItems: "center",
35 | justifyContent: "center",
36 | },
37 |
38 | desc: {
39 | display: "inline-block",
40 | },
41 |
42 | h1: {
43 | display: "inline-block",
44 | margin: "0 20px 0 0",
45 | padding: "0 23px 0 0",
46 | fontSize: 24,
47 | fontWeight: 500,
48 | verticalAlign: "top",
49 | lineHeight: "49px",
50 | },
51 |
52 | h2: {
53 | fontSize: 14,
54 | fontWeight: 400,
55 | lineHeight: "49px",
56 | margin: 0,
57 | },
58 | } as const;
59 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import { FaGithub, FaStar } from "react-icons/fa";
5 | import { useRouter } from "next/navigation";
6 | import { FormEvent } from "react";
7 | import Script from "next/script";
8 | import Image from "next/image";
9 |
10 | export default function Home() {
11 | const router = useRouter();
12 |
13 | const handleSubmit = (e: FormEvent) => {
14 | e.preventDefault();
15 | const formData = new FormData(e.currentTarget);
16 | const username = formData.get("username") as string;
17 | router.push(`/wrapped/${username}`);
18 | };
19 |
20 | return (
21 |
22 |
28 |
29 |
30 | GitHub Wrapped 2024
31 |
32 |
33 | Discover your GitHub story of 2024 - your commits, contributions, and coding journey
34 |
35 |
36 |
37 |
38 |
44 |
45 |
46 |
Created by Kyle Jeong
47 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | Required: Star the Repository
68 |
69 |
70 |
71 | To use GitHub Wrapped, you must first star our repository. This helps support the project!
72 |
73 |
79 |
80 | Star Repository
81 |
82 |
83 |
84 |
85 |
101 |
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/app/wrapped/[username]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState, useRef } from "react";
4 | import { motion } from "framer-motion";
5 | import { FaStar, FaCode, FaFire, FaTrophy, FaExternalLinkAlt, FaTwitter } from "react-icons/fa";
6 | import { useRouter } from "next/navigation";
7 | import GitHubCalendar from 'react-github-calendar';
8 | import Image from "next/image";
9 | import html2canvas from 'html2canvas';
10 |
11 | export const runtime = 'edge';
12 |
13 | const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
14 |
15 | export default function WrappedPage({ params }: { params: { username: string } }) {
16 | const [data, setData] = useState(null);
17 | const [error, setError] = useState(null);
18 | const [loading, setLoading] = useState(true);
19 | const [isSharing, setIsSharing] = useState(false);
20 | const router = useRouter();
21 |
22 | const shareToTwitter = async () => {
23 | try {
24 | setIsSharing(true);
25 |
26 | const topLanguages = data.stats.topLanguages
27 | .slice(0, 3)
28 | .map(([lang]: [string, number]) => lang)
29 | .join(', ');
30 |
31 | const tweetText = encodeURIComponent(
32 | `My GitHub Wrapped 2024:\n\n` +
33 | `🔥 ${data.stats.totalCommits.toLocaleString()} commits\n` +
34 | `📚 ${data.stats.totalRepos} repositories\n` +
35 | `💻 Top languages: ${topLanguages}\n` +
36 | `✨ ${data.stats.randomMonthAnalysis.words.join(', ')}\n\n` +
37 | `Generate yours at github-wrapped.pages.dev`
38 | );
39 |
40 | window.open(`https://twitter.com/intent/tweet?text=${tweetText}`, '_blank', 'width=550,height=420');
41 | } catch (error) {
42 | console.error('Error sharing:', error);
43 | } finally {
44 | setIsSharing(false);
45 | }
46 | };
47 |
48 | useEffect(() => {
49 | fetch("/api/auth", {
50 | method: "POST",
51 | headers: {
52 | "Content-Type": "application/json",
53 | },
54 | body: JSON.stringify({ username: params.username }),
55 | })
56 | .then((res) => res.json())
57 | .then((data: any) => {
58 | if (data.error === "NOT_STARRED") {
59 | router.push("/wrapped/error");
60 | } else if (data.error) {
61 | setError(data.error);
62 | } else {
63 | setData(data);
64 | }
65 | })
66 | .catch((err) => setError(err.message))
67 | .finally(() => setLoading(false));
68 | }, [params.username, router]);
69 |
70 | if (loading) {
71 | return (
72 |
75 | );
76 | }
77 |
78 | if (error) {
79 | return (
80 |
81 |
82 |
Oops!
83 |
{error}
84 |
85 |
86 | );
87 | }
88 |
89 | const grindingMonthName = monthNames[parseInt(data.stats.grindingMonth) - 1];
90 | const randomMonthName = monthNames[parseInt(data.stats.randomMonthAnalysis.month) - 1];
91 | return (
92 |
93 |
94 |
99 |
100 |
101 | {data.profile.name}'s GitHub Wrapped 2024
102 |
103 |
124 |
125 | Your year in code
126 |
127 |
128 |
129 |
135 |
136 |
137 |
143 |
144 |
145 |
{data.profile.name}
146 |
@{data.profile.login}
147 |
148 |
149 |
150 | }
152 | label="Repos"
153 | value={data.stats.totalRepos}
154 | />
155 | }
157 | label="Stars"
158 | value={data.stats.totalStars}
159 | />
160 | }
162 | label="Commits"
163 | value={data.stats.totalCommits}
164 | />
165 | }
167 | label="Streak"
168 | value={`${data.stats.longestStreak}d`}
169 | />
170 |
171 |
172 |
173 |
179 | Top Languages
180 |
181 | {data.stats.topLanguages.map(([lang, count]: [string, number], index: number) => (
182 |
183 |
184 | {lang}
185 | {count} repos
186 |
187 |
188 |
194 |
195 |
196 | ))}
197 |
198 |
199 |
200 |
201 |
207 |
208 |
Contribution Graph
209 |
214 |
215 |
216 |
217 |
Recent Repositories
218 |
246 |
247 |
248 |
249 |
255 |
256 |
257 |
Pull Request Activity
258 |
259 |
260 |
Total PRs
261 |
{data.stats.pullRequests.created}
262 |
263 | {data.stats.pullRequests.stats.mergedPRs} merged
264 |
265 |
266 |
267 |
Reviews
268 |
{data.stats.pullRequests.reviewed}
269 |
Code reviewed
270 |
271 |
272 |
273 |
274 | PR Stats
275 |
276 |
277 |
278 |
279 |
280 | Total Changes
281 |
282 |
{data.stats.pullRequests.stats.totalChanges.toLocaleString()} lines
283 |
284 |
285 |
286 |
287 | Avg Changes/PR
288 |
289 |
{data.stats.pullRequests.stats.averageChangesPerPR.toLocaleString()} lines
290 |
291 |
292 |
293 |
294 |
295 |
Recent Pull Requests
296 |
329 |
330 |
331 |
332 |
333 |
339 |
340 |
341 |
Monthly Highlights
342 |
343 |
🔥 Grinding Month
344 |
{grindingMonthName}
345 |
346 | {data.stats.monthlyCommits[data.stats.grindingMonth]} commits
347 |
348 |
349 |
350 |
351 | ✨ {randomMonthName} Vibes
352 |
353 |
354 | {data.stats.randomMonthAnalysis.words.map((word: string, i: number) => (
355 |
356 | {word}
357 |
358 | ))}
359 |
360 |
361 |
362 |
363 |
AI Analysis
364 |
365 |
366 | {data.stats.aiAnalysis
367 | .split("[ANALYSIS]")[1]
368 | .split("[MONTH_ANALYSIS]")[0]
369 | .split("•")
370 | .filter(Boolean)
371 | .map((observation: string, i: number) => (
372 |
373 | {observation.trim()}
374 |
375 | ))}
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 | );
384 | }
385 |
386 | function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number | string }) {
387 | return (
388 |
389 |
390 | {icon}
391 | {label}
392 |
393 |
{value}
394 |
395 | );
396 | }
--------------------------------------------------------------------------------
/src/app/wrapped/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import { FaStar, FaGithub } from "react-icons/fa";
5 | import { useRouter } from "next/navigation";
6 |
7 | export default function ErrorPage() {
8 | const router = useRouter();
9 |
10 | const handleStarClick = () => {
11 | window.open("https://github.com/Kylejeong2/Github-Wrapped", "_blank");
12 | setTimeout(() => {
13 | router.push("/");
14 | }, 500);
15 | };
16 |
17 | return (
18 |
19 |
24 |
25 |
26 |
27 | Star Required!
28 |
29 | Please star our GitHub repository to use GitHub Wrapped. This helps support the project and keeps it free for everyone!
30 |
31 |
32 |
39 |
40 |
41 |
42 | );
43 | }
--------------------------------------------------------------------------------
/src/lib/interfaces/interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface PullRequest {
2 | title: string;
3 | state: string;
4 | repo: string;
5 | date: string;
6 | url: string;
7 | merged: boolean;
8 | additions?: number;
9 | deletions?: number;
10 | }
11 |
12 | export interface PRStats {
13 | totalChanges: number;
14 | mergedPRs: number;
15 | averageChangesPerPR: number;
16 | }
17 |
18 | export interface UserStats {
19 | totalRepos: number;
20 | totalStars: number;
21 | topLanguages: [string, number][];
22 | commitActivity: { [key: string]: number };
23 | totalCommits: number;
24 | mostActiveRepo: string;
25 | longestStreak: number;
26 | currentStreak: number;
27 | pullRequests: {
28 | created: number;
29 | reviewed: number;
30 | recentPRs: PullRequest[];
31 | recentReviews: PullRequest[];
32 | stats: PRStats;
33 | };
34 | recentRepos: {
35 | name: string;
36 | stars: number;
37 | language: string | null;
38 | description: string | null;
39 | url: string;
40 | }[];
41 | monthlyCommits: { [key: string]: number };
42 | grindingMonth: string;
43 | aiAnalysis?: string;
44 | randomMonthAnalysis?: {
45 | month: string;
46 | words: string[];
47 | };
48 | }
49 |
50 | export interface GraphQLResponse {
51 | user: {
52 | contributionsCollection: {
53 | contributionCalendar: {
54 | totalContributions: number;
55 | weeks: {
56 | contributionDays: {
57 | contributionCount: number;
58 | date: string;
59 | }[];
60 | }[];
61 | };
62 | pullRequestContributions: {
63 | totalCount: number;
64 | nodes: Array<{
65 | pullRequest: {
66 | title: string;
67 | state: string;
68 | createdAt: string;
69 | repository: { name: string };
70 | };
71 | }>;
72 | };
73 | pullRequestReviewContributions: {
74 | totalCount: number;
75 | nodes: Array<{
76 | pullRequest: {
77 | title: string;
78 | state: string;
79 | repository: { name: string };
80 | };
81 | }>;
82 | };
83 | };
84 | repositories: {
85 | nodes: any[];
86 | };
87 | };
88 | }
89 |
90 | export interface ImgBBResponse {
91 | success: boolean;
92 | data: { url: string };
93 | error?: { message: string };
94 | }
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | backgroundImage: {
13 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
14 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
15 | },
16 | borderRadius: {
17 | lg: 'var(--radius)',
18 | md: 'calc(var(--radius) - 2px)',
19 | sm: 'calc(var(--radius) - 4px)'
20 | },
21 | colors: {
22 | background: 'hsl(var(--background))',
23 | foreground: 'hsl(var(--foreground))',
24 | card: {
25 | DEFAULT: 'hsl(var(--card))',
26 | foreground: 'hsl(var(--card-foreground))'
27 | },
28 | popover: {
29 | DEFAULT: 'hsl(var(--popover))',
30 | foreground: 'hsl(var(--popover-foreground))'
31 | },
32 | primary: {
33 | DEFAULT: 'hsl(var(--primary))',
34 | foreground: 'hsl(var(--primary-foreground))'
35 | },
36 | secondary: {
37 | DEFAULT: 'hsl(var(--secondary))',
38 | foreground: 'hsl(var(--secondary-foreground))'
39 | },
40 | muted: {
41 | DEFAULT: 'hsl(var(--muted))',
42 | foreground: 'hsl(var(--muted-foreground))'
43 | },
44 | accent: {
45 | DEFAULT: 'hsl(var(--accent))',
46 | foreground: 'hsl(var(--accent-foreground))'
47 | },
48 | destructive: {
49 | DEFAULT: 'hsl(var(--destructive))',
50 | foreground: 'hsl(var(--destructive-foreground))'
51 | },
52 | border: 'hsl(var(--border))',
53 | input: 'hsl(var(--input))',
54 | ring: 'hsl(var(--ring))',
55 | chart: {
56 | '1': 'hsl(var(--chart-1))',
57 | '2': 'hsl(var(--chart-2))',
58 | '3': 'hsl(var(--chart-3))',
59 | '4': 'hsl(var(--chart-4))',
60 | '5': 'hsl(var(--chart-5))'
61 | }
62 | }
63 | }
64 | },
65 | plugins: [require("tailwindcss-animate")],
66 | };
67 | export default config;
68 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | },
23 | "types": [
24 | "@cloudflare/workers-types/2023-07-01"
25 | ]
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/wrangler.toml:
--------------------------------------------------------------------------------
1 | #:schema node_modules/wrangler/config-schema.json
2 | name = "github-wrapped"
3 | compatibility_date = "2024-12-05"
4 | compatibility_flags = ["nodejs_compat"]
5 | pages_build_output_dir = ".vercel/output/static"
6 |
7 | # Automatically place your workloads in an optimal location to minimize latency.
8 | # If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure
9 | # rather than the end user may result in better performance.
10 | # Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement
11 | # [placement]
12 | # mode = "smart"
13 |
14 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
15 | # Docs:
16 | # - https://developers.cloudflare.com/pages/functions/bindings/#environment-variables
17 | # Note: Use secrets to store sensitive data.
18 | # - https://developers.cloudflare.com/pages/functions/bindings/#secrets
19 | # [vars]
20 | # MY_VARIABLE = "production_value"
21 |
22 | # Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
23 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
24 | # [ai]
25 | # binding = "AI"
26 |
27 | # Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
28 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases
29 | # [[d1_databases]]
30 | # binding = "MY_DB"
31 | # database_name = "my-database"
32 | # database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
33 |
34 | # Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
35 | # Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
36 | # Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
37 | # [[durable_objects.bindings]]
38 | # name = "MY_DURABLE_OBJECT"
39 | # class_name = "MyDurableObject"
40 | # script_name = 'my-durable-object'
41 |
42 | # Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
43 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
44 | # KV Example:
45 | # [[kv_namespaces]]
46 | # binding = "MY_KV_NAMESPACE"
47 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
48 |
49 | # Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
50 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers
51 | # [[queues.producers]]
52 | # binding = "MY_QUEUE"
53 | # queue = "my-queue"
54 |
55 | # Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
56 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets
57 | # [[r2_buckets]]
58 | # binding = "MY_BUCKET"
59 | # bucket_name = "my-bucket"
60 |
61 | # Bind another Worker service. Use this binding to call another Worker without network overhead.
62 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings
63 | # [[services]]
64 | # binding = "MY_SERVICE"
65 | # service = "my-service"
66 |
67 | # To use different bindings for preview and production environments, follow the examples below.
68 | # When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis.
69 | # Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides
70 |
71 | ######## PREVIEW environment config ########
72 |
73 | # [env.preview.vars]
74 | # API_KEY = "xyz789"
75 |
76 | # [[env.preview.kv_namespaces]]
77 | # binding = "MY_KV_NAMESPACE"
78 | # id = ""
79 |
80 | ######## PRODUCTION environment config ########
81 |
82 | # [env.production.vars]
83 | # API_KEY = "abc123"
84 |
85 | # [[env.production.kv_namespaces]]
86 | # binding = "MY_KV_NAMESPACE"
87 | # id = ""
88 |
--------------------------------------------------------------------------------