feat: add leaderboard category

This commit is contained in:
2024-08-09 20:33:07 +07:00
parent e8ed08d49b
commit a5cd6383f3
33 changed files with 744 additions and 277 deletions
+2 -5
View File
@@ -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 (
<AuthProvider>
<MainLayout>
<HomePage />
</MainLayout>
<Router />
</AuthProvider>
);
};
+21
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;
+1 -1
View File
@@ -16,7 +16,7 @@ const Appbar = ({ title }: AppbarProps) => {
{title ? (
<h1 className="text-lg md:text-xl text-center flex-1 truncate">
Antrian Kick IMPHNEN
{title}
</h1>
) : (
<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;
+6 -4
View File
@@ -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 (
<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">
<a
href="https://github.com/khairul169/github-leaderboard"
href="https://github.com/khairul169/github-leaderboard/stargazers"
target="_blank"
className="inline-flex flex-row items-center justify-center gap-2 hover:underline"
>
+20
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;
+1
View File
@@ -46,6 +46,7 @@ export const useFetch = <T = any>(
}
} catch (err) {
setError(err instanceof Error ? err : new Error("Unknown error"));
setData(undefined);
} finally {
loadingRef.current = false;
setIsLoading(false);
-21
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;
};
@@ -1,29 +1,31 @@
import { cn } from "@client/lib/utils";
import { Avatar, Badge } from "react-daisyui";
import { cn, dummyAvatar } from "@client/lib/utils";
import { Badge } from "react-daisyui";
import { FaTrophy } from "react-icons/fa";
import Lottie from "react-lottie";
import starsAnimation from "@client/assets/stars-animation.json";
type RankBoardProps = {
name: string;
avatar: string;
points: number;
avatar?: string | null;
sub: string;
rank: number;
onClick?: () => void;
};
const RankBoard = ({ name, avatar, points, rank, onClick }: RankBoardProps) => {
const RankBoard = ({ name, avatar, sub, rank, onClick }: RankBoardProps) => {
return (
<button
type="button"
className={cn(
"flex flex-col items-center rounded-lg py-4 hover:bg-neutral/70 active:scale-x-105 transition-all relative"
"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}
>
{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
options={{
animationData: starsAnimation,
@@ -43,15 +45,19 @@ const RankBoard = ({ name, avatar, points, rank, onClick }: RankBoardProps) => {
<Badge color="primary">{rank}</Badge>
)}
<Avatar
src={avatar}
size={rank === 1 ? 96 : 64}
className="mt-4"
border
shape="circle"
/>
<div
className={cn(
"mt-4 z-[1] size-[64px] rounded-full overflow-hidden bg-base-300 ring-4 ring-neutral ring-offset-2",
rank === 1 && "size-[96px]"
)}
>
<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="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>
);
};
@@ -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
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;
+59
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);
@@ -2,19 +2,39 @@ import BottomSheet, {
BottomSheetDescription,
BottomSheetTitle,
} from "@client/components/ui/bottom-sheet";
import { setHashUrl, useHashUrl } from "@client/hooks/useHashUrl";
import { memo, useMemo } from "react";
import { Avatar, Badge } from "react-daisyui";
import { useGetUserLeaderboard } from "./hooks";
import { memo, useEffect, useMemo } from "react";
import { Avatar, Badge, Card, Progress } from "react-daisyui";
import { useGetUserLeaderboard } from "../hooks";
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 { LuFolderGit } from "react-icons/lu";
import { IoMdGitBranch, IoMdGitCommit } from "react-icons/io";
import { useNavigate, useParams } from "react-router-dom";
const ViewSheet = () => {
const username = useHashUrl();
const { data } = useGetUserLeaderboard(username);
const { type, id } = useParams();
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(() => {
if (!data) {
@@ -25,7 +45,7 @@ const ViewSheet = () => {
{
icon: LuFolderGit,
name: "Personal repo",
value: data.repositories.length,
value: totalRepo,
},
{
icon: FaRegStar,
@@ -44,7 +64,7 @@ const ViewSheet = () => {
},
{
icon: FiType,
name: "Line of codes",
name: "Lines of code",
value: data.user.lineOfCodes,
},
{
@@ -53,13 +73,23 @@ const ViewSheet = () => {
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]);
return (
<BottomSheet
open={!!username}
onOpenChange={(open) => {
if (!open) setHashUrl("");
if (!open) navigate("/user", { replace: true });
}}
className="h-[90%]"
>
@@ -94,6 +124,32 @@ const ViewSheet = () => {
</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 && (
<section id="languages" className="mt-4 md:mt-8">
<div className="flex flex-row sm:flex-wrap overflow-x-auto gap-2 mt-2">
@@ -131,6 +187,34 @@ const ViewSheet = () => {
</div>
))}
</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>
</BottomSheet>
);
+30
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;
};
+16 -3
View File
@@ -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<typeof api.leaderboard.$get>;
export type LeaderboardEntry = Leaderboard[number];
export type LeaderboardColumn = Leaderboard["columns"][number];
export type LeaderboardEntry = Leaderboard["rows"][number];
export const useLeaderboard = () => {
return useFetch<Leaderboard>("leaderboard", api.leaderboard.$get);
export const useLeaderboard = (query?: any) => {
return useFetch<Leaderboard>(["leaderboard", query], () =>
api.leaderboard.$get({ query })
);
};
export type UserLeaderboard = InferResponseType<
+38 -65
View File
@@ -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 (
<div>
<section className="bg-base-100 rounded-b-xl rounded-t-0 p-4 md:rounded-t-lg md:rounded-b-lg shadow-lg relative z-[1]">
<Appbar title="Antrian Kick IMPHNEN" />
<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) => {
if (!item) {
return <div key={idx} />;
@@ -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 = () => {
</section>
<section className="bg-base-300 rounded-b-lg py-4 shadow-lg -mt-4">
<RankListHeader />
{data?.slice(3).map((item) => (
<RankListItem
key={item.id}
rank={item.rank}
name={item.name}
avatar={item.avatar || dummyAvatar(item.rank)}
points={item.points}
onClick={() => setHashUrl(item.username)}
/>
))}
{userRank != null ? (
{data?.rows && data.rows.length > 3 ? (
<>
<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)}
/>
<RankListHeader columns={data.columns} />
{data.rows.slice(3).map((item) => (
<RankListItem
key={item.id}
rank={item.rank}
columns={data.columns}
data={item}
onClick={type === "user" ? () => onView(item.id!) : undefined}
/>
))}
</>
) : (
<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>
)}
) : null}
<UserRank />
</section>
<ViewSheet />