feat: 계층구조 뷰 및 완전한 하이라이트/메모 시스템 구현

주요 기능:
- 📚 Book 및 BookCategory 모델 추가 (서적 그룹화)
- 🏗️ 계층구조 뷰 (Book > Category > Document) 구현
- 🎨 완전한 하이라이트 시스템 (생성, 표시, 삭제)
- 📝 통합 메모 관리 (추가, 수정, 삭제)
- 🔄 그리드 뷰와 계층구조 뷰 간 완전 동기화
- 🛡️ 관리자 전용 문서 삭제 기능
- 🔧 모든 CORS 및 500 오류 해결

기술적 개선:
- API 베이스 URL을 Nginx 프록시로 변경 (/api)
- 외래키 제약 조건 해결 (삭제 순서 최적화)
- SQLAlchemy 관계 로딩 최적화 (selectinload)
- 프론트엔드 캐시 무효화 시스템
- Alpine.js 컴포넌트 구조 개선

UI/UX:
- 계층구조 네비게이션 (사이드바 + 트리 구조)
- 하이라이트 모드 토글 스위치
- 완전한 툴팁 기반 메모 관리 인터페이스
- 반응형 하이라이트 메뉴 (색상 선택)
- 스마트 툴팁 위치 조정 (화면 경계 고려)
This commit is contained in:
Hyungi Ahn
2025-08-23 14:31:30 +09:00
parent 1e2e66d8fe
commit 46546da55f
18 changed files with 3206 additions and 384 deletions

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
from typing import List, Optional
import os
import uuid
from uuid import UUID
import aiofiles
from pathlib import Path
@@ -15,6 +16,7 @@ from ...core.database import get_db
from ...core.config import settings
from ...models.user import User
from ...models.document import Document, Tag
from ...models.book import Book
from ..dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
from datetime import datetime
@@ -38,6 +40,16 @@ class DocumentResponse(BaseModel):
document_date: Optional[datetime]
uploader_name: Optional[str]
tags: List[str] = []
# 서적 정보
book_id: Optional[str] = None
book_title: Optional[str] = None
book_author: Optional[str] = None
# 소분류 정보
category_id: Optional[str] = None
category_name: Optional[str] = None
sort_order: int = 0
class Config:
from_attributes = True
@@ -77,7 +89,9 @@ async def list_documents(
"""문서 목록 조회"""
query = select(Document).options(
selectinload(Document.uploader),
selectinload(Document.tags)
selectinload(Document.tags),
selectinload(Document.book), # 서적 정보 추가
selectinload(Document.category) # 소분류 정보 추가
)
# 권한 필터링 (관리자가 아니면 공개 문서 + 자신이 업로드한 문서만)
@@ -126,13 +140,108 @@ async def list_documents(
updated_at=doc.updated_at,
document_date=doc.document_date,
uploader_name=doc.uploader.full_name or doc.uploader.email,
tags=[tag.name for tag in doc.tags]
tags=[tag.name for tag in doc.tags],
# 서적 정보 추가
book_id=str(doc.book.id) if doc.book else None,
book_title=doc.book.title if doc.book else None,
book_author=doc.book.author if doc.book else None,
# 소분류 정보 추가
category_id=str(doc.category.id) if doc.category else None,
category_name=doc.category.name if doc.category else None,
sort_order=doc.sort_order
)
response_data.append(doc_data)
return response_data
@router.get("/hierarchy/structured", response_model=dict)
async def get_documents_by_hierarchy(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""계층구조별 문서 조회 (서적 > 소분류 > 문서)"""
# 모든 문서 조회 (기존 로직 재사용)
query = select(Document).options(
selectinload(Document.uploader),
selectinload(Document.tags),
selectinload(Document.book),
selectinload(Document.category)
)
# 권한 필터링
if not current_user.is_admin:
query = query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
# 정렬: 서적별 > 소분류별 > 문서 순서별
query = query.order_by(
Document.book_id.nulls_last(), # 서적 있는 것 먼저
Document.category_id.nulls_last(), # 소분류 있는 것 먼저
Document.sort_order,
Document.created_at.desc()
)
result = await db.execute(query)
documents = result.scalars().all()
# 계층구조로 그룹화
hierarchy = {
"books": {}, # 서적별 그룹
"uncategorized": [] # 미분류 문서들
}
for doc in documents:
doc_data = {
"id": str(doc.id),
"title": doc.title,
"description": doc.description,
"created_at": doc.created_at.isoformat(),
"uploader_name": doc.uploader.full_name or doc.uploader.email,
"tags": [tag.name for tag in doc.tags],
"sort_order": doc.sort_order,
"book_id": str(doc.book.id) if doc.book else None,
"book_title": doc.book.title if doc.book else None,
"category_id": str(doc.category.id) if doc.category else None,
"category_name": doc.category.name if doc.category else None
}
if doc.book:
# 서적이 있는 문서
book_id = str(doc.book.id)
if book_id not in hierarchy["books"]:
hierarchy["books"][book_id] = {
"id": book_id,
"title": doc.book.title,
"author": doc.book.author,
"categories": {},
"uncategorized_documents": []
}
if doc.category:
# 소분류가 있는 문서
category_id = str(doc.category.id)
if category_id not in hierarchy["books"][book_id]["categories"]:
hierarchy["books"][book_id]["categories"][category_id] = {
"id": category_id,
"name": doc.category.name,
"documents": []
}
hierarchy["books"][book_id]["categories"][category_id]["documents"].append(doc_data)
else:
# 서적은 있지만 소분류가 없는 문서
hierarchy["books"][book_id]["uncategorized_documents"].append(doc_data)
else:
# 서적이 없는 미분류 문서
hierarchy["uncategorized"].append(doc_data)
return hierarchy
@router.post("/", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
@@ -141,6 +250,7 @@ async def upload_document(
language: Optional[str] = Form("ko"),
is_public: bool = Form(False),
tags: Optional[List[str]] = Form(None), # 태그 리스트
book_id: Optional[str] = Form(None), # 서적 ID 추가
html_file: UploadFile = File(...),
pdf_file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_active_user),
@@ -181,6 +291,21 @@ async def upload_document(
content = await pdf_file.read()
await f.write(content)
# 서적 ID 검증 (있는 경우)
validated_book_id = None
if book_id:
try:
# UUID 형식 검증 및 서적 존재 확인
from uuid import UUID
book_uuid = UUID(book_id)
book_result = await db.execute(select(Book).where(Book.id == book_uuid))
book = book_result.scalar_one_or_none()
if book:
validated_book_id = book_uuid
except (ValueError, Exception):
# 잘못된 UUID 형식이거나 서적이 없으면 무시
pass
# 문서 메타데이터 생성
document = Document(
id=doc_id,
@@ -193,7 +318,8 @@ async def upload_document(
uploaded_by=current_user.id,
original_filename=html_file.filename,
is_public=is_public,
document_date=datetime.fromisoformat(document_date) if document_date else None
document_date=datetime.fromisoformat(document_date) if document_date else None,
book_id=validated_book_id # 서적 ID 추가
)
db.add(document)
@@ -307,6 +433,48 @@ async def get_document(
)
@router.get("/{document_id}/content")
async def get_document_content(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 HTML 콘텐츠 조회"""
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")
# HTML 파일 읽기
import os
from fastapi.responses import HTMLResponse
# html_path는 이미 전체 경로를 포함하고 있음
file_path = document.html_path
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Document file not found")
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
return HTMLResponse(content=content)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}")
@router.delete("/{document_id}")
async def delete_document(
document_id: str,
@@ -323,11 +491,11 @@ async def delete_document(
detail="Document not found"
)
# 권한 확인 (업로더 또는 관리자만)
if document.uploaded_by != current_user.id and not current_user.is_admin:
# 권한 확인 (관리자만)
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
detail="Only administrators can delete documents"
)
# 파일 삭제
@@ -338,7 +506,23 @@ async def delete_document(
if document.thumbnail_path and os.path.exists(document.thumbnail_path):
os.remove(document.thumbnail_path)
# 데이터베이스에서 삭제
# 관련 데이터 먼저 삭제 (외래키 제약 조건 해결)
from ...models.highlight import Highlight
from ...models.note import Note
from ...models.bookmark import Bookmark
# 메모 먼저 삭제 (하이라이트를 참조하므로)
await db.execute(delete(Note).where(Note.document_id == document_id))
# 북마크 삭제
await db.execute(delete(Bookmark).where(Bookmark.document_id == document_id))
# 마지막으로 하이라이트 삭제
await db.execute(delete(Highlight).where(Highlight.document_id == document_id))
# 문서-태그 관계 삭제 (Document.tags 관계를 통해 자동 처리됨)
# 마지막으로 문서 삭제
await db.execute(delete(Document).where(Document.id == document_id))
await db.commit()