feat: initial commit

This commit is contained in:
Khairul Hidayat 2024-08-08 06:07:30 +07:00
commit 8f286de718
56 changed files with 6343 additions and 0 deletions

16
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View 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
View 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;

View 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`);

View 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": {}
}
}

View 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
View 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
View 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,
});

View 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));
};

View 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 });
};

View 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 });
};

View 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 });
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}`);

View 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
View File

@ -0,0 +1,2 @@
export { users } from "./users";
export { repositories } from "./repositories";

View 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
View 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
View 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
View 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
View 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("/");
});

View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

View 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;

View 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;

View 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;

View 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}
/>
);
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

16
tailwind.config.js Normal file
View 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
View 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
View 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
View 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",
},
},
});