feat: PDF/HTML 폴더 분리 및 필터링 개선

- 업로드 시 HTML과 PDF를 별도 폴더에 저장 (/documents/, /pdfs/)
- 프론트엔드 필터링을 폴더 경로 기준으로 단순화
- PDF 삭제 시 외래키 참조 해제 로직 추가
- book-documents.js, book-editor.js 필터링 통일
- HTML 문서 목록에서 PDF 완전 분리
This commit is contained in:
Hyungi Ahn
2025-08-26 07:44:25 +09:00
parent 4038040faa
commit 04ae64fc4d
20 changed files with 1334 additions and 73 deletions

View File

@@ -0,0 +1,12 @@
-- 문서에 PDF 매칭 필드 추가
-- Migration: 005_add_matched_pdf_id.sql
-- matched_pdf_id 컬럼 추가
ALTER TABLE documents
ADD COLUMN matched_pdf_id UUID REFERENCES documents(id);
-- 인덱스 추가 (성능 향상)
CREATE INDEX idx_documents_matched_pdf_id ON documents(matched_pdf_id);
-- 코멘트 추가
COMMENT ON COLUMN documents.matched_pdf_id IS '매칭된 PDF 문서 ID (HTML 문서에 연결된 원본 PDF)';

View File

@@ -0,0 +1,9 @@
-- HTML 경로를 nullable로 변경 (PDF만 업로드하는 경우 대응)
-- Migration: 006_make_html_path_nullable.sql
-- html_path 컬럼을 nullable로 변경
ALTER TABLE documents
ALTER COLUMN html_path DROP NOT NULL;
-- 코멘트 업데이트
COMMENT ON COLUMN documents.html_path IS 'HTML 파일 경로 (PDF만 업로드하는 경우 null 가능)';

View File

@@ -3,7 +3,7 @@
""" """
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_ from sqlalchemy import select, delete, and_, or_, update
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
import os import os
@@ -27,7 +27,7 @@ class DocumentResponse(BaseModel):
id: str id: str
title: str title: str
description: Optional[str] description: Optional[str]
html_path: str html_path: Optional[str] # PDF만 업로드하는 경우 None 가능
pdf_path: Optional[str] pdf_path: Optional[str]
thumbnail_path: Optional[str] thumbnail_path: Optional[str]
file_size: Optional[int] file_size: Optional[int]
@@ -50,6 +50,9 @@ class DocumentResponse(BaseModel):
category_id: Optional[str] = None category_id: Optional[str] = None
category_name: Optional[str] = None category_name: Optional[str] = None
sort_order: int = 0 sort_order: int = 0
# PDF 매칭 정보
matched_pdf_id: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -128,7 +131,7 @@ async def list_documents(
id=str(doc.id), id=str(doc.id),
title=doc.title, title=doc.title,
description=doc.description, description=doc.description,
html_path=doc.html_path, html_path=doc.html_path, # None 가능 (PDF만 업로드한 경우)
pdf_path=doc.pdf_path, pdf_path=doc.pdf_path,
thumbnail_path=doc.thumbnail_path, thumbnail_path=doc.thumbnail_path,
file_size=doc.file_size, file_size=doc.file_size,
@@ -148,7 +151,9 @@ async def list_documents(
# 소분류 정보 추가 # 소분류 정보 추가
category_id=str(doc.category.id) if doc.category else None, category_id=str(doc.category.id) if doc.category else None,
category_name=doc.category.name if doc.category else None, category_name=doc.category.name if doc.category else None,
sort_order=doc.sort_order sort_order=doc.sort_order,
# PDF 매칭 정보 추가
matched_pdf_id=str(doc.matched_pdf_id) if doc.matched_pdf_id else None
) )
response_data.append(doc_data) response_data.append(doc_data)
@@ -257,11 +262,15 @@ async def upload_document(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""문서 업로드""" """문서 업로드"""
# 파일 확장자 확인 # 파일 확장자 확인 (HTML 또는 PDF 허용)
if not html_file.filename.lower().endswith(('.html', '.htm')): file_extension = html_file.filename.lower()
is_pdf_file = file_extension.endswith('.pdf')
is_html_file = file_extension.endswith(('.html', '.htm'))
if not (is_html_file or is_pdf_file):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Only HTML files are allowed for the main document" detail="Only HTML and PDF files are allowed"
) )
if pdf_file and not pdf_file.filename.lower().endswith('.pdf'): if pdf_file and not pdf_file.filename.lower().endswith('.pdf'):
@@ -272,24 +281,45 @@ async def upload_document(
# 고유 파일명 생성 # 고유 파일명 생성
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
html_filename = f"{doc_id}.html"
pdf_filename = f"{doc_id}.pdf" if pdf_file else None
# 파일 저장 경로 # 메인 파일 처리 (HTML 또는 PDF) - 폴더 분리
html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename) if is_pdf_file:
pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None main_filename = f"{doc_id}.pdf"
pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs")
os.makedirs(pdf_dir, exist_ok=True) # PDF 폴더 생성
main_path = os.path.join(pdf_dir, main_filename)
html_path = None # PDF만 업로드하는 경우 html_path는 None
pdf_path = main_path # PDF 파일인 경우 pdf_path에 저장
else:
main_filename = f"{doc_id}.html"
html_dir = os.path.join(settings.UPLOAD_DIR, "documents")
os.makedirs(html_dir, exist_ok=True) # HTML 폴더 생성
main_path = os.path.join(html_dir, main_filename)
html_path = main_path
pdf_path = None
# 추가 PDF 파일 처리 (HTML 파일과 함께 업로드된 경우)
additional_pdf_path = None
if pdf_file:
additional_pdf_filename = f"{doc_id}_additional.pdf"
pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs")
os.makedirs(pdf_dir, exist_ok=True) # PDF 폴더 생성
additional_pdf_path = os.path.join(pdf_dir, additional_pdf_filename)
try: try:
# HTML 파일 저장 # 메인 파일 저장 (HTML 또는 PDF)
async with aiofiles.open(html_path, 'wb') as f: async with aiofiles.open(main_path, 'wb') as f:
content = await html_file.read() content = await html_file.read()
await f.write(content) await f.write(content)
# PDF 파일 저장 (있는 경우) # 추가 PDF 파일 저장 (HTML과 함께 업로드된 경우)
if pdf_file and pdf_path: if pdf_file and additional_pdf_path:
async with aiofiles.open(pdf_path, 'wb') as f: async with aiofiles.open(additional_pdf_path, 'wb') as f:
content = await pdf_file.read() additional_content = await pdf_file.read()
await f.write(content) await f.write(additional_content)
# HTML 파일인 경우 추가 PDF를 pdf_path로 설정
if is_html_file:
pdf_path = additional_pdf_path
# 서적 ID 검증 (있는 경우) # 서적 ID 검증 (있는 경우)
validated_book_id = None validated_book_id = None
@@ -370,15 +400,16 @@ async def upload_document(
updated_at=document_with_tags.updated_at, updated_at=document_with_tags.updated_at,
document_date=document_with_tags.document_date, document_date=document_with_tags.document_date,
uploader_name=current_user.full_name or current_user.email, uploader_name=current_user.full_name or current_user.email,
tags=[tag.name for tag in document_with_tags.tags] tags=[tag.name for tag in document_with_tags.tags],
matched_pdf_id=str(document_with_tags.matched_pdf_id) if document_with_tags.matched_pdf_id else None
) )
except Exception as e: except Exception as e:
# 파일 정리 # 파일 정리
if os.path.exists(html_path): if os.path.exists(main_path):
os.remove(html_path) os.remove(main_path)
if pdf_path and os.path.exists(pdf_path): if additional_pdf_path and os.path.exists(additional_pdf_path):
os.remove(pdf_path) os.remove(additional_pdf_path)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -429,7 +460,8 @@ async def get_document(
updated_at=document.updated_at, updated_at=document.updated_at,
document_date=document.document_date, document_date=document.document_date,
uploader_name=document.uploader.full_name or document.uploader.email, uploader_name=document.uploader.full_name or document.uploader.email,
tags=[tag.name for tag in document.tags] tags=[tag.name for tag in document.tags],
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None
) )
@@ -475,6 +507,175 @@ async def get_document_content(
raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}") raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}")
class UpdateDocumentRequest(BaseModel):
"""문서 업데이트 요청"""
title: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
matched_pdf_id: Optional[str] = None
is_public: Optional[bool] = None
tags: Optional[List[str]] = None
@router.put("/{document_id}", response_model=DocumentResponse)
async def update_document(
document_id: str,
update_data: UpdateDocumentRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 정보 업데이트"""
try:
doc_uuid = UUID(document_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid document ID format")
# 문서 조회
result = await db.execute(
select(Document)
.options(selectinload(Document.tags), selectinload(Document.uploader), selectinload(Document.book))
.where(Document.id == doc_uuid)
)
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인 (관리자이거나 문서 소유자)
if not current_user.is_admin and document.uploaded_by != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this document"
)
# 업데이트할 필드들 적용
update_fields = update_data.model_dump(exclude_unset=True)
for field, value in update_fields.items():
if field == "matched_pdf_id":
# PDF 매칭 처리
if value:
try:
pdf_uuid = UUID(value)
# PDF 문서가 실제로 존재하는지 확인
pdf_result = await db.execute(select(Document).where(Document.id == pdf_uuid))
pdf_doc = pdf_result.scalar_one_or_none()
if pdf_doc:
setattr(document, field, pdf_uuid)
except ValueError:
# 잘못된 UUID 형식이면 무시
pass
else:
# None으로 설정하여 매칭 해제
setattr(document, field, None)
elif field == "tags":
# 태그 처리
if value is not None:
# 기존 태그 관계 제거
document.tags.clear()
# 새 태그 추가
for tag_name in value:
tag_name = tag_name.strip()
if tag_name:
# 기존 태그 찾기 또는 생성
tag_result = await db.execute(select(Tag).where(Tag.name == tag_name))
tag = tag_result.scalar_one_or_none()
if not tag:
tag = Tag(
name=tag_name,
created_by=current_user.id
)
db.add(tag)
await db.flush()
document.tags.append(tag)
else:
# 일반 필드 업데이트
setattr(document, field, value)
# 업데이트 시간 갱신
document.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(document)
# 응답 데이터 생성
return DocumentResponse(
id=str(document.id),
title=document.title,
description=document.description,
html_path=document.html_path,
pdf_path=document.pdf_path,
thumbnail_path=document.thumbnail_path,
file_size=document.file_size,
page_count=document.page_count,
language=document.language,
is_public=document.is_public,
is_processed=document.is_processed,
created_at=document.created_at,
updated_at=document.updated_at,
document_date=document.document_date,
uploader_name=document.uploader.full_name or document.uploader.email,
tags=[tag.name for tag in document.tags],
book_id=str(document.book.id) if document.book else None,
book_title=document.book.title if document.book else None,
book_author=document.book.author if document.book else None,
sort_order=document.sort_order,
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None
)
@router.get("/{document_id}/download")
async def download_document(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 파일 다운로드"""
try:
doc_uuid = UUID(document_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid document ID format")
# 문서 조회
query = select(Document).where(Document.id == doc_uuid)
result = await db.execute(query)
document = result.scalar_one_or_none()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
# 권한 확인
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
# 다운로드할 파일 경로 결정 (PDF 우선, 없으면 HTML)
file_path = document.pdf_path if document.pdf_path else document.html_path
if not file_path or not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Document file not found")
# 파일 응답
from fastapi.responses import FileResponse
# 파일명 설정
filename = document.original_filename
if not filename:
extension = '.pdf' if document.pdf_path else '.html'
filename = f"{document.title}{extension}"
return FileResponse(
path=file_path,
filename=filename,
media_type='application/octet-stream'
)
@router.delete("/{document_id}") @router.delete("/{document_id}")
async def delete_document( async def delete_document(
document_id: str, document_id: str,
@@ -514,6 +715,15 @@ async def delete_document(
try: try:
print(f"DEBUG: Starting deletion of document {document_id}") print(f"DEBUG: Starting deletion of document {document_id}")
# 0. PDF 참조 해제 (외래키 제약조건 해결)
# 이 문서를 matched_pdf_id로 참조하는 모든 문서의 참조를 NULL로 설정
await db.execute(
update(Document)
.where(Document.matched_pdf_id == document_id)
.values(matched_pdf_id=None)
)
print(f"DEBUG: Cleared matched_pdf_id references to document {document_id}")
# 1. 먼저 해당 문서의 모든 하이라이트 ID 조회 # 1. 먼저 해당 문서의 모든 하이라이트 ID 조회
highlight_ids_result = await db.execute(select(Highlight.id).where(Highlight.document_id == document_id)) highlight_ids_result = await db.execute(select(Highlight.id).where(Highlight.document_id == document_id))
highlight_ids = [row[0] for row in highlight_ids_result.fetchall()] highlight_ids = [row[0] for row in highlight_ids_result.fetchall()]

View File

@@ -31,9 +31,10 @@ class Document(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
# 파일 정보 # 파일 정보
html_path = Column(String(1000), nullable=False) # HTML 파일 경로 html_path = Column(String(1000), nullable=True) # HTML 파일 경로 (PDF만 업로드하는 경우 null 가능)
pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택) pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택)
thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로 thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로
matched_pdf_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True) # 매칭된 PDF 문서 ID
# 메타데이터 # 메타데이터
file_size = Column(Integer, nullable=True) # 바이트 단위 file_size = Column(Integer, nullable=True) # 바이트 단위

View File

@@ -31,11 +31,16 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<!-- 뒤로가기 및 서적 정보 --> <!-- 뒤로가기 및 서적 정보 -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center space-x-4 mb-4"> <div class="flex items-center justify-between mb-4">
<button @click="goBack()" <button @click="goBack()"
class="flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors"> class="flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>목차로 돌아가기 <i class="fas fa-arrow-left mr-2"></i>목차로 돌아가기
</button> </button>
<button @click="openBookEditor()"
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<i class="fas fa-edit mr-2"></i>서적 편집
</button>
</div> </div>
<div class="bg-white rounded-lg shadow-sm border p-6"> <div class="bg-white rounded-lg shadow-sm border p-6">
@@ -125,9 +130,9 @@
</main> </main>
<!-- JavaScript 파일들 --> <!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012380"></script> <script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script> <script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script> <script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/book-documents.js?v=2025012371"></script> <script src="/static/js/book-documents.js?v=2025012401"></script>
</body> </body>
</html> </html>

194
frontend/book-editor.html Normal file
View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서적 편집 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<style>
.sortable-ghost {
opacity: 0.4;
}
.sortable-chosen {
background-color: #f3f4f6;
}
.sortable-drag {
background-color: white;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="bookEditorApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 헤더 섹션 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<button @click="goBack()"
class="flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>서적으로 돌아가기
</button>
<button @click="saveChanges()"
:disabled="saving"
class="flex items-center px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-save mr-2"></i>
<span x-text="saving ? '저장 중...' : '변경사항 저장'"></span>
</button>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center mb-4">
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center mr-6">
<i class="fas fa-book text-white text-2xl"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900" x-text="bookInfo.title"></h1>
<p class="text-gray-600 mt-1" x-show="bookInfo.author" x-text="bookInfo.author"></p>
<p class="text-sm text-gray-500 mt-2">
<span x-text="documents.length"></span>개 문서 편집
</p>
</div>
</div>
</div>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-3xl text-gray-400 mb-4"></i>
<p class="text-gray-500">데이터를 불러오는 중...</p>
</div>
<!-- 편집 섹션 -->
<div x-show="!loading" class="space-y-8">
<!-- 서적 정보 편집 -->
<div class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-info-circle mr-2 text-blue-600"></i>
서적 정보
</h2>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">서적 제목</label>
<input type="text"
x-model="bookInfo.title"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">저자</label>
<input type="text"
x-model="bookInfo.author"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="bookInfo.description"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
</div>
</div>
</div>
<!-- 문서 순서 및 PDF 매칭 편집 -->
<div class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-list-ol mr-2 text-green-600"></i>
문서 순서 및 PDF 매칭
</h2>
<div class="flex space-x-2">
<button @click="autoSortByName()"
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors text-sm">
<i class="fas fa-sort-alpha-down mr-1"></i>이름순 정렬
</button>
<button @click="reverseOrder()"
class="px-3 py-1.5 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors text-sm">
<i class="fas fa-exchange-alt mr-1"></i>순서 뒤집기
</button>
</div>
</div>
</div>
<div class="p-6">
<div id="sortable-list" class="space-y-3">
<template x-for="(doc, index) in documents" :key="doc.id">
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 cursor-move hover:bg-gray-100 transition-colors"
:data-id="doc.id">
<div class="flex items-center justify-between">
<div class="flex items-center flex-1">
<!-- 드래그 핸들 -->
<div class="mr-4 text-gray-400">
<i class="fas fa-grip-vertical"></i>
</div>
<!-- 순서 번호 -->
<div class="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-medium mr-4">
<span x-text="index + 1"></span>
</div>
<!-- 문서 정보 -->
<div class="flex-1">
<h3 class="font-medium text-gray-900" x-text="doc.title"></h3>
<p class="text-sm text-gray-500" x-text="doc.description || '설명 없음'"></p>
</div>
</div>
<!-- PDF 매칭 및 컨트롤 -->
<div class="flex items-center space-x-3">
<!-- PDF 매칭 드롭다운 -->
<div class="min-w-48">
<select x-model="doc.matched_pdf_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<option value="">PDF 매칭 없음</option>
<template x-for="pdf in availablePDFs" :key="pdf.id">
<option :value="pdf.id" x-text="pdf.title"></option>
</template>
</select>
</div>
<!-- 이동 버튼 -->
<div class="flex flex-col space-y-1">
<button @click="moveUp(index)"
:disabled="index === 0"
class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:cursor-not-allowed">
<i class="fas fa-chevron-up text-xs"></i>
</button>
<button @click="moveDown(index)"
:disabled="index === documents.length - 1"
class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:cursor-not-allowed">
<i class="fas fa-chevron-down text-xs"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="documents.length === 0" class="text-center py-8">
<i class="fas fa-file-alt text-gray-400 text-3xl mb-4"></i>
<p class="text-gray-500">편집할 문서가 없습니다</p>
</div>
</div>
</div>
</div>
</main>
<!-- 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/book-editor.js?v=2025012401"></script>
</body>
</html>

View File

@@ -21,6 +21,9 @@
<a href="index.html" class="nav-dropdown-item" id="index-nav-item"> <a href="index.html" class="nav-dropdown-item" id="index-nav-item">
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리 <i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리
</a> </a>
<a href="pdf-manager.html" class="nav-dropdown-item" id="pdf-manager-nav-item">
<i class="fas fa-file-pdf mr-2 text-red-500"></i>PDF 관리
</a>
</div> </div>
</div> </div>

195
frontend/pdf-manager.html Normal file
View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF 파일 관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="pdfManagerApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold text-gray-900">PDF 파일 관리</h1>
<p class="text-gray-600 mt-2">업로드된 PDF 파일들을 관리하고 삭제할 수 있습니다</p>
</div>
<button @click="refreshPDFs()"
:disabled="loading"
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
<span>새로고침</span>
</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">전체 PDF</h3>
<p class="text-2xl font-bold text-red-600" x-text="pdfDocuments.length"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-link text-green-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">연결된 PDF</h3>
<p class="text-2xl font-bold text-green-600" x-text="linkedPDFs"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-unlink text-yellow-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">독립 PDF</h3>
<p class="text-2xl font-bold text-yellow-600" x-text="standalonePDFs"></p>
</div>
</div>
</div>
</div>
<!-- PDF 목록 -->
<div class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">PDF 파일 목록</h2>
<!-- 필터 버튼 -->
<div class="flex space-x-2">
<button @click="filterType = 'all'"
:class="filterType === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
전체
</button>
<button @click="filterType = 'linked'"
:class="filterType === 'linked' ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
연결됨
</button>
<button @click="filterType = 'standalone'"
:class="filterType === 'standalone' ? 'bg-yellow-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
독립
</button>
</div>
</div>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="p-8 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-500">PDF 파일을 불러오는 중...</p>
</div>
<!-- PDF 목록 -->
<div x-show="!loading && filteredPDFs.length > 0" class="divide-y divide-gray-200">
<template x-for="pdf in filteredPDFs" :key="pdf.id">
<div class="p-6 hover:bg-gray-50 transition-colors">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-4 flex-1">
<!-- PDF 아이콘 -->
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
</div>
<!-- PDF 정보 -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 mb-1" x-text="pdf.title"></h3>
<p class="text-sm text-gray-500 mb-2" x-text="pdf.original_filename"></p>
<p class="text-sm text-gray-600 line-clamp-2" x-text="pdf.description || '설명이 없습니다'"></p>
<!-- 연결 상태 -->
<div class="mt-3 flex items-center space-x-4">
<span class="text-sm text-gray-500">
<i class="fas fa-calendar mr-1"></i>
<span x-text="formatDate(pdf.created_at)"></span>
</span>
<div x-show="pdf.isLinked">
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
<i class="fas fa-link mr-1"></i>
연결됨
</span>
</div>
<div x-show="!pdf.isLinked">
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full">
<i class="fas fa-unlink mr-1"></i>
독립 파일
</span>
</div>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="flex items-center space-x-2 ml-4">
<button @click="downloadPDF(pdf)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="PDF 다운로드">
<i class="fas fa-download"></i>
</button>
<button x-show="currentUser && currentUser.is_admin"
@click="deletePDF(pdf)"
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
title="PDF 삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="!loading && filteredPDFs.length === 0" class="p-8 text-center">
<i class="fas fa-file-pdf text-gray-400 text-4xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3>
<p class="text-gray-500">
<span x-show="filterType === 'all'">업로드된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'linked'">연결된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'standalone'">독립 PDF 파일이 없습니다</span>
</p>
</div>
</div>
</main>
<!-- 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=2025012388"></script>
</body>
</html>

View File

@@ -8,7 +8,7 @@ class DocumentServerAPI {
this.token = localStorage.getItem('access_token'); this.token = localStorage.getItem('access_token');
console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL); console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL);
console.log('🔧 도커 환경 설정 완료 - 버전 2025012380'); console.log('🔧 도커 환경 설정 완료 - 버전 2025012384');
} }
// 토큰 설정 // 토큰 설정
@@ -383,6 +383,10 @@ class DocumentServerAPI {
return await this.get(`/books/${bookId}`); return await this.get(`/books/${bookId}`);
} }
async updateBook(bookId, bookData) {
return await this.put(`/books/${bookId}`, bookData);
}
async searchBooks(query, limit = 10) { async searchBooks(query, limit = 10) {
const params = new URLSearchParams({ q: query, limit }); const params = new URLSearchParams({ q: query, limit });
return await this.get(`/books/search/?${params}`); return await this.get(`/books/search/?${params}`);

View File

@@ -2,6 +2,7 @@
window.bookDocumentsApp = () => ({ window.bookDocumentsApp = () => ({
// 상태 관리 // 상태 관리
documents: [], documents: [],
availablePDFs: [],
bookInfo: {}, bookInfo: {},
loading: false, loading: false,
error: '', error: '',
@@ -73,15 +74,38 @@ window.bookDocumentsApp = () => ({
const allDocuments = await window.api.getDocuments(); const allDocuments = await window.api.getDocuments();
if (this.bookId === 'none') { if (this.bookId === 'none') {
// 서적 미분류 문서들 // 서적 미분류 HTML 문서들만 (폴더로 구분)
this.documents = allDocuments.filter(doc => !doc.book_id); this.documents = allDocuments.filter(doc =>
!doc.book_id &&
doc.html_path &&
!doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외
);
// 서적 미분류 PDF 문서들 (매칭용)
this.availablePDFs = allDocuments.filter(doc =>
!doc.book_id &&
doc.pdf_path &&
doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨
);
this.bookInfo = { this.bookInfo = {
title: '서적 미분류', title: '서적 미분류',
description: '서적에 속하지 않은 문서들입니다.' description: '서적에 속하지 않은 문서들입니다.'
}; };
} else { } else {
// 특정 서적의 문서들 // 특정 서적의 HTML 문서들만 (폴더로 구분)
this.documents = allDocuments.filter(doc => doc.book_id === this.bookId); this.documents = allDocuments.filter(doc =>
doc.book_id === this.bookId &&
doc.html_path &&
!doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외
);
// 특정 서적의 PDF 문서들 (매칭용)
this.availablePDFs = allDocuments.filter(doc =>
doc.book_id === this.bookId &&
doc.pdf_path &&
doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨
);
if (this.documents.length > 0) { if (this.documents.length > 0) {
// 첫 번째 문서에서 서적 정보 추출 // 첫 번째 문서에서 서적 정보 추출
@@ -107,6 +131,18 @@ window.bookDocumentsApp = () => ({
} }
console.log('📚 서적 문서 로드 완료:', this.documents.length, '개'); console.log('📚 서적 문서 로드 완료:', this.documents.length, '개');
console.log('📕 사용 가능한 PDF:', this.availablePDFs.length, '개');
// 디버깅: 문서들의 original_filename 확인
console.log('🔍 문서들 확인:');
this.documents.slice(0, 5).forEach(doc => {
console.log(`- ${doc.title}: ${doc.original_filename}`);
});
console.log('🔍 PDF들 확인:');
this.availablePDFs.slice(0, 5).forEach(doc => {
console.log(`- ${doc.title}: ${doc.original_filename}`);
});
} catch (error) { } catch (error) {
console.error('서적 문서 로드 실패:', error); console.error('서적 문서 로드 실패:', error);
this.error = '문서를 불러오는데 실패했습니다: ' + error.message; this.error = '문서를 불러오는데 실패했습니다: ' + error.message;
@@ -125,6 +161,15 @@ window.bookDocumentsApp = () => ({
window.open(`/viewer.html?id=${documentId}&from=book`, '_blank'); window.open(`/viewer.html?id=${documentId}&from=book`, '_blank');
}, },
// 서적 편집 페이지 열기
openBookEditor() {
if (this.bookId === 'none') {
alert('서적 미분류 문서들은 편집할 수 없습니다.');
return;
}
window.location.href = `book-editor.html?bookId=${this.bookId}`;
},
// 문서 수정 // 문서 수정
editDocument(doc) { editDocument(doc) {
// TODO: 문서 수정 모달 또는 페이지로 이동 // TODO: 문서 수정 모달 또는 페이지로 이동

View File

@@ -0,0 +1,246 @@
// 서적 편집 애플리케이션 컴포넌트
window.bookEditorApp = () => ({
// 상태 관리
documents: [],
bookInfo: {},
availablePDFs: [],
loading: false,
saving: false,
error: '',
// 인증 상태
isAuthenticated: false,
currentUser: null,
// URL 파라미터
bookId: null,
// SortableJS 인스턴스
sortableInstance: null,
// 초기화
async init() {
console.log('🚀 Book Editor App 초기화 시작');
// URL 파라미터 파싱
this.parseUrlParams();
// 인증 상태 확인
await this.checkAuthStatus();
if (this.isAuthenticated) {
await this.loadBookData();
this.initSortable();
}
// 헤더 로드
await this.loadHeader();
},
// URL 파라미터 파싱
parseUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
this.bookId = urlParams.get('bookId');
console.log('📖 편집할 서적 ID:', this.bookId);
},
// 인증 상태 확인
async checkAuthStatus() {
try {
const user = await window.api.getCurrentUser();
this.isAuthenticated = true;
this.currentUser = user;
console.log('✅ 인증됨:', user.username);
} catch (error) {
console.log('❌ 인증되지 않음');
this.isAuthenticated = false;
this.currentUser = null;
window.location.href = '/login.html';
}
},
// 헤더 로드
async loadHeader() {
try {
await window.headerLoader.loadHeader();
} catch (error) {
console.error('헤더 로드 실패:', error);
}
},
// 서적 데이터 로드
async loadBookData() {
this.loading = true;
this.error = '';
try {
// 서적 정보 로드
this.bookInfo = await window.api.getBook(this.bookId);
console.log('📚 서적 정보 로드:', this.bookInfo);
// 모든 문서 가져와서 이 서적에 속한 HTML 문서들만 필터링 (폴더로 구분)
const allDocuments = await window.api.getDocuments();
this.documents = allDocuments
.filter(doc =>
doc.book_id === this.bookId &&
doc.html_path &&
!doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외
)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); // 순서대로 정렬
console.log('📄 서적 문서들:', this.documents.length, '개');
// 사용 가능한 PDF 문서들 로드 (PDF 타입 문서들)
// PDF 문서들만 필터링 (폴더 경로 기준)
this.availablePDFs = allDocuments.filter(doc =>
doc.pdf_path &&
doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨
);
console.log('📎 사용 가능한 PDF:', this.availablePDFs.length, '개');
} catch (error) {
console.error('서적 데이터 로드 실패:', error);
this.error = '데이터를 불러오는데 실패했습니다: ' + error.message;
} finally {
this.loading = false;
}
},
// SortableJS 초기화
initSortable() {
this.$nextTick(() => {
const sortableList = document.getElementById('sortable-list');
if (sortableList && !this.sortableInstance) {
this.sortableInstance = Sortable.create(sortableList, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
handle: '.fa-grip-vertical',
onEnd: (evt) => {
// 배열 순서 업데이트
const item = this.documents.splice(evt.oldIndex, 1)[0];
this.documents.splice(evt.newIndex, 0, item);
this.updateDisplayOrder();
}
});
console.log('✅ SortableJS 초기화 완료');
}
});
},
// 표시 순서 업데이트
updateDisplayOrder() {
this.documents.forEach((doc, index) => {
doc.sort_order = index + 1;
});
console.log('🔢 표시 순서 업데이트됨');
},
// 위로 이동
moveUp(index) {
if (index > 0) {
const item = this.documents.splice(index, 1)[0];
this.documents.splice(index - 1, 0, item);
this.updateDisplayOrder();
}
},
// 아래로 이동
moveDown(index) {
if (index < this.documents.length - 1) {
const item = this.documents.splice(index, 1)[0];
this.documents.splice(index + 1, 0, item);
this.updateDisplayOrder();
}
},
// 이름순 정렬
autoSortByName() {
this.documents.sort((a, b) => {
return a.title.localeCompare(b.title, 'ko', { numeric: true });
});
this.updateDisplayOrder();
console.log('📝 이름순 정렬 완료');
},
// 순서 뒤집기
reverseOrder() {
this.documents.reverse();
this.updateDisplayOrder();
console.log('🔄 순서 뒤집기 완료');
},
// 변경사항 저장
async saveChanges() {
if (this.saving) return;
this.saving = true;
try {
// 서적 정보 업데이트
await window.api.updateBook(this.bookId, {
title: this.bookInfo.title,
author: this.bookInfo.author,
description: this.bookInfo.description
});
// 각 문서의 순서와 PDF 매칭 정보 업데이트
const updatePromises = this.documents.map(doc => {
return window.api.updateDocument(doc.id, {
sort_order: doc.sort_order,
matched_pdf_id: doc.matched_pdf_id || null
});
});
await Promise.all(updatePromises);
console.log('✅ 모든 변경사항 저장 완료');
this.showNotification('변경사항이 저장되었습니다', 'success');
// 잠시 후 서적 페이지로 돌아가기
setTimeout(() => {
this.goBack();
}, 1500);
} catch (error) {
console.error('저장 실패:', error);
this.showNotification('저장에 실패했습니다: ' + error.message, 'error');
} finally {
this.saving = false;
}
},
// 뒤로가기
goBack() {
window.location.href = `book-documents.html?bookId=${this.bookId}`;
},
// 알림 표시
showNotification(message, type = 'info') {
console.log(`${type.toUpperCase()}: ${message}`);
// 간단한 토스트 알림 생성
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-600' :
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
}`;
toast.textContent = message;
document.body.appendChild(toast);
// 3초 후 제거
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 3000);
}
});
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
console.log('📄 Book Editor 페이지 로드됨');
});

View File

@@ -0,0 +1,229 @@
// PDF 관리 애플리케이션 컴포넌트
window.pdfManagerApp = () => ({
// 상태 관리
pdfDocuments: [],
allDocuments: [],
loading: false,
error: '',
filterType: 'all', // 'all', 'linked', 'standalone'
// 인증 상태
isAuthenticated: false,
currentUser: null,
// 초기화
async init() {
console.log('🚀 PDF Manager App 초기화 시작');
// 인증 상태 확인
await this.checkAuthStatus();
if (this.isAuthenticated) {
await this.loadPDFs();
}
// 헤더 로드
await this.loadHeader();
},
// 인증 상태 확인
async checkAuthStatus() {
try {
const user = await window.api.getCurrentUser();
this.isAuthenticated = true;
this.currentUser = user;
console.log('✅ 인증됨:', user.username);
} catch (error) {
console.log('❌ 인증되지 않음');
this.isAuthenticated = false;
this.currentUser = null;
window.location.href = '/login.html';
}
},
// 헤더 로드
async loadHeader() {
try {
await window.headerLoader.loadHeader();
} catch (error) {
console.error('헤더 로드 실패:', error);
}
},
// PDF 파일들 로드
async loadPDFs() {
this.loading = true;
this.error = '';
try {
// 모든 문서 가져오기
this.allDocuments = await window.api.getDocuments();
// PDF 파일들만 필터링
this.pdfDocuments = this.allDocuments.filter(doc =>
(doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) ||
(doc.pdf_path && doc.pdf_path !== '') ||
(doc.html_path === null && doc.pdf_path) // PDF만 업로드된 경우
);
// 연결 상태 확인
this.pdfDocuments.forEach(pdf => {
// 이 PDF를 참조하는 다른 문서가 있는지 확인
const linkedDocuments = this.allDocuments.filter(doc =>
doc.matched_pdf_id === pdf.id
);
pdf.isLinked = linkedDocuments.length > 0;
pdf.linkedDocuments = linkedDocuments;
});
console.log('📕 PDF 문서들:', this.pdfDocuments.length, '개');
} catch (error) {
console.error('PDF 로드 실패:', error);
this.error = 'PDF 파일을 불러오는데 실패했습니다: ' + error.message;
this.pdfDocuments = [];
} finally {
this.loading = false;
}
},
// 필터링된 PDF 목록
get filteredPDFs() {
switch (this.filterType) {
case 'linked':
return this.pdfDocuments.filter(pdf => pdf.isLinked);
case 'standalone':
return this.pdfDocuments.filter(pdf => !pdf.isLinked);
default:
return this.pdfDocuments;
}
},
// 통계 계산
get linkedPDFs() {
return this.pdfDocuments.filter(pdf => pdf.isLinked).length;
},
get standalonePDFs() {
return this.pdfDocuments.filter(pdf => !pdf.isLinked).length;
},
// PDF 새로고침
async refreshPDFs() {
await this.loadPDFs();
},
// PDF 다운로드
async downloadPDF(pdf) {
try {
console.log('📕 PDF 다운로드 시작:', pdf.id);
// PDF 파일 다운로드 URL 생성
const downloadUrl = `/api/documents/${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 = pdf.original_filename || `${pdf.title}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// URL 정리
window.URL.revokeObjectURL(url);
console.log('✅ PDF 다운로드 완료');
} catch (error) {
console.error('❌ PDF 다운로드 실패:', error);
alert('PDF 다운로드에 실패했습니다: ' + error.message);
}
},
// PDF 삭제
async deletePDF(pdf) {
// 연결된 문서가 있는지 확인
if (pdf.isLinked && pdf.linkedDocuments.length > 0) {
const linkedTitles = pdf.linkedDocuments.map(doc => doc.title).join('\n- ');
const confirmMessage = `이 PDF는 다음 문서들과 연결되어 있습니다:\n\n- ${linkedTitles}\n\n정말 삭제하시겠습니까? 연결된 문서들의 PDF 링크가 해제됩니다.`;
if (!confirm(confirmMessage)) {
return;
}
} else {
if (!confirm(`"${pdf.title}" PDF 파일을 삭제하시겠습니까?`)) {
return;
}
}
try {
await window.api.deleteDocument(pdf.id);
// 목록에서 제거
this.pdfDocuments = this.pdfDocuments.filter(p => p.id !== pdf.id);
this.showNotification('PDF 파일이 삭제되었습니다', 'success');
// 목록 새로고침 (연결 상태 업데이트를 위해)
await this.loadPDFs();
} catch (error) {
console.error('PDF 삭제 실패:', error);
this.showNotification('PDF 삭제에 실패했습니다: ' + error.message, 'error');
}
},
// 날짜 포맷팅
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
},
// 알림 표시
showNotification(message, type = 'info') {
console.log(`${type.toUpperCase()}: ${message}`);
// 간단한 토스트 알림 생성
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-600' :
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
}`;
toast.textContent = message;
document.body.appendChild(toast);
// 3초 후 제거
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 3000);
}
});
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
console.log('📄 PDF Manager 페이지 로드됨');
});

View File

@@ -261,8 +261,11 @@ window.uploadApp = () => ({
console.log('📄 HTML 파일:', htmlFiles.length, '개'); console.log('📄 HTML 파일:', htmlFiles.length, '개');
console.log('📕 PDF 파일:', pdfFiles.length, '개'); console.log('📕 PDF 파일:', pdfFiles.length, '개');
// HTML 파일 업로드 (백엔드 API에 맞게) // 업로드할 파일들 처리
const uploadPromises = htmlFiles.map(async (file, index) => { const uploadPromises = [];
// HTML 파일 업로드 (PDF 파일이 있으면 함께 업로드)
htmlFiles.forEach(async (file, index) => {
const formData = new FormData(); const formData = new FormData();
formData.append('html_file', file); // 백엔드가 요구하는 필드명 formData.append('html_file', file); // 백엔드가 요구하는 필드명
formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거 formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
@@ -270,52 +273,96 @@ window.uploadApp = () => ({
formData.append('language', 'ko'); formData.append('language', 'ko');
formData.append('is_public', 'false'); formData.append('is_public', 'false');
// 같은 이름의 PDF 파일이 있는지 확인
const htmlBaseName = file.name.replace(/\.[^/.]+$/, "");
const matchingPdf = pdfFiles.find(pdfFile => {
const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, "");
return pdfBaseName === htmlBaseName;
});
if (matchingPdf) {
formData.append('pdf_file', matchingPdf);
console.log('📎 매칭된 PDF 파일 함께 업로드:', matchingPdf.name);
}
if (bookId) { if (bookId) {
formData.append('book_id', bookId); formData.append('book_id', bookId);
} }
try { const uploadPromise = (async () => {
const response = await window.api.uploadDocument(formData); try {
console.log('✅ HTML 파일 업로드 완료:', file.name); const response = await window.api.uploadDocument(formData);
return response; console.log('✅ HTML 파일 업로드 완료:', file.name);
} catch (error) { return response;
console.error('❌ HTML 파일 업로드 실패:', file.name, error); } catch (error) {
throw error; console.error('❌ HTML 파일 업로드 실패:', file.name, error);
throw error;
}
})();
uploadPromises.push(uploadPromise);
});
// HTML과 매칭되지 않은 PDF 파일들을 별도로 업로드
const unmatchedPdfs = pdfFiles.filter(pdfFile => {
const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, "");
return !htmlFiles.some(htmlFile => {
const htmlBaseName = htmlFile.name.replace(/\.[^/.]+$/, "");
return htmlBaseName === pdfBaseName;
});
});
unmatchedPdfs.forEach(async (file, index) => {
const formData = new FormData();
formData.append('html_file', file); // PDF도 html_file로 전송 (백엔드에서 처리)
formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
formData.append('description', `PDF 파일: ${file.name}`);
formData.append('language', 'ko');
formData.append('is_public', 'false');
if (bookId) {
formData.append('book_id', bookId);
} }
const uploadPromise = (async () => {
try {
const response = await window.api.uploadDocument(formData);
console.log('✅ PDF 파일 업로드 완료:', file.name);
return response;
} catch (error) {
console.error('❌ PDF 파일 업로드 실패:', file.name, error);
throw error;
}
})();
uploadPromises.push(uploadPromise);
}); });
// PDF 파일은 별도로 처리 (나중에 HTML과 매칭) // 모든 업로드 완료 대기
const pdfUploadPromises = pdfFiles.map(async (file, index) => { const uploadedDocs = await Promise.all(uploadPromises);
// PDF 전용 업로드 로직 (임시로 HTML 파일로 처리하지 않음)
console.log('📕 PDF 파일 대기 중:', file.name);
return {
id: `pdf-${Date.now()}-${index}`,
title: file.name.replace(/\.[^/.]+$/, ""),
original_filename: file.name,
file_type: 'pdf',
file: file // 실제 파일 객체 보관
};
});
// HTML 파일 업로드 완료 대기 // HTML 문서와 PDF 문서 분리
const uploadedHtmlDocs = await Promise.all(uploadPromises); const htmlDocuments = uploadedDocs.filter(doc =>
doc.html_path && doc.html_path !== null
);
// PDF 파일 처리 (실제 업로드는 하지 않고 매칭용으로만 보관) const pdfDocuments = uploadedDocs.filter(doc =>
const pdfDocs = await Promise.all(pdfUploadPromises); (doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) ||
(doc.pdf_path && !doc.html_path)
);
// 업로드된 HTML 문서 정리 // 업로드된 HTML 문서들만 정리 (순서 조정용)
this.uploadedDocuments = uploadedHtmlDocs.map((doc, index) => ({ this.uploadedDocuments = htmlDocuments.map((doc, index) => ({
...doc, ...doc,
display_order: index + 1, display_order: index + 1,
matched_pdf_id: null, matched_pdf_id: null
file_type: 'html'
})); }));
// PDF 파일 목록 (매칭용) // PDF 파일들을 매칭용으로 저장
this.pdfFiles = pdfDocs; this.pdfFiles = pdfDocuments;
console.log('🎉 모든 파일 업로드 완료!'); console.log('🎉 모든 파일 업로드 완료!');
console.log('📄 HTML 문서:', this.uploadedDocuments.filter(doc => doc.file_type === 'html').length, '개'); console.log('📄 HTML 문서:', this.uploadedDocuments.length, '개');
console.log('📕 PDF 문서:', this.pdfFiles.length, '개'); console.log('📕 PDF 문서:', this.pdfFiles.length, '개');
// 3단계로 이동 // 3단계로 이동

View File

@@ -1095,7 +1095,7 @@ window.documentViewer = () => ({
const response = await fetch(downloadUrl, { const response = await fetch(downloadUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('access_token')}`
} }
}); });
@@ -1124,5 +1124,66 @@ window.documentViewer = () => ({
console.error('❌ PDF 다운로드 실패:', error); console.error('❌ PDF 다운로드 실패:', error);
alert('PDF 다운로드에 실패했습니다: ' + error.message); alert('PDF 다운로드에 실패했습니다: ' + error.message);
} }
},
// 원본 파일 다운로드 (연결된 PDF 파일)
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 window.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 다운로드 완료');
} catch (error) {
console.error('❌ 연결된 PDF 다운로드 실패:', error);
alert('연결된 PDF 다운로드에 실패했습니다: ' + error.message);
}
} }
}); });

View File

@@ -335,6 +335,6 @@
<script src="/static/js/api.js?v=2025012380"></script> <script src="/static/js/api.js?v=2025012380"></script>
<script src="/static/js/auth.js?v=2025012351"></script> <script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script> <script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/upload.js?v=2025012384"></script> <script src="/static/js/upload.js?v=2025012390"></script>
</body> </body>
</html> </html>

View File

@@ -102,12 +102,12 @@
<!-- 오른쪽: 한영 전환 --> <!-- 오른쪽: 한영 전환 -->
<div class="flex items-center"> <div class="flex items-center">
<!-- PDF 다운로드 버튼 (매칭된 PDF가 있는 경우) --> <!-- PDF 다운로드 버튼 (매칭된 PDF가 있는 경우) -->
<button x-show="document.matched_pdf_id" <!-- 원본 PDF 다운로드 (항상 표시) -->
@click="downloadMatchedPDF()" <button @click="downloadOriginalFile()"
class="bg-red-600 text-white px-4 py-2 rounded-xl hover:bg-red-700 transition-all duration-200 flex items-center space-x-2 shadow-sm" class="bg-red-600 text-white px-4 py-2 rounded-xl hover:bg-red-700 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="font-medium text-sm">PDF 원본</span> <span class="font-medium text-sm">원본 다운로드</span>
</button> </button>
<button @click="toggleLanguage()" <button @click="toggleLanguage()"
@@ -391,7 +391,7 @@
<!-- 스크립트 --> <!-- 스크립트 -->
<script src="/static/js/api.js?v=2025012380"></script> <script src="/static/js/api.js?v=2025012380"></script>
<script src="/static/js/viewer.js?v=2025012225"></script> <script src="/static/js/viewer.js?v=2025012387"></script>
<style> <style>
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.