'use client'; import { type CSSProperties, type FocusEvent, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { clsx } from 'clsx'; 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 DockAction = { key: string; href?: string; label: string; icon: LucideIcon; isActive?: (pathname: string) => boolean; onClick?: () => void; }; const isActivePath = (target: string) => (pathname: string) => { if (target === '/') return pathname === '/'; return pathname.startsWith(target); }; const DOCK_LEAVE_DELAY_MS = 120; const DOCK_COLLAPSE_MS = 560; const DOCK_PINNED_STORAGE_KEY = 'dock-pinned-v2'; type DockToneStyle = CSSProperties & { '--dock-item-bg': string; '--dock-item-bg-hover': string; '--dock-item-bg-active': string; '--dock-item-border': string; '--dock-item-border-hover': string; '--dock-item-fg': string; '--dock-item-fg-strong': string; '--dock-item-ring': string; }; const createDockTone = ( bg: string, hover: string, active: string, border: string, borderHover: string, fg: string, fgStrong: string, ring: string, ): DockToneStyle => ({ '--dock-item-bg': bg, '--dock-item-bg-hover': hover, '--dock-item-bg-active': active, '--dock-item-border': border, '--dock-item-border-hover': borderHover, '--dock-item-fg': fg, '--dock-item-fg-strong': fgStrong, '--dock-item-ring': ring, }); const dockGlassTone = createDockTone( 'rgba(255, 255, 255, 0.24)', 'rgba(255, 255, 255, 0.36)', 'rgba(255, 255, 255, 0.46)', 'rgba(255, 255, 255, 0.2)', 'rgba(255, 255, 255, 0.34)', 'var(--color-text-muted)', 'var(--color-text)', 'rgba(255, 255, 255, 0.34)', ); const homeDockTone = createDockTone( 'rgba(255, 255, 255, 0.58)', 'rgba(255, 255, 255, 0.68)', 'rgba(255, 255, 255, 0.76)', 'rgba(255, 255, 255, 0.28)', 'rgba(255, 255, 255, 0.46)', 'var(--color-text-muted)', 'var(--color-text)', 'rgba(255, 255, 255, 0.42)', ); const dockToneStyles: Record = { home: homeDockTone, 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: 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: 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: 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: 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: 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: 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: 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: 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: 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, }; const getDockToneStyle = (key: string) => dockToneStyles[key] ?? dockToneStyles.default; export default function DesktopDock({ isSidebarCollapsed, onOpenMobileMenu }: DesktopDockProps) { const pathname = usePathname(); const router = useRouter(); const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore(); const [isPinned, setIsPinned] = useState(true); 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 pinToggleStartedByPointerRef = useRef(false); 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(() => { const savedValue = window.localStorage.getItem(DOCK_PINNED_STORAGE_KEY); setIsPinned(savedValue === null ? true : savedValue === 'true'); }); return () => window.cancelAnimationFrame(frameId); }, []); useEffect(() => { const frameId = window.requestAnimationFrame(() => { setIsMoreOpen(false); }); return () => window.cancelAnimationFrame(frameId); }, [pathname]); useEffect(() => { return () => clearDockTimers(); }, []); const handlePinnedChange = () => { const nextValue = !isPinned; const startedByPointer = pinToggleStartedByPointerRef.current; const isPointerInsideDock = dockZoneRef.current?.matches(':hover') ?? false; pinToggleStartedByPointerRef.current = false; window.localStorage.setItem(DOCK_PINNED_STORAGE_KEY, String(nextValue)); clearDockTimers(); setIsPinned(nextValue); if (nextValue) { setIsDockClosing(false); setIsDockHovered(false); return; } if (startedByPointer) { setIsDockFocused(false); if ( document.activeElement instanceof HTMLElement && dockZoneRef.current?.contains(document.activeElement) ) { document.activeElement.blur(); } } if (isPointerInsideDock) { setIsDockClosing(false); setIsDockHovered(true); return; } if (!isDockFocused || startedByPointer) { startDockCollapse(); return; } setIsDockHovered(false); setIsDockClosing(false); }; 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(); setIsMoreOpen(false); router.push('/'); } }; const baseItems: DockAction[] = [ { key: 'home', href: '/', label: '홈', icon: Home, isActive: isActivePath('/'), }, { key: 'archive', href: '/archive', label: '아카이브', icon: Archive, isActive: isActivePath('/archive'), }, { key: 'chess', href: '/play/chess', label: '체스', icon: Crown, isActive: isActivePath('/play/chess'), }, ]; const authItems: DockAction[] = []; if (_hasHydrated && isAdmin) { authItems.push( { key: 'admin', href: '/admin', label: '관리자', icon: Settings, isActive: (currentPath) => currentPath === '/admin', }, { key: 'write', href: '/admin/posts/new', label: '글쓰기', icon: PenLine, isActive: isActivePath('/admin/posts/new'), }, { key: 'logout', label: '로그아웃', icon: LogOut, onClick: handleLogout, }, ); } else if (_hasHydrated && isLoggedIn) { authItems.push({ key: 'logout', label: '로그아웃', icon: LogOut, onClick: handleLogout, }); } else if (_hasHydrated) { authItems.push( { key: 'login', href: '/login', label: '로그인', icon: LogIn, isActive: isActivePath('/login'), }, { key: 'signup', href: '/signup', label: '회원가입', icon: UserPlus, isActive: isActivePath('/signup'), }, ); } const desktopItems = [...baseItems, ...authItems]; const renderDesktopItem = (item: DockAction) => { const Icon = item.icon; const active = item.isActive?.(pathname) ?? false; const toneStyle = getDockToneStyle(item.key); 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)] 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)]', ); const content = ( <> {item.label} {active && ( )} ); if (item.href) { return ( {content} ); } return ( ); }; const renderMobileItem = (item: DockAction, activeOverride?: boolean) => { const Icon = item.icon; const active = activeOverride ?? item.isActive?.(pathname) ?? false; const toneStyle = getDockToneStyle(item.key); 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)] 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)]', 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)]', ); const content = ( <> {item.label} ); if (item.href) { return ( setIsMoreOpen(false)} > {content} ); } return ( ); }; return ( <> {isMoreOpen && (
설정
{authItems.map((item) => { const Icon = item.icon; const active = item.isActive?.(pathname) ?? false; const toneStyle = getDockToneStyle(item.key); 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)] 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)]', ); if (item.href) { return ( setIsMoreOpen(false)} > {item.label} ); } return ( ); })}
)} ); }