feat: add leaderboard category

This commit is contained in:
Khairul Hidayat 2024-08-09 20:33:07 +07:00
parent e8ed08d49b
commit a5cd6383f3
33 changed files with 744 additions and 277 deletions

View File

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

View File

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

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

View File

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

View File

@ -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",

View File

@ -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
} }
] ]

View File

@ -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();

View File

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

View File

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

View File

@ -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(

View File

@ -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,

View File

@ -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;
};

View File

@ -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`),

View File

@ -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) => {

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

View File

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

View File

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

View File

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

View File

@ -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" />

View File

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

View File

@ -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"
> >

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

View File

@ -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);

View File

@ -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;
};

View File

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

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

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

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

View File

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

View File

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

View File

@ -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 ? (
{data?.slice(3).map((item) => (
<RankListItem
key={item.id}
rank={item.rank}
name={item.name}
avatar={item.avatar || dummyAvatar(item.rank)}
points={item.points}
onClick={() => setHashUrl(item.username)}
/>
))}
{userRank != null ? (
<> <>
<p className="text-sm text-base-content/80 mx-4 my-2">Kamu:</p> <RankListHeader columns={data.columns} />
<RankListItem
rank={userRank.user.rank} {data.rows.slice(3).map((item) => (
name={userRank.user.name} <RankListItem
avatar={userRank.user.avatar || dummyAvatar(userRank.user.rank)} key={item.id}
points={userRank.user.points} rank={item.rank}
className="sticky z-[2] bottom-0 bg-base-100" columns={data.columns}
onClick={() => setHashUrl(userRank.user.username)} data={item}
/> onClick={type === "user" ? () => onView(item.id!) : undefined}
/>
))}
</> </>
) : ( ) : 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 />