링크/백링크 기능 수정 및 안정화

- 링크 생성 기능 완전 복구
  - createDocumentLink 함수를 Alpine.js 데이터 객체 내로 이동
  - API 필드명 불일치 수정 (source_text → selected_text 등)
  - 필수 필드 검증 및 상세 에러 로깅 추가

- API 엔드포인트 수정
  - 백엔드와 일치하도록 /documents/{id}/links, /documents/{id}/backlinks 사용
  - 올바른 매개변수 전달 방식 적용

- 링크/백링크 렌더링 안정화
  - forEach 오류 방지를 위한 배열 타입 검증
  - 데이터 없을 시 기존 링크 유지 로직
  - 안전한 초기화 및 에러 처리

- UI 단순화
  - 같은 서적/다른 서적 구분 제거
  - 서적 선택 → 문서 선택 단순한 2단계 프로세스
  - 백링크는 자동 생성되도록 수정

- 디버깅 로그 대폭 강화
  - API 호출, 응답, 렌더링 각 단계별 상세 로깅
  - 데이터 타입 및 개수 추적
This commit is contained in:
Hyungi Ahn
2025-08-28 08:48:52 +09:00
parent 5d4465b15c
commit 844587c86f
5 changed files with 561 additions and 131 deletions

View File

@@ -10,6 +10,9 @@ class LinkManager {
this.cachedApi = window.cachedApi || api; this.cachedApi = window.cachedApi || api;
this.documentLinks = []; this.documentLinks = [];
this.backlinks = []; this.backlinks = [];
// 안전한 초기화 확인
console.log('🔧 LinkManager 초기화 - backlinks 타입:', typeof this.backlinks, Array.isArray(this.backlinks));
this.selectedText = ''; this.selectedText = '';
this.selectedRange = null; this.selectedRange = null;
this.availableBooks = []; this.availableBooks = [];
@@ -23,10 +26,15 @@ class LinkManager {
*/ */
async loadDocumentLinks(documentId) { async loadDocumentLinks(documentId) {
try { try {
this.documentLinks = await this.cachedApi.get('/document-links', { document_id: documentId }, { category: 'links' }).catch(() => []); console.log('📡 링크 API 호출:', `/documents/${documentId}/links`);
return this.documentLinks || []; const response = await this.cachedApi.get(`/documents/${documentId}/links`, {}, { category: 'links' }).catch(() => []);
this.documentLinks = Array.isArray(response) ? response : [];
console.log('📡 API 응답 링크 개수:', this.documentLinks.length);
console.log('📡 API 응답 타입:', typeof response, response);
return this.documentLinks;
} catch (error) { } catch (error) {
console.error('문서 링크 로드 실패:', error); console.error('문서 링크 로드 실패:', error);
this.documentLinks = [];
return []; return [];
} }
} }
@@ -36,10 +44,15 @@ class LinkManager {
*/ */
async loadBacklinks(documentId) { async loadBacklinks(documentId) {
try { try {
this.backlinks = await this.cachedApi.get('/document-links/backlinks', { target_document_id: documentId }, { category: 'links' }).catch(() => []); console.log('📡 백링크 API 호출:', `/documents/${documentId}/backlinks`);
return this.backlinks || []; const response = await this.cachedApi.get(`/documents/${documentId}/backlinks`, {}, { category: 'links' }).catch(() => []);
this.backlinks = Array.isArray(response) ? response : [];
console.log('📡 API 응답 백링크 개수:', this.backlinks.length);
console.log('📡 API 응답 타입:', typeof response, response);
return this.backlinks;
} catch (error) { } catch (error) {
console.error('백링크 로드 실패:', error); console.error('백링크 로드 실패:', error);
this.backlinks = [];
return []; return [];
} }
} }
@@ -51,7 +64,20 @@ class LinkManager {
const documentContent = document.getElementById('document-content'); const documentContent = document.getElementById('document-content');
if (!documentContent) return; if (!documentContent) return;
// 안전한 링크 초기화
if (!Array.isArray(this.documentLinks)) {
console.warn('⚠️ this.documentLinks가 배열이 아닙니다. 빈 배열로 초기화합니다.');
console.log('🔍 기존 this.documentLinks:', typeof this.documentLinks, this.documentLinks);
this.documentLinks = [];
}
console.log('🔗 링크 렌더링 시작 - 총', this.documentLinks.length, '개'); console.log('🔗 링크 렌더링 시작 - 총', this.documentLinks.length, '개');
// 링크 데이터가 없으면 렌더링하지 않음 (기존 링크 유지)
if (this.documentLinks.length === 0) {
console.log('📝 링크 데이터가 없어서 기존 링크를 유지합니다.');
return;
}
// 기존 링크 제거 // 기존 링크 제거
const existingLinks = documentContent.querySelectorAll('.document-link'); const existingLinks = documentContent.querySelectorAll('.document-link');
@@ -62,9 +88,13 @@ class LinkManager {
}); });
// 각 링크 렌더링 // 각 링크 렌더링
this.documentLinks.forEach(link => { if (Array.isArray(this.documentLinks)) {
this.renderSingleLink(link); this.documentLinks.forEach(link => {
}); this.renderSingleLink(link);
});
} else {
console.warn('⚠️ this.documentLinks가 배열이 아닙니다:', typeof this.documentLinks, this.documentLinks);
}
console.log('✅ 링크 렌더링 완료'); console.log('✅ 링크 렌더링 완료');
} }
@@ -166,26 +196,43 @@ class LinkManager {
const documentContent = document.getElementById('document-content'); const documentContent = document.getElementById('document-content');
if (!documentContent) return; if (!documentContent) return;
// 안전한 백링크 초기화
if (!Array.isArray(this.backlinks)) {
console.warn('⚠️ this.backlinks가 배열이 아닙니다. 빈 배열로 초기화합니다.');
console.log('🔍 기존 this.backlinks:', typeof this.backlinks, this.backlinks);
this.backlinks = [];
}
console.log('🔗 백링크 렌더링 시작 - 총', this.backlinks.length, '개'); console.log('🔗 백링크 렌더링 시작 - 총', this.backlinks.length, '개');
// 백링크 데이터가 없으면 렌더링하지 않음 (기존 백링크 유지)
if (this.backlinks.length === 0) {
console.log('📝 백링크 데이터가 없어서 기존 백링크를 유지합니다.');
return;
}
// 기존 백링크는 제거하지 않고 중복 체크만 함 // 기존 백링크는 제거하지 않고 중복 체크만 함
const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight'); const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight');
console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견 (유지)`); console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견 (유지)`);
// 각 백링크 렌더링 (중복되지 않는 것만) // 각 백링크 렌더링 (중복되지 않는 것만)
this.backlinks.forEach(backlink => { if (Array.isArray(this.backlinks)) {
// 이미 렌더링된 백링크인지 확인 this.backlinks.forEach(backlink => {
const existingBacklink = Array.from(existingBacklinks).find(el => // 이미 렌더링된 백링크인지 확인
el.dataset.backlinkId === backlink.id.toString() const existingBacklink = Array.from(existingBacklinks).find(el =>
); el.dataset.backlinkId === backlink.id.toString()
);
if (!existingBacklink) {
console.log(`🆕 새로운 백링크 렌더링: ${backlink.id}`); if (!existingBacklink) {
this.renderSingleBacklink(backlink); console.log(`🆕 새로운 백링크 렌더링: ${backlink.id}`);
} else { this.renderSingleBacklink(backlink);
console.log(`✅ 백링크 이미 존재: ${backlink.id}`); } else {
} console.log(`✅ 백링크 이미 존재: ${backlink.id}`);
}); }
});
} else {
console.warn('⚠️ this.backlinks가 배열이 아닙니다:', typeof this.backlinks, this.backlinks);
}
console.log('✅ 백링크 렌더링 완료'); console.log('✅ 백링크 렌더링 완료');
} }
@@ -534,8 +581,23 @@ class LinkManager {
/** /**
* 선택된 텍스트로 링크 생성 * 선택된 텍스트로 링크 생성
*/ */
async createLinkFromSelection(documentId, selectedText, selectedRange) { async createLinkFromSelection(documentId = null, selectedText = null, selectedRange = null) {
if (!selectedText || !selectedRange) return; // 매개변수가 없으면 현재 선택된 텍스트 사용
if (!selectedText || !selectedRange) {
selectedText = window.getSelection().toString().trim();
const selection = window.getSelection();
if (!selectedText || selection.rangeCount === 0) {
alert('텍스트를 먼저 선택해주세요.');
return;
}
selectedRange = selection.getRangeAt(0);
}
if (!documentId && window.documentViewerInstance) {
documentId = window.documentViewerInstance.documentId;
}
try { try {
console.log('🔗 링크 생성 시작:', selectedText); console.log('🔗 링크 생성 시작:', selectedText);
@@ -547,6 +609,12 @@ class LinkManager {
window.documentViewerInstance.showLinkModal = true; window.documentViewerInstance.showLinkModal = true;
window.documentViewerInstance.linkForm.selected_text = selectedText; window.documentViewerInstance.linkForm.selected_text = selectedText;
// 서적 목록 로드
await window.documentViewerInstance.loadAvailableBooks();
// 기본적으로 같은 서적 문서들 로드
await window.documentViewerInstance.loadSameBookDocuments();
// 텍스트 오프셋 계산 // 텍스트 오프셋 계산
const documentContent = document.getElementById('document-content'); const documentContent = document.getElementById('document-content');
const fullText = documentContent.textContent; const fullText = documentContent.textContent;
@@ -563,6 +631,8 @@ class LinkManager {
} }
} }
/** /**
* 텍스트 오프셋 계산 * 텍스트 오프셋 계산
*/ */

View File

@@ -44,7 +44,7 @@ window.documentViewer = () => ({
target_text: '', target_text: '',
target_start_offset: 0, target_start_offset: 0,
target_end_offset: 0, target_end_offset: 0,
book_scope: 'same', // 'same' 또는 'other'
target_book_id: '' target_book_id: ''
}, },
@@ -422,15 +422,254 @@ window.documentViewer = () => ({
activateLinkMode() { activateLinkMode() {
console.log('🔗 링크 모드 활성화'); console.log('🔗 링크 모드 활성화');
this.activeMode = 'link'; this.activeMode = 'link';
this.linkManager.createLinkFromSelection();
// 선택된 텍스트 확인
const selectedText = window.getSelection().toString().trim();
const selection = window.getSelection();
if (!selectedText || selection.rangeCount === 0) {
alert('텍스트를 먼저 선택해주세요.');
return;
}
const selectedRange = selection.getRangeAt(0);
this.linkManager.createLinkFromSelection(this.documentId, selectedText, selectedRange);
}, },
activateNoteMode() { activateNoteMode() {
console.log('📝 메모 모드 활성화'); console.log('📝 메모 모드 활성화');
this.activeMode = 'memo'; this.activeMode = 'memo';
this.highlightManager.activateNoteMode(); this.highlightManager.activateNoteMode();
}, },
async loadBacklinks() {
console.log('🔗 백링크 로드 시작');
if (this.linkManager) {
await this.linkManager.loadBacklinks(this.documentId);
// UI 상태 동기화
this.backlinks = this.linkManager.backlinks || [];
}
},
async loadAvailableBooks() {
try {
console.log('📚 서적 목록 로딩 시작...');
// 문서 목록에서 서적 정보 추출
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
console.log('📄 모든 문서들 (총 개수):', allDocuments.length);
// 소스 문서의 서적 정보 찾기
const sourceBookInfo = this.getSourceBookInfo(allDocuments);
console.log('📖 소스 문서 서적 정보:', sourceBookInfo);
// 서적별로 그룹화
const bookMap = new Map();
allDocuments.forEach(doc => {
if (doc.book_id && doc.book_title) {
console.log('📖 문서 서적 정보:', {
docId: doc.id,
bookId: doc.book_id,
bookTitle: doc.book_title
});
bookMap.set(doc.book_id, {
id: doc.book_id,
title: doc.book_title
});
}
});
console.log('📚 그룹화된 모든 서적들:', Array.from(bookMap.values()));
// 소스 문서 기준으로 서적 분류
if (sourceBookInfo.id) {
// 소스 서적 제외 (다른 서적만 남김)
const wasDeleted = bookMap.delete(sourceBookInfo.id);
console.log('✅ 소스 서적 제외 결과:', {
sourceBookId: sourceBookInfo.id,
sourceBookTitle: sourceBookInfo.title,
wasDeleted: wasDeleted,
remainingBooks: bookMap.size
});
} else {
console.warn('⚠️ 소스 문서의 서적 정보를 찾을 수 없습니다!');
}
this.availableBooks = Array.from(bookMap.values());
console.log('📚 최종 사용 가능한 서적들 (다른 서적):', this.availableBooks);
console.log('🔍 제외된 소스 서적 ID:', sourceBookInfo.id);
} catch (error) {
console.error('서적 목록 로드 실패:', error);
this.availableBooks = [];
}
},
getSourceBookInfo(allDocuments = null) {
// 여러 소스에서 현재 문서의 서적 정보 찾기
let sourceBookId = this.navigation?.book_info?.id ||
this.document?.book_id ||
this.document?.book_info?.id;
let sourceBookTitle = this.navigation?.book_info?.title ||
this.document?.book_title ||
this.document?.book_info?.title;
// allDocuments에서도 확인 (가장 확실한 방법)
if (allDocuments) {
const currentDoc = allDocuments.find(doc => doc.id === this.documentId);
if (currentDoc) {
sourceBookId = currentDoc.book_id;
sourceBookTitle = currentDoc.book_title;
}
}
return {
id: sourceBookId,
title: sourceBookTitle
};
},
async loadSameBookDocuments() {
try {
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
// 소스 문서의 서적 정보 가져오기
const sourceBookInfo = this.getSourceBookInfo(allDocuments);
console.log('📚 같은 서적 문서 로드 시작:', {
sourceBookId: sourceBookInfo.id,
sourceBookTitle: sourceBookInfo.title,
totalDocs: allDocuments.length
});
if (sourceBookInfo.id) {
// 소스 문서와 같은 서적의 문서들만 필터링 (현재 문서 제외)
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === sourceBookInfo.id && doc.id !== this.documentId
);
console.log('📚 같은 서적 문서들:', {
count: this.filteredDocuments.length,
bookTitle: sourceBookInfo.title,
documents: this.filteredDocuments.map(doc => ({ id: doc.id, title: doc.title }))
});
} else {
console.warn('⚠️ 소스 문서의 서적 정보를 찾을 수 없습니다!');
this.filteredDocuments = [];
}
} catch (error) {
console.error('같은 서적 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
async loadSameBookDocumentsForSelected() {
try {
console.log('📚 선택한 문서 기준으로 같은 서적 문서 로드 시작');
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
// 선택한 대상 문서 찾기
const selectedDoc = allDocuments.find(doc => doc.id === this.linkForm.target_document_id);
if (!selectedDoc) {
console.error('❌ 선택한 문서를 찾을 수 없습니다:', this.linkForm.target_document_id);
return;
}
console.log('🎯 선택한 문서 정보:', {
id: selectedDoc.id,
title: selectedDoc.title,
bookId: selectedDoc.book_id,
bookTitle: selectedDoc.book_title
});
// 선택한 문서와 같은 서적의 모든 문서들 (소스 문서 제외)
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === selectedDoc.book_id && doc.id !== this.documentId
);
console.log('📚 선택한 문서와 같은 서적 문서들:', {
selectedBookTitle: selectedDoc.book_title,
count: this.filteredDocuments.length,
documents: this.filteredDocuments.map(doc => ({ id: doc.id, title: doc.title }))
});
} catch (error) {
console.error('선택한 문서 기준 같은 서적 로드 실패:', error);
this.filteredDocuments = [];
}
},
async loadDocumentsFromBook() {
try {
if (this.linkForm.target_book_id) {
// 선택된 서적의 문서들만 가져오기
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === this.linkForm.target_book_id
);
console.log('📚 선택된 서적 문서들:', this.filteredDocuments);
} else {
this.filteredDocuments = [];
}
// 문서 선택 초기화
this.linkForm.target_document_id = '';
} catch (error) {
console.error('서적별 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
resetTargetSelection() {
console.log('🔄 대상 선택 초기화');
this.linkForm.target_book_id = '';
this.linkForm.target_document_id = '';
this.filteredDocuments = [];
// 초기화 후 아무것도 하지 않음 (서적 선택 후 문서 로드)
},
async onTargetDocumentChange() {
console.log('📄 대상 문서 변경:', this.linkForm.target_document_id);
// 대상 문서 변경 시 특별한 처리 없음
},
selectTextFromDocument() {
console.log('🎯 대상 문서에서 텍스트 선택 시작');
if (!this.linkForm.target_document_id) {
alert('대상 문서를 먼저 선택해주세요.');
return;
}
// 새 창에서 대상 문서 열기 (텍스트 선택 모드 전용 페이지)
const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}`;
console.log('🚀 텍스트 선택 창 열기:', targetUrl);
const popup = window.open(targetUrl, 'targetDocumentSelector', 'width=1200,height=800,scrollbars=yes,resizable=yes');
if (!popup) {
console.error('❌ 팝업 창이 차단되었습니다!');
alert('팝업 창이 차단되었습니다. 브라우저 설정에서 팝업을 허용해주세요.');
} else {
console.log('✅ 팝업 창이 성공적으로 열렸습니다');
}
// 팝업에서 텍스트 선택 완료 시 메시지 수신
window.addEventListener('message', (event) => {
if (event.data.type === 'TEXT_SELECTED') {
this.linkForm.target_text = event.data.selectedText;
this.linkForm.target_start_offset = event.data.startOffset;
this.linkForm.target_end_offset = event.data.endOffset;
console.log('🎯 대상 텍스트 선택됨:', event.data);
popup.close();
}
}, { once: true });
},
activateBookmarkMode() { activateBookmarkMode() {
console.log('🔖 북마크 모드 활성화'); console.log('🔖 북마크 모드 활성화');
this.activeMode = 'bookmark'; this.activeMode = 'bookmark';
@@ -550,6 +789,66 @@ window.documentViewer = () => ({
// 언어 전환 로직 구현 필요 // 언어 전환 로직 구현 필요
}, },
async downloadOriginalFile() {
if (!this.document || !this.document.id) {
console.warn('문서 정보가 없습니다');
return;
}
// 연결된 PDF가 있는지 확인
if (!this.document.matched_pdf_id) {
alert('연결된 원본 PDF 파일이 없습니다.\n\n서적 편집 페이지에서 PDF 파일을 연결해주세요.');
return;
}
try {
console.log('📕 연결된 PDF 다운로드 시작:', this.document.matched_pdf_id);
// 연결된 PDF 문서 정보 가져오기
const pdfDocument = await this.api.getDocument(this.document.matched_pdf_id);
if (!pdfDocument) {
throw new Error('연결된 PDF 문서를 찾을 수 없습니다');
}
// PDF 파일 다운로드 URL 생성
const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`;
// 인증 헤더 추가를 위해 fetch 사용
const response = await fetch(downloadUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (!response.ok) {
throw new Error('연결된 PDF 다운로드에 실패했습니다');
}
// Blob으로 변환하여 다운로드
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 다운로드 링크 생성 및 클릭
const link = document.createElement('a');
link.href = url;
link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// URL 정리
window.URL.revokeObjectURL(url);
console.log('📕 PDF 다운로드 완료:', pdfDocument.original_filename);
} catch (error) {
console.error('PDF 다운로드 오류:', error);
alert('PDF 다운로드 중 오류가 발생했습니다: ' + error.message);
}
},
// ==================== 유틸리티 메서드 ==================== // ==================== 유틸리티 메서드 ====================
formatDate(dateString) { formatDate(dateString) {
return new Date(dateString).toLocaleString('ko-KR'); return new Date(dateString).toLocaleString('ko-KR');
@@ -574,12 +873,8 @@ window.documentViewer = () => ({
}, },
getSelectedBookTitle() { getSelectedBookTitle() {
if (this.linkForm.book_scope === 'same') { const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id);
return this.document?.book_title || '현재 서적'; return selectedBook ? selectedBook.title : '서적을 선택하세요';
} else {
const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id);
return selectedBook ? selectedBook.title : '서적을 선택하세요';
}
}, },
// ==================== 모듈 메서드 위임 ==================== // ==================== 모듈 메서드 위임 ====================
@@ -629,6 +924,94 @@ window.documentViewer = () => ({
deleteBookmark(bookmarkId) { deleteBookmark(bookmarkId) {
return this.bookmarkManager.deleteBookmark(bookmarkId); return this.bookmarkManager.deleteBookmark(bookmarkId);
},
// ==================== 링크 생성 ====================
async createDocumentLink() {
console.log('🔗 createDocumentLink 함수 실행');
console.log('📋 현재 linkForm 상태:', JSON.stringify(this.linkForm, null, 2));
try {
// 링크 데이터 검증
if (!this.linkForm.target_document_id) {
alert('대상 문서를 선택해주세요.');
return;
}
if (this.linkForm.link_type === 'text' && !this.linkForm.target_text) {
alert('대상 문서에서 텍스트를 선택해주세요. "대상 문서에서 텍스트 선택" 버튼을 클릭하여 연결할 텍스트를 드래그해주세요.');
return;
}
// API 호출용 데이터 준비 (백엔드 필드명에 맞춤)
const linkData = {
target_document_id: this.linkForm.target_document_id,
selected_text: this.linkForm.selected_text, // 백엔드: selected_text
start_offset: this.linkForm.start_offset, // 백엔드: start_offset
end_offset: this.linkForm.end_offset, // 백엔드: end_offset
link_text: this.linkForm.link_text || this.linkForm.selected_text,
description: this.linkForm.description,
link_type: this.linkForm.link_type,
target_text: this.linkForm.target_text || null,
target_start_offset: this.linkForm.target_start_offset || null,
target_end_offset: this.linkForm.target_end_offset || null
};
console.log('📤 링크 생성 데이터:', linkData);
console.log('📤 링크 생성 데이터 (JSON):', JSON.stringify(linkData, null, 2));
// 필수 필드 검증
const requiredFields = ['target_document_id', 'selected_text', 'start_offset', 'end_offset'];
const missingFields = requiredFields.filter(field =>
linkData[field] === undefined || linkData[field] === null || linkData[field] === ''
);
if (missingFields.length > 0) {
console.error('❌ 필수 필드 누락:', missingFields);
alert('필수 필드가 누락되었습니다: ' + missingFields.join(', '));
return;
}
console.log('✅ 모든 필수 필드 확인됨');
// API 호출
await this.api.createDocumentLink(this.documentId, linkData);
console.log('✅ 링크 생성됨');
// 성공 알림
alert('링크가 성공적으로 생성되었습니다!');
// 모달 닫기
this.showLinkModal = false;
// 링크 목록 새로고침
console.log('🔄 링크 목록 새로고침 시작...');
await this.linkManager.loadDocumentLinks(this.documentId);
this.documentLinks = this.linkManager.documentLinks || [];
console.log('📊 로드된 링크 개수:', this.documentLinks.length);
console.log('📊 링크 데이터:', this.documentLinks);
// 링크 렌더링
console.log('🎨 링크 렌더링 시작...');
await this.linkManager.renderDocumentLinks();
console.log('✅ 링크 렌더링 완료');
} catch (error) {
console.error('링크 생성 실패:', error);
console.error('에러 상세:', {
message: error.message,
stack: error.stack,
response: error.response
});
// 422 에러인 경우 상세 정보 표시
if (error.response && error.response.status === 422) {
console.error('422 Validation Error Details:', error.response.data);
alert('데이터 검증 실패: ' + JSON.stringify(error.response.data, null, 2));
} else {
alert('링크 생성에 실패했습니다: ' + error.message);
}
}
} }
}); });
@@ -649,3 +1032,44 @@ document.addEventListener('alpine:init', () => {
} }
}; };
}); });
// Alpine.js Store 등록
document.addEventListener('alpine:init', () => {
Alpine.store('documentViewer', {
instance: null,
init() {
// DocumentViewer 인스턴스가 생성되면 저장
setTimeout(() => {
this.instance = window.documentViewerInstance;
}, 500);
},
downloadOriginalFile() {
console.log('🏪 Store downloadOriginalFile 호출');
if (this.instance) {
return this.instance.downloadOriginalFile();
} else {
console.warn('DocumentViewer 인스턴스가 없습니다');
}
},
toggleLanguage() {
console.log('🏪 Store toggleLanguage 호출');
if (this.instance) {
return this.instance.toggleLanguage();
} else {
console.warn('DocumentViewer 인스턴스가 없습니다');
}
},
loadBacklinks() {
console.log('🏪 Store loadBacklinks 호출');
if (this.instance) {
return this.instance.loadBacklinks();
} else {
console.warn('DocumentViewer 인스턴스가 없습니다');
}
}
});
});

View File

@@ -26,7 +26,7 @@
})(); })();
</script> </script>
<div x-data="documentViewer" x-init="init()"> <div x-data="documentViewer" x-init="init(); $store.documentViewer.init()">
<!-- 헤더 - 투명하고 세련된 3줄 디자인 --> <!-- 헤더 - 투명하고 세련된 3줄 디자인 -->
<header class="bg-white/80 backdrop-blur-md shadow-lg border-b border-white/20 sticky top-0 z-50 w-full"> <header class="bg-white/80 backdrop-blur-md shadow-lg border-b border-white/20 sticky top-0 z-50 w-full">
<div class="w-full px-6 py-4"> <div class="w-full px-6 py-4">
@@ -134,67 +134,26 @@
<!-- 중앙: 기능 버튼들 --> <!-- 중앙: 기능 버튼들 -->
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<!-- 링크 버튼 그룹 --> <!-- 링크 버튼 - 드래그 후 클릭으로 링크 생성 -->
<div class="relative"> <button @click="activateLinkMode()"
<button @click="toggleFeatureMenu('link')" :class="activeMode === 'link' ? 'bg-purple-600' : 'bg-purple-500/80 hover:bg-purple-500'"
:class="activeFeatureMenu === 'link' ? 'bg-purple-600' : 'bg-purple-500/80 hover:bg-purple-500'" class="px-4 py-2 text-white rounded-xl transition-all duration-200 flex items-center space-x-2 shadow-sm"
class="px-4 py-2 text-white rounded-xl transition-all duration-200 flex items-center space-x-2 shadow-sm"> title="텍스트를 드래그한 후 이 버튼을 클릭하여 링크를 생성하세요">
<i class="fas fa-link text-sm"></i> <i class="fas fa-link text-sm"></i>
<span class="text-sm font-medium">링크</span> <span class="text-sm font-medium">링크</span>
<span x-show="documentLinks.length > 0" <span x-show="documentLinks.length > 0"
class="bg-white text-purple-600 text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold" class="bg-white text-purple-600 text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold"
x-text="documentLinks.length"></span> x-text="documentLinks.length"></span>
<i class="fas fa-chevron-down text-xs ml-1" :class="activeFeatureMenu === 'link' ? 'rotate-180' : ''"></i> </button>
</button>
<!-- 링크 서브메뉴 -->
<div x-show="activeFeatureMenu === 'link'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute top-full left-0 mt-2 bg-white/90 backdrop-blur-md rounded-xl shadow-lg border border-white/30 p-2 z-10 min-w-max">
<button @click="showLinksModal = true; activeFeatureMenu = null"
class="w-full px-3 py-2 text-sm text-gray-700 hover:bg-purple-100 rounded-lg flex items-center space-x-2 transition-colors">
<i class="fas fa-eye text-purple-500"></i>
<span>링크 보기</span>
</button>
<button @click="console.log('링크 만들기 클릭됨'); activateLinkMode(); activeFeatureMenu = null"
class="w-full px-3 py-2 text-sm text-gray-700 hover:bg-purple-100 rounded-lg flex items-center space-x-2 transition-colors">
<i class="fas fa-plus text-purple-500"></i>
<span>링크 만들기</span>
</button>
</div>
</div>
<!-- 백링크 버튼 그룹 --> <!-- 백링크 표시 (읽기 전용) -->
<div class="relative"> <div class="px-4 py-2 bg-orange-500/80 text-white rounded-xl flex items-center space-x-2 shadow-sm"
<button @click="toggleFeatureMenu('backlink')" title="이 문서를 참조하는 다른 문서들">
:class="activeFeatureMenu === 'backlink' ? 'bg-orange-600' : 'bg-orange-500/80 hover:bg-orange-500'" <i class="fas fa-arrow-left text-sm"></i>
class="px-4 py-2 text-white rounded-xl transition-all duration-200 flex items-center space-x-2 shadow-sm"> <span class="text-sm font-medium">백링크</span>
<i class="fas fa-arrow-left text-sm"></i> <span x-show="backlinks.length > 0"
<span class="text-sm font-medium">백링크</span> class="bg-white text-orange-600 text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold"
<span x-show="backlinks.length > 0" x-text="backlinks.length"></span>
class="bg-white text-orange-600 text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold"
x-text="backlinks.length"></span>
<i class="fas fa-chevron-down text-xs ml-1" :class="activeFeatureMenu === 'backlink' ? 'rotate-180' : ''"></i>
</button>
<!-- 백링크 서브메뉴 -->
<div x-show="activeFeatureMenu === 'backlink'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute top-full left-0 mt-2 bg-white/90 backdrop-blur-md rounded-xl shadow-lg border border-white/30 p-2 z-10 min-w-max">
<button @click="showBacklinksModal = true; loadBacklinks(); activeFeatureMenu = null"
class="w-full px-3 py-2 text-sm text-gray-700 hover:bg-orange-100 rounded-lg flex items-center space-x-2 transition-colors">
<i class="fas fa-eye text-orange-500"></i>
<span>백링크 보기</span>
</button>
</div>
</div> </div>
<!-- 메모 버튼 그룹 --> <!-- 메모 버튼 그룹 -->
@@ -268,14 +227,14 @@
<!-- 오른쪽: 액션 버튼들 --> <!-- 오른쪽: 액션 버튼들 -->
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<button @click="downloadOriginalFile()" <button @click="$store.documentViewer.downloadOriginalFile()"
class="bg-red-500/80 hover:bg-red-500 text-white px-4 py-2 rounded-xl transition-all duration-200 flex items-center space-x-2 shadow-sm" class="bg-red-500/80 hover:bg-red-500 text-white px-4 py-2 rounded-xl transition-all duration-200 flex items-center space-x-2 shadow-sm"
title="원본 PDF 다운로드"> title="원본 PDF 다운로드">
<i class="fas fa-file-pdf text-sm"></i> <i class="fas fa-file-pdf text-sm"></i>
<span class="text-sm font-medium">PDF</span> <span class="text-sm font-medium">PDF</span>
</button> </button>
<button @click="toggleLanguage()" <button @click="$store.documentViewer.toggleLanguage()"
class="bg-blue-500/80 hover:bg-blue-500 text-white px-4 py-2 rounded-xl transition-all duration-200 flex items-center space-x-2 shadow-sm" class="bg-blue-500/80 hover:bg-blue-500 text-white px-4 py-2 rounded-xl transition-all duration-200 flex items-center space-x-2 shadow-sm"
id="language-toggle-btn"> id="language-toggle-btn">
<i class="fas fa-globe text-sm"></i> <i class="fas fa-globe text-sm"></i>
@@ -417,37 +376,14 @@
<p class="text-purple-700" x-text="linkForm?.selected_text || ''"></p> <p class="text-purple-700" x-text="linkForm?.selected_text || ''"></p>
</div> </div>
<!-- 서적 범위 선택 --> <!-- 서적 선택 -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-3">링크 대상 범위</label> <label class="block text-sm font-semibold text-gray-700 mb-3">서적 선택</label>
<div class="grid grid-cols-2 gap-3"> <select
<button @click="linkForm.book_scope = 'same'; resetTargetSelection()" x-model="linkForm.target_book_id"
:class="linkForm.book_scope === 'same' ? 'bg-green-100 border-green-500 text-green-700' : 'bg-gray-50 border-gray-300 text-gray-600'" @change="loadDocumentsFromBook()"
class="p-4 border-2 rounded-lg transition-all duration-200 text-left"> class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
<div class="flex items-center space-x-2 mb-2"> >
<i class="fas fa-book"></i>
<span class="font-semibold">같은 서적</span>
</div>
<p class="text-xs">현재 서적 내 문서</p>
</button>
<button @click="linkForm.book_scope = 'other'; resetTargetSelection()"
:class="linkForm.book_scope === 'other' ? 'bg-blue-100 border-blue-500 text-blue-700' : 'bg-gray-50 border-gray-300 text-gray-600'"
class="p-4 border-2 rounded-lg transition-all duration-200 text-left">
<div class="flex items-center space-x-2 mb-2">
<i class="fas fa-books"></i>
<span class="font-semibold">다른 서적</span>
</div>
<p class="text-xs">다른 서적의 문서</p>
</button>
</div>
</div>
<!-- 대상 서적 선택 (다른 서적인 경우만) -->
<div x-show="linkForm.book_scope === 'other'" class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">대상 서적</label>
<select x-model="linkForm.target_book_id"
@change="loadDocumentsFromBook()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">서적을 선택하세요</option> <option value="">서적을 선택하세요</option>
<template x-for="book in availableBooks" :key="book.id"> <template x-for="book in availableBooks" :key="book.id">
<option :value="book.id" x-text="book.title"></option> <option :value="book.id" x-text="book.title"></option>
@@ -455,21 +391,21 @@
</select> </select>
</div> </div>
<!-- 대상 문서 선택 --> <!-- 대상 문서 선택 -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2"> <label class="block text-sm font-semibold text-gray-700 mb-2">
대상 문서 대상 문서
<span x-show="linkForm.book_scope === 'same'" class="text-green-600 text-xs">(현재 서적)</span> <span x-show="linkForm.target_book_id" class="text-blue-600 text-xs" x-text="`(${getSelectedBookTitle()})`"></span>
<span x-show="linkForm.book_scope === 'other' && linkForm.target_book_id" class="text-blue-600 text-xs" x-text="`(${getSelectedBookTitle()})`"></span>
</label> </label>
<select x-model="linkForm.target_document_id" <select x-model="linkForm.target_document_id"
@change="onTargetDocumentChange()" @change="onTargetDocumentChange()"
:disabled="linkForm.book_scope === 'other' && !linkForm.target_book_id" :disabled="!linkForm.target_book_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:bg-gray-100 disabled:cursor-not-allowed"> class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:bg-gray-100 disabled:cursor-not-allowed">
<option value=""> <option value="">
<span x-show="linkForm.book_scope === 'same'">문서를 선택하세요</span> <span x-show="!linkForm.target_book_id">먼저 서적을 선택하세요</span>
<span x-show="linkForm.book_scope === 'other' && !linkForm.target_book_id">먼저 서적을 선택하세요</span> <span x-show="linkForm.target_book_id">문서를 선택하세요</span>
<span x-show="linkForm.book_scope === 'other' && linkForm.target_book_id">문서를 선택하세요</span>
</option> </option>
<template x-for="doc in filteredDocuments" :key="doc.id"> <template x-for="doc in filteredDocuments" :key="doc.id">
<option :value="doc.id" x-text="doc.title"></option> <option :value="doc.id" x-text="doc.title"></option>
@@ -482,7 +418,7 @@
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">대상 텍스트</label> <label class="block text-sm font-semibold text-gray-700 mb-2">대상 텍스트</label>
<div class="space-y-3"> <div class="space-y-3">
<button @click="openTargetDocumentSelector()" <button @click="selectTextFromDocument()"
:disabled="!linkForm.target_document_id" :disabled="!linkForm.target_document_id"
:class="linkForm.target_document_id ? 'bg-blue-500 hover:bg-blue-600 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed'" :class="linkForm.target_document_id ? 'bg-blue-500 hover:bg-blue-600 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed'"
class="w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center space-x-2"> class="w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center space-x-2">
@@ -514,7 +450,7 @@
class="px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"> class="px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소 취소
</button> </button>
<button @click="saveDocumentLink()" <button @click="createDocumentLink()"
:disabled="!linkForm?.target_document_id" :disabled="!linkForm?.target_document_id"
:class="linkForm?.target_document_id ? 'bg-purple-500 hover:bg-purple-600' : 'bg-gray-300 cursor-not-allowed'" :class="linkForm?.target_document_id ? 'bg-purple-500 hover:bg-purple-600' : 'bg-gray-300 cursor-not-allowed'"
class="px-4 py-2 text-white rounded-lg transition-colors"> class="px-4 py-2 text-white rounded-lg transition-colors">

Binary file not shown.

Binary file not shown.