Files
sam-manage/public/js/menu-sortable.js
김보곤 83f10552df feat: [menus] 최상위 그룹 상단/하단 이동 버튼 추가
- depth=0 메뉴에만 이동 버튼(↕) 표시
- 클릭 시 드롭다운으로 상단/하단 이동 선택
- 기존 reorder API 재사용하여 sort_order 일괄 변경
2026-02-28 08:24:36 +09:00

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');
});
};