/** * 메뉴 관리 페이지 전용 SortableJS 초기화 스크립트 * HTMX hx-boost 네비게이션에서도 동작하도록 전역으로 정의 */ // 드래그 상태 관리 let dragStartX = 0; let dragIndicator = null; let currentDragItem = null; let lastHighlightedRow = null; 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'; // 클래스 및 텍스트 업데이트 dragIndicator.classList.remove('indent', 'outdent', 'reorder'); if (deltaX > INDENT_THRESHOLD && canIndent) { dragIndicator.classList.add('indent'); dragIndicator.innerHTML = `→ ${targetName}의 하위로`; } else if (deltaX < -INDENT_THRESHOLD && canOutdent) { dragIndicator.classList.add('outdent'); dragIndicator.innerHTML = '← 상위 레벨로'; } else { dragIndicator.classList.add('reorder'); dragIndicator.innerHTML = '↕️ 순서 변경'; } } // 행 하이라이트 업데이트 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(새 부모가 될 행) 하이라이트 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; } } // 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')); 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'); // 마우스 이동 이벤트 리스너 추가 document.addEventListener('mousemove', onDragMove); document.addEventListener('touchmove', onDragMove); }, // 드래그 종료 시 처리 onEnd: function(evt) { // 이벤트 리스너 및 인디케이터 정리 document.removeEventListener('mousemove', onDragMove); document.removeEventListener('touchmove', onDragMove); removeDragIndicator(); currentDragItem = null; // 드래그 중 텍스트 선택 방지 해제 document.body.classList.remove('is-dragging'); 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; 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; // 계층 변경 API 호출 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'); }); }; // 메뉴 순서 저장 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'); }); };