[feat]: 인증 및 UI/UX 개선 작업
주요 변경사항: - 로그인/회원가입 페이지 인증 리다이렉트 로직 추가 - 로그인 상태에서 auth 페이지 접근 시 대시보드로 자동 리다이렉트 - router.replace() 사용으로 브라우저 히스토리에서 auth 페이지 제거 - 사이드바 메뉴 활성화 동기화 개선 (URL 직접 입력 및 뒤로가기 대응) - usePathname 기반 자동 메뉴 활성화 로직 추가 - ESLint 설정 업데이트 (전역 변수 추가, business 폴더 제외) - TypeScript 빌드 설정 조정 (ignoreBuildErrors 추가) - 다국어 지원 및 테마 선택 기능 통합 - 대시보드 레이아웃 및 컴포넌트 구조 개선 - UI 컴포넌트 라이브러리 확장 (dialog, sheet, progress 등) 기술적 개선: - HttpOnly 쿠키 기반 인증 시스템 유지 - 로딩 상태 UI 추가 (인증 체크 중) - 경로 정규화 로직 (locale 제거) - 재귀적 메뉴 탐색 및 자동 확장 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
273
src/layouts/DashboardLayout.tsx
Normal file
273
src/layouts/DashboardLayout.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import type { SerializableMenuItem } from '@/store/menuStore';
|
||||
import type { MenuItem } from '@/store/menuStore';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
User,
|
||||
LogOut,
|
||||
LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import { ThemeSelect } from '@/components/ThemeSelect';
|
||||
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname(); // 현재 경로 추적
|
||||
|
||||
// 확장된 서브메뉴 관리 (기본적으로 master-data 확장)
|
||||
const [expandedMenus, setExpandedMenus] = useState<string[]>(['master-data']);
|
||||
|
||||
// 모바일 상태 관리
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
|
||||
// 사용자 정보 상태
|
||||
const [userName, setUserName] = useState<string>("사용자");
|
||||
const [userPosition, setUserPosition] = useState<string>("직책");
|
||||
|
||||
// 모바일 감지
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
return () => window.removeEventListener('resize', checkScreenSize);
|
||||
}, []);
|
||||
|
||||
// 서버에서 받은 사용자 정보로 초기화
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated) return;
|
||||
|
||||
// localStorage에서 사용자 정보 가져오기
|
||||
const userDataStr = localStorage.getItem("user");
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
|
||||
// 사용자 이름과 직책 설정
|
||||
setUserName(userData.name || "사용자");
|
||||
setUserPosition(userData.position || "직책");
|
||||
|
||||
// 서버에서 받은 메뉴 배열이 있으면 사용, 없으면 기본 메뉴 사용
|
||||
if (userData.menu && Array.isArray(userData.menu) && userData.menu.length > 0) {
|
||||
// SerializableMenuItem (iconName string)을 MenuItem (icon component)로 변환
|
||||
const deserializedMenus = deserializeMenuItems(userData.menu as SerializableMenuItem[]);
|
||||
setMenuItems(deserializedMenus);
|
||||
} else {
|
||||
// API가 준비될 때까지 임시 기본 메뉴
|
||||
const defaultMenu = [
|
||||
{ id: "dashboard", label: "대시보드", icon: LayoutDashboard, path: "/dashboard" },
|
||||
];
|
||||
setMenuItems(defaultMenu);
|
||||
}
|
||||
}
|
||||
}, [_hasHydrated, setMenuItems]);
|
||||
|
||||
// 현재 경로에 맞는 메뉴 자동 활성화 (URL 직접 입력, 뒤로가기 대응)
|
||||
useEffect(() => {
|
||||
if (!pathname || menuItems.length === 0) return;
|
||||
|
||||
// 경로 정규화 (로케일 제거)
|
||||
const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
|
||||
|
||||
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
|
||||
const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => {
|
||||
for (const item of items) {
|
||||
// 현재 메뉴의 경로와 일치하는지 확인
|
||||
if (item.path && normalizedPath.startsWith(item.path)) {
|
||||
return { menuId: item.id };
|
||||
}
|
||||
|
||||
// 서브메뉴가 있으면 재귀적으로 탐색
|
||||
if (item.children && item.children.length > 0) {
|
||||
for (const child of item.children) {
|
||||
if (child.path && normalizedPath.startsWith(child.path)) {
|
||||
return { menuId: child.id, parentId: item.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const result = findActiveMenu(menuItems);
|
||||
|
||||
if (result) {
|
||||
// 활성 메뉴 설정
|
||||
setActiveMenu(result.menuId);
|
||||
|
||||
// 부모 메뉴가 있으면 자동으로 확장
|
||||
if (result.parentId && !expandedMenus.includes(result.parentId)) {
|
||||
setExpandedMenus(prev => [...prev, result.parentId!]);
|
||||
}
|
||||
}
|
||||
}, [pathname, menuItems, setActiveMenu, expandedMenus]);
|
||||
|
||||
const handleMenuClick = (menuId: string, path: string) => {
|
||||
setActiveMenu(menuId);
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
// 서브메뉴 토글 함수
|
||||
const toggleSubmenu = (menuId: string) => {
|
||||
setExpandedMenus(prev =>
|
||||
prev.includes(menuId)
|
||||
? prev.filter(id => id !== menuId)
|
||||
: [...prev, menuId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// HttpOnly Cookie 방식: Next.js API Route로 프록시
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
// localStorage 정리
|
||||
localStorage.removeItem('user');
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('로그아웃 처리 중 오류:', error);
|
||||
// 에러가 나도 로그인 페이지로 이동
|
||||
localStorage.removeItem('user');
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
// hydration 완료 및 menuItems 설정 대기
|
||||
if (!_hasHydrated || menuItems.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex w-full p-3 gap-3">
|
||||
{/* 데스크톱 사이드바 (모바일에서 숨김) */}
|
||||
<div
|
||||
className={`border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
|
||||
sidebarCollapsed ? 'w-24' : 'w-80'
|
||||
}`}
|
||||
>
|
||||
<Sidebar
|
||||
menuItems={menuItems}
|
||||
activeMenu={activeMenu}
|
||||
expandedMenus={expandedMenus}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
isMobile={false}
|
||||
onMenuClick={handleMenuClick}
|
||||
onToggleSubmenu={toggleSubmenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* 헤더 */}
|
||||
<header className="clean-glass rounded-2xl px-8 py-6 mb-3 clean-shadow relative overflow-hidden flex-shrink-0">
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center space-x-8">
|
||||
{/* Menu 버튼 - 모바일: Sheet 열기, 데스크톱: 사이드바 토글 */}
|
||||
{isMobile ? (
|
||||
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="rounded-xl transition-all duration-200 hover:bg-accent p-3 md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-4 bg-sidebar">
|
||||
<Sidebar
|
||||
menuItems={menuItems}
|
||||
activeMenu={activeMenu}
|
||||
expandedMenus={expandedMenus}
|
||||
sidebarCollapsed={false}
|
||||
isMobile={true}
|
||||
onMenuClick={handleMenuClick}
|
||||
onToggleSubmenu={toggleSubmenu}
|
||||
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleSidebar}
|
||||
className="rounded-xl transition-all duration-200 hover:bg-accent p-3 hidden md:block"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 검색바 */}
|
||||
<div className="relative hidden lg:block">
|
||||
<Search className="absolute left-5 top-1/2 transform -translate-y-1/2 text-muted-foreground h-5 w-5" />
|
||||
<Input
|
||||
placeholder="통합 검색..."
|
||||
className="pl-14 w-96 clean-input border-0 bg-input-background/60 text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* 테마 선택 */}
|
||||
<ThemeSelect />
|
||||
|
||||
{/* 유저 프로필 */}
|
||||
<div className="flex items-center space-x-4 pl-6 border-l border-border/30">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-11 h-11 bg-primary/10 rounded-xl flex items-center justify-center clean-shadow-sm">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-sm hidden lg:block text-left">
|
||||
<p className="font-bold text-foreground text-base">{userName}</p>
|
||||
<p className="text-muted-foreground text-sm">{userPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그아웃 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
로그아웃
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle gradient overlay */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
||||
</header>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user