Skip to content

Commit edb5c2f

Browse files
authored
Merge pull request #319 from CSE-Shaco/develop
feat: add 8bit ranking dashboard
2 parents 50d2026 + b5f22cd commit edb5c2f

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

src/app/dashboard/8bit/page.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { useEffect, useState } from 'react'
5+
6+
import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi'
7+
import { unwrapApiResponse } from '@/utils/api/unwrap'
8+
9+
const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL
10+
11+
type Rhythm8BeatScoreRow = {
12+
rank: number
13+
id: number
14+
phoneNumber: string
15+
nickname: string
16+
score: number
17+
stageReached: number
18+
createdAt: string
19+
updatedAt: string
20+
}
21+
22+
const formatDateTime = (value: string) => {
23+
const date = new Date(value)
24+
if (Number.isNaN(date.getTime())) {
25+
return '-'
26+
}
27+
28+
return new Intl.DateTimeFormat('ko-KR', {
29+
year: 'numeric',
30+
month: '2-digit',
31+
day: '2-digit',
32+
hour: '2-digit',
33+
minute: '2-digit',
34+
second: '2-digit',
35+
hour12: false
36+
}).format(date)
37+
}
38+
39+
export default function Dashboard8BitPage() {
40+
const { authorizedFetch } = useAuthenticatedApi()
41+
const [scores, setScores] = useState<Rhythm8BeatScoreRow[]>([])
42+
const [isLoading, setIsLoading] = useState(true)
43+
const [error, setError] = useState<string | null>(null)
44+
45+
useEffect(() => {
46+
let isMounted = true
47+
48+
const loadScores = async () => {
49+
if (!API_BASE_URL) {
50+
if (isMounted) {
51+
setScores([])
52+
setError('API 기본 주소가 설정되지 않았습니다.')
53+
setIsLoading(false)
54+
}
55+
return
56+
}
57+
58+
try {
59+
const response = await authorizedFetch(`${API_BASE_URL}/admin/game/rythm8beat/scores`, {
60+
cache: 'no-store'
61+
})
62+
63+
if (!response.ok) {
64+
throw new Error('8bit 순위표를 불러오지 못했습니다.')
65+
}
66+
67+
const nextScores = unwrapApiResponse<Rhythm8BeatScoreRow[]>(await response.json())
68+
69+
if (isMounted) {
70+
setScores(Array.isArray(nextScores) ? nextScores : [])
71+
setError(null)
72+
}
73+
} catch {
74+
if (isMounted) {
75+
setError('8bit 순위표를 불러오지 못했습니다.')
76+
}
77+
} finally {
78+
if (isMounted) {
79+
setIsLoading(false)
80+
}
81+
}
82+
}
83+
84+
void loadScores()
85+
86+
const intervalId = window.setInterval(() => {
87+
void loadScores()
88+
}, 10000)
89+
90+
return () => {
91+
isMounted = false
92+
window.clearInterval(intervalId)
93+
}
94+
}, [authorizedFetch])
95+
96+
return (
97+
<main className="min-h-screen bg-black px-6 py-10 text-white pc:px-10">
98+
<div className="mx-auto w-full max-w-[1480px] space-y-8">
99+
<div className="flex items-end justify-between gap-4">
100+
<div className="space-y-2">
101+
<p className="typo-pc-c2 text-gray-700">Admin Dashboard</p>
102+
<h1 className="typo-h3 mobile:typo-m-h2">8bit 게임 순위표</h1>
103+
<p className="typo-pc-b3 text-gray-700">
104+
저장된 8bit 게임 점수 전체 행을 조회합니다. 10초마다 자동 새로고침됩니다.
105+
</p>
106+
</div>
107+
<Link
108+
href="/dashboard"
109+
className="rounded-xl border border-white/10 px-4 py-2 typo-pc-b3 text-gray-700 transition hover:border-white/30 hover:text-white"
110+
>
111+
대시보드로
112+
</Link>
113+
</div>
114+
115+
<div className="rounded-xl border border-white/10 bg-gray-100/30 p-4">
116+
<p className="typo-pc-b3 text-gray-700">
117+
{scores.length}
118+
{isLoading ? ' / 불러오는 중' : ''}
119+
</p>
120+
</div>
121+
122+
{error ? (
123+
<div className="rounded-xl border border-red bg-red-400/30 p-4 typo-pc-b3 text-red">
124+
{error}
125+
</div>
126+
) : null}
127+
128+
<div className="overflow-x-auto rounded-xl border border-white/10">
129+
<table className="w-full min-w-[1180px] border-collapse">
130+
<thead>
131+
<tr className="bg-gray-100 text-left">
132+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">Rank</th>
133+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">ID</th>
134+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">닉네임</th>
135+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">전화번호</th>
136+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">점수</th>
137+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">도달 스테이지</th>
138+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">생성 시각</th>
139+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">수정 시각</th>
140+
</tr>
141+
</thead>
142+
<tbody>
143+
{scores.length === 0 ? (
144+
<tr>
145+
<td colSpan={8} className="px-4 py-8 text-center typo-pc-b3 text-gray-700">
146+
{isLoading ? '순위표를 불러오는 중입니다.' : '저장된 점수가 없습니다.'}
147+
</td>
148+
</tr>
149+
) : (
150+
scores.map((score) => (
151+
<tr key={score.id} className="border-t border-white/10 bg-black">
152+
<td className="px-4 py-3 typo-pc-b3">{score.rank}</td>
153+
<td className="px-4 py-3 typo-pc-b3">{score.id}</td>
154+
<td className="px-4 py-3 typo-pc-b3">{score.nickname}</td>
155+
<td className="px-4 py-3 typo-pc-b3">{score.phoneNumber}</td>
156+
<td className="px-4 py-3 typo-pc-b3">{score.score}</td>
157+
<td className="px-4 py-3 typo-pc-b3">{score.stageReached}</td>
158+
<td className="px-4 py-3 typo-pc-b3">{formatDateTime(score.createdAt)}</td>
159+
<td className="px-4 py-3 typo-pc-b3">{formatDateTime(score.updatedAt)}</td>
160+
</tr>
161+
))
162+
)}
163+
</tbody>
164+
</table>
165+
</div>
166+
</div>
167+
</main>
168+
)
169+
}

src/app/dashboard/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ const DASHBOARD_LINKS = [
4040
title: 'Bingo',
4141
description: '대시보드 안에서 팀별 빙고 체크를 수정합니다.',
4242
minRoleRank: 2
43+
},
44+
{
45+
href: '/dashboard/8bit',
46+
title: '8bit 순위표',
47+
description: '8bit 게임 점수 전체 컬럼을 순위표 형태로 조회합니다.',
48+
minRoleRank: 2
4349
}
4450
] as const
4551

0 commit comments

Comments
 (0)