diff --git a/LOG.md b/LOG.md index 113bfcf..c125fdf 100644 --- a/LOG.md +++ b/LOG.md @@ -1,5 +1,15 @@ # Work Log +## 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. +- Reworked the Dock so mobile uses it as the primary navigation hub, while desktop keeps a folded Dock that expands on hover/focus and includes a pin toggle. +- 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. +- Rebuilt the chess page with safer viewport-aware board sizing, quieter puzzle panels, and enough layout clearance for the Dock. +- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on a local `next start` server confirmed no horizontal overflow on `/`, `/archive`, and `/play/chess` at mobile width, mobile Dock-centered navigation opens the sidebar panel without overflow, desktop CSS loads, the mobile Dock is hidden on desktop, and the desktop Dock starts folded with a pin control. The live chess board itself could not be verified because the local backend chess API was unavailable, so `/play/chess` rendered the error state. +- 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 - Fixed post detail 404s caused by double-encoding dynamic route slugs that already arrive percent-encoded from URLs such as `/posts/soft-delete%2C-hard-delete`. diff --git a/src/app/archive/page.tsx b/src/app/archive/page.tsx index f86563d..78aaca9 100644 --- a/src/app/archive/page.tsx +++ b/src/app/archive/page.tsx @@ -1,15 +1,10 @@ import type { Metadata } from 'next'; -import Link from 'next/link'; -import { Archive, Calendar, ChevronRight, FileText } from 'lucide-react'; -import { - fetchPublicPosts, -} from '@/api/publicPosts'; -import EmptyState from '@/components/ui/EmptyState'; -import StatusBadge from '@/components/ui/StatusBadge'; -import Surface from '@/components/ui/Surface'; +import { Archive } from 'lucide-react'; +import { fetchPublicPosts } from '@/api/publicPosts'; +import ArchiveExplorer from '@/components/post/ArchiveExplorer'; import WindowSurface from '@/components/ui/WindowSurface'; import { SITE_NAME } from '@/lib/site'; -import { Post, PostListResponse } from '@/types'; +import { PostListResponse } from '@/types'; export const dynamic = 'force-dynamic'; export const revalidate = 300; @@ -31,133 +26,29 @@ const getTotalElements = (data?: PostListResponse | null) => { return data?.page?.totalElements ?? data?.totalElements ?? 0; }; -const formatDate = (value: string) => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ''; - - return new Intl.DateTimeFormat('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(date); -}; - -const groupPostsByMonth = (posts: Post[]) => { - const groups: Record> = {}; - - posts.forEach((post) => { - const date = new Date(post.createdAt); - if (Number.isNaN(date.getTime())) return; - - const year = date.getFullYear().toString(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - - groups[year] ??= {}; - groups[year][month] ??= []; - groups[year][month].push(post); - }); - - return groups; -}; - -const sortPostsNewestFirst = (posts: Post[]) => { - return [...posts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); -}; - export default async function ArchivePage() { const data = await fetchPublicPosts({ page: 0, size: 1000, sort: 'createdAt,desc' }); const posts = data?.content || []; - const archiveGroups = groupPostsByMonth(posts); - const sortedYears = Object.keys(archiveGroups).sort((a, b) => Number(b) - Number(a)); const totalPosts = getTotalElements(data); return ( -
+
-
-

- - 아카이브 +
+

+ + 아카이브

-

- 지금까지 작성한 {totalPosts}개의 글을 시간순으로 정리했습니다. +

+ 지금까지 작성한 {totalPosts.toLocaleString()}개의 글을 기록 순서로 정리했습니다.

- {sortedYears.length > 0 ? ( -
-
- - {sortedYears.map((year) => { - const months = archiveGroups[year]; - const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a)); - - return ( -
-
-
-
-
-

{year}

-
- -
- {sortedMonths.map((month) => { - const monthPosts = months[month]; - const sortedPosts = sortPostsNewestFirst(monthPosts); - - return ( -
-

- - {month}월 - {monthPosts.length} -

- -
- {sortedPosts.map((post) => ( - - -
-

- {post.title} -

-
- {post.categoryName || '미분류'} - - - {formatDate(post.createdAt)} - -
-
- -
- - ))} -
-
- ); - })} -
-
- ); - })} -
- ) : ( - } - className="py-20" - /> - )} +

); diff --git a/src/app/globals.css b/src/app/globals.css index 2143590..13ea5b3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -124,9 +124,13 @@ html[data-theme="dark"] { color-scheme: dark; } +html { + overflow-x: hidden; +} + body { min-height: 100vh; - overflow-x: hidden; + overflow-x: clip; background: var(--desktop-bg); background-attachment: fixed; color: var(--foreground); @@ -141,6 +145,12 @@ body { text-rendering: optimizeLegibility; } +pre, +code, +table { + max-width: 100%; +} + ::selection { background: var(--color-accent-soft); color: var(--color-text); diff --git a/src/app/page.tsx b/src/app/page.tsx index 32963dd..2a2c38b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,9 +8,7 @@ import { Search, TrendingUp, } from 'lucide-react'; -import { - fetchPublicPosts, -} from '@/api/publicPosts'; +import { fetchPublicPosts } from '@/api/publicPosts'; import EmptyState from '@/components/ui/EmptyState'; import StatusBadge from '@/components/ui/StatusBadge'; import Surface from '@/components/ui/Surface'; @@ -100,17 +98,17 @@ function SearchResults({ -
-
- -

+
+
+ +

검색 결과

-

+

검색어 {keyword}에 대한 글 {searchTotalElements.toLocaleString()}건

@@ -134,19 +132,19 @@ function NoticeStrip({ notices }: { notices: Post[] }) { return (
{notices.slice(0, 3).map((notice) => ( - +
- 공지 - + 공지 + {notice.title}
- +
@@ -160,17 +158,22 @@ function CompactPostRow({ post, rank, showViews = false, + featured = false, }: { post: Post; rank?: number; showViews?: boolean; + featured?: boolean; }) { - const summary = getSummary(post.content, 92); + const summary = getSummary(post.content, featured ? 150 : 92); return ( {rank !== undefined && ( @@ -178,24 +181,24 @@ function CompactPostRow({ )}
-
+
{post.categoryName || '미분류'} -
-

+

{post.title}

{summary && ( -

+

{summary}

)} @@ -205,20 +208,26 @@ function CompactPostRow({ ); } +function clsxSafe(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + function PostListPanel({ title, icon, posts, isPopular = false, + featured = false, }: { title: string; icon: ReactNode; posts: Post[]; isPopular?: boolean; + featured?: boolean; }) { return ( - -
+ +

{icon} {title} @@ -229,7 +238,7 @@ function PostListPanel({

-
+
{posts.length > 0 ? (
{posts.map((post, index) => ( @@ -238,6 +247,7 @@ function PostListPanel({ post={post} rank={isPopular ? index + 1 : undefined} showViews={isPopular} + featured={featured} /> ))}
@@ -256,7 +266,7 @@ export default async function Home({ searchParams }: HomePageProps) { const searchData = await fetchPublicPosts({ keyword, size: 20 }); return ( -
+
); @@ -264,25 +274,25 @@ export default async function Home({ searchParams }: HomePageProps) { const [noticesData, latestData, popularData] = await Promise.all([ fetchPublicPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }), - fetchPublicPosts({ size: 8, sort: 'createdAt,desc' }), + fetchPublicPosts({ size: 10, sort: 'createdAt,desc' }), fetchPublicPosts({ size: 8, sort: 'viewCount,desc' }), ]); const notices = noticesData?.content || []; - const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5); + const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 7); const popularList = (popularData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5); return ( -
+

{SITE_NAME}

- 전체 글 + 전체 글 )} @@ -290,17 +300,18 @@ export default async function Home({ searchParams }: HomePageProps) { > -
- } - posts={popularList} - isPopular - /> +
} + icon={} posts={latestList} + featured + /> + } + posts={popularList} + isPopular />
diff --git a/src/app/play/chess/page.tsx b/src/app/play/chess/page.tsx index 58fa27f..422b035 100644 --- a/src/app/play/chess/page.tsx +++ b/src/app/play/chess/page.tsx @@ -3,7 +3,7 @@ import ChessPuzzleClient from '@/components/chess/ChessPuzzleClient'; export const metadata: Metadata = { title: '오늘의 체스 퍼즐 | WYPark Blog', - description: '오늘의 한 수 메이트 체스 퍼즐', + description: '오늘의 메이트 체스 퍼즐', }; export default function ChessPuzzlePage() { diff --git a/src/components/chess/ChessPuzzleClient.tsx b/src/components/chess/ChessPuzzleClient.tsx index 842356c..1844d68 100644 --- a/src/components/chess/ChessPuzzleClient.tsx +++ b/src/components/chess/ChessPuzzleClient.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { CSSProperties, ReactNode } from 'react'; import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Chess as ChessGame, type Color, type Move, type PieceSymbol, type Square } from 'chess.js'; @@ -23,6 +24,9 @@ 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 = { + width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))', +}; const PIECE_SYMBOLS: Record> = { w: { @@ -59,7 +63,7 @@ const getReadyFeedback = (puzzle: ChessPuzzle): { tone: FeedbackTone; message: s return { tone: 'neutral', - message: `${turnLabel(game.turn())} 차례 · 체크메이트 한 수`, + message: `${turnLabel(game.turn())} 차례. 체크메이트를 찾으세요.`, }; }; @@ -85,27 +89,45 @@ const formatDate = (value: string) => { function PageHeader() { return ( -
-
- -

+
+
+ +

오늘의 체스 퍼즐

-

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

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

); } +function ChessPageFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function BoardWindow({ children }: { children: ReactNode }) { + return ( + +
+ {children} +
+
+ ); +} + function LoadingState() { return ( -
+ -
- -
+
+ +
{BOARD_SQUARES.map((square, index) => (
))}
- - + +

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

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

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

-

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

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

+

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

-
+ ); } @@ -212,7 +234,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { setLastMoveSquares(null); setFeedback({ tone: 'error', - message: `${move.san}는 아직 메이트가 아닙니다.`, + message: `${move.san}은 아직 메이트가 아닙니다.`, }); } catch { setFeedback({ @@ -263,12 +285,12 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { }; return ( -
+ -
- -
+
+ +
{BOARD_SQUARES.map((square, index) => { const piece = game.get(square); const file = square[0]; @@ -286,7 +308,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { type="button" onClick={() => handleSquareClick(square)} className={clsx( - 'relative flex aspect-square items-center justify-center overflow-hidden text-[clamp(1.75rem,8vw,4.8rem)] leading-none transition', + '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)]', @@ -339,25 +361,25 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { ); })}
- +
- -
-
-

+ +

+
+

{formatDate(puzzle.date)}

-

+

{puzzle.title}

-

{puzzle.theme}

+

{puzzle.theme}

{solved && }
-
-
난이도
-
{puzzle.rating}
+
+
레이팅
+
{puzzle.rating}
-
+
정답
-
+
{solved ? puzzle.answer : '숨김'}
@@ -404,14 +426,14 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { href={puzzle.sourceUrl} target="_blank" rel="noreferrer" - className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-accent)]" + className="mt-4 inline-flex max-w-full items-center gap-1.5 break-words text-xs font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-accent)]" > - Lichess 원문 - + Lichess 원문 +
-
+ ); } diff --git a/src/components/layout/DesktopDock.tsx b/src/components/layout/DesktopDock.tsx index 0c0c4a6..82588a8 100644 --- a/src/components/layout/DesktopDock.tsx +++ b/src/components/layout/DesktopDock.tsx @@ -1,63 +1,114 @@ 'use client'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { clsx } from 'clsx'; -import { Archive, Crown, Home, LogIn, LogOut, PenLine, Settings, UserPlus } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { + Archive, + Crown, + Home, + LogIn, + LogOut, + Menu, + MoreHorizontal, + PenLine, + Pin, + PinOff, + Settings, + UserPlus, +} from 'lucide-react'; +import ThemeToggle from '@/components/theme/ThemeToggle'; import { useAuthStore } from '@/store/authStore'; interface DesktopDockProps { isSidebarCollapsed: boolean; + onOpenMobileMenu: () => void; } -type DockItem = { +type DockAction = { key: string; href?: string; label: string; - icon: typeof Home; + icon: LucideIcon; isActive?: (pathname: string) => boolean; onClick?: () => void; }; -export default function DesktopDock({ isSidebarCollapsed }: DesktopDockProps) { +const isActivePath = (target: string) => (pathname: string) => { + if (target === '/') return pathname === '/'; + return pathname.startsWith(target); +}; + +export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: DesktopDockProps) { const pathname = usePathname(); const router = useRouter(); const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore(); + const [isPinned, setIsPinned] = useState(false); + const [isMoreOpen, setIsMoreOpen] = useState(false); const isAdmin = _hasHydrated && isLoggedIn && role?.includes('ADMIN'); + useEffect(() => { + const frameId = window.requestAnimationFrame(() => { + setIsPinned(window.localStorage.getItem('dock-pinned') === 'true'); + }); + + return () => window.cancelAnimationFrame(frameId); + }, []); + + useEffect(() => { + const frameId = window.requestAnimationFrame(() => { + setIsMoreOpen(false); + }); + + return () => window.cancelAnimationFrame(frameId); + }, [pathname]); + + const handlePinnedChange = () => { + setIsPinned((previous) => { + const nextValue = !previous; + window.localStorage.setItem('dock-pinned', String(nextValue)); + return nextValue; + }); + }; + const handleLogout = () => { if (confirm('로그아웃 하시겠습니까?')) { logout(); + setIsMoreOpen(false); router.push('/'); } }; - const items: DockItem[] = [ + const baseItems: DockAction[] = [ { key: 'home', href: '/', label: '홈', icon: Home, - isActive: (currentPath) => currentPath === '/', + isActive: isActivePath('/'), }, { key: 'archive', href: '/archive', label: '아카이브', icon: Archive, - isActive: (currentPath) => currentPath.startsWith('/archive'), + isActive: isActivePath('/archive'), }, { key: 'chess', href: '/play/chess', label: '체스', icon: Crown, - isActive: (currentPath) => currentPath.startsWith('/play/chess'), + isActive: isActivePath('/play/chess'), }, ]; + const authItems: DockAction[] = []; + if (_hasHydrated && isAdmin) { - items.push( + authItems.push( { key: 'admin', href: '/admin', @@ -70,7 +121,7 @@ export default function DesktopDock({ isSidebarCollapsed }: DesktopDockProps) { href: '/admin/posts/new', label: '글쓰기', icon: PenLine, - isActive: (currentPath) => currentPath.startsWith('/admin/posts/new'), + isActive: isActivePath('/admin/posts/new'), }, { key: 'logout', @@ -80,43 +131,45 @@ export default function DesktopDock({ isSidebarCollapsed }: DesktopDockProps) { }, ); } else if (_hasHydrated && isLoggedIn) { - items.push({ + authItems.push({ key: 'logout', label: '로그아웃', icon: LogOut, onClick: handleLogout, }); } else if (_hasHydrated) { - items.push( + authItems.push( { key: 'login', href: '/login', label: '로그인', icon: LogIn, - isActive: (currentPath) => currentPath.startsWith('/login'), + isActive: isActivePath('/login'), }, { key: 'signup', href: '/signup', label: '회원가입', icon: UserPlus, - isActive: (currentPath) => currentPath.startsWith('/signup'), + isActive: isActivePath('/signup'), }, ); } - const renderItem = (item: DockItem) => { + const desktopItems = [...baseItems, ...authItems]; + + const renderDesktopItem = (item: DockAction) => { const Icon = item.icon; const active = item.isActive?.(pathname) ?? false; const itemClass = clsx( - 'group relative flex h-10 w-10 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 duration-150 hover:-translate-y-1 hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] focus-visible:-translate-y-1 sm:h-12 sm:w-12', + 'group/item relative flex h-12 w-12 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 duration-150 hover:-translate-y-1 hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] focus-visible:-translate-y-1', active && 'bg-[var(--card-bg-strong)] text-[var(--color-accent)] ring-1 ring-[var(--color-accent-soft)]', ); const content = ( <> - + {item.label} {active && ( @@ -154,17 +207,169 @@ export default function DesktopDock({ isSidebarCollapsed }: DesktopDockProps) { ); }; + const renderMobileItem = (item: DockAction, activeOverride?: boolean) => { + const Icon = item.icon; + const active = activeOverride ?? item.isActive?.(pathname) ?? false; + const itemClass = clsx( + 'flex h-12 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-lg border border-transparent px-1 text-[10px] font-semibold leading-none text-[var(--color-text-subtle)] transition-colors', + 'hover:bg-[var(--card-bg)] hover:text-[var(--color-text)] focus-visible:bg-[var(--card-bg-strong)]', + active && 'border-[var(--control-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]', + ); + const content = ( + <> + + {item.label} + + ); + + if (item.href) { + return ( + setIsMoreOpen(false)} + > + {content} + + ); + } + + return ( + + ); + }; + return ( - + + + ); } diff --git a/src/components/layout/DesktopMenuBar.tsx b/src/components/layout/DesktopMenuBar.tsx index 90f9faa..00de251 100644 --- a/src/components/layout/DesktopMenuBar.tsx +++ b/src/components/layout/DesktopMenuBar.tsx @@ -54,7 +54,7 @@ export default function DesktopMenuBar({ isSidebarCollapsed }: DesktopMenuBarPro return (