feat: PDF/HTML 폴더 분리 및 필터링 개선
- 업로드 시 HTML과 PDF를 별도 폴더에 저장 (/documents/, /pdfs/) - 프론트엔드 필터링을 폴더 경로 기준으로 단순화 - PDF 삭제 시 외래키 참조 해제 로직 추가 - book-documents.js, book-editor.js 필터링 통일 - HTML 문서 목록에서 PDF 완전 분리
This commit is contained in:
12
backend/migrations/005_add_matched_pdf_id.sql
Normal file
12
backend/migrations/005_add_matched_pdf_id.sql
Normal 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)';
|
||||||
9
backend/migrations/006_make_html_path_nullable.sql
Normal file
9
backend/migrations/006_make_html_path_nullable.sql
Normal 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 가능)';
|
||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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) # 바이트 단위
|
||||||
|
|||||||
@@ -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
194
frontend/book-editor.html
Normal 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>
|
||||||
@@ -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
195
frontend/pdf-manager.html
Normal 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>
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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: 문서 수정 모달 또는 페이지로 이동
|
||||||
|
|||||||
246
frontend/static/js/book-editor.js
Normal file
246
frontend/static/js/book-editor.js
Normal 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 페이지 로드됨');
|
||||||
|
});
|
||||||
229
frontend/static/js/pdf-manager.js
Normal file
229
frontend/static/js/pdf-manager.js
Normal 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 페이지 로드됨');
|
||||||
|
});
|
||||||
@@ -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단계로 이동
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
BIN
uploads/pdfs/319c6faa-7881-4c16-a18b-ea46f521067e.pdf
Normal file
BIN
uploads/pdfs/319c6faa-7881-4c16-a18b-ea46f521067e.pdf
Normal file
Binary file not shown.
BIN
uploads/pdfs/6b4260b0-c920-4929-87ba-4505b05052d9.pdf
Normal file
BIN
uploads/pdfs/6b4260b0-c920-4929-87ba-4505b05052d9.pdf
Normal file
Binary file not shown.
BIN
uploads/pdfs/d7b34eab-071f-48ac-ba16-951798115726.pdf
Normal file
BIN
uploads/pdfs/d7b34eab-071f-48ac-ba16-951798115726.pdf
Normal file
Binary file not shown.
BIN
uploads/pdfs/e7bf9dd9-0740-45ab-893b-0cf58b671384.pdf
Normal file
BIN
uploads/pdfs/e7bf9dd9-0740-45ab-893b-0cf58b671384.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user