├── .eslintrc.json ├── .gitignore ├── LI.s ├── README.md ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20250222130757_add_country_field │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── scripts └── populate-db.ts ├── src ├── app │ ├── api │ │ ├── cron │ │ │ └── update-rankings │ │ │ │ └── route.ts │ │ └── github │ │ │ ├── rankings │ │ │ └── route.ts │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── ranks │ │ └── page.tsx ├── components │ ├── LoadingSpinner.tsx │ ├── Sidebar.tsx │ ├── ThemeToggle.tsx │ └── UserSearch.tsx ├── lib │ ├── db.ts │ └── prisma.ts ├── styles │ └── theme.ts ├── types │ └── github.ts └── utils │ └── countries.ts ├── tailwind.config.js ├── tailwind.config.ts ├── tsconfig.json └── vercel.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@typescript-eslint/no-explicit-any": "error", 5 | "@typescript-eslint/ban-ts-comment": "warn" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /LI.s: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omololevy/GitHub-Search/15cac934e694dd8b45baf7063cf3d47c0199b18a/LI.s -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | async headers() { 5 | return [ 6 | { 7 | source: '/api/:path*', 8 | headers: [ 9 | { key: 'Access-Control-Allow-Credentials', value: 'true' }, 10 | { key: 'Access-Control-Allow-Origin', value: '*' }, 11 | { key: 'Access-Control-Allow-Methods', value: 'GET,POST,OPTIONS' }, 12 | { key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' }, 13 | ] 14 | } 15 | ] 16 | } 17 | /* config options here */ 18 | }; 19 | 20 | export default nextConfig; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "search", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate", 11 | "vercel-build": "prisma generate && prisma migrate deploy && next build", 12 | "prisma": "prisma", 13 | "prisma:generate": "prisma generate", 14 | "prisma:migrate": "prisma migrate dev", 15 | "prisma:deploy": "prisma migrate deploy", 16 | "populate-db": "tsx scripts/populate-db.ts", 17 | "setup-db": "prisma migrate deploy && npm run populate-db" 18 | }, 19 | "dependencies": { 20 | "@headlessui/react": "^2.2.0", 21 | "@prisma/client": "^6.4.1", 22 | "@vercel/postgres": "^0.10.0", 23 | "framer-motion": "^12.4.4", 24 | "next": "15.1.7", 25 | "next-themes": "^0.4.4", 26 | "react": "^19.0.0", 27 | "react-dom": "^19.0.0", 28 | "react-icons": "^5.5.0" 29 | }, 30 | "devDependencies": { 31 | "@eslint/eslintrc": "^3", 32 | "@types/node": "^20", 33 | "@types/react": "^19", 34 | "@types/react-dom": "^19", 35 | "eslint": "^9", 36 | "eslint-config-next": "15.1.7", 37 | "postcss": "^8", 38 | "prisma": "^6.4.1", 39 | "tailwindcss": "^3.4.1", 40 | "tsx": "^4.19.3", 41 | "typescript": "^5" 42 | }, 43 | "engines": { 44 | "node": ">=18.x" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /prisma/migrations/20250222130757_add_country_field/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" SERIAL NOT NULL, 4 | "login" TEXT NOT NULL, 5 | "name" TEXT, 6 | "location" TEXT, 7 | "country" TEXT, 8 | "public_repos" INTEGER NOT NULL, 9 | "followers" INTEGER NOT NULL, 10 | "avatar_url" TEXT NOT NULL, 11 | "totalStars" INTEGER NOT NULL, 12 | "contributions" INTEGER NOT NULL, 13 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "updatedAt" TIMESTAMP(3) NOT NULL, 15 | 16 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Repo" ( 21 | "id" SERIAL NOT NULL, 22 | "userId" INTEGER NOT NULL, 23 | "stargazers_count" INTEGER NOT NULL, 24 | "name" TEXT NOT NULL, 25 | 26 | CONSTRAINT "Repo_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateIndex 30 | CREATE UNIQUE INDEX "User_login_key" ON "User"("login"); 31 | 32 | -- CreateIndex 33 | CREATE INDEX "Repo_userId_idx" ON "Repo"("userId"); 34 | 35 | -- AddForeignKey 36 | ALTER TABLE "Repo" ADD CONSTRAINT "Repo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 37 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | directUrl = env("POSTGRES_URL_NON_POOLING") // Used for migrations 9 | } 10 | 11 | model User { 12 | id Int @id @default(autoincrement()) 13 | login String @unique 14 | name String? 15 | location String? 16 | country String? 17 | public_repos Int 18 | followers Int 19 | avatar_url String 20 | totalStars Int 21 | contributions Int 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt 24 | repos Repo[] 25 | } 26 | 27 | model Repo { 28 | id Int @id @default(autoincrement()) 29 | userId Int 30 | stargazers_count Int 31 | name String 32 | user User @relation(fields: [userId], references: [id]) 33 | 34 | @@index([userId]) 35 | } -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/populate-db.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../src/lib/prisma'; 2 | import { findCountryByLocation } from '../src/utils/countries'; 3 | import { GitHubUserResponse, GitHubRepoResponse } from '../src/types/github'; 4 | 5 | const GITHUB_API = "https://api.github.com"; 6 | const BATCH_SIZE = 10; 7 | const DELAY_BETWEEN_REQUESTS = 1000; 8 | 9 | async function fetchWithAuth(url: string) { 10 | const response = await fetch(url, { 11 | headers: { 12 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 13 | Accept: "application/vnd.github.v3+json", 14 | }, 15 | }); 16 | 17 | if (!response.ok) { 18 | throw new Error(`HTTP error! status: ${response.status}`); 19 | } 20 | 21 | return response; 22 | } 23 | 24 | async function delay(ms: number) { 25 | return new Promise(resolve => setTimeout(resolve, ms)); 26 | } 27 | 28 | async function populateDatabase() { 29 | try { 30 | console.log('Starting database population...'); 31 | 32 | // Fetch top GitHub users 33 | const response = await fetchWithAuth( 34 | `${GITHUB_API}/search/users?q=followers:>900&sort=followers&per_page=100` 35 | ); 36 | const data = await response.json(); 37 | 38 | console.log(`Found ${data.items.length} users to process`); 39 | 40 | // Process users in batches 41 | for (let i = 0; i < data.items.length; i += BATCH_SIZE) { 42 | const batch = data.items.slice(i, i + BATCH_SIZE); 43 | console.log(`Processing batch ${i/BATCH_SIZE + 1}...`); 44 | 45 | await Promise.all(batch.map(async (user: GitHubUserResponse) => { 46 | try { 47 | const [userDetails, repos] = await Promise.all([ 48 | fetchWithAuth(`${GITHUB_API}/users/${user.login}`) 49 | .then((res) => res.json()) as Promise, 50 | fetchWithAuth(`${GITHUB_API}/users/${user.login}/repos?per_page=100`) 51 | .then((res) => res.json()) as Promise, 52 | ]); 53 | 54 | const totalStars = repos.reduce( 55 | (acc: number, repo: GitHubRepoResponse) => 56 | acc + (repo.stargazers_count || 0), 57 | 0 58 | ); 59 | 60 | const contributions = Math.floor((userDetails.public_repos * 50) + (userDetails.followers * 2)); 61 | const country = userDetails.location ? findCountryByLocation(userDetails.location)?.name : null; 62 | 63 | await prisma.user.upsert({ 64 | where: { login: userDetails.login }, 65 | update: { 66 | name: userDetails.name, 67 | location: userDetails.location, 68 | country, 69 | public_repos: userDetails.public_repos, 70 | followers: userDetails.followers, 71 | avatar_url: userDetails.avatar_url, 72 | totalStars, 73 | contributions, 74 | }, 75 | create: { 76 | login: userDetails.login, 77 | name: userDetails.name || '', 78 | location: userDetails.location, 79 | country, 80 | public_repos: userDetails.public_repos, 81 | followers: userDetails.followers, 82 | avatar_url: userDetails.avatar_url, 83 | totalStars, 84 | contributions, 85 | }, 86 | }); 87 | 88 | console.log(`Processed user: ${userDetails.login}`); 89 | } catch (error) { 90 | console.error(`Error processing user ${user.login}:`, error); 91 | } 92 | })); 93 | 94 | // Add delay between batches 95 | await delay(DELAY_BETWEEN_REQUESTS); 96 | } 97 | 98 | console.log('Database population completed!'); 99 | } catch (error) { 100 | console.error('Failed to populate database:', error); 101 | } finally { 102 | await prisma.$disconnect(); 103 | } 104 | } 105 | 106 | populateDatabase(); 107 | -------------------------------------------------------------------------------- /src/app/api/cron/update-rankings/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { NextResponse } from "next/server"; 3 | import prisma from "@/lib/prisma"; 4 | 5 | const GITHUB_API = "https://api.github.com"; 6 | 7 | async function fetchWithAuth(url: string) { 8 | return fetch(url, { 9 | headers: { 10 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 11 | Accept: "application/vnd.github.v3+json", 12 | }, 13 | }); 14 | } 15 | 16 | export async function GET(request: Request) { 17 | try { 18 | // Verify cron secret to prevent unauthorized access 19 | const authHeader = request.headers.get('authorization'); 20 | if (authHeader !== `Bearer ${process.env.CRON_SECRET_KEY}`) { 21 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 22 | } 23 | 24 | // Fetch top GitHub users 25 | const response = await fetchWithAuth( 26 | `${GITHUB_API}/search/users?q=followers:>1000&sort=followers&per_page=100` 27 | ); 28 | const data = await response.json(); 29 | 30 | // Process each user 31 | for (const user of data.items) { 32 | const [userDetails, repos] = await Promise.all([ 33 | fetchWithAuth(`${GITHUB_API}/users/${user.login}`).then(res => res.json()), 34 | fetchWithAuth(`${GITHUB_API}/users/${user.login}/repos?per_page=100`).then(res => res.json()), 35 | ]); 36 | 37 | const totalStars = repos.reduce((acc: any, repo: { stargazers_count: any; }) => acc + (repo.stargazers_count || 0), 0); 38 | const contributions = Math.floor((userDetails.public_repos * 50) + (userDetails.followers * 2)); 39 | 40 | // Update or create user in database 41 | await prisma.user.upsert({ 42 | where: { login: userDetails.login }, 43 | update: { 44 | name: userDetails.name, 45 | location: userDetails.location, 46 | public_repos: userDetails.public_repos, 47 | followers: userDetails.followers, 48 | avatar_url: userDetails.avatar_url, 49 | totalStars, 50 | contributions, 51 | }, 52 | create: { 53 | login: userDetails.login, 54 | name: userDetails.name, 55 | location: userDetails.location, 56 | public_repos: userDetails.public_repos, 57 | followers: userDetails.followers, 58 | avatar_url: userDetails.avatar_url, 59 | totalStars, 60 | contributions, 61 | }, 62 | }); 63 | 64 | // Add delay to avoid rate limiting 65 | await new Promise(resolve => setTimeout(resolve, 1000)); 66 | } 67 | 68 | return NextResponse.json({ success: true, updated: data.items.length }); 69 | } catch (error) { 70 | console.error('Failed to update rankings:', error); 71 | return NextResponse.json({ error: 'Failed to update rankings' }, { status: 500 }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/api/github/rankings/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { NextResponse } from "next/server"; 3 | import prisma from "@/lib/prisma"; 4 | import { UserStats, PaginatedResponse, RankingFilters } from "@/types/github"; 5 | import { findCountryByLocation, countries } from "@/utils/countries"; 6 | 7 | // Add these utility functions at the top 8 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 9 | 10 | async function fetchWithRetry(url: string, retries = 3): Promise { 11 | for (let i = 0; i < retries; i++) { 12 | try { 13 | const response = await fetch(url, { 14 | headers: { 15 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 16 | Accept: "application/vnd.github.v3+json", 17 | }, 18 | }); 19 | 20 | if (response.status === 403) { 21 | const resetTime = response.headers.get("x-ratelimit-reset"); 22 | const rateLimitRemaining = response.headers.get( 23 | "x-ratelimit-remaining" 24 | ); 25 | console.warn(`Rate limit remaining: ${rateLimitRemaining}`); 26 | 27 | if (resetTime) { 28 | const waitTime = parseInt(resetTime) * 1000 - Date.now(); 29 | if (waitTime > 0 && i < retries - 1) { 30 | await delay(Math.min(waitTime + 1000, 5000)); 31 | continue; 32 | } 33 | } 34 | } 35 | 36 | if (!response.ok) { 37 | throw new Error(`HTTP error! status: ${response.status}`); 38 | } 39 | 40 | // Add a small delay between successful requests 41 | await delay(1000); 42 | return response; 43 | } catch (error) { 44 | if (i === retries - 1) throw error; 45 | await delay(1000 * (i + 1)); // Exponential backoff 46 | } 47 | } 48 | throw new Error("Max retries reached"); 49 | } 50 | 51 | export async function GET(request: Request) { 52 | try { 53 | const { searchParams } = new URL(request.url); 54 | const filters: RankingFilters = { 55 | type: (searchParams.get("type") || "all") as RankingFilters["type"], 56 | country: searchParams.get("country") || "global", 57 | page: parseInt(searchParams.get("page") || "1"), 58 | perPage: parseInt(searchParams.get("perPage") || "20"), 59 | sortBy: (searchParams.get("sortBy") || 60 | "followers") as RankingFilters["sortBy"], 61 | }; 62 | 63 | let whereClause = {}; 64 | 65 | if (filters.country !== "global") { 66 | const selectedCountry = countries.find((c) => c.code === filters.country); 67 | if (selectedCountry) { 68 | whereClause = { 69 | country: selectedCountry.name, 70 | }; 71 | } 72 | } 73 | 74 | // Query the database with improved country filtering 75 | const users = await prisma.user.findMany({ 76 | where: whereClause, 77 | orderBy: { [filters.sortBy]: "desc" }, 78 | skip: (filters.page - 1) * filters.perPage, 79 | take: filters.perPage, 80 | }); 81 | 82 | const total = await prisma.user.count({ 83 | where: whereClause, 84 | }); 85 | 86 | // Convert prisma users to UserStats type 87 | const enhancedUsers: UserStats[] = users.map((user) => ({ 88 | login: user.login, 89 | name: user.name, 90 | location: user.location, 91 | public_repos: user.public_repos, 92 | followers: user.followers, 93 | avatar_url: user.avatar_url, 94 | totalStars: user.totalStars, 95 | contributions: user.contributions, 96 | country: user.country, 97 | detectedCountry: user.location ? findCountryByLocation(user.location)?.name : null 98 | })); 99 | 100 | const result: PaginatedResponse = { 101 | items: enhancedUsers, 102 | total, 103 | page: filters.page, 104 | perPage: filters.perPage, 105 | totalPages: Math.ceil(total / filters.perPage), 106 | }; 107 | 108 | return NextResponse.json(result); 109 | } catch (error) { 110 | console.error("Rankings API error:", error); 111 | return NextResponse.json( 112 | { 113 | error: 114 | error instanceof Error ? error.message : "Failed to fetch rankings", 115 | items: [], 116 | total: 0, 117 | page: 1, 118 | perPage: 20, 119 | totalPages: 0, 120 | }, 121 | { status: 500 } 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/app/api/github/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { 3 | GitHubUserResponse, 4 | GitHubRepoResponse, 5 | } from "@/types/github"; 6 | 7 | const GITHUB_API = "https://api.github.com"; 8 | 9 | async function fetchWithAuth(url: string) { 10 | const response = await fetch(url, { 11 | headers: { 12 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 13 | Accept: "application/vnd.github.v3+json", 14 | }, 15 | }); 16 | 17 | if (!response.ok) { 18 | const error = await response.json(); 19 | throw new Error(error.message || "GitHub API request failed"); 20 | } 21 | 22 | const remaining = response.headers.get("x-ratelimit-remaining"); 23 | if (remaining && parseInt(remaining) < 10) { 24 | console.warn("GitHub API rate limit is running low:", remaining); 25 | } 26 | 27 | return response; 28 | } 29 | 30 | export async function GET(request: Request) { 31 | const { searchParams } = new URL(request.url); 32 | const username = searchParams.get("username"); 33 | 34 | if (!username) { 35 | return NextResponse.json( 36 | { error: "Username is required" }, 37 | { status: 400 } 38 | ); 39 | } 40 | 41 | try { 42 | if (!process.env.GITHUB_TOKEN) { 43 | throw new Error("GitHub token is not configured. Set GITHUB_TOKEN in your .env.local file."); 44 | } 45 | 46 | // Fetch user data from GitHub directly 47 | const userResponse = await fetchWithAuth(`${GITHUB_API}/users/${username}`); 48 | const userData: GitHubUserResponse = await userResponse.json(); 49 | 50 | // Fetch repositories 51 | const reposResponse = await fetchWithAuth( 52 | `${GITHUB_API}/users/${username}/repos?per_page=100` 53 | ); 54 | const reposData: GitHubRepoResponse[] = await reposResponse.json(); 55 | 56 | // Compute totalStars & contributions based on GitHub values 57 | const totalStars = reposData.reduce( 58 | (acc: number, repo: GitHubRepoResponse) => acc + (repo.stargazers_count || 0), 59 | 0 60 | ); 61 | 62 | const contributions = Math.floor( 63 | (userData.public_repos * 50) + (userData.followers * 2) 64 | ); 65 | 66 | return NextResponse.json({ 67 | ...userData, 68 | totalStars, 69 | contributions, 70 | }); 71 | } catch (error) { 72 | console.error("API Error:", error); 73 | return NextResponse.json( 74 | { 75 | error: error instanceof Error ? error.message : "Failed to fetch user data", 76 | details: process.env.NODE_ENV === "development" ? error : undefined, 77 | }, 78 | { status: 500 } 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omololevy/GitHub-Search/15cac934e694dd8b45baf7063cf3d47c0199b18a/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: #FFFFFF; 8 | --foreground: #1F2937; 9 | --surface: #F3F4F6; 10 | --primary: #4F46E5; 11 | --secondary: #10B981; 12 | --accent: #8B5CF6; 13 | --muted: #9CA3AF; 14 | --card: #FFFFFF; 15 | --border: #E5E7EB; 16 | } 17 | 18 | .dark { 19 | --background: #0F172A; 20 | --foreground: #F1F5F9; 21 | --surface: #1E293B; 22 | --primary: #6366F1; 23 | --secondary: #34D399; 24 | --accent: #A78BFA; 25 | --muted: #64748B; 26 | --card: #1E293B; 27 | --border: #2D3748; 28 | } 29 | } 30 | 31 | body { 32 | color: var(--foreground); 33 | background: var(--background); 34 | font-family: Arial, Helvetica, sans-serif; 35 | } 36 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ThemeProvider } from "next-themes"; 5 | import Sidebar from "@/components/Sidebar"; 6 | import ThemeToggle from "@/components/ThemeToggle"; 7 | 8 | const geistSans = Geist({ 9 | variable: "--font-geist-sans", 10 | subsets: ["latin"], 11 | }); 12 | 13 | const geistMono = Geist_Mono({ 14 | variable: "--font-geist-mono", 15 | subsets: ["latin"], 16 | }); 17 | 18 | export const metadata: Metadata = { 19 | title: "Create Next App", 20 | description: "Generated by create next app", 21 | }; 22 | 23 | export default function RootLayout({ 24 | children, 25 | }: Readonly<{ 26 | children: React.ReactNode; 27 | }>) { 28 | return ( 29 | 30 | 33 | 34 |
35 | 36 |
37 |
38 |
39 | 40 |
41 | {children} 42 |
43 |
44 |
45 |
46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import UserSearch from "@/components/UserSearch"; 5 | import LoadingSpinner from "@/components/LoadingSpinner"; 6 | import { UserStats } from "@/types/github"; 7 | import { motion, AnimatePresence } from "framer-motion"; 8 | import Image from "next/image"; 9 | 10 | export default function Home() { 11 | const [users, setUsers] = useState([]); 12 | const [loading, setLoading] = useState(false); 13 | const [error, setError] = useState(null); 14 | 15 | const searchUser = async (username: string) => { 16 | if (!username.trim()) return; 17 | 18 | setLoading(true); 19 | setError(null); 20 | 21 | try { 22 | // First, search for the user using GitHub's search API 23 | const searchResponse = await fetch( 24 | `https://api.github.com/search/users?q=${username}+in:login`, 25 | { 26 | headers: { 27 | Accept: "application/vnd.github.v3+json", 28 | }, 29 | } 30 | ); 31 | 32 | if (!searchResponse.ok) { 33 | throw new Error('Failed to search GitHub users'); 34 | } 35 | 36 | const searchData = await searchResponse.json(); 37 | 38 | if (searchData.total_count === 0) { 39 | setError(`No user found with username: ${username}`); 40 | setLoading(false); 41 | return; 42 | } 43 | 44 | // Then fetch detailed user data through our API 45 | const response = await fetch(`/api/github?username=${username}`); 46 | const userData = await response.json(); 47 | 48 | if (!response.ok) { 49 | throw new Error(userData.error || 'Failed to fetch user details'); 50 | } 51 | 52 | setUsers(prevUsers => { 53 | // Avoid duplicates 54 | const exists = prevUsers.some(user => user.login === userData.login); 55 | if (exists) return prevUsers; 56 | return [...prevUsers, userData]; 57 | }); 58 | 59 | } catch (error) { 60 | console.error("Error fetching user:", error); 61 | setError(error instanceof Error ? error.message : 'Failed to fetch user data'); 62 | } finally { 63 | setLoading(false); 64 | } 65 | }; 66 | 67 | const groupedUsers = users.reduce((acc, user) => { 68 | const country = user.location || "Unknown"; 69 | if (!acc[country]) { 70 | acc[country] = []; 71 | } 72 | acc[country].push(user); 73 | return acc; 74 | }, {} as Record); 75 | 76 | return ( 77 |
78 | 83 |

84 | GitHub User Rankings 85 |

86 | 87 |
88 | 89 | {error && ( 90 |

91 | {error} 92 |

93 | )} 94 |
95 | 96 | {loading && } 97 | 98 | 99 | {Object.entries(groupedUsers).map(([country, users]) => ( 100 | 107 |

{country}

108 |
109 | {users 110 | .sort( 111 | (a, b) => 112 | b.followers + b.totalStars - (a.followers + a.totalStars) 113 | ) 114 | .map((user) => ( 115 |
116 |
117 | {user.login} 125 |
126 |

127 | {user.name || user.login} 128 |

129 |

130 | Followers: {user.followers} | Stars:{" "} 131 | {user.totalStars} | Repos: {user.public_repos} | 132 | Contributions: {user.contributions} 133 |

134 |
135 |
136 |
137 | ))} 138 |
139 |
140 | ))} 141 |
142 |
143 |
144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/app/ranks/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | "use client"; 3 | 4 | import { useState, useEffect } from "react"; 5 | import { UserStats, RankingFilters, PaginatedResponse } from "@/types/github"; 6 | import { motion, AnimatePresence } from "framer-motion"; 7 | import LoadingSpinner from "@/components/LoadingSpinner"; 8 | import { countries, regions } from "@/utils/countries"; 9 | import Image from "next/image"; 10 | 11 | export default function RanksPage() { 12 | const [filters, setFilters] = useState({ 13 | type: "all", 14 | country: "global", 15 | page: 1, 16 | perPage: 20, 17 | sortBy: "followers", 18 | }); 19 | const [data, setData] = useState | null>(null); 20 | const [loading, setLoading] = useState(true); 21 | const [error, setError] = useState(null); 22 | 23 | useEffect(() => { 24 | const initializeRankings = async () => { 25 | // Check if we have data in the database 26 | const response = await fetch("/api/github/rankings?page=1&perPage=1"); 27 | const data = await response.json(); 28 | 29 | if (data.total === 0) { 30 | setError( 31 | "No ranking data available. Please populate the database first." 32 | ); 33 | } else { 34 | fetchRankings(); 35 | } 36 | }; 37 | 38 | initializeRankings(); 39 | }, []); 40 | 41 | useEffect(() => { 42 | fetchRankings(); 43 | }, [filters]); 44 | 45 | const fetchRankings = async () => { 46 | setLoading(true); 47 | setError(null); 48 | 49 | try { 50 | const params = new URLSearchParams({ 51 | type: filters.type, 52 | country: filters.country, 53 | page: filters.page.toString(), 54 | perPage: filters.perPage.toString(), 55 | sortBy: filters.sortBy, 56 | }); 57 | 58 | const response = await fetch(`/api/github/rankings?${params}`); 59 | const responseData = await response.json(); 60 | 61 | if (!response.ok) { 62 | throw new Error(responseData.error || "Failed to fetch rankings"); 63 | } 64 | 65 | if (!responseData.items) { 66 | throw new Error("Invalid response format from API"); 67 | } 68 | 69 | setData(responseData); 70 | } catch (error) { 71 | console.error("Error fetching rankings:", error); 72 | setError( 73 | error instanceof Error ? error.message : "Failed to fetch rankings" 74 | ); 75 | } finally { 76 | setLoading(false); 77 | } 78 | }; 79 | 80 | return ( 81 |
82 |
83 |

84 | GitHub Rankings 85 |

86 | 87 | {/* Filters */} 88 |
89 | 104 | 105 | 125 | 126 | {/* Sort buttons */} 127 |
128 | {[ 129 | { key: "followers", label: "Followers" }, 130 | { key: "totalStars", label: "Total Stars" }, 131 | { key: "contributions", label: "Contributions" }, 132 | { key: "public_repos", label: "Repositories" }, 133 | ].map(({ key, label }) => ( 134 | 152 | ))} 153 |
154 |
155 |
156 | 157 | {error && ( 158 |
159 | {error} 160 |
161 | )} 162 | 163 | {/* Results */} 164 | {loading ? ( 165 | 166 | ) : ( 167 | <> 168 | 169 |
170 | {data?.items.map((user, index) => ( 171 | 179 |
180 |
181 | 193 | {user.login} 201 |
202 |
203 |

204 | {user.name || user.login} 205 |

206 |

{user.location}

207 |
208 |
209 | 214 | 219 | 224 | 229 |
230 |
231 |
232 | ))} 233 |
234 |
235 | 236 | {/* Pagination */} 237 | {data && ( 238 |
239 | {Array.from({ length: data.totalPages }, (_, i) => ( 240 | 251 | ))} 252 |
253 | )} 254 | 255 | )} 256 |
257 | ); 258 | } 259 | 260 | function StatCard({ 261 | label, 262 | value, 263 | highlight, 264 | }: { 265 | label: string; 266 | value: number; 267 | highlight: boolean; 268 | }) { 269 | return ( 270 |
275 |
{label}
276 |
{value.toLocaleString()}
277 |
278 | ); 279 | } 280 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingSpinner() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import { useState } from "react"; 6 | import { FiSearch, FiAward, FiMenu, FiX } from "react-icons/fi"; 7 | import { motion, AnimatePresence } from "framer-motion"; 8 | 9 | type MenuItemProps = { 10 | path: string; 11 | label: string; 12 | icon: React.ElementType; 13 | }; 14 | 15 | export default function Sidebar() { 16 | const pathname = usePathname(); 17 | const [isOpen, setIsOpen] = useState(false); 18 | 19 | const menuItems = [ 20 | { path: "/", label: "Search", icon: FiSearch }, 21 | { path: "/ranks", label: "Rankings", icon: FiAward }, 22 | ]; 23 | 24 | const MenuItem = ({ path, label, icon: Icon }: MenuItemProps) => { 25 | const isActive = pathname === path; 26 | return ( 27 | setIsOpen(false)} 30 | className={`flex items-center gap-3 p-3 rounded-xl transition-all duration-300 group ${ 31 | isActive 32 | ? "bg-primary text-background shadow-lg shadow-primary/25" 33 | : "hover:bg-surface/80 text-foreground/80 hover:text-foreground" 34 | }`} 35 | > 36 |
43 | 48 |
49 | {label} 50 | 51 | ); 52 | }; 53 | 54 | return ( 55 | <> 56 | {/* Mobile Menu Button */} 57 | setIsOpen(!isOpen)} 61 | className="lg:hidden fixed top-4 left-4 z-50 p-3 rounded-xl bg-card border border-border/50 shadow-lg" 62 | > 63 | {isOpen ? : } 64 | 65 | 66 | {/* Sidebar */} 67 | 81 |
82 | {/* Logo/Brand */} 83 |
84 |

85 | GitHub Stats 86 |

87 |

Explore & Compare

88 |
89 | 90 | {/* Navigation */} 91 | 96 | 97 | {/* Bottom content */} 98 |
99 |
100 |

101 | Made with ♥️ for GitHub community 102 |

103 |
104 |
105 |
106 |
107 | 108 | {/* Backdrop */} 109 | 110 | {isOpen && ( 111 | setIsOpen(false)} 117 | /> 118 | )} 119 | 120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | 5 | export default function ThemeToggle() { 6 | const { theme, setTheme } = useTheme(); 7 | 8 | return ( 9 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/UserSearch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | export default function UserSearch({ 6 | onSearch, 7 | }: { 8 | onSearch: (username: string) => void; 9 | }) { 10 | const [username, setUsername] = useState(""); 11 | 12 | return ( 13 |
14 |
15 | setUsername(e.target.value)} 19 | placeholder="Enter GitHub username" 20 | className="flex-1 p-2 border rounded-lg" 21 | /> 22 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import prisma from "./prisma"; 2 | 3 | export async function verifyDatabaseConnection() { 4 | try { 5 | await prisma.$connect(); 6 | // Try to query the database 7 | await prisma.user.count(); 8 | console.log('Database connection successful'); 9 | return true; 10 | } catch (error) { 11 | console.error('Database connection failed:', error); 12 | return false; 13 | } finally { 14 | await prisma.$disconnect(); 15 | } 16 | } 17 | 18 | export async function initializeDatabase() { 19 | try { 20 | const count = await prisma.user.count(); 21 | if (count === 0) { 22 | console.log('Database is empty, triggering initial population...'); 23 | // Trigger the cron job manually 24 | const response = await fetch('/api/cron/update-rankings', { 25 | headers: { 26 | 'Authorization': `Bearer ${process.env.CRON_SECRET_KEY}` 27 | } 28 | }); 29 | const data = await response.json(); 30 | console.log('Initial population result:', data); 31 | } 32 | } catch (error) { 33 | console.error('Failed to initialize database:', error); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { PrismaClient } from '@prisma/client'; 4 | 5 | let prisma: PrismaClient; 6 | 7 | if (process.env.NODE_ENV === 'production') { 8 | prisma = new PrismaClient(); 9 | } else { 10 | // Prevent multiple instances of Prisma Client in development 11 | if (!(global as any).prisma) { 12 | (global as any).prisma = new PrismaClient(); 13 | } 14 | prisma = (global as any).prisma; 15 | } 16 | 17 | export default prisma; -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | export const theme = { 2 | light: { 3 | primary: "#4F46E5", // Indigo primary 4 | secondary: "#10B981", // Emerald accent 5 | background: "#FFFFFF", 6 | foreground: "#1F2937", 7 | surface: "#F3F4F6", 8 | accent: "#8B5CF6", // Purple accent 9 | muted: "#9CA3AF", 10 | card: "#FFFFFF", 11 | border: "#E5E7EB", 12 | }, 13 | dark: { 14 | primary: "#6366F1", // Brighter indigo for dark mode 15 | secondary: "#34D399", // Brighter emerald for dark mode 16 | background: "#0F172A", // Deep navy background 17 | foreground: "#F1F5F9", 18 | surface: "#1E293B", 19 | accent: "#A78BFA", // Lighter purple for dark mode 20 | muted: "#64748B", 21 | card: "#1E293B", 22 | border: "#2D3748", 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/types/github.ts: -------------------------------------------------------------------------------- 1 | export interface GitHubUser { 2 | login: string; 3 | name: string; 4 | location: string; 5 | public_repos: number; 6 | followers: number; 7 | avatar_url: string; 8 | } 9 | 10 | export interface GitHubRepo { 11 | stargazers_count: number; 12 | } 13 | 14 | export interface UserStats { 15 | login: string; 16 | name: string | null; 17 | location: string | null; 18 | public_repos: number; 19 | followers: number; 20 | avatar_url: string; 21 | totalStars: number; 22 | contributions: number; 23 | country: string | null; 24 | detectedCountry?: string | null; 25 | } 26 | 27 | export interface PaginatedResponse { 28 | items: T[]; 29 | total: number; 30 | page: number; 31 | perPage: number; 32 | totalPages: number; 33 | } 34 | 35 | export interface RankingFilters { 36 | type: "user" | "organization" | "all"; 37 | country: string | "global"; 38 | page: number; 39 | perPage: number; 40 | sortBy: "followers" | "totalStars" | "contributions" | "public_repos"; 41 | } 42 | 43 | export interface GitHubRepoResponse { 44 | stargazers_count: number; 45 | id: number; 46 | name: string; 47 | } 48 | 49 | export interface GitHubUserResponse { 50 | login: string; 51 | name: string; 52 | location: string; 53 | public_repos: number; 54 | followers: number; 55 | avatar_url: string; 56 | id: number; 57 | } 58 | 59 | export interface GitHubContributionsResponse { 60 | total: number; 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/countries.ts: -------------------------------------------------------------------------------- 1 | export interface Country { 2 | code: string; 3 | name: string; 4 | region: string; 5 | } 6 | 7 | export const countries: Country[] = [ 8 | { code: 'US', name: 'United States', region: 'North America' }, 9 | { code: 'GB', name: 'United Kingdom', region: 'Europe' }, 10 | { code: 'IN', name: 'India', region: 'Asia' }, 11 | { code: 'CN', name: 'China', region: 'Asia' }, 12 | { code: 'JP', name: 'Japan', region: 'Asia' }, 13 | { code: 'DE', name: 'Germany', region: 'Europe' }, 14 | { code: 'FR', name: 'France', region: 'Europe' }, 15 | { code: 'BR', name: 'Brazil', region: 'South America' }, 16 | { code: 'CA', name: 'Canada', region: 'North America' }, 17 | { code: 'AU', name: 'Australia', region: 'Oceania' }, 18 | { code: 'RU', name: 'Russia', region: 'Europe' }, 19 | { code: 'KR', name: 'South Korea', region: 'Asia' }, 20 | { code: 'IL', name: 'Israel', region: 'Asia' }, 21 | { code: 'NL', name: 'Netherlands', region: 'Europe' }, 22 | { code: 'SE', name: 'Sweden', region: 'Europe' }, 23 | { code: 'PL', name: 'Poland', region: 'Europe' }, 24 | { code: 'SG', name: 'Singapore', region: 'Asia' }, 25 | { code: 'UA', name: 'Ukraine', region: 'Europe' }, 26 | { code: 'KE', name: 'Kenya', region: 'Africa' }, 27 | { code: 'NG', name: 'Nigeria', region: 'Africa' }, 28 | { code: 'ZA', name: 'South Africa', region: 'Africa' }, 29 | { code: 'EG', name: 'Egypt', region: 'Africa' }, 30 | // Add more countries as needed 31 | ]; 32 | 33 | export const regions = [...new Set(countries.map(country => country.region))]; 34 | 35 | export function getCountriesByRegion(region: string): Country[] { 36 | return countries.filter(country => country.region === region); 37 | } 38 | 39 | export function findCountryByLocation(location: string): Country | undefined { 40 | const lowercaseLocation = location.toLowerCase(); 41 | return countries.find(country => 42 | lowercaseLocation.includes(country.name.toLowerCase()) || 43 | lowercaseLocation.includes(country.code.toLowerCase()) 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | darkMode: 'class', 9 | theme: { 10 | extend: { 11 | colors: { 12 | primary: 'var(--primary)', 13 | secondary: 'var(--secondary)', 14 | background: 'var(--background)', 15 | foreground: 'var(--foreground)', 16 | surface: 'var(--surface)', 17 | accent: 'var(--accent)', 18 | muted: 'var(--muted)', 19 | card: 'var(--card)', 20 | border: 'var(--border)', 21 | }, 22 | boxShadow: { 23 | 'soft': '0 2px 15px 0 rgb(0 0 0 / 0.05)', 24 | 'soft-lg': '0 4px 25px 0 rgb(0 0 0 / 0.05)', 25 | }, 26 | }, 27 | }, 28 | plugins: [], 29 | } 30 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 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 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "crons": [{ 3 | "path": "/api/cron/update-rankings", 4 | "schedule": "0 0 * * 0" 5 | }] 6 | } 7 | --------------------------------------------------------------------------------