- 사용자 페이지 (/modpack): GitHub Release 스타일 UI - 버전별 카드, 변경 로그/콘텐츠 접이식 표시 - 애니메이션 적용 (AnimatePresence) - 관리자 콘솔: 모드팩 탭 추가 - 목록 조회, 업로드/수정 다이얼로그 - 모바일/데스크톱 분기 처리 - 모바일 바텀 네비게이션 - Sidebar: 모드팩 메뉴 추가 (월드맵 아래)
603 lines
23 KiB
JavaScript
603 lines
23 KiB
JavaScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom';
|
|
import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2, Package } from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { io } from 'socket.io-client';
|
|
|
|
// 사이드바 네비게이션 컴포넌트
|
|
const Sidebar = ({ isMobile = false }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
|
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
|
const [minecraftLink, setMinecraftLink] = useState(null);
|
|
const [serverOnline, setServerOnline] = useState(false);
|
|
const [toast, setToast] = useState(null);
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { isLoggedIn, isAdmin, user, logout, checkAuth } = useAuth();
|
|
const profileMenuRef = useRef(null);
|
|
|
|
const menuItems = [
|
|
{ path: '/', icon: Home, label: '홈' },
|
|
{ path: '/players', icon: Users, label: '플레이어', matchPaths: ['/players', '/player/'] },
|
|
{ path: '/worlds', icon: Globe, label: '월드 정보' },
|
|
{ path: '/worldmap', icon: Map, label: '월드맵' },
|
|
{ path: '/modpack', icon: Package, label: '모드팩' },
|
|
];
|
|
|
|
// 커스텀 활성 상태 확인 함수
|
|
const isMenuActive = (item) => {
|
|
if (item.matchPaths) {
|
|
return item.matchPaths.some(path => location.pathname.startsWith(path));
|
|
}
|
|
return location.pathname === item.path;
|
|
};
|
|
|
|
// 프로필 메뉴 외부 클릭 시 닫기
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (profileMenuRef.current && !profileMenuRef.current.contains(e.target)) {
|
|
setShowProfileMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// 연동 상태 확인
|
|
useEffect(() => {
|
|
if (!isLoggedIn) {
|
|
setMinecraftLink(null);
|
|
return;
|
|
}
|
|
|
|
const fetchLinkStatus = async () => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await fetch('/link/status', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await res.json();
|
|
if (data.linked) {
|
|
setMinecraftLink(data);
|
|
}
|
|
} catch (error) {
|
|
// 에러 무시
|
|
}
|
|
};
|
|
|
|
fetchLinkStatus();
|
|
}, [isLoggedIn, user?.id]);
|
|
|
|
// 서버 상태 확인 및 닉네임 동기화 (socket.io)
|
|
// useRef로 최신 값을 참조하여 의존성 루프 방지
|
|
const userNameRef = useRef(user?.name);
|
|
const checkAuthRef = useRef(checkAuth);
|
|
const minecraftUuidRef = useRef(minecraftLink?.minecraftUuid);
|
|
const lastSyncedServerNameRef = useRef(null); // 마지막으로 확인한 서버 닉네임
|
|
|
|
useEffect(() => {
|
|
userNameRef.current = user?.name;
|
|
}, [user?.name]);
|
|
|
|
useEffect(() => {
|
|
checkAuthRef.current = checkAuth;
|
|
}, [checkAuth]);
|
|
|
|
useEffect(() => {
|
|
minecraftUuidRef.current = minecraftLink?.minecraftUuid;
|
|
}, [minecraftLink?.minecraftUuid]);
|
|
|
|
useEffect(() => {
|
|
const socket = io(window.location.origin, { path: '/socket.io' });
|
|
let isSyncing = false;
|
|
|
|
// 서버 상태만 업데이트
|
|
socket.on('status', (status) => {
|
|
setServerOnline(status?.online || false);
|
|
});
|
|
|
|
// 닉네임 동기화는 players 이벤트에서 처리 (displayName 포함)
|
|
socket.on('players', async (playersList) => {
|
|
const currentUuid = minecraftUuidRef.current;
|
|
if (!currentUuid || !playersList) return;
|
|
if (isSyncing) return;
|
|
|
|
const playerInGame = playersList.find(p => p.uuid === currentUuid);
|
|
// displayName이 있으면 displayName 사용, 없으면 name 사용
|
|
const serverName = playerInGame?.displayName || playerInGame?.name;
|
|
|
|
// 서버 닉네임이 변경되었고, 아직 동기화하지 않은 경우에만 실행
|
|
if (playerInGame && serverName &&
|
|
serverName !== lastSyncedServerNameRef.current &&
|
|
serverName !== userNameRef.current) {
|
|
isSyncing = true;
|
|
lastSyncedServerNameRef.current = serverName; // 동기화 시도한 이름 저장
|
|
try {
|
|
// /link/status 호출하여 DB 업데이트 트리거
|
|
const token = localStorage.getItem('token');
|
|
await fetch('/link/status', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
// user 상태 갱신
|
|
await checkAuthRef.current();
|
|
} catch (error) {
|
|
// 에러 무시
|
|
} finally {
|
|
isSyncing = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
return () => socket.disconnect();
|
|
}, []); // 의존성 없음 - ref로 최신 값 참조
|
|
|
|
// 토스트 자동 숨기기
|
|
useEffect(() => {
|
|
if (toast) {
|
|
const timer = setTimeout(() => setToast(null), 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [toast]);
|
|
|
|
// 통계 페이지 이동 핸들러
|
|
const handleStatsClick = () => {
|
|
if (!serverOnline) {
|
|
setToast('서버가 오프라인입니다.');
|
|
setShowProfileMenu(false);
|
|
return;
|
|
}
|
|
setShowProfileMenu(false);
|
|
navigate(`/player/${minecraftLink.minecraftUuid}/stats`);
|
|
};
|
|
|
|
// 로그아웃 핸들러
|
|
const handleLogout = () => {
|
|
setShowProfileMenu(false);
|
|
setShowLogoutDialog(true);
|
|
};
|
|
|
|
const confirmLogout = () => {
|
|
logout();
|
|
setShowLogoutDialog(false);
|
|
navigate('/');
|
|
};
|
|
|
|
// 프로필 메뉴 컴포넌트
|
|
const ProfileMenu = () => (
|
|
<AnimatePresence>
|
|
{showProfileMenu && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="absolute bottom-full left-0 right-0 mb-2 bg-zinc-800 border border-zinc-700 rounded-xl shadow-xl overflow-hidden z-50"
|
|
>
|
|
{/* 프로필 정보 */}
|
|
<div className="p-4 bg-zinc-800/80 border-b border-zinc-700">
|
|
<div className="flex items-center gap-3">
|
|
<img
|
|
src={user?.profileUrl || '/default-profile.png'}
|
|
alt="프로필"
|
|
className="w-12 h-12 rounded-full object-cover border-2 border-mc-green"
|
|
onError={(e) => { e.target.src = 'https://via.placeholder.com/48'; }}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-semibold text-white truncate">{user?.name}</p>
|
|
{isAdmin && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/20 text-yellow-400 rounded">관리자</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-zinc-400 truncate">{user?.email}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 메뉴 항목 */}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={() => { setShowProfileMenu(false); navigate('/profile'); }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-zinc-300 hover:bg-zinc-700 hover:text-white transition-colors"
|
|
>
|
|
<User size={16} />
|
|
프로필 관리
|
|
</button>
|
|
|
|
{minecraftLink && (
|
|
<button
|
|
onClick={handleStatsClick}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-mc-green hover:bg-zinc-700 transition-colors"
|
|
>
|
|
<BarChart2 size={16} />
|
|
내 통계
|
|
</button>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => { setShowProfileMenu(false); navigate('/admin'); }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-yellow-400 hover:bg-zinc-700 transition-colors"
|
|
>
|
|
<Shield size={16} />
|
|
관리자
|
|
</button>
|
|
)}
|
|
|
|
<div className="border-t border-zinc-700 my-1" />
|
|
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
|
>
|
|
<LogOut size={16} />
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
// 로그아웃 확인 다이얼로그
|
|
const LogoutDialog = () => (
|
|
<AnimatePresence>
|
|
{showLogoutDialog && (
|
|
<>
|
|
{/* 오버레이 */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
|
|
onClick={() => setShowLogoutDialog(false)}
|
|
/>
|
|
{/* 다이얼로그 */}
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-zinc-900 border border-zinc-700 rounded-2xl p-6 z-[101] shadow-2xl"
|
|
>
|
|
<div className="text-center">
|
|
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center">
|
|
<LogOut className="w-6 h-6 text-red-400" />
|
|
</div>
|
|
<h3 className="text-lg font-bold text-white mb-2">로그아웃</h3>
|
|
<p className="text-sm text-zinc-400 mb-6">정말 로그아웃 하시겠습니까?</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setShowLogoutDialog(false)}
|
|
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-xl font-medium transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={confirmLogout}
|
|
className="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors"
|
|
>
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
// 프로필 영역 (클릭 가능)
|
|
const ProfileSection = () => (
|
|
<div className="relative p-4 border-t border-white/10" ref={profileMenuRef}>
|
|
<ProfileMenu />
|
|
<button
|
|
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
|
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors"
|
|
>
|
|
<img
|
|
src={user?.profileUrl || '/default-profile.png'}
|
|
alt="프로필"
|
|
className="w-10 h-10 rounded-full object-cover border border-zinc-700"
|
|
onError={(e) => { e.target.src = 'https://via.placeholder.com/40'; }}
|
|
/>
|
|
<div className="flex-1 min-w-0 text-left">
|
|
<p className="text-sm text-white font-medium truncate">{user?.name}</p>
|
|
<p className="text-xs text-zinc-500 truncate">{user?.email}</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
// 모바일: 상단 툴바 + 햄버거 메뉴 사이드바
|
|
if (isMobile) {
|
|
return (
|
|
<>
|
|
<LogoutDialog />
|
|
|
|
{/* 토스트 알림 */}
|
|
<AnimatePresence>
|
|
{toast && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 50 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 50 }}
|
|
className="fixed bottom-20 left-4 right-4 z-[200] bg-red-500/90 backdrop-blur-sm text-white px-4 py-3 rounded-xl text-center font-medium shadow-lg"
|
|
>
|
|
{toast}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
{/* 상단 툴바 */}
|
|
<header className="fixed top-0 left-0 right-0 z-50 bg-mc-dark/95 backdrop-blur-xl border-b border-white/10 safe-area-top">
|
|
<div className="flex items-center justify-between h-14 px-4">
|
|
{/* 햄버거 메뉴 버튼 */}
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="p-2 -ml-2 rounded-lg text-white hover:bg-white/10 transition-all"
|
|
>
|
|
<Menu size={24} />
|
|
</button>
|
|
|
|
{/* 로고/타이틀 */}
|
|
<div className="flex items-center gap-2">
|
|
<Gamepad2 className="text-mc-green" size={20} />
|
|
<span className="font-bold text-white">마인크래프트</span>
|
|
</div>
|
|
|
|
{/* 로그인/유저 */}
|
|
{isLoggedIn ? (
|
|
<div className="relative" ref={profileMenuRef}>
|
|
<button onClick={() => setShowProfileMenu(!showProfileMenu)}>
|
|
<img
|
|
src={user?.profileUrl || '/default-profile.png'}
|
|
alt="프로필"
|
|
className="w-8 h-8 rounded-full object-cover border border-zinc-700"
|
|
onError={(e) => { e.target.src = 'https://via.placeholder.com/32'; }}
|
|
/>
|
|
</button>
|
|
|
|
{/* 드롭다운 메뉴 */}
|
|
<AnimatePresence>
|
|
{showProfileMenu && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="absolute top-full right-0 mt-2 w-56 bg-zinc-800 border border-zinc-700 rounded-xl shadow-xl overflow-hidden z-[60]"
|
|
>
|
|
{/* 프로필 정보 */}
|
|
<div className="p-3 bg-zinc-800/80 border-b border-zinc-700">
|
|
<div className="flex items-center gap-3">
|
|
<img
|
|
src={user?.profileUrl || '/default-profile.png'}
|
|
alt="프로필"
|
|
className="w-10 h-10 rounded-full object-cover border-2 border-mc-green"
|
|
onError={(e) => { e.target.src = 'https://via.placeholder.com/40'; }}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-white truncate">{user?.name}</p>
|
|
<p className="text-xs text-zinc-400 truncate">{user?.email}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 메뉴 항목 */}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={() => { setShowProfileMenu(false); navigate('/profile'); }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-zinc-300 hover:bg-zinc-700 hover:text-white transition-colors"
|
|
>
|
|
<User size={16} />
|
|
프로필 수정
|
|
</button>
|
|
|
|
{minecraftLink && (
|
|
<button
|
|
onClick={() => { setShowProfileMenu(false); handleStatsClick(); }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-mc-green hover:bg-zinc-700 transition-colors"
|
|
>
|
|
<BarChart2 size={16} />
|
|
내 통계
|
|
</button>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => { setShowProfileMenu(false); navigate('/admin'); }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-yellow-400 hover:bg-zinc-700 transition-colors"
|
|
>
|
|
<Shield size={16} />
|
|
관리자
|
|
</button>
|
|
)}
|
|
|
|
<div className="border-t border-zinc-700 my-1" />
|
|
|
|
<button
|
|
onClick={() => { setShowProfileMenu(false); setShowLogoutDialog(true); }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
|
>
|
|
<LogOut size={16} />
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
) : (
|
|
<Link to="/login" className="text-mc-green">
|
|
<LogIn size={20} />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* 상단 툴바 높이만큼 공간 확보 */}
|
|
<div className="h-14" />
|
|
|
|
{/* 오버레이 */}
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60]"
|
|
onClick={() => setIsOpen(false)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 사이드바 (슬라이드) */}
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.aside
|
|
initial={{ x: -280 }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: -280 }}
|
|
transition={{ type: 'tween', duration: 0.25, ease: 'easeOut' }}
|
|
className="fixed left-0 top-0 bottom-0 w-72 bg-mc-dark/98 backdrop-blur-xl border-r border-white/10 z-[70] flex flex-col safe-area-left"
|
|
>
|
|
{/* 사이드바 헤더 */}
|
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-xl bg-gradient-to-br from-mc-green/20 to-mc-emerald/20 border border-mc-green/30">
|
|
<Gamepad2 className="text-mc-green" size={20} />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-base font-bold text-white">마인크래프트</h1>
|
|
<p className="text-xs text-gray-500">서버 대시보드</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsOpen(false)}
|
|
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/10 transition-all"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 메뉴 */}
|
|
<nav className="flex-1 p-3 space-y-1">
|
|
{menuItems.map((item) => {
|
|
const active = isMenuActive(item);
|
|
return (
|
|
<NavLink
|
|
key={item.path}
|
|
to={item.path}
|
|
onClick={() => setIsOpen(false)}
|
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors outline-none focus:ring-0 border ${
|
|
active
|
|
? 'bg-mc-green/10 text-mc-green border-mc-green/20'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/5 border-transparent'
|
|
}`}
|
|
>
|
|
<item.icon size={20} />
|
|
<span className="font-medium">{item.label}</span>
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* 하단: 로그인 버튼 (비로그인 시에만) */}
|
|
{!isLoggedIn && (
|
|
<div className="p-4 border-t border-white/10">
|
|
<Link
|
|
to="/login"
|
|
onClick={() => setIsOpen(false)}
|
|
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
|
|
>
|
|
<LogIn size={18} />
|
|
로그인
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</motion.aside>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// PC: 기존 사이드바
|
|
return (
|
|
<>
|
|
<LogoutDialog />
|
|
|
|
{/* 토스트 알림 */}
|
|
<AnimatePresence>
|
|
{toast && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 50 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 50 }}
|
|
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] bg-red-500/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg"
|
|
>
|
|
{toast}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
{/* 데스크탑 사이드바 (항상 표시) */}
|
|
<aside className="hidden md:flex fixed left-0 top-0 bottom-0 w-64 bg-mc-dark/95 backdrop-blur-xl border-r border-white/10 z-30 flex-col">
|
|
{/* 로고 */}
|
|
<div className="p-6 border-b border-white/10">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-xl bg-gradient-to-br from-mc-green/20 to-mc-emerald/20 border border-mc-green/30">
|
|
<Gamepad2 className="text-mc-green" size={24} />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-lg font-bold text-white">마인크래프트</h1>
|
|
<p className="text-xs text-gray-500">서버 대시보드</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 메뉴 */}
|
|
<nav className="flex-1 p-4 space-y-2">
|
|
{menuItems.map((item) => {
|
|
const active = isMenuActive(item);
|
|
return (
|
|
<NavLink
|
|
key={item.path}
|
|
to={item.path}
|
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors outline-none focus:ring-0 border ${
|
|
active
|
|
? 'bg-mc-green/10 text-mc-green border-mc-green/20'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/5 border-transparent'
|
|
}`}
|
|
>
|
|
<item.icon size={20} />
|
|
<span className="font-medium">{item.label}</span>
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* 하단: 로그인/유저 정보 */}
|
|
{isLoggedIn ? (
|
|
<ProfileSection />
|
|
) : (
|
|
<div className="p-4 border-t border-white/10">
|
|
<Link
|
|
to="/login"
|
|
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
|
|
>
|
|
<LogIn size={18} />
|
|
로그인
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
|
|
{/* 데스크톱용 사이드바 spacer */}
|
|
<div className="hidden md:block w-64 shrink-0" />
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Sidebar;
|