├── LI.s
├── src
├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── api
│ │ ├── github
│ │ │ ├── route.ts
│ │ │ └── rankings
│ │ │ │ └── route.ts
│ │ └── cron
│ │ │ └── update-rankings
│ │ │ └── route.ts
│ ├── page.tsx
│ └── ranks
│ │ └── page.tsx
├── components
│ ├── ThemeToggle.tsx
│ ├── LoadingSpinner.tsx
│ ├── UserSearch.tsx
│ └── Sidebar.tsx
├── lib
│ ├── prisma.ts
│ └── db.ts
├── styles
│ └── theme.ts
├── types
│ └── github.ts
└── utils
│ └── countries.ts
├── vercel.json
├── public
├── vercel.svg
├── window.svg
├── file.svg
├── globe.svg
└── next.svg
├── prisma
├── migrations
│ ├── migration_lock.toml
│ └── 20250222130757_add_country_field
│ │ └── migration.sql
└── schema.prisma
├── postcss.config.mjs
├── .eslintrc.json
├── eslint.config.mjs
├── tailwind.config.ts
├── .gitignore
├── tsconfig.json
├── next.config.ts
├── tailwind.config.js
├── package.json
├── README.md
└── scripts
└── populate-db.ts
/LI.s:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omololevy/GitHub-Search/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "crons": [{
3 | "path": "/api/cron/update-rankings",
4 | "schedule": "0 0 * * 0"
5 | }]
6 | }
7 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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"
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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;
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/components/LoadingSpinner.tsx:
--------------------------------------------------------------------------------
1 | export default function LoadingSpinner() {
2 | return (
3 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
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 |
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 |
--------------------------------------------------------------------------------