스토리 뷰 페이지 헤더 z-index 충돌 문제 해결

- 메인 컨테이너 padding-top을 pt-4에서 pt-20으로 증가
- 드롭다운 z-index를 z-[60]으로 설정하여 헤더보다 높은 우선순위 부여
- 스토리 선택 드롭다운이 정상적으로 작동하도록 수정
- 디버깅용 코드 정리
This commit is contained in:
Hyungi Ahn
2025-09-04 10:22:43 +09:00
parent 3ba804276c
commit 43e7466195
11 changed files with 709 additions and 35 deletions

View File

@@ -12,8 +12,8 @@ from ..core.security import verify_token, get_user_id_from_token
from ..models.user import User
# HTTP Bearer 토큰 스키마
security = HTTPBearer()
# HTTP Bearer 토큰 스키마 (선택적)
security = HTTPBearer(auto_error=False)
async def get_current_user(
@@ -94,16 +94,22 @@ async def get_current_user_with_token_param(
db: AsyncSession = Depends(get_db)
) -> User:
"""URL 파라미터 또는 헤더에서 토큰을 가져와서 사용자 인증"""
print(f"🔍 토큰 인증 시작 - URL 파라미터: {_token[:50] if _token else 'None'}...")
print(f"🔍 Authorization 헤더: {credentials.credentials[:50] if credentials else 'None'}...")
token = None
# URL 파라미터에서 토큰 확인
if _token:
token = _token
print("✅ URL 파라미터에서 토큰 사용")
# Authorization 헤더에서 토큰 확인
elif credentials:
token = credentials.credentials
print("✅ Authorization 헤더에서 토큰 사용")
if not token:
print("❌ 토큰이 제공되지 않음")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No authentication token provided"
@@ -112,23 +118,27 @@ async def get_current_user_with_token_param(
try:
# 토큰에서 사용자 ID 추출
user_id = get_user_id_from_token(token)
print(f"✅ 토큰에서 사용자 ID 추출: {user_id}")
# 데이터베이스에서 사용자 조회
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
print(f"❌ 사용자를 찾을 수 없음: {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
print(f"❌ 비활성 사용자: {user.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
print(f"✅ 사용자 인증 성공: {user.email}")
return user
except Exception as e:

View File

@@ -516,9 +516,14 @@ async def get_document_pdf(
db: AsyncSession = Depends(get_db)
):
"""문서 PDF 파일 조회"""
print(f"🔍 PDF 요청 - 문서 ID: {document_id}")
print(f"🔍 토큰 파라미터: {_token[:50] if _token else 'None'}...")
print(f"🔍 현재 사용자: {current_user.email if current_user else 'None'}")
try:
doc_uuid = UUID(document_id)
except ValueError:
print(f"❌ 잘못된 문서 ID 형식: {document_id}")
raise HTTPException(status_code=400, detail="Invalid document ID format")
# 문서 조회
@@ -527,10 +532,16 @@ async def get_document_pdf(
document = result.scalar_one_or_none()
if not document:
print(f"❌ 문서를 찾을 수 없음: {document_id}")
raise HTTPException(status_code=404, detail="Document not found")
print(f"📄 문서 정보: {document.title}")
print(f"🔐 문서 권한: is_public={document.is_public}, uploaded_by={document.uploaded_by}")
print(f"👤 사용자 권한: is_admin={current_user.is_admin}, user_id={current_user.id}")
# 권한 확인
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
print(f"❌ 접근 권한 없음 - 관리자: {current_user.is_admin}, 공개: {document.is_public}, 소유자: {document.uploaded_by == current_user.id}")
raise HTTPException(status_code=403, detail="Access denied")
# PDF 파일 확인
@@ -562,11 +573,20 @@ async def get_document_pdf(
print(f"📂 디렉토리도 없음: {dir_path}")
raise HTTPException(status_code=404, detail="PDF file not found on disk")
return FileResponse(
# PDF 인라인 표시를 위한 헤더 설정
from fastapi.responses import FileResponse
response = FileResponse(
path=file_path,
media_type='application/pdf',
filename=f"{document.title}.pdf"
)
# 브라우저에서 인라인으로 표시하도록 설정 (다운로드 방지)
response.headers["Content-Disposition"] = f"inline; filename=\"{document.title}.pdf\""
response.headers["X-Frame-Options"] = "SAMEORIGIN" # iframe 허용
return response
@router.get("/{document_id}/search-in-content")

View File

@@ -193,6 +193,12 @@
<!-- 액션 버튼 -->
<div class="flex items-center space-x-2 ml-4">
<button @click="previewPDF(pdf)"
class="p-2 text-gray-400 hover:text-green-600 transition-colors"
title="PDF 미리보기">
<i class="fas fa-eye"></i>
</button>
<button @click="downloadPDF(pdf)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="PDF 다운로드">
@@ -225,10 +231,86 @@
</div>
</main>
<!-- PDF 미리보기 모달 -->
<div x-show="showPreviewModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
@click.self="closePreview()">
<div class="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
<!-- 헤더 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div class="flex items-center space-x-3">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
<div>
<h3 class="text-xl font-bold text-gray-900" x-text="previewPdf?.title"></h3>
<p class="text-sm text-gray-500" x-text="previewPdf?.original_filename"></p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="downloadPDF(previewPdf)"
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<i class="fas fa-download"></i>
<span>다운로드</span>
</button>
<button @click="closePreview()"
class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
</div>
<!-- PDF 뷰어 -->
<div class="p-6 overflow-y-auto" style="max-height: calc(90vh - 120px);">
<!-- PDF 미리보기 -->
<div x-show="previewPdf" class="mb-4">
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 600px;">
<!-- PDF iframe 뷰어 -->
<iframe x-show="!pdfPreviewError && !pdfPreviewLoading && pdfPreviewSrc"
class="w-full h-full border-0"
:src="pdfPreviewSrc"
@load="pdfPreviewLoaded = true"
@error="handlePdfPreviewError()">
</iframe>
<!-- PDF 로딩 상태 -->
<div x-show="pdfPreviewLoading" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-3xl text-gray-500 mb-4"></i>
<p class="text-gray-600 text-lg">PDF를 로드하는 중...</p>
</div>
</div>
<!-- PDF 에러 상태 -->
<div x-show="pdfPreviewError" class="flex items-center justify-center h-full text-gray-500">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-3xl mb-4 text-red-500"></i>
<p class="text-lg mb-4">PDF를 로드할 수 없습니다</p>
<button @click="retryPdfPreview()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-2">
다시 시도
</button>
<button @click="downloadPDF(previewPdf)"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
파일 다운로드
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/pdf-manager.js?v=2025012459"></script>
<script src="/static/js/pdf-manager.js?v=2025012627"></script>
</body>
</html>

View File

@@ -11,6 +11,14 @@ window.pdfManagerApp = () => ({
isAuthenticated: false,
currentUser: null,
// PDF 미리보기 상태
showPreviewModal: false,
previewPdf: null,
pdfPreviewSrc: '',
pdfPreviewLoading: false,
pdfPreviewError: false,
pdfPreviewLoaded: false,
// 초기화
async init() {
console.log('🚀 PDF Manager App 초기화 시작');
@@ -213,6 +221,56 @@ window.pdfManagerApp = () => ({
}
},
// ==================== PDF 미리보기 관련 ====================
async previewPDF(pdf) {
console.log('👁️ PDF 미리보기:', pdf.title);
this.previewPdf = pdf;
this.showPreviewModal = true;
this.pdfPreviewLoading = true;
this.pdfPreviewError = false;
this.pdfPreviewLoaded = false;
try {
const token = localStorage.getItem('access_token');
if (!token || token === 'null' || token === null) {
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
}
// PDF 미리보기 URL 설정
this.pdfPreviewSrc = `/api/documents/${pdf.id}/pdf?_token=${encodeURIComponent(token)}`;
console.log('✅ PDF 미리보기 준비 완료:', this.pdfPreviewSrc);
} catch (error) {
console.error('❌ PDF 미리보기 로드 실패:', error);
this.pdfPreviewError = true;
this.showNotification('PDF 미리보기 로드에 실패했습니다: ' + error.message, 'error');
} finally {
this.pdfPreviewLoading = false;
}
},
closePreview() {
this.showPreviewModal = false;
this.previewPdf = null;
this.pdfPreviewSrc = '';
this.pdfPreviewLoading = false;
this.pdfPreviewError = false;
this.pdfPreviewLoaded = false;
},
handlePdfPreviewError() {
console.error('❌ PDF 미리보기 iframe 로드 오류');
this.pdfPreviewError = true;
this.pdfPreviewLoading = false;
},
async retryPdfPreview() {
if (this.previewPdf) {
await this.previewPDF(this.previewPdf);
}
},
// 날짜 포맷팅
formatDate(dateString) {
if (!dateString) return '';

View File

@@ -17,7 +17,10 @@ window.storyViewApp = function() {
// UI 상태
showLoginModal: false,
showEditModal: false,
editingNode: null,
editingNode: {
title: '',
content: ''
},
// 로그인 폼 상태
loginForm: {
@@ -79,11 +82,22 @@ window.storyViewApp = function() {
async loadUserTrees() {
try {
console.log('📊 사용자 트리 목록 로딩...');
console.log('🔍 API 객체 확인:', window.api);
console.log('🔍 getUserMemoTrees 함수 확인:', typeof window.api?.getUserMemoTrees);
const trees = await window.api.getUserMemoTrees();
this.userTrees = trees || [];
console.log(`${this.userTrees.length}개 트리 로드 완료`);
console.log('📋 트리 목록:', this.userTrees);
// Alpine.js 반응성 업데이트를 위한 약간의 지연 후 URL 파라미터 확인
setTimeout(() => {
this.checkUrlParams();
}, 100);
} catch (error) {
console.error('❌ 트리 목록 로드 실패:', error);
console.error('❌ 에러 상세:', error.message);
console.error('❌ 에러 스택:', error.stack);
this.userTrees = [];
}
},

View File

@@ -24,9 +24,15 @@ class DocumentLoader {
document.title = `${noteDocument.title} - Document Server`;
// 노트 내용을 HTML로 설정
const contentElement = document.getElementById('document-content');
if (contentElement && noteDocument.content) {
contentElement.innerHTML = noteDocument.content;
const noteContentElement = document.getElementById('note-content');
if (noteContentElement && noteDocument.content) {
noteContentElement.innerHTML = noteDocument.content;
} else {
// 폴백: document-content 사용
const contentElement = document.getElementById('document-content');
if (contentElement && noteDocument.content) {
contentElement.innerHTML = noteDocument.content;
}
}
console.log('📝 노트 로드 완료:', noteDocument.title);
@@ -46,25 +52,28 @@ class DocumentLoader {
// 백엔드에서 문서 정보 가져오기 (캐싱 적용)
const docData = await this.cachedApi.get(`/documents/${documentId}`, { content_type: 'document' }, { category: 'document' });
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
const htmlPath = docData.html_path;
const fileName = htmlPath.split('/').pop();
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
if (!response.ok) {
throw new Error('문서 파일을 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트
document.title = `${docData.title} - Document Server`;
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
this.setupDocumentScriptHandlers();
// PDF 문서가 아닌 경우에만 HTML 로드
if (!docData.pdf_path && docData.html_path) {
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
const htmlPath = docData.html_path;
const fileName = htmlPath.split('/').pop();
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
if (!response.ok) {
throw new Error('문서 파일을 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
this.setupDocumentScriptHandlers();
}
console.log('✅ 문서 로드 완료:', docData.title);
console.log('✅ 문서 로드 완료:', docData.title, docData.pdf_path ? '(PDF)' : '(HTML)');
return docData;
} catch (error) {

View File

@@ -11,6 +11,27 @@ window.documentViewer = () => ({
contentType: 'document', // 'document' 또는 'note'
navigation: null,
// ==================== PDF 뷰어 상태 ====================
pdfSrc: '',
pdfLoading: false,
pdfError: false,
pdfLoaded: false,
// ==================== PDF 검색 상태 ====================
showPdfSearchModal: false,
pdfSearchQuery: '',
pdfSearchResults: [],
pdfSearchLoading: false,
// ==================== PDF.js 뷰어 상태 ====================
pdfDocument: null,
currentPage: 1,
totalPages: 0,
pdfScale: 1.0,
pdfCanvas: null,
pdfContext: null,
pdfTextContent: [],
// ==================== 데이터 상태 ====================
highlights: [],
notes: [],
@@ -280,6 +301,11 @@ window.documentViewer = () => ({
this.document = await this.documentLoader.loadDocument(this.documentId);
// 네비게이션 별도 로드
this.navigation = await this.documentLoader.loadNavigation(this.documentId);
// PDF 문서인 경우 PDF 뷰어 준비
if (this.document && this.document.pdf_path) {
await this.loadPdfViewer();
}
}
// 관련 데이터 병렬 로드
@@ -1825,6 +1851,217 @@ window.documentViewer = () => ({
}
},
// ==================== PDF 뷰어 관련 ====================
async loadPdfViewer() {
console.log('📄 PDF 뷰어 로드 시작');
this.pdfLoading = true;
this.pdfError = false;
this.pdfLoaded = false;
try {
const token = localStorage.getItem('access_token');
if (!token || token === 'null' || token === null) {
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
}
// PDF 뷰어 URL 설정 (토큰 포함)
this.pdfSrc = `/api/documents/${this.documentId}/pdf?_token=${encodeURIComponent(token)}`;
console.log('✅ PDF 뷰어 준비 완료:', this.pdfSrc);
// PDF.js로 PDF 로드
await this.loadPdfWithPdfJs();
} catch (error) {
console.error('❌ PDF 뷰어 로드 실패:', error);
this.pdfError = true;
} finally {
this.pdfLoading = false;
}
},
async loadPdfWithPdfJs() {
try {
// PDF.js 워커 설정
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
console.log('📄 PDF.js로 PDF 로드 시작:', this.pdfSrc);
// PDF 문서 로드
const loadingTask = pdfjsLib.getDocument(this.pdfSrc);
this.pdfDocument = await loadingTask.promise;
this.totalPages = this.pdfDocument.numPages;
this.currentPage = 1;
console.log(`✅ PDF 로드 완료: ${this.totalPages} 페이지`);
// 캔버스 초기화
this.initPdfCanvas();
// 첫 페이지 렌더링
await this.renderPdfPage(1);
this.pdfLoaded = true;
} else {
throw new Error('PDF.js 라이브러리가 로드되지 않았습니다.');
}
} catch (error) {
console.error('❌ PDF.js 로드 실패:', error);
throw error;
}
},
initPdfCanvas() {
this.pdfCanvas = document.getElementById('pdf-canvas');
if (this.pdfCanvas) {
this.pdfContext = this.pdfCanvas.getContext('2d');
}
},
async renderPdfPage(pageNum) {
if (!this.pdfDocument || !this.pdfCanvas) return;
try {
console.log(`📄 페이지 ${pageNum} 렌더링 시작`);
const page = await this.pdfDocument.getPage(pageNum);
const viewport = page.getViewport({ scale: this.pdfScale });
// 캔버스 크기 설정
this.pdfCanvas.height = viewport.height;
this.pdfCanvas.width = viewport.width;
// 페이지 렌더링
const renderContext = {
canvasContext: this.pdfContext,
viewport: viewport
};
await page.render(renderContext).promise;
// 텍스트 내용 추출 (검색용)
const textContent = await page.getTextContent();
this.pdfTextContent[pageNum] = textContent.items.map(item => item.str).join(' ');
console.log(`✅ 페이지 ${pageNum} 렌더링 완료`);
} catch (error) {
console.error(`❌ 페이지 ${pageNum} 렌더링 실패:`, error);
}
},
handlePdfError() {
console.error('❌ PDF iframe 로드 오류');
this.pdfError = true;
this.pdfLoading = false;
},
async retryPdfLoad() {
console.log('🔄 PDF 재로드 시도');
await this.loadPdfViewer();
},
// ==================== PDF 검색 관련 ====================
openPdfSearchModal() {
this.showPdfSearchModal = true;
this.pdfSearchQuery = '';
this.pdfSearchResults = [];
// 모달이 열린 후 입력 필드에 포커스
setTimeout(() => {
const searchInput = document.querySelector('input[x-ref="searchInput"]');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}, 100);
},
async searchInPdf() {
if (!this.pdfSearchQuery.trim()) {
alert('검색어를 입력해주세요.');
return;
}
console.log('🔍 PDF 검색 시작:', this.pdfSearchQuery);
this.pdfSearchLoading = true;
this.pdfSearchResults = [];
try {
// 백엔드 API를 통해 PDF 내용 검색
const searchResults = await this.api.get(
`/documents/${this.documentId}/search-in-content?q=${encodeURIComponent(this.pdfSearchQuery)}`
);
console.log('✅ PDF 검색 결과:', searchResults);
if (searchResults.matches && searchResults.matches.length > 0) {
this.pdfSearchResults = searchResults.matches.map(match => ({
page: match.page || 1,
context: match.context || match.text || this.pdfSearchQuery,
position: match.position || 0
}));
console.log(`📄 ${this.pdfSearchResults.length}개의 검색 결과 발견`);
if (this.pdfSearchResults.length === 0) {
alert('검색 결과를 찾을 수 없습니다.');
}
} else {
alert('검색 결과를 찾을 수 없습니다.');
}
} catch (error) {
console.error('❌ PDF 검색 실패:', error);
alert('PDF 검색 중 오류가 발생했습니다: ' + error.message);
} finally {
this.pdfSearchLoading = false;
}
},
jumpToPdfResult(result) {
console.log('📍 PDF 결과로 이동:', result);
// PDF URL에 페이지 번호 추가하여 해당 페이지로 이동
const token = localStorage.getItem('access_token');
let newPdfSrc = `/api/documents/${this.documentId}/pdf?_token=${encodeURIComponent(token)}`;
// 페이지 번호가 있으면 URL 프래그먼트로 추가
if (result.page && result.page > 1) {
newPdfSrc += `#page=${result.page}`;
}
// PDF src 업데이트하여 해당 페이지로 이동
this.pdfSrc = newPdfSrc;
console.log(`📄 페이지 ${result.page}로 이동:`, newPdfSrc);
// 잠시 후 검색 기능 활성화
setTimeout(() => {
const iframe = document.querySelector('#pdf-viewer-iframe');
if (iframe && iframe.contentWindow) {
try {
iframe.contentWindow.focus();
// 브라우저 내장 검색 기능 활용
if (iframe.contentWindow.find) {
iframe.contentWindow.find(this.pdfSearchQuery);
} else {
// 대안: 사용자에게 수동 검색 안내
this.showSuccessMessage(`페이지 ${result.page}로 이동했습니다. Ctrl+F를 눌러 "${this.pdfSearchQuery}"를 검색하세요.`);
}
} catch (e) {
console.warn('PDF iframe 접근 제한:', e);
this.showSuccessMessage(`페이지 ${result.page}로 이동했습니다. Ctrl+F를 눌러 "${this.pdfSearchQuery}"를 검색하세요.`);
}
}
}, 1000);
// 모달 닫기
this.showPdfSearchModal = false;
},
async editNote(noteId, currentContent) {
console.log('✏️ 메모 편집:', noteId);
console.log('🔍 HighlightManager 상태:', this.highlightManager);

View File

@@ -44,7 +44,7 @@
<div id="header-container"></div>
<!-- 메인 컨테이너 -->
<div class="min-h-screen pt-4" x-show="currentUser">
<div class="min-h-screen pt-20" x-show="currentUser">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- 상단 툴바 -->
@@ -54,8 +54,8 @@
<div class="flex items-center space-x-4">
<select
x-model="selectedTreeId"
@change="loadStory(selectedTreeId)"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="loadStory($event.target.value)"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer relative z-[60]"
>
<option value="">📚 스토리 선택</option>
<template x-for="tree in userTrees" :key="tree.id">
@@ -63,6 +63,7 @@
</template>
</select>
<div x-show="selectedTree" class="text-sm text-gray-600">
<span x-text="`총 ${canonicalNodes.length}개 챕터`"></span>
<span class="mx-2"></span>
@@ -178,7 +179,7 @@
</div>
</div>
<div class="flex-1 p-6 overflow-hidden">
<div class="flex-1 p-6 overflow-hidden" x-show="editingNode">
<div class="h-full flex flex-col space-y-4">
<!-- 제목 편집 -->
<div>
@@ -288,17 +289,31 @@
// 스크립트 순차 로딩
(async () => {
try {
await loadScript('static/js/api.js?v=2025012380');
console.log('🚀 스크립트 로딩 시작...');
await loadScript('static/js/api.js?v=2025012627');
console.log('✅ API 스크립트 로드 완료');
await loadScript('static/js/story-view.js?v=2025012364');
await loadScript('static/js/story-view.js?v=2025012627');
console.log('✅ Story View 스크립트 로드 완료');
// Alpine.js 로드 전에 함수 등록 확인
console.log('🔍 storyViewApp 함수 확인:', typeof window.storyViewApp);
// 모든 스크립트 로드 완료 후 Alpine.js 로드
console.log('🚀 Alpine.js 로딩...');
await loadScript('https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js');
console.log('✅ Alpine.js 로드 완료');
// Alpine.js 초기화 확인
setTimeout(() => {
console.log('🔍 Alpine 객체 확인:', typeof Alpine);
console.log('🔍 Alpine 초기화 상태:', Alpine ? 'OK' : 'FAILED');
}, 500);
} catch (error) {
console.error('❌ 스크립트 로드 실패:', error);
console.error('❌ 에러 상세:', error.message);
}
})();
</script>

View File

@@ -266,13 +266,210 @@
</div>
<!-- 문서 내용 -->
<div x-show="!loading && !error" id="document-content" class="prose max-w-none">
<!-- 문서 HTML이 여기에 로드됩니다 -->
<div x-show="!loading && !error">
<!-- HTML 문서 내용 -->
<div x-show="contentType === 'document' && document && !document.pdf_path"
id="document-content" class="prose max-w-none">
<!-- 문서 HTML이 여기에 로드됩니다 -->
</div>
<!-- PDF 뷰어 -->
<div x-show="contentType === 'document' && document && document.pdf_path"
class="pdf-viewer-container">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
<h1 class="text-2xl font-bold text-gray-900" x-text="document?.title"></h1>
</div>
<div class="flex items-center space-x-2">
<button @click="openPdfSearchModal()"
class="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2">
<i class="fas fa-search"></i>
<span>PDF에서 검색</span>
</button>
<button @click="downloadOriginalFile()"
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<i class="fas fa-download"></i>
<span>다운로드</span>
</button>
</div>
</div>
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-white relative" style="min-height: 800px;">
<!-- PDF.js 뷰어 -->
<div x-show="!pdfError && !pdfLoading && pdfSrc" class="w-full h-full">
<!-- PDF 뷰어 툴바 -->
<div class="bg-gray-100 border-b px-4 py-2 flex items-center justify-between">
<div class="flex items-center space-x-4">
<button @click="previousPage()" :disabled="currentPage <= 1"
class="px-3 py-1 bg-blue-600 text-white rounded disabled:bg-gray-300">
<i class="fas fa-chevron-left"></i>
</button>
<span class="text-sm">
<input type="number" x-model="currentPage" @change="goToPage(currentPage)"
class="w-16 px-2 py-1 border rounded text-center" min="1" :max="totalPages">
/ <span x-text="totalPages"></span>
</span>
<button @click="nextPage()" :disabled="currentPage >= totalPages"
class="px-3 py-1 bg-blue-600 text-white rounded disabled:bg-gray-300">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="flex items-center space-x-2">
<button @click="zoomOut()" class="px-2 py-1 bg-gray-600 text-white rounded">
<i class="fas fa-search-minus"></i>
</button>
<span class="text-sm" x-text="Math.round(pdfScale * 100) + '%'"></span>
<button @click="zoomIn()" class="px-2 py-1 bg-gray-600 text-white rounded">
<i class="fas fa-search-plus"></i>
</button>
</div>
</div>
<!-- PDF 캔버스 -->
<div class="overflow-auto" style="height: 750px;">
<canvas id="pdf-canvas" class="mx-auto block"></canvas>
</div>
</div>
<!-- 기존 iframe (폴백용) -->
<iframe id="pdf-viewer-iframe"
x-show="false"
class="w-full border-0"
style="height: 800px;"
:src="pdfSrc"
@load="pdfLoaded = true; console.log('PDF iframe 로드 완료')"
@error="handlePdfError()"
allow="fullscreen">
</iframe>
<!-- PDF 로딩 상태 -->
<div x-show="pdfLoading" class="flex items-center justify-center h-full" style="min-height: 400px;">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-3xl text-gray-500 mb-4"></i>
<p class="text-gray-600 text-lg">PDF를 로드하는 중...</p>
</div>
</div>
<!-- PDF 에러 상태 -->
<div x-show="pdfError" class="flex items-center justify-center h-full text-gray-500" style="min-height: 400px;">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-3xl mb-4 text-red-500"></i>
<p class="text-lg mb-4">PDF를 로드할 수 없습니다</p>
<button @click="retryPdfLoad()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-2">
다시 시도
</button>
<button @click="downloadOriginalFile()"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
파일 다운로드
</button>
</div>
</div>
</div>
</div>
<!-- 노트 문서 내용 -->
<div x-show="contentType === 'note'"
id="note-content" class="prose max-w-none">
<!-- 노트 내용이 여기에 로드됩니다 -->
</div>
</div>
</div>
</main>
</div>
<!-- PDF 검색 모달 -->
<div x-show="showPdfSearchModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
@click.self="showPdfSearchModal = false">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<!-- 헤더 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h3 class="text-xl font-bold text-gray-900 flex items-center space-x-2">
<i class="fas fa-search text-green-600"></i>
<span>PDF에서 검색</span>
</h3>
<button @click="showPdfSearchModal = false"
class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 내용 -->
<div class="p-6">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">검색어</label>
<div class="relative">
<input type="text"
x-model="pdfSearchQuery"
@keydown.enter="searchInPdf()"
@input="pdfSearchResults = []"
placeholder="예: pressure, vessel, design..."
class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
x-ref="searchInput">
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
<i class="fas fa-search text-gray-400"></i>
</div>
</div>
<div class="mt-1 text-xs text-gray-500">
Enter 키를 누르거나 검색 버튼을 클릭하세요
</div>
</div>
<!-- 검색 결과 -->
<div x-show="pdfSearchResults.length > 0" class="mb-4">
<div class="text-sm text-gray-600 mb-2">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<span x-text="pdfSearchResults.length"></span>개의 결과를 찾았습니다.
</div>
<div class="max-h-40 overflow-y-auto space-y-2">
<template x-for="(result, index) in pdfSearchResults" :key="index">
<div class="p-3 bg-gray-50 rounded cursor-pointer hover:bg-green-50 border hover:border-green-200 transition-colors"
@click="jumpToPdfResult(result)">
<div class="text-sm font-medium text-green-700">
<i class="fas fa-file-pdf mr-1"></i>
페이지 <span x-text="result.page"></span>
</div>
<div class="text-xs text-gray-600 mt-1" x-text="result.context"></div>
</div>
</template>
</div>
</div>
<!-- 검색 결과 없음 -->
<div x-show="pdfSearchQuery.trim() && !pdfSearchLoading && pdfSearchResults.length === 0" class="mb-4">
<div class="text-center py-4 text-gray-500">
<i class="fas fa-search text-2xl mb-2"></i>
<p class="text-sm">검색 결과가 없습니다.</p>
<p class="text-xs mt-1">다른 검색어로 시도해보세요.</p>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3">
<button @click="searchInPdf()"
:disabled="!pdfSearchQuery.trim() || pdfSearchLoading"
class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
<i :class="pdfSearchLoading ? 'fas fa-spinner fa-spin' : 'fas fa-search'" class="mr-2"></i>
<span x-text="pdfSearchLoading ? '검색 중...' : '검색'"></span>
</button>
<button @click="showPdfSearchModal = false"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors">
닫기
</button>
</div>
</div>
</div>
</div>
<!-- 링크 모달 -->
<div x-show="showLinksModal"
x-transition:enter="transition ease-out duration-300"
@@ -781,14 +978,17 @@
<script src="/static/js/viewer/utils/module-loader.js?v=2025012607"></script>
<!-- 모든 모듈들 직접 로드 -->
<script src="/static/js/viewer/core/document-loader.js?v=2025012607"></script>
<script src="/static/js/viewer/core/document-loader.js?v=2025012624"></script>
<script src="/static/js/viewer/features/ui-manager.js?v=2025012607"></script>
<script src="/static/js/viewer/features/highlight-manager.js?v=2025012619"></script>
<script src="/static/js/viewer/features/link-manager.js?v=2025012622"></script>
<script src="/static/js/viewer/features/bookmark-manager.js?v=2025012607"></script>
<!-- ViewerCore (Alpine.js 컴포넌트) -->
<script src="/static/js/viewer/viewer-core.js?v=2025012623"></script>
<script src="/static/js/viewer/viewer-core.js?v=2025012626"></script>
<!-- PDF.js 라이브러리 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<!-- Alpine.js 프레임워크 -->
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>

View File

@@ -11,6 +11,36 @@ server {
access_log off;
}
# PDF 파일 요청 (iframe 허용)
location ~ ^/api/documents/[^/]+/pdf$ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 타임아웃 설정
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# 버퍼링 설정
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# 리다이렉트 방지
proxy_redirect off;
# PDF iframe 허용 및 인라인 표시 설정
add_header X-Frame-Options "SAMEORIGIN" always;
# PDF 파일이 다운로드되지 않고 브라우저에서 표시되도록 설정
location ~ \.pdf$ {
add_header Content-Disposition "inline";
}
}
# API 요청을 백엔드로 프록시
location /api/ {
proxy_pass http://backend:8000;

View File

@@ -45,8 +45,7 @@ http {
application/atom+xml
image/svg+xml;
# 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
# 보안 헤더 (PDF 파일은 제외)
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;