/** * 메뉴 관리 페이지 전용 SortableJS 초기화 스크립트 * HTMX hx-boost 네비게이션에서도 동작하도록 전역으로 정의 * - 그룹 드래그: 상위 메뉴 드래그 시 하위 메뉴 자동 포함 */ // 드래그 상태 관리 let dragStartX = 0; let dragIndicator = null; let currentDragItem = null; let lastHighlightedRow = null; let groupDragItems = []; // 그룹 드래그 대상 행들 const INDENT_THRESHOLD = 40; // px - 인덴트 임계값 // 드래그 인디케이터 생성 function createDragIndicator() { const indicator = document.createElement('div'); indicator.className = 'drag-indicator reorder'; indicator.innerHTML = '↕️ 순서 변경'; indicator.style.display = 'none'; document.body.appendChild(indicator); return indicator; } // 드래그 인디케이터 업데이트 function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName) { if (!dragIndicator) return; dragIndicator.style.display = 'block'; dragIndicator.style.left = (mouseX + 15) + 'px'; dragIndicator.style.top = (mouseY - 15) + 'px'; const groupCount = groupDragItems.length; const groupLabel = groupCount > 1 ? ` ${groupCount}개` : ''; // 클래스 및 텍스트 업데이트 dragIndicator.classList.remove('indent', 'outdent', 'reorder'); if (deltaX > INDENT_THRESHOLD && canIndent) { dragIndicator.classList.add('indent'); dragIndicator.innerHTML = `→ ${targetName}의 하위로${groupLabel}`; } else if (deltaX < -INDENT_THRESHOLD && canOutdent) { dragIndicator.classList.add('outdent'); dragIndicator.innerHTML = `← 상위 레벨로${groupLabel}`; } else { dragIndicator.classList.add('reorder'); dragIndicator.innerHTML = `↕️ 순서 변경${groupLabel}`; } } // 행 하이라이트 업데이트 function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent) { // 이전 하이라이트 제거 if (lastHighlightedRow) { lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent'); } // 새 하이라이트 적용 if (deltaX > INDENT_THRESHOLD && canIndent && prevRow) { prevRow.classList.add('drag-target-indent'); lastHighlightedRow = prevRow; } else if (deltaX < -INDENT_THRESHOLD && canOutdent && draggedRow) { draggedRow.classList.add('drag-target-outdent'); lastHighlightedRow = draggedRow; } else { lastHighlightedRow = null; } } // 드래그 인디케이터 제거 function removeDragIndicator() { if (dragIndicator) { dragIndicator.remove(); dragIndicator = null; } if (lastHighlightedRow) { lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent'); lastHighlightedRow = null; } } /** * 상위 메뉴 드래그 시 하위 메뉴를 재귀적으로 수집 * - 자식이 있는 메뉴(상위 메뉴)를 드래그하면 자동으로 모든 하위 포함 * - 자식이 없는 메뉴(리프 메뉴)는 단독 드래그 */ function collectGroupItems(draggedRow) { const draggedId = draggedRow.dataset.menuId; // 하위 메뉴가 있는지 확인 (DOM에서 data-parent-id로 탐색) const directChildren = document.querySelectorAll(`#menu-sortable tr.menu-row[data-parent-id="${draggedId}"]`); if (directChildren.length === 0) return [draggedRow]; // 드래그 항목 + 모든 하위 메뉴를 재귀적으로 수집 const items = [draggedRow]; const descendantIds = getDescendantIds(draggedId); descendantIds.forEach(id => { const row = document.querySelector(`#menu-sortable tr.menu-row[data-menu-id="${id}"]`); if (row) items.push(row); }); return items; } /** * 특정 메뉴의 모든 하위 메뉴 ID를 재귀적으로 수집 */ function getDescendantIds(parentId) { const ids = []; const children = document.querySelectorAll(`tr.menu-row[data-parent-id="${parentId}"]`); children.forEach(child => { const childId = child.dataset.menuId; ids.push(childId); ids.push(...getDescendantIds(childId)); }); return ids; } // SortableJS 초기화 함수 (전역) window.initMenuSortable = function() { const tbody = document.getElementById('menu-sortable'); if (!tbody) return; // Sortable 라이브러리 체크 if (typeof Sortable === 'undefined') { console.warn('SortableJS not loaded'); return; } // 기존 인스턴스 제거 if (tbody.sortableInstance) { tbody.sortableInstance.destroy(); } // 드래그 중 마우스 이동 핸들러 function onDragMove(e) { if (!currentDragItem) return; const mouseX = e.clientX || e.touches?.[0]?.clientX || 0; const mouseY = e.clientY || e.touches?.[0]?.clientY || 0; const deltaX = mouseX - dragStartX; const rows = Array.from(tbody.querySelectorAll('tr.menu-row:not(.group-drag-hidden)')); const draggedIndex = rows.indexOf(currentDragItem); // 위 행 찾기 (드래그 중인 항목 제외) let prevRow = null; for (let i = draggedIndex - 1; i >= 0; i--) { if (rows[i] !== currentDragItem && !rows[i].classList.contains('sortable-ghost')) { prevRow = rows[i]; break; } } // 인덴트/아웃덴트 가능 여부 체크 const oldParentIdRaw = currentDragItem.dataset.parentId; const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null); const canIndent = prevRow !== null; const canOutdent = oldPid !== null; // 타겟 이름 가져오기 let targetName = ''; if (prevRow && canIndent) { const nameCell = prevRow.querySelector('td:nth-child(3) span'); targetName = nameCell?.textContent?.trim() || '위 항목'; } updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName); updateRowHighlight(prevRow, currentDragItem, deltaX, canIndent, canOutdent); } // CSRF 토큰 가져오기 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; // SortableJS 초기화 - 노션 스타일 인덴트 tbody.sortableInstance = new Sortable(tbody, { handle: '.drag-handle', animation: 150, forceFallback: true, fallbackClass: 'sortable-fallback', fallbackOnBody: true, ghostClass: 'bg-blue-50', chosenClass: 'bg-blue-100', dragClass: 'shadow-lg', // 드래그 시작 시 onStart: function(evt) { dragStartX = evt.originalEvent?.clientX || evt.originalEvent?.touches?.[0]?.clientX || 0; currentDragItem = evt.item; dragIndicator = createDragIndicator(); // 드래그 중 텍스트 선택 방지 document.body.classList.add('is-dragging'); // 그룹 드래그 처리 groupDragItems = collectGroupItems(currentDragItem); if (groupDragItems.length > 1) { const draggedId = currentDragItem.dataset.menuId; // 드래그 중인 항목 제외한 그룹 항목들 숨기기 groupDragItems.forEach(row => { if (row !== currentDragItem) { row.classList.add('group-drag-hidden'); row.style.display = 'none'; } }); // 드래그 항목에 그룹 뱃지 추가 const badge = document.createElement('span'); badge.className = 'group-drag-badge'; badge.textContent = groupDragItems.length + '개 항목'; const nameCell = currentDragItem.querySelector('td:nth-child(4)'); if (nameCell) nameCell.appendChild(badge); } // 마우스 이동 이벤트 리스너 추가 document.addEventListener('mousemove', onDragMove); document.addEventListener('touchmove', onDragMove); }, // 드래그 종료 시 처리 onEnd: function(evt) { // 이벤트 리스너 및 인디케이터 정리 document.removeEventListener('mousemove', onDragMove); document.removeEventListener('touchmove', onDragMove); removeDragIndicator(); // 드래그 중 텍스트 선택 방지 해제 document.body.classList.remove('is-dragging'); // 그룹 뱃지 제거 document.querySelectorAll('.group-drag-badge').forEach(b => b.remove()); // 숨긴 행 복원 document.querySelectorAll('.group-drag-hidden').forEach(row => { row.classList.remove('group-drag-hidden'); row.style.display = ''; }); const movedItem = evt.item; const menuId = parseInt(movedItem.dataset.menuId); const oldParentIdRaw = movedItem.dataset.parentId; const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null); const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); const newIndex = rows.indexOf(movedItem); // X 이동량 계산 const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX; const deltaX = dragEndX - dragStartX; let newParentId = oldPid; let sortOrder = 1; // 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로) if (deltaX > INDENT_THRESHOLD && newIndex > 0) { const prevRow = rows[newIndex - 1]; if (prevRow) { const prevId = parseInt(prevRow.dataset.menuId); if (prevId !== menuId) { newParentId = prevId; } } } // 왼쪽으로 이동 → 아웃덴트 (부모의 형제로) else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) { const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid); if (parentRow) { const grandParentIdRaw = parentRow.dataset.parentId; newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null); } else { newParentId = null; } } const parentChanged = oldPid !== newParentId; const isGroupDrag = groupDragItems.length > 1; // 그룹 드래그인 경우: 모든 체크된 항목의 ID 수집 const groupMenuIds = isGroupDrag ? groupDragItems.map(r => parseInt(r.dataset.menuId)) : [menuId]; // 상태 초기화 groupDragItems = []; currentDragItem = null; if (parentChanged) { // 새 부모 하위에서의 순서 계산 const existingSiblings = rows.filter(row => { if (row === movedItem) return false; const rowParentId = row.dataset.parentId || ''; const rowPid = rowParentId === '' ? null : parseInt(rowParentId); return rowPid === newParentId; }); let siblingsBeforeMe = 0; for (const sibling of existingSiblings) { if (rows.indexOf(sibling) < newIndex) { siblingsBeforeMe++; } } sortOrder = siblingsBeforeMe + 1; if (isGroupDrag) { // 그룹 이동: 대표 항목(드래그한 항목)만 이동 후 새로고침 // 나머지 체크된 항목도 순차적으로 이동 window.moveMenuGroup(groupMenuIds, menuId, newParentId, sortOrder, csrfToken); } else { window.moveMenu(menuId, newParentId, sortOrder, csrfToken); } } else { // 같은 레벨 내 순서 변경 const siblingRows = rows.filter(row => { const rowParentId = row.dataset.parentId || ''; const rowPid = rowParentId === '' ? null : parseInt(rowParentId); return rowPid === oldPid; }); const items = siblingRows.map((row, index) => ({ id: parseInt(row.dataset.menuId), sort_order: index + 1 })); window.saveMenuOrder(items, csrfToken); } } }); }; // 메뉴 이동 API 호출 (계층 변경) window.moveMenu = function(menuId, newParentId, sortOrder, csrfToken) { fetch('/api/admin/menus/move', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }, body: JSON.stringify({ menu_id: menuId, new_parent_id: newParentId, sort_order: sortOrder }) }) .then(response => response.json()) .then(data => { if (data.success) { htmx.trigger('#menu-table', 'filterSubmit'); } else { showToast('메뉴 이동 실패: ' + (data.message || ''), 'error'); htmx.trigger('#menu-table', 'filterSubmit'); } }) .catch(error => { console.error('moveMenu API Error:', error); showToast('메뉴 이동 중 오류 발생', 'error'); htmx.trigger('#menu-table', 'filterSubmit'); }); }; // 그룹 메뉴 이동 (체크된 항목들 순차 이동) window.moveMenuGroup = function(groupMenuIds, leadId, newParentId, sortOrder, csrfToken) { // 대표 항목 먼저 이동 const movePromise = fetch('/api/admin/menus/move', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }, body: JSON.stringify({ menu_id: leadId, new_parent_id: newParentId, sort_order: sortOrder }) }).then(r => r.json()); // 나머지 항목들도 동일한 부모 아래로 이동 (대표 항목 이후 순서로) const otherIds = groupMenuIds.filter(id => id !== leadId); if (otherIds.length === 0) { movePromise.then(() => htmx.trigger('#menu-table', 'filterSubmit')); return; } movePromise.then(() => { // 나머지 항목을 순차적으로 이동 const promises = otherIds.map((id, idx) => fetch('/api/admin/menus/move', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }, body: JSON.stringify({ menu_id: id, new_parent_id: newParentId, sort_order: sortOrder + idx + 1 }) }).then(r => r.json()) ); return Promise.all(promises); }) .then(() => { htmx.trigger('#menu-table', 'filterSubmit'); }) .catch(error => { console.error('moveMenuGroup error:', error); showToast('그룹 이동 중 오류 발생', 'error'); htmx.trigger('#menu-table', 'filterSubmit'); }); }; // ===== 최상위 그룹 상단/하단 이동 ===== // 이동 드롭다운 표시 window.showMoveGroupDropdown = function(menuId, buttonEl) { // 기존 드롭다운 제거 const existing = document.getElementById('move-group-dropdown'); if (existing) { existing.remove(); // 같은 버튼 클릭 시 토글 (닫기) if (existing.dataset.menuId === String(menuId)) return; } const dropdown = document.createElement('div'); dropdown.id = 'move-group-dropdown'; dropdown.dataset.menuId = menuId; dropdown.className = 'move-group-dropdown'; dropdown.innerHTML = ` `; // 버튼 위치 기준으로 드롭다운 배치 const rect = buttonEl.getBoundingClientRect(); dropdown.style.position = 'fixed'; dropdown.style.left = rect.left + 'px'; dropdown.style.top = (rect.bottom + 4) + 'px'; dropdown.style.zIndex = '9999'; document.body.appendChild(dropdown); // 외부 클릭 시 닫기 function closeDropdown(e) { if (!dropdown.contains(e.target) && e.target !== buttonEl && !buttonEl.contains(e.target)) { dropdown.remove(); document.removeEventListener('click', closeDropdown, true); } } // 다음 이벤트 루프에서 리스너 등록 (현재 클릭 무시) setTimeout(() => document.addEventListener('click', closeDropdown, true), 0); }; // 최상위 그룹을 상단/하단으로 이동 window.moveGroupToPosition = function(menuId, position) { // 드롭다운 닫기 const dropdown = document.getElementById('move-group-dropdown'); if (dropdown) dropdown.remove(); // DOM에서 최상위 그룹(depth=0, parent_id="") 수집 const allRows = document.querySelectorAll('#menu-sortable tr.menu-row[data-depth="0"][data-parent-id=""]'); if (allRows.length === 0) return; const groupIds = Array.from(allRows).map(row => parseInt(row.dataset.menuId)); const targetIndex = groupIds.indexOf(menuId); if (targetIndex === -1) return; // 이미 해당 위치에 있으면 무시 if (position === 'top' && targetIndex === 0) return; if (position === 'bottom' && targetIndex === groupIds.length - 1) return; // 대상을 배열에서 제거 후 처음/끝에 삽입 groupIds.splice(targetIndex, 1); if (position === 'top') { groupIds.unshift(menuId); } else { groupIds.push(menuId); } // sort_order 계산 (1부터 순차) const items = groupIds.map((id, index) => ({ id: id, sort_order: index + 1 })); // CSRF 토큰 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; // 기존 saveMenuOrder API 호출 window.saveMenuOrder(items, csrfToken); }; // 메뉴 순서 저장 API 호출 (같은 레벨) window.saveMenuOrder = function(items, csrfToken) { fetch('/api/admin/menus/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }, body: JSON.stringify({ items: items }) }) .then(response => response.json()) .then(data => { if (data.success) { htmx.trigger('#menu-table', 'filterSubmit'); } else { showToast('순서 변경 실패: ' + (data.message || ''), 'error'); htmx.trigger('#menu-table', 'filterSubmit'); } }) .catch(error => { console.error('Error:', error); showToast('순서 변경 중 오류 발생', 'error'); htmx.trigger('#menu-table', 'filterSubmit'); }); };