Files
sam-manage/public/js/menu-sortable.js
hskwon 7c0bed7dd9 fix(MNG): HTMX 네비게이션 스크립트 초기화 및 세션 자동 갱신
1. /menus 페이지 hx-boost 네비게이션 시 SortableJS 미실행 수정
   - htmx:afterSettle 이벤트로 페이지별 스크립트 초기화
   - menu-sortable.js로 SortableJS 로직 분리
   - 중복 코드 제거

2. 세션 만료 시 자동 갱신 로직 추가
   - /auth/refresh-session 엔드포인트 추가
   - Remember Token으로 자동 재인증 (자동 로그인 사용자)
   - 재인증 실패 시 로그인 페이지 리다이렉트
2026-01-20 13:42:28 +09:00

303 lines
11 KiB
JavaScript

/**
* 메뉴 관리 페이지 전용 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 = `→ <b>${targetName}</b>의 하위로`;
} 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');
});
};