mirror of
https://github.com/khairul169/github-leaderboard.git
synced 2025-04-28 15:39:31 +07:00
feat: add leaderboard category
This commit is contained in:
parent
e8ed08d49b
commit
a5cd6383f3
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Antrian Kick IMPHNEN</title>
|
<title>Top Global Ranked Github</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.2.1",
|
"react-icons": "^5.2.1",
|
||||||
"react-lottie": "^1.2.4",
|
"react-lottie": "^1.2.4",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"vaul": "^0.9.1",
|
"vaul": "^0.9.1",
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4"
|
||||||
|
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@ -47,6 +47,9 @@ importers:
|
|||||||
react-lottie:
|
react-lottie:
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(react@18.3.1)
|
version: 1.2.4(react@18.3.1)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^6.26.0
|
||||||
|
version: 6.26.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.4.0
|
specifier: ^2.4.0
|
||||||
version: 2.4.0
|
version: 2.4.0
|
||||||
@ -816,6 +819,10 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@remix-run/router@1.19.0':
|
||||||
|
resolution: {integrity: sha512-zDICCLKEwbVYTS6TjYaWtHXxkdoUvD/QXvyVZjGCsWz5vyH7aFeONlPffPdW+Y/t6KT0MgXb2Mfjun9YpWN1dA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
'@rollup/rollup-android-arm-eabi@4.20.0':
|
||||||
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
|
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
@ -2109,6 +2116,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-router-dom@6.26.0:
|
||||||
|
resolution: {integrity: sha512-RRGUIiDtLrkX3uYcFiCIxKFWMcWQGMojpYZfcstc63A1+sSnVgILGIm9gNUA6na3Fm1QuPGSBQH2EMbAZOnMsQ==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
|
react-router@6.26.0:
|
||||||
|
resolution: {integrity: sha512-wVQq0/iFYd3iZ9H2l3N3k4PL8EEHcb0XlU2Na8nEwmiXgIUElEH6gaJDtUQxJ+JFzmIXaQjfdpcGWaM6IoQGxg==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
|
||||||
react-style-singleton@2.2.1:
|
react-style-singleton@2.2.1:
|
||||||
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
|
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -2957,6 +2977,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.3
|
'@types/react': 18.3.3
|
||||||
|
|
||||||
|
'@remix-run/router@1.19.0': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
'@rollup/rollup-android-arm-eabi@4.20.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -4240,6 +4262,18 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.3
|
'@types/react': 18.3.3
|
||||||
|
|
||||||
|
react-router-dom@6.26.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
'@remix-run/router': 1.19.0
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
react-router: 6.26.0(react@18.3.1)
|
||||||
|
|
||||||
|
react-router@6.26.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
'@remix-run/router': 1.19.0
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
react-style-singleton@2.2.1(@types/react@18.3.3)(react@18.3.1):
|
react-style-singleton@2.2.1(@types/react@18.3.3)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
|
@ -9,6 +9,8 @@ CREATE TABLE `repositories` (
|
|||||||
`last_update` text NOT NULL,
|
`last_update` text NOT NULL,
|
||||||
`languages` text,
|
`languages` text,
|
||||||
`contributors` text,
|
`contributors` text,
|
||||||
|
`is_pending` integer DEFAULT false NOT NULL,
|
||||||
|
`is_error` integer DEFAULT false NOT NULL,
|
||||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
`updated_at` text NOT NULL,
|
`updated_at` text NOT NULL,
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "891041fb-3c81-46b0-ac5a-cd85a640fe8c",
|
"id": "13d859a6-4c32-46a6-90e9-19740b2bb13a",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"repositories": {
|
"repositories": {
|
||||||
@ -77,6 +77,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"is_pending": {
|
||||||
|
"name": "is_pending",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"is_error": {
|
||||||
|
"name": "is_error",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1723077872529,
|
"when": 1723210265266,
|
||||||
"tag": "0000_medical_magma",
|
"tag": "0000_young_emma_frost",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -3,7 +3,6 @@ import db from ".";
|
|||||||
import logger from "../lib/logger";
|
import logger from "../lib/logger";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import queue from "@server/lib/queue";
|
|
||||||
|
|
||||||
const seed = async () => {
|
const seed = async () => {
|
||||||
logger.info("🌿 Seeding database...");
|
logger.info("🌿 Seeding database...");
|
||||||
@ -11,9 +10,9 @@ const seed = async () => {
|
|||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
tx.run(sql`DELETE FROM users`);
|
tx.run(sql`DELETE FROM users`);
|
||||||
|
|
||||||
await tx
|
// await tx
|
||||||
.insert(users)
|
// .insert(users)
|
||||||
.values({ username: "khairul169", name: "Khairul Hidayat" });
|
// .values({ username: "khairul169", name: "Khairul Hidayat" });
|
||||||
|
|
||||||
await tx.insert(users).values(
|
await tx.insert(users).values(
|
||||||
[...Array(50)].map(() => ({
|
[...Array(50)].map(() => ({
|
||||||
@ -29,8 +28,6 @@ const seed = async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await queue.add("fetchUserProfile", { userId: 1 });
|
|
||||||
|
|
||||||
logger.info("🌱 Database seeded");
|
logger.info("🌱 Database seeded");
|
||||||
|
|
||||||
process.exit();
|
process.exit();
|
||||||
|
@ -34,7 +34,7 @@ export const fetchRepoContributors = async (
|
|||||||
|
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.update(repositories)
|
.update(repositories)
|
||||||
.set({ contributors })
|
.set({ contributors, isPending: false, isError: false })
|
||||||
.where(eq(repositories.id, data.id))
|
.where(eq(repositories.id, data.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@ -44,3 +44,12 @@ export const fetchRepoContributors = async (
|
|||||||
|
|
||||||
await queue.add("calculateUserPoints", { userId: result.userId });
|
await queue.add("calculateUserPoints", { userId: result.userId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const onFetchRepoContribFailed = async (
|
||||||
|
data: FetchRepoContributorsType
|
||||||
|
) => {
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({ isPending: false, isError: true })
|
||||||
|
.where(eq(repositories.id, data.id));
|
||||||
|
};
|
||||||
|
@ -28,6 +28,7 @@ export const fetchUserRepos = async (data: FetchUserRepos) => {
|
|||||||
...repo,
|
...repo,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
lastUpdate: repo.lastUpdate.toISOString(),
|
lastUpdate: repo.lastUpdate.toISOString(),
|
||||||
|
isPending: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [existing] = await tx
|
const [existing] = await tx
|
||||||
|
@ -30,10 +30,6 @@ const github = {
|
|||||||
const $ = cheerio.load(response);
|
const $ = cheerio.load(response);
|
||||||
|
|
||||||
const name = $(selectors.user.name).text().trim();
|
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 location = $(selectors.user.location).text().trim();
|
||||||
const followers = intval($(selectors.user.followers).text().trim());
|
const followers = intval($(selectors.user.followers).text().trim());
|
||||||
const following = intval($(selectors.user.following).text().trim());
|
const following = intval($(selectors.user.following).text().trim());
|
||||||
@ -45,7 +41,14 @@ const github = {
|
|||||||
achievements.push({ name, image });
|
achievements.push({ name, image });
|
||||||
});
|
});
|
||||||
|
|
||||||
return { name, username, location, followers, following, achievements };
|
return {
|
||||||
|
name: name || username,
|
||||||
|
username,
|
||||||
|
location,
|
||||||
|
followers,
|
||||||
|
following,
|
||||||
|
achievements,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getRepositories(
|
async getRepositories(
|
||||||
|
@ -6,7 +6,7 @@ import type { JobNames } from "@server/jobs";
|
|||||||
const queue = new Queue<any, any, JobNames>(BULLMQ_JOB_NAME, {
|
const queue = new Queue<any, any, JobNames>(BULLMQ_JOB_NAME, {
|
||||||
connection: BULLMQ_CONNECTION,
|
connection: BULLMQ_CONNECTION,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 5,
|
attempts: 3,
|
||||||
backoff: {
|
backoff: {
|
||||||
type: "exponential",
|
type: "exponential",
|
||||||
delay: 3000,
|
delay: 3000,
|
||||||
|
@ -8,3 +8,15 @@ export const intval = (value: any) => {
|
|||||||
const num = parseInt(value);
|
const num = parseInt(value);
|
||||||
return Number.isNaN(num) ? 0 : num;
|
return Number.isNaN(num) ? 0 : num;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLanguageLogo = (language: string) => {
|
||||||
|
const alias: Record<string, string> = {
|
||||||
|
gdscript: "godot",
|
||||||
|
};
|
||||||
|
|
||||||
|
let lang = language.toLowerCase().replace(/[^a-zA-Z]/g, "");
|
||||||
|
lang = alias[lang] || lang;
|
||||||
|
const uri = `https://github.com/devicons/devicon/raw/master/icons/${lang}/${lang}-original.svg`;
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
};
|
||||||
|
@ -20,6 +20,11 @@ export const repositories = sqliteTable(
|
|||||||
languages: text("languages", { mode: "json" }).$type<Language[]>(),
|
languages: text("languages", { mode: "json" }).$type<Language[]>(),
|
||||||
contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(),
|
contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(),
|
||||||
|
|
||||||
|
isPending: integer("is_pending", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
isError: integer("is_error", { mode: "boolean" }).notNull().default(false),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
@ -2,6 +2,7 @@ import { Job, Worker } from "bullmq";
|
|||||||
import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./lib/consts";
|
import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./lib/consts";
|
||||||
import logger from "./lib/logger";
|
import logger from "./lib/logger";
|
||||||
import { jobs } from "./jobs";
|
import { jobs } from "./jobs";
|
||||||
|
import { onFetchRepoContribFailed } from "./jobs/fetch-repo-contributors";
|
||||||
|
|
||||||
const handler = async (job: Job) => {
|
const handler = async (job: Job) => {
|
||||||
const jobFn = (jobs as any)[job.name];
|
const jobFn = (jobs as any)[job.name];
|
||||||
@ -13,6 +14,12 @@ const handler = async (job: Job) => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onJobRetriesExhausted = async (job: Job) => {
|
||||||
|
if (job.name === "fetchRepoContributors") {
|
||||||
|
await onFetchRepoContribFailed(job.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const worker = new Worker(BULLMQ_JOB_NAME, handler, {
|
const worker = new Worker(BULLMQ_JOB_NAME, handler, {
|
||||||
connection: BULLMQ_CONNECTION,
|
connection: BULLMQ_CONNECTION,
|
||||||
concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1,
|
concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1,
|
||||||
@ -28,6 +35,11 @@ worker.on("active", (job) => {
|
|||||||
|
|
||||||
worker.on("failed", (job, err) => {
|
worker.on("failed", (job, err) => {
|
||||||
logger.child({ jobId: job?.id }).error(err);
|
logger.child({ jobId: job?.id }).error(err);
|
||||||
|
|
||||||
|
if ((job?.attemptsMade || 0) >= (job?.opts.attempts || 0)) {
|
||||||
|
logger.error(`Job ${job?.id} has reached the maximum number of attempts`);
|
||||||
|
onJobRetriesExhausted(job!);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on("completed", (job, result) => {
|
worker.on("completed", (job, result) => {
|
||||||
|
173
server/repository/leaderboard.ts
Normal file
173
server/repository/leaderboard.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import { getLanguageLogo } from "@server/lib/utils";
|
||||||
|
import { repositories, users } from "@server/models";
|
||||||
|
import { count, desc, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
||||||
|
export type GetLeaderboardRes = {
|
||||||
|
columns: { title: string; selector: string }[];
|
||||||
|
rows: {
|
||||||
|
id?: string | null;
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
sub: string;
|
||||||
|
image?: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LeaderboardRepository {
|
||||||
|
async getTopUsers() {
|
||||||
|
const data = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.orderBy(desc(users.points))
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "Nama",
|
||||||
|
selector: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Points",
|
||||||
|
selector: "sub",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = data.map((data, idx) => ({
|
||||||
|
id: data.username,
|
||||||
|
rank: idx + 1,
|
||||||
|
name: data.name,
|
||||||
|
sub: `${data.points} pts`,
|
||||||
|
image: data.avatar,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { columns, rows } satisfies GetLeaderboardRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopLanguages() {
|
||||||
|
const data = await db
|
||||||
|
.select({
|
||||||
|
language: repositories.language,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(repositories)
|
||||||
|
.groupBy(repositories.language)
|
||||||
|
.orderBy(desc(count()));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "Bahasa",
|
||||||
|
selector: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Repo",
|
||||||
|
selector: "sub",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = data
|
||||||
|
.filter((i) => !!i.language)
|
||||||
|
.map((data, idx) => ({
|
||||||
|
rank: idx + 1,
|
||||||
|
name: data.language,
|
||||||
|
sub: `${data.count} repo`,
|
||||||
|
image: getLanguageLogo(data.language),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { columns, rows } satisfies GetLeaderboardRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopUsersByLang(lang: string) {
|
||||||
|
const languages = lang.toLowerCase().split(",");
|
||||||
|
|
||||||
|
const data = await db
|
||||||
|
.select({
|
||||||
|
id: users.username,
|
||||||
|
name: users.name,
|
||||||
|
avatar: users.avatar,
|
||||||
|
count: count(),
|
||||||
|
points: users.points,
|
||||||
|
})
|
||||||
|
.from(repositories)
|
||||||
|
.innerJoin(users, eq(users.id, repositories.userId))
|
||||||
|
.where(inArray(sql`lower(${repositories.language})`, languages))
|
||||||
|
.groupBy(users.id)
|
||||||
|
.orderBy(desc(count()));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "Nama",
|
||||||
|
selector: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Repo",
|
||||||
|
selector: "sub",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = data.map((data, idx) => ({
|
||||||
|
id: data.id,
|
||||||
|
rank: idx + 1,
|
||||||
|
name: data.name,
|
||||||
|
sub: `${data.count} repo`,
|
||||||
|
image: data.avatar,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { columns, rows } satisfies GetLeaderboardRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserRank(username: string) {
|
||||||
|
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 { user, repositories: repos, languages };
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,7 @@ import db from "@server/db";
|
|||||||
import { JWT_SECRET } from "@server/lib/consts";
|
import { JWT_SECRET } from "@server/lib/consts";
|
||||||
import github from "@server/lib/github";
|
import github from "@server/lib/github";
|
||||||
import queue from "@server/lib/queue";
|
import queue from "@server/lib/queue";
|
||||||
import { repositories, users } from "@server/models";
|
import { users } from "@server/models";
|
||||||
import { CreateUser } from "@server/models/users";
|
import { CreateUser } from "@server/models/users";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@ -50,7 +50,7 @@ export const auth = new Hono()
|
|||||||
|
|
||||||
const userData: CreateUser = {
|
const userData: CreateUser = {
|
||||||
username: ghUser.login,
|
username: ghUser.login,
|
||||||
name: ghUser.name,
|
name: ghUser.name || ghUser.login,
|
||||||
avatar: ghUser.avatar_url,
|
avatar: ghUser.avatar_url,
|
||||||
location: ghUser.location,
|
location: ghUser.location,
|
||||||
accessToken,
|
accessToken,
|
||||||
@ -73,18 +73,16 @@ export const auth = new Hono()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch latest user profile
|
// Fetch latest user profile
|
||||||
await queue.add("fetchUserProfile", { userId: user.id });
|
await queue.add(
|
||||||
|
"fetchUserProfile",
|
||||||
// Fetch user repositories
|
{ userId: user.id },
|
||||||
const [hasRepo] = await db
|
{ jobId: `fetchUserProfile:${user.id}` }
|
||||||
.select({ id: repositories.id })
|
);
|
||||||
.from(repositories)
|
await queue.add(
|
||||||
.where(eq(repositories.userId, user.id))
|
"fetchUserRepos",
|
||||||
.limit(1);
|
{ userId: user.id },
|
||||||
|
{ jobId: `fetchUserRepos:${user.id}` }
|
||||||
if (!hasRepo) {
|
);
|
||||||
await queue.add("fetchUserRepos", { userId: user.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const authToken = await jwt.sign({ id: user.id }, JWT_SECRET);
|
const authToken = await jwt.sign({ id: user.id }, JWT_SECRET);
|
||||||
setCookie(c, "token", authToken, { httpOnly: true });
|
setCookie(c, "token", authToken, { httpOnly: true });
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import db from "@server/db";
|
import {
|
||||||
import { repositories, users } from "@server/models";
|
GetLeaderboardRes,
|
||||||
import { desc, eq, getTableColumns, sql } from "drizzle-orm";
|
LeaderboardRepository,
|
||||||
|
} from "@server/repository/leaderboard";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { HTTPException } from "hono/http-exception";
|
|
||||||
|
|
||||||
export const leaderboard = new Hono()
|
export const leaderboard = new Hono()
|
||||||
|
|
||||||
@ -10,12 +10,25 @@ export const leaderboard = new Hono()
|
|||||||
* Get users leaderboard
|
* Get users leaderboard
|
||||||
*/
|
*/
|
||||||
.get("/", async (c) => {
|
.get("/", async (c) => {
|
||||||
const rows = await db
|
const query = c.req.query();
|
||||||
.select()
|
const repo = new LeaderboardRepository();
|
||||||
.from(users)
|
|
||||||
.orderBy(desc(users.points))
|
let result: GetLeaderboardRes;
|
||||||
.limit(100);
|
|
||||||
const result = rows.map((data, idx) => ({ ...data, rank: idx + 1 }));
|
switch (query.type) {
|
||||||
|
case "lang":
|
||||||
|
result = await repo.getTopLanguages();
|
||||||
|
break;
|
||||||
|
case "lang-users":
|
||||||
|
result = await repo.getTopUsersByLang(query.lang);
|
||||||
|
break;
|
||||||
|
case "user":
|
||||||
|
case "":
|
||||||
|
result = await repo.getTopUsers();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid query type");
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
})
|
})
|
||||||
@ -25,56 +38,8 @@ export const leaderboard = new Hono()
|
|||||||
*/
|
*/
|
||||||
.get("/:username", async (c) => {
|
.get("/:username", async (c) => {
|
||||||
const { username } = c.req.param();
|
const { username } = c.req.param();
|
||||||
|
const repo = new LeaderboardRepository();
|
||||||
|
const result = await repo.getUserRank(username);
|
||||||
|
|
||||||
const rankSubquery = db
|
return c.json(result);
|
||||||
.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 });
|
|
||||||
});
|
});
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
import { AuthProvider } from "@client/components/context/auth-context";
|
import { AuthProvider } from "@client/components/context/auth-context";
|
||||||
import MainLayout from "@client/components/layouts/main-layout";
|
import Router from "./router";
|
||||||
import HomePage from "@client/pages/home/page";
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<MainLayout>
|
<Router />
|
||||||
<HomePage />
|
|
||||||
</MainLayout>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
21
src/app/router.tsx
Normal file
21
src/app/router.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||||
|
import MainLayout from "@client/components/layouts/main-layout";
|
||||||
|
import HomePage from "@client/pages/home/page";
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
Component: MainLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ":type?/:id?",
|
||||||
|
Component: HomePage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const Router = () => {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Router;
|
@ -16,7 +16,7 @@ const Appbar = ({ title }: AppbarProps) => {
|
|||||||
|
|
||||||
{title ? (
|
{title ? (
|
||||||
<h1 className="text-lg md:text-xl text-center flex-1 truncate">
|
<h1 className="text-lg md:text-xl text-center flex-1 truncate">
|
||||||
Antrian Kick IMPHNEN
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
@ -1,61 +0,0 @@
|
|||||||
import { cn } from "@client/lib/utils";
|
|
||||||
import { Avatar, Badge } from "react-daisyui";
|
|
||||||
|
|
||||||
type RankListItemProps = {
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
points: number;
|
|
||||||
rank: number;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RankListItem = ({
|
|
||||||
name,
|
|
||||||
avatar,
|
|
||||||
points,
|
|
||||||
rank,
|
|
||||||
className,
|
|
||||||
onClick,
|
|
||||||
}: 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
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<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;
|
|
@ -1,14 +1,16 @@
|
|||||||
import { PropsWithChildren } from "react";
|
|
||||||
import { FaGithub } from "react-icons/fa";
|
import { FaGithub } from "react-icons/fa";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
const MainLayout = ({ children }: PropsWithChildren) => {
|
const MainLayout = () => {
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-3xl mx-auto p-0 md:py-8">
|
<div className="container max-w-3xl mx-auto p-0 md:py-8">
|
||||||
<main>{children}</main>
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</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">
|
<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
|
<a
|
||||||
href="https://github.com/khairul169/github-leaderboard"
|
href="https://github.com/khairul169/github-leaderboard/stargazers"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="inline-flex flex-row items-center justify-center gap-2 hover:underline"
|
className="inline-flex flex-row items-center justify-center gap-2 hover:underline"
|
||||||
>
|
>
|
||||||
|
20
src/components/ui/avatar.tsx
Normal file
20
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Avatar as BaseAvatar } from "react-daisyui";
|
||||||
|
|
||||||
|
type Props = React.ComponentPropsWithoutRef<typeof BaseAvatar> & {
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Avatar = ({ src, fallback, ...props }: Props) => {
|
||||||
|
const [isError, setError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseAvatar
|
||||||
|
{...props}
|
||||||
|
src={isError && (fallback || !src) ? fallback : src}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Avatar;
|
@ -46,6 +46,7 @@ export const useFetch = <T = any>(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err : new Error("Unknown error"));
|
setError(err instanceof Error ? err : new Error("Unknown error"));
|
||||||
|
setData(undefined);
|
||||||
} finally {
|
} finally {
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export const setHashUrl = (value: string) => {
|
|
||||||
history.replaceState(undefined, undefined as never, "#" + value);
|
|
||||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useHashUrl = () => {
|
|
||||||
const [value, setValue] = useState(location.hash.substring(1));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onHashChange = () => {
|
|
||||||
setValue(location.hash.substring(1));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("hashchange", onHashChange);
|
|
||||||
return () => window.removeEventListener("hashchange", onHashChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
@ -1,29 +1,31 @@
|
|||||||
import { cn } from "@client/lib/utils";
|
import { cn, dummyAvatar } from "@client/lib/utils";
|
||||||
import { Avatar, Badge } from "react-daisyui";
|
import { Badge } from "react-daisyui";
|
||||||
import { FaTrophy } from "react-icons/fa";
|
import { FaTrophy } from "react-icons/fa";
|
||||||
import Lottie from "react-lottie";
|
import Lottie from "react-lottie";
|
||||||
import starsAnimation from "@client/assets/stars-animation.json";
|
import starsAnimation from "@client/assets/stars-animation.json";
|
||||||
|
|
||||||
type RankBoardProps = {
|
type RankBoardProps = {
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar?: string | null;
|
||||||
points: number;
|
sub: string;
|
||||||
rank: number;
|
rank: number;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RankBoard = ({ name, avatar, points, rank, onClick }: RankBoardProps) => {
|
const RankBoard = ({ name, avatar, sub, rank, onClick }: RankBoardProps) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center rounded-lg py-4 hover:bg-neutral/70 active:scale-x-105 transition-all relative"
|
"flex flex-col items-center rounded-lg py-4 transition-all relative cursor-default",
|
||||||
|
onClick != null &&
|
||||||
|
"hover:bg-neutral/70 active:scale-x-105 cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{rank === 1 ? (
|
{rank === 1 ? (
|
||||||
<>
|
<>
|
||||||
<div className="absolute z-0 top-1/2 -translate-y-1/2 scale-150 left-0 pointer-events-none">
|
<div className="absolute z-0 top-1/2 -translate-y-1/2 scale-125 left-0 pointer-events-none">
|
||||||
<Lottie
|
<Lottie
|
||||||
options={{
|
options={{
|
||||||
animationData: starsAnimation,
|
animationData: starsAnimation,
|
||||||
@ -43,15 +45,19 @@ const RankBoard = ({ name, avatar, points, rank, onClick }: RankBoardProps) => {
|
|||||||
<Badge color="primary">{rank}</Badge>
|
<Badge color="primary">{rank}</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Avatar
|
<div
|
||||||
src={avatar}
|
className={cn(
|
||||||
size={rank === 1 ? 96 : 64}
|
"mt-4 z-[1] size-[64px] rounded-full overflow-hidden bg-base-300 ring-4 ring-neutral ring-offset-2",
|
||||||
className="mt-4"
|
rank === 1 && "size-[96px]"
|
||||||
border
|
)}
|
||||||
shape="circle"
|
>
|
||||||
|
<img
|
||||||
|
src={avatar || dummyAvatar(rank)}
|
||||||
|
className={cn("size-[64px] scale-110", rank === 1 && "size-[96px]")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<p className="mt-4 text-sm">{name}</p>
|
<p className="mt-4 text-sm">{name}</p>
|
||||||
<p className="text-xs mt-0.5 text-base-content/80">{`${points} poin`}</p>
|
<p className="text-xs mt-0.5 text-base-content/80">{sub}</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
89
src/pages/home/components/rank-list-item.tsx
Normal file
89
src/pages/home/components/rank-list-item.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { cn, dummyAvatar } from "@client/lib/utils";
|
||||||
|
import { Badge } from "react-daisyui";
|
||||||
|
import { LeaderboardColumn, LeaderboardEntry } from "../hooks";
|
||||||
|
import Avatar from "@client/components/ui/avatar";
|
||||||
|
|
||||||
|
type RankListItemProps = {
|
||||||
|
rank: number;
|
||||||
|
columns: LeaderboardColumn[];
|
||||||
|
data: LeaderboardEntry;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RankListItem = ({
|
||||||
|
rank,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: 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 transition-all cursor-default",
|
||||||
|
rank % 2 === 0 && "bg-base-100/50",
|
||||||
|
onClick != null && "active:scale-x-105 cursor-pointer",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="w-10">
|
||||||
|
<Badge
|
||||||
|
color="primary"
|
||||||
|
variant={rank > 10 ? "outline" : undefined}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{columns.map((col, idx) => {
|
||||||
|
const value = (data as any)[col.selector];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 truncate",
|
||||||
|
idx === 0 && data.image
|
||||||
|
? "flex flex-row items-center gap-x-2"
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{idx === 0 && data.image ? (
|
||||||
|
<Avatar
|
||||||
|
src={data.image}
|
||||||
|
fallback={dummyAvatar(idx + 1)}
|
||||||
|
shape="circle"
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="truncate">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type RankListHeaderProps = {
|
||||||
|
columns: LeaderboardColumn[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RankListHeader = ({ columns }: RankListHeaderProps) => {
|
||||||
|
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 rounded-lg">
|
||||||
|
<div className="w-10" />
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<p key={idx} className="flex-1">
|
||||||
|
{col.title}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RankListItem;
|
26
src/pages/home/components/type-tabs.tsx
Normal file
26
src/pages/home/components/type-tabs.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Tabs } from "react-daisyui";
|
||||||
|
import { useLeaderboardType } from "../hooks";
|
||||||
|
import { leaderboardTypes } from "../data";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const TypeTabs = () => {
|
||||||
|
const [type] = useLeaderboardType();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs variant="boxed" className="mt-2 overflow-x-auto">
|
||||||
|
{leaderboardTypes.map((item) => (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={item.value}
|
||||||
|
active={item.value === type}
|
||||||
|
onClick={() => navigate(`/${item.value}`)}
|
||||||
|
className="shrink-0 min-w-[140px]"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TypeTabs;
|
59
src/pages/home/components/user-rank.tsx
Normal file
59
src/pages/home/components/user-rank.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useGetUserLeaderboard, useLeaderboardType } from "../hooks";
|
||||||
|
import { onLogin, useAuth } from "@client/hooks/useAuth";
|
||||||
|
import RankListItem from "./rank-list-item";
|
||||||
|
import { Button } from "react-daisyui";
|
||||||
|
import { FaGithub } from "react-icons/fa";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
const UserRank = () => {
|
||||||
|
const [type] = useLeaderboardType();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data } = useGetUserLeaderboard(user?.username);
|
||||||
|
|
||||||
|
const onView = (username: string) => {
|
||||||
|
navigate(`/${type}/${username}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type !== "user") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-base-content/80 mx-4 my-2">Kamu:</p>
|
||||||
|
<RankListItem
|
||||||
|
rank={data.user.rank}
|
||||||
|
columns={[
|
||||||
|
{ title: "Nama", selector: "name" },
|
||||||
|
{ title: "Points", selector: "sub" },
|
||||||
|
]}
|
||||||
|
data={{
|
||||||
|
name: data.user.name,
|
||||||
|
sub: `${data.user.points} pts`,
|
||||||
|
image: data.user.avatar,
|
||||||
|
rank: data.user.rank,
|
||||||
|
}}
|
||||||
|
className="sticky z-[2] bottom-0 bg-base-100"
|
||||||
|
onClick={() => onView(data.user.username)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(UserRank);
|
@ -2,19 +2,39 @@ import BottomSheet, {
|
|||||||
BottomSheetDescription,
|
BottomSheetDescription,
|
||||||
BottomSheetTitle,
|
BottomSheetTitle,
|
||||||
} from "@client/components/ui/bottom-sheet";
|
} from "@client/components/ui/bottom-sheet";
|
||||||
import { setHashUrl, useHashUrl } from "@client/hooks/useHashUrl";
|
import { memo, useEffect, useMemo } from "react";
|
||||||
import { memo, useMemo } from "react";
|
import { Avatar, Badge, Card, Progress } from "react-daisyui";
|
||||||
import { Avatar, Badge } from "react-daisyui";
|
import { useGetUserLeaderboard } from "../hooks";
|
||||||
import { useGetUserLeaderboard } from "./hooks";
|
|
||||||
import { dummyAvatar } from "@client/lib/utils";
|
import { dummyAvatar } from "@client/lib/utils";
|
||||||
import { FiType, FiUsers } from "react-icons/fi";
|
import { FiGitMerge, FiStar, FiType, FiUsers } from "react-icons/fi";
|
||||||
import { FaCode, FaRegStar, FaTrophy } from "react-icons/fa";
|
import { FaCode, FaRegStar, FaTrophy } from "react-icons/fa";
|
||||||
import { LuFolderGit } from "react-icons/lu";
|
import { LuFolderGit } from "react-icons/lu";
|
||||||
import { IoMdGitBranch, IoMdGitCommit } from "react-icons/io";
|
import { IoMdGitBranch, IoMdGitCommit } from "react-icons/io";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
const ViewSheet = () => {
|
const ViewSheet = () => {
|
||||||
const username = useHashUrl();
|
const { type, id } = useParams();
|
||||||
const { data } = useGetUserLeaderboard(username);
|
const username = type === "user" ? id : null;
|
||||||
|
const { data, refetch } = useGetUserLeaderboard(username);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const totalRepo = useMemo(() => {
|
||||||
|
return data?.repositories.length || 0;
|
||||||
|
}, [data]);
|
||||||
|
const pendingRepos = useMemo(() => {
|
||||||
|
return data?.repositories.filter((i) => i.isPending).length || 0;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
console.log({ pendingRepos, totalRepo });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingRepos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(refetch, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [pendingRepos]);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@ -25,7 +45,7 @@ const ViewSheet = () => {
|
|||||||
{
|
{
|
||||||
icon: LuFolderGit,
|
icon: LuFolderGit,
|
||||||
name: "Personal repo",
|
name: "Personal repo",
|
||||||
value: data.repositories.length,
|
value: totalRepo,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FaRegStar,
|
icon: FaRegStar,
|
||||||
@ -44,7 +64,7 @@ const ViewSheet = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FiType,
|
icon: FiType,
|
||||||
name: "Line of codes",
|
name: "Lines of code",
|
||||||
value: data.user.lineOfCodes,
|
value: data.user.lineOfCodes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -53,13 +73,23 @@ const ViewSheet = () => {
|
|||||||
value: data.languages.length,
|
value: data.languages.length,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, [data, totalRepo]);
|
||||||
|
|
||||||
|
const achievements = useMemo(() => {
|
||||||
|
const items: Record<string, { name: string; image?: string }> = {};
|
||||||
|
|
||||||
|
data?.user.achievements?.forEach((item) => {
|
||||||
|
items[item.name] = item;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(items);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheet
|
<BottomSheet
|
||||||
open={!!username}
|
open={!!username}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setHashUrl("");
|
if (!open) navigate("/user", { replace: true });
|
||||||
}}
|
}}
|
||||||
className="h-[90%]"
|
className="h-[90%]"
|
||||||
>
|
>
|
||||||
@ -94,6 +124,32 @@ const ViewSheet = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{data && pendingRepos > 0 && (
|
||||||
|
<Card compact className="mt-4 md:mt-8 bg-base-300">
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title className="text-sm font-normal">
|
||||||
|
Mengimport data repositori, mohon tunggu...
|
||||||
|
</Card.Title>
|
||||||
|
<Progress
|
||||||
|
max={100}
|
||||||
|
value={((totalRepo - pendingRepos) / totalRepo) * 100}
|
||||||
|
/>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{achievements.length > 0 && (
|
||||||
|
<section id="achievements" className="mt-4 md:mt-8">
|
||||||
|
<div className="flex flex-row sm:flex-wrap overflow-x-auto sm:overflow-hidden gap-2">
|
||||||
|
{achievements.map((item) => (
|
||||||
|
<div key={item.name} className="size-12 shrink-0">
|
||||||
|
<img src={item.image} title={item.name} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{data?.languages && data.languages.length > 0 && (
|
{data?.languages && data.languages.length > 0 && (
|
||||||
<section id="languages" className="mt-4 md:mt-8">
|
<section id="languages" className="mt-4 md:mt-8">
|
||||||
<div className="flex flex-row sm:flex-wrap overflow-x-auto gap-2 mt-2">
|
<div className="flex flex-row sm:flex-wrap overflow-x-auto gap-2 mt-2">
|
||||||
@ -131,6 +187,34 @@ const ViewSheet = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8 grid">
|
||||||
|
<p>Top Repositori</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-2">
|
||||||
|
{data?.repositories.slice(0, 3).map((item, idx) => (
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${username}/${item.name}`}
|
||||||
|
target="_blank"
|
||||||
|
key={idx}
|
||||||
|
className="rounded-xl px-4 py-3 bg-base-300 hover:bg-base-200 transition-all border border-base-content/50"
|
||||||
|
>
|
||||||
|
<LuFolderGit size={18} />
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
<p className="mt-1 truncate">{item.name}</p>
|
||||||
|
<p className="text-xs truncate mt-0.5 text-base-content/80">
|
||||||
|
{item.languages?.map((i) => i.lang).join(", ") ||
|
||||||
|
item.language}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="flex flex-wrap items-center gap-1 text-xs mt-4">
|
||||||
|
<FiStar size={18} /> {item.stars}
|
||||||
|
<FiGitMerge size={18} className="ml-2" /> {item.forks}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
);
|
);
|
30
src/pages/home/data.ts
Normal file
30
src/pages/home/data.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
|
||||||
|
export const leaderboardTypes: LeaderboardType[] = [
|
||||||
|
{
|
||||||
|
title: "Pengguna😱",
|
||||||
|
value: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bahasa☔️",
|
||||||
|
value: "lang",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Javascript👑",
|
||||||
|
value: "js",
|
||||||
|
query: { type: "lang-users", lang: "javascript,typescript" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PHP🐘",
|
||||||
|
value: "php",
|
||||||
|
query: { type: "lang-users", lang: "php" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type LeaderboardTypes = "user" | "lang" | "js" | "php";
|
||||||
|
|
||||||
|
export type LeaderboardType = {
|
||||||
|
title: string;
|
||||||
|
value: LeaderboardTypes;
|
||||||
|
query?: any;
|
||||||
|
};
|
@ -1,12 +1,25 @@
|
|||||||
import { useFetch } from "@client/hooks/useFetch";
|
import { useFetch } from "@client/hooks/useFetch";
|
||||||
import api from "@client/lib/api";
|
import api from "@client/lib/api";
|
||||||
import { InferResponseType } from "hono";
|
import { InferResponseType } from "hono";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { leaderboardTypes } from "./data";
|
||||||
|
|
||||||
|
export const useLeaderboardType = () => {
|
||||||
|
const { type } = useParams();
|
||||||
|
const item =
|
||||||
|
leaderboardTypes.find((t) => t.value === type) || leaderboardTypes[0];
|
||||||
|
|
||||||
|
return [item.value, item] as const;
|
||||||
|
};
|
||||||
|
|
||||||
export type Leaderboard = InferResponseType<typeof api.leaderboard.$get>;
|
export type Leaderboard = InferResponseType<typeof api.leaderboard.$get>;
|
||||||
export type LeaderboardEntry = Leaderboard[number];
|
export type LeaderboardColumn = Leaderboard["columns"][number];
|
||||||
|
export type LeaderboardEntry = Leaderboard["rows"][number];
|
||||||
|
|
||||||
export const useLeaderboard = () => {
|
export const useLeaderboard = (query?: any) => {
|
||||||
return useFetch<Leaderboard>("leaderboard", api.leaderboard.$get);
|
return useFetch<Leaderboard>(["leaderboard", query], () =>
|
||||||
|
api.leaderboard.$get({ query })
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserLeaderboard = InferResponseType<
|
export type UserLeaderboard = InferResponseType<
|
||||||
|
@ -1,25 +1,21 @@
|
|||||||
import Appbar from "@client/components/containers/appbar";
|
import Appbar from "@client/components/containers/appbar";
|
||||||
import RankBoard from "@client/components/containers/rank-board";
|
import RankBoard from "@client/pages/home/components/rank-board";
|
||||||
import RankListItem, {
|
import { LeaderboardEntry, useLeaderboard, useLeaderboardType } from "./hooks";
|
||||||
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 { useMemo } from "react";
|
||||||
import { dummyAvatar } from "@client/lib/utils";
|
import TypeTabs from "./components/type-tabs";
|
||||||
import { onLogin, useAuth } from "@client/hooks/useAuth";
|
import ViewSheet from "./components/view-sheet";
|
||||||
import ViewSheet from "./view-sheet";
|
import RankListItem, { RankListHeader } from "./components/rank-list-item";
|
||||||
import { setHashUrl } from "@client/hooks/useHashUrl";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import UserRank from "./components/user-rank";
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { user } = useAuth();
|
const [type, { query }] = useLeaderboardType();
|
||||||
const { data } = useLeaderboard();
|
const { data } = useLeaderboard({ type, ...query });
|
||||||
const { data: userRank } = useGetUserLeaderboard(user?.username);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onView = (username: string) => {
|
||||||
|
navigate(`/${type}/${username}`);
|
||||||
|
};
|
||||||
|
|
||||||
const topLeaderboard = useMemo(() => {
|
const topLeaderboard = useMemo(() => {
|
||||||
const res = new Array(3).fill(null) as LeaderboardEntry[];
|
const res = new Array(3).fill(null) as LeaderboardEntry[];
|
||||||
@ -27,18 +23,19 @@ const HomePage = () => {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
res[1] = data[0];
|
res[1] = data.rows[0];
|
||||||
res[0] = data[1];
|
res[0] = data.rows[1];
|
||||||
res[2] = data[2];
|
res[2] = data.rows[2];
|
||||||
return res;
|
return res;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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]">
|
<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" />
|
<Appbar title="Top Global Ranked Github" />
|
||||||
|
<TypeTabs />
|
||||||
|
|
||||||
<div className="grid grid-cols-3 items-end">
|
<div className="grid grid-cols-3 items-end mt-8 mb-4">
|
||||||
{topLeaderboard.map((item, idx) => {
|
{topLeaderboard.map((item, idx) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return <div key={idx} />;
|
return <div key={idx} />;
|
||||||
@ -49,9 +46,9 @@ const HomePage = () => {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
rank={item.rank}
|
rank={item.rank}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
avatar={item.avatar || dummyAvatar(item.rank)}
|
avatar={item.image}
|
||||||
points={item.points}
|
sub={item.sub}
|
||||||
onClick={() => setHashUrl(item.username)}
|
onClick={type === "user" ? () => onView(item.id!) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -59,47 +56,23 @@ const HomePage = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-base-300 rounded-b-lg py-4 shadow-lg -mt-4">
|
<section className="bg-base-300 rounded-b-lg py-4 shadow-lg -mt-4">
|
||||||
<RankListHeader />
|
{data?.rows && data.rows.length > 3 ? (
|
||||||
|
<>
|
||||||
|
<RankListHeader columns={data.columns} />
|
||||||
|
|
||||||
{data?.slice(3).map((item) => (
|
{data.rows.slice(3).map((item) => (
|
||||||
<RankListItem
|
<RankListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
rank={item.rank}
|
rank={item.rank}
|
||||||
name={item.name}
|
columns={data.columns}
|
||||||
avatar={item.avatar || dummyAvatar(item.rank)}
|
data={item}
|
||||||
points={item.points}
|
onClick={type === "user" ? () => onView(item.id!) : undefined}
|
||||||
onClick={() => setHashUrl(item.username)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{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"
|
|
||||||
onClick={() => setHashUrl(userRank.user.username)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<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>
|
<UserRank />
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
<ViewSheet />
|
<ViewSheet />
|
||||||
|
Loading…
x
Reference in New Issue
Block a user