From 2f73a1f4e63cefd2c39e49e5fa8f6b7d70eb12e6 Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 1 Dec 2025 21:14:19 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A9=94=EB=89=B4=20=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=20=EC=9D=B8=EB=8D=B4=ED=8A=B8/=EC=95=84=EC=9B=83?= =?UTF-8?q?=EB=8D=B4=ED=8A=B8=20=EC=8B=9C=EA=B0=81=EC=A0=81=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notion 스타일 좌우 드래그로 계층 이동 방식 변경 - → 오른쪽 드래그: 상위 메뉴의 하위로 이동 (파란색 하이라이트) - ← 왼쪽 드래그: 상위 레벨로 이동 (주황색 하이라이트) - 드래그 인디케이터 툴팁 추가 (인덴트/아웃덴트/순서변경) - CSS 펄스 애니메이션으로 타겟 행 강조 - updateRowHighlight 함수 수정: 아웃덴트 시 드래그 중인 행 하이라이트 --- resources/views/menus/index.blade.php | 329 +++++++++++++++++++++++--- 1 file changed, 295 insertions(+), 34 deletions(-) diff --git a/resources/views/menus/index.blade.php b/resources/views/menus/index.blade.php index 6d7b8567..6fffb0bd 100644 --- a/resources/views/menus/index.blade.php +++ b/resources/views/menus/index.blade.php @@ -8,7 +8,7 @@

메뉴 관리

- 드래그: 같은 레벨 순서 변경 | Shift+드래그: 위 메뉴의 하위로 이동 + 드래그: 순서 변경 | → 오른쪽: 하위로 이동 | ← 왼쪽: 상위로 이동

@@ -57,6 +57,74 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> @endsection +@push('styles') + +@endpush + @push('scripts') @@ -79,6 +147,79 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> } }); + // 드래그 상태 관리 + 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 초기화 함수 function initMenuSortable() { const tbody = document.getElementById('menu-sortable'); @@ -89,63 +230,173 @@ function initMenuSortable() { tbody.sortableInstance.destroy(); } - // SortableJS 초기화 - 계층 이동 지원 + // 드래그 중 마우스 이동 핸들러 + 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); + } + + // SortableJS 초기화 - 노션 스타일 인덴트 tbody.sortableInstance = new Sortable(tbody, { handle: '.drag-handle', animation: 150, 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.addEventListener('mousemove', onDragMove); + document.addEventListener('touchmove', onDragMove); + + console.log('=== Drag Start ==='); + console.log('dragStartX:', dragStartX); + }, + + // 드래그 종료 시 처리 onEnd: function(evt) { + // 이벤트 리스너 및 인디케이터 정리 + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('touchmove', onDragMove); + removeDragIndicator(); + currentDragItem = null; + const movedItem = evt.item; const menuId = parseInt(movedItem.dataset.menuId); - const oldParentId = movedItem.dataset.parentId || null; + const currentDepth = parseInt(movedItem.dataset.depth) || 0; + 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); + const oldIndex = evt.oldIndex; - // 새 부모 결정: Shift 키로 계층 이동 - let newParentId = null; + // X 이동량 계산 + const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX; + const deltaX = dragEndX - dragStartX; + + console.log('=== Menu Drag Debug (Indent Style) ==='); + console.log('menuId:', menuId, 'currentDepth:', currentDepth); + console.log('oldIndex:', oldIndex, '→ newIndex:', newIndex); + console.log('dragStartX:', dragStartX, 'dragEndX:', dragEndX, 'deltaX:', deltaX); + console.log('oldParentId:', oldPid); + + let newParentId = oldPid; let sortOrder = 1; + let action = 'reorder'; // 'reorder', 'indent', 'outdent' - if (newIndex > 0) { + // 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로) + if (deltaX > INDENT_THRESHOLD && newIndex > 0) { const prevRow = rows[newIndex - 1]; if (prevRow) { const prevId = parseInt(prevRow.dataset.menuId); - const prevParentId = prevRow.dataset.parentId || null; - - // Shift+드래그: 위 행의 하위로 이동 - if (evt.originalEvent && evt.originalEvent.shiftKey) { + // 자기 자신의 자식으로는 이동 불가 + if (prevId !== menuId) { newParentId = prevId; - } else { - // 일반 드래그: 위 행과 같은 부모 - newParentId = prevParentId ? parseInt(prevParentId) : null; + action = 'indent'; + console.log('→ INDENT: 위 항목의 자식으로 이동, newParentId:', newParentId); } } } + // 왼쪽으로 이동 → 아웃덴트 (부모의 형제로) + else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) { + // 현재 부모를 찾아서 그 부모의 parent_id를 가져옴 + const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid); + if (parentRow) { + const grandParentIdRaw = parentRow.dataset.parentId; + newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null); + action = 'outdent'; + console.log('← OUTDENT: 부모의 형제로 이동, newParentId:', newParentId); + } else { + // 부모를 찾을 수 없으면 최상위로 + newParentId = null; + action = 'outdent'; + console.log('← OUTDENT: 최상위로 이동'); + } + } + // 수평 이동 없음 → 같은 레벨에서 순서만 변경 + else { + // 위 행과 같은 부모로 (기존 동작) + if (newIndex > 0) { + const prevRow = rows[newIndex - 1]; + if (prevRow) { + const prevParentIdRaw = prevRow.dataset.parentId; + newParentId = prevParentIdRaw === '' ? null : (prevParentIdRaw ? parseInt(prevParentIdRaw) : null); + } + } else { + newParentId = null; + } + console.log('↔ REORDER: 위 행과 같은 레벨로, newParentId:', newParentId); + } - // 부모 변경 여부 확인 - const oldPid = oldParentId === '' ? null : (oldParentId ? parseInt(oldParentId) : null); - const newPid = newParentId; - const parentChanged = oldPid !== newPid; + const parentChanged = oldPid !== newParentId; + console.log('oldPid:', oldPid, 'newPid:', newParentId, 'parentChanged:', parentChanged); + console.log('action:', action); if (parentChanged) { // 새 부모 하위에서의 순서 계산 - const siblingRows = rows.filter(row => { - const rowParentId = row.dataset.parentId || null; - const rowPid = rowParentId === '' ? null : (rowParentId ? parseInt(rowParentId) : null); - return rowPid === newPid; + const existingSiblings = rows.filter(row => { + if (row === movedItem) return false; + const rowParentId = row.dataset.parentId || ''; + const rowPid = rowParentId === '' ? null : parseInt(rowParentId); + return rowPid === newParentId; }); - sortOrder = siblingRows.indexOf(movedItem) + 1; - if (sortOrder <= 0) sortOrder = siblingRows.length + 1; + + // DOM에서 movedItem보다 앞에 있는 형제의 수 = 새 순서 + let siblingsBeforeMe = 0; + for (const sibling of existingSiblings) { + if (rows.indexOf(sibling) < newIndex) { + siblingsBeforeMe++; + } + } + sortOrder = siblingsBeforeMe + 1; + + console.log('existingSiblings count:', existingSiblings.length); + console.log('siblingsBeforeMe:', siblingsBeforeMe, 'sortOrder:', sortOrder); // 계층 변경 API 호출 - moveMenu(menuId, newPid, sortOrder); + moveMenu(menuId, newParentId, sortOrder); } else { // 같은 레벨 내 순서 변경 const siblingRows = rows.filter(row => { - const rowParentId = row.dataset.parentId || null; - const rowPid = rowParentId === '' ? null : (rowParentId ? parseInt(rowParentId) : null); + const rowParentId = row.dataset.parentId || ''; + const rowPid = rowParentId === '' ? null : parseInt(rowParentId); return rowPid === oldPid; }); @@ -154,6 +405,7 @@ function initMenuSortable() { sort_order: index + 1 })); + console.log('Same level reorder:', items); saveMenuOrder(items); } } @@ -162,6 +414,13 @@ function initMenuSortable() { // 메뉴 이동 API 호출 (계층 변경) function moveMenu(menuId, newParentId, sortOrder) { + const payload = { + menu_id: menuId, + new_parent_id: newParentId, + sort_order: sortOrder + }; + console.log('moveMenu API Request:', payload); + fetch('/api/admin/menus/move', { method: 'POST', headers: { @@ -169,23 +428,25 @@ function moveMenu(menuId, newParentId, sortOrder) { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, - body: JSON.stringify({ - menu_id: menuId, - new_parent_id: newParentId, - sort_order: sortOrder - }) + body: JSON.stringify(payload) + }) + .then(response => { + console.log('moveMenu API Response status:', response.status); + return response.json(); }) - .then(response => response.json()) .then(data => { + console.log('moveMenu API Response data:', data); if (data.success) { + console.log('메뉴 이동 성공!'); htmx.trigger('#menu-table', 'filterSubmit'); } else { + console.error('메뉴 이동 실패:', data.message); alert('메뉴 이동 실패: ' + (data.message || '')); htmx.trigger('#menu-table', 'filterSubmit'); } }) .catch(error => { - console.error('Error:', error); + console.error('moveMenu API Error:', error); alert('메뉴 이동 중 오류 발생'); htmx.trigger('#menu-table', 'filterSubmit'); });