diff --git a/public/images/bingo/team1.png b/public/images/bingo/team1.png new file mode 100644 index 0000000..92f4e64 Binary files /dev/null and b/public/images/bingo/team1.png differ diff --git a/public/images/bingo/team10.png b/public/images/bingo/team10.png new file mode 100644 index 0000000..0bf3d76 Binary files /dev/null and b/public/images/bingo/team10.png differ diff --git a/public/images/bingo/team2.png b/public/images/bingo/team2.png new file mode 100644 index 0000000..77c9996 Binary files /dev/null and b/public/images/bingo/team2.png differ diff --git a/public/images/bingo/team3.png b/public/images/bingo/team3.png new file mode 100644 index 0000000..4c96de1 Binary files /dev/null and b/public/images/bingo/team3.png differ diff --git a/public/images/bingo/team4.png b/public/images/bingo/team4.png new file mode 100644 index 0000000..e81b00a Binary files /dev/null and b/public/images/bingo/team4.png differ diff --git a/public/images/bingo/team5.png b/public/images/bingo/team5.png new file mode 100644 index 0000000..3695a6a Binary files /dev/null and b/public/images/bingo/team5.png differ diff --git a/public/images/bingo/team6.png b/public/images/bingo/team6.png new file mode 100644 index 0000000..faec809 Binary files /dev/null and b/public/images/bingo/team6.png differ diff --git a/public/images/bingo/team7.png b/public/images/bingo/team7.png new file mode 100644 index 0000000..7604b0d Binary files /dev/null and b/public/images/bingo/team7.png differ diff --git a/public/images/bingo/team8.png b/public/images/bingo/team8.png new file mode 100644 index 0000000..bd12985 Binary files /dev/null and b/public/images/bingo/team8.png differ diff --git a/public/images/bingo/team9.png b/public/images/bingo/team9.png new file mode 100644 index 0000000..90e50f0 Binary files /dev/null and b/public/images/bingo/team9.png differ diff --git a/src/app/dashboard/8bit/page.tsx b/src/app/dashboard/8bit/page.tsx new file mode 100644 index 0000000..e3c2510 --- /dev/null +++ b/src/app/dashboard/8bit/page.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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(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 ( +
+
+
+
+

Admin Dashboard

+

8bit 게임 순위표

+

+ 저장된 8bit 게임 점수 전체 행을 조회합니다. 10초마다 자동 새로고침됩니다. +

+
+ + 대시보드로 + +
+ +
+

+ 총 {scores.length}건 + {isLoading ? ' / 불러오는 중' : ''} +

+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + + + + + + + + + + + + + + {scores.length === 0 ? ( + + + + ) : ( + scores.map((score) => ( + + + + + + + + + + + )) + )} + +
RankID닉네임전화번호점수도달 스테이지생성 시각수정 시각
+ {isLoading ? '순위표를 불러오는 중입니다.' : '저장된 점수가 없습니다.'} +
{score.rank}{score.id}{score.nickname}{score.phoneNumber}{score.score}{score.stageReached}{formatDateTime(score.createdAt)}{formatDateTime(score.updatedAt)}
+
+
+
+ ) +} diff --git a/src/app/dashboard/bingo/[team]/page.tsx b/src/app/dashboard/bingo/[team]/page.tsx new file mode 100644 index 0000000..e47ddbf --- /dev/null +++ b/src/app/dashboard/bingo/[team]/page.tsx @@ -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 ( + + ) +} diff --git a/src/app/dashboard/bingo/page.tsx b/src/app/dashboard/bingo/page.tsx new file mode 100644 index 0000000..b1f874e --- /dev/null +++ b/src/app/dashboard/bingo/page.tsx @@ -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(await response.json()) + return Array.isArray(payload) ? payload : [] +} + +export default function DashboardBingoPage() { + const [rankings, setRankings] = useState([]) + + 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 ( +
+
+
+
+

Admin Dashboard

+

Bingo 관리

+

+ 팀을 선택하면 대시보드 안에서 빙고 체크를 수정할 수 있습니다. +

+
+ + 대시보드로 + +
+ +
+ {TEAM_NUMBERS.map((teamNumber) => { + const ranking = rankingMap.get(teamNumber) + + return ( + +
+
+

TEAM {teamNumber}

+

+ {ranking ? `${ranking.checkedCount}칸 체크` : '기록 없음'} +

+

+ {ranking ? `현재 ${ranking.rank}위` : '아직 랭킹 데이터가 없습니다.'} +

+
+ + 수정 + +
+ + ) + })} +
+
+
+ ) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index e3ccd58..3c5fddc 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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 diff --git a/src/app/event/2026/bingo/[team]/page.tsx b/src/app/event/2026/bingo/[team]/page.tsx new file mode 100644 index 0000000..b7c8d1e --- /dev/null +++ b/src/app/event/2026/bingo/[team]/page.tsx @@ -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 +} diff --git a/src/app/event/2026/bingo/page.tsx b/src/app/event/2026/bingo/page.tsx new file mode 100644 index 0000000..50ab499 --- /dev/null +++ b/src/app/event/2026/bingo/page.tsx @@ -0,0 +1,152 @@ +'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(await response.json()) + return Array.isArray(payload) ? payload : [] +} + +export default function BingoIndexPage() { + const [rankings, setRankings] = useState([]) + + 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 ( +
+
+
+
+
+

+ GDGoC INHA 2026 +

+

Bingo Lobby

+

+ 팀을 선택하면 각 팀 빙고판으로 이동합니다. 아래 순위는 현재 저장된 체크 칸 수 + 기준입니다. +

+
+
+ 총{' '} + + 10 Teams + +
+
+
+ +
+
+

체크 순위

+ local ranking +
+
+ {rankings.map((entry) => ( +
+

+ #{entry.rank} +

+

TEAM {entry.teamNumber}

+

{entry.checkedCount}칸

+
+ ))} +
+
+ +
+ {TEAM_NUMBERS.map((teamNumber) => { + const ranking = rankingMap.get(teamNumber) + + return ( + +
+
+
+
+ + TEAM {teamNumber} + + + {ranking ? `#${ranking.rank}` : '-'} + +
+

+ {ranking ? `${ranking.checkedCount}칸 체크` : '기록 없음'} +

+
+ 팀 빙고판 열기 + + 입장 + +
+
+ + ) + })} +
+
+
+ ) +} diff --git a/src/components/event/bingo/BingoBoard.tsx b/src/components/event/bingo/BingoBoard.tsx new file mode 100644 index 0000000..7e3c77e --- /dev/null +++ b/src/components/event/bingo/BingoBoard.tsx @@ -0,0 +1,621 @@ +'use client' + +import Image from 'next/image' +import Link from 'next/link' +import { useEffect, useMemo, useState } from 'react' +import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' +import { unwrapApiResponse } from '@/utils/api/unwrap' + +const CELL_COUNT = 16 +const GRID_COLUMNS = 4 +const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL +const TEAM_TASKS: Record = { + 1: [ + '다른 팀과\n잔막하기', + 'PC방 가기', + '영화 보기', + '인형 뽑기', + '학식 먹기', + '네컷사진\n찍기', + '카페에서\n공부하기', + '에타 시간표\n공유하기', + '사격장 가기', + '보드게임카페\n가기', + '동아리방에서\n단체사진 찍기', + '인스타그램\n맞팔로우', + '점심 먹기', + '노래방 가기', + '술집 가기', + '릴스 찍기' + ] +} + +const BOARD_FRAME = { + top: 23.5, + width: 74, + height: 59.5 +} + +function getBoardLeft() { + return (100 - BOARD_FRAME.width) / 2 +} + +const MARK_COLORS = { + x: { + background: 'bg-[#ffc7d1]/42', + foreground: 'bg-[#fff1f4]/30 text-[#d85d78]' + } +} as const + +type CellMark = 'empty' | 'x' + +type BingoBoardProps = { + teamNumber: number + editable?: boolean + backHref?: string + backLabel?: string +} + +type BoardFrame = { + left: number + top: number + width: number + height: number +} + +function createEmptyBoard() { + return Array(CELL_COUNT).fill('empty') +} + +type BingoBoardResponse = { + teamNumber: number + marks: CellMark[] + checkedCount: number + rank: number +} + +async function fetchBingoBoard(teamNumber: number, requester: typeof fetch = fetch) { + if (!API_BASE_URL) { + return null + } + + const response = await requester(`${API_BASE_URL}/game/bingo/boards/${teamNumber}`, { + credentials: 'include', + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error('빙고 보드를 불러오지 못했습니다.') + } + + return unwrapApiResponse(await response.json()) +} + +async function saveBingoBoard( + teamNumber: number, + marks: CellMark[], + editable: boolean, + requester: typeof fetch = fetch +) { + if (!API_BASE_URL) { + return null + } + + const path = editable + ? `${API_BASE_URL}/admin/game/bingo/boards/${teamNumber}` + : `${API_BASE_URL}/game/bingo/boards/${teamNumber}` + + const response = await requester(path, { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ marks }) + }) + + if (!response.ok) { + throw new Error('빙고 보드를 저장하지 못했습니다.') + } + + return unwrapApiResponse(await response.json()) +} + +function getCompletedLines(cellMarks: CellMark[]) { + const lines = [ + [0, 1, 2, 3], + [4, 5, 6, 7], + [8, 9, 10, 11], + [12, 13, 14, 15], + [0, 4, 8, 12], + [1, 5, 9, 13], + [2, 6, 10, 14], + [3, 7, 11, 15], + [0, 5, 10, 15], + [3, 6, 9, 12] + ] + + return lines.filter((line) => line.every((index) => cellMarks[index] === 'x')) +} + +function getLineCoordinates(line: number[], frame: BoardFrame) { + const [startIndex, , , endIndex] = line + const startColumn = startIndex % GRID_COLUMNS + const startRow = Math.floor(startIndex / GRID_COLUMNS) + const endColumn = endIndex % GRID_COLUMNS + const endRow = Math.floor(endIndex / GRID_COLUMNS) + + return { + x1: frame.left + (startColumn + 0.5) * (frame.width / GRID_COLUMNS), + y1: frame.top + (startRow + 0.5) * (frame.height / GRID_COLUMNS), + x2: frame.left + (endColumn + 0.5) * (frame.width / GRID_COLUMNS), + y2: frame.top + (endRow + 0.5) * (frame.height / GRID_COLUMNS) + } +} + +function SprayXMark() { + return ( + + ) +} + +function CherryFlower({ + className, + petalColor = '#ff8f97', + scale = 1 +}: { + className: string + petalColor?: string + scale?: number +}) { + return ( +
+
+ {[0, 72, 144, 216, 288].map((angle) => ( + + ))} + + + + +
+
+ ) +} + +function FallingPetal({ className }: { className: string }) { + return ( + + ) +} + +function Crayon({ className, color }: { className: string; color: string }) { + return ( +
+ + +
+ ) +} + +function MiniFlower({ className }: { className: string }) { + return ( +
+ + + + + +
+ ) +} + +export default function BingoBoard({ + teamNumber, + editable = false, + backHref = '/event/2026/bingo', + backLabel = '팀 목록으로' +}: BingoBoardProps) { + const { authorizedFetch } = useAuthenticatedApi() + const [cellMarks, setCellMarks] = useState(createEmptyBoard) + const [isLoading, setIsLoading] = useState(true) + const [syncError, setSyncError] = useState(null) + const boardRequester = editable ? authorizedFetch : fetch + + useEffect(() => { + let isMounted = true + + const loadBoard = async () => { + if (!API_BASE_URL) { + if (isMounted) { + setCellMarks(createEmptyBoard()) + setIsLoading(false) + } + return + } + + try { + const board = await fetchBingoBoard(teamNumber, boardRequester) + if (!isMounted) { + return + } + + if (board?.marks?.length === CELL_COUNT) { + setCellMarks(board.marks) + } else { + setCellMarks(createEmptyBoard()) + } + setSyncError(null) + } catch { + if (isMounted) { + setSyncError('서버와 동기화되지 않았습니다.') + } + } finally { + if (isMounted) { + setIsLoading(false) + } + } + } + + void loadBoard() + const intervalId = window.setInterval(() => { + void loadBoard() + }, 10000) + + return () => { + isMounted = false + window.clearInterval(intervalId) + } + }, [boardRequester, teamNumber]) + + const checkedCount = cellMarks.filter((mark) => mark === 'x').length + const completedLines = useMemo(() => getCompletedLines(cellMarks), [cellMarks]) + const canEditBingo = editable + const isInteractionDisabled = !canEditBingo || isLoading + const boardTasks = TEAM_TASKS[teamNumber] ?? TEAM_TASKS[1] ?? null + const imageBoardFrame = useMemo( + () => ({ + left: getBoardLeft(), + top: BOARD_FRAME.top, + width: BOARD_FRAME.width, + height: BOARD_FRAME.height + }), + [] + ) + + const applyMark = (cellIndex: number) => { + if (isInteractionDisabled) { + return + } + + const nextMarks = cellMarks.map((currentMark, index) => { + if (index !== cellIndex) { + return currentMark + } + + return currentMark === 'x' ? 'empty' : 'x' + }) + + setCellMarks(nextMarks) + setSyncError(null) + + void saveBingoBoard(teamNumber, nextMarks, editable, boardRequester) + .then((board) => { + if (board?.marks?.length === CELL_COUNT) { + setCellMarks(board.marks) + } + }) + .catch(() => { + setSyncError('변경 내용을 저장하지 못했습니다.') + }) + } + + const resetBoard = () => { + if (isInteractionDisabled) { + return + } + + const nextMarks = createEmptyBoard() + setCellMarks(nextMarks) + setSyncError(null) + + void saveBingoBoard(teamNumber, nextMarks, editable, boardRequester) + .then((board) => { + if (board?.marks?.length === CELL_COUNT) { + setCellMarks(board.marks) + } + }) + .catch(() => { + setSyncError('초기화 내용을 저장하지 못했습니다.') + }) + } + + return ( +
+
+
+
+ + {backLabel} + +
+

+ GDGoC 2026 Event +

+

+ Team {teamNumber} +

+ {isLoading ? ( +

+ 보드 상태를 불러오는 중입니다. +

+ ) : null} + {syncError ? ( +

{syncError}

+ ) : null} +
+
+ +
+
+ + {checkedCount}/{CELL_COUNT} + +
+
+
+
+ {editable ? ( + + ) : null} +
+
+ +
+
+
+ {`${teamNumber}팀 + +
+ {cellMarks.map((mark, index) => { + const isX = mark === 'x' + + return ( + + ) + })} + + +
+ + +
+
+ + {canEditBingo ? ( + + ) : null} +
+
+
+ ) +} diff --git a/src/lib/api/authorizedFetch.ts b/src/lib/api/authorizedFetch.ts index bf4f083..c143274 100644 --- a/src/lib/api/authorizedFetch.ts +++ b/src/lib/api/authorizedFetch.ts @@ -1,5 +1,7 @@ 'use client' +import { ACCESS_TOKEN_KEY, readStoredString } from '@/lib/auth/storage' + export type AuthorizedFetcher = ( input: RequestInfo | URL, init?: RequestInit @@ -71,10 +73,16 @@ export const createAuthorizedFetch = ({ return async (input, init) => { const resolvedInput = resolveUrl(input, baseURL) const context: FetchContext = { input, init } - const initialInit = cloneInit(init) const dispatch = async (): Promise => { - const { target, options } = makeRequest(resolvedInput, initialInit) + const requestInit = cloneInit(init) + const accessToken = readStoredString(ACCESS_TOKEN_KEY) + + if (accessToken && !requestInit.headers.has('Authorization')) { + requestInit.headers.set('Authorization', `Bearer ${accessToken}`) + } + + const { target, options } = makeRequest(resolvedInput, requestInit) return fetch(target, options) }