feat:메뉴 관리 그룹 드래그 앤 드롭 구현
체크된 메뉴 항목들을 함께 드래그하여 이동할 수 있도록 개선: - 상위 메뉴 체크 시 하위 메뉴도 함께 그룹으로 묶여서 이동 - 드래그 중 그룹 항목 수 뱃지 표시 - 드래그 인디케이터에 그룹 개수 표시 - 그룹 이동 시 순차적으로 API 호출 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 메뉴 관리 페이지 전용 SortableJS 초기화 스크립트
|
||||
* HTMX hx-boost 네비게이션에서도 동작하도록 전역으로 정의
|
||||
* - 그룹 드래그: 체크된 항목들을 함께 이동
|
||||
*/
|
||||
|
||||
// 드래그 상태 관리
|
||||
@@ -8,6 +9,7 @@ let dragStartX = 0;
|
||||
let dragIndicator = null;
|
||||
let currentDragItem = null;
|
||||
let lastHighlightedRow = null;
|
||||
let groupDragItems = []; // 그룹 드래그 대상 행들
|
||||
const INDENT_THRESHOLD = 40; // px - 인덴트 임계값
|
||||
|
||||
// 드래그 인디케이터 생성
|
||||
@@ -28,18 +30,21 @@ function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targ
|
||||
dragIndicator.style.left = (mouseX + 15) + 'px';
|
||||
dragIndicator.style.top = (mouseY - 15) + 'px';
|
||||
|
||||
const groupCount = groupDragItems.length;
|
||||
const groupLabel = groupCount > 1 ? ` <span style="background:rgba(255,255,255,0.3);padding:1px 6px;border-radius:10px;margin-left:4px;font-size:11px;">${groupCount}개</span>` : '';
|
||||
|
||||
// 클래스 및 텍스트 업데이트
|
||||
dragIndicator.classList.remove('indent', 'outdent', 'reorder');
|
||||
|
||||
if (deltaX > INDENT_THRESHOLD && canIndent) {
|
||||
dragIndicator.classList.add('indent');
|
||||
dragIndicator.innerHTML = `→ <b>${targetName}</b>의 하위로`;
|
||||
dragIndicator.innerHTML = `→ <b>${targetName}</b>의 하위로${groupLabel}`;
|
||||
} else if (deltaX < -INDENT_THRESHOLD && canOutdent) {
|
||||
dragIndicator.classList.add('outdent');
|
||||
dragIndicator.innerHTML = '← 상위 레벨로';
|
||||
dragIndicator.innerHTML = `← 상위 레벨로${groupLabel}`;
|
||||
} else {
|
||||
dragIndicator.classList.add('reorder');
|
||||
dragIndicator.innerHTML = '↕️ 순서 변경';
|
||||
dragIndicator.innerHTML = `↕️ 순서 변경${groupLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +57,9 @@ function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent)
|
||||
|
||||
// 새 하이라이트 적용
|
||||
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 {
|
||||
@@ -76,6 +79,40 @@ function removeDragIndicator() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크된 항목 + 그 하위 메뉴를 재귀적으로 수집
|
||||
*/
|
||||
function collectGroupItems(draggedRow) {
|
||||
const draggedId = draggedRow.dataset.menuId;
|
||||
const checkbox = draggedRow.querySelector('.menu-checkbox');
|
||||
|
||||
// 드래그 항목이 체크 안 되어 있으면 단독 드래그
|
||||
if (!checkbox || !checkbox.checked) return [draggedRow];
|
||||
|
||||
// 체크된 모든 항목 수집
|
||||
const checkedRows = Array.from(document.querySelectorAll('#menu-sortable .menu-checkbox:checked'))
|
||||
.map(cb => cb.closest('tr.menu-row'))
|
||||
.filter(Boolean);
|
||||
|
||||
if (checkedRows.length <= 1) return [draggedRow];
|
||||
|
||||
return checkedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메뉴의 모든 하위 메뉴 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');
|
||||
@@ -100,7 +137,7 @@ window.initMenuSortable = function() {
|
||||
const mouseY = e.clientY || e.touches?.[0]?.clientY || 0;
|
||||
const deltaX = mouseX - dragStartX;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.menu-row:not(.group-drag-hidden)'));
|
||||
const draggedIndex = rows.indexOf(currentDragItem);
|
||||
|
||||
// 위 행 찾기 (드래그 중인 항목 제외)
|
||||
@@ -152,6 +189,28 @@ window.initMenuSortable = function() {
|
||||
// 드래그 중 텍스트 선택 방지
|
||||
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);
|
||||
@@ -163,11 +222,19 @@ window.initMenuSortable = function() {
|
||||
document.removeEventListener('mousemove', onDragMove);
|
||||
document.removeEventListener('touchmove', onDragMove);
|
||||
removeDragIndicator();
|
||||
currentDragItem = null;
|
||||
|
||||
// 드래그 중 텍스트 선택 방지 해제
|
||||
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;
|
||||
@@ -205,6 +272,16 @@ window.initMenuSortable = function() {
|
||||
}
|
||||
|
||||
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) {
|
||||
// 새 부모 하위에서의 순서 계산
|
||||
@@ -223,8 +300,13 @@ window.initMenuSortable = function() {
|
||||
}
|
||||
sortOrder = siblingsBeforeMe + 1;
|
||||
|
||||
// 계층 변경 API 호출
|
||||
window.moveMenu(menuId, newParentId, sortOrder, csrfToken);
|
||||
if (isGroupDrag) {
|
||||
// 그룹 이동: 대표 항목(드래그한 항목)만 이동 후 새로고침
|
||||
// 나머지 체크된 항목도 순차적으로 이동
|
||||
window.moveMenuGroup(groupMenuIds, menuId, newParentId, sortOrder, csrfToken);
|
||||
} else {
|
||||
window.moveMenu(menuId, newParentId, sortOrder, csrfToken);
|
||||
}
|
||||
} else {
|
||||
// 같은 레벨 내 순서 변경
|
||||
const siblingRows = rows.filter(row => {
|
||||
@@ -275,6 +357,61 @@ window.moveMenu = function(menuId, newParentId, sortOrder, csrfToken) {
|
||||
});
|
||||
};
|
||||
|
||||
// 그룹 메뉴 이동 (체크된 항목들 순차 이동)
|
||||
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');
|
||||
});
|
||||
};
|
||||
|
||||
// 메뉴 순서 저장 API 호출 (같은 레벨)
|
||||
window.saveMenuOrder = function(items, csrfToken) {
|
||||
fetch('/api/admin/menus/reorder', {
|
||||
@@ -300,4 +437,4 @@ window.saveMenuOrder = function(items, csrfToken) {
|
||||
showToast('순서 변경 중 오류 발생', 'error');
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -270,6 +270,32 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
/* 그룹 드래그: 숨김 상태 (드래그 중 나머지 체크된 항목) */
|
||||
.group-drag-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 그룹 드래그: 항목 수 뱃지 */
|
||||
.group-drag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
animation: badge-pulse 1s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
|
||||
Reference in New Issue
Block a user