feat: add Maia chess game UI
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m11s

This commit is contained in:
박원엽
2026-06-19 14:49:28 +09:00
parent a828be323a
commit 924dd8b372
14 changed files with 1441 additions and 4 deletions

8
LOG.md
View File

@@ -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.

View File

@@ -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, '착수를 처리하지 못했습니다.');
};

View 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
View 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 />;
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
};

View File

@@ -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'),
}, },
]; ];

View File

@@ -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';

View File

@@ -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">

View File

@@ -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;