diff --git a/LOG.md b/LOG.md index 3388fee..18f5722 100644 --- a/LOG.md +++ b/LOG.md @@ -1,5 +1,19 @@ # LOG.md +## 2026-05-29 + +- 체스 퍼즐을 프론트 하드코딩 배열에서 백엔드 `GET /api/chess-puzzles/today?timezone=Asia/Seoul` 조회 방식으로 전환했다. +- `../blog-backend`에 `chess_puzzle` Flyway migration, Lichess CC0 mate-in-1 seed 30개, 공개 오늘의 퍼즐 API, 날짜 자동 순환 로직, 통합 테스트를 추가했다. +- 검증: 백엔드 `JAVA_HOME=/Users/wypark/Library/Java/JavaVirtualMachines/temurin-21.0.11/Contents/Home ./gradlew test` 통과, 프론트 `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. +- 다음 추천 작업: 배포 환경 DB에 Flyway migration이 정상 적용된 뒤 `https://blog.wypark.me/play/chess`에서 실제 API 응답과 CORS를 확인한다. + +## 2026-05-28 + +- `/play/chess` 체스 퍼즐 페이지를 추가하고 `chess.js`로 합법수/체크메이트 판정을 처리하는 한 수 메이트 퍼즐 3개를 구현했다. +- 왼쪽 사이드바 카테고리 영역 아래에 구분선과 `기타` 섹션을 추가하고, 그 안에 `체스` 메뉴를 연결했다. +- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser로 `/play/chess`에서 사이드바 `기타 > 체스` 노출, 보드 렌더링, 첫 퍼즐 `Qxf7#` 정답 판정을 확인했다. +- 다음 추천 작업: 체스 퍼즐 기록을 localStorage나 로그인 사용자 서버 저장으로 확장해 완료한 퍼즐/연속 정답 수를 유지한다. + ## 2026-05-28 - 상단 메뉴의 관리자 `새 글` 버튼이 라이트/다크 모드에서 흑백 반전처럼 보이지 않도록 accent 색상 기반 primary 버튼으로 고정했다. diff --git a/package-lock.json b/package-lock.json index 8e862a7..91c6c51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@uiw/react-md-editor": "^4.0.11", "axios": "^1.13.2", + "chess.js": "^1.4.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "github-slugger": "^2.0.0", @@ -3157,6 +3158,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chess.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz", + "integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==", + "license": "BSD-2-Clause" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", diff --git a/package.json b/package.json index ae131db..99b4302 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@uiw/react-md-editor": "^4.0.11", "axios": "^1.13.2", + "chess.js": "^1.4.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "github-slugger": "^2.0.0", diff --git a/src/api/chess.ts b/src/api/chess.ts new file mode 100644 index 0000000..245cccb --- /dev/null +++ b/src/api/chess.ts @@ -0,0 +1,10 @@ +import { http } from './http'; +import { ApiResponse, ChessPuzzle } from '@/types'; + +export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => { + const response = await http.get>('/api/chess-puzzles/today', { + params: { timezone }, + }); + + return response.data.data; +}; diff --git a/src/app/play/chess/page.tsx b/src/app/play/chess/page.tsx new file mode 100644 index 0000000..58fa27f --- /dev/null +++ b/src/app/play/chess/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import ChessPuzzleClient from '@/components/chess/ChessPuzzleClient'; + +export const metadata: Metadata = { + title: '오늘의 체스 퍼즐 | WYPark Blog', + description: '오늘의 한 수 메이트 체스 퍼즐', +}; + +export default function ChessPuzzlePage() { + return ; +} diff --git a/src/components/chess/ChessPuzzleClient.tsx b/src/components/chess/ChessPuzzleClient.tsx new file mode 100644 index 0000000..98e2c8e --- /dev/null +++ b/src/components/chess/ChessPuzzleClient.tsx @@ -0,0 +1,439 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Chess as ChessGame, type Color, type Move, type PieceSymbol, type Square } from 'chess.js'; +import { clsx } from 'clsx'; +import { + AlertCircle, + CheckCircle2, + ExternalLink, + Lightbulb, + Loader2, + RotateCcw, + Target, +} from 'lucide-react'; +import { getTodayChessPuzzle } from '@/api/chess'; +import Surface from '@/components/ui/Surface'; +import { type ChessPuzzle } from '@/types'; + +type FeedbackTone = 'neutral' | 'success' | 'error'; + +const TIMEZONE = 'Asia/Seoul'; +const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const; +const RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const; +const BOARD_SQUARES = RANKS.flatMap((rank) => FILES.map((file) => `${file}${rank}` as Square)); + +const PIECE_SYMBOLS: Record> = { + w: { + k: '♔', + q: '♕', + r: '♖', + b: '♗', + n: '♘', + p: '♙', + }, + b: { + k: '♚', + q: '♛', + r: '♜', + b: '♝', + n: '♞', + p: '♟', + }, +}; + +const PIECE_NAMES: Record = { + k: '킹', + q: '퀸', + r: '룩', + b: '비숍', + n: '나이트', + p: '폰', +}; + +const turnLabel = (color: Color) => (color === 'w' ? '백' : '흑'); + +const getReadyFeedback = (puzzle: ChessPuzzle): { tone: FeedbackTone; message: string } => { + const game = new ChessGame(puzzle.fen); + + return { + tone: 'neutral', + message: `${turnLabel(game.turn())} 차례 · 체크메이트 한 수`, + }; +}; + +const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => { + if (!piece) return `${square} 빈 칸`; + + return `${square} ${turnLabel(piece.color)} ${PIECE_NAMES[piece.type]}`; +}; + +const formatDate = (value: string) => { + const date = new Date(`${value}T00:00:00+09:00`); + + if (Number.isNaN(date.getTime())) return value; + + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'short', + timeZone: TIMEZONE, + }).format(date); +}; + +function PageHeader() { + return ( +
+
+ +

+ 오늘의 체스 퍼즐 +

+
+

+ 매일 하나씩 바뀌는 한 수 메이트 퍼즐입니다. +

+
+ ); +} + +function LoadingState() { + return ( +
+ +
+ +
+ {BOARD_SQUARES.map((square, index) => ( +
+ ))} +
+ + + +

오늘의 퍼즐을 불러오는 중입니다.

+
+
+
+ ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+ + + +

오늘의 퍼즐을 불러오지 못했습니다.

+

+ 백엔드 API가 아직 준비되지 않았거나 잠시 응답하지 않습니다. +

+ +
+
+ ); +} + +function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { + const [currentFen, setCurrentFen] = useState(puzzle.fen); + const [selectedSquare, setSelectedSquare] = useState(null); + const [solved, setSolved] = useState(false); + const [lastMoveSquares, setLastMoveSquares] = useState<{ from: Square; to: Square } | null>(null); + const [feedback, setFeedback] = useState(getReadyFeedback(puzzle)); + + const game = useMemo(() => new ChessGame(currentFen), [currentFen]); + const legalMoves = useMemo(() => { + if (!selectedSquare || solved) return []; + + return game.moves({ square: selectedSquare, verbose: true }); + }, [game, selectedSquare, solved]); + const legalTargets = useMemo(() => new Set(legalMoves.map((move) => move.to)), [legalMoves]); + + const resetPuzzle = () => { + setCurrentFen(puzzle.fen); + setSelectedSquare(null); + setSolved(false); + setLastMoveSquares(null); + setFeedback(getReadyFeedback(puzzle)); + }; + + const registerSolvedPuzzle = (move: Move, nextFen: string) => { + setCurrentFen(nextFen); + setSolved(true); + setSelectedSquare(null); + setLastMoveSquares({ from: move.from, to: move.to }); + setFeedback({ + tone: 'success', + message: `${move.san} 정답입니다.`, + }); + }; + + const tryMove = (from: Square, to: Square) => { + const candidate = legalMoves.find((move) => move.to === to); + + if (!candidate) { + setFeedback({ + tone: 'error', + message: '그 칸으로는 이동할 수 없습니다.', + }); + return; + } + + try { + const nextGame = new ChessGame(currentFen); + const move = nextGame.move({ + from, + to, + promotion: candidate.promotion, + }); + + if (nextGame.isCheckmate()) { + registerSolvedPuzzle(move, nextGame.fen()); + return; + } + + setSelectedSquare(null); + setLastMoveSquares(null); + setFeedback({ + tone: 'error', + message: `${move.san}는 아직 메이트가 아닙니다.`, + }); + } catch { + setFeedback({ + tone: 'error', + message: '합법적인 수가 아닙니다.', + }); + } + }; + + const handleSquareClick = (square: Square) => { + if (solved) return; + + const piece = game.get(square); + const isTurnPiece = piece?.color === game.turn(); + + if (!selectedSquare) { + if (isTurnPiece) { + setSelectedSquare(square); + setFeedback(getReadyFeedback(puzzle)); + } + return; + } + + if (selectedSquare === square) { + setSelectedSquare(null); + return; + } + + if (legalTargets.has(square)) { + tryMove(selectedSquare, square); + return; + } + + if (isTurnPiece) { + setSelectedSquare(square); + setFeedback(getReadyFeedback(puzzle)); + return; + } + + setSelectedSquare(null); + }; + + const showHint = () => { + setFeedback({ + tone: 'neutral', + message: puzzle.hint, + }); + }; + + return ( +
+ + +
+ +
+ {BOARD_SQUARES.map((square, index) => { + const piece = game.get(square); + const file = square[0]; + const rank = square[1]; + const fileIndex = index % 8; + const rankIndex = Math.floor(index / 8); + const isLight = (fileIndex + rankIndex) % 2 === 0; + const isSelected = selectedSquare === square; + const isTarget = legalTargets.has(square); + const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square; + + return ( + + ); + })} +
+
+ + +
+
+

+ {formatDate(puzzle.date)} +

+

+ {puzzle.title} +

+

{puzzle.theme}

+
+ {solved && } +
+ +
+ {feedback.message} +
+ +
+
+
난이도
+
{puzzle.rating}
+
+
+
정답
+
+ {solved ? puzzle.answer : '숨김'} +
+
+
+ +
+ + +
+ + + Lichess 원문 + + +
+
+
+ ); +} + +export default function ChessPuzzleClient() { + const { + data: puzzle, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: ['chess-puzzle', 'today', TIMEZONE], + queryFn: () => getTodayChessPuzzle(TIMEZONE), + retry: 0, + }); + + if (isLoading) { + return ; + } + + if (isError || !puzzle) { + return void refetch()} />; + } + + return ; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9526d74..c6420b6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -9,6 +9,7 @@ import { clsx } from 'clsx'; import { Archive, ChevronRight, + Crown, FileQuestion, Folder, FolderOpen, @@ -284,6 +285,26 @@ function SidebarContent() { + +
+
+

기타

+
+ + + + 체스 + +
diff --git a/src/types/index.ts b/src/types/index.ts index 19cc601..9ed549c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,6 +47,19 @@ export interface PostSaveRequest { tags: string[]; } +export interface ChessPuzzle { + id: number; + date: string; + title: string; + theme: string; + fen: string; + answer: string; + answerUci: string; + hint: string; + rating: number; + sourceUrl: string; +} + // 4. 로그인 응답 export interface AuthResponse { grantType: string;