Fix: 업로드 및 API 연결 문제 해결
- FastAPI 라우터에서 슬래시 문제로 인한 307 리다이렉트 수정 - Nginx 프록시 설정에서 경로 중복 문제 해결 - 계정 관리 시스템 구현 (로그인, 사용자 관리, 권한 설정) - 노트북 연결 기능 수정 (notebook_id 필드 추가) - 메모 트리 UI 개선 (수평 레이아웃, 드래그 기능 제거) - 헤더 UI 개선 및 고정 위치 설정 - 백업/복원 스크립트 추가 - PDF 미리보기 토큰 인증 지원
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/* 메인 스타일 */
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
padding-top: 4rem; /* 고정 헤더를 위한 패딩 */
|
||||
}
|
||||
|
||||
/* 알림 애니메이션 */
|
||||
|
||||
41
frontend/static/images/README.md
Normal file
41
frontend/static/images/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 로그인 페이지 이미지
|
||||
|
||||
이 폴더에는 로그인 페이지에서 사용되는 배경 이미지를 저장합니다.
|
||||
|
||||
## 필요한 이미지 파일
|
||||
|
||||
### 배경 이미지
|
||||
- `login-bg.jpg` - 전체 페이지 배경 이미지 (권장 크기: 1920x1080px 이상)
|
||||
|
||||
## 이미지 사양
|
||||
|
||||
- **형식**: JPG, PNG 지원
|
||||
- **품질**: 웹 최적화된 고품질 이미지
|
||||
- **용량**: 1MB 이하 권장
|
||||
- **비율**: 16:9 또는 16:10 비율 권장
|
||||
- **색상**: 어두운 톤 또는 블러 처리된 이미지 권장 (텍스트 가독성을 위해)
|
||||
|
||||
## 폴백 동작
|
||||
|
||||
배경 이미지 파일이 없는 경우:
|
||||
- 파란색-보라색 그라디언트 배경으로 자동 폴백
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```
|
||||
static/images/
|
||||
└── login-bg.jpg (전체 배경)
|
||||
```
|
||||
|
||||
## 배경 이미지 선택 가이드
|
||||
|
||||
- **문서/도서관 테마**: 책장, 도서관, 서재 등
|
||||
- **기술/현대적 테마**: 추상적 패턴, 기하학적 형태
|
||||
- **자연 테마**: 차분한 풍경, 블러 처리된 자연 이미지
|
||||
- **미니멀 테마**: 단순한 패턴, 텍스처
|
||||
|
||||
## 변경 사항 (v2.0)
|
||||
|
||||
- 갤러리 액자 기능 제거
|
||||
- 중앙 집중형 로그인 레이아웃으로 변경
|
||||
- 배경 이미지만 사용하는 심플한 디자인
|
||||
BIN
frontend/static/images/login-bg-2.jpg
Normal file
BIN
frontend/static/images/login-bg-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
BIN
frontend/static/images/login-bg-3.jpg
Normal file
BIN
frontend/static/images/login-bg-3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 KiB |
BIN
frontend/static/images/login-bg.jpg
Normal file
BIN
frontend/static/images/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 690 KiB |
@@ -201,7 +201,7 @@ class DocumentServerAPI {
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
return await this.get('/auth/me');
|
||||
return await this.get('/users/me');
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken) {
|
||||
|
||||
92
frontend/static/js/auth-guard.js
Normal file
92
frontend/static/js/auth-guard.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 인증 가드 - 모든 보호된 페이지에서 사용
|
||||
* 로그인하지 않은 사용자를 자동으로 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 인증이 필요하지 않은 페이지들
|
||||
const PUBLIC_PAGES = [
|
||||
'login.html',
|
||||
'setup.html'
|
||||
];
|
||||
|
||||
// 현재 페이지가 공개 페이지인지 확인
|
||||
function isPublicPage() {
|
||||
const currentPath = window.location.pathname;
|
||||
return PUBLIC_PAGES.some(page => currentPath.includes(page));
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
function redirectToLogin() {
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
console.log('🔐 인증되지 않은 접근. 로그인 페이지로 이동합니다.');
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
}
|
||||
|
||||
// 인증 체크 함수
|
||||
async function checkAuthentication() {
|
||||
// 공개 페이지는 체크하지 않음
|
||||
if (isPublicPage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
// 토큰이 없으면 즉시 리다이렉트
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 토큰 유효성 검사
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('🔐 토큰이 유효하지 않습니다. 상태:', response.status);
|
||||
localStorage.removeItem('access_token');
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// 인증 성공
|
||||
const user = await response.json();
|
||||
console.log('✅ 인증 성공:', user.email);
|
||||
|
||||
// 전역 사용자 정보 설정
|
||||
window.currentUser = user;
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(user);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔐 인증 확인 중 오류:', error);
|
||||
localStorage.removeItem('access_token');
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// DOM 로드 완료 전에 인증 체크 실행
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', checkAuthentication);
|
||||
} else {
|
||||
checkAuthentication();
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.authGuard = {
|
||||
checkAuthentication,
|
||||
redirectToLogin,
|
||||
isPublicPage
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -155,9 +155,97 @@ document.addEventListener('headerLoaded', () => {
|
||||
setTimeout(() => {
|
||||
window.headerLoader.updateActiveStates();
|
||||
|
||||
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작)
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(null);
|
||||
// updateUserMenu 함수 정의 (헤더 로더에서 직접 정의)
|
||||
if (typeof window.updateUserMenu === 'undefined') {
|
||||
window.updateUserMenu = (user) => {
|
||||
console.log('🔄 updateUserMenu 호출됨:', user);
|
||||
|
||||
const loggedInMenu = document.getElementById('logged-in-menu');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const adminMenuSection = document.getElementById('admin-menu-section');
|
||||
|
||||
// 사용자 정보 요소들
|
||||
const userName = document.getElementById('user-name');
|
||||
const userRole = document.getElementById('user-role');
|
||||
const dropdownUserName = document.getElementById('dropdown-user-name');
|
||||
const dropdownUserEmail = document.getElementById('dropdown-user-email');
|
||||
const dropdownUserRole = document.getElementById('dropdown-user-role');
|
||||
|
||||
if (user) {
|
||||
// 로그인된 상태
|
||||
console.log('✅ 사용자 로그인 상태 - UI 업데이트');
|
||||
if (loggedInMenu) {
|
||||
loggedInMenu.classList.remove('hidden');
|
||||
console.log('✅ 로그인 메뉴 표시');
|
||||
}
|
||||
if (loginButton) {
|
||||
loginButton.classList.add('hidden');
|
||||
console.log('✅ 로그인 버튼 숨김');
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
const displayName = user.full_name || user.email || 'User';
|
||||
const roleText = user.role === 'root' ? '시스템 관리자' :
|
||||
user.role === 'admin' ? '관리자' : '사용자';
|
||||
|
||||
if (userName) userName.textContent = displayName;
|
||||
if (userRole) userRole.textContent = roleText;
|
||||
if (dropdownUserName) dropdownUserName.textContent = displayName;
|
||||
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
|
||||
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
|
||||
|
||||
// 관리자 메뉴 표시/숨김
|
||||
if (adminMenuSection) {
|
||||
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
|
||||
adminMenuSection.classList.remove('hidden');
|
||||
} else {
|
||||
adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 로그아웃된 상태
|
||||
console.log('❌ 로그아웃 상태');
|
||||
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
||||
if (loginButton) loginButton.classList.remove('hidden');
|
||||
if (adminMenuSection) adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
console.log('✅ updateUserMenu 함수 정의 완료');
|
||||
}
|
||||
|
||||
// 사용자 메뉴 상태 설정 (현재 로그인 상태 확인)
|
||||
setTimeout(() => {
|
||||
// 전역 사용자 정보가 있으면 사용, 없으면 토큰으로 확인
|
||||
if (window.currentUser) {
|
||||
window.updateUserMenu(window.currentUser);
|
||||
} else {
|
||||
// 토큰이 있으면 사용자 정보 다시 가져오기
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
.then(response => response.ok ? response.json() : null)
|
||||
.then(user => {
|
||||
if (user) {
|
||||
window.currentUser = user;
|
||||
window.updateUserMenu(user);
|
||||
} else {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
})
|
||||
.catch(() => window.updateUserMenu(null));
|
||||
} else {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// 전역 함수들이 정의되지 않은 경우 빈 함수로 초기화
|
||||
if (typeof window.handleLanguageChange === 'undefined') {
|
||||
window.handleLanguageChange = function(lang) {
|
||||
console.log('언어 변경 함수가 아직 로드되지 않았습니다:', lang);
|
||||
};
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,15 @@ window.documentApp = () => ({
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
// 로그인 페이지로 리다이렉트 (setup.html 제외)
|
||||
if (!window.location.pathname.includes('setup.html') &&
|
||||
!window.location.pathname.includes('login.html')) {
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
}
|
||||
},
|
||||
|
||||
@@ -63,9 +63,6 @@ window.memoTreeApp = function() {
|
||||
treePanX: 0,
|
||||
treePanY: 0,
|
||||
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
|
||||
isDragging: false,
|
||||
dragNode: null,
|
||||
dragOffset: { x: 0, y: 0 },
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
@@ -290,7 +287,15 @@ window.memoTreeApp = function() {
|
||||
|
||||
const node = await window.api.createMemoNode(nodeData);
|
||||
this.treeNodes.push(node);
|
||||
this.selectNode(node);
|
||||
|
||||
// 노드 위치 재계산 (새 노드 추가 후)
|
||||
this.$nextTick(() => {
|
||||
this.calculateNodePositions();
|
||||
// 위치 계산 완료 후 새 노드 선택
|
||||
setTimeout(() => {
|
||||
this.selectNode(node);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
console.log('✅ 루트 노드 생성 완료');
|
||||
} catch (error) {
|
||||
@@ -301,6 +306,11 @@ window.memoTreeApp = function() {
|
||||
|
||||
// 노드 선택
|
||||
selectNode(node) {
|
||||
// 현재 팬 값 저장 (위치 변경 방지)
|
||||
const currentPanX = this.treePanX;
|
||||
const currentPanY = this.treePanY;
|
||||
const currentZoom = this.treeZoom;
|
||||
|
||||
// 이전 노드 저장
|
||||
if (this.selectedNode && this.isEditorDirty) {
|
||||
this.saveNode();
|
||||
@@ -323,6 +333,11 @@ window.memoTreeApp = function() {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 팬 값 복원 (위치 변경 방지)
|
||||
this.treePanX = currentPanX;
|
||||
this.treePanY = currentPanY;
|
||||
this.treeZoom = currentZoom;
|
||||
|
||||
console.log('📝 노드 선택:', node.title);
|
||||
},
|
||||
|
||||
@@ -552,8 +567,14 @@ window.memoTreeApp = function() {
|
||||
// 부모 노드 펼치기
|
||||
this.expandedNodes.add(parentNode.id);
|
||||
|
||||
// 새 노드 선택
|
||||
this.selectNode(node);
|
||||
// 노드 위치 재계산 (새 노드 추가 후)
|
||||
this.$nextTick(() => {
|
||||
this.calculateNodePositions();
|
||||
// 위치 계산 완료 후 새 노드 선택
|
||||
setTimeout(() => {
|
||||
this.selectNode(node);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
console.log('✅ 자식 노드 생성 완료');
|
||||
} catch (error) {
|
||||
@@ -623,123 +644,9 @@ window.memoTreeApp = function() {
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 드래그 시작
|
||||
startDragNode(event, node) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
console.log('🎯 노드 드래그 시작:', node.title);
|
||||
|
||||
this.isDragging = true;
|
||||
this.dragNode = node;
|
||||
|
||||
// 드래그 시작 위치 계산
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
this.dragOffset = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
};
|
||||
|
||||
// 드래그 중인 노드 스타일 적용
|
||||
event.target.style.opacity = '0.7';
|
||||
event.target.style.transform = 'scale(1.05)';
|
||||
event.target.style.zIndex = '1000';
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
document.addEventListener('mousemove', this.handleNodeDrag.bind(this));
|
||||
document.addEventListener('mouseup', this.endNodeDrag.bind(this));
|
||||
},
|
||||
|
||||
// 노드 드래그 처리
|
||||
handleNodeDrag(event) {
|
||||
if (!this.isDragging || !this.dragNode) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 드래그 중인 노드 위치 업데이트
|
||||
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
|
||||
if (dragElement) {
|
||||
const newX = event.clientX - this.dragOffset.x;
|
||||
const newY = event.clientY - this.dragOffset.y;
|
||||
|
||||
dragElement.style.position = 'fixed';
|
||||
dragElement.style.left = `${newX}px`;
|
||||
dragElement.style.top = `${newY}px`;
|
||||
dragElement.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
// 드롭 대상 하이라이트
|
||||
this.highlightDropTarget(event);
|
||||
},
|
||||
|
||||
// 드롭 대상 하이라이트
|
||||
highlightDropTarget(event) {
|
||||
// 모든 노드에서 하이라이트 제거
|
||||
document.querySelectorAll('.tree-diagram-node').forEach(el => {
|
||||
el.classList.remove('drop-target-highlight');
|
||||
});
|
||||
|
||||
// 현재 마우스 위치의 노드 찾기
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
const targetNode = elements.find(el =>
|
||||
el.classList.contains('tree-diagram-node') &&
|
||||
el.getAttribute('data-node-id') !== this.dragNode.id
|
||||
);
|
||||
|
||||
if (targetNode) {
|
||||
targetNode.classList.add('drop-target-highlight');
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 드래그 종료
|
||||
endNodeDrag(event) {
|
||||
if (!this.isDragging || !this.dragNode) return;
|
||||
|
||||
console.log('🎯 노드 드래그 종료');
|
||||
|
||||
// 드롭 대상 찾기
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
const targetElement = elements.find(el =>
|
||||
el.classList.contains('tree-diagram-node') &&
|
||||
el.getAttribute('data-node-id') !== this.dragNode.id
|
||||
);
|
||||
|
||||
let targetNodeId = null;
|
||||
if (targetElement) {
|
||||
targetNodeId = targetElement.getAttribute('data-node-id');
|
||||
}
|
||||
|
||||
// 드래그 중인 노드 스타일 복원
|
||||
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
|
||||
if (dragElement) {
|
||||
dragElement.style.opacity = '';
|
||||
dragElement.style.transform = '';
|
||||
dragElement.style.zIndex = '';
|
||||
dragElement.style.position = '';
|
||||
dragElement.style.left = '';
|
||||
dragElement.style.top = '';
|
||||
dragElement.style.pointerEvents = '';
|
||||
}
|
||||
|
||||
// 모든 하이라이트 제거
|
||||
document.querySelectorAll('.tree-diagram-node').forEach(el => {
|
||||
el.classList.remove('drop-target-highlight');
|
||||
});
|
||||
|
||||
// 실제 노드 이동 처리
|
||||
if (targetNodeId && targetNodeId !== this.dragNode.id) {
|
||||
this.moveNodeToParent(this.dragNode.id, targetNodeId);
|
||||
}
|
||||
|
||||
// 상태 초기화
|
||||
this.isDragging = false;
|
||||
this.dragNode = null;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
|
||||
// 이벤트 리스너 제거
|
||||
document.removeEventListener('mousemove', this.handleNodeDrag);
|
||||
document.removeEventListener('mouseup', this.endNodeDrag);
|
||||
},
|
||||
|
||||
// 노드를 다른 부모로 이동
|
||||
async moveNodeToParent(nodeId, newParentId) {
|
||||
@@ -783,15 +690,12 @@ window.memoTreeApp = function() {
|
||||
|
||||
// 노드 위치 계산 및 반환
|
||||
getNodePosition(node) {
|
||||
if (!this.nodePositions.has(node.id)) {
|
||||
this.calculateNodePositions();
|
||||
}
|
||||
|
||||
// 위치가 없으면 기본 위치 반환 (전체 재계산 방지)
|
||||
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
|
||||
return `left: ${pos.x}px; top: ${pos.y}px;`;
|
||||
},
|
||||
|
||||
// 트리 노드 위치 자동 계산
|
||||
// 트리 노드 위치 자동 계산 (가로 방향: 왼쪽에서 오른쪽)
|
||||
calculateNodePositions() {
|
||||
const canvas = document.getElementById('tree-canvas');
|
||||
if (!canvas) return;
|
||||
@@ -802,11 +706,11 @@ window.memoTreeApp = function() {
|
||||
// 노드 크기 설정
|
||||
const nodeWidth = 200;
|
||||
const nodeHeight = 80;
|
||||
const levelHeight = 150; // 레벨 간 간격
|
||||
const nodeSpacing = 50; // 노드 간 간격
|
||||
const levelWidth = 250; // 레벨 간 가로 간격 (왼쪽에서 오른쪽)
|
||||
const nodeSpacing = 100; // 노드 간 세로 간격
|
||||
const margin = 100; // 여백
|
||||
|
||||
// 레벨별 노드 그룹화
|
||||
// 레벨별 노드 그룹화 (가로 방향)
|
||||
const levels = new Map();
|
||||
|
||||
// 루트 노드들 찾기
|
||||
@@ -814,7 +718,7 @@ window.memoTreeApp = function() {
|
||||
|
||||
if (rootNodes.length === 0) return;
|
||||
|
||||
// BFS로 레벨별 노드 배치
|
||||
// BFS로 레벨별 노드 배치 (가로 방향)
|
||||
const queue = [];
|
||||
rootNodes.forEach(node => {
|
||||
queue.push({ node, level: 0 });
|
||||
@@ -835,25 +739,25 @@ window.memoTreeApp = function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 트리 전체 크기 계산
|
||||
// 트리 전체 크기 계산 (가로 방향)
|
||||
const maxLevel = Math.max(...levels.keys());
|
||||
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
|
||||
|
||||
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
|
||||
const treeHeight = (maxLevel + 1) * levelHeight;
|
||||
const treeWidth = (maxLevel + 1) * levelWidth; // 가로 방향 전체 너비
|
||||
const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // 세로 방향 전체 높이
|
||||
|
||||
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
|
||||
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
|
||||
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
|
||||
|
||||
// 각 레벨의 노드들 위치 계산
|
||||
// 각 레벨의 노드들 위치 계산 (가로 방향)
|
||||
levels.forEach((nodes, level) => {
|
||||
const y = offsetY + level * levelHeight;
|
||||
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
|
||||
const startX = offsetX + (treeWidth - levelWidth) / 2;
|
||||
const x = offsetX + level * levelWidth; // 가로 위치 (왼쪽에서 오른쪽)
|
||||
const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing;
|
||||
const startY = offsetY + (treeHeight - levelHeight) / 2; // 세로 중앙 정렬
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const x = startX + index * (nodeWidth + nodeSpacing);
|
||||
const y = startY + index * (nodeHeight + nodeSpacing);
|
||||
this.nodePositions.set(node.id, { x, y });
|
||||
});
|
||||
});
|
||||
@@ -893,17 +797,17 @@ window.memoTreeApp = function() {
|
||||
// 연결선 생성
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
|
||||
// 부모 노드 하단 중앙에서 시작
|
||||
const startX = parentPos.x + 100; // 노드 중앙
|
||||
const startY = parentPos.y + 80; // 노드 하단
|
||||
// 부모 노드 오른쪽 중앙에서 시작 (가로 방향)
|
||||
const startX = parentPos.x + 200; // 노드 오른쪽 끝
|
||||
const startY = parentPos.y + 40; // 노드 세로 중앙
|
||||
|
||||
// 자식 노드 상단 중앙으로 연결
|
||||
const endX = childPos.x + 100; // 노드 중앙
|
||||
const endY = childPos.y; // 노드 상단
|
||||
// 자식 노드 왼쪽 중앙으로 연결 (가로 방향)
|
||||
const endX = childPos.x; // 노드 왼쪽 끝
|
||||
const endY = childPos.y + 40; // 노드 세로 중앙
|
||||
|
||||
// 곡선 경로 생성 (베지어 곡선)
|
||||
const midY = startY + (endY - startY) / 2;
|
||||
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`;
|
||||
// 곡선 경로 생성 (베지어 곡선, 가로 방향)
|
||||
const midX = startX + (endX - startX) / 2;
|
||||
const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`;
|
||||
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('stroke', '#9CA3AF');
|
||||
|
||||
@@ -15,6 +15,7 @@ window.searchApp = function() {
|
||||
|
||||
// 필터링
|
||||
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
|
||||
fileTypeFilter: '', // '', 'PDF', 'HTML'
|
||||
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
|
||||
|
||||
// 검색 디바운스
|
||||
@@ -157,9 +158,43 @@ window.searchApp = function() {
|
||||
applyFilters() {
|
||||
let results = [...this.searchResults];
|
||||
|
||||
// 중복 ID 제거 (같은 문서의 document와 document_content가 중복될 수 있음)
|
||||
const uniqueResults = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
results.forEach(result => {
|
||||
const uniqueKey = `${result.type}-${result.id}`;
|
||||
if (!seenIds.has(uniqueKey)) {
|
||||
seenIds.add(uniqueKey);
|
||||
uniqueResults.push({
|
||||
...result,
|
||||
unique_id: uniqueKey // Alpine.js x-for 키로 사용
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
results = uniqueResults;
|
||||
|
||||
// 타입 필터
|
||||
if (this.typeFilter) {
|
||||
results = results.filter(result => result.type === this.typeFilter);
|
||||
results = results.filter(result => {
|
||||
// 문서 타입은 document와 document_content 모두 포함
|
||||
if (this.typeFilter === 'document') {
|
||||
return result.type === 'document' || result.type === 'document_content';
|
||||
}
|
||||
// 하이라이트 타입은 highlight와 highlight_note 모두 포함
|
||||
if (this.typeFilter === 'highlight') {
|
||||
return result.type === 'highlight' || result.type === 'highlight_note';
|
||||
}
|
||||
return result.type === this.typeFilter;
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 타입 필터
|
||||
if (this.fileTypeFilter) {
|
||||
results = results.filter(result => {
|
||||
return result.highlight_info?.file_type === this.fileTypeFilter;
|
||||
});
|
||||
}
|
||||
|
||||
// 정렬
|
||||
@@ -179,7 +214,7 @@ window.searchApp = function() {
|
||||
});
|
||||
|
||||
this.filteredResults = results;
|
||||
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
|
||||
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과 (타입:', this.typeFilter, ', 파일타입:', this.fileTypeFilter, ')');
|
||||
},
|
||||
|
||||
// URL 업데이트
|
||||
@@ -339,22 +374,23 @@ window.searchApp = function() {
|
||||
this.pdfLoaded = false;
|
||||
|
||||
try {
|
||||
// PDF 파일 존재 여부 먼저 확인
|
||||
const response = await fetch(`/api/documents/${documentId}/pdf`, {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
// PDF 파일 src 직접 설정 (HEAD 요청 대신)
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('🔍 토큰 디버깅:', {
|
||||
token: token,
|
||||
tokenType: typeof token,
|
||||
tokenLength: token ? token.length : 0,
|
||||
isNull: token === null,
|
||||
isStringNull: token === 'null',
|
||||
localStorage: Object.keys(localStorage)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// PDF 파일이 존재하면 src 설정
|
||||
const token = localStorage.getItem('token');
|
||||
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
console.log('PDF 미리보기 준비 완료:', this.pdfSrc);
|
||||
} else {
|
||||
throw new Error(`PDF 파일을 찾을 수 없습니다 (${response.status})`);
|
||||
if (!token || token === 'null' || token === null) {
|
||||
console.error('❌ 토큰 문제:', token);
|
||||
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
console.log('✅ PDF 미리보기 준비 완료:', this.pdfSrc);
|
||||
} catch (error) {
|
||||
console.error('PDF 미리보기 로드 실패:', error);
|
||||
this.pdfError = true;
|
||||
@@ -370,6 +406,50 @@ window.searchApp = function() {
|
||||
this.pdfLoading = false;
|
||||
},
|
||||
|
||||
// PDF에서 검색어 찾기 (브라우저 내장 검색 활용)
|
||||
searchInPdf() {
|
||||
if (this.searchQuery && this.pdfLoaded) {
|
||||
// iframe 내에서 검색 실행 (Ctrl+F 시뮬레이션)
|
||||
const iframe = document.querySelector('#pdf-preview-iframe');
|
||||
if (iframe && iframe.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.focus();
|
||||
// 브라우저 검색 창 열기 시도
|
||||
if (iframe.contentWindow.find) {
|
||||
iframe.contentWindow.find(this.searchQuery);
|
||||
} else {
|
||||
// 대안: 사용자에게 수동 검색 안내
|
||||
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
// 보안상 직접 접근이 안 되는 경우, 사용자에게 안내
|
||||
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 표시 (간단한 토스트)
|
||||
showNotification(message, type = 'info') {
|
||||
// 간단한 알림 구현 (실제로는 더 정교한 토스트 시스템을 사용할 수 있음)
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
|
||||
type === 'info' ? 'bg-blue-500' :
|
||||
type === 'success' ? 'bg-green-500' :
|
||||
type === 'error' ? 'bg-red-500' : 'bg-gray-500'
|
||||
}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 3초 후 자동 제거
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
// HTML 미리보기 로드
|
||||
async loadHtmlPreview(documentId) {
|
||||
this.htmlLoading = true;
|
||||
@@ -385,7 +465,12 @@ window.searchApp = function() {
|
||||
const iframe = document.getElementById('htmlPreviewFrame');
|
||||
if (iframe) {
|
||||
// iframe src를 직접 설정 (인증 헤더 포함)
|
||||
const token = localStorage.getItem('token');
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('🔍 HTML 미리보기 토큰:', token ? '있음' : '없음', token);
|
||||
if (!token || token === 'null' || token === null) {
|
||||
console.error('❌ HTML 미리보기 토큰 문제:', token);
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
iframe.src = `/api/documents/${documentId}/content?_token=${encodeURIComponent(token)}`;
|
||||
|
||||
// iframe 로드 완료 후 검색어 하이라이트
|
||||
|
||||
Reference in New Issue
Block a user