.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m11s

This commit is contained in:
박원엽
2026-06-02 14:39:23 +09:00
parent e3654a7bd5
commit 0b6ce404e1
2 changed files with 137 additions and 14 deletions

2
LOG.md
View File

@@ -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

View File

@@ -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<HTMLDivElement>(null);
const dockLeaveTimerRef = useRef<number | null>(null);
const dockCollapseTimerRef = useRef<number | null>(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<HTMLElement>('[data-dock-panel="true"] a, [data-dock-panel="true"] button')
?.focus();
});
};
const handleDockBlur = (event: FocusEvent<HTMLDivElement>) => {
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,26 +351,52 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
<nav
aria-label="빠른 실행 Dock"
className={clsx(
'group fixed bottom-2 left-1/2 z-40 hidden -translate-x-1/2 md:bottom-4 md:block',
'fixed bottom-0 left-1/2 z-40 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
ref={dockZoneRef}
onMouseEnter={expandDock}
onMouseLeave={scheduleDockCollapse}
onFocus={handleDockZoneFocus}
onBlur={handleDockBlur}
className={clsx(
'pointer-events-none absolute left-1/2 top-0 -translate-x-1/2 -translate-y-2 transition-opacity',
isPinned && 'opacity-0',
'relative flex items-end justify-center transition-[width,height,padding] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
isDockRangeExpanded
? 'h-32 w-[min(calc(100vw-2rem),56rem)] px-8 pb-7 pt-12'
: 'h-12 w-16 px-0 pb-0 pt-0',
)}
aria-hidden="true"
>
<span className="block h-1.5 w-10 rounded-full border border-[var(--dock-border)] bg-[var(--dock-bg)] shadow-[var(--shadow-control)] backdrop-blur-[18px]" />
</div>
<button
type="button"
aria-label="Dock 열기"
tabIndex={isDockRangeExpanded ? -1 : 0}
onMouseEnter={expandDock}
onFocus={handleDockHandleFocus}
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-border)] bg-[var(--dock-bg)] pt-1.5 text-[var(--color-text-subtle)] shadow-[var(--shadow-control)] backdrop-blur-[22px]',
'transition-[transform,opacity,width,height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:text-[var(--color-text)] focus-visible:text-[var(--color-text)]',
isDockRangeExpanded
? 'pointer-events-none translate-y-5 scale-75 opacity-0'
: 'pointer-events-auto translate-y-0 scale-100 opacity-100 hover:h-10 hover:w-14',
)}
>
<MoreHorizontal size={18} strokeWidth={2.3} />
</button>
<div
data-dock-panel="true"
className={clsx(
'flex max-w-[calc(100vw-2rem)] 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]',
'transition-[transform,opacity] duration-300 ease-out',
!isPinned && 'md:translate-y-[calc(100%-0.875rem)] md:opacity-80 md:hover:translate-y-0 md:hover:opacity-100 md:focus-within:translate-y-0 md:focus-within:opacity-100',
'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]',
'transition-[transform,opacity,filter] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
isDockOpen
? 'pointer-events-auto translate-y-0 scale-x-100 scale-y-100 opacity-100 blur-0'
: clsx(
'translate-y-8 scale-x-[0.16] scale-y-[0.22] opacity-0 blur-sm',
isDockClosing ? 'pointer-events-auto' : 'pointer-events-none',
),
)}
>
{desktopItems.map(renderDesktopItem)}
@@ -297,6 +417,7 @@ export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: De
</span>
</button>
</div>
</div>
</nav>
{isMoreOpen && (