Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/images/bingo/team1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bingo/team9.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
169 changes: 169 additions & 0 deletions src/app/dashboard/8bit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use client'

import Link from 'next/link'
import { useEffect, useState } from 'react'

import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi'
import { unwrapApiResponse } from '@/utils/api/unwrap'

const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL

type Rhythm8BeatScoreRow = {
rank: number
id: number
phoneNumber: string
nickname: string
score: number
stageReached: number
createdAt: string
updatedAt: string
}

const formatDateTime = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}

return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).format(date)
}

export default function Dashboard8BitPage() {
const { authorizedFetch } = useAuthenticatedApi()
const [scores, setScores] = useState<Rhythm8BeatScoreRow[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
let isMounted = true

const loadScores = async () => {
if (!API_BASE_URL) {
if (isMounted) {
setScores([])
setError('API 기본 주소가 설정되지 않았습니다.')
setIsLoading(false)
}
return
}

try {
const response = await authorizedFetch(`${API_BASE_URL}/admin/game/rythm8beat/scores`, {
cache: 'no-store'
})

if (!response.ok) {
throw new Error('8bit 순위표를 불러오지 못했습니다.')
}

const nextScores = unwrapApiResponse<Rhythm8BeatScoreRow[]>(await response.json())

if (isMounted) {
setScores(Array.isArray(nextScores) ? nextScores : [])
setError(null)
}
} catch {
if (isMounted) {
setError('8bit 순위표를 불러오지 못했습니다.')
}
} finally {
if (isMounted) {
setIsLoading(false)
}
}
}

void loadScores()

const intervalId = window.setInterval(() => {
void loadScores()
}, 10000)

return () => {
isMounted = false
window.clearInterval(intervalId)
}
}, [authorizedFetch])

return (
<main className="min-h-screen bg-black px-6 py-10 text-white pc:px-10">
<div className="mx-auto w-full max-w-[1480px] space-y-8">
<div className="flex items-end justify-between gap-4">
<div className="space-y-2">
<p className="typo-pc-c2 text-gray-700">Admin Dashboard</p>
<h1 className="typo-h3 mobile:typo-m-h2">8bit 게임 순위표</h1>
<p className="typo-pc-b3 text-gray-700">
저장된 8bit 게임 점수 전체 행을 조회합니다. 10초마다 자동 새로고침됩니다.
</p>
</div>
<Link
href="/dashboard"
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"
>
대시보드로
</Link>
</div>

<div className="rounded-xl border border-white/10 bg-gray-100/30 p-4">
<p className="typo-pc-b3 text-gray-700">
총 {scores.length}건
{isLoading ? ' / 불러오는 중' : ''}
</p>
</div>

{error ? (
<div className="rounded-xl border border-red bg-red-400/30 p-4 typo-pc-b3 text-red">
{error}
</div>
) : null}

<div className="overflow-x-auto rounded-xl border border-white/10">
<table className="w-full min-w-[1180px] border-collapse">
<thead>
<tr className="bg-gray-100 text-left">
<th className="px-4 py-3 typo-pc-b3 text-gray-700">Rank</th>
<th className="px-4 py-3 typo-pc-b3 text-gray-700">ID</th>
<th className="px-4 py-3 typo-pc-b3 text-gray-700">닉네임</th>
<th className="px-4 py-3 typo-pc-b3 text-gray-700">전화번호</th>
<th className="px-4 py-3 typo-pc-b3 text-gray-700">점수</th>
<th className="px-4 py-3 typo-pc-b3 text-gray-700">도달 스테이지</th>
<th className="px-4 py-3 typo-pc-b3 text-gray-700">생성 시각</th>
<th className="px-4 py-3 typo-pc-b3 text-gray-700">수정 시각</th>
</tr>
</thead>
<tbody>
{scores.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center typo-pc-b3 text-gray-700">
{isLoading ? '순위표를 불러오는 중입니다.' : '저장된 점수가 없습니다.'}
</td>
</tr>
) : (
scores.map((score) => (
<tr key={score.id} className="border-t border-white/10 bg-black">
<td className="px-4 py-3 typo-pc-b3">{score.rank}</td>
<td className="px-4 py-3 typo-pc-b3">{score.id}</td>
<td className="px-4 py-3 typo-pc-b3">{score.nickname}</td>
<td className="px-4 py-3 typo-pc-b3">{score.phoneNumber}</td>
<td className="px-4 py-3 typo-pc-b3">{score.score}</td>
<td className="px-4 py-3 typo-pc-b3">{score.stageReached}</td>
<td className="px-4 py-3 typo-pc-b3">{formatDateTime(score.createdAt)}</td>
<td className="px-4 py-3 typo-pc-b3">{formatDateTime(score.updatedAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</main>
)
}
34 changes: 34 additions & 0 deletions src/app/dashboard/bingo/[team]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { notFound } from 'next/navigation'

import BingoBoard from '@/components/event/bingo/BingoBoard'

type DashboardBingoTeamPageProps = {
params: Promise<{
team: string
}>
}

const TEAM_NUMBERS = new Set(Array.from({ length: 10 }, (_, index) => String(index + 1)))

export function generateStaticParams() {
return Array.from({ length: 10 }, (_, index) => ({
team: String(index + 1)
}))
}

export default async function DashboardBingoTeamPage({ params }: DashboardBingoTeamPageProps) {
const { team } = await params

if (!TEAM_NUMBERS.has(team)) {
notFound()
}

return (
<BingoBoard
teamNumber={Number(team)}
editable
backHref="/dashboard/bingo"
backLabel="빙고 관리로"
/>
)
}
120 changes: 120 additions & 0 deletions src/app/dashboard/bingo/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use client'

import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'

import { unwrapApiResponse } from '@/utils/api/unwrap'

const TEAM_NUMBERS = Array.from({ length: 10 }, (_, index) => index + 1)
const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL

type TeamRankingEntry = {
teamNumber: number
checkedCount: number
rank: number
}

async function fetchRankings() {
if (!API_BASE_URL) {
return []
}

const response = await fetch(`${API_BASE_URL}/game/bingo/boards`, {
credentials: 'include',
cache: 'no-store'
})

if (!response.ok) {
throw new Error('빙고 현황을 불러오지 못했습니다.')
}

const payload = unwrapApiResponse<TeamRankingEntry[]>(await response.json())
return Array.isArray(payload) ? payload : []
}

export default function DashboardBingoPage() {
const [rankings, setRankings] = useState<TeamRankingEntry[]>([])

useEffect(() => {
let isMounted = true

const loadRankings = async () => {
try {
const nextRankings = await fetchRankings()
if (isMounted) {
setRankings(nextRankings)
}
} catch {
if (isMounted) {
setRankings([])
}
}
}

void loadRankings()
const intervalId = window.setInterval(() => {
void loadRankings()
}, 10000)

return () => {
isMounted = false
window.clearInterval(intervalId)
}
}, [])

const rankingMap = useMemo(
() => new Map(rankings.map((entry) => [entry.teamNumber, entry])),
[rankings]
)

return (
<main className="min-h-screen bg-black px-6 py-10 text-white pc:px-10">
<div className="mx-auto w-full max-w-[1280px] space-y-8">
<div className="flex items-end justify-between gap-4">
<div className="space-y-2">
<p className="typo-pc-c2 text-gray-700">Admin Dashboard</p>
<h1 className="typo-h3 mobile:typo-m-h2">Bingo 관리</h1>
<p className="typo-pc-b3 text-gray-700">
팀을 선택하면 대시보드 안에서 빙고 체크를 수정할 수 있습니다.
</p>
</div>
<Link
href="/dashboard"
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"
>
대시보드로
</Link>
</div>

<div className="grid gap-4 pc:grid-cols-2">
{TEAM_NUMBERS.map((teamNumber) => {
const ranking = rankingMap.get(teamNumber)

return (
<Link
key={teamNumber}
href={`/dashboard/bingo/${teamNumber}`}
className="group rounded-2xl border border-white/10 bg-gray-100/30 p-5 transition hover:border-white/40 hover:bg-white/5"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="typo-pc-c2 text-gray-700">TEAM {teamNumber}</p>
<h2 className="typo-pc-h4 text-white">
{ranking ? `${ranking.checkedCount}칸 체크` : '기록 없음'}
</h2>
<p className="typo-pc-b3 text-gray-700">
{ranking ? `현재 ${ranking.rank}위` : '아직 랭킹 데이터가 없습니다.'}
</p>
</div>
<span className="rounded-full border border-white/10 px-3 py-1 typo-pc-c2 text-gray-700 transition group-hover:border-white/30 group-hover:text-white">
수정
</span>
</div>
</Link>
)
})}
</div>
</div>
</main>
)
}
12 changes: 12 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ const DASHBOARD_LINKS = [
title: 'Memo 발송',
description: '신입생 알림 신청 대상에게 안내 메시지를 발송합니다.',
minRoleRank: 2
},
{
href: '/dashboard/bingo',
title: 'Bingo',
description: '대시보드 안에서 팀별 빙고 체크를 수정합니다.',
minRoleRank: 2
},
{
href: '/dashboard/8bit',
title: '8bit 순위표',
description: '8bit 게임 점수 전체 컬럼을 순위표 형태로 조회합니다.',
minRoleRank: 2
}
] as const

Expand Down
27 changes: 27 additions & 0 deletions src/app/event/2026/bingo/[team]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { notFound } from 'next/navigation'

import BingoBoard from '@/components/event/bingo/BingoBoard'

type BingoTeamPageProps = {
params: Promise<{
team: string
}>
}

const TEAM_NUMBERS = new Set(Array.from({ length: 10 }, (_, index) => String(index + 1)))

export function generateStaticParams() {
return Array.from({ length: 10 }, (_, index) => ({
team: String(index + 1)
}))
}

export default async function BingoTeamPage({ params }: BingoTeamPageProps) {
const { team } = await params

if (!TEAM_NUMBERS.has(team)) {
notFound()
}

return <BingoBoard teamNumber={Number(team)} />
}
Loading
Loading