feat: add daily chess puzzle page
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m29s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m29s
This commit is contained in:
14
LOG.md
14
LOG.md
@@ -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
7
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
10
src/api/chess.ts
Normal 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;
|
||||||
|
};
|
||||||
11
src/app/play/chess/page.tsx
Normal file
11
src/app/play/chess/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
439
src/components/chess/ChessPuzzleClient.tsx
Normal file
439
src/components/chess/ChessPuzzleClient.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user