mirror of
				https://github.com/khairul169/github-leaderboard.git
				synced 2025-10-31 08:19:32 +07:00 
			
		
		
		
	feat: initial commit
This commit is contained in:
		
						commit
						8f286de718
					
				
							
								
								
									
										16
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| # App | ||||
| HOST= | ||||
| PORT= | ||||
| 
 | ||||
| # Database | ||||
| DATABASE_PATH= | ||||
| 
 | ||||
| # Auth | ||||
| GITHUB_CLIENT_ID= | ||||
| GITHUB_SECRET_KEY= | ||||
| JWT_SECRET=supersecretkey | ||||
| 
 | ||||
| # Queue Worker | ||||
| QUEUE_CONCURRENCY=1 | ||||
| REDIS_HOST= | ||||
| REDIS_PORT= | ||||
							
								
								
									
										18
									
								
								.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   env: { browser: true, es2020: true }, | ||||
|   extends: [ | ||||
|     'eslint:recommended', | ||||
|     'plugin:@typescript-eslint/recommended', | ||||
|     'plugin:react-hooks/recommended', | ||||
|   ], | ||||
|   ignorePatterns: ['dist', '.eslintrc.cjs'], | ||||
|   parser: '@typescript-eslint/parser', | ||||
|   plugins: ['react-refresh'], | ||||
|   rules: { | ||||
|     'react-refresh/only-export-components': [ | ||||
|       'warn', | ||||
|       { allowConstantExport: true }, | ||||
|     ], | ||||
|   }, | ||||
| } | ||||
							
								
								
									
										28
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
| 
 | ||||
| node_modules | ||||
| dist | ||||
| dist-ssr | ||||
| *.local | ||||
| 
 | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| .DS_Store | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
| 
 | ||||
| *.db | ||||
| .env* | ||||
| !.env.example | ||||
							
								
								
									
										30
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| # React + TypeScript + Vite | ||||
| 
 | ||||
| This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. | ||||
| 
 | ||||
| Currently, two official plugins are available: | ||||
| 
 | ||||
| - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh | ||||
| - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh | ||||
| 
 | ||||
| ## Expanding the ESLint configuration | ||||
| 
 | ||||
| If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: | ||||
| 
 | ||||
| - Configure the top-level `parserOptions` property like this: | ||||
| 
 | ||||
| ```js | ||||
| export default { | ||||
|   // other rules... | ||||
|   parserOptions: { | ||||
|     ecmaVersion: 'latest', | ||||
|     sourceType: 'module', | ||||
|     project: ['./tsconfig.json', './tsconfig.node.json', './tsconfig.app.json'], | ||||
|     tsconfigRootDir: __dirname, | ||||
|   }, | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` | ||||
| - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` | ||||
| - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list | ||||
							
								
								
									
										13
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Antrian Kick IMPHNEN</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|     <script type="module" src="/src/main.tsx"></script> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										62
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | ||||
| { | ||||
|   "name": "github-contrib-rank", | ||||
|   "private": true, | ||||
|   "version": "0.0.0", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", | ||||
|     "client:dev": "vite", | ||||
|     "client:build": "tsc -b && vite build", | ||||
|     "client:preview": "vite preview", | ||||
|     "server:dev": "bun --watch server/main.ts", | ||||
|     "db:drop": "drizzle-kit drop --config server/drizzle.config.ts", | ||||
|     "db:generate": "drizzle-kit generate --config server/drizzle.config.ts", | ||||
|     "db:migrate": "drizzle-kit migrate --config server/drizzle.config.ts", | ||||
|     "db:push": "drizzle-kit push --config server/drizzle.config.ts", | ||||
|     "db:seed": "bun run server/db/seed.ts", | ||||
|     "dev": "concurrently \"npm run client:dev\" \"npm run server:dev\"", | ||||
|     "build": "npm run client:build && npm run server:build", | ||||
|     "start": "NODE_ENV=production bun run server/main.ts", | ||||
|     "start:worker": "NODE_ENV=production bun run server/queue-worker.ts" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "bullmq": "^5.12.1", | ||||
|     "cheerio": "1.0.0-rc.12", | ||||
|     "clsx": "^2.1.1", | ||||
|     "daisyui": "^4.12.10", | ||||
|     "dayjs": "^1.11.12", | ||||
|     "drizzle-orm": "^0.32.2", | ||||
|     "hono": "^4.5.4", | ||||
|     "pino": "^9.3.2", | ||||
|     "react": "^18.3.1", | ||||
|     "react-daisyui": "^5.0.3", | ||||
|     "react-dom": "^18.3.1", | ||||
|     "react-icons": "^5.2.1", | ||||
|     "react-lottie": "^1.2.4", | ||||
|     "tailwind-merge": "^2.4.0", | ||||
|     "zustand": "^4.5.4" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@faker-js/faker": "^8.4.1", | ||||
|     "@types/bun": "^1.1.6", | ||||
|     "@types/react": "^18.3.3", | ||||
|     "@types/react-dom": "^18.3.0", | ||||
|     "@types/react-lottie": "^1.2.10", | ||||
|     "@typescript-eslint/eslint-plugin": "^7.15.0", | ||||
|     "@typescript-eslint/parser": "^7.15.0", | ||||
|     "@vitejs/plugin-react-swc": "^3.5.0", | ||||
|     "autoprefixer": "^10.4.20", | ||||
|     "better-sqlite3": "^11.1.2", | ||||
|     "concurrently": "^8.2.2", | ||||
|     "drizzle-kit": "^0.23.2", | ||||
|     "eslint": "^8.57.0", | ||||
|     "eslint-plugin-react-hooks": "^4.6.2", | ||||
|     "eslint-plugin-react-refresh": "^0.4.7", | ||||
|     "pino-pretty": "^11.2.2", | ||||
|     "postcss": "^8.4.41", | ||||
|     "prop-types": "^15.8.1", | ||||
|     "tailwindcss": "^3.4.7", | ||||
|     "typescript": "^5.2.2", | ||||
|     "vite": "^5.3.4" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										4183
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										4183
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										6
									
								
								postcss.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								postcss.config.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| export default { | ||||
|   plugins: { | ||||
|     tailwindcss: {}, | ||||
|     autoprefixer: {}, | ||||
|   }, | ||||
| } | ||||
							
								
								
									
										1
									
								
								public/vite.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/vite.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> | ||||
| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										10
									
								
								server/db/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/db/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| import { drizzle } from "drizzle-orm/bun-sqlite"; | ||||
| import { Database } from "bun:sqlite"; | ||||
| import * as schema from "../models"; | ||||
| 
 | ||||
| const DATABASE_PATH = import.meta.env.DATABASE_PATH || "./data.db"; | ||||
| 
 | ||||
| const sqlite = new Database(DATABASE_PATH); | ||||
| const db = drizzle(sqlite, { schema }); | ||||
| 
 | ||||
| export default db; | ||||
							
								
								
									
										38
									
								
								server/db/migrations/0000_low_catseye.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								server/db/migrations/0000_low_catseye.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| CREATE TABLE `repositories` ( | ||||
| 	`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, | ||||
| 	`user_id` integer NOT NULL, | ||||
| 	`name` text NOT NULL, | ||||
| 	`uri` text NOT NULL, | ||||
| 	`language` text NOT NULL, | ||||
| 	`stars` integer NOT NULL, | ||||
| 	`last_update` text NOT NULL, | ||||
| 	`languages` text, | ||||
| 	`contributors` text, | ||||
| 	`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, | ||||
| 	`updated_at` text NOT NULL, | ||||
| 	FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action | ||||
| ); | ||||
| --> statement-breakpoint | ||||
| CREATE TABLE `users` ( | ||||
| 	`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, | ||||
| 	`username` text NOT NULL, | ||||
| 	`name` text NOT NULL, | ||||
| 	`avatar` text, | ||||
| 	`location` text, | ||||
| 	`followers` integer DEFAULT 0 NOT NULL, | ||||
| 	`following` integer DEFAULT 0 NOT NULL, | ||||
| 	`achievements` text DEFAULT '[]', | ||||
| 	`points` integer DEFAULT 0 NOT NULL, | ||||
| 	`commits` integer DEFAULT 0 NOT NULL, | ||||
| 	`line_of_codes` integer DEFAULT 0 NOT NULL, | ||||
| 	`github_id` integer, | ||||
| 	`access_token` text, | ||||
| 	`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, | ||||
| 	`updated_at` text NOT NULL | ||||
| ); | ||||
| --> statement-breakpoint | ||||
| CREATE INDEX `repositories_name_idx` ON `repositories` (`name`);--> statement-breakpoint | ||||
| CREATE INDEX `repositories_uri_idx` ON `repositories` (`uri`);--> statement-breakpoint | ||||
| CREATE INDEX `repositories_language_idx` ON `repositories` (`language`);--> statement-breakpoint | ||||
| CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint | ||||
| CREATE UNIQUE INDEX `users_github_id_unique` ON `users` (`github_id`); | ||||
							
								
								
									
										276
									
								
								server/db/migrations/meta/0000_snapshot.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										276
									
								
								server/db/migrations/meta/0000_snapshot.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,276 @@ | ||||
| { | ||||
|   "version": "6", | ||||
|   "dialect": "sqlite", | ||||
|   "id": "ccf7929b-8198-4452-ad60-e02285ac8149", | ||||
|   "prevId": "00000000-0000-0000-0000-000000000000", | ||||
|   "tables": { | ||||
|     "repositories": { | ||||
|       "name": "repositories", | ||||
|       "columns": { | ||||
|         "id": { | ||||
|           "name": "id", | ||||
|           "type": "integer", | ||||
|           "primaryKey": true, | ||||
|           "notNull": true, | ||||
|           "autoincrement": true | ||||
|         }, | ||||
|         "user_id": { | ||||
|           "name": "user_id", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "name": { | ||||
|           "name": "name", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "uri": { | ||||
|           "name": "uri", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "language": { | ||||
|           "name": "language", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "stars": { | ||||
|           "name": "stars", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "last_update": { | ||||
|           "name": "last_update", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "languages": { | ||||
|           "name": "languages", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": false, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "contributors": { | ||||
|           "name": "contributors", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": false, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "created_at": { | ||||
|           "name": "created_at", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false, | ||||
|           "default": "CURRENT_TIMESTAMP" | ||||
|         }, | ||||
|         "updated_at": { | ||||
|           "name": "updated_at", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         } | ||||
|       }, | ||||
|       "indexes": { | ||||
|         "repositories_name_idx": { | ||||
|           "name": "repositories_name_idx", | ||||
|           "columns": [ | ||||
|             "name" | ||||
|           ], | ||||
|           "isUnique": false | ||||
|         }, | ||||
|         "repositories_uri_idx": { | ||||
|           "name": "repositories_uri_idx", | ||||
|           "columns": [ | ||||
|             "uri" | ||||
|           ], | ||||
|           "isUnique": false | ||||
|         }, | ||||
|         "repositories_language_idx": { | ||||
|           "name": "repositories_language_idx", | ||||
|           "columns": [ | ||||
|             "language" | ||||
|           ], | ||||
|           "isUnique": false | ||||
|         } | ||||
|       }, | ||||
|       "foreignKeys": { | ||||
|         "repositories_user_id_users_id_fk": { | ||||
|           "name": "repositories_user_id_users_id_fk", | ||||
|           "tableFrom": "repositories", | ||||
|           "tableTo": "users", | ||||
|           "columnsFrom": [ | ||||
|             "user_id" | ||||
|           ], | ||||
|           "columnsTo": [ | ||||
|             "id" | ||||
|           ], | ||||
|           "onDelete": "no action", | ||||
|           "onUpdate": "no action" | ||||
|         } | ||||
|       }, | ||||
|       "compositePrimaryKeys": {}, | ||||
|       "uniqueConstraints": {} | ||||
|     }, | ||||
|     "users": { | ||||
|       "name": "users", | ||||
|       "columns": { | ||||
|         "id": { | ||||
|           "name": "id", | ||||
|           "type": "integer", | ||||
|           "primaryKey": true, | ||||
|           "notNull": true, | ||||
|           "autoincrement": true | ||||
|         }, | ||||
|         "username": { | ||||
|           "name": "username", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "name": { | ||||
|           "name": "name", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "avatar": { | ||||
|           "name": "avatar", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": false, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "location": { | ||||
|           "name": "location", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": false, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "followers": { | ||||
|           "name": "followers", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false, | ||||
|           "default": 0 | ||||
|         }, | ||||
|         "following": { | ||||
|           "name": "following", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false, | ||||
|           "default": 0 | ||||
|         }, | ||||
|         "achievements": { | ||||
|           "name": "achievements", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": false, | ||||
|           "autoincrement": false, | ||||
|           "default": "'[]'" | ||||
|         }, | ||||
|         "points": { | ||||
|           "name": "points", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false, | ||||
|           "default": 0 | ||||
|         }, | ||||
|         "commits": { | ||||
|           "name": "commits", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false, | ||||
|           "default": 0 | ||||
|         }, | ||||
|         "line_of_codes": { | ||||
|           "name": "line_of_codes", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false, | ||||
|           "default": 0 | ||||
|         }, | ||||
|         "github_id": { | ||||
|           "name": "github_id", | ||||
|           "type": "integer", | ||||
|           "primaryKey": false, | ||||
|           "notNull": false, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "access_token": { | ||||
|           "name": "access_token", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": false, | ||||
|           "autoincrement": false | ||||
|         }, | ||||
|         "created_at": { | ||||
|           "name": "created_at", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false, | ||||
|           "default": "CURRENT_TIMESTAMP" | ||||
|         }, | ||||
|         "updated_at": { | ||||
|           "name": "updated_at", | ||||
|           "type": "text", | ||||
|           "primaryKey": false, | ||||
|           "notNull": true, | ||||
|           "autoincrement": false | ||||
|         } | ||||
|       }, | ||||
|       "indexes": { | ||||
|         "users_username_unique": { | ||||
|           "name": "users_username_unique", | ||||
|           "columns": [ | ||||
|             "username" | ||||
|           ], | ||||
|           "isUnique": true | ||||
|         }, | ||||
|         "users_github_id_unique": { | ||||
|           "name": "users_github_id_unique", | ||||
|           "columns": [ | ||||
|             "github_id" | ||||
|           ], | ||||
|           "isUnique": true | ||||
|         } | ||||
|       }, | ||||
|       "foreignKeys": {}, | ||||
|       "compositePrimaryKeys": {}, | ||||
|       "uniqueConstraints": {} | ||||
|     } | ||||
|   }, | ||||
|   "enums": {}, | ||||
|   "_meta": { | ||||
|     "schemas": {}, | ||||
|     "tables": {}, | ||||
|     "columns": {} | ||||
|   }, | ||||
|   "internal": { | ||||
|     "indexes": {} | ||||
|   } | ||||
| } | ||||
							
								
								
									
										13
									
								
								server/db/migrations/meta/_journal.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/db/migrations/meta/_journal.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| { | ||||
|   "version": "7", | ||||
|   "dialect": "sqlite", | ||||
|   "entries": [ | ||||
|     { | ||||
|       "idx": 0, | ||||
|       "version": "6", | ||||
|       "when": 1723071912146, | ||||
|       "tag": "0000_low_catseye", | ||||
|       "breakpoints": true | ||||
|     } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										39
									
								
								server/db/seed.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								server/db/seed.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | ||||
| import { users } from "../models"; | ||||
| import db from "."; | ||||
| import logger from "../lib/logger"; | ||||
| import { sql } from "drizzle-orm"; | ||||
| import { faker } from "@faker-js/faker"; | ||||
| import queue from "@server/lib/queue"; | ||||
| 
 | ||||
| const seed = async () => { | ||||
|   logger.info("🌿 Seeding database..."); | ||||
| 
 | ||||
|   await db.transaction(async (tx) => { | ||||
|     tx.run(sql`DELETE FROM users`); | ||||
| 
 | ||||
|     await tx | ||||
|       .insert(users) | ||||
|       .values({ username: "khairul169", name: "Khairul Hidayat" }); | ||||
| 
 | ||||
|     await tx.insert(users).values( | ||||
|       [...Array(50)].map(() => ({ | ||||
|         username: faker.internet.userName(), | ||||
|         name: faker.person.fullName(), | ||||
|         location: faker.location.city(), | ||||
|         followers: faker.number.int({ min: 0, max: 1000 }), | ||||
|         following: faker.number.int({ min: 0, max: 1000 }), | ||||
|         points: faker.number.int({ min: 20, max: 3000 }), | ||||
|         commits: faker.number.int({ min: 20, max: 420 }), | ||||
|         lineOfCodes: faker.number.int({ min: 1000, max: 300000 }), | ||||
|       })) | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   await queue.add("fetchUserProfile", { userId: 1 }); | ||||
| 
 | ||||
|   logger.info("🌱 Database seeded"); | ||||
| 
 | ||||
|   process.exit(); | ||||
| }; | ||||
| 
 | ||||
| seed(); | ||||
							
								
								
									
										11
									
								
								server/drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								server/drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| import { defineConfig } from "drizzle-kit"; | ||||
| 
 | ||||
| const DATABASE_PATH = process.env.DATABASE_PATH || "./data.db"; | ||||
| 
 | ||||
| export default defineConfig({ | ||||
|   schema: "./server/models/index.ts", | ||||
|   out: "./server/db/migrations", | ||||
|   dialect: "sqlite", | ||||
|   dbCredentials: { url: DATABASE_PATH }, | ||||
|   verbose: true, | ||||
| }); | ||||
							
								
								
									
										84
									
								
								server/jobs/calculate-user-points.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								server/jobs/calculate-user-points.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | ||||
| import db from "@server/db"; | ||||
| import { repositories, users } from "@server/models"; | ||||
| import { eq } from "drizzle-orm"; | ||||
| 
 | ||||
| type CalculateUserPointsType = { | ||||
|   userId: number; | ||||
| }; | ||||
| 
 | ||||
| const weights = { | ||||
|   followers: 20, | ||||
|   following: 10, | ||||
|   achievements: 100, | ||||
|   repositories: 1, | ||||
|   contributorsAmount: 25, | ||||
|   stars: 10, | ||||
|   forks: 10, | ||||
|   languagesKnown: 50, | ||||
|   commits: 1, | ||||
|   lineOfCodes: 0.01, | ||||
| }; | ||||
| 
 | ||||
| export const calculateUserPoints = async (data: CalculateUserPointsType) => { | ||||
|   const [user] = await db.select().from(users).where(eq(users.id, data.userId)); | ||||
|   if (!user) { | ||||
|     throw new Error("User not found!"); | ||||
|   } | ||||
| 
 | ||||
|   let points = 0; | ||||
|   let totalCommits = 0; | ||||
|   let totalLineOfCodes = 0; | ||||
| 
 | ||||
|   // User statistics
 | ||||
|   points += user.followers * weights.followers; | ||||
|   points += user.following * weights.following; | ||||
|   points += user.achievements | ||||
|     ? user.achievements?.length * weights.achievements | ||||
|     : 0; | ||||
| 
 | ||||
|   // User repositories
 | ||||
|   const repos = await db | ||||
|     .select() | ||||
|     .from(repositories) | ||||
|     .where(eq(repositories.userId, user.id)); | ||||
|   points += repos.length * weights.repositories; | ||||
| 
 | ||||
|   // Languages known
 | ||||
|   const languages = new Set( | ||||
|     repos.flatMap((i) => i.languages?.map((j) => j.lang)) | ||||
|   ); | ||||
|   points += languages.size * weights.languagesKnown; | ||||
| 
 | ||||
|   // Activities
 | ||||
|   repos.forEach((repo) => { | ||||
|     const contributors = repo.contributors?.filter( | ||||
|       (i) => i.author?.login !== user.username | ||||
|     ); | ||||
|     points += contributors | ||||
|       ? contributors.length * weights.contributorsAmount | ||||
|       : 0; | ||||
| 
 | ||||
|     points += repo.stars * weights.stars; | ||||
|     points += repo.forks * weights.forks; | ||||
| 
 | ||||
|     const contrib = repo.contributors?.find( | ||||
|       (i) => i.author?.login === user.username | ||||
|     ); | ||||
|     const commits = contrib?.commits || 0; | ||||
|     const lineOfCodes = contrib?.additions || 0; | ||||
| 
 | ||||
|     points += commits * weights.commits; | ||||
|     points += lineOfCodes * weights.lineOfCodes; | ||||
|     totalCommits += commits; | ||||
|     totalLineOfCodes += lineOfCodes; | ||||
|   }); | ||||
| 
 | ||||
|   await db | ||||
|     .update(users) | ||||
|     .set({ | ||||
|       points: Math.round(points), | ||||
|       commits: totalCommits, | ||||
|       lineOfCodes: totalLineOfCodes, | ||||
|     }) | ||||
|     .where(eq(users.id, user.id)); | ||||
| }; | ||||
							
								
								
									
										46
									
								
								server/jobs/fetch-repo-contributors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								server/jobs/fetch-repo-contributors.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| import db from "@server/db"; | ||||
| import github from "@server/lib/github"; | ||||
| import queue from "@server/lib/queue"; | ||||
| import { repositories, users } from "@server/models"; | ||||
| import { eq } from "drizzle-orm"; | ||||
| 
 | ||||
| export type FetchRepoContributorsType = { | ||||
|   id: number; | ||||
|   uri: string; | ||||
| }; | ||||
| 
 | ||||
| export const fetchRepoContributors = async ( | ||||
|   data: FetchRepoContributorsType | ||||
| ) => { | ||||
|   const [repo] = await db | ||||
|     .select({ id: repositories.id, userAccessToken: users.accessToken }) | ||||
|     .from(repositories) | ||||
|     .innerJoin(users, eq(users.id, repositories.userId)) | ||||
|     .where(eq(repositories.id, data.id)); | ||||
| 
 | ||||
|   if (!repo) { | ||||
|     throw new Error("Repository not found!"); | ||||
|   } | ||||
| 
 | ||||
|   if (!repo.userAccessToken) { | ||||
|     throw new Error("User access token not found!"); | ||||
|   } | ||||
| 
 | ||||
|   const contributors = await github.getRepoContributors(data.uri, { | ||||
|     headers: { | ||||
|       Authorization: `Bearer ${repo.userAccessToken}`, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   const [result] = await db | ||||
|     .update(repositories) | ||||
|     .set({ contributors }) | ||||
|     .where(eq(repositories.id, data.id)) | ||||
|     .returning(); | ||||
| 
 | ||||
|   if (!result) { | ||||
|     throw new Error("Cannot update repository!"); | ||||
|   } | ||||
| 
 | ||||
|   await queue.add("calculateUserPoints", { userId: result.userId }); | ||||
| }; | ||||
							
								
								
									
										26
									
								
								server/jobs/fetch-repo-data.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								server/jobs/fetch-repo-data.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | ||||
| import db from "@server/db"; | ||||
| import github from "@server/lib/github"; | ||||
| import queue from "@server/lib/queue"; | ||||
| import { repositories } from "@server/models"; | ||||
| import { eq } from "drizzle-orm"; | ||||
| 
 | ||||
| export type FetchRepoDataJobType = { | ||||
|   id: number; | ||||
|   uri: string; | ||||
| }; | ||||
| 
 | ||||
| export const fetchRepoData = async (data: FetchRepoDataJobType) => { | ||||
|   const details = await github.getRepoDetails(data.uri); | ||||
| 
 | ||||
|   const [result] = await db | ||||
|     .update(repositories) | ||||
|     .set({ languages: details.languages }) | ||||
|     .where(eq(repositories.id, data.id)) | ||||
|     .returning(); | ||||
| 
 | ||||
|   if (!result) { | ||||
|     throw new Error("Repository not found!"); | ||||
|   } | ||||
| 
 | ||||
|   await queue.add("calculateUserPoints", { userId: result.userId }); | ||||
| }; | ||||
							
								
								
									
										30
									
								
								server/jobs/fetch-user-profile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								server/jobs/fetch-user-profile.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| import db from "@server/db"; | ||||
| import github from "@server/lib/github"; | ||||
| import queue from "@server/lib/queue"; | ||||
| import { users } from "@server/models"; | ||||
| import { eq } from "drizzle-orm"; | ||||
| 
 | ||||
| export type FetchUserProfileType = { | ||||
|   userId: number; | ||||
| }; | ||||
| 
 | ||||
| export const fetchUserProfile = async (data: FetchUserProfileType) => { | ||||
|   const [user] = await db.select().from(users).where(eq(users.id, data.userId)); | ||||
|   if (!user) { | ||||
|     throw new Error("User not found!"); | ||||
|   } | ||||
|   const details = await github.getUser(user.username); | ||||
| 
 | ||||
|   await db | ||||
|     .update(users) | ||||
|     .set({ | ||||
|       name: details.name, | ||||
|       followers: details.followers, | ||||
|       following: details.following, | ||||
|       location: details.location, | ||||
|       achievements: details.achievements || user.achievements, | ||||
|     }) | ||||
|     .where(eq(users.id, user.id)); | ||||
| 
 | ||||
|   await queue.add("calculateUserPoints", { userId: user.id }); | ||||
| }; | ||||
							
								
								
									
										69
									
								
								server/jobs/fetch-user-repos.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								server/jobs/fetch-user-repos.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,69 @@ | ||||
| import db from "@server/db"; | ||||
| import github from "@server/lib/github"; | ||||
| import queue from "@server/lib/queue"; | ||||
| import { repositories, users } from "@server/models"; | ||||
| import { and, eq } from "drizzle-orm"; | ||||
| import { FetchRepoDataJobType } from "./fetch-repo-data"; | ||||
| 
 | ||||
| export type FetchUserRepos = { | ||||
|   userId: number; | ||||
| }; | ||||
| 
 | ||||
| export const fetchUserRepos = async (data: FetchUserRepos) => { | ||||
|   const [user] = await db.select().from(users).where(eq(users.id, data.userId)); | ||||
|   if (!user) { | ||||
|     throw new Error("User not found!"); | ||||
|   } | ||||
| 
 | ||||
|   const res = await github.getRepositories(user.username, { | ||||
|     sort: "stargazers", | ||||
|     fetchAll: true, | ||||
|   }); | ||||
| 
 | ||||
|   const jobList = [] as FetchRepoDataJobType[]; | ||||
| 
 | ||||
|   await db.transaction(async (tx) => { | ||||
|     for (const repo of res.repositories) { | ||||
|       const data = { | ||||
|         ...repo, | ||||
|         userId: user.id, | ||||
|         lastUpdate: repo.lastUpdate.toISOString(), | ||||
|       }; | ||||
| 
 | ||||
|       const [existing] = await tx | ||||
|         .select({ id: repositories.id }) | ||||
|         .from(repositories) | ||||
|         .where( | ||||
|           and( | ||||
|             eq(repositories.userId, data.userId), | ||||
|             eq(repositories.name, data.name) | ||||
|           ) | ||||
|         ); | ||||
| 
 | ||||
|       if (existing) { | ||||
|         await tx | ||||
|           .update(repositories) | ||||
|           .set(data) | ||||
|           .where(eq(repositories.id, existing.id)); | ||||
|         jobList.push({ id: existing.id, uri: data.uri }); | ||||
|       } else { | ||||
|         const [result] = await tx.insert(repositories).values(data).returning(); | ||||
|         jobList.push({ id: result.id, uri: data.uri }); | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   // Queue fetch repo details
 | ||||
|   queue.addBulk(jobList.map((data) => ({ name: "fetchRepoData", data }))); | ||||
|   queue.addBulk( | ||||
|     jobList.map((data) => ({ | ||||
|       name: "fetchRepoContributors", | ||||
|       data, | ||||
|       opts: { | ||||
|         attempts: 5, | ||||
|         backoff: { type: "exponential", delay: 30000 }, | ||||
|         jobId: `contributors:${data.uri}`, | ||||
|       }, | ||||
|     })) | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										15
									
								
								server/jobs/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								server/jobs/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| import { calculateUserPoints } from "./calculate-user-points"; | ||||
| import { fetchRepoContributors } from "./fetch-repo-contributors"; | ||||
| import { fetchRepoData } from "./fetch-repo-data"; | ||||
| import { fetchUserProfile } from "./fetch-user-profile"; | ||||
| import { fetchUserRepos } from "./fetch-user-repos"; | ||||
| 
 | ||||
| export const jobs = { | ||||
|   fetchUserRepos, | ||||
|   fetchRepoData, | ||||
|   fetchRepoContributors, | ||||
|   calculateUserPoints, | ||||
|   fetchUserProfile, | ||||
| }; | ||||
| 
 | ||||
| export type JobNames = keyof typeof jobs; | ||||
							
								
								
									
										12
									
								
								server/lib/consts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/lib/consts.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| //
 | ||||
| 
 | ||||
| export const __PROD = import.meta.env.NODE_ENV === "production"; | ||||
| export const __DEV = !__PROD; | ||||
| 
 | ||||
| export const BULLMQ_CONNECTION = { | ||||
|   host: import.meta.env.REDIS_HOST || "127.0.0.1", | ||||
|   port: Number(import.meta.env.REDIS_PORT) || 6379, | ||||
| }; | ||||
| export const BULLMQ_JOB_NAME = "ghcontribjob"; | ||||
| 
 | ||||
| export const JWT_SECRET = import.meta.env.JWT_SECRET || "secret"; | ||||
							
								
								
									
										283
									
								
								server/lib/github.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										283
									
								
								server/lib/github.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,283 @@ | ||||
| import * as cheerio from "cheerio"; | ||||
| import { intval } from "./utils"; | ||||
| import dayjs from "dayjs"; | ||||
| 
 | ||||
| const GITHUB_URL = "https://github.com"; | ||||
| const GITHUB_API_URL = "https://api.github.com"; | ||||
| 
 | ||||
| const selectors = { | ||||
|   user: { | ||||
|     name: "h1.vcard-names > span.vcard-fullname", | ||||
|     location: "li[itemprop='homeLocation'] span", | ||||
|     followers: ".js-profile-editable-area a[href$='?tab=followers'] > span", | ||||
|     following: ".js-profile-editable-area a[href$='?tab=following'] > span", | ||||
|     achievement: "img.achievement-badge-sidebar", | ||||
|   }, | ||||
| 
 | ||||
|   repo: { | ||||
|     list: "div#user-repositories-list li", | ||||
|     listForked: ':contains("Forked")', | ||||
|     listLanguage: "span[itemprop='programmingLanguage']", | ||||
|     listStars: "a[href$='stargazers']", | ||||
|     listForks: "a[href$='forks']", | ||||
|     langList: ".Layout-sidebar h2:contains('Languages')", | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| const github = { | ||||
|   async getUser(username: string) { | ||||
|     const response = await this.fetch(username); | ||||
|     const $ = cheerio.load(response); | ||||
| 
 | ||||
|     const name = $(selectors.user.name).text().trim(); | ||||
|     if (typeof name !== "string" || !name?.length) { | ||||
|       throw new Error("User not found"); | ||||
|     } | ||||
| 
 | ||||
|     const location = $(selectors.user.location).text().trim(); | ||||
|     const followers = intval($(selectors.user.followers).text().trim()); | ||||
|     const following = intval($(selectors.user.following).text().trim()); | ||||
|     const achievements = [] as { name: string; image?: string }[]; | ||||
| 
 | ||||
|     $(selectors.user.achievement).each((_i, el) => { | ||||
|       const name = $(el).attr("alt")?.split(" ")[1] || ""; | ||||
|       const image = $(el).attr("src"); | ||||
|       achievements.push({ name, image }); | ||||
|     }); | ||||
| 
 | ||||
|     return { name, username, location, followers, following, achievements }; | ||||
|   }, | ||||
| 
 | ||||
|   async getRepositories( | ||||
|     username: string, | ||||
|     params?: Partial<GetRepositoriesParams> | ||||
|   ) { | ||||
|     const response = await this.fetch(username, { | ||||
|       params: { | ||||
|         tab: "repositories", | ||||
|         type: "public", | ||||
|         ...params, | ||||
|       }, | ||||
|     }); | ||||
|     const $ = cheerio.load(response); | ||||
|     let repositories = [] as { | ||||
|       name: string; | ||||
|       uri: string; | ||||
|       language: string; | ||||
|       stars: number; | ||||
|       forks: number; | ||||
|       lastUpdate: Date; | ||||
|     }[]; | ||||
| 
 | ||||
|     $(selectors.repo.list).each((_i, el) => { | ||||
|       const isForked = $(el).find(selectors.repo.listForked).length > 0; | ||||
|       if (isForked) return; | ||||
| 
 | ||||
|       const name = $(el).find("h3 > a").text().trim(); | ||||
|       const language = $(el).find(selectors.repo.listLanguage).text().trim(); | ||||
|       const stars = intval($(el).find(selectors.repo.listStars).text().trim()); | ||||
|       const forks = intval($(el).find(selectors.repo.listForks).text().trim()); | ||||
|       const lastUpdate = $(el).find("relative-time").attr("datetime"); | ||||
| 
 | ||||
|       repositories.push({ | ||||
|         name, | ||||
|         uri: `${username}/${name}`, | ||||
|         language, | ||||
|         stars, | ||||
|         forks, | ||||
|         lastUpdate: dayjs(lastUpdate).toDate(), | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     const prevPage = intval( | ||||
|       $("a.prev_page") | ||||
|         .attr("href") | ||||
|         ?.match(/page=(\d+)/)?.[1] | ||||
|     ); | ||||
|     const nextPage = intval( | ||||
|       $("a.next_page") | ||||
|         .attr("href") | ||||
|         ?.match(/page=(\d+)/)?.[1] | ||||
|     ); | ||||
| 
 | ||||
|     if (params?.fetchAll && nextPage > 1 && nextPage < 10) { | ||||
|       try { | ||||
|         const nextPageRes = await this.getRepositories(username, { | ||||
|           ...params, | ||||
|           page: nextPage, | ||||
|         }); | ||||
|         if (nextPageRes.repositories?.length > 0) { | ||||
|           repositories = [...repositories, ...nextPageRes.repositories]; | ||||
|         } | ||||
|       } catch (err) { | ||||
|         //
 | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return { repositories, prevPage, nextPage }; | ||||
|   }, | ||||
| 
 | ||||
|   async getRepoDetails(repo: string) { | ||||
|     const response = await this.fetch(repo); | ||||
|     const $ = cheerio.load(response); | ||||
| 
 | ||||
|     const languages = [] as { lang: string; amount: number }[]; | ||||
| 
 | ||||
|     $(selectors.repo.langList) | ||||
|       .parent() | ||||
|       .find("ul > li > a") | ||||
|       .each((_i, el) => { | ||||
|         const lang = $(el).children().eq(1).text().trim(); | ||||
|         const percentage = $(el).children().eq(2).text().trim(); | ||||
|         const amount = parseFloat(percentage?.replace(/[^0-9.]/, "")) || 0; | ||||
|         languages.push({ lang, amount }); | ||||
|       }); | ||||
| 
 | ||||
|     return { languages }; | ||||
|   }, | ||||
| 
 | ||||
|   async getRepoContributors(repo: string, options?: Partial<FetchOptions>) { | ||||
|     const response = await this.fetch(`repos/${repo}/stats/contributors`, { | ||||
|       ...options, | ||||
|       ghApi: true, | ||||
|       headers: { accept: "application/json", ...(options?.headers || {}) }, | ||||
|     }); | ||||
| 
 | ||||
|     if (!Array.isArray(response)) { | ||||
|       throw new Error("Invalid response: " + JSON.stringify(response)); | ||||
|     } | ||||
| 
 | ||||
|     const result = response | ||||
|       .map((item: any) => { | ||||
|         const { author, total, weeks } = item; | ||||
|         let additions = 0; | ||||
|         let deletions = 0; | ||||
|         let commits = 0; | ||||
| 
 | ||||
|         weeks.forEach((week: any) => { | ||||
|           additions += week.a || 0; | ||||
|           deletions += week.d || 0; | ||||
|           commits += week.c || 0; | ||||
|         }); | ||||
| 
 | ||||
|         return { author, total, additions, deletions, commits }; | ||||
|       }) | ||||
|       .sort((a, b) => b.total - a.total); | ||||
| 
 | ||||
|     return result; | ||||
|   }, | ||||
| 
 | ||||
|   async getAllData(username: string, options?: Partial<GetAllDataOptions>) { | ||||
|     const user = await this.getUser(username); | ||||
|     const repositories = [] as (Repository & { | ||||
|       languages: Language[]; | ||||
|       contributors: Contributors; | ||||
|     })[]; | ||||
| 
 | ||||
|     const _repos = await this.getRepositories(username, { | ||||
|       sort: "stargazers", | ||||
|       fetchAll: true, | ||||
|     }); | ||||
| 
 | ||||
|     const repoCount = Math.min( | ||||
|       _repos.repositories.length, | ||||
|       options?.maxRepo || Number.POSITIVE_INFINITY | ||||
|     ); | ||||
| 
 | ||||
|     for (let idx = 0; idx < repoCount; idx++) { | ||||
|       const repo = _repos.repositories[idx]; | ||||
|       const [details, contributors] = await Promise.all([ | ||||
|         this.getRepoDetails(repo.uri), | ||||
|         this.getRepoContributors(repo.uri), | ||||
|       ]); | ||||
| 
 | ||||
|       repositories.push({ | ||||
|         ...repo, | ||||
|         languages: details.languages, | ||||
|         contributors, | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     return { user, repositories }; | ||||
|   }, | ||||
| 
 | ||||
|   async fetch<T = any>(path: string, options?: Partial<FetchOptions>) { | ||||
|     const url = new URL( | ||||
|       "/" + path, | ||||
|       options?.ghApi ? GITHUB_API_URL : GITHUB_URL | ||||
|     ); | ||||
| 
 | ||||
|     if (options?.params) { | ||||
|       Object.entries(options.params).forEach(([key, value]) => { | ||||
|         url.searchParams.append(key, value as string); | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     const headers = { | ||||
|       "User-Agent": | ||||
|         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", | ||||
|       ...(options?.headers || {}), | ||||
|     }; | ||||
| 
 | ||||
|     if (options?.xhr) { | ||||
|       headers["X-Requested-With"] = "XMLHttpRequest"; | ||||
|     } | ||||
| 
 | ||||
|     const init = { | ||||
|       method: "GET", | ||||
|       headers, | ||||
|       referrer: options?.referrer || GITHUB_URL, | ||||
|     }; | ||||
| 
 | ||||
|     const res = await fetch(url, init); | ||||
|     if (!res.ok) { | ||||
|       throw new Error(res.statusText); | ||||
|     } | ||||
| 
 | ||||
|     const type = res.headers.get("Content-Type"); | ||||
| 
 | ||||
|     if (type?.includes("application/json")) { | ||||
|       return res.json() as T; | ||||
|     } | ||||
| 
 | ||||
|     return res.text(); | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| type FetchOptions = { | ||||
|   xhr: boolean; | ||||
|   ghApi: boolean; | ||||
|   params: any; | ||||
|   headers: any; | ||||
|   referrer: string; | ||||
| }; | ||||
| 
 | ||||
| type GetRepositoriesParams = { | ||||
|   page: string | number; | ||||
|   sort: "stargazers" | "name" | null; | ||||
|   fetchAll: boolean; | ||||
| }; | ||||
| 
 | ||||
| type GetAllDataOptions = { | ||||
|   maxRepo: number; | ||||
| }; | ||||
| 
 | ||||
| export type GithubUser = Awaited<ReturnType<typeof github.getUser>>; | ||||
| 
 | ||||
| export type Repository = Awaited< | ||||
|   ReturnType<typeof github.getRepositories> | ||||
| >["repositories"][number]; | ||||
| 
 | ||||
| export type Language = Awaited< | ||||
|   ReturnType<typeof github.getRepoDetails> | ||||
| >["languages"][number]; | ||||
| 
 | ||||
| export type Contributors = Awaited< | ||||
|   ReturnType<typeof github.getRepoContributors> | ||||
| >; | ||||
| 
 | ||||
| export type Contributor = NonNullable<Contributors>[number]; | ||||
| 
 | ||||
| export type Achievement = GithubUser["achievements"][number]; | ||||
| 
 | ||||
| export default github; | ||||
							
								
								
									
										17
									
								
								server/lib/logger.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/lib/logger.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| import pino from "pino"; | ||||
| import { __DEV } from "./consts"; | ||||
| 
 | ||||
| const logger = pino( | ||||
|   __DEV | ||||
|     ? { | ||||
|         transport: { | ||||
|           target: "pino-pretty", | ||||
|           options: { | ||||
|             colorize: true, | ||||
|           }, | ||||
|         }, | ||||
|       } | ||||
|     : {} | ||||
| ); | ||||
| 
 | ||||
| export default logger; | ||||
							
								
								
									
										19
									
								
								server/lib/queue.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								server/lib/queue.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| import { Queue } from "bullmq"; | ||||
| import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./consts"; | ||||
| import logger from "./logger"; | ||||
| import type { JobNames } from "@server/jobs"; | ||||
| 
 | ||||
| const queue = new Queue<any, any, JobNames>(BULLMQ_JOB_NAME, { | ||||
|   connection: BULLMQ_CONNECTION, | ||||
|   defaultJobOptions: { | ||||
|     attempts: 5, | ||||
|     backoff: { | ||||
|       type: "exponential", | ||||
|       delay: 3000, | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| queue.on("error", logger.error); | ||||
| 
 | ||||
| export default queue; | ||||
							
								
								
									
										10
									
								
								server/lib/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/lib/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| //
 | ||||
| 
 | ||||
| export const intval = (value: any) => { | ||||
|   if (typeof value === "number" && !Number.isNaN(value)) { | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   const num = parseInt(value); | ||||
|   return Number.isNaN(num) ? 0 : num; | ||||
| }; | ||||
							
								
								
									
										40
									
								
								server/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								server/main.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | ||||
| import { Hono } from "hono"; | ||||
| import { cors } from "hono/cors"; | ||||
| import { serveStatic } from "hono/bun"; | ||||
| import router from "./router"; | ||||
| import logger from "./lib/logger"; | ||||
| import { __DEV, __PROD } from "./lib/consts"; | ||||
| 
 | ||||
| const HOST = import.meta.env.HOST || "127.0.0.1"; | ||||
| const PORT = Number(import.meta.env.PORT) || 5589; | ||||
| 
 | ||||
| const app = new Hono(); | ||||
| 
 | ||||
| app.onError((err, c) => { | ||||
|   logger.error(err); | ||||
|   return c.text(err.message, 500); | ||||
| }); | ||||
| 
 | ||||
| // Allow all origin on development
 | ||||
| if (__DEV) { | ||||
|   app.use(cors({ origin: "*" })); | ||||
| } | ||||
| 
 | ||||
| // Health check
 | ||||
| app.get("/health", (c) => c.text("OK")); | ||||
| 
 | ||||
| // Serve prod client app
 | ||||
| if (__PROD) { | ||||
|   app.use("*", serveStatic({ root: "./dist/client" })); | ||||
| } | ||||
| 
 | ||||
| // API router
 | ||||
| app.route("/api", router); | ||||
| 
 | ||||
| Bun.serve({ | ||||
|   fetch: app.fetch, | ||||
|   hostname: HOST, | ||||
|   port: PORT, | ||||
| }); | ||||
| 
 | ||||
| logger.info(`Server started at http://${HOST}:${PORT}`); | ||||
							
								
								
									
										34
									
								
								server/middlewares/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								server/middlewares/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | ||||
| import { JWT_SECRET } from "@server/lib/consts"; | ||||
| import { Context, Next } from "hono"; | ||||
| import { getCookie } from "hono/cookie"; | ||||
| import { HTTPException } from "hono/http-exception"; | ||||
| import * as jwt from "hono/jwt"; | ||||
| 
 | ||||
| type AuthOptions = { | ||||
|   required: boolean; | ||||
| }; | ||||
| 
 | ||||
| export const auth = | ||||
|   (opt?: Partial<AuthOptions>) => async (c: Context, next: Next) => { | ||||
|     try { | ||||
|       const token = getCookie(c, "token"); | ||||
|       if (!token) { | ||||
|         throw new Error("No token found!"); | ||||
|       } | ||||
| 
 | ||||
|       const jwtData: any = await jwt.verify(token, JWT_SECRET); | ||||
|       const userId = jwtData.id; | ||||
| 
 | ||||
|       if (!userId) { | ||||
|         throw new Error("No user id found!"); | ||||
|       } | ||||
| 
 | ||||
|       c.set("userId", userId); | ||||
|     } catch (err) { | ||||
|       if (opt?.required) { | ||||
|         throw new HTTPException(401, { message: "Unauthorized!" }); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return next(); | ||||
|   }; | ||||
							
								
								
									
										2
									
								
								server/models/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/models/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| export { users } from "./users"; | ||||
| export { repositories } from "./repositories"; | ||||
							
								
								
									
										38
									
								
								server/models/repositories.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								server/models/repositories.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| import { Contributor, Language } from "@server/lib/github"; | ||||
| import { InferInsertModel, InferSelectModel, sql } from "drizzle-orm"; | ||||
| import { text, sqliteTable, integer, index } from "drizzle-orm/sqlite-core"; | ||||
| import { users } from "./users"; | ||||
| 
 | ||||
| export const repositories = sqliteTable( | ||||
|   "repositories", | ||||
|   { | ||||
|     id: integer("id").primaryKey({ autoIncrement: true }), | ||||
|     userId: integer("user_id") | ||||
|       .notNull() | ||||
|       .references(() => users.id), | ||||
| 
 | ||||
|     name: text("name").notNull(), | ||||
|     uri: text("uri").notNull(), | ||||
|     language: text("language").notNull(), | ||||
|     stars: integer("stars").notNull(), | ||||
|     forks: integer("stars").notNull(), | ||||
|     lastUpdate: text("last_update").notNull(), | ||||
|     languages: text("languages", { mode: "json" }).$type<Language[]>(), | ||||
|     contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(), | ||||
| 
 | ||||
|     createdAt: text("created_at") | ||||
|       .notNull() | ||||
|       .default(sql`CURRENT_TIMESTAMP`), | ||||
|     updatedAt: text("updated_at") | ||||
|       .notNull() | ||||
|       .$onUpdate(() => sql`CURRENT_TIMESTAMP`), | ||||
|   }, | ||||
|   (t) => ({ | ||||
|     nameIdx: index("repositories_name_idx").on(t.name), | ||||
|     uriIdx: index("repositories_uri_idx").on(t.uri), | ||||
|     language: index("repositories_language_idx").on(t.language), | ||||
|   }) | ||||
| ); | ||||
| 
 | ||||
| export type Repository = InferSelectModel<typeof repositories>; | ||||
| export type CreateRepository = InferInsertModel<typeof repositories>; | ||||
							
								
								
									
										31
									
								
								server/models/users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								server/models/users.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| import { Achievement } from "@server/lib/github"; | ||||
| import { InferInsertModel, InferSelectModel, sql } from "drizzle-orm"; | ||||
| import { text, sqliteTable, integer } from "drizzle-orm/sqlite-core"; | ||||
| 
 | ||||
| export const users = sqliteTable("users", { | ||||
|   id: integer("id").primaryKey({ autoIncrement: true }), | ||||
|   username: text("username").notNull().unique(), | ||||
|   name: text("name").notNull(), | ||||
|   avatar: text("avatar"), | ||||
|   location: text("location"), | ||||
|   followers: integer("followers").notNull().default(0), | ||||
|   following: integer("following").notNull().default(0), | ||||
|   achievements: text("achievements", { mode: "json" }) | ||||
|     .$type<Achievement[]>() | ||||
|     .default([]), | ||||
|   points: integer("points").notNull().default(0), | ||||
|   commits: integer("commits").notNull().default(0), | ||||
|   lineOfCodes: integer("line_of_codes").notNull().default(0), | ||||
|   githubId: integer("github_id").unique(), | ||||
|   accessToken: text("access_token"), | ||||
| 
 | ||||
|   createdAt: text("created_at") | ||||
|     .notNull() | ||||
|     .default(sql`CURRENT_TIMESTAMP`), | ||||
|   updatedAt: text("updated_at") | ||||
|     .notNull() | ||||
|     .$onUpdate(() => sql`CURRENT_TIMESTAMP`), | ||||
| }); | ||||
| 
 | ||||
| export type User = InferSelectModel<typeof users>; | ||||
| export type CreateUser = InferInsertModel<typeof users>; | ||||
							
								
								
									
										42
									
								
								server/queue-worker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								server/queue-worker.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | ||||
| import { Job, Worker } from "bullmq"; | ||||
| import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./lib/consts"; | ||||
| import logger from "./lib/logger"; | ||||
| import { jobs } from "./jobs"; | ||||
| 
 | ||||
| const handler = async (job: Job) => { | ||||
|   const jobFn = (jobs as any)[job.name]; | ||||
| 
 | ||||
|   if (jobFn) { | ||||
|     return jobFn(job.data); | ||||
|   } | ||||
| 
 | ||||
|   return false; | ||||
| }; | ||||
| 
 | ||||
| const worker = new Worker(BULLMQ_JOB_NAME, handler, { | ||||
|   connection: BULLMQ_CONNECTION, | ||||
|   concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1, | ||||
|   removeOnComplete: { count: 0 }, | ||||
|   removeOnFail: { count: 0 }, | ||||
| }); | ||||
| 
 | ||||
| worker.on("error", logger.error); | ||||
| 
 | ||||
| worker.on("active", (job) => { | ||||
|   logger.info(`Job ${job.name}.${job.id} started.`); | ||||
| }); | ||||
| 
 | ||||
| worker.on("failed", (job, err) => { | ||||
|   logger.child({ jobId: job?.id }).error(err); | ||||
| }); | ||||
| 
 | ||||
| worker.on("completed", (job, result) => { | ||||
|   logger.info({ | ||||
|     msg: `Job ${job.name}.${job.id} completed.`, | ||||
|     result, | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| worker.on("ready", () => { | ||||
|   logger.info("Worker ready!"); | ||||
| }); | ||||
							
								
								
									
										17
									
								
								server/router.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/router.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| import { Hono } from "hono"; | ||||
| import { auth } from "./routes/auth"; | ||||
| import { leaderboard } from "./routes/leaderboard"; | ||||
| 
 | ||||
| const router = new Hono() | ||||
|   .route("/auth", auth) | ||||
|   .route("/leaderboard", leaderboard); | ||||
| 
 | ||||
| export type AppType = typeof router; | ||||
| 
 | ||||
| declare module "hono" { | ||||
|   interface ContextVariableMap { | ||||
|     userId?: number; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										115
									
								
								server/routes/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								server/routes/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,115 @@ | ||||
| import db from "@server/db"; | ||||
| import { JWT_SECRET } from "@server/lib/consts"; | ||||
| import github from "@server/lib/github"; | ||||
| import queue from "@server/lib/queue"; | ||||
| import { repositories, users } from "@server/models"; | ||||
| import { CreateUser } from "@server/models/users"; | ||||
| import { eq } from "drizzle-orm"; | ||||
| import { Hono } from "hono"; | ||||
| import { setCookie } from "hono/cookie"; | ||||
| import * as jwt from "hono/jwt"; | ||||
| import { auth as authMiddleware } from "../middlewares/auth"; | ||||
| 
 | ||||
| const { GITHUB_CLIENT_ID, GITHUB_SECRET_KEY } = import.meta.env; | ||||
| 
 | ||||
| export const auth = new Hono() | ||||
| 
 | ||||
|   /** | ||||
|    * Redirect to github oauth | ||||
|    */ | ||||
|   .get("/login", (c) => { | ||||
|     return c.redirect( | ||||
|       "https://github.com/login/oauth/authorize?client_id=" + GITHUB_CLIENT_ID | ||||
|     ); | ||||
|   }) | ||||
| 
 | ||||
|   /** | ||||
|    * Auth callback | ||||
|    */ | ||||
|   .get("/callback", async (c) => { | ||||
|     const code = c.req.query("code"); | ||||
|     const result = await github.fetch("login/oauth/access_token", { | ||||
|       params: { | ||||
|         client_id: GITHUB_CLIENT_ID, | ||||
|         client_secret: GITHUB_SECRET_KEY, | ||||
|         code, | ||||
|       }, | ||||
|       headers: { | ||||
|         accept: "application/json", | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const accessToken = result.access_token; | ||||
|     const ghUser = await github.fetch("user", { | ||||
|       ghApi: true, | ||||
|       headers: { | ||||
|         accept: "application/json", | ||||
|         Authorization: "Bearer " + accessToken, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const userData: CreateUser = { | ||||
|       username: ghUser.login, | ||||
|       name: ghUser.name, | ||||
|       avatar: ghUser.avatar_url, | ||||
|       location: ghUser.location, | ||||
|       accessToken, | ||||
|       githubId: ghUser.id, | ||||
|       followers: ghUser.followers, | ||||
|       following: ghUser.following, | ||||
|     }; | ||||
| 
 | ||||
|     const [user] = await db | ||||
|       .insert(users) | ||||
|       .values(userData) | ||||
|       .onConflictDoUpdate({ | ||||
|         target: users.username, | ||||
|         set: userData, | ||||
|       }) | ||||
|       .returning(); | ||||
| 
 | ||||
|     if (!user) { | ||||
|       throw new Error("Auth user failed!"); | ||||
|     } | ||||
| 
 | ||||
|     // Fetch latest user profile
 | ||||
|     await queue.add("fetchUserProfile", { userId: user.id }); | ||||
| 
 | ||||
|     // Fetch user repositories
 | ||||
|     const [hasRepo] = await db | ||||
|       .select({ id: repositories.id }) | ||||
|       .from(repositories) | ||||
|       .where(eq(repositories.userId, user.id)) | ||||
|       .limit(1); | ||||
| 
 | ||||
|     if (!hasRepo) { | ||||
|       await queue.add("fetchUserRepos", { userId: user.id }); | ||||
|     } | ||||
| 
 | ||||
|     const authToken = await jwt.sign({ id: user.id }, JWT_SECRET); | ||||
|     setCookie(c, "token", authToken, { httpOnly: true }); | ||||
| 
 | ||||
|     return c.redirect("/"); | ||||
|   }) | ||||
| 
 | ||||
|   /** | ||||
|    * Get authenticated user | ||||
|    */ | ||||
|   .get("/user", authMiddleware(), async (c) => { | ||||
|     const userId = c.get("userId"); | ||||
|     if (!userId) { | ||||
|       return c.json(null); | ||||
|     } | ||||
| 
 | ||||
|     const [user] = await db.select().from(users).where(eq(users.id, userId)); | ||||
| 
 | ||||
|     return c.json(user ? { ...user, accessToken: undefined } : null); | ||||
|   }) | ||||
| 
 | ||||
|   /** | ||||
|    * Logout | ||||
|    */ | ||||
|   .get("/logout", authMiddleware({ required: true }), async (c) => { | ||||
|     setCookie(c, "token", "", { httpOnly: true }); | ||||
|     return c.redirect("/"); | ||||
|   }); | ||||
							
								
								
									
										80
									
								
								server/routes/leaderboard.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								server/routes/leaderboard.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,80 @@ | ||||
| import db from "@server/db"; | ||||
| import { repositories, users } from "@server/models"; | ||||
| import { desc, eq, getTableColumns, sql } from "drizzle-orm"; | ||||
| import { Hono } from "hono"; | ||||
| import { HTTPException } from "hono/http-exception"; | ||||
| 
 | ||||
| export const leaderboard = new Hono() | ||||
| 
 | ||||
|   /** | ||||
|    * Get users leaderboard | ||||
|    */ | ||||
|   .get("/", async (c) => { | ||||
|     const rows = await db | ||||
|       .select() | ||||
|       .from(users) | ||||
|       .orderBy(desc(users.points)) | ||||
|       .limit(100); | ||||
|     const result = rows.map((data, idx) => ({ ...data, rank: idx + 1 })); | ||||
| 
 | ||||
|     return c.json(result); | ||||
|   }) | ||||
| 
 | ||||
|   /** | ||||
|    * Get specific user data | ||||
|    */ | ||||
|   .get("/:username", async (c) => { | ||||
|     const { username } = c.req.param(); | ||||
| 
 | ||||
|     const rankSubquery = db | ||||
|       .select({ | ||||
|         userId: users.id, | ||||
|         value: sql`rank() over (order by points desc)`.as("rank"), | ||||
|       }) | ||||
|       .from(users) | ||||
|       .orderBy(desc(users.points)) | ||||
|       .as("rank"); | ||||
| 
 | ||||
|     const [user] = await db | ||||
|       .select({ | ||||
|         ...getTableColumns(users), | ||||
|         accessToken: sql`null`, | ||||
|         rank: rankSubquery.value, | ||||
|       }) | ||||
|       .from(users) | ||||
|       .leftJoin(rankSubquery, eq(users.id, rankSubquery.userId)) | ||||
|       .where(eq(users.username, username)); | ||||
| 
 | ||||
|     if (!user) { | ||||
|       throw new HTTPException(404, { message: "User not found!" }); | ||||
|     } | ||||
| 
 | ||||
|     const repos = await db | ||||
|       .select() | ||||
|       .from(repositories) | ||||
|       .where(eq(repositories.userId, user.id)) | ||||
|       .orderBy(desc(repositories.stars), desc(repositories.forks)); | ||||
| 
 | ||||
|     const languageMap: Record<string, number> = {}; | ||||
|     repos | ||||
|       .flatMap((i) => i.languages || []) | ||||
|       .forEach((i) => { | ||||
|         if (!languageMap[i.lang]) languageMap[i.lang] = 0; | ||||
|         languageMap[i.lang] += i.amount; | ||||
|       }); | ||||
| 
 | ||||
|     const totalLangWeight = Object.values(languageMap).reduce( | ||||
|       (a, b) => a + b, | ||||
|       0 | ||||
|     ); | ||||
|     let languages = [] as { name: string; percent: number }[]; | ||||
|     for (const [lang, amount] of Object.entries(languageMap)) { | ||||
|       languages.push({ | ||||
|         name: lang, | ||||
|         percent: (amount / totalLangWeight) * 100, | ||||
|       }); | ||||
|     } | ||||
|     languages = languages.sort((a, b) => b.percent - a.percent); | ||||
| 
 | ||||
|     return c.json({ user, repositories: repos, languages }); | ||||
|   }); | ||||
							
								
								
									
										15
									
								
								src/app/app.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/app/app.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| import { AuthProvider } from "@client/components/context/auth-context"; | ||||
| import MainLayout from "@client/components/layouts/main-layout"; | ||||
| import HomePage from "@client/pages/home/page"; | ||||
| 
 | ||||
| const App = () => { | ||||
|   return ( | ||||
|     <AuthProvider> | ||||
|       <MainLayout> | ||||
|         <HomePage /> | ||||
|       </MainLayout> | ||||
|     </AuthProvider> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default App; | ||||
							
								
								
									
										13
									
								
								src/app/styles.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/app/styles.css
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap"); | ||||
| 
 | ||||
| @tailwind base; | ||||
| @tailwind components; | ||||
| @tailwind utilities; | ||||
| 
 | ||||
| @layer base { | ||||
|   html, | ||||
|   body { | ||||
|     @apply bg-base-200 min-h-screen; | ||||
|     font-family: "Poppins", system-ui, sans-serif; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1
									
								
								src/assets/react.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/assets/react.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> | ||||
| After Width: | Height: | Size: 4.0 KiB | 
							
								
								
									
										1
									
								
								src/assets/stars-animation.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										1
									
								
								src/assets/stars-animation.json
									
									
									
									
									
										Executable file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										72
									
								
								src/components/containers/appbar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/components/containers/appbar.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | ||||
| import { onLogin, onLogout, useAuth } from "@client/hooks/useAuth"; | ||||
| import { dummyAvatar } from "@client/lib/utils"; | ||||
| import { Avatar, Button, Card, Dropdown } from "react-daisyui"; | ||||
| import { FiUser } from "react-icons/fi"; | ||||
| 
 | ||||
| type AppbarProps = { | ||||
|   title?: string; | ||||
| }; | ||||
| 
 | ||||
| const Appbar = ({ title }: AppbarProps) => { | ||||
|   const { user } = useAuth(); | ||||
| 
 | ||||
|   return ( | ||||
|     <header className="flex flex-row items-center h-12"> | ||||
|       <div className="w-10" /> | ||||
| 
 | ||||
|       {title ? ( | ||||
|         <h1 className="text-lg md:text-xl text-center flex-1 truncate"> | ||||
|           Antrian Kick IMPHNEN | ||||
|         </h1> | ||||
|       ) : ( | ||||
|         <div className="flex-1" /> | ||||
|       )} | ||||
| 
 | ||||
|       {!user ? ( | ||||
|         <Button shape="circle" onClick={onLogin}> | ||||
|           <FiUser size={18} /> | ||||
|         </Button> | ||||
|       ) : ( | ||||
|         <Dropdown end> | ||||
|           <Dropdown.Toggle button={false}> | ||||
|             <Button | ||||
|               shape="circle" | ||||
|               className="p-0 w-[40px] min-h-0 h-[40px] overflow-hidden" | ||||
|             > | ||||
|               <Avatar | ||||
|                 shape="circle" | ||||
|                 size={40} | ||||
|                 src={user.avatar || dummyAvatar(user.id)} | ||||
|               /> | ||||
|             </Button> | ||||
|           </Dropdown.Toggle> | ||||
|           <Dropdown.Menu className="card card-compact w-64 z-[5] shadow bg-base-300 text-base-content m-1"> | ||||
|             <Card.Body> | ||||
|               <div className="flex items-start gap-4"> | ||||
|                 <FiUser size={24} className="mt-1" /> | ||||
|                 <div className="flex-1"> | ||||
|                   <p>{user.name}</p> | ||||
|                   <p className="text-sm mt-0.5 text-base-content/80"> | ||||
|                     {"@" + user.username} | ||||
|                   </p> | ||||
| 
 | ||||
|                   <Button | ||||
|                     color="secondary" | ||||
|                     className="mt-4" | ||||
|                     size="sm" | ||||
|                     fullWidth | ||||
|                     onClick={onLogout} | ||||
|                   > | ||||
|                     Keluar | ||||
|                   </Button> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </Card.Body> | ||||
|           </Dropdown.Menu> | ||||
|         </Dropdown> | ||||
|       )} | ||||
|     </header> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Appbar; | ||||
							
								
								
									
										57
									
								
								src/components/containers/rank-board.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/components/containers/rank-board.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,57 @@ | ||||
| import { cn } from "@client/lib/utils"; | ||||
| import { Avatar, Badge } from "react-daisyui"; | ||||
| import { FaTrophy } from "react-icons/fa"; | ||||
| import Lottie from "react-lottie"; | ||||
| import starsAnimation from "@client/assets/stars-animation.json"; | ||||
| 
 | ||||
| type RankBoardProps = { | ||||
|   name: string; | ||||
|   avatar: string; | ||||
|   points: number; | ||||
|   rank: number; | ||||
| }; | ||||
| 
 | ||||
| const RankBoard = ({ name, avatar, points, rank }: RankBoardProps) => { | ||||
|   return ( | ||||
|     <button | ||||
|       type="button" | ||||
|       className={cn( | ||||
|         "flex flex-col items-center rounded-lg py-4 hover:bg-neutral/70 active:scale-x-105 transition-all relative" | ||||
|       )} | ||||
|     > | ||||
|       {rank === 1 ? ( | ||||
|         <> | ||||
|           <div className="absolute z-0 top-1/2 -translate-y-1/2 scale-150 left-0 pointer-events-none"> | ||||
|             <Lottie | ||||
|               options={{ | ||||
|                 animationData: starsAnimation, | ||||
|                 loop: true, | ||||
|                 autoplay: true, | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div className="relative"> | ||||
|             <FaTrophy size={32} className="text-yellow-400" /> | ||||
|             <p className="text-gray-900 absolute top-0 left-1/2 -translate-x-1/2 z-[1] font-bold text-center"> | ||||
|               {rank} | ||||
|             </p> | ||||
|           </div> | ||||
|         </> | ||||
|       ) : ( | ||||
|         <Badge color="primary">{rank}</Badge> | ||||
|       )} | ||||
| 
 | ||||
|       <Avatar | ||||
|         src={avatar} | ||||
|         size={rank === 1 ? 96 : 64} | ||||
|         className="mt-4" | ||||
|         border | ||||
|         shape="circle" | ||||
|       /> | ||||
|       <p className="mt-4 text-sm">{name}</p> | ||||
|       <p className="text-xs mt-0.5 text-base-content/80">{`${points} poin`}</p> | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default RankBoard; | ||||
							
								
								
									
										58
									
								
								src/components/containers/rank-list-item.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/components/containers/rank-list-item.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | ||||
| import { cn } from "@client/lib/utils"; | ||||
| import { Avatar, Badge } from "react-daisyui"; | ||||
| 
 | ||||
| type RankListItemProps = { | ||||
|   name: string; | ||||
|   avatar: string; | ||||
|   points: number; | ||||
|   rank: number; | ||||
|   className?: string; | ||||
| }; | ||||
| 
 | ||||
| const RankListItem = ({ | ||||
|   name, | ||||
|   avatar, | ||||
|   points, | ||||
|   rank, | ||||
|   className, | ||||
| }: RankListItemProps) => { | ||||
|   return ( | ||||
|     <button | ||||
|       type="button" | ||||
|       className={cn( | ||||
|         "flex flex-row w-full items-center gap-x-2 text-left text-sm p-4 hover:bg-neutral/70 active:scale-x-105 transition-all", | ||||
|         rank % 2 === 0 && "bg-base-100/50", | ||||
|         className | ||||
|       )} | ||||
|     > | ||||
|       <div className="w-10"> | ||||
|         <Badge | ||||
|           color="primary" | ||||
|           variant={rank > 10 ? "outline" : undefined} | ||||
|           size="sm" | ||||
|         > | ||||
|           {rank} | ||||
|         </Badge> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="flex-1 flex flex-row items-center gap-x-2 truncate"> | ||||
|         <Avatar src={avatar} size={24} /> | ||||
|         <p className="truncate">{name}</p> | ||||
|       </div> | ||||
| 
 | ||||
|       <p className="flex-1 text-base-content/80">{`${points} poin`}</p> | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const RankListHeader = () => { | ||||
|   return ( | ||||
|     <div className="flex flex-row items-center gap-x-2 w-full text-sm bg-base-300 text-base-content/80 p-4 py-2 mt-2 sticky z-[2] top-0"> | ||||
|       <div className="w-10" /> | ||||
|       <p className="flex-1">Nama</p> | ||||
|       <p className="flex-1">Jumlah Poin</p> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default RankListItem; | ||||
							
								
								
									
										28
									
								
								src/components/context/auth-context.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/components/context/auth-context.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| import { useFetch } from "@client/hooks/useFetch"; | ||||
| import api from "@client/lib/api"; | ||||
| import { InferResponseType } from "hono"; | ||||
| import { createContext, PropsWithChildren } from "react"; | ||||
| 
 | ||||
| export type AuthUser = NonNullable< | ||||
|   InferResponseType<typeof api.auth.user.$get> | ||||
| >; | ||||
| 
 | ||||
| export const AuthContext = createContext<{ | ||||
|   user?: AuthUser | null; | ||||
|   isLoading: boolean; | ||||
|   isLoggedIn: boolean; | ||||
| }>(null!); | ||||
| 
 | ||||
| export const AuthProvider = ({ children }: PropsWithChildren) => { | ||||
|   const { data: user, isLoading } = useFetch<AuthUser>( | ||||
|     "user", | ||||
|     api.auth.user.$get | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <AuthContext.Provider | ||||
|       value={{ user, isLoading, isLoggedIn: user != null }} | ||||
|       children={children} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										23
									
								
								src/components/layouts/main-layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/components/layouts/main-layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { PropsWithChildren } from "react"; | ||||
| import { FaGithub } from "react-icons/fa"; | ||||
| 
 | ||||
| const MainLayout = ({ children }: PropsWithChildren) => { | ||||
|   return ( | ||||
|     <div className="container max-w-3xl mx-auto p-0 md:py-8"> | ||||
|       <main>{children}</main> | ||||
| 
 | ||||
|       <footer className="py-8 bg-base-300 text-center rounded-t-xl rounded-b-0 p-4 md:rounded-b-lg md:rounded-t-lg mt-8 shadow-lg"> | ||||
|         <a | ||||
|           href="/" | ||||
|           target="_blank" | ||||
|           className="inline-flex flex-row items-center justify-center gap-2 hover:underline" | ||||
|         > | ||||
|           <p>Stars on Github</p> | ||||
|           <FaGithub className="inline" /> | ||||
|         </a> | ||||
|       </footer> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default MainLayout; | ||||
							
								
								
									
										20
									
								
								src/hooks/useAuth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/hooks/useAuth.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| import { useContext } from "react"; | ||||
| import { AuthContext } from "@client/components/context/auth-context"; | ||||
| import { API_BASEURL } from "@client/lib/api"; | ||||
| 
 | ||||
| export const useAuth = () => { | ||||
|   const ctx = useContext(AuthContext); | ||||
|   if (!ctx) { | ||||
|     throw new Error("useAuth must be used within an AuthProvider"); | ||||
|   } | ||||
| 
 | ||||
|   return ctx; | ||||
| }; | ||||
| 
 | ||||
| export const onLogin = () => { | ||||
|   window.location.href = API_BASEURL + "/auth/login"; | ||||
| }; | ||||
| 
 | ||||
| export const onLogout = () => { | ||||
|   window.location.href = API_BASEURL + "/auth/logout"; | ||||
| }; | ||||
							
								
								
									
										62
									
								
								src/hooks/useFetch.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/hooks/useFetch.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | ||||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||||
| 
 | ||||
| const cacheStore = new Map<string, any>(); | ||||
| 
 | ||||
| type UseFetchOptions = { | ||||
|   enabled: boolean; | ||||
| }; | ||||
| 
 | ||||
| export const useFetch = <T = any>( | ||||
|   fetchKey: any, | ||||
|   fetchFn: () => Promise<Response>, | ||||
|   options?: Partial<UseFetchOptions> | ||||
| ) => { | ||||
|   const key = JSON.stringify(fetchKey); | ||||
|   const loadingRef = useRef(false); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [data, setData] = useState<T | undefined>(cacheStore.get(key)); | ||||
|   const [error, setError] = useState<Error | undefined>(); | ||||
| 
 | ||||
|   const fetchData = useCallback(async () => { | ||||
|     if (loadingRef.current) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       loadingRef.current = true; | ||||
|       setIsLoading(true); | ||||
| 
 | ||||
|       const res = await fetchFn(); | ||||
|       if (!res.ok) { | ||||
|         throw new Error(res.statusText); | ||||
|       } | ||||
| 
 | ||||
|       const isJson = res.headers | ||||
|         .get("Content-Type") | ||||
|         ?.includes("application/json"); | ||||
| 
 | ||||
|       if (isJson) { | ||||
|         const json = (await res.json()) as T; | ||||
|         setData(json); | ||||
|         cacheStore.set(key, json); | ||||
|       } else { | ||||
|         const text = await res.text(); | ||||
|         setData(text as unknown as T); | ||||
|         cacheStore.set(key, text); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       setError(err instanceof Error ? err : new Error("Unknown error")); | ||||
|     } finally { | ||||
|       loadingRef.current = false; | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }, [key]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (options?.enabled !== false) { | ||||
|       fetchData(); | ||||
|     } | ||||
|   }, [fetchData, options?.enabled]); | ||||
| 
 | ||||
|   return { data, isLoading, error, refetch: fetchData }; | ||||
| }; | ||||
							
								
								
									
										8
									
								
								src/lib/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/lib/api.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| import type { AppType } from "@server/router"; | ||||
| import { hc } from "hono/client"; | ||||
| 
 | ||||
| export const API_BASEURL = "/api"; | ||||
| 
 | ||||
| const api = hc<AppType>(API_BASEURL); | ||||
| 
 | ||||
| export default api; | ||||
							
								
								
									
										10
									
								
								src/lib/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/lib/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| import clsx from "clsx"; | ||||
| import { twMerge } from "tailwind-merge"; | ||||
| 
 | ||||
| export const cn = (...args: any[]) => { | ||||
|   return twMerge(clsx(...args)); | ||||
| }; | ||||
| 
 | ||||
| export const dummyAvatar = (id = 1) => { | ||||
|   return `https://avatar.iran.liara.run/public/${id}`; | ||||
| }; | ||||
							
								
								
									
										10
									
								
								src/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/main.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom/client"; | ||||
| import App from "./app/app.tsx"; | ||||
| import "./app/styles.css"; | ||||
| 
 | ||||
| ReactDOM.createRoot(document.getElementById("root")!).render( | ||||
|   <React.StrictMode> | ||||
|     <App /> | ||||
|   </React.StrictMode> | ||||
| ); | ||||
							
								
								
									
										22
									
								
								src/pages/home/hooks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/pages/home/hooks.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| import { useFetch } from "@client/hooks/useFetch"; | ||||
| import api from "@client/lib/api"; | ||||
| import { InferResponseType } from "hono"; | ||||
| 
 | ||||
| export type Leaderboard = InferResponseType<typeof api.leaderboard.$get>; | ||||
| export type LeaderboardEntry = Leaderboard[number]; | ||||
| 
 | ||||
| export const useLeaderboard = () => { | ||||
|   return useFetch<Leaderboard>("leaderboard", api.leaderboard.$get); | ||||
| }; | ||||
| 
 | ||||
| export type UserLeaderboard = InferResponseType< | ||||
|   (typeof api.leaderboard)[":username"]["$get"] | ||||
| >; | ||||
| 
 | ||||
| export const useGetUserLeaderboard = (username?: string | null) => { | ||||
|   return useFetch<UserLeaderboard>( | ||||
|     ["leaderboard", username], | ||||
|     () => api.leaderboard[":username"].$get({ param: { username: username! } }), | ||||
|     { enabled: !!username } | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										103
									
								
								src/pages/home/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/pages/home/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,103 @@ | ||||
| import Appbar from "@client/components/containers/appbar"; | ||||
| import RankBoard from "@client/components/containers/rank-board"; | ||||
| import RankListItem, { | ||||
|   RankListHeader, | ||||
| } from "@client/components/containers/rank-list-item"; | ||||
| import { Button } from "react-daisyui"; | ||||
| import { FaGithub } from "react-icons/fa"; | ||||
| import { | ||||
|   LeaderboardEntry, | ||||
|   useGetUserLeaderboard, | ||||
|   useLeaderboard, | ||||
| } from "./hooks"; | ||||
| import { useMemo } from "react"; | ||||
| import { dummyAvatar } from "@client/lib/utils"; | ||||
| import { onLogin, useAuth } from "@client/hooks/useAuth"; | ||||
| 
 | ||||
| const HomePage = () => { | ||||
|   const { user } = useAuth(); | ||||
|   const { data } = useLeaderboard(); | ||||
|   const { data: userRank } = useGetUserLeaderboard(user?.username); | ||||
| 
 | ||||
|   const topLeaderboard = useMemo(() => { | ||||
|     const res = new Array(3).fill(null) as LeaderboardEntry[]; | ||||
|     if (!data) { | ||||
|       return res; | ||||
|     } | ||||
| 
 | ||||
|     res[1] = data[0]; | ||||
|     res[0] = data[1]; | ||||
|     res[2] = data[2]; | ||||
|     return res; | ||||
|   }, [data]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <section className="bg-base-100 rounded-b-xl rounded-t-0 p-4 md:rounded-t-lg md:rounded-b-lg shadow-lg relative z-[1]"> | ||||
|         <Appbar title="Antrian Kick IMPHNEN" /> | ||||
| 
 | ||||
|         <div className="grid grid-cols-3 items-end"> | ||||
|           {topLeaderboard.map((item, idx) => { | ||||
|             if (!item) { | ||||
|               return <div key={idx} />; | ||||
|             } | ||||
| 
 | ||||
|             return ( | ||||
|               <RankBoard | ||||
|                 key={item.name} | ||||
|                 rank={item.rank} | ||||
|                 name={item.name} | ||||
|                 avatar={item.avatar || dummyAvatar(item.rank)} | ||||
|                 points={item.points} | ||||
|               /> | ||||
|             ); | ||||
|           })} | ||||
|         </div> | ||||
|       </section> | ||||
| 
 | ||||
|       <section className="bg-base-300 rounded-b-lg py-4 shadow-lg -mt-4"> | ||||
|         <RankListHeader /> | ||||
| 
 | ||||
|         {data?.slice(3).map((item) => ( | ||||
|           <RankListItem | ||||
|             key={item.id} | ||||
|             rank={item.rank} | ||||
|             name={item.name} | ||||
|             avatar={item.avatar || dummyAvatar(item.rank)} | ||||
|             points={item.points} | ||||
|           /> | ||||
|         ))} | ||||
| 
 | ||||
|         {userRank != null ? ( | ||||
|           <> | ||||
|             <p className="text-sm text-base-content/80 mx-4 my-2">Kamu:</p> | ||||
|             <RankListItem | ||||
|               rank={userRank.user.rank} | ||||
|               name={userRank.user.name} | ||||
|               avatar={userRank.user.avatar || dummyAvatar(userRank.user.rank)} | ||||
|               points={userRank.user.points} | ||||
|               className="sticky z-[2] bottom-0 bg-base-100" | ||||
|             /> | ||||
|           </> | ||||
|         ) : ( | ||||
|           <div className="bg-base-100 mx-4 rounded-lg px-6 py-4 sticky bottom-4 z-[2]"> | ||||
|             <p>Pengen nama kamu masuk list ini juga?</p> | ||||
|             <p className="inline">Hayuk</p> | ||||
|             <Button | ||||
|               size="sm" | ||||
|               color="primary" | ||||
|               className="mx-2" | ||||
|               onClick={onLogin} | ||||
|             > | ||||
|               <FaGithub /> | ||||
|               <p>Login</p> | ||||
|             </Button> | ||||
|             <p className="inline">{"⸜(。˃ ᵕ ˂ )⸝♡"}</p> | ||||
|           </div> | ||||
|         )} | ||||
|       </section> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default HomePage; | ||||
							
								
								
									
										1
									
								
								src/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| /// <reference types="vite/client" />
 | ||||
							
								
								
									
										16
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| /** @type {import('tailwindcss').Config} */ | ||||
| export default { | ||||
|   content: [ | ||||
|     "./index.html", | ||||
|     "./src/**/*.{js,ts,jsx,tsx}", | ||||
|     "node_modules/daisyui/dist/**/*.js", | ||||
|     "node_modules/react-daisyui/dist/**/*.js", | ||||
|   ], | ||||
|   theme: { | ||||
|     extend: {}, | ||||
|   }, | ||||
|   plugins: [require("daisyui")], | ||||
|   daisyui: { | ||||
|     themes: ["dracula"], | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										33
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "composite": true, | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
| 
 | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "resolveJsonModule": true, | ||||
|     "isolatedModules": true, | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
| 
 | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "noFallthroughCasesInSwitch": true, | ||||
|      | ||||
|     "baseUrl": "./", | ||||
|     "paths": { | ||||
|       "@client/*": ["src/*"], | ||||
|       "@server/*": ["server/*"], | ||||
|     } | ||||
|   }, | ||||
|   "include": ["src", "server"] | ||||
| } | ||||
							
								
								
									
										13
									
								
								tsconfig.node.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								tsconfig.node.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "composite": true, | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | ||||
|     "skipLibCheck": true, | ||||
|     "module": "ESNext", | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowSyntheticDefaultImports": true, | ||||
|     "strict": true, | ||||
|     "noEmit": true | ||||
|   }, | ||||
|   "include": ["vite.config.ts"] | ||||
| } | ||||
							
								
								
									
										23
									
								
								vite.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								vite.config.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { defineConfig } from "vite"; | ||||
| import path from "path"; | ||||
| import react from "@vitejs/plugin-react-swc"; | ||||
| 
 | ||||
| // https://vitejs.dev/config/
 | ||||
| export default defineConfig({ | ||||
|   plugins: [react()], | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       "@client": path.resolve(__dirname, "./src"), | ||||
|       "@server": path.resolve(__dirname, "./server"), | ||||
|     }, | ||||
|   }, | ||||
|   build: { | ||||
|     outDir: "dist/client", | ||||
|     emptyOutDir: true, | ||||
|   }, | ||||
|   server: { | ||||
|     proxy: { | ||||
|       "/api": process.env.API_BASEURL || "http://127.0.0.1:5589", | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user