- MenuFavorite 모델 생성 (menu_favorites 테이블) - SidebarMenuService에 즐겨찾기 CRUD 메서드 추가 - MenuFavoriteController 생성 (toggle/reorder API) - 사이드바 상단에 즐겨찾기 섹션 표시 - 메뉴 아이템에 별 아이콘 추가 (hover 시 표시, 토글) - 최대 10개 제한, 리프 메뉴만 대상
1441 lines
50 KiB
PHP
1441 lines
50 KiB
PHP
<!-- Sidebar (Dynamic Menu from DB + Static Tools/Labs) -->
|
|
<!-- 모바일: fixed + 슬라이드 인/아웃, 데스크톱(lg+): static + 기존 동작 -->
|
|
<aside id="sidebar" class="sidebar bg-white shadow-lg flex-shrink-0 transition-all duration-300 ease-in-out w-64
|
|
fixed inset-y-0 left-0 z-50 transform -translate-x-full
|
|
lg:relative lg:translate-x-0 lg:z-auto">
|
|
<div class="flex flex-col h-full">
|
|
<!-- Logo / Brand -->
|
|
<div class="flex items-center h-16 border-b border-gray-200 px-3">
|
|
<!-- 펼쳐진 상태: 햄버거 버튼 + 로고 + 검색 -->
|
|
<div class="sidebar-expanded-only flex items-center gap-2 w-full">
|
|
<button
|
|
type="button"
|
|
onclick="toggleSidebar()"
|
|
class="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
|
title="메뉴 접기"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
</button>
|
|
<span class="text-xl font-bold text-gray-900 flex-1">{{ config('app.name') }}</span>
|
|
<!-- 검색 아이콘 -->
|
|
<button
|
|
type="button"
|
|
onclick="openMenuSearch()"
|
|
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
title="메뉴 검색"
|
|
id="menu-search-btn"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- 접힌 상태: S 버튼 (클릭하면 확장) -->
|
|
<button
|
|
type="button"
|
|
onclick="toggleSidebar()"
|
|
class="sidebar-collapsed-only hidden w-full p-2 text-xl font-bold text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
|
title="메뉴 펼치기"
|
|
>
|
|
S
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 메뉴 검색창 (숨김 상태) -->
|
|
<div id="menu-search-container" class="hidden border-b border-gray-200 px-3 py-2 bg-gray-50">
|
|
<div class="relative">
|
|
<input
|
|
type="text"
|
|
id="menu-search-input"
|
|
placeholder="메뉴 검색..."
|
|
class="w-full pl-10 pr-8 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
oninput="filterMenus(this.value)"
|
|
autocomplete="off"
|
|
>
|
|
<!-- 검색 아이콘 -->
|
|
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
<!-- 닫기 버튼 -->
|
|
<button
|
|
type="button"
|
|
onclick="closeMenuSearch()"
|
|
class="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
|
title="검색 닫기"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- 검색 결과 안내 -->
|
|
<div id="menu-search-info" class="hidden mt-2 text-xs text-gray-500"></div>
|
|
</div>
|
|
|
|
<!-- 모바일 전용: 테넌트 셀렉터 (lg 미만에서만 표시, 영업 메뉴에서는 숨김) -->
|
|
<div class="lg:hidden border-b border-gray-200 p-3 bg-gray-50 {{ request()->routeIs('sales.*') ? 'hidden' : '' }}">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
|
</svg>
|
|
<span class="text-xs font-medium text-gray-500">테넌트 선택</span>
|
|
</div>
|
|
<form action="{{ route('tenant.switch') }}" method="POST" id="mobile-tenant-switch-form">
|
|
@csrf
|
|
<select
|
|
name="tenant_id"
|
|
id="mobile-tenant-select"
|
|
onchange="document.getElementById('mobile-tenant-switch-form').submit()"
|
|
class="w-full border-gray-300 rounded-lg text-sm focus:ring-primary focus:border-primary"
|
|
>
|
|
@foreach($globalTenants as $tenant)
|
|
<option value="{{ $tenant->id }}" {{ session('selected_tenant_id') == $tenant->id ? 'selected' : '' }}>
|
|
{{ $tenant->company_name }}
|
|
</option>
|
|
@endforeach
|
|
</select>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Navigation Menu -->
|
|
<nav class="flex-1 overflow-y-auto p-4 sidebar-nav"
|
|
hx-boost="true"
|
|
hx-target="#main-content"
|
|
hx-select="#main-content"
|
|
hx-swap="outerHTML"
|
|
hx-push-url="true">
|
|
<!-- 전체 접기/펼치기 -->
|
|
<div class="sidebar-expanded-only flex items-center justify-end gap-1 mb-2 px-1">
|
|
<button type="button" onclick="toggleAllMenuGroups(true)" class="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition" title="전체 접기">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" /></svg>
|
|
</button>
|
|
<button type="button" onclick="toggleAllMenuGroups(false)" class="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition" title="전체 펼치기">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<ul class="space-y-1">
|
|
{{-- Favorites Section --}}
|
|
<x-sidebar.favorites-section :favorites="$favoriteMenus" />
|
|
|
|
{{-- Main Section Menus (Dynamic from DB) --}}
|
|
<x-sidebar.menu-tree :menus="$mainMenus" />
|
|
|
|
{{-- R&D Labs Section (Dynamic from DB with Tab UI) --}}
|
|
<x-sidebar.labs-menu :menus="$labsMenus" />
|
|
</ul>
|
|
</nav>
|
|
|
|
<!-- 개발 도구 (하단 고정, DB 기반) -->
|
|
@if(!empty($toolsMenus) && $toolsMenus->count() > 0)
|
|
<div class="border-t border-gray-200 p-2 bg-gray-50"
|
|
hx-boost="true"
|
|
hx-target="#main-content"
|
|
hx-select="#main-content"
|
|
hx-swap="outerHTML"
|
|
hx-push-url="true">
|
|
<x-sidebar.tools-menu :menus="$toolsMenus" />
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</aside>
|
|
|
|
<style>
|
|
/* ========== 즐겨찾기 별 아이콘 ========== */
|
|
.fav-star.fav-active { opacity: 1 !important; }
|
|
.sidebar-collapsed .fav-star { display: none !important; }
|
|
|
|
/* ========== 메뉴 검색 스타일 ========== */
|
|
.menu-search-highlight {
|
|
background-color: #fef08a;
|
|
color: #854d0e;
|
|
padding: 0 2px;
|
|
border-radius: 2px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.menu-search-match > a {
|
|
background-color: #f0fdf4 !important;
|
|
border-left: 3px solid #22c55e;
|
|
color: #1E293B !important;
|
|
}
|
|
|
|
.menu-search-match > a .sidebar-text {
|
|
color: #1E293B !important;
|
|
}
|
|
|
|
#menu-search-container {
|
|
animation: slideDown 0.2s ease-out;
|
|
}
|
|
|
|
#menu-search-container.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
/* 사이드바 접힌 상태에서 검색창 숨김 */
|
|
html.sidebar-is-collapsed #sidebar #menu-search-container,
|
|
.sidebar.sidebar-collapsed #menu-search-container {
|
|
display: none !important;
|
|
}
|
|
|
|
@keyframes slideDown {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
/* ========== 초기 로드 시 transition 비활성화 (깜빡임 방지) ========== */
|
|
body.no-transition .sidebar,
|
|
body.no-transition .sidebar *,
|
|
body.no-transition .sidebar-nav,
|
|
body.no-transition [id^="menu-group-"] {
|
|
transition: none !important;
|
|
}
|
|
|
|
/* ========== 사이드바 기본 스타일 ========== */
|
|
.sidebar {
|
|
width: 16rem;
|
|
overflow: visible;
|
|
transition: width 0.3s ease-in-out;
|
|
}
|
|
|
|
.sidebar-collapsed {
|
|
width: 4rem !important;
|
|
}
|
|
|
|
/* 접힌 상태 기본 설정 */
|
|
.sidebar-expanded-only {
|
|
display: flex;
|
|
}
|
|
|
|
.sidebar-collapsed-only {
|
|
display: none;
|
|
}
|
|
|
|
/* 접힌 상태에서 overflow 설정 (툴팁 표시를 위해) */
|
|
html.sidebar-is-collapsed #sidebar,
|
|
.sidebar.sidebar-collapsed {
|
|
width: 4rem !important;
|
|
overflow: visible !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav,
|
|
.sidebar.sidebar-collapsed .sidebar-nav {
|
|
overflow-y: auto !important;
|
|
overflow-x: visible !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar > div,
|
|
.sidebar.sidebar-collapsed > div {
|
|
overflow: visible !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-text,
|
|
.sidebar.sidebar-collapsed .sidebar-text {
|
|
display: none;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-group-header,
|
|
.sidebar.sidebar-collapsed .sidebar-group-header {
|
|
display: none;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav,
|
|
.sidebar.sidebar-collapsed .sidebar-nav {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-expanded-only,
|
|
.sidebar.sidebar-collapsed .sidebar-expanded-only {
|
|
display: none;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-collapsed-only,
|
|
.sidebar.sidebar-collapsed .sidebar-collapsed-only {
|
|
display: block;
|
|
}
|
|
|
|
/* 접힌 상태에서 메뉴 아이템 중앙 정렬 */
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav a,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav span,
|
|
.sidebar.sidebar-collapsed .sidebar-nav a,
|
|
.sidebar.sidebar-collapsed .sidebar-nav span {
|
|
justify-content: center;
|
|
padding-left: 0.75rem !important;
|
|
padding-right: 0.75rem !important;
|
|
}
|
|
|
|
/* 접힌 상태에서 개발 도구 영역 조정 */
|
|
html.sidebar-is-collapsed #sidebar .border-t.border-gray-200.p-2,
|
|
.sidebar.sidebar-collapsed .border-t.border-gray-200.p-2 {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
/* 접힌 상태에서 메뉴 아이콘 호버 툴팁 */
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav a,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > a,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span,
|
|
.sidebar.sidebar-collapsed .sidebar-nav a,
|
|
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > a,
|
|
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span {
|
|
position: relative;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav a::after,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span::after,
|
|
.sidebar.sidebar-collapsed .sidebar-nav a::after,
|
|
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span::after {
|
|
content: attr(title);
|
|
position: absolute;
|
|
left: 100%;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
margin-left: 0.75rem;
|
|
padding: 0.5rem 0.75rem;
|
|
background-color: #1f2937;
|
|
color: white;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
border-radius: 0.375rem;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
|
|
z-index: 1000;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav a:hover::after,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span:hover::after,
|
|
.sidebar.sidebar-collapsed .sidebar-nav a:hover::after,
|
|
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span:hover::after {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
|
|
/* 툴팁 화살표 */
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav a::before,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span::before,
|
|
.sidebar.sidebar-collapsed .sidebar-nav a::before,
|
|
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 100%;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
margin-left: 0.25rem;
|
|
border: 6px solid transparent;
|
|
border-right-color: #1f2937;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
|
|
z-index: 1000;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav a:hover::before,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span:hover::before,
|
|
.sidebar.sidebar-collapsed .sidebar-nav a:hover::before,
|
|
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span:hover::before {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
|
|
/* 접힌 상태에서 서브메뉴 숨김 */
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav ul ul,
|
|
.sidebar.sidebar-collapsed .sidebar-nav ul ul {
|
|
display: none;
|
|
}
|
|
|
|
/* 접힌 상태에서 그룹 헤더 버튼 스타일 */
|
|
html.sidebar-is-collapsed #sidebar .sidebar-group-header span.flex,
|
|
.sidebar.sidebar-collapsed .sidebar-group-header span.flex {
|
|
justify-content: center;
|
|
}
|
|
|
|
/* ========== R&D Labs 탭 + 플라이아웃 스타일 ========== */
|
|
|
|
/* R&D Labs 그룹 특별 스타일 */
|
|
.lab-group-header {
|
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
|
border: 1px solid #f59e0b;
|
|
}
|
|
|
|
.lab-group-header:hover {
|
|
background: linear-gradient(135deg, #fde68a 0%, #fcd34d 100%);
|
|
}
|
|
|
|
/* 탭 버튼 스타일 */
|
|
.lab-tabs .lab-tab {
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.lab-tabs .lab-tab.active {
|
|
background: white;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.lab-tabs .lab-tab:not(.active):hover {
|
|
background: rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
/* 확장 상태에서 축소 뷰 숨김 */
|
|
.lab-expanded-view {
|
|
display: block;
|
|
}
|
|
|
|
.lab-collapsed-view {
|
|
display: none !important;
|
|
}
|
|
|
|
/* 축소 상태에서 확장 뷰 숨김, 축소 뷰 표시 */
|
|
html.sidebar-is-collapsed #sidebar .lab-expanded-view,
|
|
.sidebar.sidebar-collapsed .lab-expanded-view {
|
|
display: none !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .lab-collapsed-view,
|
|
.sidebar.sidebar-collapsed .lab-collapsed-view {
|
|
display: block !important;
|
|
}
|
|
|
|
/* 플라이아웃 트리거 */
|
|
.lab-flyout-trigger {
|
|
position: relative;
|
|
}
|
|
|
|
/* 플라이아웃 표시 - JavaScript로 제어 */
|
|
.lab-flyout.show {
|
|
display: block !important;
|
|
animation: flyoutFadeIn 0.15s ease-out;
|
|
}
|
|
|
|
/* 플라이아웃 왼쪽 투명 브릿지 영역 (hover gap 연결) */
|
|
.lab-flyout::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: -20px;
|
|
top: 0;
|
|
width: 20px;
|
|
height: 100%;
|
|
background: transparent;
|
|
}
|
|
|
|
@keyframes flyoutFadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(-8px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
/* 플라이아웃 탭 스타일 */
|
|
.lab-flyout-tab {
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.lab-flyout-tab.active {
|
|
background: white;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.lab-flyout-tab:not(.active):hover {
|
|
background: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
/* 접힌 상태에서 R&D Labs 메뉴 아이템 스타일 리셋 */
|
|
html.sidebar-is-collapsed #sidebar .lab-panel span,
|
|
.sidebar.sidebar-collapsed .lab-panel span {
|
|
display: none;
|
|
}
|
|
|
|
/* 접힌 상태에서 탭 숨김 */
|
|
html.sidebar-is-collapsed #sidebar .lab-tabs,
|
|
.sidebar.sidebar-collapsed .lab-tabs {
|
|
display: none;
|
|
}
|
|
|
|
/* 접힌 상태에서 R&D Labs 그룹 border-t 유지 */
|
|
html.sidebar-is-collapsed #sidebar .lab-menu-container,
|
|
.sidebar.sidebar-collapsed .lab-menu-container {
|
|
border-top: 1px solid #e5e7eb;
|
|
padding-top: 1rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
/* ========== 모바일 반응형 스타일 ========== */
|
|
|
|
/* 모바일에서 사이드바 열림 상태 */
|
|
.sidebar.sidebar-mobile-open {
|
|
transform: translateX(0) !important;
|
|
}
|
|
|
|
/* 모바일에서 데스크톱 토글 버튼 숨김 (lg 미만) */
|
|
@media (max-width: 1023px) {
|
|
.sidebar .sidebar-expanded-only button[onclick="toggleSidebar()"],
|
|
.sidebar .sidebar-collapsed-only[onclick="toggleSidebar()"] {
|
|
display: none !important;
|
|
}
|
|
|
|
/* 모바일에서 사이드바 헤더 로고만 표시 */
|
|
.sidebar .sidebar-expanded-only {
|
|
justify-content: center !important;
|
|
}
|
|
|
|
/* 모바일에서 접힌 상태 클래스 무시 (항상 펼쳐진 상태로 표시) */
|
|
.sidebar.sidebar-collapsed {
|
|
width: 16rem !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-text,
|
|
.sidebar.sidebar-collapsed .sidebar-text {
|
|
display: inline !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-expanded-only,
|
|
.sidebar.sidebar-collapsed .sidebar-expanded-only {
|
|
display: flex !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-collapsed-only,
|
|
.sidebar.sidebar-collapsed .sidebar-collapsed-only {
|
|
display: none !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav,
|
|
.sidebar.sidebar-collapsed .sidebar-nav {
|
|
padding: 1rem !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav a,
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav span,
|
|
.sidebar.sidebar-collapsed .sidebar-nav a,
|
|
.sidebar.sidebar-collapsed .sidebar-nav span {
|
|
justify-content: flex-start !important;
|
|
padding-left: 0.75rem !important;
|
|
padding-right: 0.75rem !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-nav ul ul,
|
|
.sidebar.sidebar-collapsed .sidebar-nav ul ul {
|
|
display: block !important;
|
|
}
|
|
|
|
html.sidebar-is-collapsed #sidebar .sidebar-group-header,
|
|
.sidebar.sidebar-collapsed .sidebar-group-header {
|
|
display: block !important;
|
|
}
|
|
}
|
|
|
|
/* 데스크톱(lg+)에서 모바일 클래스 무시 */
|
|
@media (min-width: 1024px) {
|
|
.sidebar.sidebar-mobile-open {
|
|
transform: none !important;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// ========== 모바일 사이드바 함수 ==========
|
|
|
|
// 모바일 사이드바 열기
|
|
function openMobileSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const backdrop = document.getElementById('sidebar-backdrop');
|
|
|
|
if (!sidebar) return;
|
|
sidebar.classList.add('sidebar-mobile-open');
|
|
backdrop?.classList.remove('hidden');
|
|
document.body.classList.add('overflow-hidden');
|
|
}
|
|
|
|
// 모바일 사이드바 닫기
|
|
function closeMobileSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const backdrop = document.getElementById('sidebar-backdrop');
|
|
|
|
if (!sidebar) return;
|
|
sidebar.classList.remove('sidebar-mobile-open');
|
|
backdrop?.classList.add('hidden');
|
|
document.body.classList.remove('overflow-hidden');
|
|
}
|
|
|
|
// ESC 키로 모바일 사이드바 닫기
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
closeMobileSidebar();
|
|
}
|
|
});
|
|
|
|
// 모바일에서 메뉴 링크 클릭 시 사이드바 닫기
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (sidebar) {
|
|
sidebar.addEventListener('click', function(e) {
|
|
const link = e.target.closest('a[href]');
|
|
if (link && window.innerWidth < 1024) {
|
|
// 약간의 지연 후 닫기 (시각적 피드백)
|
|
setTimeout(closeMobileSidebar, 150);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========== 데스크톱 사이드바 토글 ==========
|
|
|
|
// 사이드바 토글 (로컬스토리지 연동)
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const html = document.documentElement;
|
|
|
|
if (!sidebar) return;
|
|
|
|
if (html.classList.contains('sidebar-is-collapsed')) {
|
|
html.classList.remove('sidebar-is-collapsed');
|
|
sidebar.classList.remove('sidebar-collapsed');
|
|
localStorage.setItem('sidebar-collapsed', 'false');
|
|
} else {
|
|
html.classList.add('sidebar-is-collapsed');
|
|
sidebar.classList.add('sidebar-collapsed');
|
|
localStorage.setItem('sidebar-collapsed', 'true');
|
|
}
|
|
}
|
|
|
|
// 메뉴 그룹 토글 (R&D Labs, 개발 도구 등)
|
|
function toggleGroup(groupId) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
// 사이드바가 접힌 상태면 그룹 토글 무시
|
|
if (!sidebar || sidebar.classList.contains('sidebar-collapsed')) {
|
|
return;
|
|
}
|
|
|
|
const group = document.getElementById(groupId);
|
|
const icon = document.getElementById(groupId + '-icon');
|
|
|
|
if (!group) return;
|
|
|
|
if (group.style.display === 'none') {
|
|
group.style.display = 'block';
|
|
if (icon) icon.style.transform = 'rotate(0deg)';
|
|
localStorage.setItem('sidebar-' + groupId, 'open');
|
|
} else {
|
|
group.style.display = 'none';
|
|
if (icon) icon.style.transform = 'rotate(-90deg)';
|
|
localStorage.setItem('sidebar-' + groupId, 'closed');
|
|
}
|
|
}
|
|
|
|
// 전체 메뉴 그룹 접기/펼치기
|
|
function toggleAllMenuGroups(collapse) {
|
|
document.querySelectorAll('[id^="menu-group-"]').forEach(function(group) {
|
|
const groupId = group.id;
|
|
const icon = document.getElementById(groupId + '-icon');
|
|
|
|
if (collapse) {
|
|
group.style.display = 'none';
|
|
if (icon) icon.style.transform = 'rotate(0deg)';
|
|
localStorage.setItem('menu-group-' + groupId, 'hidden');
|
|
} else {
|
|
group.style.display = 'block';
|
|
if (icon) icon.style.transform = 'rotate(180deg)';
|
|
localStorage.setItem('menu-group-' + groupId, 'visible');
|
|
}
|
|
});
|
|
}
|
|
|
|
// 동적 메뉴 그룹 토글 (DB에서 가져온 메뉴)
|
|
function toggleMenuGroup(groupId) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
// 사이드바가 접힌 상태면 그룹 토글 무시
|
|
if (!sidebar || sidebar.classList.contains('sidebar-collapsed') || document.documentElement.classList.contains('sidebar-is-collapsed')) {
|
|
return;
|
|
}
|
|
|
|
const group = document.getElementById(groupId);
|
|
const icon = document.getElementById(groupId + '-icon');
|
|
|
|
if (!group) return;
|
|
|
|
// computed style로 실제 표시 상태 확인
|
|
const isHidden = window.getComputedStyle(group).display === 'none';
|
|
|
|
if (isHidden) {
|
|
group.style.display = 'block';
|
|
if (icon) {
|
|
icon.style.transform = 'rotate(180deg)';
|
|
}
|
|
localStorage.setItem('menu-group-' + groupId, 'visible');
|
|
} else {
|
|
group.style.display = 'none';
|
|
if (icon) {
|
|
icon.style.transform = 'rotate(0deg)';
|
|
}
|
|
localStorage.setItem('menu-group-' + groupId, 'hidden');
|
|
}
|
|
}
|
|
|
|
// ========== 즐겨찾기 토글 ==========
|
|
|
|
async function toggleMenuFavorite(menuId, btnEl) {
|
|
try {
|
|
const res = await fetch('{{ route("menu-favorites.toggle") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({ menu_id: menuId }),
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.action === 'max_reached') {
|
|
alert('즐겨찾기는 최대 ' + (data.max || 10) + '개까지 등록할 수 있습니다.');
|
|
return;
|
|
}
|
|
|
|
// 페이지 새로고침으로 사이드바 갱신 (가장 안정적)
|
|
window.location.reload();
|
|
} catch (e) {
|
|
console.error('즐겨찾기 토글 실패:', e);
|
|
}
|
|
}
|
|
|
|
// ========== R&D Labs 사이드바 스크롤 함수 ==========
|
|
|
|
// 사이드바를 최하단으로 스크롤하고 위치 저장
|
|
function scrollSidebarToBottom() {
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (sidebarNav) {
|
|
setTimeout(function() {
|
|
sidebarNav.scrollTo({
|
|
top: sidebarNav.scrollHeight,
|
|
behavior: 'smooth'
|
|
});
|
|
localStorage.setItem('sidebar-scroll-bottom', 'true');
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
// 저장된 스크롤 위치 복원 (즉시 적용, 깜빡임 방지)
|
|
function restoreSidebarScroll() {
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (!sidebarNav) return;
|
|
|
|
// head에서 미리 읽은 값 사용 (더 빠른 적용)
|
|
const scrollToBottom = window._savedSidebarScrollBottom || localStorage.getItem('sidebar-scroll-bottom');
|
|
const savedScrollTop = window._savedSidebarScroll || localStorage.getItem('sidebar-scroll-top');
|
|
|
|
// 즉시 적용 (setTimeout 제거)
|
|
if (scrollToBottom === 'true') {
|
|
sidebarNav.scrollTop = sidebarNav.scrollHeight;
|
|
} else if (savedScrollTop) {
|
|
sidebarNav.scrollTop = parseInt(savedScrollTop, 10);
|
|
}
|
|
}
|
|
|
|
// 메뉴 클릭 시 현재 스크롤 위치 저장
|
|
function saveSidebarScroll() {
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (sidebarNav) {
|
|
localStorage.setItem('sidebar-scroll-top', sidebarNav.scrollTop);
|
|
localStorage.removeItem('sidebar-scroll-bottom');
|
|
}
|
|
}
|
|
|
|
// ========== HTMX 메뉴 활성화 ==========
|
|
|
|
// 메뉴 클릭 시 활성화 처리
|
|
const sidebarElement = document.getElementById('sidebar');
|
|
if (sidebarElement) {
|
|
sidebarElement.addEventListener('click', function(e) {
|
|
const link = e.target.closest('a[href]');
|
|
if (!link || link.getAttribute('href') === '#') return;
|
|
|
|
// 모든 메뉴에서 활성화 클래스 제거
|
|
this.querySelectorAll('nav a, .border-t a').forEach(a => {
|
|
a.classList.remove('bg-primary', 'text-white', 'hover:bg-primary');
|
|
a.classList.add('text-gray-700', 'hover:bg-gray-100');
|
|
});
|
|
|
|
// 클릭한 메뉴에 활성화 클래스 추가
|
|
link.classList.remove('text-gray-700', 'hover:bg-gray-100');
|
|
link.classList.add('bg-primary', 'text-white', 'hover:bg-primary');
|
|
});
|
|
}
|
|
|
|
// ========== R&D Labs 탭 전환 함수 ==========
|
|
|
|
// 확장 상태: 탭 전환
|
|
function switchLabTab(tabKey) {
|
|
const tabs = ['s', 'a', 'm'];
|
|
|
|
tabs.forEach(function(key) {
|
|
const tab = document.getElementById('lab-tab-' + key);
|
|
const panel = document.getElementById('lab-panel-' + key);
|
|
|
|
if (tab) {
|
|
tab.classList.remove('active');
|
|
}
|
|
if (panel) {
|
|
panel.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
const activeTab = document.getElementById('lab-tab-' + tabKey);
|
|
const activePanel = document.getElementById('lab-panel-' + tabKey);
|
|
|
|
if (activeTab) {
|
|
activeTab.classList.add('active');
|
|
}
|
|
if (activePanel) {
|
|
activePanel.classList.remove('hidden');
|
|
}
|
|
|
|
localStorage.setItem('lab-active-tab', tabKey);
|
|
|
|
if (!window._skipLabScroll) {
|
|
scrollSidebarToBottom();
|
|
}
|
|
}
|
|
|
|
// 축소 상태 (플라이아웃): 탭 전환
|
|
function switchLabFlyoutTab(tabKey) {
|
|
const tabs = ['s', 'a', 'm'];
|
|
|
|
tabs.forEach(function(key) {
|
|
const tab = document.getElementById('lab-flyout-tab-' + key);
|
|
const panel = document.getElementById('lab-flyout-panel-' + key);
|
|
|
|
if (tab) {
|
|
tab.classList.remove('active');
|
|
}
|
|
if (panel) {
|
|
panel.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
const activeTab = document.getElementById('lab-flyout-tab-' + tabKey);
|
|
const activePanel = document.getElementById('lab-flyout-panel-' + tabKey);
|
|
|
|
if (activeTab) {
|
|
activeTab.classList.add('active');
|
|
}
|
|
if (activePanel) {
|
|
activePanel.classList.remove('hidden');
|
|
}
|
|
|
|
localStorage.setItem('lab-active-tab', tabKey);
|
|
}
|
|
|
|
// 플라이아웃 위치 동적 계산 및 hover 제어
|
|
function initLabFlyoutPosition() {
|
|
const trigger = document.querySelector('.lab-flyout-trigger');
|
|
const flyout = document.querySelector('.lab-flyout');
|
|
|
|
if (!trigger || !flyout) return;
|
|
|
|
let hideTimeout = null;
|
|
const HIDE_DELAY = 150;
|
|
|
|
function showFlyout() {
|
|
if (hideTimeout) {
|
|
clearTimeout(hideTimeout);
|
|
hideTimeout = null;
|
|
}
|
|
|
|
const triggerRect = trigger.getBoundingClientRect();
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (!sidebar) return;
|
|
const sidebarRect = sidebar.getBoundingClientRect();
|
|
|
|
flyout.style.left = (sidebarRect.right + 8) + 'px';
|
|
flyout.style.top = Math.max(triggerRect.top - 10, 10) + 'px';
|
|
|
|
const flyoutHeight = flyout.offsetHeight || 300;
|
|
const windowHeight = window.innerHeight;
|
|
if (triggerRect.top + flyoutHeight > windowHeight - 20) {
|
|
flyout.style.top = Math.max(windowHeight - flyoutHeight - 20, 10) + 'px';
|
|
}
|
|
|
|
flyout.classList.add('show');
|
|
}
|
|
|
|
function hideFlyout() {
|
|
hideTimeout = setTimeout(function() {
|
|
flyout.classList.remove('show');
|
|
}, HIDE_DELAY);
|
|
}
|
|
|
|
trigger.addEventListener('mouseenter', showFlyout);
|
|
trigger.addEventListener('mouseleave', hideFlyout);
|
|
|
|
flyout.addEventListener('mouseenter', function() {
|
|
if (hideTimeout) {
|
|
clearTimeout(hideTimeout);
|
|
hideTimeout = null;
|
|
}
|
|
});
|
|
flyout.addEventListener('mouseleave', hideFlyout);
|
|
}
|
|
|
|
// JavaScript 기반 툴팁 (overflow 제약 우회) - 이벤트 위임 방식
|
|
function initSidebarTooltips() {
|
|
if (document.getElementById('sidebar-tooltip')) {
|
|
return;
|
|
}
|
|
|
|
const tooltip = document.createElement('div');
|
|
tooltip.id = 'sidebar-tooltip';
|
|
tooltip.className = 'fixed bg-gray-800 text-white text-sm font-medium px-3 py-2 rounded-lg shadow-lg z-[9999] pointer-events-none opacity-0 transition-opacity duration-150 whitespace-nowrap';
|
|
tooltip.style.cssText = 'display: none;';
|
|
document.body.appendChild(tooltip);
|
|
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (!sidebar) return;
|
|
|
|
let currentTarget = null;
|
|
|
|
sidebar.addEventListener('mouseover', function(e) {
|
|
if (!sidebar.classList.contains('sidebar-collapsed') && !document.documentElement.classList.contains('sidebar-is-collapsed')) {
|
|
return;
|
|
}
|
|
|
|
const target = e.target.closest('.sidebar-nav a[title], .sidebar-nav span[title], #dev-tools-group a[title]');
|
|
if (!target || target === currentTarget) return;
|
|
|
|
currentTarget = target;
|
|
const title = target.getAttribute('title');
|
|
if (!title) return;
|
|
|
|
if (!target.hasAttribute('data-tooltip')) {
|
|
target.setAttribute('data-tooltip', title);
|
|
}
|
|
target.removeAttribute('title');
|
|
|
|
tooltip.textContent = title;
|
|
tooltip.style.display = 'block';
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
tooltip.style.left = (rect.right + 12) + 'px';
|
|
tooltip.style.top = (rect.top + (rect.height / 2) - (tooltip.offsetHeight / 2)) + 'px';
|
|
|
|
requestAnimationFrame(() => {
|
|
tooltip.style.opacity = '1';
|
|
});
|
|
});
|
|
|
|
sidebar.addEventListener('mouseout', function(e) {
|
|
const target = e.target.closest('.sidebar-nav a, .sidebar-nav span, #dev-tools-group a');
|
|
if (!target) return;
|
|
|
|
const relatedTarget = e.relatedTarget;
|
|
if (relatedTarget && target.contains(relatedTarget)) {
|
|
return;
|
|
}
|
|
|
|
const originalTitle = target.getAttribute('data-tooltip');
|
|
if (originalTitle) {
|
|
target.setAttribute('title', originalTitle);
|
|
}
|
|
|
|
currentTarget = null;
|
|
tooltip.style.opacity = '0';
|
|
setTimeout(() => {
|
|
if (tooltip.style.opacity === '0') {
|
|
tooltip.style.display = 'none';
|
|
}
|
|
}, 150);
|
|
});
|
|
}
|
|
|
|
// 페이지 로드 시 상태 복원
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// 스크롤 위치 먼저 복원 (가장 먼저 실행)
|
|
restoreSidebarScroll();
|
|
|
|
// 사이드바 상태 복원
|
|
const isCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
|
|
if (isCollapsed) {
|
|
document.documentElement.classList.add('sidebar-is-collapsed');
|
|
document.getElementById('sidebar')?.classList.add('sidebar-collapsed');
|
|
}
|
|
|
|
// 그룹 상태 복원
|
|
['lab-group', 'dev-tools-group'].forEach(function(groupId) {
|
|
const state = localStorage.getItem('sidebar-' + groupId);
|
|
if (state === 'closed') {
|
|
const group = document.getElementById(groupId);
|
|
const icon = document.getElementById(groupId + '-icon');
|
|
if (group) group.style.display = 'none';
|
|
if (icon) icon.style.transform = 'rotate(-90deg)';
|
|
}
|
|
});
|
|
|
|
// CSS preload 스타일을 inline style로 교체 (이후 JS 토글이 정상 동작하도록)
|
|
document.querySelectorAll('[id^="menu-group-"]').forEach(function(group) {
|
|
const groupId = group.id;
|
|
const savedState = localStorage.getItem('menu-group-' + groupId);
|
|
const icon = document.getElementById(groupId + '-icon');
|
|
|
|
// CSS에서 설정한 상태를 inline style로 이전
|
|
if (savedState === 'hidden') {
|
|
group.style.display = 'none';
|
|
if (icon) icon.style.transform = 'rotate(0deg)';
|
|
} else {
|
|
// visible 또는 미설정: 펼침 상태 유지
|
|
group.style.display = 'block';
|
|
if (icon) icon.style.transform = 'rotate(180deg)';
|
|
}
|
|
});
|
|
|
|
// preload 스타일 제거 (이제 inline style이 적용됨)
|
|
const preloadStyle = document.getElementById('menu-group-preload-styles');
|
|
if (preloadStyle) {
|
|
preloadStyle.remove();
|
|
}
|
|
|
|
// R&D Labs 탭 상태 복원
|
|
const savedTab = localStorage.getItem('lab-active-tab');
|
|
if (savedTab && ['s', 'a', 'm'].includes(savedTab)) {
|
|
window._skipLabScroll = true;
|
|
switchLabTab(savedTab);
|
|
switchLabFlyoutTab(savedTab);
|
|
window._skipLabScroll = false;
|
|
}
|
|
|
|
// 플라이아웃 위치 초기화
|
|
initLabFlyoutPosition();
|
|
|
|
// 사이드바 툴팁 초기화
|
|
initSidebarTooltips();
|
|
|
|
// transition 활성화 + 로딩 오버레이 제거
|
|
requestAnimationFrame(function() {
|
|
document.body.classList.remove('no-transition');
|
|
|
|
// 로딩 오버레이 fade-out 후 제거
|
|
const loader = document.getElementById('page-loader');
|
|
if (loader) {
|
|
loader.classList.add('fade-out');
|
|
loader.addEventListener('animationend', function() {
|
|
loader.remove();
|
|
});
|
|
}
|
|
});
|
|
|
|
// R&D Labs 메뉴 클릭 시 스크롤
|
|
const labMenuContainer = document.getElementById('lab-menu-container');
|
|
if (labMenuContainer) {
|
|
labMenuContainer.addEventListener('click', function(e) {
|
|
if (e.target.closest('a[href]')) {
|
|
scrollSidebarToBottom();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 메뉴 클릭 시 스크롤 위치 저장
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (sidebarNav) {
|
|
sidebarNav.addEventListener('click', function(e) {
|
|
const link = e.target.closest('a[href]');
|
|
if (link && !link.closest('#lab-menu-container')) {
|
|
saveSidebarScroll();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 개발 도구 메뉴 클릭 시 스크롤 위치 저장
|
|
const toolsMenu = document.querySelector('.border-t.border-gray-200');
|
|
if (toolsMenu) {
|
|
toolsMenu.addEventListener('click', function(e) {
|
|
const link = e.target.closest('a[href]');
|
|
if (link) {
|
|
saveSidebarScroll();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========== 메뉴 검색 기능 ==========
|
|
|
|
let menuSearchActive = false;
|
|
let originalMenuState = null;
|
|
|
|
// 검색창 열기
|
|
function openMenuSearch() {
|
|
const container = document.getElementById('menu-search-container');
|
|
const input = document.getElementById('menu-search-input');
|
|
const searchBtn = document.getElementById('menu-search-btn');
|
|
|
|
if (!container || !input) return;
|
|
|
|
// 원래 메뉴 상태 저장
|
|
if (!menuSearchActive) {
|
|
saveOriginalMenuState();
|
|
}
|
|
|
|
menuSearchActive = true;
|
|
container.classList.remove('hidden');
|
|
searchBtn.classList.add('hidden');
|
|
|
|
// localStorage에 검색 활성화 상태 저장
|
|
localStorage.setItem('menu-search-active', 'true');
|
|
|
|
// 포커스 및 애니메이션
|
|
setTimeout(() => {
|
|
input.focus();
|
|
}, 50);
|
|
}
|
|
|
|
// 검색창 닫기
|
|
function closeMenuSearch() {
|
|
const container = document.getElementById('menu-search-container');
|
|
const input = document.getElementById('menu-search-input');
|
|
const searchBtn = document.getElementById('menu-search-btn');
|
|
const info = document.getElementById('menu-search-info');
|
|
|
|
if (!container || !input) return;
|
|
|
|
// 검색 결과에서 매칭된 메뉴 링크의 href를 기억 (스크롤 위치 복원용)
|
|
const matchedLink = document.querySelector('.menu-search-match a[href]');
|
|
const activeLink = document.querySelector('.sidebar-nav a.bg-primary');
|
|
const scrollTargetHref = (activeLink && activeLink.closest('.menu-search-match'))
|
|
? activeLink.getAttribute('href')
|
|
: (matchedLink ? matchedLink.getAttribute('href') : null);
|
|
|
|
menuSearchActive = false;
|
|
container.classList.add('hidden');
|
|
searchBtn.classList.remove('hidden');
|
|
input.value = '';
|
|
info.classList.add('hidden');
|
|
|
|
// localStorage에서 검색 상태 제거
|
|
localStorage.removeItem('menu-search-active');
|
|
localStorage.removeItem('menu-search-query');
|
|
|
|
// 원래 메뉴 상태 복원
|
|
restoreOriginalMenuState();
|
|
|
|
// 검색 결과 메뉴 위치로 스크롤
|
|
if (scrollTargetHref) {
|
|
const targetLink = document.querySelector(`.sidebar-nav a[href="${scrollTargetHref}"]`);
|
|
if (targetLink) {
|
|
// 부모 그룹을 펼침
|
|
let parent = targetLink.closest('li');
|
|
while (parent) {
|
|
const groupUl = parent.querySelector(':scope > ul[id^="menu-group-"]');
|
|
if (groupUl) {
|
|
groupUl.style.display = 'block';
|
|
localStorage.setItem('menu-group-' + groupUl.id.replace('menu-group-', ''), 'visible');
|
|
}
|
|
parent = parent.parentElement?.closest('li');
|
|
}
|
|
// 스크롤
|
|
setTimeout(() => {
|
|
targetLink.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
}, 50);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 원래 메뉴 상태 저장
|
|
function saveOriginalMenuState() {
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (!sidebarNav) return;
|
|
|
|
originalMenuState = {
|
|
items: []
|
|
};
|
|
|
|
// 모든 메뉴 아이템과 그룹의 표시 상태 저장
|
|
sidebarNav.querySelectorAll('li, [id^="menu-group-"], #lab-group').forEach(el => {
|
|
originalMenuState.items.push({
|
|
element: el,
|
|
display: el.style.display,
|
|
classList: [...el.classList]
|
|
});
|
|
});
|
|
}
|
|
|
|
// 원래 메뉴 상태 복원 (또는 전체 메뉴 표시)
|
|
function restoreOriginalMenuState() {
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
|
|
if (originalMenuState) {
|
|
// 저장된 상태가 있으면 복원
|
|
originalMenuState.items.forEach(item => {
|
|
item.element.style.display = item.display || '';
|
|
item.element.classList.remove('menu-search-hidden', 'menu-search-match');
|
|
});
|
|
originalMenuState = null;
|
|
} else if (sidebarNav) {
|
|
// 저장된 상태가 없으면 모든 메뉴 표시 (새로고침 후 닫기 시)
|
|
sidebarNav.querySelectorAll('li').forEach(li => {
|
|
li.style.display = '';
|
|
li.classList.remove('menu-search-hidden', 'menu-search-match');
|
|
});
|
|
sidebarNav.querySelectorAll('[id^="menu-group-"], #lab-group').forEach(group => {
|
|
group.style.display = '';
|
|
});
|
|
}
|
|
|
|
// 검색 하이라이트 제거
|
|
document.querySelectorAll('.menu-search-highlight').forEach(el => {
|
|
const parent = el.parentNode;
|
|
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
parent.normalize();
|
|
});
|
|
}
|
|
|
|
// 메뉴 필터링 (실시간 검색)
|
|
function filterMenus(query, skipSave) {
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
const info = document.getElementById('menu-search-info');
|
|
|
|
if (!sidebarNav) return;
|
|
|
|
query = query.trim().toLowerCase();
|
|
|
|
// localStorage에 검색어 저장 (복원 시에는 저장 안 함)
|
|
if (!skipSave) {
|
|
if (query) {
|
|
localStorage.setItem('menu-search-query', query);
|
|
} else {
|
|
localStorage.removeItem('menu-search-query');
|
|
}
|
|
}
|
|
|
|
// 검색어가 비어있으면 모든 메뉴 표시
|
|
if (!query) {
|
|
sidebarNav.querySelectorAll('li').forEach(li => {
|
|
li.style.display = '';
|
|
li.classList.remove('menu-search-hidden', 'menu-search-match');
|
|
});
|
|
sidebarNav.querySelectorAll('[id^="menu-group-"], #lab-group').forEach(group => {
|
|
group.style.display = '';
|
|
});
|
|
// 하이라이트 제거
|
|
document.querySelectorAll('.menu-search-highlight').forEach(el => {
|
|
const parent = el.parentNode;
|
|
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
parent.normalize();
|
|
});
|
|
info.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
let matchCount = 0;
|
|
const groupHeaderMatches = new Set(); // 그룹 헤더가 매칭된 li 추적
|
|
|
|
// 1단계: 그룹 헤더(대분류/서브그룹) 매칭 확인
|
|
sidebarNav.querySelectorAll('li').forEach(li => {
|
|
const groupBtn = li.querySelector(':scope > button.sidebar-group-header, :scope > button.sidebar-subgroup-header');
|
|
if (!groupBtn) return;
|
|
|
|
const groupText = (groupBtn.querySelector('.sidebar-text')?.textContent || '').trim().toLowerCase();
|
|
if (groupText.includes(query)) {
|
|
groupHeaderMatches.add(li);
|
|
}
|
|
});
|
|
|
|
// 2단계: 모든 메뉴 아이템 순회
|
|
const menuItems = sidebarNav.querySelectorAll('li');
|
|
|
|
menuItems.forEach(li => {
|
|
// 그룹 헤더가 매칭된 경우: 헤더 자체 + 모든 하위 메뉴 표시
|
|
if (groupHeaderMatches.has(li)) {
|
|
li.style.display = '';
|
|
li.classList.add('menu-search-match');
|
|
li.classList.remove('menu-search-hidden');
|
|
matchCount++;
|
|
|
|
// 그룹 헤더에 하이라이트 적용
|
|
const groupBtn = li.querySelector(':scope > button.sidebar-group-header, :scope > button.sidebar-subgroup-header');
|
|
if (groupBtn) highlightText(groupBtn, query);
|
|
|
|
// 하위 모든 메뉴 표시
|
|
li.querySelectorAll('li').forEach(child => {
|
|
child.style.display = '';
|
|
child.classList.remove('menu-search-hidden');
|
|
});
|
|
|
|
// 부모 그룹들도 표시
|
|
showParentGroups(li);
|
|
return;
|
|
}
|
|
|
|
// 매칭된 그룹 헤더의 하위 항목이면 이미 처리됨 — 스킵
|
|
let isChildOfMatchedGroup = false;
|
|
for (const matched of groupHeaderMatches) {
|
|
if (matched !== li && matched.contains(li)) {
|
|
isChildOfMatchedGroup = true;
|
|
break;
|
|
}
|
|
}
|
|
if (isChildOfMatchedGroup) return;
|
|
|
|
const link = li.querySelector('a[href], span[title]');
|
|
if (!link) {
|
|
li.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const menuName = link.textContent.trim().toLowerCase();
|
|
const title = (link.getAttribute('title') || '').toLowerCase();
|
|
|
|
if (menuName.includes(query) || title.includes(query)) {
|
|
li.style.display = '';
|
|
li.classList.add('menu-search-match');
|
|
li.classList.remove('menu-search-hidden');
|
|
matchCount++;
|
|
|
|
// 하이라이트 적용
|
|
highlightText(link, query);
|
|
|
|
// 부모 그룹들도 표시
|
|
showParentGroups(li);
|
|
} else {
|
|
li.style.display = 'none';
|
|
li.classList.add('menu-search-hidden');
|
|
li.classList.remove('menu-search-match');
|
|
}
|
|
});
|
|
|
|
// 검색 결과 정보 표시
|
|
if (matchCount > 0) {
|
|
info.textContent = `${matchCount}개 메뉴 발견`;
|
|
info.classList.remove('hidden');
|
|
} else {
|
|
info.textContent = '검색 결과가 없습니다';
|
|
info.classList.remove('hidden');
|
|
}
|
|
|
|
// 모든 그룹 펼치기
|
|
sidebarNav.querySelectorAll('[id^="menu-group-"], #lab-group').forEach(group => {
|
|
if (group.querySelector('.menu-search-match')) {
|
|
group.style.display = 'block';
|
|
} else {
|
|
group.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// 매칭된 그룹 헤더의 하위 그룹도 펼치기
|
|
groupHeaderMatches.forEach(li => {
|
|
li.querySelectorAll('[id^="menu-group-"]').forEach(group => {
|
|
group.style.display = 'block';
|
|
});
|
|
});
|
|
}
|
|
|
|
// 부모 그룹 표시
|
|
function showParentGroups(element) {
|
|
let parent = element.parentElement;
|
|
while (parent) {
|
|
if (parent.id && (parent.id.startsWith('menu-group-') || parent.id === 'lab-group')) {
|
|
parent.style.display = 'block';
|
|
}
|
|
if (parent.tagName === 'LI') {
|
|
parent.style.display = '';
|
|
}
|
|
parent = parent.parentElement;
|
|
if (parent && parent.classList && parent.classList.contains('sidebar-nav')) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 텍스트 하이라이트
|
|
function highlightText(element, query) {
|
|
const textSpan = element.querySelector('.sidebar-text');
|
|
if (!textSpan) return;
|
|
|
|
// 기존 하이라이트 제거
|
|
const existingHighlights = textSpan.querySelectorAll('.menu-search-highlight');
|
|
existingHighlights.forEach(el => {
|
|
const parent = el.parentNode;
|
|
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
parent.normalize();
|
|
});
|
|
|
|
const text = textSpan.textContent;
|
|
const lowerText = text.toLowerCase();
|
|
const index = lowerText.indexOf(query);
|
|
|
|
if (index >= 0) {
|
|
const before = text.substring(0, index);
|
|
const match = text.substring(index, index + query.length);
|
|
const after = text.substring(index + query.length);
|
|
|
|
textSpan.innerHTML = '';
|
|
textSpan.appendChild(document.createTextNode(before));
|
|
|
|
const highlight = document.createElement('span');
|
|
highlight.className = 'menu-search-highlight';
|
|
highlight.textContent = match;
|
|
textSpan.appendChild(highlight);
|
|
|
|
textSpan.appendChild(document.createTextNode(after));
|
|
}
|
|
}
|
|
|
|
// ESC 키로 검색 닫기
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && menuSearchActive) {
|
|
closeMenuSearch();
|
|
}
|
|
});
|
|
|
|
// Ctrl+K 또는 Cmd+K로 검색 열기
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
if (menuSearchActive) {
|
|
closeMenuSearch();
|
|
} else {
|
|
openMenuSearch();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 페이지 로드 시 검색 상태 복원
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const savedSearchActive = localStorage.getItem('menu-search-active');
|
|
const savedSearchQuery = localStorage.getItem('menu-search-query');
|
|
|
|
if (savedSearchActive === 'true') {
|
|
const container = document.getElementById('menu-search-container');
|
|
const input = document.getElementById('menu-search-input');
|
|
const searchBtn = document.getElementById('menu-search-btn');
|
|
|
|
if (container && input) {
|
|
menuSearchActive = true;
|
|
container.classList.remove('hidden');
|
|
searchBtn.classList.add('hidden');
|
|
|
|
// 저장된 검색어가 있으면 복원 및 필터링
|
|
if (savedSearchQuery) {
|
|
input.value = savedSearchQuery;
|
|
// 약간의 딜레이 후 필터링 (DOM 완전 로드 대기)
|
|
setTimeout(() => {
|
|
filterMenus(savedSearchQuery, true);
|
|
}, 100);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
</script>
|