mirror of
https://github.com/khairul169/github-leaderboard.git
synced 2026-09-17 09:53:21 +07:00
feat: add view leaderboard item
This commit is contained in:
@@ -9,15 +9,17 @@ type RankBoardProps = {
|
||||
avatar: string;
|
||||
points: number;
|
||||
rank: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const RankBoard = ({ name, avatar, points, rank }: RankBoardProps) => {
|
||||
const RankBoard = ({ name, avatar, points, 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"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{rank === 1 ? (
|
||||
<>
|
||||
|
||||
@@ -7,6 +7,7 @@ type RankListItemProps = {
|
||||
points: number;
|
||||
rank: number;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const RankListItem = ({
|
||||
@@ -15,6 +16,7 @@ const RankListItem = ({
|
||||
points,
|
||||
rank,
|
||||
className,
|
||||
onClick,
|
||||
}: RankListItemProps) => {
|
||||
return (
|
||||
<button
|
||||
@@ -24,6 +26,7 @@ const RankListItem = ({
|
||||
rank % 2 === 0 && "bg-base-100/50",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-10">
|
||||
<Badge
|
||||
|
||||
@@ -8,7 +8,7 @@ const MainLayout = ({ children }: PropsWithChildren) => {
|
||||
|
||||
<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="/"
|
||||
href="https://github.com/khairul169/github-leaderboard"
|
||||
target="_blank"
|
||||
className="inline-flex flex-row items-center justify-center gap-2 hover:underline"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from "@client/lib/utils";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
import { Drawer } from "vaul";
|
||||
|
||||
type BottomSheetProps = ComponentPropsWithoutRef<typeof Drawer.Root> & {
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const BottomSheet = ({
|
||||
trigger,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: BottomSheetProps) => {
|
||||
return (
|
||||
<Drawer.Root {...props}>
|
||||
{trigger ? <Drawer.Trigger asChild>{trigger}</Drawer.Trigger> : null}
|
||||
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-[9]" />
|
||||
<Drawer.Content
|
||||
className={cn(
|
||||
"flex flex-col bg-base-100 rounded-t-2xl mt-24 h-[60%] max-h-[96%] fixed z-10 bottom-0 left-0 right-0 md:mx-auto md:max-w-3xl focus:outline-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Drawer.Handle className="bg-neutral my-2" />
|
||||
{children}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const BottomSheetTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof Drawer.Title>) => {
|
||||
return <Drawer.Title className={cn("text-2xl", className)} {...props} />;
|
||||
};
|
||||
|
||||
export const BottomSheetDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof Drawer.Description>) => {
|
||||
return <Drawer.Description className={className} {...props} />;
|
||||
};
|
||||
|
||||
export default BottomSheet;
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
};
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
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";
|
||||
|
||||
const HomePage = () => {
|
||||
const { user } = useAuth();
|
||||
@@ -49,6 +51,7 @@ const HomePage = () => {
|
||||
name={item.name}
|
||||
avatar={item.avatar || dummyAvatar(item.rank)}
|
||||
points={item.points}
|
||||
onClick={() => setHashUrl(item.username)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -65,6 +68,7 @@ const HomePage = () => {
|
||||
name={item.name}
|
||||
avatar={item.avatar || dummyAvatar(item.rank)}
|
||||
points={item.points}
|
||||
onClick={() => setHashUrl(item.username)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -77,6 +81,7 @@ const HomePage = () => {
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -96,6 +101,8 @@ const HomePage = () => {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ViewSheet />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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 { dummyAvatar } from "@client/lib/utils";
|
||||
import { 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";
|
||||
|
||||
const ViewSheet = () => {
|
||||
const username = useHashUrl();
|
||||
const { data } = useGetUserLeaderboard(username);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
icon: LuFolderGit,
|
||||
name: "Personal repo",
|
||||
value: data.repositories.length,
|
||||
},
|
||||
{
|
||||
icon: FaRegStar,
|
||||
name: "Stars",
|
||||
value: data.repositories.reduce((acc, repo) => acc + repo.stars, 0),
|
||||
},
|
||||
{
|
||||
icon: IoMdGitBranch,
|
||||
name: "Forks",
|
||||
value: data.repositories.reduce((acc, repo) => acc + repo.forks, 0),
|
||||
},
|
||||
{
|
||||
icon: IoMdGitCommit,
|
||||
name: "Commits",
|
||||
value: data.user.commits,
|
||||
},
|
||||
{
|
||||
icon: FiType,
|
||||
name: "Line of codes",
|
||||
value: data.user.lineOfCodes,
|
||||
},
|
||||
{
|
||||
icon: FaCode,
|
||||
name: "Languages",
|
||||
value: data.languages.length,
|
||||
},
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
open={!!username}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setHashUrl("");
|
||||
}}
|
||||
className="h-[90%]"
|
||||
>
|
||||
<div className="p-4 md:p-8 flex-1 overflow-y-auto">
|
||||
<div className="text-center flex flex-col md:flex-row md:text-left gap-x-8 gap-y-4 items-center">
|
||||
<Avatar
|
||||
shape="circle"
|
||||
size="md"
|
||||
src={data?.user.avatar || dummyAvatar(data?.user.id)}
|
||||
/>
|
||||
|
||||
<div className="md:flex-1">
|
||||
<BottomSheetTitle>{data?.user.name}</BottomSheetTitle>
|
||||
<BottomSheetDescription>
|
||||
{"@" + (data?.user.username || "")}
|
||||
</BottomSheetDescription>
|
||||
|
||||
<p className="text-sm mt-4">
|
||||
<FiUsers className="inline" />{" "}
|
||||
{data?.user?.followers + " followers"}
|
||||
{" • "}
|
||||
{data?.user?.following + " following"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral text-neutral-content px-6 py-3 w-full md:w-auto rounded-lg">
|
||||
<div className="flex flex-row items-center justify-center font-mono gap-2 text-4xl md:text-3xl text-primary">
|
||||
<FaTrophy size={24} />
|
||||
<p>{data?.user.rank}</p>
|
||||
</div>
|
||||
<p className="text-xs">{data?.user.points + " pts"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{data?.languages.slice(0, 10).map((lang) => (
|
||||
<div
|
||||
key={lang.name}
|
||||
className="bg-base-300 shrink-0 rounded-xl px-3 py-2 text-xs inline-flex gap-2"
|
||||
>
|
||||
<p>{lang.name}</p>
|
||||
<Badge color="primary" size="sm" className="px-1">
|
||||
{lang.percent.toFixed(1) + "%"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-8 grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{summary.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-row items-center gap-x-4 bg-base-300 rounded-xl px-4 py-3"
|
||||
>
|
||||
<item.icon size={24} className="shrink-0 md:ml-2" />
|
||||
<div className="flex-1 truncate">
|
||||
<p
|
||||
className="text-2xl font-mono truncate"
|
||||
title={String(item.value)}
|
||||
>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="text-xs truncate">{item.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ViewSheet);
|
||||
Reference in New Issue
Block a user