feat: add daily chess puzzle page
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m29s

This commit is contained in:
wypark
2026-05-29 00:18:05 +09:00
parent 4bc0441490
commit ab585b5faa
8 changed files with 516 additions and 0 deletions

14
LOG.md
View File

@@ -1,5 +1,19 @@
# LOG.md # 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 ## 2026-05-28
- 상단 메뉴의 관리자 `새 글` 버튼이 라이트/다크 모드에서 흑백 반전처럼 보이지 않도록 accent 색상 기반 primary 버튼으로 고정했다. - 상단 메뉴의 관리자 `새 글` 버튼이 라이트/다크 모드에서 흑백 반전처럼 보이지 않도록 accent 색상 기반 primary 버튼으로 고정했다.

7
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"chess.js": "^1.4.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
@@ -3157,6 +3158,12 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",

View File

@@ -16,6 +16,7 @@
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"chess.js": "^1.4.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",

10
src/api/chess.ts Normal file
View File

@@ -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<ApiResponse<ChessPuzzle>>('/api/chess-puzzles/today', {
params: { timezone },
});
return response.data.data;
};

View File

@@ -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 <ChessPuzzleClient />;
}

View File

@@ -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<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 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 (
<section className="flex flex-col gap-2 border-b border-[var(--color-line)] pb-6">
<div className="flex items-center gap-2 text-[var(--color-accent)]">
<Target size={23} />
<h1 className="text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
</h1>
</div>
<p className="max-w-2xl text-sm leading-6 text-[var(--color-text-muted)]">
.
</p>
</section>
);
}
function LoadingState() {
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-3 py-6 md:px-6">
<PageHeader />
<section className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]">
<Surface strong className="p-3 md:p-5">
<div className="mx-auto grid aspect-square w-full max-w-[min(78vh,680px)] grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)]">
{BOARD_SQUARES.map((square, index) => (
<div
key={square}
className={clsx(
'aspect-square animate-pulse',
(index + Math.floor(index / 8)) % 2 === 0 ? 'bg-black/[0.06]' : 'bg-black/[0.12]',
'dark:bg-white/[0.08]',
)}
/>
))}
</div>
</Surface>
<Surface strong as="aside" className="flex min-h-72 flex-col items-center justify-center p-5 text-center">
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={28} />
<p className="text-sm font-semibold text-[var(--color-text)]"> .</p>
</Surface>
</section>
</main>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-3 py-6 md:px-6">
<PageHeader />
<Surface strong className="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<AlertCircle className="mb-3 text-red-500" size={30} />
<h2 className="text-lg font-bold text-[var(--color-text)]"> .</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--color-text-muted)]">
API가 .
</p>
<button
type="button"
onClick={onRetry}
className="mt-5 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)]"
>
<RotateCcw size={16} />
</button>
</Surface>
</main>
);
}
function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
const [currentFen, setCurrentFen] = useState(puzzle.fen);
const [selectedSquare, setSelectedSquare] = useState<Square | null>(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<Move[]>(() => {
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 (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-3 py-6 md:px-6">
<PageHeader />
<section className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]">
<Surface strong className="p-3 md:p-5">
<div className="mx-auto grid aspect-square w-full max-w-[min(78vh,680px)] grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
{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 (
<button
key={square}
type="button"
onClick={() => handleSquareClick(square)}
className={clsx(
'relative flex aspect-square items-center justify-center overflow-hidden text-[clamp(1.75rem,8vw,4.8rem)] leading-none transition',
isLight ? 'bg-[#eef0e6]' : 'bg-[#5f8d68]',
isSelected && 'z-10 ring-4 ring-[var(--color-accent)] ring-inset',
isLastMoveSquare && 'bg-[var(--color-accent-soft)]',
!solved && 'hover:brightness-105 focus-visible:z-20',
)}
aria-label={getSquareLabel(square, piece)}
aria-pressed={isSelected}
>
{file === 'a' && (
<span
className={clsx(
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
)}
>
{rank}
</span>
)}
{rank === '1' && (
<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',
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>
</Surface>
<Surface strong as="aside" className="p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase text-[var(--color-text-subtle)]">
{formatDate(puzzle.date)}
</p>
<h2 className="mt-1 text-xl font-bold tracking-normal text-[var(--color-text)]">
{puzzle.title}
</h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">{puzzle.theme}</p>
</div>
{solved && <CheckCircle2 size={24} className="shrink-0 text-emerald-500" />}
</div>
<div
className={clsx(
'mb-4 rounded-lg border px-3 py-3 text-sm font-semibold leading-6',
feedback.tone === 'success' && 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
feedback.tone === 'error' && 'border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300',
feedback.tone === 'neutral' && 'border-[var(--color-line)] bg-black/[0.025] text-[var(--color-text-muted)] dark:bg-white/[0.06]',
)}
>
{feedback.message}
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="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 font-semibold text-[var(--color-text)]">{puzzle.rating}</dd>
</div>
<div className="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 font-semibold text-[var(--color-text)]">
{solved ? puzzle.answer : '숨김'}
</dd>
</div>
</dl>
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={resetPuzzle}
className="inline-flex h-10 items-center justify-center rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
aria-label="다시 시작"
title="다시 시작"
>
<RotateCcw size={18} />
</button>
<button
type="button"
onClick={showHint}
className="inline-flex h-10 items-center justify-center rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
aria-label="힌트"
title="힌트"
>
<Lightbulb size={18} />
</button>
</div>
<a
href={puzzle.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-accent)]"
>
Lichess
<ExternalLink size={13} />
</a>
</Surface>
</section>
</main>
);
}
export default function ChessPuzzleClient() {
const {
data: puzzle,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ['chess-puzzle', 'today', TIMEZONE],
queryFn: () => getTodayChessPuzzle(TIMEZONE),
retry: 0,
});
if (isLoading) {
return <LoadingState />;
}
if (isError || !puzzle) {
return <ErrorState onRetry={() => void refetch()} />;
}
return <ChessPuzzleBoard key={`${puzzle.id}-${puzzle.date}`} puzzle={puzzle} />;
}

View File

@@ -9,6 +9,7 @@ import { clsx } from 'clsx';
import { import {
Archive, Archive,
ChevronRight, ChevronRight,
Crown,
FileQuestion, FileQuestion,
Folder, Folder,
FolderOpen, FolderOpen,
@@ -284,6 +285,26 @@ function SidebarContent() {
</div> </div>
</div> </div>
</div> </div>
<div className="-mx-2 mt-3 shrink-0 border-t border-[var(--color-line)] px-2 pt-3">
<div className="mb-2 flex h-8 items-center px-3">
<p className="text-xs font-semibold uppercase text-[var(--color-text-subtle)]"></p>
</div>
<Link
href="/play/chess"
onClick={closeSidebar}
className={clsx(
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all',
pathname === '/play/chess'
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<Crown size={16} />
<span></span>
</Link>
</div>
</div> </div>
</nav> </nav>

View File

@@ -47,6 +47,19 @@ export interface PostSaveRequest {
tags: string[]; 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. 로그인 응답 // 4. 로그인 응답
export interface AuthResponse { export interface AuthResponse {
grantType: string; grantType: string;