mirror of
https://github.com/khairul169/github-leaderboard.git
synced 2025-04-28 15:39:31 +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