Skip to content

Commit

Permalink
[FE] - 리더보드 UI 구현 및 useQuery + emitAckEvent Wrapper 함수 구현 (#149)
Browse files Browse the repository at this point in the history
* chore: tanstack/react-query-devtools install

* fix: session end 라우팅 주소 변경

* feat: Ack 응답의 emit 이벤트 Promise로 감싸는 Wrapping 함수

- useQuery로 사용하기 위해서 구현

* feat: react-query 기본값 설정 및 devtools 설정

* feat: leaderboard custom hook 구현

- 받아온 데이터 캐시에서 관리

* feat: leaderboard UI 구현
  • Loading branch information
chan-byeong authored Dec 1, 2024
1 parent 5a5676a commit c4f30a9
Show file tree
Hide file tree
Showing 16 changed files with 304 additions and 6 deletions.
37 changes: 37 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.59.19",
"@tanstack/react-query-devtools": "^5.62.0",
"@youquiz/shared": "workspace:*",
"eslint": "^9.13.0",
"lucide-react": "^0.462.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/app/routes/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function Router() {

<Route path="/quiz/session/host/:pinCode/:id" element={<QuizMasterSession />} />

<Route path="/quiz/session/end" element={<Leaderboard />} />
<Route path="/quiz/session/:pinCode/end" element={<Leaderboard />} />
<Route path={'*'} element={<NotFound />} />
</Routes>
);
Expand Down
12 changes: 11 additions & 1 deletion packages/client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from '@/app';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 10,
},
},
});

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>
</BrowserRouter>
</StrictMode>,
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/pages/leaderboard/ConfettiBackground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default function ConfettiBackground() {
return (
<div className="absolute w-dvw ">
{Array.from({ length: 20 }).map((_, index) => (
<div
key={index}
className="absolute animate-[confetti_linear_infinite] w-2 h-2"
style={{
left: `${Math.random() * 100}%`,
top: `-20px`,
animationDelay: `${Math.random() * 3}s`,
animationDuration: `${3 + Math.random() * 2}s`,
backgroundColor: ['#FFD700', '#FF3E4D', '#4CAF50', '#2196F3'][
Math.floor(Math.random() * 4)
],
}}
/>
))}
</div>
);
}
46 changes: 46 additions & 0 deletions packages/client/src/pages/leaderboard/PlayerList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Ranking } from '.';

interface PlayerListProps {
players: Ranking[];
}

export default function PlayerList({ players }: PlayerListProps) {
return (
<div className="space-y-2 mt-10">
{players.map((player, index) => (
<div
key={index + 3}
className="group flex items-center p-3 gap-3 rounded-xl hover:bg-white/80 hover:shadow-sm transition-all duration-200"
>
{/* Rank */}
<div className="w-12 h-12 rounded-base bg-gradient-to-br from-blue-100 to-blue-50 border border-gray-100 flex items-center justify-center font-bold text-blue-600 text-xl group-hover:animate-bounce">
{index + 4}
</div>

{/* Status & Name */}
<div className="flex-1 flex items-center gap-4">
{/* <div
className={`w-2 h-2 rounded-full ${player.isOnline ? 'bg-green-500' : 'bg-gray-300'}`}
/> */}
<span className="font-semibold text-lg text-gray-800">{player.nickname}</span>
</div>

{/* Stats */}
<div className="flex items-center gap-6 text-sm">
{/* <div className="flex items-center gap-2 text-blue-600"> */}
{/* <CheckCircle2 className="w-4 h-4" />
<span>
{player.correct}/{player.total}
</span>
</div>
<div className="flex items-center gap-2 text-purple-600">
<Timer className="w-4 h-4" />
<span>{player.speed}s</span>
</div> */}
<div className="w-20 text-lg text-right font-semibold">{player.score}</div>
</div>
</div>
))}
</div>
);
}
72 changes: 72 additions & 0 deletions packages/client/src/pages/leaderboard/TopPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Ranking } from '.';
import DogImage from '@/shared/assets/characters/강아지.png';
import CatImage from '@/shared/assets/characters/고양이.png';
import PigImage from '@/shared/assets/characters/돼지.png';
import RabbitImage from '@/shared/assets/characters/토끼.png';
import PenguinImage from '@/shared/assets/characters/펭귄.png';
import HamsterImage from '@/shared/assets/characters/햄스터.png';

interface TopPlayerProps {
players: Ranking[];
}

const characters = [DogImage, CatImage, PigImage, RabbitImage, PenguinImage, HamsterImage];
const characterNames = ['강아지', '고양이', '돼지', '토끼', '펭귄', '햄스터'];

const Nickname = ({ nickname }: { nickname: string }) => {
return (
<div className="text-xl font-semibold rounded-base bg-white p-2 shadow-md text-gray-600">
{nickname}
</div>
);
};
export default function TopPlayer({ players }: TopPlayerProps) {
return (
<div className="flex justify-center items-end gap-20 p-4">
<div className="flex flex-col items-center gap-2">
<div className="rounded-full w-24 h-24 ">
<img
src={characters[players[1]?.character] ?? DogImage}
alt={`${characterNames[players[1]?.character]}character`}
/>
</div>
<div className="relative w-24 h-28 bg-gradient-to-t from-gray-300 to-gray-200 rounded-base">
<span className="absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 text-2xl font-semibold text-gray-600">
{players[1]?.score}
</span>
</div>
<Nickname nickname={players[1]?.nickname} />
</div>

<div className="flex flex-col items-center gap-2">
<div className="rounded-full w-24 h-24 ">
<img
src={characters[players[0]?.character] ?? DogImage}
alt={`${characterNames[players[0]?.character]}character`}
/>
</div>
<div className="relative w-28 h-40 bg-gradient-to-t from-yellow-300 to-yellow-50 rounded-base">
<span className="absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 text-2xl font-semibold text-gray-600">
{players[0]?.score}
</span>
</div>
<Nickname nickname={players[0]?.nickname} />
</div>

<div className="flex flex-col items-center gap-2">
<div className="rounded-full w-24 h-24 ">
<img
src={characters[players[2]?.character] ?? DogImage}
alt={`${characterNames[players[2]?.character]}character`}
/>
</div>
<div className="relative w-24 h-20 bg-gradient-to-t from-orange-300 to-orange-200 rounded-base">
<span className="absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 text-2xl font-semibold text-gray-600">
{players[2]?.score}
</span>
</div>
<Nickname nickname={players[2]?.nickname} />
</div>
</div>
);
}
57 changes: 56 additions & 1 deletion packages/client/src/pages/leaderboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,58 @@
import { useParams } from 'react-router-dom';

import { getQuizSocket } from '@/shared/utils/socket';
import TopPlayer from './TopPlayer';
import PlayerList from './PlayerList';
import ConfettiBackground from './ConfettiBackground';
import { useLeaderboard } from './model/hooks/useLeaderboard';

export interface Ranking {
nickname: string;
score: number;
character: number;
}

export default function Leaderboard() {
return <div>Leaderboard</div>;
const { pinCode } = useParams();

const socket = getQuizSocket();
const { data, isLoading } = useLeaderboard(socket, pinCode ?? '');

if (isLoading) {
return <div>Loading...</div>;
}

if (data) {
//TODO: 쿠키 삭제 로직
}

return (
<section className="relative w-dvw min-h-dvh h-fit bg-gradient-to-br from-blue-100 via-white to-purple-100">
<ConfettiBackground />
<div className="w-full max-w-3xl mx-auto p-6 bg-gradient-to-br from-blue-50 via-white to-purple-50 rounded-2xl shadow-xl">
<span className="text-2xl font-bold mb-4 bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent">
최종 결과 🎉
</span>
<div className="flex justify-center ">
<div className="w-full">
<TopPlayer players={data?.rankerData.slice(0, 3) ?? []} />
</div>
</div>
{/* 랭킹 리스트 */}
<PlayerList players={data?.rankerData.slice(3) ?? []} />
<div className="mt-8 p-4 bg-white/50 rounded-xl border border-gray-100">
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-2">
<div className="text-sm text-gray-500 mb-1">참가자</div>
<div className="font-bold text-gray-800">{data?.participantNumber}</div>
</div>
<div className="p-2 border-x border-gray-100">
<div className="text-sm text-gray-500 mb-1">평균 점수</div>
<div className="font-bold text-gray-800">{data?.averageScore}</div>
</div>
</div>
</div>
</div>
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { Socket } from 'socket.io-client';

import { Ranking } from '../..';
import { emitEventWithAck } from '@/shared/utils/emitEventWithAck';

interface LeaderboardData {
rankerData: Ranking[];
participantNumber: number;
averageScore: number;
}

export const useLeaderboard = (socket: Socket, pinCode: string) => {
return useQuery({
queryKey: ['leaderboard', pinCode],
queryFn: () => emitEventWithAck<LeaderboardData>(socket, 'leaderboard', { pinCode }),
});
};
2 changes: 1 addition & 1 deletion packages/client/src/pages/quiz-master-session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default function QuizMasterSession() {
const handleNextQuiz = () => {
if (isLastQuiz) {
socket.emit('end quiz', { pinCode, sid: getCookie('sid') });
navigate('/quiz/session/end');
navigate(`/quiz/session/${pinCode}/end`);
return;
}
if (Math.floor(tick.remainingTime / 1000) === 0) {
Expand Down
5 changes: 3 additions & 2 deletions packages/client/src/pages/quiz-session/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';

import { getQuizSocket } from '@/shared/utils/socket';
import QuizBox from './ui/QuizBox';
Expand All @@ -15,6 +15,7 @@ export default function QuizSession() {
const socket = getQuizSocket();
// const toast = toastController();
const navigate = useNavigate();
const { pinCode } = useParams();
const [isLoading, setIsLoading] = useState(true);
const [isQuizEnd, setIsQuizEnd] = useState(false);
const [tick, setTick] = useState<TimerTickResponse>(INITIAL_TICK);
Expand Down Expand Up @@ -67,7 +68,7 @@ export default function QuizSession() {
};

const handleQuizEnd = () => {
navigate('/quiz/session/end');
navigate(`/quiz/session/${pinCode}/end`);
};

socket.on('end quiz', handleQuizEnd);
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/shared/utils/emitEventWithAck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Socket } from 'socket.io-client';

export const emitEventWithAck = <T>(socket: Socket, event: string, data: any) => {
return new Promise<T>((resolve, reject) => {
socket.emit(event, data, (response: T) => {
if (response) {
resolve(response);
} else {
reject(new Error(`"${event}" event emit failed`));
}
});
});
};
4 changes: 4 additions & 0 deletions packages/client/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export default {
'50%': { transform: 'translateY(-20px) rotate(5deg)', opacity: 1 },
'100%': { transform: 'translateY(0) rotate(0deg)', opacity: 0.8 },
},
confetti: {
'0%': { transform: 'translateY(0) rotate(0deg)', opacity: 0 },
'100%': { transform: 'translateY(100vh) rotate(720deg)', opacity: 1 },
},
},
},
},
Expand Down
Loading

0 comments on commit c4f30a9

Please sign in to comment.