[feat]: Safari 쿠키 호환성 및 UI/UX 개선
주요 변경사항: - Safari 쿠키 호환성 개선 (SameSite=Lax, 개발 환경 Secure 제외) - Sidebar 활성 메뉴 자동 스크롤 기능 추가 - Sidebar 스크롤바 스타일링 (호버 시에만 표시) - DashboardLayout sticky 포지셔닝 적용 - IE 브라우저 차단 및 안내 페이지 추가 - 메뉴 탐색 로직 개선 (서브메뉴 우선 매칭) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
256
public/unsupported-browser.html
Normal file
256
public/unsupported-browser.html
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>브라우저 업그레이드 안내 - SAM ERP</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 50px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 700px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: #3B82F6;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 30px;
|
||||||
|
font-size: 36px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browsers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
padding: 30px 20px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
border-color: #3B82F6;
|
||||||
|
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser.recommended {
|
||||||
|
border-color: #3B82F6;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-icon {
|
||||||
|
font-size: 60px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #3B82F6;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 2px solid #f59e0b;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 25px;
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-title {
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-list {
|
||||||
|
color: #78350f;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-list li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 25px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-list li:before {
|
||||||
|
content: "•";
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 30px;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browsers {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-icon {
|
||||||
|
font-size: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">S</div>
|
||||||
|
<div class="icon">⚠️</div>
|
||||||
|
<h1>지원하지 않는 브라우저입니다</h1>
|
||||||
|
<p class="subtitle">Internet Explorer는 더 이상 지원되지 않습니다</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
안정적이고 안전한 서비스 이용을 위해<br>
|
||||||
|
아래의 최신 브라우저를 설치하여 이용해 주세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="browsers">
|
||||||
|
<a href="https://www.microsoft.com/edge/download" class="browser recommended" target="_blank">
|
||||||
|
<div class="badge">권장</div>
|
||||||
|
<div class="browser-icon">🌐</div>
|
||||||
|
<div class="browser-name">Microsoft Edge</div>
|
||||||
|
<div class="browser-desc">Windows 권장 브라우저</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://www.google.com/chrome" class="browser" target="_blank">
|
||||||
|
<div class="browser-icon">🔵</div>
|
||||||
|
<div class="browser-name">Google Chrome</div>
|
||||||
|
<div class="browser-desc">가장 많이 사용되는 브라우저</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://support.apple.com/safari" class="browser" target="_blank">
|
||||||
|
<div class="browser-icon">🧭</div>
|
||||||
|
<div class="browser-name">Safari</div>
|
||||||
|
<div class="browser-desc">macOS/iOS 기본 브라우저</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<div class="warning-title">
|
||||||
|
<span>⚡</span>
|
||||||
|
<span>Internet Explorer 지원 중단 안내</span>
|
||||||
|
</div>
|
||||||
|
<ul class="warning-list">
|
||||||
|
<li>Microsoft는 2022년 6월 15일부로 Internet Explorer 지원을 공식 종료하였습니다</li>
|
||||||
|
<li>보안 취약점이 더 이상 패치되지 않아 개인정보 유출 위험이 있습니다</li>
|
||||||
|
<li>최신 웹 기술을 지원하지 않아 서비스를 정상적으로 이용할 수 없습니다</li>
|
||||||
|
<li>성능 문제로 인해 느린 화면 로딩 및 오류가 발생할 수 있습니다</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<strong>지원 브라우저:</strong> Chrome (최신 버전), Safari (최신 버전), Edge (최신 버전)
|
||||||
|
<br><br>
|
||||||
|
문의사항이 있으시면 고객센터로 연락해 주세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -72,23 +72,26 @@ export async function GET(request: NextRequest) {
|
|||||||
if (refreshResponse.ok) {
|
if (refreshResponse.ok) {
|
||||||
const data = await refreshResponse.json();
|
const data = await refreshResponse.json();
|
||||||
|
|
||||||
// Set new tokens
|
// Set new tokens with Safari-compatible configuration
|
||||||
|
// Safari compatibility: Secure only in production (HTTPS)
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
const accessTokenCookie = [
|
const accessTokenCookie = [
|
||||||
`access_token=${data.access_token}`,
|
`access_token=${data.access_token}`,
|
||||||
'HttpOnly',
|
'HttpOnly', // ✅ JavaScript cannot access
|
||||||
'Secure',
|
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
||||||
'SameSite=Strict',
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||||
'Path=/',
|
'Path=/',
|
||||||
`Max-Age=${data.expires_in || 7200}`,
|
`Max-Age=${data.expires_in || 7200}`,
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
const refreshTokenCookie = [
|
const refreshTokenCookie = [
|
||||||
`refresh_token=${data.refresh_token}`,
|
`refresh_token=${data.refresh_token}`,
|
||||||
'HttpOnly',
|
'HttpOnly', // ✅ JavaScript cannot access
|
||||||
'Secure',
|
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
||||||
'SameSite=Strict',
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||||
'Path=/',
|
'Path=/',
|
||||||
'Max-Age=604800',
|
'Max-Age=604800', // 7 days (longer for refresh token)
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
console.log('✅ Token auto-refreshed in auth check');
|
console.log('✅ Token auto-refreshed in auth check');
|
||||||
|
|||||||
@@ -148,11 +148,14 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Set HttpOnly cookies for both access_token and refresh_token
|
// Set HttpOnly cookies for both access_token and refresh_token
|
||||||
|
// Safari compatibility: Secure only in production (HTTPS)
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
const accessTokenCookie = [
|
const accessTokenCookie = [
|
||||||
`access_token=${data.access_token}`,
|
`access_token=${data.access_token}`,
|
||||||
'HttpOnly', // ✅ JavaScript cannot access
|
'HttpOnly', // ✅ JavaScript cannot access
|
||||||
'Secure', // ✅ HTTPS only (production)
|
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
||||||
'SameSite=Strict', // ✅ CSRF protection
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||||
'Path=/',
|
'Path=/',
|
||||||
`Max-Age=${data.expires_in || 7200}`, // Use backend expiry (default 2 hours)
|
`Max-Age=${data.expires_in || 7200}`, // Use backend expiry (default 2 hours)
|
||||||
].join('; ');
|
].join('; ');
|
||||||
@@ -160,8 +163,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const refreshTokenCookie = [
|
const refreshTokenCookie = [
|
||||||
`refresh_token=${data.refresh_token}`,
|
`refresh_token=${data.refresh_token}`,
|
||||||
'HttpOnly', // ✅ JavaScript cannot access
|
'HttpOnly', // ✅ JavaScript cannot access
|
||||||
'Secure', // ✅ HTTPS only (production)
|
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
||||||
'SameSite=Strict', // ✅ CSRF protection
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||||
'Path=/',
|
'Path=/',
|
||||||
'Max-Age=604800', // 7 days (longer for refresh token)
|
'Max-Age=604800', // 7 days (longer for refresh token)
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|||||||
@@ -49,11 +49,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear both HttpOnly cookies
|
// Clear both HttpOnly cookies
|
||||||
|
// Safari compatibility: Must use same attributes as when setting cookies
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
const clearAccessToken = [
|
const clearAccessToken = [
|
||||||
'access_token=',
|
'access_token=',
|
||||||
'HttpOnly',
|
'HttpOnly',
|
||||||
'Secure',
|
...(isProduction ? ['Secure'] : []), // ✅ Match login/check cookie attributes
|
||||||
'SameSite=Strict',
|
'SameSite=Lax', // ✅ Match login/check cookie attributes
|
||||||
'Path=/',
|
'Path=/',
|
||||||
'Max-Age=0', // Delete immediately
|
'Max-Age=0', // Delete immediately
|
||||||
].join('; ');
|
].join('; ');
|
||||||
@@ -61,8 +64,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const clearRefreshToken = [
|
const clearRefreshToken = [
|
||||||
'refresh_token=',
|
'refresh_token=',
|
||||||
'HttpOnly',
|
'HttpOnly',
|
||||||
'Secure',
|
...(isProduction ? ['Secure'] : []), // ✅ Match login/check cookie attributes
|
||||||
'SameSite=Strict',
|
'SameSite=Lax', // ✅ Match login/check cookie attributes
|
||||||
'Path=/',
|
'Path=/',
|
||||||
'Max-Age=0', // Delete immediately
|
'Max-Age=0', // Delete immediately
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|||||||
@@ -297,6 +297,51 @@
|
|||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar scroll - hide by default, show on hover */
|
||||||
|
.sidebar-scroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sidebar-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sidebar-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
.sidebar-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll:hover {
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sidebar-scroll:hover {
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clean modern component styles */
|
/* Clean modern component styles */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import type { MenuItem } from '@/store/menuStore';
|
import type { MenuItem } from '@/store/menuStore';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
menuItems: MenuItem[];
|
menuItems: MenuItem[];
|
||||||
@@ -22,6 +23,24 @@ export default function Sidebar({
|
|||||||
onToggleSubmenu,
|
onToggleSubmenu,
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
// 활성 메뉴 자동 스크롤을 위한 ref
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const activeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const menuContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// 활성 메뉴가 변경될 때 자동 스크롤
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeMenuRef.current && menuContainerRef.current) {
|
||||||
|
// 부드러운 스크롤로 활성 메뉴를 화면에 표시
|
||||||
|
activeMenuRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeMenu]); // activeMenu 변경 시에만 스크롤 (메뉴 클릭 시)
|
||||||
|
|
||||||
const handleMenuClick = (menuId: string, path: string, hasChildren: boolean) => {
|
const handleMenuClick = (menuId: string, path: string, hasChildren: boolean) => {
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
onToggleSubmenu(menuId);
|
onToggleSubmenu(menuId);
|
||||||
@@ -67,9 +86,12 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메뉴 */}
|
{/* 메뉴 */}
|
||||||
<div className={`flex-1 overflow-y-auto transition-all duration-300 ${
|
<div
|
||||||
sidebarCollapsed ? 'px-3 py-4' : 'p-4 md:p-6'
|
ref={menuContainerRef}
|
||||||
}`}>
|
className={`sidebar-scroll flex-1 overflow-y-auto transition-all duration-300 ${
|
||||||
|
sidebarCollapsed ? 'px-3 py-4' : 'p-4 md:p-6'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className={`transition-all duration-300 ${
|
<div className={`transition-all duration-300 ${
|
||||||
sidebarCollapsed ? 'space-y-2' : 'space-y-3'
|
sidebarCollapsed ? 'space-y-2' : 'space-y-3'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -80,7 +102,11 @@ export default function Sidebar({
|
|||||||
const isActive = activeMenu === item.id;
|
const isActive = activeMenu === item.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="relative">
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="relative"
|
||||||
|
ref={isActive ? activeMenuRef : null}
|
||||||
|
>
|
||||||
{/* 메인 메뉴 버튼 */}
|
{/* 메인 메뉴 버튼 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleMenuClick(item.id, item.path, !!hasChildren)}
|
onClick={() => handleMenuClick(item.id, item.path, !!hasChildren)}
|
||||||
@@ -129,19 +155,24 @@ export default function Sidebar({
|
|||||||
<div className="mt-2 ml-4 space-y-1 border-l-2 border-primary/20 pl-4">
|
<div className="mt-2 ml-4 space-y-1 border-l-2 border-primary/20 pl-4">
|
||||||
{item.children?.map((subItem) => {
|
{item.children?.map((subItem) => {
|
||||||
const SubIcon = subItem.icon;
|
const SubIcon = subItem.icon;
|
||||||
|
const isSubActive = activeMenu === subItem.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={subItem.id}
|
key={subItem.id}
|
||||||
onClick={() => handleMenuClick(subItem.id, subItem.path, false)}
|
ref={isSubActive ? activeMenuRef : null}
|
||||||
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2 space-x-2 group ${
|
|
||||||
activeMenu === subItem.id
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<SubIcon className="h-4 w-4" />
|
<button
|
||||||
<span className="text-xs font-medium">{subItem.label}</span>
|
onClick={() => handleMenuClick(subItem.id, subItem.path, false)}
|
||||||
</button>
|
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2 space-x-2 group ${
|
||||||
|
isSubActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SubIcon className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium">{subItem.label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,12 +89,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
|
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
|
||||||
const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => {
|
const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
// 현재 메뉴의 경로와 일치하는지 확인
|
// 서브메뉴가 있으면 먼저 확인 (더 구체적인 경로 우선)
|
||||||
if (item.path && normalizedPath.startsWith(item.path)) {
|
|
||||||
return { menuId: item.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서브메뉴가 있으면 재귀적으로 탐색
|
|
||||||
if (item.children && item.children.length > 0) {
|
if (item.children && item.children.length > 0) {
|
||||||
for (const child of item.children) {
|
for (const child of item.children) {
|
||||||
if (child.path && normalizedPath.startsWith(child.path)) {
|
if (child.path && normalizedPath.startsWith(child.path)) {
|
||||||
@@ -102,6 +97,11 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 서브메뉴에서 매칭되지 않으면 현재 메뉴 확인
|
||||||
|
if (item.path && normalizedPath.startsWith(item.path)) {
|
||||||
|
return { menuId: item.id };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -163,7 +163,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
<div className="min-h-screen flex w-full p-3 gap-3">
|
<div className="min-h-screen flex w-full p-3 gap-3">
|
||||||
{/* 데스크톱 사이드바 (모바일에서 숨김) */}
|
{/* 데스크톱 사이드바 (모바일에서 숨김) */}
|
||||||
<div
|
<div
|
||||||
className={`border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
|
className={`sticky top-3 h-[calc(100vh-24px)] border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
|
||||||
sidebarCollapsed ? 'w-24' : 'w-80'
|
sidebarCollapsed ? 'w-24' : 'w-80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ function isBot(userAgent: string): boolean {
|
|||||||
return BOT_PATTERNS.some(pattern => pattern.test(userAgent));
|
return BOT_PATTERNS.some(pattern => pattern.test(userAgent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user-agent is Internet Explorer
|
||||||
|
* IE 11: Contains "Trident" in user-agent
|
||||||
|
* IE 10 and below: Contains "MSIE" in user-agent
|
||||||
|
*/
|
||||||
|
function isInternetExplorer(userAgent: string): boolean {
|
||||||
|
if (!userAgent) return false;
|
||||||
|
|
||||||
|
return /MSIE|Trident/.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the path should be protected from bots
|
* Check if the path should be protected from bots
|
||||||
*/
|
*/
|
||||||
@@ -180,6 +191,16 @@ export function middleware(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const userAgent = request.headers.get('user-agent') || '';
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
|
|
||||||
|
// 🚨 0️⃣ Internet Explorer Detection (최우선 처리)
|
||||||
|
// IE 사용자는 지원 안내 페이지로 리다이렉트
|
||||||
|
if (isInternetExplorer(userAgent)) {
|
||||||
|
// unsupported-browser.html 페이지 자체는 제외 (무한 리다이렉트 방지)
|
||||||
|
if (!pathname.includes('unsupported-browser')) {
|
||||||
|
console.log(`[IE Blocked] ${userAgent} attempted to access ${pathname}`);
|
||||||
|
return NextResponse.redirect(new URL('/unsupported-browser.html', request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1️⃣ 로케일 제거
|
// 1️⃣ 로케일 제거
|
||||||
const pathnameWithoutLocale = getPathnameWithoutLocale(pathname);
|
const pathnameWithoutLocale = getPathnameWithoutLocale(pathname);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user