From a5cd6383f3f4f8bcf985192bdd8a9946cac23598 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Fri, 9 Aug 2024 20:33:07 +0700 Subject: [PATCH] feat: add leaderboard category --- index.html | 2 +- package.json | 1 + pnpm-lock.yaml | 34 ++++ ...al_magma.sql => 0000_young_emma_frost.sql} | 2 + server/db/migrations/meta/0000_snapshot.json | 18 +- server/db/migrations/meta/_journal.json | 4 +- server/db/seed.ts | 9 +- server/jobs/fetch-repo-contributors.ts | 11 +- server/jobs/fetch-user-repos.ts | 1 + server/lib/github.ts | 13 +- server/lib/queue.ts | 2 +- server/lib/utils.ts | 12 ++ server/models/repositories.ts | 5 + server/queue-worker.ts | 12 ++ server/repository/leaderboard.ts | 173 ++++++++++++++++++ server/routes/auth.ts | 26 ++- server/routes/leaderboard.ts | 87 +++------ src/app/app.tsx | 7 +- src/app/router.tsx | 21 +++ src/components/containers/appbar.tsx | 2 +- src/components/containers/rank-list-item.tsx | 61 ------ src/components/layouts/main-layout.tsx | 10 +- src/components/ui/avatar.tsx | 20 ++ src/hooks/useFetch.ts | 1 + src/hooks/useHashUrl.ts | 21 --- .../home/components}/rank-board.tsx | 36 ++-- src/pages/home/components/rank-list-item.tsx | 89 +++++++++ src/pages/home/components/type-tabs.tsx | 26 +++ src/pages/home/components/user-rank.tsx | 59 ++++++ .../home/{ => components}/view-sheet.tsx | 104 ++++++++++- src/pages/home/data.ts | 30 +++ src/pages/home/hooks.ts | 19 +- src/pages/home/page.tsx | 103 ++++------- 33 files changed, 744 insertions(+), 277 deletions(-) rename server/db/migrations/{0000_medical_magma.sql => 0000_young_emma_frost.sql} (94%) create mode 100644 server/repository/leaderboard.ts create mode 100644 src/app/router.tsx delete mode 100644 src/components/containers/rank-list-item.tsx create mode 100644 src/components/ui/avatar.tsx delete mode 100644 src/hooks/useHashUrl.ts rename src/{components/containers => pages/home/components}/rank-board.tsx (55%) create mode 100644 src/pages/home/components/rank-list-item.tsx create mode 100644 src/pages/home/components/type-tabs.tsx create mode 100644 src/pages/home/components/user-rank.tsx rename src/pages/home/{ => components}/view-sheet.tsx (53%) create mode 100644 src/pages/home/data.ts diff --git a/index.html b/index.html index 3713ef6..01e11a4 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Antrian Kick IMPHNEN + Top Global Ranked Github
diff --git a/package.json b/package.json index 2fe16af..1ac4e7f 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react-dom": "^18.3.1", "react-icons": "^5.2.1", "react-lottie": "^1.2.4", + "react-router-dom": "^6.26.0", "tailwind-merge": "^2.4.0", "vaul": "^0.9.1", "zustand": "^4.5.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78dd5a9..6d71832 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: react-lottie: specifier: ^1.2.4 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: specifier: ^2.4.0 version: 2.4.0 @@ -816,6 +819,10 @@ packages: '@types/react': 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': resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==} cpu: [arm] @@ -2109,6 +2116,19 @@ packages: '@types/react': 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: resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -2957,6 +2977,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@remix-run/router@1.19.0': {} + '@rollup/rollup-android-arm-eabi@4.20.0': optional: true @@ -4240,6 +4262,18 @@ snapshots: optionalDependencies: '@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): dependencies: get-nonce: 1.0.1 diff --git a/server/db/migrations/0000_medical_magma.sql b/server/db/migrations/0000_young_emma_frost.sql similarity index 94% rename from server/db/migrations/0000_medical_magma.sql rename to server/db/migrations/0000_young_emma_frost.sql index 215bc6d..b36d075 100644 --- a/server/db/migrations/0000_medical_magma.sql +++ b/server/db/migrations/0000_young_emma_frost.sql @@ -9,6 +9,8 @@ CREATE TABLE `repositories` ( `last_update` text NOT NULL, `languages` 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, `updated_at` text NOT NULL, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action diff --git a/server/db/migrations/meta/0000_snapshot.json b/server/db/migrations/meta/0000_snapshot.json index d797d7a..d744dbf 100644 --- a/server/db/migrations/meta/0000_snapshot.json +++ b/server/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "891041fb-3c81-46b0-ac5a-cd85a640fe8c", + "id": "13d859a6-4c32-46a6-90e9-19740b2bb13a", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "repositories": { @@ -77,6 +77,22 @@ "notNull": 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": { "name": "created_at", "type": "text", diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index 7da335e..1dfa564 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1723077872529, - "tag": "0000_medical_magma", + "when": 1723210265266, + "tag": "0000_young_emma_frost", "breakpoints": true } ] diff --git a/server/db/seed.ts b/server/db/seed.ts index ed644f9..d4024df 100644 --- a/server/db/seed.ts +++ b/server/db/seed.ts @@ -3,7 +3,6 @@ import db from "."; import logger from "../lib/logger"; import { sql } from "drizzle-orm"; import { faker } from "@faker-js/faker"; -import queue from "@server/lib/queue"; const seed = async () => { logger.info("🌿 Seeding database..."); @@ -11,9 +10,9 @@ const seed = async () => { await db.transaction(async (tx) => { tx.run(sql`DELETE FROM users`); - await tx - .insert(users) - .values({ username: "khairul169", name: "Khairul Hidayat" }); + // await tx + // .insert(users) + // .values({ username: "khairul169", name: "Khairul Hidayat" }); await tx.insert(users).values( [...Array(50)].map(() => ({ @@ -29,8 +28,6 @@ const seed = async () => { ); }); - await queue.add("fetchUserProfile", { userId: 1 }); - logger.info("🌱 Database seeded"); process.exit(); diff --git a/server/jobs/fetch-repo-contributors.ts b/server/jobs/fetch-repo-contributors.ts index 69d6952..a581322 100644 --- a/server/jobs/fetch-repo-contributors.ts +++ b/server/jobs/fetch-repo-contributors.ts @@ -34,7 +34,7 @@ export const fetchRepoContributors = async ( const [result] = await db .update(repositories) - .set({ contributors }) + .set({ contributors, isPending: false, isError: false }) .where(eq(repositories.id, data.id)) .returning(); @@ -44,3 +44,12 @@ export const fetchRepoContributors = async ( 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)); +}; diff --git a/server/jobs/fetch-user-repos.ts b/server/jobs/fetch-user-repos.ts index 8803f18..1bb7994 100644 --- a/server/jobs/fetch-user-repos.ts +++ b/server/jobs/fetch-user-repos.ts @@ -28,6 +28,7 @@ export const fetchUserRepos = async (data: FetchUserRepos) => { ...repo, userId: user.id, lastUpdate: repo.lastUpdate.toISOString(), + isPending: true, }; const [existing] = await tx diff --git a/server/lib/github.ts b/server/lib/github.ts index ebe0392..a44e99a 100644 --- a/server/lib/github.ts +++ b/server/lib/github.ts @@ -30,10 +30,6 @@ const github = { const $ = cheerio.load(response); const name = $(selectors.user.name).text().trim(); - if (typeof name !== "string" || !name?.length) { - throw new Error("User not found"); - } - const location = $(selectors.user.location).text().trim(); const followers = intval($(selectors.user.followers).text().trim()); const following = intval($(selectors.user.following).text().trim()); @@ -45,7 +41,14 @@ const github = { achievements.push({ name, image }); }); - return { name, username, location, followers, following, achievements }; + return { + name: name || username, + username, + location, + followers, + following, + achievements, + }; }, async getRepositories( diff --git a/server/lib/queue.ts b/server/lib/queue.ts index 52658d6..f374aef 100644 --- a/server/lib/queue.ts +++ b/server/lib/queue.ts @@ -6,7 +6,7 @@ import type { JobNames } from "@server/jobs"; const queue = new Queue(BULLMQ_JOB_NAME, { connection: BULLMQ_CONNECTION, defaultJobOptions: { - attempts: 5, + attempts: 3, backoff: { type: "exponential", delay: 3000, diff --git a/server/lib/utils.ts b/server/lib/utils.ts index fa68fe2..ae32db2 100644 --- a/server/lib/utils.ts +++ b/server/lib/utils.ts @@ -8,3 +8,15 @@ export const intval = (value: any) => { const num = parseInt(value); return Number.isNaN(num) ? 0 : num; }; + +export const getLanguageLogo = (language: string) => { + const alias: Record = { + 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; +}; diff --git a/server/models/repositories.ts b/server/models/repositories.ts index a5fe568..474005d 100644 --- a/server/models/repositories.ts +++ b/server/models/repositories.ts @@ -20,6 +20,11 @@ export const repositories = sqliteTable( languages: text("languages", { mode: "json" }).$type(), contributors: text("contributors", { mode: "json" }).$type(), + isPending: integer("is_pending", { mode: "boolean" }) + .notNull() + .default(false), + isError: integer("is_error", { mode: "boolean" }).notNull().default(false), + createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/server/queue-worker.ts b/server/queue-worker.ts index 72ba7a6..da99f28 100644 --- a/server/queue-worker.ts +++ b/server/queue-worker.ts @@ -2,6 +2,7 @@ import { Job, Worker } from "bullmq"; import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./lib/consts"; import logger from "./lib/logger"; import { jobs } from "./jobs"; +import { onFetchRepoContribFailed } from "./jobs/fetch-repo-contributors"; const handler = async (job: Job) => { const jobFn = (jobs as any)[job.name]; @@ -13,6 +14,12 @@ const handler = async (job: Job) => { return false; }; +const onJobRetriesExhausted = async (job: Job) => { + if (job.name === "fetchRepoContributors") { + await onFetchRepoContribFailed(job.data); + } +}; + const worker = new Worker(BULLMQ_JOB_NAME, handler, { connection: BULLMQ_CONNECTION, concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1, @@ -28,6 +35,11 @@ worker.on("active", (job) => { worker.on("failed", (job, 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) => { diff --git a/server/repository/leaderboard.ts b/server/repository/leaderboard.ts new file mode 100644 index 0000000..63e3859 --- /dev/null +++ b/server/repository/leaderboard.ts @@ -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 = {}; + 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 }; + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ae8dd0e..4d4205a 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -2,7 +2,7 @@ import db from "@server/db"; import { JWT_SECRET } from "@server/lib/consts"; import github from "@server/lib/github"; import queue from "@server/lib/queue"; -import { repositories, users } from "@server/models"; +import { users } from "@server/models"; import { CreateUser } from "@server/models/users"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; @@ -50,7 +50,7 @@ export const auth = new Hono() const userData: CreateUser = { username: ghUser.login, - name: ghUser.name, + name: ghUser.name || ghUser.login, avatar: ghUser.avatar_url, location: ghUser.location, accessToken, @@ -73,18 +73,16 @@ export const auth = new Hono() } // Fetch latest user profile - await queue.add("fetchUserProfile", { userId: user.id }); - - // Fetch user repositories - const [hasRepo] = await db - .select({ id: repositories.id }) - .from(repositories) - .where(eq(repositories.userId, user.id)) - .limit(1); - - if (!hasRepo) { - await queue.add("fetchUserRepos", { userId: user.id }); - } + await queue.add( + "fetchUserProfile", + { userId: user.id }, + { jobId: `fetchUserProfile:${user.id}` } + ); + await queue.add( + "fetchUserRepos", + { userId: user.id }, + { jobId: `fetchUserRepos:${user.id}` } + ); const authToken = await jwt.sign({ id: user.id }, JWT_SECRET); setCookie(c, "token", authToken, { httpOnly: true }); diff --git a/server/routes/leaderboard.ts b/server/routes/leaderboard.ts index 12149d7..4d042ee 100644 --- a/server/routes/leaderboard.ts +++ b/server/routes/leaderboard.ts @@ -1,8 +1,8 @@ -import db from "@server/db"; -import { repositories, users } from "@server/models"; -import { desc, eq, getTableColumns, sql } from "drizzle-orm"; +import { + GetLeaderboardRes, + LeaderboardRepository, +} from "@server/repository/leaderboard"; import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; export const leaderboard = new Hono() @@ -10,12 +10,25 @@ export const leaderboard = new Hono() * Get users leaderboard */ .get("/", async (c) => { - const rows = await db - .select() - .from(users) - .orderBy(desc(users.points)) - .limit(100); - const result = rows.map((data, idx) => ({ ...data, rank: idx + 1 })); + const query = c.req.query(); + const repo = new LeaderboardRepository(); + + let result: GetLeaderboardRes; + + 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); }) @@ -25,56 +38,8 @@ export const leaderboard = new Hono() */ .get("/:username", async (c) => { const { username } = c.req.param(); + const repo = new LeaderboardRepository(); + const result = await repo.getUserRank(username); - 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 = {}; - 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 }); + return c.json(result); }); diff --git a/src/app/app.tsx b/src/app/app.tsx index 3d86821..dde1737 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -1,13 +1,10 @@ import { AuthProvider } from "@client/components/context/auth-context"; -import MainLayout from "@client/components/layouts/main-layout"; -import HomePage from "@client/pages/home/page"; +import Router from "./router"; const App = () => { return ( - - - + ); }; diff --git a/src/app/router.tsx b/src/app/router.tsx new file mode 100644 index 0000000..a9604de --- /dev/null +++ b/src/app/router.tsx @@ -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 ; +}; + +export default Router; diff --git a/src/components/containers/appbar.tsx b/src/components/containers/appbar.tsx index cc45f8a..8c89888 100644 --- a/src/components/containers/appbar.tsx +++ b/src/components/containers/appbar.tsx @@ -16,7 +16,7 @@ const Appbar = ({ title }: AppbarProps) => { {title ? (

- Antrian Kick IMPHNEN + {title}

) : (
diff --git a/src/components/containers/rank-list-item.tsx b/src/components/containers/rank-list-item.tsx deleted file mode 100644 index 19a5be3..0000000 --- a/src/components/containers/rank-list-item.tsx +++ /dev/null @@ -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 ( - - ); -}; - -export const RankListHeader = () => { - return ( -
-
-

Nama

-

Jumlah Poin

-
- ); -}; - -export default RankListItem; diff --git a/src/components/layouts/main-layout.tsx b/src/components/layouts/main-layout.tsx index e10285c..f7676aa 100644 --- a/src/components/layouts/main-layout.tsx +++ b/src/components/layouts/main-layout.tsx @@ -1,14 +1,16 @@ -import { PropsWithChildren } from "react"; import { FaGithub } from "react-icons/fa"; +import { Outlet } from "react-router-dom"; -const MainLayout = ({ children }: PropsWithChildren) => { +const MainLayout = () => { return (
-
{children}
+
+ +
+ {data && pendingRepos > 0 && ( + + + + Mengimport data repositori, mohon tunggu... + + + + + )} + + {achievements.length > 0 && ( +
+
+ {achievements.map((item) => ( +
+ +
+ ))} +
+
+ )} + {data?.languages && data.languages.length > 0 && (
@@ -131,6 +187,34 @@ const ViewSheet = () => {
))}
+ +
+

Top Repositori

+
+
); diff --git a/src/pages/home/data.ts b/src/pages/home/data.ts new file mode 100644 index 0000000..564b715 --- /dev/null +++ b/src/pages/home/data.ts @@ -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; +}; diff --git a/src/pages/home/hooks.ts b/src/pages/home/hooks.ts index 6eac5eb..80494c8 100644 --- a/src/pages/home/hooks.ts +++ b/src/pages/home/hooks.ts @@ -1,12 +1,25 @@ import { useFetch } from "@client/hooks/useFetch"; import api from "@client/lib/api"; 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; -export type LeaderboardEntry = Leaderboard[number]; +export type LeaderboardColumn = Leaderboard["columns"][number]; +export type LeaderboardEntry = Leaderboard["rows"][number]; -export const useLeaderboard = () => { - return useFetch("leaderboard", api.leaderboard.$get); +export const useLeaderboard = (query?: any) => { + return useFetch(["leaderboard", query], () => + api.leaderboard.$get({ query }) + ); }; export type UserLeaderboard = InferResponseType< diff --git a/src/pages/home/page.tsx b/src/pages/home/page.tsx index c2ef309..dda0606 100644 --- a/src/pages/home/page.tsx +++ b/src/pages/home/page.tsx @@ -1,25 +1,21 @@ import Appbar from "@client/components/containers/appbar"; -import RankBoard from "@client/components/containers/rank-board"; -import RankListItem, { - RankListHeader, -} from "@client/components/containers/rank-list-item"; -import { Button } from "react-daisyui"; -import { FaGithub } from "react-icons/fa"; -import { - LeaderboardEntry, - useGetUserLeaderboard, - useLeaderboard, -} from "./hooks"; +import RankBoard from "@client/pages/home/components/rank-board"; +import { LeaderboardEntry, useLeaderboard, useLeaderboardType } from "./hooks"; import { useMemo } from "react"; -import { dummyAvatar } from "@client/lib/utils"; -import { onLogin, useAuth } from "@client/hooks/useAuth"; -import ViewSheet from "./view-sheet"; -import { setHashUrl } from "@client/hooks/useHashUrl"; +import TypeTabs from "./components/type-tabs"; +import ViewSheet from "./components/view-sheet"; +import RankListItem, { RankListHeader } from "./components/rank-list-item"; +import { useNavigate } from "react-router-dom"; +import UserRank from "./components/user-rank"; const HomePage = () => { - const { user } = useAuth(); - const { data } = useLeaderboard(); - const { data: userRank } = useGetUserLeaderboard(user?.username); + const [type, { query }] = useLeaderboardType(); + const { data } = useLeaderboard({ type, ...query }); + const navigate = useNavigate(); + + const onView = (username: string) => { + navigate(`/${type}/${username}`); + }; const topLeaderboard = useMemo(() => { const res = new Array(3).fill(null) as LeaderboardEntry[]; @@ -27,18 +23,19 @@ const HomePage = () => { return res; } - res[1] = data[0]; - res[0] = data[1]; - res[2] = data[2]; + res[1] = data.rows[0]; + res[0] = data.rows[1]; + res[2] = data.rows[2]; return res; }, [data]); return (
- + + -
+
{topLeaderboard.map((item, idx) => { if (!item) { return
; @@ -49,9 +46,9 @@ const HomePage = () => { key={item.name} rank={item.rank} name={item.name} - avatar={item.avatar || dummyAvatar(item.rank)} - points={item.points} - onClick={() => setHashUrl(item.username)} + avatar={item.image} + sub={item.sub} + onClick={type === "user" ? () => onView(item.id!) : undefined} /> ); })} @@ -59,47 +56,23 @@ const HomePage = () => {
- - - {data?.slice(3).map((item) => ( - setHashUrl(item.username)} - /> - ))} - - {userRank != null ? ( + {data?.rows && data.rows.length > 3 ? ( <> -

Kamu:

- setHashUrl(userRank.user.username)} - /> + + + {data.rows.slice(3).map((item) => ( + onView(item.id!) : undefined} + /> + ))} - ) : ( -
-

Pengen nama kamu masuk list ini juga?

-

Hayuk

- -

{"⸜(。˃ ᵕ ˂ )⸝♡"}

-
- )} + ) : null} + +