From 924dd8b37275aa8f02d53d8d4f1d574c85492a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9B=90=EC=97=BD?= Date: Fri, 19 Jun 2026 14:49:28 +0900 Subject: [PATCH] feat: add Maia chess game UI --- LOG.md | 8 + src/api/chess.ts | 65 ++- src/app/chess/history/page.tsx | 11 + src/app/chess/page.tsx | 11 + src/app/chess/play/[gameId]/page.tsx | 19 + src/components/chess/ChessBoard.tsx | 215 ++++++++++ src/components/chess/ChessGamePlayClient.tsx | 416 +++++++++++++++++++ src/components/chess/ChessHistoryClient.tsx | 212 ++++++++++ src/components/chess/ChessHomeClient.tsx | 361 ++++++++++++++++ src/components/chess/chessUi.ts | 54 +++ src/components/layout/DesktopDock.tsx | 4 +- src/components/layout/DesktopMenuBar.tsx | 1 + src/components/layout/DesktopShell.tsx | 2 +- src/types/index.ts | 66 +++ 14 files changed, 1441 insertions(+), 4 deletions(-) create mode 100644 src/app/chess/history/page.tsx create mode 100644 src/app/chess/page.tsx create mode 100644 src/app/chess/play/[gameId]/page.tsx create mode 100644 src/components/chess/ChessBoard.tsx create mode 100644 src/components/chess/ChessGamePlayClient.tsx create mode 100644 src/components/chess/ChessHistoryClient.tsx create mode 100644 src/components/chess/ChessHomeClient.tsx create mode 100644 src/components/chess/chessUi.ts diff --git a/LOG.md b/LOG.md index 9021fc7..5750ab3 100644 --- a/LOG.md +++ b/LOG.md @@ -1,5 +1,13 @@ # Work Log +## 2026-06-19 + +- Added the authenticated Maia chess frontend: `/chess` lobby with rating/color/model and advanced settings, `/chess/play/[gameId]` playable board with backend FEN synchronization and PGN copy, and `/chess/history` account game history with outcome filters. +- Extended the chess API client and shared types for Maia game create/list/stats/detail/PGN/move endpoints while leaving the public `/api/chess-puzzles/**` puzzle request unchanged. +- Updated the Dock/menu/shell route handling so Chess opens the Maia lobby, with the existing public puzzle page still available at `/play/chess`. +- Validation: `npm run lint` passed and `npm run build` passed. A foreground `next dev --port 3122` startup reached Ready, but persistent detached/browser verification could not be completed because sandbox-launched background dev processes exited or hung before serving requests. +- Recommended next task: verify `/chess`, `/chess/play/{gameId}`, and `/chess/history` in an authenticated browser session against a reachable backend, especially real Maia move latency/error responses. + ## 2026-06-07 - Fixed admin pages being pushed outside the visible content area by removing viewport-based `md:w-[80vw]` widths from the admin route shell and Markdown editor container. diff --git a/src/api/chess.ts b/src/api/chess.ts index 245cccb..050253d 100644 --- a/src/api/chess.ts +++ b/src/api/chess.ts @@ -1,5 +1,22 @@ import { http } from './http'; -import { ApiResponse, ChessPuzzle } from '@/types'; +import { + ApiResponse, + ChessGameCreateRequest, + ChessGamePageResponse, + ChessGamePgnResponse, + ChessGameResponse, + ChessGameStatsResponse, + ChessMoveRequest, + ChessPuzzle, +} from '@/types'; + +const requireApiData = (response: ApiResponse, fallbackMessage: string) => { + if (!response.data) { + throw new Error(response.message || fallbackMessage); + } + + return response.data; +}; export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => { const response = await http.get>('/api/chess-puzzles/today', { @@ -8,3 +25,49 @@ export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => { return response.data.data; }; + +export const createChessGame = async (data: ChessGameCreateRequest) => { + const response = await http.post>('/api/chess/games', data); + + return requireApiData(response.data, '대국을 생성하지 못했습니다.'); +}; + +export const getChessGames = async ({ + page = 0, + size = 20, + sort = 'updatedAt,desc', +}: { + page?: number; + size?: number; + sort?: string; +} = {}) => { + const response = await http.get>('/api/chess/games', { + params: { page, size, sort }, + }); + + return requireApiData(response.data, '대국 기록을 불러오지 못했습니다.'); +}; + +export const getChessGameStats = async () => { + const response = await http.get>('/api/chess/games/stats'); + + return requireApiData(response.data, '대국 통계를 불러오지 못했습니다.'); +}; + +export const getChessGame = async (gameId: string) => { + const response = await http.get>(`/api/chess/games/${gameId}`); + + return requireApiData(response.data, '대국 상세를 불러오지 못했습니다.'); +}; + +export const getChessGamePgn = async (gameId: string) => { + const response = await http.get>(`/api/chess/games/${gameId}/pgn`); + + return requireApiData(response.data, 'PGN을 불러오지 못했습니다.'); +}; + +export const postChessMove = async (gameId: string, data: ChessMoveRequest) => { + const response = await http.post>(`/api/chess/games/${gameId}/moves`, data); + + return requireApiData(response.data, '착수를 처리하지 못했습니다.'); +}; diff --git a/src/app/chess/history/page.tsx b/src/app/chess/history/page.tsx new file mode 100644 index 0000000..222ad7b --- /dev/null +++ b/src/app/chess/history/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import ChessHistoryClient from '@/components/chess/ChessHistoryClient'; + +export const metadata: Metadata = { + title: '대국 기록 | WYPark Blog', + description: 'Maia 체스 대국 기록과 결과를 확인합니다.', +}; + +export default function ChessHistoryPage() { + return ; +} diff --git a/src/app/chess/page.tsx b/src/app/chess/page.tsx new file mode 100644 index 0000000..058d6d2 --- /dev/null +++ b/src/app/chess/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import ChessHomeClient from '@/components/chess/ChessHomeClient'; + +export const metadata: Metadata = { + title: 'Maia 체스 | WYPark Blog', + description: 'Maia3 봇과 체스를 두고 대국 기록과 PGN을 확인합니다.', +}; + +export default function ChessPage() { + return ; +} diff --git a/src/app/chess/play/[gameId]/page.tsx b/src/app/chess/play/[gameId]/page.tsx new file mode 100644 index 0000000..2209bdf --- /dev/null +++ b/src/app/chess/play/[gameId]/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import ChessGamePlayClient from '@/components/chess/ChessGamePlayClient'; + +export const metadata: Metadata = { + title: 'Maia 대국 | WYPark Blog', + description: 'Maia 체스 대국을 이어서 진행하고 PGN을 확인합니다.', +}; + +type ChessGamePageProps = { + params: Promise<{ + gameId: string; + }>; +}; + +export default async function ChessGamePage({ params }: ChessGamePageProps) { + const { gameId } = await params; + + return ; +} diff --git a/src/components/chess/ChessBoard.tsx b/src/components/chess/ChessBoard.tsx new file mode 100644 index 0000000..9de4572 --- /dev/null +++ b/src/components/chess/ChessBoard.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useMemo } from 'react'; +import { Chess as ChessGame, type Color, type PieceSymbol, type Square } from 'chess.js'; +import { clsx } from 'clsx'; +import { ChessColor } from '@/types'; + +type MoveSquares = { + from: Square; + to: Square; +}; + +interface ChessBoardProps { + fen: string; + orientation?: ChessColor; + selectedSquare?: Square | null; + legalTargets?: Set; + lastMoveSquares?: MoveSquares | null; + disabled?: boolean; + isDraggableSquare?: (square: Square) => boolean; + onSquareClick?: (square: Square) => void; + onSquareDrop?: (from: Square, to: Square) => void; +} + +const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const; +const WHITE_RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const; +const BLACK_RANKS = [1, 2, 3, 4, 5, 6, 7, 8] as const; + +const PIECE_SYMBOLS: Record> = { + w: { + k: '♔', + q: '♕', + r: '♖', + b: '♗', + n: '♘', + p: '♙', + }, + b: { + k: '♚', + q: '♛', + r: '♜', + b: '♝', + n: '♞', + p: '♟', + }, +}; + +const PIECE_NAMES: Record = { + k: '킹', + q: '퀸', + r: '룩', + b: '비숍', + n: '나이트', + p: '폰', +}; + +const colorLabel = (color: Color) => (color === 'w' ? '백' : '흑'); + +const createGame = (fen: string) => { + try { + return new ChessGame(fen); + } catch { + return new ChessGame(); + } +}; + +const getBoardSquares = (orientation: ChessColor) => { + const ranks = orientation === 'black' ? BLACK_RANKS : WHITE_RANKS; + const files = orientation === 'black' ? [...FILES].reverse() : FILES; + + return ranks.flatMap((rank) => files.map((file) => `${file}${rank}` as Square)); +}; + +const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => { + if (!piece) return `${square} 빈 칸`; + + return `${square} ${colorLabel(piece.color)} ${PIECE_NAMES[piece.type]}`; +}; + +const isLightSquare = (square: Square) => { + const fileIndex = FILES.indexOf(square[0] as (typeof FILES)[number]); + const rank = Number(square[1]); + + return (fileIndex + rank) % 2 === 0; +}; + +export const getMoveSquares = (move?: string | null): MoveSquares | null => { + if (!move || move.length < 4) return null; + + return { + from: move.slice(0, 2) as Square, + to: move.slice(2, 4) as Square, + }; +}; + +export const toChessJsColor = (color: ChessColor): Color => (color === 'white' ? 'w' : 'b'); + +export const getTurnLabel = (color: ChessColor) => (color === 'white' ? '백' : '흑'); + +export default function ChessBoard({ + fen, + orientation = 'white', + selectedSquare, + legalTargets, + lastMoveSquares, + disabled = false, + isDraggableSquare, + onSquareClick, + onSquareDrop, +}: ChessBoardProps) { + const game = useMemo(() => createGame(fen), [fen]); + const boardSquares = useMemo(() => getBoardSquares(orientation), [orientation]); + const firstFile = orientation === 'black' ? 'h' : 'a'; + const lastRank = orientation === 'black' ? '8' : '1'; + + return ( +
+ {boardSquares.map((square) => { + const piece = game.get(square); + const isLight = isLightSquare(square); + const isSelected = selectedSquare === square; + const isTarget = legalTargets?.has(square) ?? false; + const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square; + const canDrag = !disabled && Boolean(piece) && (isDraggableSquare?.(square) ?? true); + const file = square[0]; + const rank = square[1]; + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/chess/ChessGamePlayClient.tsx b/src/components/chess/ChessGamePlayClient.tsx new file mode 100644 index 0000000..2ab3b7a --- /dev/null +++ b/src/components/chess/ChessGamePlayClient.tsx @@ -0,0 +1,416 @@ +'use client'; + +import { useMemo, useState, type ReactNode } from 'react'; +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Chess as ChessGame, type Move, type Square } from 'chess.js'; +import { clsx } from 'clsx'; +import { + AlertCircle, + ChevronLeft, + Clipboard, + History, + Home, + Loader2, + RotateCcw, + Swords, +} from 'lucide-react'; +import toast from 'react-hot-toast'; +import { getChessGame, postChessMove } from '@/api/chess'; +import ChessBoard, { getMoveSquares, getTurnLabel, toChessJsColor } from '@/components/chess/ChessBoard'; +import { getChessErrorMessage, isAuthError, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi'; +import StatusBadge from '@/components/ui/StatusBadge'; +import WindowSurface from '@/components/ui/WindowSurface'; +import { useAuthStore } from '@/store/authStore'; +import { ChessGameResponse } from '@/types'; + +interface ChessGamePlayClientProps { + gameId: string; +} + +const INITIAL_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; + +const createGame = (fen: string) => { + try { + return new ChessGame(fen); + } catch { + return null; + } +}; + +const getPreferredMove = (moves: Move[], to: Square) => { + const candidates = moves.filter((move) => move.to === to); + + return candidates.find((move) => move.promotion === 'q') ?? candidates[0] ?? null; +}; + +const toUciMove = (move: Move) => `${move.from}${move.to}${move.promotion ?? ''}`; + +function ChessPageFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function LoadingState({ message = '대국을 불러오는 중입니다.' }: { message?: string }) { + return ( + + + +

{message}

+
+
+ ); +} + +function LoginRequired() { + return ( + + + +

로그인이 필요합니다.

+

+ Maia 대국은 로그인한 계정의 기록으로 저장됩니다. +

+ + 로그인 + +
+
+ ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( + + + +

대국을 불러오지 못했습니다.

+

{message}

+ +
+
+ ); +} + +function GameInfoPanel({ + game, + isPlayerTurn, + pending, + onCopyPgn, +}: { + game: ChessGameResponse; + isPlayerTurn: boolean; + pending: boolean; + onCopyPgn: () => void; +}) { + const gameEnded = game.status !== 'IN_PROGRESS'; + + return ( + +
+ {outcomeLabels[game.outcome]} + {game.result && {game.result}} +
+ +
+
+
레이팅
+
{game.rating}
+
+
+
모델
+
Maia {game.model}
+
+
+
내 색상
+
{getTurnLabel(game.playerColor)}
+
+
+
현재 차례
+
{getTurnLabel(game.turn)}
+
+
+ +
+ {gameEnded && '종료된 대국입니다.'} + {!gameEnded && pending && 'Maia 응답을 기다리는 중입니다.'} + {!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'} + {!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'} +
+ +
+

마지막 Maia 수

+

+ {game.maiaMove || '아직 없음'} +

+
+ +
+
+

PGN

+ +
+
+          {game.pgn || 'PGN이 아직 없습니다.'}
+        
+
+
+ ); +} + +export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps) { + const queryClient = useQueryClient(); + const { isLoggedIn, _hasHydrated } = useAuthStore(); + const [selectedSquare, setSelectedSquare] = useState(null); + const [optimisticFen, setOptimisticFen] = useState(null); + const gameQueryKey = useMemo(() => ['chess-game', gameId] as const, [gameId]); + + const gameQuery = useQuery({ + queryKey: gameQueryKey, + queryFn: () => getChessGame(gameId), + enabled: _hasHydrated && isLoggedIn, + retry: 0, + }); + + const moveMutation = useMutation({ + mutationFn: (move: string) => postChessMove(gameId, { move }), + onSuccess: async (updatedGame) => { + queryClient.setQueryData(gameQueryKey, updatedGame); + setOptimisticFen(null); + setSelectedSquare(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['chess-games'] }), + queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }), + ]); + }, + onError: (error) => { + setOptimisticFen(null); + setSelectedSquare(null); + toast.error(getChessErrorMessage(error, '봇 응답을 가져오지 못했습니다. 잠시 후 다시 시도해주세요.')); + void gameQuery.refetch(); + }, + }); + + const game = gameQuery.data; + const currentFen = optimisticFen ?? game?.fen ?? INITIAL_FEN; + const localGame = useMemo(() => createGame(currentFen), [currentFen]); + const playerColor = game ? toChessJsColor(game.playerColor) : null; + const canInteract = + Boolean(game && localGame && playerColor) && + game?.status === 'IN_PROGRESS' && + game.turn === game.playerColor && + !moveMutation.isPending; + + const legalMoves = useMemo(() => { + if (!localGame || !selectedSquare || !canInteract) return []; + + return localGame.moves({ square: selectedSquare, verbose: true }); + }, [canInteract, localGame, selectedSquare]); + + const legalTargets = useMemo(() => new Set(legalMoves.map((move) => move.to)), [legalMoves]); + const lastMoveSquares = useMemo(() => getMoveSquares(game?.maiaMove ?? game?.moves.at(-1)), [game?.maiaMove, game?.moves]); + + const isOwnTurnPiece = (square: Square) => { + if (!localGame || !playerColor || !canInteract) return false; + + const piece = localGame.get(square); + + return piece?.color === playerColor && piece.color === localGame.turn(); + }; + + const attemptMove = (from: Square, to: Square) => { + if (!game || !localGame || !canInteract || !isOwnTurnPiece(from)) return; + + const movesFromSquare = localGame.moves({ square: from, verbose: true }); + const candidate = getPreferredMove(movesFromSquare, to); + + if (!candidate) { + setSelectedSquare(null); + toast.error('그 칸으로는 이동할 수 없습니다.'); + return; + } + + try { + const nextGame = new ChessGame(currentFen); + nextGame.move({ + from, + to, + promotion: candidate.promotion, + }); + + setOptimisticFen(nextGame.fen()); + setSelectedSquare(null); + moveMutation.mutate(toUciMove(candidate)); + } catch { + toast.error('합법적인 수가 아닙니다.'); + } + }; + + const handleSquareClick = (square: Square) => { + if (!localGame || !canInteract) return; + + const piece = localGame.get(square); + const isSelectablePiece = piece?.color === playerColor && piece.color === localGame.turn(); + + if (!selectedSquare) { + if (isSelectablePiece) setSelectedSquare(square); + return; + } + + if (selectedSquare === square) { + setSelectedSquare(null); + return; + } + + if (legalTargets.has(square)) { + attemptMove(selectedSquare, square); + return; + } + + if (isSelectablePiece) { + setSelectedSquare(square); + return; + } + + setSelectedSquare(null); + }; + + const copyPgn = () => { + if (!game?.pgn) { + toast.error('복사할 PGN이 없습니다.'); + return; + } + + void navigator.clipboard.writeText(game.pgn) + .then(() => toast.success('PGN을 복사했습니다.')) + .catch(() => toast.error('PGN 복사에 실패했습니다.')); + }; + + if (!_hasHydrated) return ; + if (!isLoggedIn) return ; + if (gameQuery.isLoading) return ; + if (gameQuery.isError) { + if (isAuthError(gameQuery.error)) return ; + + return ( + void gameQuery.refetch()} + /> + ); + } + + if (!game) { + return ( + void gameQuery.refetch()} + /> + ); + } + + const isPlayerTurn = game.status === 'IN_PROGRESS' && game.turn === game.playerColor; + + return ( + +
+
+
+ +

+ Maia 대국 +

+
+
+ + + 로비 + + + + 기록 + +
+
+ + + 새 대국 설정으로 돌아가기 + +
+ +
+ +
+ + {moveMutation.isPending && ( +
+
+ + Maia 응답 대기 +
+
+ )} +
+
+ + +
+
+ ); +} diff --git a/src/components/chess/ChessHistoryClient.tsx b/src/components/chess/ChessHistoryClient.tsx new file mode 100644 index 0000000..40243b2 --- /dev/null +++ b/src/components/chess/ChessHistoryClient.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useMemo, useState, type ReactNode } from 'react'; +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { AlertCircle, Bot, ChevronRight, Home, Loader2, RotateCcw, Swords } from 'lucide-react'; +import { getChessGames } from '@/api/chess'; +import { getTurnLabel } from '@/components/chess/ChessBoard'; +import { + formatChessDateTime, + getChessErrorMessage, + isAuthError, + outcomeBadgeTones, + outcomeLabels, + type OutcomeFilter, +} from '@/components/chess/chessUi'; +import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl'; +import StatusBadge from '@/components/ui/StatusBadge'; +import WindowSurface from '@/components/ui/WindowSurface'; +import { useAuthStore } from '@/store/authStore'; +import { ChessGameSummaryResponse } from '@/types'; + +const FILTER_OPTIONS: readonly SegmentedControlOption[] = [ + { label: '전체', value: 'ALL' }, + { label: '진행중', value: 'IN_PROGRESS' }, + { label: '승', value: 'WIN' }, + { label: '패', value: 'LOSS' }, + { label: '무', value: 'DRAW' }, +]; + +function ChessPageFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function LoadingState({ message = '대국 기록을 불러오는 중입니다.' }: { message?: string }) { + return ( + + + +

{message}

+
+
+ ); +} + +function LoginRequired() { + return ( + + + +

로그인이 필요합니다.

+

+ 대국 기록은 로그인한 계정 기준으로 조회됩니다. +

+ + 로그인 + +
+
+ ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( + + + +

기록을 불러오지 못했습니다.

+

{message}

+ +
+
+ ); +} + +function HistoryRow({ game }: { game: ChessGameSummaryResponse }) { + return ( + +
+
+ {outcomeLabels[game.outcome]} + {game.result && {game.result}} +
+

+ Maia {game.model} · 레이팅 {game.rating} +

+

+ 업데이트 {formatChessDateTime(game.updatedAt)} +

+
+ +
+

내 색상

+

{getTurnLabel(game.playerColor)}

+
+
+

+

{game.movesCount.toLocaleString()}수

+
+
+

생성

+

{formatChessDateTime(game.createdAt)}

+
+ + + ); +} + +export default function ChessHistoryClient() { + const { isLoggedIn, _hasHydrated } = useAuthStore(); + const [filter, setFilter] = useState('ALL'); + + const gamesQuery = useQuery({ + queryKey: ['chess-games', { page: 0, size: 100, sort: 'updatedAt,desc' }], + queryFn: () => getChessGames({ page: 0, size: 100, sort: 'updatedAt,desc' }), + enabled: _hasHydrated && isLoggedIn, + retry: 0, + }); + + const games = useMemo(() => gamesQuery.data?.content ?? [], [gamesQuery.data?.content]); + const filteredGames = useMemo(() => { + if (filter === 'ALL') return games; + + return games.filter((game) => game.outcome === filter); + }, [filter, games]); + + if (!_hasHydrated) return ; + if (!isLoggedIn) return ; + if (gamesQuery.isLoading) return ; + if (gamesQuery.isError) { + if (isAuthError(gamesQuery.error)) return ; + + return ( + void gamesQuery.refetch()} + /> + ); + } + + return ( + +
+
+
+ +

+ 대국 기록 +

+
+ + + 로비 + +
+

+ 로그인한 계정의 Maia 대국 기록입니다. 항목을 열면 보드와 PGN을 볼 수 있습니다. +

+
+ + + )} + bodyClassName="p-3 md:p-4" + > + {filteredGames.length > 0 ? ( +
+ {filteredGames.map((game) => ( + + ))} +
+ ) : ( +
+ +

표시할 대국이 없습니다.

+

필터를 바꾸거나 새 대국을 시작해보세요.

+
+ )} +
+
+ ); +} diff --git a/src/components/chess/ChessHomeClient.tsx b/src/components/chess/ChessHomeClient.tsx new file mode 100644 index 0000000..9892310 --- /dev/null +++ b/src/components/chess/ChessHomeClient.tsx @@ -0,0 +1,361 @@ +'use client'; + +import { type FormEvent, type ReactNode, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Bot, + ChevronRight, + Clock3, + History, + Loader2, + LockKeyhole, + Puzzle, + Swords, + Trophy, +} from 'lucide-react'; +import toast from 'react-hot-toast'; +import { createChessGame, getChessGames, getChessGameStats } from '@/api/chess'; +import { getTurnLabel } from '@/components/chess/ChessBoard'; +import { formatChessDateTime, getChessErrorMessage, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi'; +import MetricCard from '@/components/ui/MetricCard'; +import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl'; +import StatusBadge from '@/components/ui/StatusBadge'; +import WindowSurface from '@/components/ui/WindowSurface'; +import { useAuthStore } from '@/store/authStore'; +import { ChessColor, ChessGameCreateRequest, MaiaModel } from '@/types'; + +type ChessFormState = Required>; + +const COLOR_OPTIONS: readonly SegmentedControlOption[] = [ + { label: '백', value: 'white' }, + { label: '흑', value: 'black' }, +]; + +const MODEL_OPTIONS: readonly SegmentedControlOption[] = [ + { label: '3m', value: '3m' }, + { label: '5m', value: '5m' }, + { label: '23m', value: '23m' }, + { label: '79m', value: '79m' }, +]; + +const DEFAULT_FORM: ChessFormState = { + rating: 1500, + playerColor: 'white', + model: '5m', + temperature: 0.8, + topP: 0.95, +}; + +const clampRating = (value: number) => Math.min(2600, Math.max(600, value)); + +function ChessPageFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function LoadingGate() { + return ( + + + +

로그인 상태를 확인하는 중입니다.

+
+
+ ); +} + +function LoginRequired() { + return ( + + +
+
+ +
+

로그인이 필요합니다.

+

+ Maia 봇과의 대국, 계정별 기록, PGN은 로그인한 사용자만 볼 수 있습니다. +

+
+ + + 로그인 + + + + 퍼즐 풀기 + +
+
+
+
+ ); +} + +function GameSummaryRow({ + game, +}: { + game: { + gameId: string; + rating: number; + playerColor: ChessColor; + model: string; + outcome: keyof typeof outcomeLabels; + movesCount: number; + updatedAt: string; + }; +}) { + return ( + +
+
+ {outcomeLabels[game.outcome]} + + Maia {game.model} · {game.rating} + +
+

+ 내 색상 {getTurnLabel(game.playerColor)} · {game.movesCount.toLocaleString()}수 +

+

{formatChessDateTime(game.updatedAt)}

+
+ + + ); +} + +export default function ChessHomeClient() { + const router = useRouter(); + const queryClient = useQueryClient(); + const { isLoggedIn, _hasHydrated } = useAuthStore(); + const [form, setForm] = useState(DEFAULT_FORM); + + const statsQuery = useQuery({ + queryKey: ['chess-game-stats'], + queryFn: getChessGameStats, + enabled: _hasHydrated && isLoggedIn, + retry: 0, + }); + + const gamesQuery = useQuery({ + queryKey: ['chess-games', { page: 0, size: 6, sort: 'updatedAt,desc' }], + queryFn: () => getChessGames({ page: 0, size: 6, sort: 'updatedAt,desc' }), + enabled: _hasHydrated && isLoggedIn, + retry: 0, + }); + + const createMutation = useMutation({ + mutationFn: (payload: ChessGameCreateRequest) => createChessGame(payload), + onSuccess: async (game) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['chess-games'] }), + queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }), + ]); + router.push(`/chess/play/${game.gameId}`); + }, + onError: (error) => { + toast.error(getChessErrorMessage(error, '대국을 생성하지 못했습니다.')); + }, + }); + + const recentGames = gamesQuery.data?.content ?? []; + const stats = statsQuery.data; + const isLoadingData = statsQuery.isLoading || gamesQuery.isLoading; + const hasDataError = statsQuery.isError || gamesQuery.isError; + const dataErrorMessage = useMemo(() => { + if (statsQuery.error) return getChessErrorMessage(statsQuery.error, '통계를 불러오지 못했습니다.'); + if (gamesQuery.error) return getChessErrorMessage(gamesQuery.error, '대국 기록을 불러오지 못했습니다.'); + return ''; + }, [gamesQuery.error, statsQuery.error]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + createMutation.mutate(form); + }; + + if (!_hasHydrated) return ; + if (!isLoggedIn) return ; + + return ( + +
+
+
+ +

+ Maia 체스 +

+
+
+ + + 기록 + + + + 퍼즐 + +
+
+

+ 레이팅과 색상을 고르고 Maia3 봇과 대국합니다. 최종 보드는 백엔드가 돌려준 FEN으로 동기화됩니다. +

+
+ +
+ } /> + } /> + + + +
+ + {hasDataError && ( +
+ {dataErrorMessage} +
+ )} + +
+ +
+
+
+ + + {form.rating} + +
+ setForm((previous) => ({ ...previous, rating: clampRating(Number(event.target.value)) }))} + className="w-full accent-[var(--color-accent)]" + /> + setForm((previous) => ({ ...previous, rating: clampRating(Number(event.target.value) || DEFAULT_FORM.rating) }))} + className="mt-3 h-10 w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]" + /> +
+ +
+
+

내 색상

+ setForm((previous) => ({ ...previous, playerColor }))} + className="w-full justify-center" + /> +
+
+

모델

+ setForm((previous) => ({ ...previous, model }))} + className="w-full justify-center" + /> +
+
+ +
+ + 고급 설정 + +
+ + +
+
+ + +
+
+ + + {gamesQuery.isLoading ? ( +
+ +

기록을 불러오는 중입니다.

+
+ ) : recentGames.length > 0 ? ( +
+ {recentGames.map((game) => ( + + ))} +
+ ) : ( +
+ +

아직 대국 기록이 없습니다.

+

첫 Maia 대국을 시작해보세요.

+
+ )} +
+
+
+ ); +} diff --git a/src/components/chess/chessUi.ts b/src/components/chess/chessUi.ts new file mode 100644 index 0000000..d86fae5 --- /dev/null +++ b/src/components/chess/chessUi.ts @@ -0,0 +1,54 @@ +import axios from 'axios'; +import { ChessOutcome } from '@/types'; + +export type OutcomeFilter = 'ALL' | 'IN_PROGRESS' | 'WIN' | 'LOSS' | 'DRAW'; + +export const outcomeLabels: Record = { + IN_PROGRESS: '진행중', + WIN: '승', + LOSS: '패', + DRAW: '무', + UNKNOWN: '미정', +}; + +export const outcomeBadgeTones: Record = { + IN_PROGRESS: 'info', + WIN: 'success', + LOSS: 'danger', + DRAW: 'warning', + UNKNOWN: 'neutral', +}; + +export const formatChessDateTime = (value?: string) => { + if (!value) return ''; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date); +}; + +export const getChessErrorMessage = (error: unknown, fallback = '요청을 처리하지 못했습니다.') => { + if (axios.isAxiosError(error)) { + if (error.response?.status === 401 || error.response?.status === 403) { + return '로그인이 필요합니다.'; + } + + const message = error.response?.data?.message; + if (typeof message === 'string' && message.trim()) return message; + } + + if (error instanceof Error && error.message) return error.message; + + return fallback; +}; + +export const isAuthError = (error: unknown) => { + return axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 403); +}; diff --git a/src/components/layout/DesktopDock.tsx b/src/components/layout/DesktopDock.tsx index 5fc9c17..47a04a4 100644 --- a/src/components/layout/DesktopDock.tsx +++ b/src/components/layout/DesktopDock.tsx @@ -326,10 +326,10 @@ export default function DesktopDock({ onOpenMobileMenu }: DesktopDockProps) { }, { key: 'chess', - href: '/play/chess', + href: '/chess', label: '체스', icon: Crown, - isActive: isActivePath('/play/chess'), + isActive: (currentPath) => currentPath.startsWith('/chess') || currentPath.startsWith('/play/chess'), }, ]; diff --git a/src/components/layout/DesktopMenuBar.tsx b/src/components/layout/DesktopMenuBar.tsx index 5d2bacd..6baaa07 100644 --- a/src/components/layout/DesktopMenuBar.tsx +++ b/src/components/layout/DesktopMenuBar.tsx @@ -29,6 +29,7 @@ const getAppTitle = (pathname: string) => { if (pathname.startsWith('/archive')) return 'Archive'; if (pathname.startsWith('/category')) return getCategoryTitle(pathname); if (pathname.startsWith('/posts')) return 'Reader'; + if (pathname.startsWith('/chess')) return 'Maia Chess'; if (pathname.startsWith('/play/chess')) return 'Chess'; if (pathname.startsWith('/login')) return 'Login'; if (pathname.startsWith('/signup')) return 'Signup'; diff --git a/src/components/layout/DesktopShell.tsx b/src/components/layout/DesktopShell.tsx index 3e84037..7e2468f 100644 --- a/src/components/layout/DesktopShell.tsx +++ b/src/components/layout/DesktopShell.tsx @@ -38,7 +38,7 @@ export default function DesktopShell({ children }: { children: ReactNode }) { }; const isReaderRoute = pathname.startsWith('/posts/'); - const isChessRoute = pathname.startsWith('/play/chess'); + const isChessRoute = pathname.startsWith('/chess') || pathname.startsWith('/play/chess'); return (
diff --git a/src/types/index.ts b/src/types/index.ts index 9ed549c..76269d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -60,6 +60,72 @@ export interface ChessPuzzle { sourceUrl: string; } +export type ChessOutcome = 'IN_PROGRESS' | 'WIN' | 'LOSS' | 'DRAW' | 'UNKNOWN'; +export type ChessColor = 'white' | 'black'; +export type MaiaModel = '3m' | '5m' | '23m' | '79m'; + +export interface ChessGameCreateRequest { + rating?: number; + playerColor?: ChessColor; + model?: MaiaModel; + temperature?: number; + topP?: number; +} + +export interface ChessGameResponse { + gameId: string; + rating: number; + playerColor: ChessColor; + model: MaiaModel; + fen: string; + turn: ChessColor; + moves: string[]; + status: string; + result: string | null; + outcome: ChessOutcome; + pgn: string; + maiaMove: string | null; +} + +export interface ChessGameSummaryResponse { + gameId: string; + rating: number; + playerColor: ChessColor; + model: string; + status: string; + result: string | null; + outcome: ChessOutcome; + movesCount: number; + createdAt: string; + updatedAt: string; +} + +export interface ChessGameStatsResponse { + total: number; + inProgress: number; + wins: number; + losses: number; + draws: number; + unknown: number; +} + +export interface ChessGamePgnResponse { + gameId: string; + pgn: string; +} + +export interface ChessMoveRequest { + move: string; +} + +export interface ChessGamePageResponse extends PageMeta { + content: ChessGameSummaryResponse[]; + page?: PageMeta; + size?: number; + first?: boolean; + empty?: boolean; +} + // 4. 로그인 응답 export interface AuthResponse { grantType: string;