1. /menus 페이지 hx-boost 네비게이션 시 SortableJS 미실행 수정 - htmx:afterSettle 이벤트로 페이지별 스크립트 초기화 - menu-sortable.js로 SortableJS 로직 분리 - 중복 코드 제거 2. 세션 만료 시 자동 갱신 로직 추가 - /auth/refresh-session 엔드포인트 추가 - Remember Token으로 자동 재인증 (자동 로그인 사용자) - 재인증 실패 시 로그인 페이지 리다이렉트
303 lines
11 KiB
JavaScript
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');
|
|
});
|
|
}; |