- depth=0 메뉴에만 이동 버튼(↕) 표시 - 클릭 시 드롭다운으로 상단/하단 이동 선택 - 기존 reorder API 재사용하여 sort_order 일괄 변경
535 lines
20 KiB
JavaScript
535 lines
20 KiB
JavaScript
/**
|
|
* 메뉴 관리 페이지 전용 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 ? ` <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>의 하위로${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 = `
|
|
<button type="button" onclick="moveGroupToPosition(${menuId}, 'top')" class="move-group-option">
|
|
<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="M5 15l7-7 7 7" />
|
|
</svg>
|
|
상단으로 이동
|
|
</button>
|
|
<button type="button" onclick="moveGroupToPosition(${menuId}, 'bottom')" class="move-group-option">
|
|
<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="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
하단으로 이동
|
|
</button>
|
|
`;
|
|
|
|
// 버튼 위치 기준으로 드롭다운 배치
|
|
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');
|
|
});
|
|
};
|