From 0b6ce404e11fd525e630a15ff77a0d54c337688d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9B=90=EC=97=BD?= Date: Tue, 2 Jun 2026 14:39:23 +0900 Subject: [PATCH] . --- LOG.md | 2 + src/components/layout/DesktopDock.tsx | 149 +++++++++++++++++++++++--- 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/LOG.md b/LOG.md index c125fdf..2b8f217 100644 --- a/LOG.md +++ b/LOG.md @@ -4,10 +4,12 @@ - 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. +- Smoothed the desktop Dock hide/show interaction by separating hover-open, closing, and fully-collapsed states; the Dock now keeps the expanded hit area during the closing animation so re-entering mid-collapse reverses smoothly. - 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. +- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on a local `next start` server confirmed the desktop Dock starts as a small bottom tab, uses 560ms transitions for tab/panel geometry, and expands into the larger Dock hit area when opened. - 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 diff --git a/src/components/layout/DesktopDock.tsx b/src/components/layout/DesktopDock.tsx index 82588a8..51fd047 100644 --- a/src/components/layout/DesktopDock.tsx +++ b/src/components/layout/DesktopDock.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { type FocusEvent, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { clsx } from 'clsx'; @@ -41,13 +41,61 @@ const isActivePath = (target: string) => (pathname: string) => { return pathname.startsWith(target); }; +const DOCK_LEAVE_DELAY_MS = 120; +const DOCK_COLLAPSE_MS = 560; + 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 [isDockHovered, setIsDockHovered] = useState(false); + const [isDockFocused, setIsDockFocused] = useState(false); + const [isDockClosing, setIsDockClosing] = useState(false); + const dockZoneRef = useRef(null); + const dockLeaveTimerRef = useRef(null); + const dockCollapseTimerRef = useRef(null); const isAdmin = _hasHydrated && isLoggedIn && role?.includes('ADMIN'); + const isDockOpen = isPinned || isDockHovered || isDockFocused; + const isDockRangeExpanded = isDockOpen || isDockClosing; + + const clearDockTimers = () => { + if (dockLeaveTimerRef.current !== null) { + window.clearTimeout(dockLeaveTimerRef.current); + dockLeaveTimerRef.current = null; + } + + if (dockCollapseTimerRef.current !== null) { + window.clearTimeout(dockCollapseTimerRef.current); + dockCollapseTimerRef.current = null; + } + }; + + const expandDock = () => { + clearDockTimers(); + setIsDockClosing(false); + setIsDockHovered(true); + }; + + const startDockCollapse = () => { + clearDockTimers(); + setIsDockHovered(false); + setIsDockClosing(true); + dockCollapseTimerRef.current = window.setTimeout(() => { + setIsDockClosing(false); + dockCollapseTimerRef.current = null; + }, DOCK_COLLAPSE_MS); + }; + + const scheduleDockCollapse = () => { + if (isPinned || isDockFocused) return; + + clearDockTimers(); + dockLeaveTimerRef.current = window.setTimeout(() => { + startDockCollapse(); + }, DOCK_LEAVE_DELAY_MS); + }; useEffect(() => { const frameId = window.requestAnimationFrame(() => { @@ -65,14 +113,60 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De return () => window.cancelAnimationFrame(frameId); }, [pathname]); + useEffect(() => { + return () => clearDockTimers(); + }, []); + const handlePinnedChange = () => { setIsPinned((previous) => { const nextValue = !previous; window.localStorage.setItem('dock-pinned', String(nextValue)); + clearDockTimers(); + setIsDockClosing(false); + setIsDockHovered(!nextValue); return nextValue; }); }; + const handleDockZoneFocus = () => { + clearDockTimers(); + setIsDockClosing(false); + setIsDockFocused(true); + }; + + const handleDockHandleFocus = () => { + handleDockZoneFocus(); + + window.requestAnimationFrame(() => { + dockZoneRef.current + ?.querySelector('[data-dock-panel="true"] a, [data-dock-panel="true"] button') + ?.focus(); + }); + }; + + const handleDockBlur = (event: FocusEvent) => { + const nextTarget = event.relatedTarget; + + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + + if (!nextTarget) { + window.setTimeout(() => { + if (dockZoneRef.current?.contains(document.activeElement)) return; + + setIsDockFocused(false); + if (!isPinned && !isDockHovered) { + startDockCollapse(); + } + }, 80); + return; + } + + setIsDockFocused(false); + if (!isPinned && !isDockHovered) { + startDockCollapse(); + } + }; + const handleLogout = () => { if (confirm('로그아웃 하시겠습니까?')) { logout(); @@ -257,28 +351,54 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De