feat: add Maia chess game UI
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m11s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m11s
This commit is contained in:
8
LOG.md
8
LOG.md
@@ -1,5 +1,13 @@
|
|||||||
# Work Log
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
import { ApiResponse, ChessPuzzle } from '@/types';
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
ChessGameCreateRequest,
|
||||||
|
ChessGamePageResponse,
|
||||||
|
ChessGamePgnResponse,
|
||||||
|
ChessGameResponse,
|
||||||
|
ChessGameStatsResponse,
|
||||||
|
ChessMoveRequest,
|
||||||
|
ChessPuzzle,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
const requireApiData = <T>(response: ApiResponse<T>, fallbackMessage: string) => {
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error(response.message || fallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => {
|
export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => {
|
||||||
const response = await http.get<ApiResponse<ChessPuzzle>>('/api/chess-puzzles/today', {
|
const response = await http.get<ApiResponse<ChessPuzzle>>('/api/chess-puzzles/today', {
|
||||||
@@ -8,3 +25,49 @@ export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => {
|
|||||||
|
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createChessGame = async (data: ChessGameCreateRequest) => {
|
||||||
|
const response = await http.post<ApiResponse<ChessGameResponse>>('/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<ApiResponse<ChessGamePageResponse>>('/api/chess/games', {
|
||||||
|
params: { page, size, sort },
|
||||||
|
});
|
||||||
|
|
||||||
|
return requireApiData(response.data, '대국 기록을 불러오지 못했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChessGameStats = async () => {
|
||||||
|
const response = await http.get<ApiResponse<ChessGameStatsResponse>>('/api/chess/games/stats');
|
||||||
|
|
||||||
|
return requireApiData(response.data, '대국 통계를 불러오지 못했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChessGame = async (gameId: string) => {
|
||||||
|
const response = await http.get<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}`);
|
||||||
|
|
||||||
|
return requireApiData(response.data, '대국 상세를 불러오지 못했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChessGamePgn = async (gameId: string) => {
|
||||||
|
const response = await http.get<ApiResponse<ChessGamePgnResponse>>(`/api/chess/games/${gameId}/pgn`);
|
||||||
|
|
||||||
|
return requireApiData(response.data, 'PGN을 불러오지 못했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postChessMove = async (gameId: string, data: ChessMoveRequest) => {
|
||||||
|
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/moves`, data);
|
||||||
|
|
||||||
|
return requireApiData(response.data, '착수를 처리하지 못했습니다.');
|
||||||
|
};
|
||||||
|
|||||||
11
src/app/chess/history/page.tsx
Normal file
11
src/app/chess/history/page.tsx
Normal file
@@ -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 <ChessHistoryClient />;
|
||||||
|
}
|
||||||
11
src/app/chess/page.tsx
Normal file
11
src/app/chess/page.tsx
Normal file
@@ -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 <ChessHomeClient />;
|
||||||
|
}
|
||||||
19
src/app/chess/play/[gameId]/page.tsx
Normal file
19
src/app/chess/play/[gameId]/page.tsx
Normal file
@@ -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 <ChessGamePlayClient gameId={gameId} />;
|
||||||
|
}
|
||||||
215
src/components/chess/ChessBoard.tsx
Normal file
215
src/components/chess/ChessBoard.tsx
Normal file
@@ -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<Square>;
|
||||||
|
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<Color, Record<PieceSymbol, string>> = {
|
||||||
|
w: {
|
||||||
|
k: '♔',
|
||||||
|
q: '♕',
|
||||||
|
r: '♖',
|
||||||
|
b: '♗',
|
||||||
|
n: '♘',
|
||||||
|
p: '♙',
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
k: '♚',
|
||||||
|
q: '♛',
|
||||||
|
r: '♜',
|
||||||
|
b: '♝',
|
||||||
|
n: '♞',
|
||||||
|
p: '♟',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIECE_NAMES: Record<PieceSymbol, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={square}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSquareClick?.(square)}
|
||||||
|
draggable={canDrag}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (!canDrag) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', square);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!disabled && onSquareDrop) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
if (disabled || !onSquareDrop) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const from = event.dataTransfer.getData('text/plain') as Square;
|
||||||
|
if (!from || from === square) return;
|
||||||
|
|
||||||
|
onSquareDrop(from, square);
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'relative flex aspect-square items-center justify-center overflow-hidden text-[2rem] leading-none transition sm:text-[2.5rem] md:text-[3.5rem]',
|
||||||
|
isLight ? 'bg-[#eef0e6]' : 'bg-[#5f8d68]',
|
||||||
|
isSelected && 'z-10 ring-4 ring-[var(--color-accent)] ring-inset',
|
||||||
|
isLastMoveSquare && 'bg-[var(--color-accent-soft)]',
|
||||||
|
!disabled && 'hover:brightness-105 focus-visible:z-20',
|
||||||
|
disabled && 'cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
aria-label={getSquareLabel(square, piece)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
{file === firstFile && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
|
||||||
|
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rank === lastRank && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
|
||||||
|
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isTarget && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute h-4 w-4 rounded-full',
|
||||||
|
piece ? 'h-full w-full rounded-none ring-4 ring-black/20 ring-inset' : 'bg-black/20',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{piece && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'relative z-10 select-none',
|
||||||
|
canDrag && 'cursor-grab active:cursor-grabbing',
|
||||||
|
piece.color === 'w'
|
||||||
|
? 'text-[#fbfbfb] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.55)]'
|
||||||
|
: 'text-[#202124] [text-shadow:0_1px_1px_rgb(255_255_255_/_0.22)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PIECE_SYMBOLS[piece.color][piece.type]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
416
src/components/chess/ChessGamePlayClient.tsx
Normal file
416
src/components/chess/ChessGamePlayClient.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="mx-auto flex w-full min-w-0 max-w-[1180px] flex-col gap-5 px-0 py-3 md:py-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState({ message = '대국을 불러오는 중입니다.' }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={30} />
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-text)]">{message}</p>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginRequired() {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<AlertCircle className="mb-3 text-amber-500" size={30} />
|
||||||
|
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">로그인이 필요합니다.</h1>
|
||||||
|
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Maia 대국은 로그인한 계정의 기록으로 저장됩니다.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login?redirect=/chess"
|
||||||
|
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</Link>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<AlertCircle className="mb-3 text-red-500" size={30} />
|
||||||
|
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">대국을 불러오지 못했습니다.</h1>
|
||||||
|
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">{message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameInfoPanel({
|
||||||
|
game,
|
||||||
|
isPlayerTurn,
|
||||||
|
pending,
|
||||||
|
onCopyPgn,
|
||||||
|
}: {
|
||||||
|
game: ChessGameResponse;
|
||||||
|
isPlayerTurn: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onCopyPgn: () => void;
|
||||||
|
}) {
|
||||||
|
const gameEnded = game.status !== 'IN_PROGRESS';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WindowSurface title="Game Info" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
||||||
|
<div className="mb-4 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
|
||||||
|
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
|
||||||
|
<dt className="text-xs text-[var(--color-text-subtle)]">레이팅</dt>
|
||||||
|
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">{game.rating}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
|
||||||
|
<dt className="text-xs text-[var(--color-text-subtle)]">모델</dt>
|
||||||
|
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">Maia {game.model}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
|
||||||
|
<dt className="text-xs text-[var(--color-text-subtle)]">내 색상</dt>
|
||||||
|
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">{getTurnLabel(game.playerColor)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
|
||||||
|
<dt className="text-xs text-[var(--color-text-subtle)]">현재 차례</dt>
|
||||||
|
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">{getTurnLabel(game.turn)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'mt-4 rounded-lg border px-3 py-3 text-sm font-semibold leading-6',
|
||||||
|
gameEnded && 'border-[var(--color-line)] bg-black/[0.025] text-[var(--color-text-muted)] dark:bg-white/[0.06]',
|
||||||
|
!gameEnded && isPlayerTurn && !pending && 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||||
|
!gameEnded && (!isPlayerTurn || pending) && 'border-[var(--color-line)] bg-black/[0.025] text-[var(--color-text-muted)] dark:bg-white/[0.06]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{gameEnded && '종료된 대국입니다.'}
|
||||||
|
{!gameEnded && pending && 'Maia 응답을 기다리는 중입니다.'}
|
||||||
|
{!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'}
|
||||||
|
{!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg border border-[var(--color-line)] bg-black/[0.025] px-3 py-3 dark:bg-white/[0.06]">
|
||||||
|
<p className="text-xs font-semibold text-[var(--color-text-subtle)]">마지막 Maia 수</p>
|
||||||
|
<p className="mt-1 break-words font-mono text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
{game.maiaMove || '아직 없음'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="mb-2 flex min-w-0 items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-bold text-[var(--color-text)]">PGN</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopyPgn}
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-xs font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<Clipboard size={14} />
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-3 text-xs leading-5 text-[var(--color-text-muted)]">
|
||||||
|
{game.pgn || 'PGN이 아직 없습니다.'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</WindowSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { isLoggedIn, _hasHydrated } = useAuthStore();
|
||||||
|
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
|
||||||
|
const [optimisticFen, setOptimisticFen] = useState<string | null>(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<Move[]>(() => {
|
||||||
|
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 <LoadingState message="로그인 상태를 확인하는 중입니다." />;
|
||||||
|
if (!isLoggedIn) return <LoginRequired />;
|
||||||
|
if (gameQuery.isLoading) return <LoadingState />;
|
||||||
|
if (gameQuery.isError) {
|
||||||
|
if (isAuthError(gameQuery.error)) return <LoginRequired />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={getChessErrorMessage(gameQuery.error, '대국 상세를 불러오지 못했습니다.')}
|
||||||
|
onRetry={() => void gameQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message="대국 정보를 찾을 수 없습니다."
|
||||||
|
onRetry={() => void gameQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPlayerTurn = game.status === 'IN_PROGRESS' && game.turn === game.playerColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<section className="flex min-w-0 flex-col gap-3 border-b border-[var(--color-line)] pb-5">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
|
||||||
|
<Swords size={24} className="shrink-0" />
|
||||||
|
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
|
||||||
|
Maia 대국
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/chess"
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<Home size={16} />
|
||||||
|
로비
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/chess/history"
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<History size={16} />
|
||||||
|
기록
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/chess"
|
||||||
|
className="inline-flex w-fit items-center gap-1.5 text-sm font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
새 대국 설정으로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid min-w-0 items-start gap-5 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<WindowSurface
|
||||||
|
title="Board"
|
||||||
|
subtitle={moveMutation.isPending ? 'Maia thinking' : isPlayerTurn ? 'Your move' : 'Synced'}
|
||||||
|
showTrafficLights={false}
|
||||||
|
bodyClassName="p-3 md:p-5"
|
||||||
|
>
|
||||||
|
<div className="relative mx-auto max-w-full" style={{ width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))' }}>
|
||||||
|
<ChessBoard
|
||||||
|
fen={currentFen}
|
||||||
|
orientation={game.playerColor}
|
||||||
|
selectedSquare={selectedSquare}
|
||||||
|
legalTargets={legalTargets}
|
||||||
|
lastMoveSquares={lastMoveSquares}
|
||||||
|
disabled={!canInteract}
|
||||||
|
isDraggableSquare={isOwnTurnPiece}
|
||||||
|
onSquareClick={handleSquareClick}
|
||||||
|
onSquareDrop={attemptMove}
|
||||||
|
/>
|
||||||
|
{moveMutation.isPending && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/20 backdrop-blur-[2px]">
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-black/55 px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-control)]">
|
||||||
|
<Loader2 className="animate-spin" size={17} />
|
||||||
|
Maia 응답 대기
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</WindowSurface>
|
||||||
|
|
||||||
|
<GameInfoPanel
|
||||||
|
game={game}
|
||||||
|
isPlayerTurn={isPlayerTurn}
|
||||||
|
pending={moveMutation.isPending}
|
||||||
|
onCopyPgn={copyPgn}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/chess/ChessHistoryClient.tsx
Normal file
212
src/components/chess/ChessHistoryClient.tsx
Normal file
@@ -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<OutcomeFilter>[] = [
|
||||||
|
{ label: '전체', value: 'ALL' },
|
||||||
|
{ label: '진행중', value: 'IN_PROGRESS' },
|
||||||
|
{ label: '승', value: 'WIN' },
|
||||||
|
{ label: '패', value: 'LOSS' },
|
||||||
|
{ label: '무', value: 'DRAW' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ChessPageFrame({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex w-full min-w-0 max-w-[1160px] flex-col gap-5 px-0 py-3 md:py-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState({ message = '대국 기록을 불러오는 중입니다.' }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Game History" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={30} />
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-text)]">{message}</p>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginRequired() {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Game History" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<AlertCircle className="mb-3 text-amber-500" size={30} />
|
||||||
|
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">로그인이 필요합니다.</h1>
|
||||||
|
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
대국 기록은 로그인한 계정 기준으로 조회됩니다.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login?redirect=/chess/history"
|
||||||
|
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</Link>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Game History" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<AlertCircle className="mb-3 text-red-500" size={30} />
|
||||||
|
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">기록을 불러오지 못했습니다.</h1>
|
||||||
|
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">{message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryRow({ game }: { game: ChessGameSummaryResponse }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/chess/play/${game.gameId}`}
|
||||||
|
className="group grid min-w-0 gap-3 rounded-lg px-3 py-4 transition hover:bg-[var(--card-bg)] md:grid-cols-[minmax(0,1fr)_8rem_8rem_7rem_1.25rem] md:items-center"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
|
||||||
|
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm font-bold text-[var(--color-text)]">
|
||||||
|
Maia {game.model} · 레이팅 {game.rating}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-[var(--color-text-subtle)]">
|
||||||
|
업데이트 {formatChessDateTime(game.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 text-sm md:text-right">
|
||||||
|
<p className="text-xs text-[var(--color-text-subtle)] md:hidden">내 색상</p>
|
||||||
|
<p className="font-semibold text-[var(--color-text-muted)]">{getTurnLabel(game.playerColor)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-sm md:text-right">
|
||||||
|
<p className="text-xs text-[var(--color-text-subtle)] md:hidden">수</p>
|
||||||
|
<p className="font-semibold tabular-nums text-[var(--color-text-muted)]">{game.movesCount.toLocaleString()}수</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-sm md:text-right">
|
||||||
|
<p className="text-xs text-[var(--color-text-subtle)] md:hidden">생성</p>
|
||||||
|
<p className="truncate font-semibold text-[var(--color-text-muted)]">{formatChessDateTime(game.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={17} className="hidden text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)] md:block" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChessHistoryClient() {
|
||||||
|
const { isLoggedIn, _hasHydrated } = useAuthStore();
|
||||||
|
const [filter, setFilter] = useState<OutcomeFilter>('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 <LoadingState message="로그인 상태를 확인하는 중입니다." />;
|
||||||
|
if (!isLoggedIn) return <LoginRequired />;
|
||||||
|
if (gamesQuery.isLoading) return <LoadingState />;
|
||||||
|
if (gamesQuery.isError) {
|
||||||
|
if (isAuthError(gamesQuery.error)) return <LoginRequired />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={getChessErrorMessage(gamesQuery.error, '대국 기록을 불러오지 못했습니다.')}
|
||||||
|
onRetry={() => void gamesQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<section className="flex min-w-0 flex-col gap-3 border-b border-[var(--color-line)] pb-5">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
|
||||||
|
<Swords size={24} className="shrink-0" />
|
||||||
|
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
|
||||||
|
대국 기록
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/chess"
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<Home size={16} />
|
||||||
|
로비
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-2xl break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
로그인한 계정의 Maia 대국 기록입니다. 항목을 열면 보드와 PGN을 볼 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<WindowSurface
|
||||||
|
title="History"
|
||||||
|
subtitle={`${filteredGames.length.toLocaleString()} / ${games.length.toLocaleString()} games`}
|
||||||
|
showTrafficLights={false}
|
||||||
|
controls={(
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="대국 결과 필터"
|
||||||
|
options={FILTER_OPTIONS}
|
||||||
|
value={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
className="max-w-[calc(100vw-3rem)] overflow-x-auto"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
bodyClassName="p-3 md:p-4"
|
||||||
|
>
|
||||||
|
{filteredGames.length > 0 ? (
|
||||||
|
<div className="divide-y divide-[var(--color-line)]">
|
||||||
|
{filteredGames.map((game) => (
|
||||||
|
<HistoryRow key={game.gameId} game={game} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-72 flex-col items-center justify-center text-center">
|
||||||
|
<Bot className="mb-3 text-[var(--color-accent)]" size={30} />
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-text)]">표시할 대국이 없습니다.</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">필터를 바꾸거나 새 대국을 시작해보세요.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/components/chess/ChessHomeClient.tsx
Normal file
361
src/components/chess/ChessHomeClient.tsx
Normal file
@@ -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<Pick<ChessGameCreateRequest, 'rating' | 'playerColor' | 'model' | 'temperature' | 'topP'>>;
|
||||||
|
|
||||||
|
const COLOR_OPTIONS: readonly SegmentedControlOption<ChessColor>[] = [
|
||||||
|
{ label: '백', value: 'white' },
|
||||||
|
{ label: '흑', value: 'black' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODEL_OPTIONS: readonly SegmentedControlOption<MaiaModel>[] = [
|
||||||
|
{ 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 (
|
||||||
|
<main className="mx-auto flex w-full min-w-0 max-w-[1160px] flex-col gap-5 px-0 py-3 md:py-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingGate() {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={30} />
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-text)]">로그인 상태를 확인하는 중입니다.</p>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginRequired() {
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="p-6 md:p-8">
|
||||||
|
<div className="flex min-h-80 flex-col items-center justify-center text-center">
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
|
||||||
|
<LockKeyhole size={30} />
|
||||||
|
</div>
|
||||||
|
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">로그인이 필요합니다.</h1>
|
||||||
|
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Maia 봇과의 대국, 계정별 기록, PGN은 로그인한 사용자만 볼 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/login?redirect=/chess"
|
||||||
|
className="inline-flex h-10 items-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
|
||||||
|
>
|
||||||
|
<LockKeyhole size={16} />
|
||||||
|
로그인
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/play/chess"
|
||||||
|
className="inline-flex h-10 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<Puzzle size={16} />
|
||||||
|
퍼즐 풀기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WindowSurface>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameSummaryRow({
|
||||||
|
game,
|
||||||
|
}: {
|
||||||
|
game: {
|
||||||
|
gameId: string;
|
||||||
|
rating: number;
|
||||||
|
playerColor: ChessColor;
|
||||||
|
model: string;
|
||||||
|
outcome: keyof typeof outcomeLabels;
|
||||||
|
movesCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/chess/play/${game.gameId}`}
|
||||||
|
className="group flex min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-3 transition hover:bg-[var(--card-bg)]"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
|
||||||
|
<span className="text-xs font-semibold text-[var(--color-text-subtle)]">
|
||||||
|
Maia {game.model} · {game.rating}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
내 색상 {getTurnLabel(game.playerColor)} · {game.movesCount.toLocaleString()}수
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-[var(--color-text-subtle)]">{formatChessDateTime(game.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={17} className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChessHomeClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { isLoggedIn, _hasHydrated } = useAuthStore();
|
||||||
|
const [form, setForm] = useState<ChessFormState>(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<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
createMutation.mutate(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!_hasHydrated) return <LoadingGate />;
|
||||||
|
if (!isLoggedIn) return <LoginRequired />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChessPageFrame>
|
||||||
|
<section className="flex min-w-0 flex-col gap-2 border-b border-[var(--color-line)] pb-5">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
|
||||||
|
<Swords size={24} className="shrink-0" />
|
||||||
|
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
|
||||||
|
Maia 체스
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/chess/history"
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<History size={16} />
|
||||||
|
기록
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/play/chess"
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
<Puzzle size={16} />
|
||||||
|
퍼즐
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-2xl break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
레이팅과 색상을 고르고 Maia3 봇과 대국합니다. 최종 보드는 백엔드가 돌려준 FEN으로 동기화됩니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<MetricCard label="전체" value={stats?.total ?? (isLoadingData ? null : 0)} icon={<Trophy size={18} />} />
|
||||||
|
<MetricCard label="진행중" value={stats?.inProgress ?? (isLoadingData ? null : 0)} icon={<Clock3 size={18} />} />
|
||||||
|
<MetricCard label="승" value={stats?.wins ?? (isLoadingData ? null : 0)} />
|
||||||
|
<MetricCard label="패" value={stats?.losses ?? (isLoadingData ? null : 0)} />
|
||||||
|
<MetricCard label="무" value={stats?.draws ?? (isLoadingData ? null : 0)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hasDataError && (
|
||||||
|
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm font-semibold text-red-700 dark:text-red-300">
|
||||||
|
{dataErrorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="grid min-w-0 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<WindowSurface title="New Game" showTrafficLights={false} bodyClassName="p-4 md:p-5">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<label htmlFor="chess-rating" className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
레이팅
|
||||||
|
</label>
|
||||||
|
<span className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-bold tabular-nums text-[var(--color-accent)]">
|
||||||
|
{form.rating}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="chess-rating"
|
||||||
|
type="range"
|
||||||
|
min={600}
|
||||||
|
max={2600}
|
||||||
|
step={50}
|
||||||
|
value={form.rating}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, rating: clampRating(Number(event.target.value)) }))}
|
||||||
|
className="w-full accent-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={600}
|
||||||
|
max={2600}
|
||||||
|
step={50}
|
||||||
|
value={form.rating}
|
||||||
|
onChange={(event) => 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)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 gap-4 md:grid-cols-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="mb-2 text-sm font-semibold text-[var(--color-text)]">내 색상</p>
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="내 색상"
|
||||||
|
options={COLOR_OPTIONS}
|
||||||
|
value={form.playerColor}
|
||||||
|
onChange={(playerColor) => setForm((previous) => ({ ...previous, playerColor }))}
|
||||||
|
className="w-full justify-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="mb-2 text-sm font-semibold text-[var(--color-text)]">모델</p>
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="Maia 모델"
|
||||||
|
options={MODEL_OPTIONS}
|
||||||
|
value={form.model}
|
||||||
|
onChange={(model) => setForm((previous) => ({ ...previous, model }))}
|
||||||
|
className="w-full justify-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="rounded-lg border border-[var(--color-line)] bg-black/[0.025] px-4 py-3 dark:bg-white/[0.06]">
|
||||||
|
<summary className="cursor-pointer text-sm font-semibold text-[var(--color-text-muted)]">
|
||||||
|
고급 설정
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="min-w-0 text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
Temperature
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.05}
|
||||||
|
value={form.temperature}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, temperature: Number(event.target.value) }))}
|
||||||
|
className="mt-2 h-10 w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-0 text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
Top P
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={form.topP}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, topP: Number(event.target.value) }))}
|
||||||
|
className="mt-2 h-10 w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-bold text-white transition hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? <Loader2 className="animate-spin" size={18} /> : <Bot size={18} />}
|
||||||
|
새 대국 시작
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</WindowSurface>
|
||||||
|
|
||||||
|
<WindowSurface title="Recent Games" showTrafficLights={false} as="aside" bodyClassName="p-3 md:p-4">
|
||||||
|
{gamesQuery.isLoading ? (
|
||||||
|
<div className="flex min-h-56 flex-col items-center justify-center text-center">
|
||||||
|
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={26} />
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-text)]">기록을 불러오는 중입니다.</p>
|
||||||
|
</div>
|
||||||
|
) : recentGames.length > 0 ? (
|
||||||
|
<div className="divide-y divide-[var(--color-line)]">
|
||||||
|
{recentGames.map((game) => (
|
||||||
|
<GameSummaryRow key={game.gameId} game={game} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-56 flex-col items-center justify-center text-center">
|
||||||
|
<Bot className="mb-3 text-[var(--color-accent)]" size={28} />
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-text)]">아직 대국 기록이 없습니다.</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">첫 Maia 대국을 시작해보세요.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WindowSurface>
|
||||||
|
</section>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/chess/chessUi.ts
Normal file
54
src/components/chess/chessUi.ts
Normal file
@@ -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<ChessOutcome, string> = {
|
||||||
|
IN_PROGRESS: '진행중',
|
||||||
|
WIN: '승',
|
||||||
|
LOSS: '패',
|
||||||
|
DRAW: '무',
|
||||||
|
UNKNOWN: '미정',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const outcomeBadgeTones: Record<ChessOutcome, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -326,10 +326,10 @@ export default function DesktopDock({ onOpenMobileMenu }: DesktopDockProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'chess',
|
key: 'chess',
|
||||||
href: '/play/chess',
|
href: '/chess',
|
||||||
label: '체스',
|
label: '체스',
|
||||||
icon: Crown,
|
icon: Crown,
|
||||||
isActive: isActivePath('/play/chess'),
|
isActive: (currentPath) => currentPath.startsWith('/chess') || currentPath.startsWith('/play/chess'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const getAppTitle = (pathname: string) => {
|
|||||||
if (pathname.startsWith('/archive')) return 'Archive';
|
if (pathname.startsWith('/archive')) return 'Archive';
|
||||||
if (pathname.startsWith('/category')) return getCategoryTitle(pathname);
|
if (pathname.startsWith('/category')) return getCategoryTitle(pathname);
|
||||||
if (pathname.startsWith('/posts')) return 'Reader';
|
if (pathname.startsWith('/posts')) return 'Reader';
|
||||||
|
if (pathname.startsWith('/chess')) return 'Maia Chess';
|
||||||
if (pathname.startsWith('/play/chess')) return 'Chess';
|
if (pathname.startsWith('/play/chess')) return 'Chess';
|
||||||
if (pathname.startsWith('/login')) return 'Login';
|
if (pathname.startsWith('/login')) return 'Login';
|
||||||
if (pathname.startsWith('/signup')) return 'Signup';
|
if (pathname.startsWith('/signup')) return 'Signup';
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function DesktopShell({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isReaderRoute = pathname.startsWith('/posts/');
|
const isReaderRoute = pathname.startsWith('/posts/');
|
||||||
const isChessRoute = pathname.startsWith('/play/chess');
|
const isChessRoute = pathname.startsWith('/chess') || pathname.startsWith('/play/chess');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="desktop-shell min-h-screen overflow-x-clip">
|
<div className="desktop-shell min-h-screen overflow-x-clip">
|
||||||
|
|||||||
@@ -60,6 +60,72 @@ export interface ChessPuzzle {
|
|||||||
sourceUrl: string;
|
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. 로그인 응답
|
// 4. 로그인 응답
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
grantType: string;
|
grantType: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user