Compare commits
10 Commits
49fc7b5331
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbfd84a8ab | ||
|
|
cc4fb0f947 | ||
|
|
924dd8b372 | ||
|
|
a828be323a | ||
|
|
8fedc8657e | ||
|
|
b466fa98c9 | ||
|
|
eb2e713b24 | ||
|
|
091f97968b | ||
|
|
f1668f08f2 | ||
|
|
3fe1b0e6d5 |
63
LOG.md
63
LOG.md
@@ -1,5 +1,64 @@
|
|||||||
# Work Log
|
# Work Log
|
||||||
|
|
||||||
|
## 2026-06-24
|
||||||
|
|
||||||
|
- Refined the chess board visual style to match the app's glass/control surfaces instead of the previous heavy wood-and-token treatment.
|
||||||
|
- Removed the circular chip backgrounds from pieces, kept high-contrast glyphs with subtle outlines, softened square/highlight colors, and tightened coordinate labels for a cleaner chess UI.
|
||||||
|
- Rebalanced chess board sizing and layout columns so the board window, actual board, side panel, and Dock spacing feel less awkward on a 1280x720 desktop viewport.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. Browser verification on `http://localhost:3124/play/chess` with a temporary local puzzle API confirmed 64 squares, 32 rendered pieces, a 350px visible board above the Dock, balanced 480px/320px board-panel windows, and no console errors. The temporary API was stopped after verification.
|
||||||
|
- Recommended next task: verify the real authenticated `/chess/play/{gameId}` board against the Maia backend at common laptop heights and tune only the board size formula if the live game info panel changes vertical rhythm.
|
||||||
|
|
||||||
|
## 2026-06-19
|
||||||
|
|
||||||
|
- Added Maia game resign and undo API wrappers, then wired the play screen with in-progress-only "무르기" and "기권" actions plus a persistent "새 게임" link back to the setup screen.
|
||||||
|
- Locked board/actions during move, undo, and resign requests, updated the game cache from backend responses as the single source of truth, and refreshed history/stat queries after successful state changes.
|
||||||
|
- Added resigned-loss labeling so play, recent games, and history can show `기권패` for `RESIGNED` losses while still grouping them under losses.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. Authenticated browser verification against the live Maia backend was not run in this session.
|
||||||
|
- Recommended next task: test resign and undo with an authenticated real game, including black-player games where Maia's opening white move must remain after undo.
|
||||||
|
|
||||||
|
- Reworked the shared chess board UI with a bordered board surface, high-contrast chip-style pieces, stronger selected/legal/last-move highlights, and transform-based piece movement transitions so player and Maia moves read as motion instead of instant jumps.
|
||||||
|
- Connected the daily puzzle board to the shared `ChessBoard` component, removing duplicated piece rendering and adding drag/drop support for puzzle moves.
|
||||||
|
- Updated Maia play state so optimistic player moves are highlighted locally while pending, then Maia's returned move can animate from the server response; changed the pending overlay into a small status pill so it no longer hides the board.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. A local dev server is running on `http://localhost:3124`; browser verification with a temporary local puzzle API confirmed 64 squares, 32 piece-layer nodes, 0.42s piece transition styling, square selection/legal target markers, and no console errors. The temporary API was stopped after verification.
|
||||||
|
- Recommended next task: verify `/chess/play/{gameId}` against the real Maia backend with an authenticated account to tune the exact Maia response timing and capture/promotion edge cases.
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- Changed admin containers to use the remaining shell content width (`w-full` with `max-w-[1400px]`) so the sidebar margin no longer combines with an extra viewport-width layout.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. Authenticated browser verification was not possible in the current session because `/admin` redirects to login without an admin token.
|
||||||
|
- Recommended next task: verify `/admin` and `/admin/posts/new` in an authenticated browser session at desktop widths around 1280px and 1440px.
|
||||||
|
|
||||||
|
- Fixed the admin dashboard traffic chart visibility by giving bar columns a real height context, so percentage-based bar heights render instead of collapsing.
|
||||||
|
- Added fallback traffic points from recent/popular post data when the dashboard API or `traffic` array is empty, keeping the graph visible while the backend traffic aggregation is unavailable.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. Browser navigation to `/admin` was attempted on `http://localhost:3112/`, but the current browser session was unauthenticated and rendered the login screen, so the admin chart itself was validated by code path and build checks rather than a live authenticated screenshot.
|
||||||
|
- Recommended next task: verify `/admin` with an authenticated admin session against the production API to confirm the real `traffic` payload replaces the fallback graph.
|
||||||
|
|
||||||
|
- Simplified the macOS-inspired layout by removing duplicated navigation surfaces: the desktop shortcut icon layer, the home page's inner Finder sidebar, and app navigation links from the system menu bar.
|
||||||
|
- Reworked the global sidebar into a cleaner library panel focused on profile, search, and categories, with a quieter compact profile row and no duplicated app shortcuts.
|
||||||
|
- Kept app-level movement in the Dock, category/search movement in the sidebar, and current-context/status information in the menu bar so each shell area has a clearer role.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3112/` confirmed no horizontal overflow, no desktop shortcut duplicates, the simplified home window, the Korean sidebar header, and no browser console warnings/errors. The local backend remained unavailable, so the home page showed empty post states.
|
||||||
|
- Recommended next task: test the simplified sidebar with real production categories and long category names, then tune category row spacing if the populated tree feels too dense.
|
||||||
|
|
||||||
|
## 2026-06-06
|
||||||
|
|
||||||
|
- Reworked the macOS-inspired shell into a fuller desktop illusion: full-width system menu bar, floating Finder-style sidebar window, viewport-centered Dock, desktop wallpaper layers, and desktop shortcut icons for major destinations.
|
||||||
|
- Rebuilt the public home screen as a Finder window with a locations sidebar, disk/status strip, file-like post rows, Spotlight-style search results, and stronger shared window glass treatment.
|
||||||
|
- Extended the OS treatment across archive, category, reader, login/signup, admin shell, post cards, and list rows, including file icons, Reader window framing, and cleanup of a stale mojibake notice-category check.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3112/`, `/archive`, and `/login` confirmed no horizontal overflow, desktop/mobile menu and Dock visibility rules, shared window surfaces, and no browser console warnings/errors. The default local API data rendered empty post states during the browser pass.
|
||||||
|
- Recommended next task: run a visual pass against `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me` so populated post lists and one real reader page can be tuned with long Korean titles and real content.
|
||||||
|
|
||||||
|
- Fixed the desktop Dock so route changes from Dock menu links no longer leave focus-held open state behind when the Dock is not pinned.
|
||||||
|
- Kept the saved `dock-pinned-v2` preference unchanged during navigation and only released transient Dock focus/hover state after menu clicks and pathname changes.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3111/` confirmed that after unpinning the Dock, clicking the Dock archive menu navigates to `/archive`, the pin button remains `aria-pressed=false`, and the Dock collapses after the pointer leaves.
|
||||||
|
- Recommended next task: do one visual pass on the Dock at narrow desktop widths to confirm the collapse animation still feels smooth around the sidebar offset.
|
||||||
|
|
||||||
## 2026-06-02
|
## 2026-06-02
|
||||||
|
|
||||||
- Refined the public layout after desktop/mobile review: the shell now guards horizontal overflow, uses route-aware bottom padding, hides the top menubar on mobile, and opens the mobile sidebar/search panel from the Dock.
|
- Refined the public layout after desktop/mobile review: the shell now guards horizontal overflow, uses route-aware bottom padding, hides the top menubar on mobile, and opens the mobile sidebar/search panel from the Dock.
|
||||||
@@ -9,6 +68,8 @@
|
|||||||
- Softened the Dock button palette to quieter pastel tones and made the light-mode Dock bar read as a cleaner white glass surface.
|
- Softened the Dock button palette to quieter pastel tones and made the light-mode Dock bar read as a cleaner white glass surface.
|
||||||
- Fixed the Dock pin toggle so mouse-based unpinning no longer leaves button focus holding the Dock open, and changed the Dock Home button to a white neutral tone.
|
- Fixed the Dock pin toggle so mouse-based unpinning no longer leaves button focus holding the Dock open, and changed the Dock Home button to a white neutral tone.
|
||||||
- Reworked the Dock material away from cute pastel tiles into a more macOS-like neutral frosted glass texture with translucent white glass buttons.
|
- Reworked the Dock material away from cute pastel tiles into a more macOS-like neutral frosted glass texture with translucent white glass buttons.
|
||||||
|
- Corrected the Dock button direction so only Home stays white, while the other actions keep subtle pastel tints with a translucent macOS-style glass material.
|
||||||
|
- Increased Dock button translucency contrast so the buttons remain glassy while sitting slightly more opaque than the Dock background.
|
||||||
- Rebalanced the home page around latest posts with popular posts as a supporting panel, converted the post detail page into a quieter reader surface, and hardened Markdown/code/table wrapping against mobile overflow.
|
- Rebalanced the home page around latest posts with popular posts as a supporting panel, converted the post detail page into a quieter reader surface, and hardened Markdown/code/table wrapping against mobile overflow.
|
||||||
- Added a client-side archive explorer with year, month, category, and compact/comfort density controls over the existing server-fetched post list.
|
- Added a client-side archive explorer with year, month, category, and compact/comfort density controls over the existing server-fetched post list.
|
||||||
- Rebuilt the chess page with safer viewport-aware board sizing, quieter puzzle panels, and enough layout clearance for the Dock.
|
- Rebuilt the chess page with safer viewport-aware board sizing, quieter puzzle panels, and enough layout clearance for the Dock.
|
||||||
@@ -18,6 +79,8 @@
|
|||||||
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3106/` confirmed the light-mode Dock bar renders as a cleaner white glass surface and the Dock button colors use quieter pastel tones.
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3106/` confirmed the light-mode Dock bar renders as a cleaner white glass surface and the Dock button colors use quieter pastel tones.
|
||||||
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3107/` confirmed the Dock Home button renders white and mouse-based unpinning collapses the Dock after the pointer leaves.
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3107/` confirmed the Dock Home button renders white and mouse-based unpinning collapses the Dock after the pointer leaves.
|
||||||
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3108/` confirmed the Dock uses neutral translucent glass material, with non-home buttons rendering as subtle white glass rather than pastel color tiles.
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3108/` confirmed the Dock uses neutral translucent glass material, with non-home buttons rendering as subtle white glass rather than pastel color tiles.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3109/` confirmed Home stays white while the other Dock buttons keep pastel-tinted translucent glass backgrounds with button-level blur.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3110/` confirmed Dock buttons remain translucent but render slightly more opaque than the Dock background for clearer contrast.
|
||||||
- Recommended next task: run a browser pass against a reachable backend API, especially one real post detail route and the populated chess puzzle board, then tune any remaining content-specific long-title or board-height edge cases.
|
- Recommended next task: run a browser pass against a reachable backend API, especially one real post detail route and the populated chess puzzle board, then tune any remaining content-specific long-title or board-height edge cases.
|
||||||
|
|
||||||
## 2026-05-29
|
## 2026-05-29
|
||||||
|
|||||||
@@ -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,61 @@ 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, '착수를 처리하지 못했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resignChessGame = async (gameId: string) => {
|
||||||
|
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/resign`);
|
||||||
|
|
||||||
|
return requireApiData(response.data, '기권을 처리하지 못했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const undoChessMove = async (gameId: string) => {
|
||||||
|
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/undo`);
|
||||||
|
|
||||||
|
return requireApiData(response.data, '무르기를 처리하지 못했습니다.');
|
||||||
|
};
|
||||||
|
|||||||
@@ -29,10 +29,17 @@ import {
|
|||||||
Category,
|
Category,
|
||||||
DashboardPostStat,
|
DashboardPostStat,
|
||||||
DashboardRange,
|
DashboardRange,
|
||||||
|
DashboardTrafficPoint,
|
||||||
PageMeta,
|
PageMeta,
|
||||||
Post,
|
Post,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
|
const dashboardRangeDays: Record<DashboardRange, number> = {
|
||||||
|
'7d': 7,
|
||||||
|
'30d': 30,
|
||||||
|
'90d': 90,
|
||||||
|
};
|
||||||
|
|
||||||
const countCategories = (categories: Category[] = []): number => {
|
const countCategories = (categories: Category[] = []): number => {
|
||||||
return categories.reduce((count, category) => {
|
return categories.reduce((count, category) => {
|
||||||
return count + 1 + countCategories(category.children || []);
|
return count + 1 + countCategories(category.children || []);
|
||||||
@@ -89,6 +96,46 @@ const toPostStat = (post: Post): DashboardPostStat => ({
|
|||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formatKstDateKey = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
timeZone: 'Asia/Seoul',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFallbackTraffic = (
|
||||||
|
posts: Post[],
|
||||||
|
range: DashboardRange,
|
||||||
|
): DashboardTrafficPoint[] => {
|
||||||
|
const days = dashboardRangeDays[range];
|
||||||
|
const today = new Date();
|
||||||
|
const points = Array.from({ length: days }, (_, index) => {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() - (days - 1 - index));
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: formatKstDateKey(date),
|
||||||
|
views: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const pointMap = new Map(points.map((point) => [point.date, point]));
|
||||||
|
|
||||||
|
posts.forEach((post) => {
|
||||||
|
const createdAt = new Date(post.createdAt);
|
||||||
|
if (Number.isNaN(createdAt.getTime())) return;
|
||||||
|
|
||||||
|
const dateKey = formatKstDateKey(createdAt);
|
||||||
|
const point = pointMap.get(dateKey);
|
||||||
|
if (!point) return;
|
||||||
|
|
||||||
|
point.views += post.viewCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
|
||||||
function RecentPostRow({ post }: { post: Post }) {
|
function RecentPostRow({ post }: { post: Post }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -249,6 +296,11 @@ export default function AdminPage() {
|
|||||||
() => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat),
|
() => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat),
|
||||||
[dashboard?.topPosts, popularData?.content],
|
[dashboard?.topPosts, popularData?.content],
|
||||||
);
|
);
|
||||||
|
const fallbackTraffic = useMemo(
|
||||||
|
() => getFallbackTraffic([...(latestData?.content ?? []), ...(popularData?.content ?? [])], range),
|
||||||
|
[latestData?.content, popularData?.content, range],
|
||||||
|
);
|
||||||
|
const trafficPoints = dashboard?.traffic?.length ? dashboard.traffic : fallbackTraffic;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -301,10 +353,10 @@ export default function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminDashboardTrafficChart
|
<AdminDashboardTrafficChart
|
||||||
points={dashboard?.traffic ?? []}
|
points={trafficPoints}
|
||||||
range={range}
|
range={range}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
isFallback={isFallback}
|
isFallback={isFallback || !dashboard?.traffic?.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]">
|
<div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]">
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export default async function ArchivePage() {
|
|||||||
return (
|
return (
|
||||||
<main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
|
<main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title="Archive"
|
title="Finder"
|
||||||
subtitle={`${totalPosts.toLocaleString()} posts`}
|
subtitle={`Archive · ${totalPosts.toLocaleString()} posts`}
|
||||||
bodyClassName="p-4 md:p-7"
|
bodyClassName="p-4 md:p-7"
|
||||||
>
|
>
|
||||||
<div className="mb-7 min-w-0 border-b border-[var(--color-line)] pb-6 text-center md:text-left">
|
<div className="mb-7 min-w-0 border-b border-[var(--color-line)] pb-6 text-center md:text-left">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { use, useState } from 'react';
|
import { use, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { ChevronLeft, ChevronRight, LayoutGrid, List, Loader2, Search as SearchIcon } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, FolderOpen, LayoutGrid, List, Loader2, Search as SearchIcon } from 'lucide-react';
|
||||||
import { getPostsByCategory } from '@/api/posts';
|
import { getPostsByCategory } from '@/api/posts';
|
||||||
import PostCard from '@/components/post/PostCard';
|
import PostCard from '@/components/post/PostCard';
|
||||||
import PostListItem from '@/components/post/PostListItem';
|
import PostListItem from '@/components/post/PostListItem';
|
||||||
@@ -112,12 +112,16 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
|||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
|
<main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title={apiCategoryName}
|
title="Finder"
|
||||||
|
subtitle={apiCategoryName}
|
||||||
bodyClassName="p-5 md:p-6"
|
bodyClassName="p-5 md:p-6"
|
||||||
>
|
>
|
||||||
<div className="mb-8 flex flex-col justify-between gap-4 border-b border-[var(--color-line)] pb-5 md:flex-row md:items-center">
|
<div className="mb-8 flex flex-col justify-between gap-4 border-b border-[var(--color-line)] pb-5 md:flex-row md:items-center">
|
||||||
<h1 className="flex shrink-0 items-baseline gap-2 text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
<h1 className="flex min-w-0 items-center gap-3 text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||||
{apiCategoryName}
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
|
||||||
|
<FolderOpen size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">{apiCategoryName}</span>
|
||||||
<span className="text-lg font-normal text-[var(--color-text-subtle)]">글 목록</span>
|
<span className="text-lg font-normal text-[var(--color-text-subtle)]">글 목록</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
11
src/app/chess/history/page.tsx
Normal file
11
src/app/chess/history/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import ChessHistoryClient from '@/components/chess/ChessHistoryClient';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '대국 기록 | WYPark Blog',
|
||||||
|
description: 'Maia 체스 대국 기록과 결과를 확인합니다.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChessHistoryPage() {
|
||||||
|
return <ChessHistoryClient />;
|
||||||
|
}
|
||||||
11
src/app/chess/page.tsx
Normal file
11
src/app/chess/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import ChessHomeClient from '@/components/chess/ChessHomeClient';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Maia 체스 | WYPark Blog',
|
||||||
|
description: 'Maia3 봇과 체스를 두고 대국 기록과 PGN을 확인합니다.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChessPage() {
|
||||||
|
return <ChessHomeClient />;
|
||||||
|
}
|
||||||
19
src/app/chess/play/[gameId]/page.tsx
Normal file
19
src/app/chess/play/[gameId]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import ChessGamePlayClient from '@/components/chess/ChessGamePlayClient';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Maia 대국 | WYPark Blog',
|
||||||
|
description: 'Maia 체스 대국을 이어서 진행하고 PGN을 확인합니다.',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChessGamePageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
gameId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ChessGamePage({ params }: ChessGamePageProps) {
|
||||||
|
const { gameId } = await params;
|
||||||
|
|
||||||
|
return <ChessGamePlayClient gameId={gameId} />;
|
||||||
|
}
|
||||||
@@ -24,28 +24,34 @@
|
|||||||
Consolas,
|
Consolas,
|
||||||
"Liberation Mono",
|
"Liberation Mono",
|
||||||
monospace;
|
monospace;
|
||||||
--color-page: #f7f4fb;
|
--color-page: #edf4ff;
|
||||||
--desktop-bg:
|
--desktop-bg:
|
||||||
linear-gradient(135deg, rgba(255, 232, 238, 0.78) 0%, rgba(255, 232, 238, 0) 36%),
|
linear-gradient(118deg, rgba(95, 174, 255, 0.22) 0%, rgba(95, 174, 255, 0) 35%),
|
||||||
linear-gradient(155deg, rgba(218, 235, 255, 0.86) 6%, rgba(218, 235, 255, 0) 50%),
|
linear-gradient(152deg, rgba(255, 158, 194, 0.2) 10%, rgba(255, 158, 194, 0) 48%),
|
||||||
linear-gradient(42deg, rgba(222, 251, 236, 0.74) 48%, rgba(222, 251, 236, 0) 88%),
|
linear-gradient(46deg, rgba(124, 220, 185, 0.18) 46%, rgba(124, 220, 185, 0) 82%),
|
||||||
linear-gradient(180deg, #fbf8ff 0%, #eef5ff 100%);
|
linear-gradient(180deg, #f9fcff 0%, #eaf3ff 48%, #f8f2ff 100%);
|
||||||
--window-bg: rgba(255, 255, 255, 0.6);
|
--desktop-sheen:
|
||||||
--window-bg-strong: rgba(255, 255, 255, 0.8);
|
linear-gradient(120deg, rgba(255, 255, 255, 0.48) 0 1px, transparent 1px 100%),
|
||||||
--window-titlebar: rgba(255, 255, 255, 0.5);
|
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.12) 0 1px, transparent 1px 18px);
|
||||||
--window-border: rgba(255, 255, 255, 0.68);
|
--desktop-icon-bg: rgba(255, 255, 255, 0.44);
|
||||||
|
--desktop-icon-bg-hover: rgba(255, 255, 255, 0.64);
|
||||||
|
--desktop-icon-border: rgba(255, 255, 255, 0.62);
|
||||||
|
--window-bg: rgba(255, 255, 255, 0.66);
|
||||||
|
--window-bg-strong: rgba(255, 255, 255, 0.86);
|
||||||
|
--window-titlebar: rgba(255, 255, 255, 0.58);
|
||||||
|
--window-border: rgba(255, 255, 255, 0.74);
|
||||||
--window-titlebar-border: rgba(39, 54, 82, 0.1);
|
--window-titlebar-border: rgba(39, 54, 82, 0.1);
|
||||||
--card-bg: rgba(255, 255, 255, 0.46);
|
--card-bg: rgba(255, 255, 255, 0.46);
|
||||||
--card-bg-strong: rgba(255, 255, 255, 0.68);
|
--card-bg-strong: rgba(255, 255, 255, 0.68);
|
||||||
--card-border: rgba(39, 54, 82, 0.11);
|
--card-border: rgba(39, 54, 82, 0.11);
|
||||||
--card-border-hover: rgba(39, 54, 82, 0.18);
|
--card-border-hover: rgba(39, 54, 82, 0.18);
|
||||||
--control-border: rgba(39, 54, 82, 0.12);
|
--control-border: rgba(39, 54, 82, 0.12);
|
||||||
--menubar-bg: rgba(255, 255, 255, 0.5);
|
--menubar-bg: rgba(255, 255, 255, 0.58);
|
||||||
--menubar-border: rgba(255, 255, 255, 0.7);
|
--menubar-border: rgba(255, 255, 255, 0.72);
|
||||||
--dock-bg: rgba(248, 250, 252, 0.46);
|
--dock-bg: rgba(248, 250, 252, 0.54);
|
||||||
--dock-border: rgba(255, 255, 255, 0.62);
|
--dock-border: rgba(255, 255, 255, 0.68);
|
||||||
--sidebar-bg: rgba(255, 255, 255, 0.48);
|
--sidebar-bg: rgba(255, 255, 255, 0.58);
|
||||||
--sidebar-border: rgba(255, 255, 255, 0.6);
|
--sidebar-border: rgba(255, 255, 255, 0.68);
|
||||||
--color-surface: var(--card-bg);
|
--color-surface: var(--card-bg);
|
||||||
--color-surface-strong: var(--card-bg-strong);
|
--color-surface-strong: var(--card-bg-strong);
|
||||||
--color-control: rgba(255, 255, 255, 0.56);
|
--color-control: rgba(255, 255, 255, 0.56);
|
||||||
@@ -57,9 +63,9 @@
|
|||||||
--color-accent-hover: #0071e3;
|
--color-accent-hover: #0071e3;
|
||||||
--color-accent-soft: rgba(0, 102, 204, 0.1);
|
--color-accent-soft: rgba(0, 102, 204, 0.1);
|
||||||
--color-danger-soft: rgba(255, 59, 48, 0.1);
|
--color-danger-soft: rgba(255, 59, 48, 0.1);
|
||||||
--shadow-window: 0 28px 80px rgba(70, 92, 130, 0.18), 0 10px 28px rgba(70, 92, 130, 0.08), 0 1px 0 rgba(255, 255, 255, 0.72) inset;
|
--shadow-window: 0 34px 96px rgba(47, 74, 112, 0.22), 0 16px 34px rgba(47, 74, 112, 0.1), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
|
||||||
--shadow-sidebar: 16px 0 42px rgba(70, 92, 130, 0.12), -1px 0 0 rgba(255, 255, 255, 0.46) inset;
|
--shadow-sidebar: 18px 20px 64px rgba(47, 74, 112, 0.16), 0 1px 0 rgba(255, 255, 255, 0.56) inset;
|
||||||
--shadow-menubar: 0 16px 40px rgba(70, 92, 130, 0.12), 0 1px 0 rgba(255, 255, 255, 0.58) inset;
|
--shadow-menubar: 0 12px 32px rgba(47, 74, 112, 0.13), 0 1px 0 rgba(255, 255, 255, 0.62) inset;
|
||||||
--shadow-panel: var(--shadow-sidebar);
|
--shadow-panel: var(--shadow-sidebar);
|
||||||
--shadow-card: 0 14px 34px rgba(70, 92, 130, 0.09), 0 1px 0 rgba(255, 255, 255, 0.5) inset;
|
--shadow-card: 0 14px 34px rgba(70, 92, 130, 0.09), 0 1px 0 rgba(255, 255, 255, 0.5) inset;
|
||||||
--shadow-dock: 0 28px 78px rgba(15, 23, 42, 0.18), 0 12px 32px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.68) inset, 0 -1px 0 rgba(15, 23, 42, 0.06) inset;
|
--shadow-dock: 0 28px 78px rgba(15, 23, 42, 0.18), 0 12px 32px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.68) inset, 0 -1px 0 rgba(15, 23, 42, 0.06) inset;
|
||||||
@@ -78,15 +84,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
--color-page: #11131d;
|
--color-page: #0c101a;
|
||||||
--desktop-bg:
|
--desktop-bg:
|
||||||
linear-gradient(135deg, rgba(88, 56, 104, 0.42) 0%, rgba(88, 56, 104, 0) 38%),
|
linear-gradient(118deg, rgba(44, 128, 204, 0.34) 0%, rgba(44, 128, 204, 0) 38%),
|
||||||
linear-gradient(150deg, rgba(43, 73, 110, 0.5) 10%, rgba(43, 73, 110, 0) 54%),
|
linear-gradient(150deg, rgba(125, 66, 139, 0.3) 12%, rgba(125, 66, 139, 0) 55%),
|
||||||
linear-gradient(42deg, rgba(38, 91, 72, 0.38) 48%, rgba(38, 91, 72, 0) 88%),
|
linear-gradient(46deg, rgba(30, 118, 89, 0.26) 46%, rgba(30, 118, 89, 0) 84%),
|
||||||
linear-gradient(180deg, #131620 0%, #07090f 100%);
|
linear-gradient(180deg, #111827 0%, #080b12 100%);
|
||||||
--window-bg: rgba(28, 31, 42, 0.68);
|
--desktop-sheen:
|
||||||
--window-bg-strong: rgba(39, 42, 56, 0.84);
|
linear-gradient(120deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 100%),
|
||||||
--window-titlebar: rgba(255, 255, 255, 0.075);
|
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 18px);
|
||||||
|
--desktop-icon-bg: rgba(22, 25, 34, 0.5);
|
||||||
|
--desktop-icon-bg-hover: rgba(40, 44, 58, 0.72);
|
||||||
|
--desktop-icon-border: rgba(255, 255, 255, 0.16);
|
||||||
|
--window-bg: rgba(25, 29, 40, 0.72);
|
||||||
|
--window-bg-strong: rgba(39, 43, 58, 0.88);
|
||||||
|
--window-titlebar: rgba(255, 255, 255, 0.09);
|
||||||
--window-border: rgba(255, 255, 255, 0.18);
|
--window-border: rgba(255, 255, 255, 0.18);
|
||||||
--window-titlebar-border: rgba(255, 255, 255, 0.13);
|
--window-titlebar-border: rgba(255, 255, 255, 0.13);
|
||||||
--card-bg: rgba(43, 46, 60, 0.5);
|
--card-bg: rgba(43, 46, 60, 0.5);
|
||||||
@@ -94,9 +106,9 @@ html[data-theme="dark"] {
|
|||||||
--card-border: rgba(255, 255, 255, 0.14);
|
--card-border: rgba(255, 255, 255, 0.14);
|
||||||
--card-border-hover: rgba(255, 255, 255, 0.22);
|
--card-border-hover: rgba(255, 255, 255, 0.22);
|
||||||
--control-border: rgba(255, 255, 255, 0.13);
|
--control-border: rgba(255, 255, 255, 0.13);
|
||||||
--menubar-bg: rgba(24, 27, 38, 0.62);
|
--menubar-bg: rgba(14, 17, 26, 0.68);
|
||||||
--menubar-border: rgba(255, 255, 255, 0.16);
|
--menubar-border: rgba(255, 255, 255, 0.16);
|
||||||
--dock-bg: rgba(18, 20, 28, 0.46);
|
--dock-bg: rgba(18, 20, 28, 0.56);
|
||||||
--dock-border: rgba(255, 255, 255, 0.16);
|
--dock-border: rgba(255, 255, 255, 0.16);
|
||||||
--sidebar-bg: rgba(21, 24, 34, 0.64);
|
--sidebar-bg: rgba(21, 24, 34, 0.64);
|
||||||
--sidebar-border: rgba(255, 255, 255, 0.14);
|
--sidebar-border: rgba(255, 255, 255, 0.14);
|
||||||
@@ -145,6 +157,68 @@ body {
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-shell {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--desktop-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-shell::before,
|
||||||
|
.desktop-shell::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-shell::before {
|
||||||
|
z-index: 0;
|
||||||
|
background:
|
||||||
|
var(--desktop-sheen),
|
||||||
|
radial-gradient(ellipse at 20% 15%, rgba(255, 255, 255, 0.42), transparent 42%),
|
||||||
|
var(--desktop-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-shell::after {
|
||||||
|
z-index: 0;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.06) 0 1px, transparent 1px 3px),
|
||||||
|
linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.18));
|
||||||
|
opacity: 0.32;
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .desktop-shell::after {
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-surface {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-surface::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent 22rem),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.18), transparent 28%);
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .window-surface::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 22rem),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.05), transparent 28%);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-surface > * {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
pre,
|
pre,
|
||||||
code,
|
code,
|
||||||
table {
|
table {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense, useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, LockKeyhole, UserRound } from 'lucide-react';
|
||||||
import { login } from '@/api/auth';
|
import { login } from '@/api/auth';
|
||||||
import WindowSurface from '@/components/ui/WindowSurface';
|
import WindowSurface from '@/components/ui/WindowSurface';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
@@ -61,12 +61,15 @@ function LoginForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title="Login"
|
title="Login Window"
|
||||||
subtitle="WYPark OS"
|
subtitle="WYPark OS"
|
||||||
className="w-full max-w-md"
|
className="w-full max-w-md"
|
||||||
bodyClassName="p-8"
|
bodyClassName="p-8"
|
||||||
>
|
>
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-2xl border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-card)]">
|
||||||
|
<UserRound size={34} strokeWidth={1.9} />
|
||||||
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-[var(--color-text)]">로그인</h1>
|
<h1 className="text-3xl font-bold text-[var(--color-text)]">로그인</h1>
|
||||||
<p className="mt-2 text-[var(--color-text-muted)]">블로그 데스크톱으로 다시 들어갑니다.</p>
|
<p className="mt-2 text-[var(--color-text-muted)]">블로그 데스크톱으로 다시 들어갑니다.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +106,7 @@ function LoginForm() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{loading && <Loader2 className="animate-spin" size={20} />}
|
{loading ? <Loader2 className="animate-spin" size={20} /> : <LockKeyhole size={18} />}
|
||||||
로그인
|
로그인
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
|
FileText,
|
||||||
|
HardDrive,
|
||||||
Search,
|
Search,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -96,25 +98,30 @@ function SearchResults({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title="Search"
|
title="Spotlight"
|
||||||
subtitle={`"${keyword}"`}
|
subtitle={`"${keyword}"`}
|
||||||
bodyClassName="p-4 md:p-6"
|
bodyClassName="p-0"
|
||||||
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
|
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||||
>
|
>
|
||||||
<div className="mb-6 flex min-w-0 flex-col gap-2 border-b border-[var(--color-line)] pb-5">
|
<div className="border-b border-[var(--color-line)] bg-[var(--window-titlebar)] px-4 py-4 md:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Search size={22} className="shrink-0" />
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
|
||||||
|
<Search size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||||
검색 결과
|
검색 결과
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
|
||||||
<p className="break-words text-sm text-[var(--color-text-muted)]">
|
<p className="break-words text-sm text-[var(--color-text-muted)]">
|
||||||
검색어 <span className="font-semibold text-[var(--color-text)]">{keyword}</span>에 대한 글 {searchTotalElements.toLocaleString()}건
|
검색어 <span className="font-semibold text-[var(--color-text)]">{keyword}</span>에 대한 글 {searchTotalElements.toLocaleString()}건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 md:p-6">
|
||||||
{searchResults.length > 0 ? (
|
{searchResults.length > 0 ? (
|
||||||
<div className="divide-y divide-[var(--color-line)]">
|
<div className="divide-y divide-[var(--color-line)] rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] px-2">
|
||||||
{searchResults.map((post) => (
|
{searchResults.map((post) => (
|
||||||
<CompactPostRow key={post.id} post={post} />
|
<CompactPostRow key={post.id} post={post} />
|
||||||
))}
|
))}
|
||||||
@@ -122,6 +129,7 @@ function SearchResults({
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState title="검색 결과가 없습니다." description="다른 키워드로 다시 찾아보세요." />
|
<EmptyState title="검색 결과가 없습니다." description="다른 키워드로 다시 찾아보세요." />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</WindowSurface>
|
</WindowSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,6 +188,11 @@ function CompactPostRow({
|
|||||||
{rank}
|
{rank}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{rank === undefined && (
|
||||||
|
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
|
||||||
|
<FileText size={17} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={isNoticePost(post) ? 'danger' : 'neutral'} className="shrink-0">
|
<StatusBadge tone={isNoticePost(post) ? 'danger' : 'neutral'} className="shrink-0">
|
||||||
@@ -226,7 +239,7 @@ function PostListPanel({
|
|||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Surface as="section" strong className="min-w-0 overflow-hidden">
|
<Surface as="section" strong className="min-w-0 overflow-hidden shadow-none">
|
||||||
<div className="flex min-h-12 min-w-0 items-center justify-between gap-3 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4 py-3 md:px-5">
|
<div className="flex min-h-12 min-w-0 items-center justify-between gap-3 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4 py-3 md:px-5">
|
||||||
<h2 className="flex min-w-0 items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
<h2 className="flex min-w-0 items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
||||||
{icon}
|
{icon}
|
||||||
@@ -287,6 +300,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|||||||
<h1 className="sr-only">{SITE_NAME}</h1>
|
<h1 className="sr-only">{SITE_NAME}</h1>
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title="WYPark"
|
title="WYPark"
|
||||||
|
subtitle="블로그 라이브러리"
|
||||||
controls={(
|
controls={(
|
||||||
<Link
|
<Link
|
||||||
href="/archive"
|
href="/archive"
|
||||||
@@ -298,6 +312,23 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|||||||
)}
|
)}
|
||||||
bodyClassName="space-y-6 p-4 md:p-6"
|
bodyClassName="space-y-6 p-4 md:p-6"
|
||||||
>
|
>
|
||||||
|
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] px-4 py-3 md:flex-row md:items-center">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
|
||||||
|
<HardDrive size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-bold text-[var(--color-text)]">최근 기록</p>
|
||||||
|
<p className="truncate text-xs text-[var(--color-text-subtle)]">
|
||||||
|
최신 글 {latestList.length.toLocaleString()}개 · 인기 글 {popularList.length.toLocaleString()}개
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold tabular-nums text-[var(--color-text-subtle)]">
|
||||||
|
{new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium' }).format(new Date())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<NoticeStrip notices={notices} />
|
<NoticeStrip notices={notices} />
|
||||||
|
|
||||||
<section className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)]">
|
<section className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)]">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { MailCheck, UserPlus } from 'lucide-react';
|
||||||
import { signup, verifyEmail } from '@/api/auth';
|
import { signup, verifyEmail } from '@/api/auth';
|
||||||
import WindowSurface from '@/components/ui/WindowSurface';
|
import WindowSurface from '@/components/ui/WindowSurface';
|
||||||
import { SignupRequest } from '@/types';
|
import { SignupRequest } from '@/types';
|
||||||
@@ -74,12 +75,15 @@ export default function SignupPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title="Signup"
|
title="Account Setup"
|
||||||
subtitle={step === 'FORM' ? 'Create account' : registeredEmail}
|
subtitle={step === 'FORM' ? 'Create account' : registeredEmail}
|
||||||
className="w-full max-w-md"
|
className="w-full max-w-md"
|
||||||
bodyClassName="p-8"
|
bodyClassName="p-8"
|
||||||
>
|
>
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-2xl border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-card)]">
|
||||||
|
{step === 'FORM' ? <UserPlus size={34} strokeWidth={1.9} /> : <MailCheck size={34} strokeWidth={1.9} />}
|
||||||
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text)]">회원가입</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text)]">회원가입</h1>
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
{step === 'FORM' ? '블로그 OS 계정을 만듭니다.' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
{step === 'FORM' ? '블로그 OS 계정을 만듭니다.' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6" onPaste={handlePaste}>
|
<main className="w-full min-w-0" onPaste={handlePaste}>
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title={isEditMode ? '글 수정' : '새 글 작성'}
|
title={isEditMode ? '글 수정' : '새 글 작성'}
|
||||||
subtitle="Markdown Studio"
|
subtitle="Markdown Studio"
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full space-y-6 px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6">
|
<div className="mx-auto w-full max-w-[1400px] space-y-6 px-0 py-4 md:py-6">
|
||||||
<WindowSurface title="Admin Console" subtitle="Operations toolbar" bodyClassName="p-2">
|
<WindowSurface title="System Settings" subtitle="Admin Console" bodyClassName="p-2">
|
||||||
<nav
|
<nav
|
||||||
className="flex gap-2 overflow-x-auto rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-1 shadow-[var(--shadow-card)] backdrop-blur-[18px]"
|
className="flex gap-2 overflow-x-auto rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-1 shadow-[var(--shadow-card)] backdrop-blur-[18px]"
|
||||||
aria-label="관리자 메뉴"
|
aria-label="관리자 메뉴"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function AdminDashboardTrafficChart({
|
|||||||
isFallback,
|
isFallback,
|
||||||
}: AdminDashboardTrafficChartProps) {
|
}: AdminDashboardTrafficChartProps) {
|
||||||
const maxViews = Math.max(...points.map((point) => point.views), 1);
|
const maxViews = Math.max(...points.map((point) => point.views), 1);
|
||||||
|
const hasTraffic = points.some((point) => point.views > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface as="section" className="p-5">
|
<Surface as="section" className="p-5">
|
||||||
@@ -44,7 +45,9 @@ export default function AdminDashboardTrafficChart({
|
|||||||
<h2 className="text-lg font-bold text-[var(--color-text)]">트래픽 흐름</h2>
|
<h2 className="text-lg font-bold text-[var(--color-text)]">트래픽 흐름</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
최근 기간별 조회수 흐름을 확인합니다.
|
{isFallback
|
||||||
|
? '대시보드 집계 전에는 게시글 누적 조회수로 임시 흐름을 표시합니다.'
|
||||||
|
: '최근 기간별 조회수 흐름을 확인합니다.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
@@ -56,24 +59,37 @@ export default function AdminDashboardTrafficChart({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{points.length > 0 ? (
|
{points.length > 0 ? (
|
||||||
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
|
<>
|
||||||
|
<div className="h-64 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
|
||||||
|
<div className="flex h-full min-w-full items-stretch gap-1.5">
|
||||||
{points.map((point) => {
|
{points.map((point) => {
|
||||||
const height = Math.max((point.views / maxViews) * 100, 4);
|
const height = Math.max((point.views / maxViews) * 100, 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
|
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
|
||||||
|
<div className="flex min-h-0 w-full flex-1 items-end">
|
||||||
<div
|
<div
|
||||||
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
|
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
|
||||||
style={{ height: `${height}%` }}
|
style={{ height: `${height}%` }}
|
||||||
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
|
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
|
||||||
/>
|
/>
|
||||||
<span className="hidden text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
|
</div>
|
||||||
|
<span className="hidden h-4 text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
|
||||||
{formatDay(point.date)}
|
{formatDay(point.date)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{isFallback && (
|
||||||
|
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||||
|
{hasTraffic
|
||||||
|
? '임시 그래프는 글 발행일과 누적 조회수를 기준으로 계산됩니다.'
|
||||||
|
: '집계 API가 연결되면 실제 일별 조회수로 대체됩니다.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="통계 API 연결 전"
|
title="통계 API 연결 전"
|
||||||
|
|||||||
347
src/components/chess/ChessBoard.tsx
Normal file
347
src/components/chess/ChessBoard.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
import { Chess as ChessGame, type Color, type PieceSymbol, type Square } from 'chess.js';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { ChessColor } from '@/types';
|
||||||
|
|
||||||
|
export type MoveSquares = {
|
||||||
|
from: Square;
|
||||||
|
to: Square;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoardPiece = {
|
||||||
|
square: Square;
|
||||||
|
color: Color;
|
||||||
|
type: PieceSymbol;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VisualPiece = BoardPiece & {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
export const BOARD_SQUARES = WHITE_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 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 getPieces = (game: ChessGame): BoardPiece[] =>
|
||||||
|
BOARD_SQUARES.flatMap((square) => {
|
||||||
|
const piece = game.get(square);
|
||||||
|
|
||||||
|
if (!piece) return [];
|
||||||
|
|
||||||
|
return [{ square, color: piece.color, type: piece.type }];
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSquarePosition = (square: Square, orientation: ChessColor): CSSProperties => {
|
||||||
|
const fileIndex = FILES.indexOf(square[0] as (typeof FILES)[number]);
|
||||||
|
const rank = Number(square[1]);
|
||||||
|
const x = orientation === 'black' ? 7 - fileIndex : fileIndex;
|
||||||
|
const y = orientation === 'black' ? rank - 1 : 8 - rank;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `translate(${x * 100}%, ${y * 100}%)`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const samePiece = (a: BoardPiece, b: BoardPiece) => a.color === b.color && a.type === b.type;
|
||||||
|
|
||||||
|
const canRepresentMove = (fromPiece: VisualPiece, toPiece: BoardPiece) =>
|
||||||
|
fromPiece.color === toPiece.color && (fromPiece.type === toPiece.type || fromPiece.type === 'p');
|
||||||
|
|
||||||
|
const createPieceId = (piece: BoardPiece, index: number) =>
|
||||||
|
`${piece.color}-${piece.type}-${piece.square}-${index}`;
|
||||||
|
|
||||||
|
const createVisualPieces = (pieces: BoardPiece[]) => pieces.map((piece, index) => ({ ...piece, id: createPieceId(piece, index) }));
|
||||||
|
|
||||||
|
const reconcileVisualPieces = (
|
||||||
|
previousPieces: VisualPiece[],
|
||||||
|
nextPieces: BoardPiece[],
|
||||||
|
lastMoveSquares?: MoveSquares | null,
|
||||||
|
) => {
|
||||||
|
if (!lastMoveSquares) return createVisualPieces(nextPieces);
|
||||||
|
|
||||||
|
const nextBySquare = new Map(nextPieces.map((piece) => [piece.square, piece]));
|
||||||
|
const movedPieceTarget = nextBySquare.get(lastMoveSquares.to);
|
||||||
|
|
||||||
|
if (!movedPieceTarget) return createVisualPieces(nextPieces);
|
||||||
|
|
||||||
|
const usedVisualIds = new Set<string>();
|
||||||
|
const usedSquares = new Set<Square>();
|
||||||
|
const reconciled: VisualPiece[] = [];
|
||||||
|
const movingPiece =
|
||||||
|
previousPieces.find(
|
||||||
|
(piece) => piece.square === lastMoveSquares.from && canRepresentMove(piece, movedPieceTarget),
|
||||||
|
) ?? previousPieces.find((piece) => piece.square === lastMoveSquares.from);
|
||||||
|
|
||||||
|
if (movingPiece) {
|
||||||
|
reconciled.push({ ...movingPiece, ...movedPieceTarget });
|
||||||
|
usedVisualIds.add(movingPiece.id);
|
||||||
|
usedSquares.add(movedPieceTarget.square);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPieces.forEach((visualPiece) => {
|
||||||
|
if (usedVisualIds.has(visualPiece.id)) return;
|
||||||
|
|
||||||
|
const nextPiece = nextBySquare.get(visualPiece.square);
|
||||||
|
if (!nextPiece || usedSquares.has(nextPiece.square) || !samePiece(visualPiece, nextPiece)) return;
|
||||||
|
|
||||||
|
reconciled.push({ ...visualPiece, ...nextPiece });
|
||||||
|
usedVisualIds.add(visualPiece.id);
|
||||||
|
usedSquares.add(nextPiece.square);
|
||||||
|
});
|
||||||
|
|
||||||
|
nextPieces.forEach((nextPiece, index) => {
|
||||||
|
if (usedSquares.has(nextPiece.square)) return;
|
||||||
|
|
||||||
|
reconciled.push({ ...nextPiece, id: createPieceId(nextPiece, previousPieces.length + index) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return reconciled;
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
const [visualPieces, setVisualPieces] = useState(() => createVisualPieces(getPieces(game)));
|
||||||
|
const previousFenRef = useRef(fen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextPieces = getPieces(game);
|
||||||
|
|
||||||
|
setVisualPieces((previousPieces) => {
|
||||||
|
if (previousFenRef.current === fen) return previousPieces;
|
||||||
|
|
||||||
|
previousFenRef.current = fen;
|
||||||
|
return reconcileVisualPieces(previousPieces, nextPieces, lastMoveSquares);
|
||||||
|
});
|
||||||
|
}, [fen, game, lastMoveSquares]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-square w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-[18px]">
|
||||||
|
<div className="relative h-full w-full overflow-hidden rounded-md border border-white/60 bg-[var(--color-surface-strong)] shadow-[0_1px_0_rgb(255_255_255_/_0.46)_inset] dark:border-white/10">
|
||||||
|
<div className="grid h-full w-full grid-cols-[repeat(8,minmax(0,1fr))] grid-rows-[repeat(8,minmax(0,1fr))]">
|
||||||
|
{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 min-h-0 min-w-0 overflow-hidden transition-[filter,box-shadow] duration-150',
|
||||||
|
isLight ? 'bg-[#edf2f5] dark:bg-[#c8d0d6]' : 'bg-[#7fa18b] dark:bg-[#415d52]',
|
||||||
|
canDrag && 'cursor-grab active:cursor-grabbing',
|
||||||
|
!disabled && 'hover:brightness-[1.04] focus-visible:z-20',
|
||||||
|
disabled && 'cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
aria-label={getSquareLabel(square, piece)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
{isLastMoveSquare && (
|
||||||
|
<span className="absolute inset-1 rounded bg-[var(--color-accent-soft)] shadow-[0_0_0_1px_rgb(255_255_255_/_0.34)_inset]" />
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<span className="absolute inset-1 rounded border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] shadow-[0_0_0_2px_rgb(255_255_255_/_0.28)_inset]" />
|
||||||
|
)}
|
||||||
|
{file === firstFile && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute left-1 top-1 text-[9px] font-bold leading-none sm:left-1.5 sm:top-1.5 sm:text-[10px]',
|
||||||
|
isLight ? 'text-[#7a9a86]' : 'text-white/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rank === lastRank && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute bottom-1 right-1 text-[9px] font-bold leading-none sm:bottom-1.5 sm:right-1.5 sm:text-[10px]',
|
||||||
|
isLight ? 'text-[#7a9a86]' : 'text-white/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isTarget && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute z-10 rounded-full transition',
|
||||||
|
piece
|
||||||
|
? 'inset-[13%] border-[2px] border-[var(--color-accent)]/70 shadow-[0_0_0_1px_rgb(255_255_255_/_0.28)_inset]'
|
||||||
|
: 'left-1/2 top-1/2 h-[22%] w-[22%] -translate-x-1/2 -translate-y-1/2 bg-[var(--color-accent)]/42 shadow-[0_1px_3px_rgb(0_0_0_/_0.18)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
{visualPieces.map((piece) => {
|
||||||
|
const canDrag = !disabled && (isDraggableSquare?.(piece.square) ?? true);
|
||||||
|
const isSelected = selectedSquare === piece.square;
|
||||||
|
const isLastMovePiece = lastMoveSquares?.to === piece.square;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={piece.id}
|
||||||
|
className="absolute left-0 top-0 flex h-[12.5%] w-[12.5%] items-center justify-center transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||||
|
style={getSquarePosition(piece.square, orientation)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'flex h-full w-full select-none items-center justify-center font-serif text-[2.5rem] font-bold leading-none transition-[transform,filter] duration-200 sm:text-[3rem] md:text-[3.6rem]',
|
||||||
|
canDrag && 'cursor-grab active:cursor-grabbing',
|
||||||
|
isSelected && 'scale-110 -translate-y-0.5 brightness-110',
|
||||||
|
isLastMovePiece && 'scale-[1.04]',
|
||||||
|
piece.color === 'w'
|
||||||
|
? 'text-[#fffdf7] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.48),0_0_1px_rgb(0_0_0_/_0.72),0_5px_9px_rgb(15_23_42_/_0.2)]'
|
||||||
|
: 'text-[#151922] [text-shadow:0_1px_0_rgb(255_255_255_/_0.72),0_0_1px_rgb(255_255_255_/_0.82),0_5px_9px_rgb(15_23_42_/_0.2)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PIECE_SYMBOLS[piece.color][piece.type]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
535
src/components/chess/ChessGamePlayClient.tsx
Normal file
535
src/components/chess/ChessGamePlayClient.tsx
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
'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,
|
||||||
|
Flag,
|
||||||
|
History,
|
||||||
|
Home,
|
||||||
|
Loader2,
|
||||||
|
PlusCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Swords,
|
||||||
|
Undo2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { getChessGame, postChessMove, resignChessGame, undoChessMove } from '@/api/chess';
|
||||||
|
import ChessBoard, { getMoveSquares, getTurnLabel, toChessJsColor, type MoveSquares } from '@/components/chess/ChessBoard';
|
||||||
|
import { getChessErrorMessage, getChessOutcomeLabel, isAuthError, outcomeBadgeTones } from '@/components/chess/chessUi';
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
|
import WindowSurface from '@/components/ui/WindowSurface';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { ChessColor, 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 ?? ''}`;
|
||||||
|
|
||||||
|
const hasPlayerMoved = (moves: string[], playerColor: ChessColor) => {
|
||||||
|
return moves.some((_, index) => {
|
||||||
|
const side = index % 2 === 0 ? 'white' : 'black';
|
||||||
|
|
||||||
|
return side === playerColor;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
canUndo,
|
||||||
|
canResign,
|
||||||
|
onCopyPgn,
|
||||||
|
onUndo,
|
||||||
|
onResign,
|
||||||
|
}: {
|
||||||
|
game: ChessGameResponse;
|
||||||
|
isPlayerTurn: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
canUndo: boolean;
|
||||||
|
canResign: boolean;
|
||||||
|
onCopyPgn: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onResign: () => 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]}>{getChessOutcomeLabel(game.outcome, game.status)}</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 && '요청을 처리하는 중입니다.'}
|
||||||
|
{!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'}
|
||||||
|
{!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3 xl:grid-cols-1">
|
||||||
|
{game.status === 'IN_PROGRESS' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
className="inline-flex h-10 min-w-0 items-center justify-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)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Undo2 size={16} />
|
||||||
|
무르기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResign}
|
||||||
|
disabled={!canResign}
|
||||||
|
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-3 text-sm font-semibold text-red-700 shadow-[var(--shadow-control)] transition hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<Flag size={16} />
|
||||||
|
기권
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/chess"
|
||||||
|
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
|
||||||
|
>
|
||||||
|
<PlusCircle size={16} />
|
||||||
|
새 게임
|
||||||
|
</Link>
|
||||||
|
</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 [optimisticMoveSquares, setOptimisticMoveSquares] = useState<MoveSquares | 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 syncUpdatedGame = async (updatedGame: ChessGameResponse) => {
|
||||||
|
queryClient.setQueryData(gameQueryKey, updatedGame);
|
||||||
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
setSelectedSquare(null);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chess-games'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveMutation = useMutation({
|
||||||
|
mutationFn: (move: string) => postChessMove(gameId, { move }),
|
||||||
|
onSuccess: async (updatedGame) => {
|
||||||
|
await syncUpdatedGame(updatedGame);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
setSelectedSquare(null);
|
||||||
|
toast.error(getChessErrorMessage(error, '봇 응답을 가져오지 못했습니다. 잠시 후 다시 시도해주세요.'));
|
||||||
|
void gameQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const undoMutation = useMutation({
|
||||||
|
mutationFn: () => undoChessMove(gameId),
|
||||||
|
onSuccess: async (updatedGame) => {
|
||||||
|
await syncUpdatedGame(updatedGame);
|
||||||
|
toast.success('무르기를 적용했습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
setSelectedSquare(null);
|
||||||
|
toast.error(getChessErrorMessage(error, '무르기를 처리하지 못했습니다.'));
|
||||||
|
void gameQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resignMutation = useMutation({
|
||||||
|
mutationFn: () => resignChessGame(gameId),
|
||||||
|
onSuccess: async (updatedGame) => {
|
||||||
|
await syncUpdatedGame(updatedGame);
|
||||||
|
toast.success('기권 처리되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
setSelectedSquare(null);
|
||||||
|
toast.error(getChessErrorMessage(error, '기권을 처리하지 못했습니다.'));
|
||||||
|
void gameQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = gameQuery.data;
|
||||||
|
const isPending = moveMutation.isPending || undoMutation.isPending || resignMutation.isPending;
|
||||||
|
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 &&
|
||||||
|
!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 serverLastMoveSquares = useMemo(() => getMoveSquares(game?.maiaMove ?? game?.moves.at(-1)), [game?.maiaMove, game?.moves]);
|
||||||
|
const lastMoveSquares = optimisticMoveSquares ?? serverLastMoveSquares;
|
||||||
|
|
||||||
|
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());
|
||||||
|
setOptimisticMoveSquares({ from, to });
|
||||||
|
setSelectedSquare(null);
|
||||||
|
moveMutation.mutate(toUciMove(candidate));
|
||||||
|
} catch {
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
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 복사에 실패했습니다.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const canResign = Boolean(game && game.status === 'IN_PROGRESS' && !isPending);
|
||||||
|
const canUndo = Boolean(game && game.status === 'IN_PROGRESS' && !isPending && hasPlayerMoved(game.moves, game.playerColor));
|
||||||
|
const pendingLabel = resignMutation.isPending
|
||||||
|
? '기권 처리 중'
|
||||||
|
: undoMutation.isPending
|
||||||
|
? '무르기 처리 중'
|
||||||
|
: moveMutation.isPending
|
||||||
|
? 'Maia 생각 중'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (!canUndo) return;
|
||||||
|
|
||||||
|
undoMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResign = () => {
|
||||||
|
if (!canResign) return;
|
||||||
|
if (!window.confirm('정말 기권하시겠습니까? 이 대국은 패배로 종료됩니다.')) return;
|
||||||
|
|
||||||
|
resignMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 justify-center gap-5 xl:grid-cols-[minmax(0,30rem)_22rem]">
|
||||||
|
<WindowSurface
|
||||||
|
title="Board"
|
||||||
|
subtitle={isPending ? 'Updating' : isPlayerTurn ? 'Your move' : 'Synced'}
|
||||||
|
showTrafficLights={false}
|
||||||
|
bodyClassName="p-2 md:p-3"
|
||||||
|
>
|
||||||
|
<div className="relative mx-auto max-w-full" style={{ width: 'min(100%, clamp(19rem, min(52vw, calc(100svh - 22.5rem)), 30rem))' }}>
|
||||||
|
<ChessBoard
|
||||||
|
fen={currentFen}
|
||||||
|
orientation={game.playerColor}
|
||||||
|
selectedSquare={selectedSquare}
|
||||||
|
legalTargets={legalTargets}
|
||||||
|
lastMoveSquares={lastMoveSquares}
|
||||||
|
disabled={!canInteract}
|
||||||
|
isDraggableSquare={isOwnTurnPiece}
|
||||||
|
onSquareClick={handleSquareClick}
|
||||||
|
onSquareDrop={attemptMove}
|
||||||
|
/>
|
||||||
|
{isPending && (
|
||||||
|
<div className="pointer-events-none absolute bottom-3 left-1/2 z-30 -translate-x-1/2">
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-white/25 bg-black/62 px-4 py-2 text-sm font-semibold text-white shadow-[0_12px_28px_rgb(0_0_0_/_0.28)] backdrop-blur-md">
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
{pendingLabel || '처리 중'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</WindowSurface>
|
||||||
|
|
||||||
|
<GameInfoPanel
|
||||||
|
game={game}
|
||||||
|
isPlayerTurn={isPlayerTurn}
|
||||||
|
pending={isPending}
|
||||||
|
canUndo={canUndo}
|
||||||
|
canResign={canResign}
|
||||||
|
onCopyPgn={copyPgn}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onResign={handleResign}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</ChessPageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/chess/ChessHistoryClient.tsx
Normal file
212
src/components/chess/ChessHistoryClient.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AlertCircle, Bot, ChevronRight, Home, Loader2, RotateCcw, Swords } from 'lucide-react';
|
||||||
|
import { getChessGames } from '@/api/chess';
|
||||||
|
import { getTurnLabel } from '@/components/chess/ChessBoard';
|
||||||
|
import {
|
||||||
|
formatChessDateTime,
|
||||||
|
getChessErrorMessage,
|
||||||
|
getChessOutcomeLabel,
|
||||||
|
isAuthError,
|
||||||
|
outcomeBadgeTones,
|
||||||
|
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]}>{getChessOutcomeLabel(game.outcome, game.status)}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
src/components/chess/ChessHomeClient.tsx
Normal file
362
src/components/chess/ChessHomeClient.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
'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, getChessOutcomeLabel, 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;
|
||||||
|
status: string;
|
||||||
|
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]}>{getChessOutcomeLabel(game.outcome, game.status)}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CSSProperties, ReactNode } from 'react';
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Chess as ChessGame, type Color, type Move, type PieceSymbol, type Square } from 'chess.js';
|
import { Chess as ChessGame, type Color, type Move, type Square } from 'chess.js';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -15,45 +15,15 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getTodayChessPuzzle } from '@/api/chess';
|
import { getTodayChessPuzzle } from '@/api/chess';
|
||||||
|
import ChessBoard, { BOARD_SQUARES, type MoveSquares } from '@/components/chess/ChessBoard';
|
||||||
import WindowSurface from '@/components/ui/WindowSurface';
|
import WindowSurface from '@/components/ui/WindowSurface';
|
||||||
import { type ChessPuzzle } from '@/types';
|
import { type ChessPuzzle } from '@/types';
|
||||||
|
|
||||||
type FeedbackTone = 'neutral' | 'success' | 'error';
|
type FeedbackTone = 'neutral' | 'success' | 'error';
|
||||||
|
|
||||||
const TIMEZONE = 'Asia/Seoul';
|
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 BOARD_SIZE_STYLE: CSSProperties = {
|
const BOARD_SIZE_STYLE: CSSProperties = {
|
||||||
width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))',
|
width: 'min(100%, clamp(19rem, min(52vw, calc(100svh - 22.5rem)), 30rem))',
|
||||||
};
|
|
||||||
|
|
||||||
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 turnLabel = (color: Color) => (color === 'w' ? '백' : '흑');
|
||||||
@@ -67,10 +37,10 @@ const getReadyFeedback = (puzzle: ChessPuzzle): { tone: FeedbackTone; message: s
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => {
|
const getPreferredMove = (moves: Move[], to: Square) => {
|
||||||
if (!piece) return `${square} 빈 칸`;
|
const candidates = moves.filter((move) => move.to === to);
|
||||||
|
|
||||||
return `${square} ${turnLabel(piece.color)} ${PIECE_NAMES[piece.type]}`;
|
return candidates.find((move) => move.promotion === 'q') ?? candidates[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (value: string) => {
|
const formatDate = (value: string) => {
|
||||||
@@ -113,7 +83,7 @@ function ChessPageFrame({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
function BoardWindow({ children }: { children: ReactNode }) {
|
function BoardWindow({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<WindowSurface title="Board" showTrafficLights={false} bodyClassName="p-3 md:p-5">
|
<WindowSurface title="Board" showTrafficLights={false} bodyClassName="p-2 md:p-3">
|
||||||
<div className="mx-auto max-w-full" style={BOARD_SIZE_STYLE}>
|
<div className="mx-auto max-w-full" style={BOARD_SIZE_STYLE}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +95,7 @@ function LoadingState() {
|
|||||||
return (
|
return (
|
||||||
<ChessPageFrame>
|
<ChessPageFrame>
|
||||||
<PageHeader />
|
<PageHeader />
|
||||||
<section className="grid min-w-0 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<section className="grid min-w-0 items-start justify-center gap-5 lg:grid-cols-[minmax(0,30rem)_20rem]">
|
||||||
<BoardWindow>
|
<BoardWindow>
|
||||||
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)]">
|
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)]">
|
||||||
{BOARD_SQUARES.map((square, index) => (
|
{BOARD_SQUARES.map((square, index) => (
|
||||||
@@ -133,8 +103,9 @@ function LoadingState() {
|
|||||||
key={square}
|
key={square}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'aspect-square animate-pulse',
|
'aspect-square animate-pulse',
|
||||||
(index + Math.floor(index / 8)) % 2 === 0 ? 'bg-black/[0.06]' : 'bg-black/[0.12]',
|
(index + Math.floor(index / 8)) % 2 === 0
|
||||||
'dark:bg-white/[0.08]',
|
? 'bg-[#edf2f5] dark:bg-[#c8d0d6]'
|
||||||
|
: 'bg-[#7fa18b] dark:bg-[#415d52]',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -176,7 +147,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
|||||||
const [currentFen, setCurrentFen] = useState(puzzle.fen);
|
const [currentFen, setCurrentFen] = useState(puzzle.fen);
|
||||||
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
|
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
|
||||||
const [solved, setSolved] = useState(false);
|
const [solved, setSolved] = useState(false);
|
||||||
const [lastMoveSquares, setLastMoveSquares] = useState<{ from: Square; to: Square } | null>(null);
|
const [lastMoveSquares, setLastMoveSquares] = useState<MoveSquares | null>(null);
|
||||||
const [feedback, setFeedback] = useState(getReadyFeedback(puzzle));
|
const [feedback, setFeedback] = useState(getReadyFeedback(puzzle));
|
||||||
|
|
||||||
const game = useMemo(() => new ChessGame(currentFen), [currentFen]);
|
const game = useMemo(() => new ChessGame(currentFen), [currentFen]);
|
||||||
@@ -206,8 +177,19 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDraggablePuzzleSquare = (square: Square) => {
|
||||||
|
if (solved) return false;
|
||||||
|
|
||||||
|
const piece = game.get(square);
|
||||||
|
|
||||||
|
return piece?.color === game.turn();
|
||||||
|
};
|
||||||
|
|
||||||
const tryMove = (from: Square, to: Square) => {
|
const tryMove = (from: Square, to: Square) => {
|
||||||
const candidate = legalMoves.find((move) => move.to === to);
|
if (solved || !isDraggablePuzzleSquare(from)) return;
|
||||||
|
|
||||||
|
const movesFromSquare = game.moves({ square: from, verbose: true });
|
||||||
|
const candidate = getPreferredMove(movesFromSquare, to);
|
||||||
|
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
@@ -288,79 +270,18 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
|||||||
<ChessPageFrame>
|
<ChessPageFrame>
|
||||||
<PageHeader />
|
<PageHeader />
|
||||||
|
|
||||||
<section className="grid min-w-0 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<section className="grid min-w-0 items-start justify-center gap-5 lg:grid-cols-[minmax(0,30rem)_20rem]">
|
||||||
<BoardWindow>
|
<BoardWindow>
|
||||||
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
|
<ChessBoard
|
||||||
{BOARD_SQUARES.map((square, index) => {
|
fen={currentFen}
|
||||||
const piece = game.get(square);
|
selectedSquare={selectedSquare}
|
||||||
const file = square[0];
|
legalTargets={legalTargets}
|
||||||
const rank = square[1];
|
lastMoveSquares={lastMoveSquares}
|
||||||
const fileIndex = index % 8;
|
disabled={solved}
|
||||||
const rankIndex = Math.floor(index / 8);
|
isDraggableSquare={isDraggablePuzzleSquare}
|
||||||
const isLight = (fileIndex + rankIndex) % 2 === 0;
|
onSquareClick={handleSquareClick}
|
||||||
const isSelected = selectedSquare === square;
|
onSquareDrop={tryMove}
|
||||||
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-[2rem] leading-none transition sm:text-[2.5rem] md:text-[3.75rem]',
|
|
||||||
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>
|
|
||||||
</BoardWindow>
|
</BoardWindow>
|
||||||
|
|
||||||
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
||||||
|
|||||||
60
src/components/chess/chessUi.ts
Normal file
60
src/components/chess/chessUi.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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 getChessOutcomeLabel = (outcome: ChessOutcome, status?: string) => {
|
||||||
|
if (status === 'RESIGNED' && outcome === 'LOSS') return '기권패';
|
||||||
|
|
||||||
|
return outcomeLabels[outcome];
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type CSSProperties, type FocusEvent, useEffect, useRef, useState } from 'react';
|
import { type CSSProperties, type FocusEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
@@ -23,7 +23,6 @@ import ThemeToggle from '@/components/theme/ThemeToggle';
|
|||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
interface DesktopDockProps {
|
interface DesktopDockProps {
|
||||||
isSidebarCollapsed: boolean;
|
|
||||||
onOpenMobileMenu: () => void;
|
onOpenMobileMenu: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +87,9 @@ const dockGlassTone = createDockTone(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const homeDockTone = createDockTone(
|
const homeDockTone = createDockTone(
|
||||||
'rgba(255, 255, 255, 0.36)',
|
'rgba(255, 255, 255, 0.58)',
|
||||||
'rgba(255, 255, 255, 0.5)',
|
'rgba(255, 255, 255, 0.68)',
|
||||||
'rgba(255, 255, 255, 0.62)',
|
'rgba(255, 255, 255, 0.76)',
|
||||||
'rgba(255, 255, 255, 0.28)',
|
'rgba(255, 255, 255, 0.28)',
|
||||||
'rgba(255, 255, 255, 0.46)',
|
'rgba(255, 255, 255, 0.46)',
|
||||||
'var(--color-text-muted)',
|
'var(--color-text-muted)',
|
||||||
@@ -100,22 +99,22 @@ const homeDockTone = createDockTone(
|
|||||||
|
|
||||||
const dockToneStyles: Record<string, DockToneStyle> = {
|
const dockToneStyles: Record<string, DockToneStyle> = {
|
||||||
home: homeDockTone,
|
home: homeDockTone,
|
||||||
archive: dockGlassTone,
|
archive: createDockTone('rgba(222, 216, 255, 0.54)', 'rgba(222, 216, 255, 0.64)', 'rgba(222, 216, 255, 0.72)', 'rgba(255, 255, 255, 0.26)', 'rgba(151, 132, 214, 0.34)', '#6f668f', '#514274', 'rgba(151, 132, 214, 0.2)'),
|
||||||
chess: dockGlassTone,
|
chess: createDockTone('rgba(255, 229, 162, 0.54)', 'rgba(255, 229, 162, 0.64)', 'rgba(255, 229, 162, 0.72)', 'rgba(255, 255, 255, 0.28)', 'rgba(184, 134, 42, 0.34)', '#7b6d4a', '#665125', 'rgba(184, 134, 42, 0.2)'),
|
||||||
admin: dockGlassTone,
|
admin: createDockTone('rgba(192, 226, 255, 0.54)', 'rgba(192, 226, 255, 0.64)', 'rgba(192, 226, 255, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(81, 145, 195, 0.34)', '#627f96', '#3f6686', 'rgba(81, 145, 195, 0.2)'),
|
||||||
write: dockGlassTone,
|
write: createDockTone('rgba(255, 209, 184, 0.54)', 'rgba(255, 209, 184, 0.64)', 'rgba(255, 209, 184, 0.72)', 'rgba(255, 255, 255, 0.28)', 'rgba(187, 106, 70, 0.34)', '#8c705f', '#724d38', 'rgba(187, 106, 70, 0.2)'),
|
||||||
login: dockGlassTone,
|
login: createDockTone('rgba(190, 236, 238, 0.54)', 'rgba(190, 236, 238, 0.64)', 'rgba(190, 236, 238, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(68, 150, 158, 0.34)', '#63898c', '#446d72', 'rgba(68, 150, 158, 0.2)'),
|
||||||
signup: dockGlassTone,
|
signup: createDockTone('rgba(237, 207, 250, 0.54)', 'rgba(237, 207, 250, 0.64)', 'rgba(237, 207, 250, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(153, 91, 176, 0.34)', '#846c8e', '#684a78', 'rgba(153, 91, 176, 0.2)'),
|
||||||
logout: dockGlassTone,
|
logout: createDockTone('rgba(255, 207, 216, 0.54)', 'rgba(255, 207, 216, 0.64)', 'rgba(255, 207, 216, 0.72)', 'rgba(255, 255, 255, 0.28)', 'rgba(188, 84, 99, 0.34)', '#8f6970', '#714850', 'rgba(188, 84, 99, 0.2)'),
|
||||||
pin: dockGlassTone,
|
pin: createDockTone('rgba(255, 219, 170, 0.56)', 'rgba(255, 219, 170, 0.66)', 'rgba(255, 219, 170, 0.74)', 'rgba(255, 255, 255, 0.28)', 'rgba(184, 118, 44, 0.36)', '#8a704e', '#6b4f2d', 'rgba(184, 118, 44, 0.22)'),
|
||||||
menu: dockGlassTone,
|
menu: createDockTone('rgba(213, 234, 203, 0.54)', 'rgba(213, 234, 203, 0.64)', 'rgba(213, 234, 203, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(98, 143, 80, 0.34)', '#6f8366', '#526f44', 'rgba(98, 143, 80, 0.2)'),
|
||||||
more: dockGlassTone,
|
more: createDockTone('rgba(210, 228, 255, 0.54)', 'rgba(210, 228, 255, 0.64)', 'rgba(210, 228, 255, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(86, 126, 183, 0.34)', '#6d7f98', '#4f688d', 'rgba(86, 126, 183, 0.2)'),
|
||||||
default: dockGlassTone,
|
default: dockGlassTone,
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDockToneStyle = (key: string) => dockToneStyles[key] ?? dockToneStyles.default;
|
const getDockToneStyle = (key: string) => dockToneStyles[key] ?? dockToneStyles.default;
|
||||||
|
|
||||||
export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: DesktopDockProps) {
|
export default function DesktopDock({ onOpenMobileMenu }: DesktopDockProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
|
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
|
||||||
@@ -124,6 +123,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
const [isDockHovered, setIsDockHovered] = useState(false);
|
const [isDockHovered, setIsDockHovered] = useState(false);
|
||||||
const [isDockFocused, setIsDockFocused] = useState(false);
|
const [isDockFocused, setIsDockFocused] = useState(false);
|
||||||
const [isDockClosing, setIsDockClosing] = useState(false);
|
const [isDockClosing, setIsDockClosing] = useState(false);
|
||||||
|
const isPinnedRef = useRef(isPinned);
|
||||||
const dockZoneRef = useRef<HTMLDivElement>(null);
|
const dockZoneRef = useRef<HTMLDivElement>(null);
|
||||||
const dockLeaveTimerRef = useRef<number | null>(null);
|
const dockLeaveTimerRef = useRef<number | null>(null);
|
||||||
const dockCollapseTimerRef = useRef<number | null>(null);
|
const dockCollapseTimerRef = useRef<number | null>(null);
|
||||||
@@ -132,7 +132,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
const isDockOpen = isPinned || isDockHovered || isDockFocused;
|
const isDockOpen = isPinned || isDockHovered || isDockFocused;
|
||||||
const isDockRangeExpanded = isDockOpen || isDockClosing;
|
const isDockRangeExpanded = isDockOpen || isDockClosing;
|
||||||
|
|
||||||
const clearDockTimers = () => {
|
const clearDockTimers = useCallback(() => {
|
||||||
if (dockLeaveTimerRef.current !== null) {
|
if (dockLeaveTimerRef.current !== null) {
|
||||||
window.clearTimeout(dockLeaveTimerRef.current);
|
window.clearTimeout(dockLeaveTimerRef.current);
|
||||||
dockLeaveTimerRef.current = null;
|
dockLeaveTimerRef.current = null;
|
||||||
@@ -142,7 +142,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
window.clearTimeout(dockCollapseTimerRef.current);
|
window.clearTimeout(dockCollapseTimerRef.current);
|
||||||
dockCollapseTimerRef.current = null;
|
dockCollapseTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const expandDock = () => {
|
const expandDock = () => {
|
||||||
clearDockTimers();
|
clearDockTimers();
|
||||||
@@ -150,7 +150,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
setIsDockHovered(true);
|
setIsDockHovered(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDockCollapse = () => {
|
const startDockCollapse = useCallback(() => {
|
||||||
clearDockTimers();
|
clearDockTimers();
|
||||||
setIsDockHovered(false);
|
setIsDockHovered(false);
|
||||||
setIsDockClosing(true);
|
setIsDockClosing(true);
|
||||||
@@ -158,7 +158,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
setIsDockClosing(false);
|
setIsDockClosing(false);
|
||||||
dockCollapseTimerRef.current = null;
|
dockCollapseTimerRef.current = null;
|
||||||
}, DOCK_COLLAPSE_MS);
|
}, DOCK_COLLAPSE_MS);
|
||||||
};
|
}, [clearDockTimers]);
|
||||||
|
|
||||||
const scheduleDockCollapse = () => {
|
const scheduleDockCollapse = () => {
|
||||||
if (isPinned || isDockFocused) return;
|
if (isPinned || isDockFocused) return;
|
||||||
@@ -169,10 +169,44 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
}, DOCK_LEAVE_DELAY_MS);
|
}, DOCK_LEAVE_DELAY_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const blurFocusedDockItem = useCallback(() => {
|
||||||
|
if (
|
||||||
|
document.activeElement instanceof HTMLElement &&
|
||||||
|
dockZoneRef.current?.contains(document.activeElement)
|
||||||
|
) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const releaseUnpinnedDockAfterNavigation = useCallback(() => {
|
||||||
|
if (isPinnedRef.current) return;
|
||||||
|
|
||||||
|
const isPointerInsideDock = dockZoneRef.current?.matches(':hover') ?? false;
|
||||||
|
|
||||||
|
blurFocusedDockItem();
|
||||||
|
setIsDockFocused(false);
|
||||||
|
|
||||||
|
if (isPointerInsideDock) {
|
||||||
|
clearDockTimers();
|
||||||
|
setIsDockClosing(false);
|
||||||
|
setIsDockHovered(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startDockCollapse();
|
||||||
|
}, [blurFocusedDockItem, clearDockTimers, startDockCollapse]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isPinnedRef.current = isPinned;
|
||||||
|
}, [isPinned]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frameId = window.requestAnimationFrame(() => {
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
const savedValue = window.localStorage.getItem(DOCK_PINNED_STORAGE_KEY);
|
const savedValue = window.localStorage.getItem(DOCK_PINNED_STORAGE_KEY);
|
||||||
setIsPinned(savedValue === null ? true : savedValue === 'true');
|
const nextValue = savedValue === null ? true : savedValue === 'true';
|
||||||
|
|
||||||
|
isPinnedRef.current = nextValue;
|
||||||
|
setIsPinned(nextValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => window.cancelAnimationFrame(frameId);
|
return () => window.cancelAnimationFrame(frameId);
|
||||||
@@ -181,14 +215,15 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frameId = window.requestAnimationFrame(() => {
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
setIsMoreOpen(false);
|
setIsMoreOpen(false);
|
||||||
|
releaseUnpinnedDockAfterNavigation();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => window.cancelAnimationFrame(frameId);
|
return () => window.cancelAnimationFrame(frameId);
|
||||||
}, [pathname]);
|
}, [pathname, releaseUnpinnedDockAfterNavigation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => clearDockTimers();
|
return () => clearDockTimers();
|
||||||
}, []);
|
}, [clearDockTimers]);
|
||||||
|
|
||||||
const handlePinnedChange = () => {
|
const handlePinnedChange = () => {
|
||||||
const nextValue = !isPinned;
|
const nextValue = !isPinned;
|
||||||
@@ -198,6 +233,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
pinToggleStartedByPointerRef.current = false;
|
pinToggleStartedByPointerRef.current = false;
|
||||||
window.localStorage.setItem(DOCK_PINNED_STORAGE_KEY, String(nextValue));
|
window.localStorage.setItem(DOCK_PINNED_STORAGE_KEY, String(nextValue));
|
||||||
clearDockTimers();
|
clearDockTimers();
|
||||||
|
isPinnedRef.current = nextValue;
|
||||||
setIsPinned(nextValue);
|
setIsPinned(nextValue);
|
||||||
|
|
||||||
if (nextValue) {
|
if (nextValue) {
|
||||||
@@ -208,13 +244,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
|
|
||||||
if (startedByPointer) {
|
if (startedByPointer) {
|
||||||
setIsDockFocused(false);
|
setIsDockFocused(false);
|
||||||
|
blurFocusedDockItem();
|
||||||
if (
|
|
||||||
document.activeElement instanceof HTMLElement &&
|
|
||||||
dockZoneRef.current?.contains(document.activeElement)
|
|
||||||
) {
|
|
||||||
document.activeElement.blur();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPointerInsideDock) {
|
if (isPointerInsideDock) {
|
||||||
@@ -296,10 +326,10 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
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'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -361,7 +391,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
const active = item.isActive?.(pathname) ?? false;
|
const active = item.isActive?.(pathname) ?? false;
|
||||||
const toneStyle = getDockToneStyle(item.key);
|
const toneStyle = getDockToneStyle(item.key);
|
||||||
const itemClass = clsx(
|
const itemClass = clsx(
|
||||||
'group/item relative flex h-12 w-12 items-center justify-center rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] transition duration-150 hover:-translate-y-1 hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)] focus-visible:-translate-y-1',
|
'group/item relative flex h-12 w-12 items-center justify-center rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition duration-150 hover:-translate-y-1 hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)] focus-visible:-translate-y-1',
|
||||||
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
|
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -387,6 +417,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={itemClass}
|
className={itemClass}
|
||||||
style={toneStyle}
|
style={toneStyle}
|
||||||
|
onClick={releaseUnpinnedDockAfterNavigation}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -413,7 +444,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
const active = activeOverride ?? item.isActive?.(pathname) ?? false;
|
const active = activeOverride ?? item.isActive?.(pathname) ?? false;
|
||||||
const toneStyle = getDockToneStyle(item.key);
|
const toneStyle = getDockToneStyle(item.key);
|
||||||
const itemClass = clsx(
|
const itemClass = clsx(
|
||||||
'flex h-12 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] px-1 text-[10px] font-semibold leading-none text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] transition-colors',
|
'flex h-12 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] px-1 text-[10px] font-semibold leading-none text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors',
|
||||||
'hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)] focus-visible:bg-[var(--dock-item-bg-active)]',
|
'hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)] focus-visible:bg-[var(--dock-item-bg-active)]',
|
||||||
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
|
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
|
||||||
);
|
);
|
||||||
@@ -461,9 +492,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
<nav
|
<nav
|
||||||
aria-label="빠른 실행 Dock"
|
aria-label="빠른 실행 Dock"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed bottom-0 left-1/2 z-40 hidden -translate-x-1/2 md:block',
|
'fixed bottom-1 left-1/2 z-50 hidden -translate-x-1/2 md:block',
|
||||||
'transition-[left] duration-300 ease-out',
|
|
||||||
isSidebarCollapsed ? 'md:left-[calc(50%+2.5rem)]' : 'md:left-[calc(50%+9rem)]',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -476,7 +505,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
'relative flex items-end justify-center transition-[width,height,padding] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
'relative flex items-end justify-center transition-[width,height,padding] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
isDockRangeExpanded
|
isDockRangeExpanded
|
||||||
? 'h-32 w-[min(calc(100vw-2rem),56rem)] px-8 pb-7 pt-12'
|
? 'h-32 w-[min(calc(100vw-2rem),56rem)] px-8 pb-7 pt-12'
|
||||||
: 'h-12 w-16 px-0 pb-0 pt-0',
|
: 'h-14 w-16 px-0 pb-0 pt-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -486,7 +515,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
onMouseEnter={expandDock}
|
onMouseEnter={expandDock}
|
||||||
onFocus={handleDockHandleFocus}
|
onFocus={handleDockHandleFocus}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'absolute bottom-0 left-1/2 z-10 flex h-9 w-12 -translate-x-1/2 items-start justify-center rounded-t-lg border border-b-0 border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] pt-1.5 text-[var(--dock-item-fg-strong)] shadow-[var(--shadow-control)] backdrop-blur-[22px]',
|
'absolute bottom-0 left-1/2 z-10 flex h-10 w-12 -translate-x-1/2 items-start justify-center rounded-t-lg border border-b-0 border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] pt-1.5 text-[var(--dock-item-fg-strong)] shadow-[var(--shadow-control)] backdrop-blur-[22px]',
|
||||||
'transition-[transform,opacity,width,height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:bg-[var(--dock-item-bg-hover)] focus-visible:bg-[var(--dock-item-bg-hover)]',
|
'transition-[transform,opacity,width,height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:bg-[var(--dock-item-bg-hover)] focus-visible:bg-[var(--dock-item-bg-hover)]',
|
||||||
isDockRangeExpanded
|
isDockRangeExpanded
|
||||||
? 'pointer-events-none translate-y-5 scale-75 opacity-0'
|
? 'pointer-events-none translate-y-5 scale-75 opacity-0'
|
||||||
@@ -500,7 +529,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
<div
|
<div
|
||||||
data-dock-panel="true"
|
data-dock-panel="true"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex max-w-[calc(100vw-2rem)] origin-bottom items-end gap-2 overflow-visible rounded-lg border border-[var(--dock-border)] bg-[var(--dock-bg)] px-2 py-2 shadow-[var(--shadow-dock)] backdrop-blur-[30px]',
|
'flex max-w-[calc(100vw-2rem)] origin-bottom items-end gap-2 overflow-visible rounded-xl border border-[var(--dock-border)] bg-[var(--dock-bg)] px-2 py-2 shadow-[var(--shadow-dock)] backdrop-blur-[30px]',
|
||||||
'transition-[transform,opacity,filter] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
'transition-[transform,opacity,filter] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
isDockOpen
|
isDockOpen
|
||||||
? 'pointer-events-auto translate-y-0 scale-x-100 scale-y-100 opacity-100 blur-0'
|
? 'pointer-events-auto translate-y-0 scale-x-100 scale-y-100 opacity-100 blur-0'
|
||||||
@@ -521,7 +550,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
}}
|
}}
|
||||||
onClick={handlePinnedChange}
|
onClick={handlePinnedChange}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group/item relative ml-1 flex h-12 w-12 items-center justify-center rounded-lg border border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] shadow-[var(--shadow-control)] ring-1 ring-[var(--dock-item-ring)] transition duration-150 hover:-translate-y-1 hover:bg-[var(--dock-item-bg-hover)]',
|
'group/item relative ml-1 flex h-12 w-12 items-center justify-center rounded-lg border border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] shadow-[var(--shadow-control)] ring-1 ring-[var(--dock-item-ring)] backdrop-blur-[18px] transition duration-150 hover:-translate-y-1 hover:bg-[var(--dock-item-bg-hover)]',
|
||||||
isPinned && 'shadow-[var(--shadow-dock)]',
|
isPinned && 'shadow-[var(--shadow-dock)]',
|
||||||
)}
|
)}
|
||||||
style={getDockToneStyle('pin')}
|
style={getDockToneStyle('pin')}
|
||||||
@@ -550,7 +579,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
|
|||||||
const active = item.isActive?.(pathname) ?? false;
|
const active = item.isActive?.(pathname) ?? false;
|
||||||
const toneStyle = getDockToneStyle(item.key);
|
const toneStyle = getDockToneStyle(item.key);
|
||||||
const className = clsx(
|
const className = clsx(
|
||||||
'inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] px-3 text-sm font-semibold text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)]',
|
'inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] px-3 text-sm font-semibold text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)]',
|
||||||
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
|
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { clsx } from 'clsx';
|
import { BatteryMedium, ChevronRight, Command, Monitor, Search, Wifi } from 'lucide-react';
|
||||||
import { getProfile } from '@/api/profile';
|
import { getProfile } from '@/api/profile';
|
||||||
import ThemeToggle from '@/components/theme/ThemeToggle';
|
import ThemeToggle from '@/components/theme/ThemeToggle';
|
||||||
|
|
||||||
interface DesktopMenuBarProps {
|
|
||||||
isSidebarCollapsed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProfile = {
|
const defaultProfile = {
|
||||||
githubUrl: 'https://github.com',
|
githubUrl: 'https://github.com',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
@@ -24,22 +21,36 @@ const getCategoryTitle = (pathname: string) => {
|
|||||||
|
|
||||||
const getAppTitle = (pathname: string) => {
|
const getAppTitle = (pathname: string) => {
|
||||||
if (pathname.startsWith('/admin/posts/new')) return 'Write';
|
if (pathname.startsWith('/admin/posts/new')) return 'Write';
|
||||||
if (pathname.startsWith('/admin/posts')) return 'Posts';
|
if (pathname.startsWith('/admin/posts')) return 'Post Manager';
|
||||||
if (pathname.startsWith('/admin/comments')) return 'Comments';
|
if (pathname.startsWith('/admin/comments')) return 'Comment Center';
|
||||||
if (pathname.startsWith('/admin/categories')) return 'Categories';
|
if (pathname.startsWith('/admin/categories')) return 'Category Studio';
|
||||||
if (pathname.startsWith('/admin/profile')) return 'Profile';
|
if (pathname.startsWith('/admin/profile')) return 'Profile Panel';
|
||||||
if (pathname.startsWith('/admin')) return 'Dashboard';
|
if (pathname.startsWith('/admin')) return 'Admin Console';
|
||||||
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';
|
||||||
return '홈';
|
return '홈';
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DesktopMenuBar({ isSidebarCollapsed }: DesktopMenuBarProps) {
|
const formatMenuTime = (date: Date | null) => {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DesktopMenuBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [now, setNow] = useState<Date | null>(null);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ['profile'],
|
queryKey: ['profile'],
|
||||||
@@ -47,38 +58,64 @@ export default function DesktopMenuBar({ isSidebarCollapsed }: DesktopMenuBarPro
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTime = () => setNow(new Date());
|
||||||
|
const frameId = window.requestAnimationFrame(updateTime);
|
||||||
|
const timerId = window.setInterval(updateTime, 60_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const githubUrl = profile?.githubUrl || defaultProfile.githubUrl;
|
const githubUrl = profile?.githubUrl || defaultProfile.githubUrl;
|
||||||
const email = profile?.email || defaultProfile.email;
|
const email = profile?.email || defaultProfile.email;
|
||||||
const menuLinkClass = 'text-xs font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-text)]';
|
const menuLinkClass = 'hidden rounded px-2 py-1 text-xs font-semibold text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg)] hover:text-[var(--color-text)] xl:inline-flex';
|
||||||
|
const systemIconClass = 'text-[var(--color-text-muted)]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className="fixed inset-x-0 top-0 z-[70] hidden h-10 border-b border-[var(--menubar-border)] bg-[var(--menubar-bg)] px-3 shadow-[var(--shadow-menubar)] backdrop-blur-[28px] md:block"
|
||||||
'fixed right-6 top-3 z-40 hidden transition-[left] duration-300 ease-out md:block',
|
role="menubar"
|
||||||
isSidebarCollapsed ? 'md:left-24' : 'md:left-[19rem]',
|
aria-label="시스템 메뉴 막대"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex h-11 items-center justify-between gap-3 rounded-lg border border-[var(--menubar-border)] bg-[var(--menubar-bg)] px-3 shadow-[var(--shadow-menubar)] backdrop-blur-[24px]">
|
<div className="flex h-full min-w-0 items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<Link href="/" className="shrink-0 text-sm font-bold text-[var(--color-text)]">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mr-1 inline-flex h-7 shrink-0 items-center gap-1.5 rounded px-2 text-sm font-bold text-[var(--color-text)] transition hover:bg-[var(--card-bg)]"
|
||||||
|
>
|
||||||
|
<Command size={15} strokeWidth={2.4} />
|
||||||
WYPark
|
WYPark
|
||||||
</Link>
|
</Link>
|
||||||
<span className="hidden h-4 w-px bg-[var(--color-line)] sm:block" />
|
|
||||||
<span className="truncate text-sm font-semibold text-[var(--color-text-muted)]">
|
<div className="hidden min-w-0 items-center gap-1.5 rounded px-2 py-1 text-xs font-semibold text-[var(--color-text-subtle)] sm:flex">
|
||||||
{getAppTitle(pathname)}
|
<Monitor size={14} />
|
||||||
</span>
|
<span className="truncate text-[var(--color-text-muted)]">{getAppTitle(pathname)}</span>
|
||||||
<div className="hidden items-center gap-3 md:flex">
|
<ChevronRight size={13} />
|
||||||
|
<span className="truncate">{profile?.name || 'WYPark'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<a href={githubUrl} target="_blank" rel="noreferrer" className={menuLinkClass}>
|
<a href={githubUrl} target="_blank" rel="noreferrer" className={menuLinkClass}>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
<a href={`mailto:${email}`} className={menuLinkClass}>
|
<a href={`mailto:${email}`} className={menuLinkClass}>
|
||||||
Email
|
Email
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<Search size={15} className={systemIconClass} />
|
||||||
</div>
|
<Wifi size={15} className={systemIconClass} />
|
||||||
|
<BatteryMedium size={17} className={systemIconClass} />
|
||||||
|
<span className="hidden min-w-[8.5rem] text-right text-xs font-semibold tabular-nums text-[var(--color-text-muted)] lg:block">
|
||||||
|
{formatMenuTime(now)}
|
||||||
|
</span>
|
||||||
|
<div className="scale-[0.88]">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,33 +38,32 @@ 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="min-h-screen overflow-x-clip">
|
<div className="desktop-shell min-h-screen overflow-x-clip">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isDesktopCollapsed={isSidebarCollapsed}
|
isDesktopCollapsed={isSidebarCollapsed}
|
||||||
isMobileOpen={isMobileSidebarOpen}
|
isMobileOpen={isMobileSidebarOpen}
|
||||||
onDesktopCollapsedChange={handleSidebarCollapsedChange}
|
onDesktopCollapsedChange={handleSidebarCollapsedChange}
|
||||||
onMobileOpenChange={setIsMobileSidebarOpen}
|
onMobileOpenChange={setIsMobileSidebarOpen}
|
||||||
/>
|
/>
|
||||||
<DesktopMenuBar isSidebarCollapsed={isSidebarCollapsed} />
|
<DesktopMenuBar />
|
||||||
<DesktopDock
|
<DesktopDock
|
||||||
isSidebarCollapsed={isSidebarCollapsed}
|
|
||||||
onOpenMobileMenu={() => setIsMobileSidebarOpen(true)}
|
onOpenMobileMenu={() => setIsMobileSidebarOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative min-w-0 max-w-full flex-1 overflow-x-clip transition-[margin,width] duration-300 ease-out',
|
'relative z-10 min-w-0 max-w-full flex-1 overflow-x-clip transition-[margin,width] duration-300 ease-out',
|
||||||
isSidebarCollapsed
|
isSidebarCollapsed
|
||||||
? 'md:ml-20 md:w-[calc(100%-5rem)]'
|
? 'md:ml-[7rem] md:w-[calc(100%-7rem)]'
|
||||||
: 'md:ml-72 md:w-[calc(100%-18rem)]',
|
: 'md:ml-[20rem] md:w-[calc(100%-20rem)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'mx-auto min-w-0 max-w-full px-3 pt-16 md:px-6 md:pt-24 lg:px-8',
|
'mx-auto min-w-0 max-w-full px-3 pt-14 md:px-6 md:pt-16 lg:px-8',
|
||||||
isChessRoute ? 'pb-44 md:pb-40' : isReaderRoute ? 'pb-44 md:pb-36' : 'pb-36 md:pb-32',
|
isChessRoute ? 'pb-44 md:pb-40' : isReaderRoute ? 'pb-44 md:pb-36' : 'pb-36 md:pb-32',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
Crown,
|
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Home,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getCategories } from '@/api/category';
|
import { getCategories } from '@/api/category';
|
||||||
import { getProfile } from '@/api/profile';
|
import { getProfile } from '@/api/profile';
|
||||||
@@ -180,26 +178,40 @@ function SidebarContent({
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed left-0 top-0 z-[60] flex h-screen w-72 max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-[var(--sidebar-bg)] shadow-[var(--shadow-sidebar)] backdrop-blur-[30px] transition-[transform,width] duration-300 ease-out md:z-40 md:max-w-none md:translate-x-0',
|
'fixed left-0 top-0 z-[60] flex h-screen w-72 max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-[var(--sidebar-bg)] shadow-[var(--shadow-sidebar)] backdrop-blur-[30px] transition-[transform,width] duration-300 ease-out md:left-4 md:top-14 md:z-40 md:h-[calc(100vh-9rem)] md:max-w-none md:translate-x-0 md:rounded-lg md:border',
|
||||||
isDesktopCollapsed ? 'md:w-20' : 'md:w-72',
|
isDesktopCollapsed ? 'md:w-[4.5rem]' : 'md:w-72',
|
||||||
isMobileOpen ? 'translate-x-0' : '-translate-x-full',
|
isMobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={clsx('relative shrink-0 text-center', isDesktopCollapsed ? 'px-6 pb-5 pt-8 md:px-3 md:pb-4 md:pt-5' : 'px-6 pb-5 pt-8')}>
|
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4">
|
||||||
|
<div className={clsx('min-w-0', isDesktopCollapsed && 'md:hidden')}>
|
||||||
|
<p className="truncate text-sm font-bold text-[var(--color-text)]">라이브러리</p>
|
||||||
|
<p className="truncate text-[11px] font-medium text-[var(--color-text-subtle)]">검색과 카테고리</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDesktopCollapsedChange(!isDesktopCollapsed)}
|
onClick={() => onDesktopCollapsedChange(!isDesktopCollapsed)}
|
||||||
className="absolute right-3 top-3 hidden h-8 w-8 items-center justify-center rounded-full border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] md:flex"
|
className="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] md:flex"
|
||||||
aria-label={isDesktopCollapsed ? '사이드바 펼치기' : '사이드바 접기'}
|
aria-label={isDesktopCollapsed ? '사이드바 펼치기' : '사이드바 접기'}
|
||||||
>
|
>
|
||||||
{isDesktopCollapsed ? <ChevronsRight size={16} /> : <ChevronsLeft size={16} />}
|
{isDesktopCollapsed ? <ChevronsRight size={16} /> : <ChevronsLeft size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/" onClick={closeSidebar} className="block transition-opacity hover:opacity-85">
|
<div className={clsx('relative shrink-0', isDesktopCollapsed ? 'px-5 pb-4 pt-5 md:px-3 md:pb-3 md:pt-4' : 'px-5 pb-4 pt-5')}>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
onClick={closeSidebar}
|
||||||
|
className={clsx(
|
||||||
|
'flex min-w-0 items-center gap-3 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 transition hover:bg-[var(--card-bg-strong)]',
|
||||||
|
isDesktopCollapsed && 'md:justify-center md:border-transparent md:bg-transparent md:p-0 md:hover:bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative mx-auto overflow-hidden rounded-2xl bg-black/[0.04] shadow-inner ring-1 ring-[var(--color-line)] transition-all dark:bg-white/10',
|
'relative shrink-0 overflow-hidden rounded-lg bg-black/[0.04] shadow-inner ring-1 ring-[var(--color-line)] transition-all dark:bg-white/10',
|
||||||
isDesktopCollapsed ? 'mb-4 h-20 w-20 md:mb-0 md:mt-8 md:h-12 md:w-12' : 'mb-4 h-20 w-20',
|
isDesktopCollapsed ? 'h-12 w-12' : 'h-12 w-12',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isProfileLoading ? (
|
{isProfileLoading ? (
|
||||||
@@ -217,16 +229,16 @@ function SidebarContent({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={clsx(isDesktopCollapsed && 'md:hidden')}>
|
<div className={clsx('min-w-0 text-left', isDesktopCollapsed && 'md:hidden')}>
|
||||||
{isProfileLoading ? (
|
{isProfileLoading ? (
|
||||||
<div className="flex flex-col items-center space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-6 w-24 animate-pulse rounded bg-black/[0.06] dark:bg-white/10" />
|
<div className="h-6 w-24 animate-pulse rounded bg-black/[0.06] dark:bg-white/10" />
|
||||||
<div className="h-4 w-32 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
<div className="h-4 w-32 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-lg font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
|
<h2 className="truncate text-sm font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
|
||||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
|
<p className="mt-0.5 line-clamp-2 whitespace-pre-line text-xs leading-5 text-[var(--color-text-muted)]">{displayProfile.bio}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -289,42 +301,9 @@ function SidebarContent({
|
|||||||
</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 tracking-normal 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-[var(--card-bg)] hover:text-[var(--color-text)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Crown size={16} />
|
|
||||||
<span>체스</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={clsx('hidden flex-1 flex-col items-center gap-2 overflow-y-auto px-0 pb-4 pt-2 md:flex', !isDesktopCollapsed && 'md:hidden')}>
|
<div className={clsx('hidden flex-1 flex-col items-center gap-2 overflow-y-auto px-0 pb-4 pt-2 md:flex', !isDesktopCollapsed && 'md:hidden')}>
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
title="홈"
|
|
||||||
aria-label="홈"
|
|
||||||
className={clsx(
|
|
||||||
'flex h-11 w-11 items-center justify-center rounded-xl border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]',
|
|
||||||
pathname === '/' && 'bg-[var(--card-bg-strong)] text-[var(--color-accent)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Home size={18} />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="my-1 h-px w-9 bg-[var(--color-line)]" />
|
|
||||||
|
|
||||||
{compactCategoryItems.map((category) => {
|
{compactCategoryItems.map((category) => {
|
||||||
const isActive = decodedPathname === `/category/${category.name}`;
|
const isActive = decodedPathname === `/category/${category.name}`;
|
||||||
|
|
||||||
@@ -355,20 +334,6 @@ function SidebarContent({
|
|||||||
>
|
>
|
||||||
<FileQuestion size={18} />
|
<FileQuestion size={18} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="my-1 h-px w-9 bg-[var(--color-line)]" />
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/play/chess"
|
|
||||||
title="체스"
|
|
||||||
aria-label="체스"
|
|
||||||
className={clsx(
|
|
||||||
'flex h-11 w-11 items-center justify-center rounded-xl border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]',
|
|
||||||
pathname === '/play/chess' && 'bg-[var(--card-bg-strong)] text-[var(--color-accent)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Crown size={18} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { Calendar, ChevronRight, Rows3, SlidersHorizontal } from 'lucide-react';
|
import { Calendar, ChevronRight, FileText, Rows3, SlidersHorizontal } from 'lucide-react';
|
||||||
import EmptyState from '@/components/ui/EmptyState';
|
import EmptyState from '@/components/ui/EmptyState';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
import Surface from '@/components/ui/Surface';
|
import Surface from '@/components/ui/Surface';
|
||||||
@@ -218,6 +218,9 @@ export default function ArchiveExplorer({ posts }: ArchiveExplorerProps) {
|
|||||||
className="group/item block min-w-0"
|
className="group/item block min-w-0"
|
||||||
>
|
>
|
||||||
<Surface interactive className={clsx('flex min-w-0 items-center justify-between gap-3 shadow-none', isCompact ? 'p-3' : 'p-4')}>
|
<Surface interactive className={clsx('flex min-w-0 items-center justify-between gap-3 shadow-none', isCompact ? 'p-3' : 'p-4')}>
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
|
||||||
|
<FileText size={17} />
|
||||||
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="line-clamp-2 break-words text-sm font-semibold text-[var(--color-text)] transition-colors group-hover/item:text-[var(--color-accent)] md:text-base">
|
<h4 className="line-clamp-2 break-words text-sm font-semibold text-[var(--color-text)] transition-colors group-hover/item:text-[var(--color-accent)] md:text-base">
|
||||||
{post.title}
|
{post.title}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
import Surface from '@/components/ui/Surface';
|
import Surface from '@/components/ui/Surface';
|
||||||
import { Post } from '@/types';
|
import { Post } from '@/types';
|
||||||
|
|
||||||
const isNoticePost = (post: Post) => {
|
const isNoticePost = (post: Post) => {
|
||||||
const categoryName = post.categoryName || '';
|
const categoryName = post.categoryName || '';
|
||||||
return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
|
return categoryName === '공지' || categoryName.toLowerCase() === 'notice';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (value: string) => {
|
const formatDate = (value: string) => {
|
||||||
@@ -44,9 +45,14 @@ export default function PostCard({ post }: { post: Post }) {
|
|||||||
className="flex h-full min-h-52 flex-col p-5 shadow-none"
|
className="flex h-full min-h-52 flex-col p-5 shadow-none"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
|
||||||
|
<FileText size={17} />
|
||||||
|
</span>
|
||||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
|
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
|
||||||
{post.categoryName || '미분류'}
|
{post.categoryName || '미분류'}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||||
{formatDate(post.createdAt)}
|
{formatDate(post.createdAt)}
|
||||||
</time>
|
</time>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { AlertCircle, ArrowLeft, Calendar, ChevronLeft, ChevronRight, Loader2, User } from 'lucide-react';
|
import { AlertCircle, ArrowLeft, Calendar, ChevronLeft, ChevronRight, Loader2, Monitor, User } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -91,14 +91,26 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto min-w-0 max-w-[1120px] px-0 py-3 md:py-6">
|
<div className="mx-auto min-w-0 max-w-[1120px] px-0 py-3 md:py-6">
|
||||||
<Link href="/" className="mb-5 inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
|
<Link href="/" className="mb-5 inline-flex items-center gap-1 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] px-3 py-2 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-accent)]">
|
||||||
<ArrowLeft size={18} />
|
<ArrowLeft size={18} />
|
||||||
<span>목록으로</span>
|
<span>Finder로</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="relative grid min-w-0 gap-6 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-8">
|
<div className="relative grid min-w-0 gap-6 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-8">
|
||||||
<main className="min-w-0 space-y-6">
|
<main className="min-w-0 space-y-6">
|
||||||
<Surface as="article" strong className="mx-auto w-full max-w-[820px] px-5 py-7 md:px-10 md:py-10">
|
<WindowSurface
|
||||||
|
as="article"
|
||||||
|
title="Reader"
|
||||||
|
subtitle={post.categoryName || '미분류'}
|
||||||
|
className="mx-auto w-full max-w-[820px]"
|
||||||
|
bodyClassName="px-5 py-7 md:px-10 md:py-10"
|
||||||
|
controls={(
|
||||||
|
<div className="hidden items-center gap-1.5 text-xs font-semibold text-[var(--color-text-subtle)] sm:flex">
|
||||||
|
<Monitor size={14} />
|
||||||
|
<span className="max-w-44 truncate">{post.slug}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
<header className="mb-9 border-b border-[var(--color-line)] pb-7">
|
<header className="mb-9 border-b border-[var(--color-line)] pb-7">
|
||||||
<StatusBadge tone="neutral" className="mb-5">
|
<StatusBadge tone="neutral" className="mb-5">
|
||||||
{post.categoryName || '미분류'}
|
{post.categoryName || '미분류'}
|
||||||
@@ -134,7 +146,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
|||||||
<div className="prose prose-base min-w-0 max-w-none break-words prose-headings:font-bold prose-headings:tracking-normal prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-p:leading-8 prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-li:leading-8 prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 md:prose-lg [overflow-wrap:anywhere]">
|
<div className="prose prose-base min-w-0 max-w-none break-words prose-headings:font-bold prose-headings:tracking-normal prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-p:leading-8 prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-li:leading-8 prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 md:prose-lg [overflow-wrap:anywhere]">
|
||||||
<MarkdownRenderer content={post.content || ''} />
|
<MarkdownRenderer content={post.content || ''} />
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</WindowSurface>
|
||||||
|
|
||||||
<WindowSurface title="Navigation" showTrafficLights={false} className="mx-auto max-w-[820px]" bodyClassName="p-4 md:p-5">
|
<WindowSurface title="Navigation" showTrafficLights={false} className="mx-auto max-w-[820px]" bodyClassName="p-4 md:p-5">
|
||||||
<nav className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
|
<nav className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -184,7 +196,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="hidden w-[220px] shrink-0 xl:block">
|
<aside className="hidden w-[220px] shrink-0 xl:block">
|
||||||
<WindowSurface title="목차" showTrafficLights={false} bodyClassName="p-4" className="sticky top-24 shadow-[var(--shadow-card)]">
|
<WindowSurface title="목차" showTrafficLights={false} bodyClassName="p-4" className="sticky top-16 shadow-[var(--shadow-card)]">
|
||||||
<TOC content={post.content || ''} />
|
<TOC content={post.content || ''} />
|
||||||
</WindowSurface>
|
</WindowSurface>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ChevronRight, Eye } from 'lucide-react';
|
import { ChevronRight, Eye, FileText } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
import { Post } from '@/types';
|
import { Post } from '@/types';
|
||||||
@@ -11,7 +11,7 @@ interface PostListItemProps {
|
|||||||
|
|
||||||
const isNoticePost = (post: Post) => {
|
const isNoticePost = (post: Post) => {
|
||||||
const categoryName = post.categoryName || '';
|
const categoryName = post.categoryName || '';
|
||||||
return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
|
return categoryName === '공지' || categoryName.toLowerCase() === 'notice';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (value: string) => {
|
const formatDate = (value: string) => {
|
||||||
@@ -37,6 +37,9 @@ export default function PostListItem({ post, showViews = false }: PostListItemPr
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<span className="hidden h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)] sm:flex">
|
||||||
|
<FileText size={17} />
|
||||||
|
</span>
|
||||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
|
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
|
||||||
{post.categoryName || '미분류'}
|
{post.categoryName || '미분류'}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function Surface({
|
|||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded-lg border border-[var(--card-border)] backdrop-blur-[18px]',
|
'rounded-lg border border-[var(--card-border)] backdrop-blur-[20px]',
|
||||||
strong
|
strong
|
||||||
? 'bg-[var(--card-bg-strong)] shadow-[var(--shadow-card)]'
|
? 'bg-[var(--card-bg-strong)] shadow-[var(--shadow-card)]'
|
||||||
: 'bg-[var(--card-bg)] shadow-[var(--shadow-card)]',
|
: 'bg-[var(--card-bg)] shadow-[var(--shadow-card)]',
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ export default function WindowSurface({
|
|||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full overflow-hidden rounded-lg border border-[var(--window-border)] bg-[var(--window-bg)] shadow-[var(--shadow-window)] backdrop-blur-[28px]',
|
'window-surface w-full overflow-hidden rounded-lg border border-[var(--window-border)] bg-[var(--window-bg)] shadow-[var(--shadow-window)] backdrop-blur-[30px]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{hasTitlebar && (
|
{hasTitlebar && (
|
||||||
<div className="flex min-h-11 items-center justify-between gap-4 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4 py-2.5">
|
<div className="flex min-h-11 items-center justify-between gap-4 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4 py-2.5 backdrop-blur-[28px]">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
{showTrafficLights && (
|
{showTrafficLights && (
|
||||||
<div className="flex shrink-0 items-center gap-1.5" aria-hidden="true">
|
<div className="flex shrink-0 items-center gap-1.5" aria-hidden="true">
|
||||||
|
|||||||
@@ -60,6 +60,72 @@ export interface ChessPuzzle {
|
|||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChessOutcome = 'IN_PROGRESS' | 'WIN' | 'LOSS' | 'DRAW' | 'UNKNOWN';
|
||||||
|
export type ChessColor = 'white' | 'black';
|
||||||
|
export type MaiaModel = '3m' | '5m' | '23m' | '79m';
|
||||||
|
|
||||||
|
export interface ChessGameCreateRequest {
|
||||||
|
rating?: number;
|
||||||
|
playerColor?: ChessColor;
|
||||||
|
model?: MaiaModel;
|
||||||
|
temperature?: number;
|
||||||
|
topP?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChessGameResponse {
|
||||||
|
gameId: string;
|
||||||
|
rating: number;
|
||||||
|
playerColor: ChessColor;
|
||||||
|
model: MaiaModel;
|
||||||
|
fen: string;
|
||||||
|
turn: ChessColor;
|
||||||
|
moves: string[];
|
||||||
|
status: string;
|
||||||
|
result: string | null;
|
||||||
|
outcome: ChessOutcome;
|
||||||
|
pgn: string;
|
||||||
|
maiaMove: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChessGameSummaryResponse {
|
||||||
|
gameId: string;
|
||||||
|
rating: number;
|
||||||
|
playerColor: ChessColor;
|
||||||
|
model: string;
|
||||||
|
status: string;
|
||||||
|
result: string | null;
|
||||||
|
outcome: ChessOutcome;
|
||||||
|
movesCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChessGameStatsResponse {
|
||||||
|
total: number;
|
||||||
|
inProgress: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
draws: number;
|
||||||
|
unknown: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChessGamePgnResponse {
|
||||||
|
gameId: string;
|
||||||
|
pgn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChessMoveRequest {
|
||||||
|
move: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChessGamePageResponse extends PageMeta {
|
||||||
|
content: ChessGameSummaryResponse[];
|
||||||
|
page?: PageMeta;
|
||||||
|
size?: number;
|
||||||
|
first?: boolean;
|
||||||
|
empty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 로그인 응답
|
// 4. 로그인 응답
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
grantType: string;
|
grantType: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user