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

@@ -0,0 +1,50 @@
-- 서적 테이블 및 관계 추가
-- 2025-08-22: 서적 그룹화 기능 추가
-- 서적 테이블 생성
CREATE TABLE IF NOT EXISTS books (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
author VARCHAR(200),
publisher VARCHAR(200),
isbn VARCHAR(20) UNIQUE,
description TEXT,
language VARCHAR(10) DEFAULT 'ko',
total_pages INTEGER DEFAULT 0,
cover_image_path VARCHAR(500),
is_public BOOLEAN DEFAULT true,
tags VARCHAR(1000),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_books_title ON books(title);
CREATE INDEX IF NOT EXISTS idx_books_author ON books(author);
CREATE INDEX IF NOT EXISTS idx_books_created_at ON books(created_at);
-- documents 테이블에 book_id 컬럼 추가
ALTER TABLE documents ADD COLUMN IF NOT EXISTS book_id UUID;
-- 외래키 제약조건 추가
ALTER TABLE documents ADD CONSTRAINT IF NOT EXISTS fk_documents_book_id
FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE SET NULL;
-- book_id 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_documents_book_id ON documents(book_id);
-- 업데이트 트리거 함수 생성 (updated_at 자동 업데이트)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- books 테이블에 업데이트 트리거 추가
DROP TRIGGER IF EXISTS update_books_updated_at ON books;
CREATE TRIGGER update_books_updated_at
BEFORE UPDATE ON books
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from typing import List
from uuid import UUID
from ...core.database import get_db
from ..dependencies import get_current_active_user
from ...models.user import User
from ...models.book import Book
from ...models.book_category import BookCategory
from ...models.document import Document
from ...schemas.book_category import (
CreateBookCategoryRequest,
UpdateBookCategoryRequest,
BookCategoryResponse,
UpdateDocumentOrderRequest
)
router = APIRouter()
@router.post("/", response_model=BookCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_book_category(
category_data: CreateBookCategoryRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새로운 서적 소분류 생성"""
# 서적 존재 확인
book_result = await db.execute(select(Book).where(Book.id == category_data.book_id))
book = book_result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
# 권한 확인 (관리자만)
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories")
new_category = BookCategory(**category_data.model_dump())
db.add(new_category)
await db.commit()
await db.refresh(new_category)
return await _get_category_response(db, new_category)
@router.get("/book/{book_id}", response_model=List[BookCategoryResponse])
async def get_book_categories(
book_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 서적의 소분류 목록 조회"""
result = await db.execute(
select(BookCategory)
.where(BookCategory.book_id == book_id)
.order_by(BookCategory.sort_order, BookCategory.name)
)
categories = result.scalars().all()
response_categories = []
for category in categories:
response_categories.append(await _get_category_response(db, category))
return response_categories
@router.put("/{category_id}", response_model=BookCategoryResponse)
async def update_book_category(
category_id: UUID,
category_data: UpdateBookCategoryRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 소분류 수정"""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories")
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
for field, value in category_data.model_dump(exclude_unset=True).items():
setattr(category, field, value)
await db.commit()
await db.refresh(category)
return await _get_category_response(db, category)
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book_category(
category_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 소분류 삭제 (포함된 문서들은 미분류로 이동)"""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories")
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
# 포함된 문서들을 미분류로 이동 (category_id를 NULL로 설정)
await db.execute(
update(Document)
.where(Document.category_id == category_id)
.values(category_id=None)
)
await db.delete(category)
await db.commit()
return {"message": "Category deleted successfully"}
@router.put("/documents/reorder", status_code=status.HTTP_200_OK)
async def update_document_order(
order_data: UpdateDocumentOrderRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 순서 변경"""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reorder documents")
# 문서 순서 업데이트
for item in order_data.document_orders:
document_id = item.get("document_id")
sort_order = item.get("sort_order", 0)
await db.execute(
update(Document)
.where(Document.id == document_id)
.values(sort_order=sort_order)
)
await db.commit()
return {"message": "Document order updated successfully"}
# Helper function
async def _get_category_response(db: AsyncSession, category: BookCategory) -> BookCategoryResponse:
"""BookCategory를 BookCategoryResponse로 변환"""
document_count_result = await db.execute(
select(func.count(Document.id)).where(Document.category_id == category.id)
)
document_count = document_count_result.scalar_one()
return BookCategoryResponse(
id=category.id,
book_id=category.book_id,
name=category.name,
description=category.description,
sort_order=category.sort_order,
created_at=category.created_at,
updated_at=category.updated_at,
document_count=document_count
)

View File

@@ -0,0 +1,230 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from uuid import UUID
import difflib # For similarity suggestions
from ...core.database import get_db
from ..dependencies import get_current_active_user
from ...models.user import User
from ...models.book import Book
from ...models.document import Document
from ...schemas.book import CreateBookRequest, UpdateBookRequest, BookResponse, BookSearchResponse, BookSuggestionResponse
router = APIRouter()
# Helper to convert Book ORM object to BookResponse
async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
document_count_result = await db.execute(
select(func.count(Document.id)).where(Document.book_id == book.id)
)
document_count = document_count_result.scalar_one()
return BookResponse(
id=book.id,
title=book.title,
author=book.author,
description=book.description,
language=book.language,
is_public=book.is_public,
created_at=book.created_at,
updated_at=book.updated_at,
document_count=document_count
)
@router.post("/", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
async def create_book(
book_data: CreateBookRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새로운 서적 생성"""
# Check if a book with the same title and author already exists for the user
existing_book_query = select(Book).where(Book.title == book_data.title)
if book_data.author:
existing_book_query = existing_book_query.where(Book.author == book_data.author)
existing_book = await db.execute(existing_book_query)
if existing_book.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A book with this title and author already exists."
)
new_book = Book(**book_data.model_dump())
db.add(new_book)
await db.commit()
await db.refresh(new_book)
return await _get_book_response(db, new_book)
@router.get("/", response_model=List[BookResponse])
async def get_books(
skip: int = 0,
limit: int = 50,
search: Optional[str] = Query(None, description="Search by book title or author"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""모든 서적 목록 조회"""
query = select(Book)
if search:
query = query.where(
or_(
Book.title.ilike(f"%{search}%"),
Book.author.ilike(f"%{search}%")
)
)
# Only show public books or books owned by the current user/admin
if not current_user.is_admin:
query = query.where(Book.is_public == True) # For simplicity, assuming all books are public for now or user can only see public ones.
# In a real app, you'd link books to users.
query = query.offset(skip).limit(limit).order_by(Book.title)
result = await db.execute(query)
books = result.scalars().all()
response_books = []
for book in books:
response_books.append(await _get_book_response(db, book))
return response_books
@router.get("/{book_id}", response_model=BookResponse)
async def get_book(
book_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 서적 상세 정보 조회"""
result = await db.execute(
select(Book).where(Book.id == book_id)
)
book = result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
# Access control (simplified)
if not book.is_public and not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to access this book")
return await _get_book_response(db, book)
@router.put("/{book_id}", response_model=BookResponse)
async def update_book(
book_id: UUID,
book_data: UpdateBookRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 정보 업데이트"""
if not current_user.is_admin: # Only admin can update books for now
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update books")
result = await db.execute(
select(Book).where(Book.id == book_id)
)
book = result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
for field, value in book_data.model_dump(exclude_unset=True).items():
setattr(book, field, value)
await db.commit()
await db.refresh(book)
return await _get_book_response(db, book)
@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(
book_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 삭제"""
if not current_user.is_admin: # Only admin can delete books for now
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete books")
result = await db.execute(
select(Book).where(Book.id == book_id)
)
book = result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
# Disassociate documents from this book before deleting
await db.execute(
select(Document).where(Document.book_id == book_id)
)
documents_to_update = (await db.execute(select(Document).where(Document.book_id == book_id))).scalars().all()
for doc in documents_to_update:
doc.book_id = None
await db.delete(book)
await db.commit()
return {"message": "Book deleted successfully"}
@router.get("/search/", response_model=List[BookSearchResponse])
async def search_books(
q: str = Query(..., min_length=1, description="Search query for book title or author"),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 검색 (제목 또는 저자)"""
query = select(Book).where(
or_(
Book.title.ilike(f"%{q}%"),
Book.author.ilike(f"%{q}%")
)
)
if not current_user.is_admin:
query = query.where(Book.is_public == True)
result = await db.execute(query.limit(limit))
books = result.scalars().all()
response_books = []
for book in books:
response_books.append(await _get_book_response(db, book))
return response_books
@router.get("/suggestions/", response_model=List[BookSuggestionResponse])
async def get_book_suggestions(
title: str = Query(..., min_length=1, description="Book title for suggestions"),
limit: int = Query(5, ge=1, le=10),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""제목 유사도 기반 서적 추천"""
all_books_query = select(Book)
if not current_user.is_admin:
all_books_query = all_books_query.where(Book.is_public == True)
all_books_result = await db.execute(all_books_query)
all_books = all_books_result.scalars().all()
suggestions = []
for book in all_books:
# Calculate similarity score using difflib
score = difflib.SequenceMatcher(None, title.lower(), book.title.lower()).ratio()
if score > 0.1: # Only consider if there's some similarity
suggestions.append({
"book": book,
"similarity_score": score
})
# Sort by similarity score in descending order
suggestions.sort(key=lambda x: x["similarity_score"], reverse=True)
response_suggestions = []
for s in suggestions[:limit]:
book_response = await _get_book_response(db, s["book"])
response_suggestions.append(BookSuggestionResponse(
**book_response.model_dump(),
similarity_score=s["similarity_score"]
))
return response_suggestions

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
import os import os
import uuid import uuid
from uuid import UUID
import aiofiles import aiofiles
from pathlib import Path from pathlib import Path
@@ -15,6 +16,7 @@ from ...core.database import get_db
from ...core.config import settings from ...core.config import settings
from ...models.user import User from ...models.user import User
from ...models.document import Document, Tag from ...models.document import Document, Tag
from ...models.book import Book
from ..dependencies import get_current_active_user, get_current_admin_user from ..dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
@@ -38,6 +40,16 @@ class DocumentResponse(BaseModel):
document_date: Optional[datetime] document_date: Optional[datetime]
uploader_name: Optional[str] uploader_name: Optional[str]
tags: List[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: class Config:
from_attributes = True from_attributes = True
@@ -77,7 +89,9 @@ async def list_documents(
"""문서 목록 조회""" """문서 목록 조회"""
query = select(Document).options( query = select(Document).options(
selectinload(Document.uploader), 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, updated_at=doc.updated_at,
document_date=doc.document_date, document_date=doc.document_date,
uploader_name=doc.uploader.full_name or doc.uploader.email, 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) response_data.append(doc_data)
return response_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) @router.post("/", response_model=DocumentResponse)
async def upload_document( async def upload_document(
title: str = Form(...), title: str = Form(...),
@@ -141,6 +250,7 @@ async def upload_document(
language: Optional[str] = Form("ko"), language: Optional[str] = Form("ko"),
is_public: bool = Form(False), is_public: bool = Form(False),
tags: Optional[List[str]] = Form(None), # 태그 리스트 tags: Optional[List[str]] = Form(None), # 태그 리스트
book_id: Optional[str] = Form(None), # 서적 ID 추가
html_file: UploadFile = File(...), html_file: UploadFile = File(...),
pdf_file: Optional[UploadFile] = File(None), pdf_file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
@@ -181,6 +291,21 @@ async def upload_document(
content = await pdf_file.read() content = await pdf_file.read()
await f.write(content) 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( document = Document(
id=doc_id, id=doc_id,
@@ -193,7 +318,8 @@ async def upload_document(
uploaded_by=current_user.id, uploaded_by=current_user.id,
original_filename=html_file.filename, original_filename=html_file.filename,
is_public=is_public, 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) 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}") @router.delete("/{document_id}")
async def delete_document( async def delete_document(
document_id: str, document_id: str,
@@ -323,11 +491,11 @@ async def delete_document(
detail="Document not found" 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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): if document.thumbnail_path and os.path.exists(document.thumbnail_path):
os.remove(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.execute(delete(Document).where(Document.id == document_id))
await db.commit() await db.commit()

View File

@@ -36,6 +36,7 @@ class UpdateHighlightRequest(BaseModel):
"""하이라이트 업데이트 요청""" """하이라이트 업데이트 요청"""
highlight_color: Optional[str] = None highlight_color: Optional[str] = None
highlight_type: Optional[str] = None highlight_type: Optional[str] = None
note: Optional[str] = None # 메모 업데이트 지원
class HighlightResponse(BaseModel): class HighlightResponse(BaseModel):
@@ -155,63 +156,60 @@ async def get_document_highlights(
try: try:
print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}") print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}")
# 임시로 빈 배열 반환 (테스트용) # 문서 존재 및 권한 확인
return [] result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
# 원래 코드는 주석 처리 if not document:
# # 문서 존재 및 권한 확인 raise HTTPException(
# result = await db.execute(select(Document).where(Document.id == document_id)) status_code=status.HTTP_404_NOT_FOUND,
# document = result.scalar_one_or_none() detail="Document not found"
# )
# if not document:
# raise HTTPException( # 문서 접근 권한 확인
# status_code=status.HTTP_404_NOT_FOUND, if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
# detail="Document not found" raise HTTPException(
# ) status_code=status.HTTP_403_FORBIDDEN,
# detail="Not enough permissions to access this document"
# # 문서 접근 권한 확인 )
# if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
# raise HTTPException( # 사용자의 하이라이트만 조회
# status_code=status.HTTP_403_FORBIDDEN, result = await db.execute(
# detail="Not enough permissions to access this document" select(Highlight)
# ) .where(
# and_(
# # 사용자의 하이라이트만 조회 (notes 로딩 제거) Highlight.document_id == document_id,
# result = await db.execute( Highlight.user_id == current_user.id
# select(Highlight) )
# .where( )
# and_( .order_by(Highlight.start_offset)
# Highlight.document_id == document_id, )
# Highlight.user_id == current_user.id highlights = result.scalars().all()
# )
# ) print(f"DEBUG: Found {len(highlights)} highlights for user {current_user.id}")
# .order_by(Highlight.start_offset)
# ) # 응답 데이터 변환
# highlights = result.scalars().all() response_data = []
# for highlight in highlights:
# # 응답 데이터 변환 highlight_data = HighlightResponse(
# response_data = [] id=str(highlight.id),
# for highlight in highlights: user_id=str(highlight.user_id),
# highlight_data = HighlightResponse( document_id=str(highlight.document_id),
# id=str(highlight.id), start_offset=highlight.start_offset,
# user_id=str(highlight.user_id), end_offset=highlight.end_offset,
# document_id=str(highlight.document_id), selected_text=highlight.selected_text,
# start_offset=highlight.start_offset, element_selector=highlight.element_selector,
# end_offset=highlight.end_offset, start_container_xpath=highlight.start_container_xpath,
# selected_text=highlight.selected_text, end_container_xpath=highlight.end_container_xpath,
# element_selector=highlight.element_selector, highlight_color=highlight.highlight_color,
# start_container_xpath=highlight.start_container_xpath, highlight_type=highlight.highlight_type,
# end_container_xpath=highlight.end_container_xpath, created_at=highlight.created_at,
# highlight_color=highlight.highlight_color, updated_at=highlight.updated_at,
# highlight_type=highlight.highlight_type, note=None
# created_at=highlight.created_at, )
# updated_at=highlight.updated_at, response_data.append(highlight_data)
# note=None
# ) return response_data
# # 메모는 별도 API에서 조회하므로 여기서는 처리하지 않음
# response_data.append(highlight_data)
#
# return response_data
except Exception as e: except Exception as e:
print(f"ERROR in get_document_highlights: {e}") print(f"ERROR in get_document_highlights: {e}")
@@ -288,7 +286,7 @@ async def update_highlight(
"""하이라이트 업데이트""" """하이라이트 업데이트"""
result = await db.execute( result = await db.execute(
select(Highlight) select(Highlight)
.options(selectinload(Highlight.user)) .options(selectinload(Highlight.user), selectinload(Highlight.notes))
.where(Highlight.id == highlight_id) .where(Highlight.id == highlight_id)
) )
highlight = result.scalar_one_or_none() highlight = result.scalar_one_or_none()
@@ -312,6 +310,23 @@ async def update_highlight(
if highlight_data.highlight_type: if highlight_data.highlight_type:
highlight.highlight_type = highlight_data.highlight_type highlight.highlight_type = highlight_data.highlight_type
# 메모 업데이트 처리
if highlight_data.note is not None:
if highlight.notes:
# 기존 메모 업데이트
highlight.notes.content = highlight_data.note
highlight.notes.updated_at = datetime.utcnow()
else:
# 새 메모 생성
new_note = Note(
user_id=current_user.id,
document_id=highlight.document_id,
highlight_id=highlight.id,
content=highlight_data.note,
tags=""
)
db.add(new_note)
await db.commit() await db.commit()
await db.refresh(highlight) await db.refresh(highlight)

View File

@@ -9,7 +9,7 @@ import uvicorn
from .core.config import settings from .core.config import settings
from .core.database import init_db from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories
@asynccontextmanager @asynccontextmanager
@@ -47,6 +47,8 @@ app.include_router(users.router, prefix="/api/users", tags=["사용자"])
app.include_router(documents.router, prefix="/api/documents", tags=["문서"]) app.include_router(documents.router, prefix="/api/documents", tags=["문서"])
app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"]) app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"])
app.include_router(notes.router, prefix="/api/notes", tags=["메모"]) app.include_router(notes.router, prefix="/api/notes", tags=["메모"])
app.include_router(books.router, prefix="/api/books", tags=["서적"])
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"]) app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
app.include_router(search.router, prefix="/api/search", tags=["검색"]) app.include_router(search.router, prefix="/api/search", tags=["검색"])

View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, String, DateTime, Text, Boolean
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class Book(Base):
"""서적 테이블 (여러 문서를 묶는 단위)"""
__tablename__ = "books"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(500), nullable=False, index=True)
author = Column(String(255), nullable=True)
description = Column(Text, nullable=True)
language = Column(String(10), default="ko")
is_public = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
documents = relationship("Document", back_populates="book", cascade="all, delete-orphan")
categories = relationship("BookCategory", back_populates="book", cascade="all, delete-orphan", order_by="BookCategory.sort_order")
def __repr__(self):
return f"<Book(title='{self.title}', author='{self.author}')>"

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class BookCategory(Base):
"""서적 소분류 테이블 (서적 내 문서 그룹화)"""
__tablename__ = "book_categories"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
book_id = Column(UUID(as_uuid=True), ForeignKey('books.id', ondelete='CASCADE'), nullable=False, index=True)
name = Column(String(200), nullable=False) # 소분류 이름 (예: "Chapter 1", "설계 기준", "계산서")
description = Column(Text, nullable=True) # 소분류 설명
sort_order = Column(Integer, default=0) # 소분류 정렬 순서
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
book = relationship("Book", back_populates="categories")
documents = relationship("Document", back_populates="category", cascade="all, delete-orphan")
def __repr__(self):
return f"<BookCategory(name='{self.name}', book='{self.book.title if self.book else None}')>"

View File

@@ -24,7 +24,10 @@ class Document(Base):
__tablename__ = "documents" __tablename__ = "documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
book_id = Column(UUID(as_uuid=True), ForeignKey('books.id'), nullable=True, index=True) # 서적 ID
category_id = Column(UUID(as_uuid=True), ForeignKey('book_categories.id'), nullable=True, index=True) # 소분류 ID
title = Column(String(500), nullable=False, index=True) title = Column(String(500), nullable=False, index=True)
sort_order = Column(Integer, default=0) # 문서 정렬 순서 (소분류 내에서)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
# 파일 정보 # 파일 정보
@@ -51,6 +54,8 @@ class Document(Base):
document_date = Column(DateTime(timezone=True), nullable=True) # 문서 작성일 (사용자 입력) document_date = Column(DateTime(timezone=True), nullable=True) # 문서 작성일 (사용자 입력)
# 관계 # 관계
book = relationship("Book", back_populates="documents") # 서적 관계
category = relationship("BookCategory", back_populates="documents") # 소분류 관계
uploader = relationship("User", backref="uploaded_documents") uploader = relationship("User", backref="uploaded_documents")
tags = relationship("Tag", secondary=document_tags, back_populates="documents") tags = relationship("Tag", secondary=document_tags, back_populates="documents")
highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan") highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan")

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
class BookBase(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
author: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
language: str = Field("ko", max_length=10)
is_public: bool = False
class CreateBookRequest(BookBase):
pass
class UpdateBookRequest(BookBase):
pass
class BookResponse(BookBase):
id: UUID
created_at: datetime
updated_at: Optional[datetime]
document_count: int = 0 # 문서 개수 추가
class Config:
from_attributes = True
class BookSearchResponse(BookResponse):
pass
class BookSuggestionResponse(BookResponse):
similarity_score: float = Field(..., ge=0.0, le=1.0)

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
class BookCategoryBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
sort_order: int = Field(0, ge=0)
class CreateBookCategoryRequest(BookCategoryBase):
book_id: UUID
class UpdateBookCategoryRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
sort_order: Optional[int] = Field(None, ge=0)
class BookCategoryResponse(BookCategoryBase):
id: UUID
book_id: UUID
created_at: datetime
updated_at: Optional[datetime]
document_count: int = 0 # 포함된 문서 수
class Config:
from_attributes = True
class UpdateDocumentOrderRequest(BaseModel):
document_orders: List[dict] = Field(..., description="문서 ID와 순서 정보")
# 예: [{"document_id": "uuid", "sort_order": 1}, ...]

677
frontend/hierarchy.html Normal file
View File

@@ -0,0 +1,677 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문서 관리 시스템 - 계층구조</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
/* 드래그 앤 드롭 스타일 */
.draggable {
cursor: move;
transition: all 0.2s ease;
}
.draggable:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.drag-over {
border: 2px dashed #3b82f6;
background-color: #eff6ff;
}
.tree-item {
position: relative;
}
.tree-item::before {
content: '';
position: absolute;
left: -12px;
top: 0;
bottom: 0;
width: 1px;
background: #e5e7eb;
}
.tree-item:last-child::before {
height: 20px;
}
.tree-item::after {
content: '';
position: absolute;
left: -12px;
top: 20px;
width: 12px;
height: 1px;
background: #e5e7eb;
}
.sidebar-scroll {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.sidebar-scroll::-webkit-scrollbar {
width: 6px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: #f1f5f9;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
/* 하이라이트 스타일 */
.highlight {
border-radius: 2px;
padding: 1px 2px;
margin: 0 1px;
transition: all 0.2s ease;
}
.highlight:hover {
opacity: 0.8;
transform: scale(1.02);
}
/* 텍스트 선택 시 하이라이트 메뉴 */
.highlight-menu {
animation: fadeInUp 0.2s ease-out;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3) !important;
border: 3px solid #3b82f6 !important;
background: white !important;
z-index: 99999 !important;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 문서 콘텐츠 영역 */
.prose {
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
</style>
</head>
<body class="bg-gray-50" x-data="hierarchyApp()" x-init="init()">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900">📚 문서 관리 시스템</h1>
<div class="flex space-x-2">
<a href="index.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">그리드 뷰</a>
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">계층구조 뷰</span>
</div>
</div>
<div class="flex items-center space-x-4">
<!-- 검색 -->
<div class="relative" x-show="currentUser">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input
type="text"
x-model="searchQuery"
@input="searchDocuments"
placeholder="문서, 메모 검색..."
class="w-80 pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<!-- 사용자 메뉴 -->
<div x-show="currentUser" class="flex items-center space-x-3">
<span class="text-sm text-gray-600" x-text="currentUser?.full_name || currentUser?.email"></span>
<button @click="logout()" class="text-sm text-red-600 hover:text-red-800">로그아웃</button>
</div>
<!-- 로그인 버튼 -->
<button x-show="!currentUser" @click="openLoginModal()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
로그인
</button>
</div>
</div>
</div>
</header>
<!-- 메인 컨테이너 -->
<div class="flex h-screen pt-16">
<!-- 사이드바 -->
<aside class="w-80 bg-white shadow-lg border-r flex flex-col">
<!-- 사이드바 헤더 -->
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-gray-900">📖 문서 구조</h2>
<div class="flex space-x-2">
<button
@click="showUploadModal = true"
x-show="currentUser"
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"
title="문서 업로드"
>
<i class="fas fa-plus"></i>
</button>
<button
@click="refreshHierarchy()"
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg"
title="새로고침"
>
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
</div>
<!-- 트리 구조 -->
<div class="flex-1 overflow-y-auto sidebar-scroll p-4">
<div x-show="loading" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
<p class="text-gray-500 mt-2">로딩 중...</p>
</div>
<div x-show="!loading">
<!-- 서적들 -->
<template x-for="(book, bookId) in hierarchy.books" :key="bookId">
<div class="mb-4">
<!-- 서적 헤더 -->
<div
class="flex items-center p-3 bg-blue-50 border border-blue-200 rounded-lg cursor-pointer hover:bg-blue-100"
@click="toggleBook(bookId)"
>
<i class="fas fa-book text-blue-600 mr-3"></i>
<div class="flex-1">
<h3 class="font-medium text-blue-900" x-text="book.title"></h3>
<p class="text-sm text-blue-700" x-text="book.author || '저자 미상'"></p>
</div>
<i
class="fas fa-chevron-down transition-transform duration-200"
:class="expandedBooks.includes(bookId) ? 'rotate-180' : ''"
></i>
</div>
<!-- 서적 내용 -->
<div x-show="expandedBooks.includes(bookId)" class="mt-2 ml-4 space-y-2">
<!-- 소분류들 -->
<template x-for="(category, categoryId) in book.categories" :key="categoryId">
<div class="tree-item">
<!-- 소분류 헤더 -->
<div
class="flex items-center p-2 bg-green-50 border border-green-200 rounded cursor-pointer hover:bg-green-100"
@click="toggleCategory(categoryId)"
>
<i class="fas fa-folder text-green-600 mr-2"></i>
<span class="flex-1 font-medium text-green-900" x-text="category.name"></span>
<span class="text-xs text-green-700" x-text="`${category.documents.length}개`"></span>
<i
class="fas fa-chevron-down ml-2 transition-transform duration-200"
:class="expandedCategories.includes(categoryId) ? 'rotate-180' : ''"
></i>
</div>
<!-- 소분류 문서들 -->
<div x-show="expandedCategories.includes(categoryId)" class="mt-1 ml-4 space-y-1">
<template x-for="doc in category.documents" :key="doc.id">
<div
class="flex items-center p-2 bg-white border rounded cursor-pointer hover:bg-gray-50 draggable"
@click="openDocument(doc.id)"
draggable="true"
@dragstart="handleDragStart($event, doc)"
@dragover.prevent
@drop="handleDrop($event, categoryId)"
>
<i class="fas fa-file-alt text-gray-500 mr-2"></i>
<span class="flex-1 text-sm" x-text="doc.title"></span>
<div class="flex space-x-1">
<button
@click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600"
title="편집"
>
<i class="fas fa-edit text-xs"></i>
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600"
title="삭제"
>
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- 서적 내 미분류 문서들 -->
<div x-show="book.uncategorized_documents.length > 0" class="tree-item">
<div
class="flex items-center p-2 bg-yellow-50 border border-yellow-200 rounded cursor-pointer hover:bg-yellow-100"
@click="toggleUncategorizedInBook(bookId)"
>
<i class="fas fa-folder-open text-yellow-600 mr-2"></i>
<span class="flex-1 font-medium text-yellow-900">미분류</span>
<span class="text-xs text-yellow-700" x-text="`${book.uncategorized_documents.length}개`"></span>
<i
class="fas fa-chevron-down ml-2 transition-transform duration-200"
:class="expandedUncategorizedInBooks.includes(bookId) ? 'rotate-180' : ''"
></i>
</div>
<div x-show="expandedUncategorizedInBooks.includes(bookId)" class="mt-1 ml-4 space-y-1">
<template x-for="doc in book.uncategorized_documents" :key="doc.id">
<div
class="flex items-center p-2 bg-white border rounded cursor-pointer hover:bg-gray-50 draggable"
@click="openDocument(doc.id)"
draggable="true"
@dragstart="handleDragStart($event, doc)"
>
<i class="fas fa-file-alt text-gray-500 mr-2"></i>
<span class="flex-1 text-sm" x-text="doc.title"></span>
<div class="flex space-x-1">
<button
@click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600"
title="편집"
>
<i class="fas fa-edit text-xs"></i>
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600"
title="삭제"
>
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<!-- 전체 미분류 문서들 -->
<div x-show="hierarchy.uncategorized.length > 0" class="mb-4">
<div
class="flex items-center p-3 bg-gray-100 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-200"
@click="toggleUncategorized()"
>
<i class="fas fa-folder-open text-gray-600 mr-3"></i>
<div class="flex-1">
<h3 class="font-medium text-gray-900">📄 미분류 문서</h3>
<p class="text-sm text-gray-600" x-text="`${hierarchy.uncategorized.length}개 문서`"></p>
</div>
<i
class="fas fa-chevron-down transition-transform duration-200"
:class="showUncategorized ? 'rotate-180' : ''"
></i>
</div>
<div x-show="showUncategorized" class="mt-2 ml-4 space-y-1">
<template x-for="doc in hierarchy.uncategorized" :key="doc.id">
<div
class="flex items-center p-2 bg-white border rounded cursor-pointer hover:bg-gray-50 draggable"
@click="openDocument(doc.id)"
draggable="true"
@dragstart="handleDragStart($event, doc)"
>
<i class="fas fa-file-alt text-gray-500 mr-2"></i>
<span class="flex-1 text-sm" x-text="doc.title"></span>
<div class="flex space-x-1">
<button
@click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600"
title="편집"
>
<i class="fas fa-edit text-xs"></i>
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600"
title="삭제"
>
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
</template>
</div>
</div>
<!-- 빈 상태 -->
<div x-show="Object.keys(hierarchy.books).length === 0 && hierarchy.uncategorized.length === 0" class="text-center py-8">
<i class="fas fa-folder-open text-gray-300 text-4xl mb-4"></i>
<p class="text-gray-500">아직 문서가 없습니다.</p>
<button
x-show="currentUser"
@click="showUploadModal = true"
class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
첫 문서 업로드하기
</button>
</div>
</div>
</div>
</aside>
<!-- 메인 콘텐츠 영역 -->
<main class="flex-1 flex flex-col">
<!-- 콘텐츠 헤더 -->
<div class="bg-white border-b p-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900" x-text="selectedDocument ? selectedDocument.title : '문서를 선택하세요'"></h2>
<p class="text-sm text-gray-500" x-text="selectedDocument ? `${selectedDocument.book_title || '미분류'} ${selectedDocument.category_name ? '> ' + selectedDocument.category_name : ''}` : '사이드바에서 문서를 클릭하여 내용을 확인하세요'"></p>
</div>
<div x-show="selectedDocument" class="flex space-x-2">
<button
@click="toggleHighlightMode()"
class="px-3 py-2 rounded transition-colors"
:class="highlightMode ? 'bg-yellow-500 text-white' : 'text-yellow-600 border border-yellow-600 hover:bg-yellow-50'"
>
<i class="fas fa-highlighter mr-1"></i>
<span x-text="highlightMode ? '하이라이트 ON' : '하이라이트 OFF'"></span>
</button>
<button
@click="editDocument(selectedDocument)"
class="px-3 py-2 text-blue-600 border border-blue-600 rounded hover:bg-blue-50"
>
<i class="fas fa-edit mr-1"></i> 편집
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click="deleteDocument(selectedDocument.id)"
class="px-3 py-2 text-red-600 border border-red-600 rounded hover:bg-red-50"
>
<i class="fas fa-trash mr-1"></i> 삭제
</button>
</div>
</div>
</div>
<!-- 문서 뷰어 -->
<div class="flex-1 overflow-auto">
<div x-show="!selectedDocument" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-file-alt text-gray-300 text-6xl mb-4"></i>
<h3 class="text-xl font-medium text-gray-600 mb-2">문서를 선택하세요</h3>
<p class="text-gray-500">왼쪽 사이드바에서 문서를 클릭하면 내용이 표시됩니다.</p>
</div>
</div>
<div x-show="selectedDocument && documentContent" class="p-6">
<div x-html="documentContent" class="prose max-w-none"></div>
</div>
<div x-show="selectedDocument && !documentContent && loadingDocument" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-gray-400 text-3xl mb-4"></i>
<p class="text-gray-500">문서를 불러오는 중...</p>
</div>
</div>
</div>
<!-- 하이라이트 메뉴 (직접 DOM 조작) -->
<div
id="highlight-menu"
class="fixed top-20 left-1/2 transform -translate-x-1/2 z-50 bg-white border border-gray-300 rounded-lg shadow-lg p-3"
style="display: none;"
>
<div class="text-center mb-2">
<h4 class="font-semibold text-sm">하이라이트 색상</h4>
</div>
<div class="flex space-x-2">
<button onclick="window.hierarchyInstance.createHighlight('yellow')" class="w-8 h-8 bg-yellow-300 rounded hover:bg-yellow-400" title="노랑"></button>
<button onclick="window.hierarchyInstance.createHighlight('blue')" class="w-8 h-8 bg-blue-300 rounded hover:bg-blue-400" title="파랑"></button>
<button onclick="window.hierarchyInstance.createHighlight('green')" class="w-8 h-8 bg-green-300 rounded hover:bg-green-400" title="초록"></button>
<button onclick="window.hierarchyInstance.createHighlight('red')" class="w-8 h-8 bg-red-300 rounded hover:bg-red-400" title="빨강"></button>
</div>
</div>
</main>
</div>
<!-- 로그인 모달 -->
<div x-data="authModal()" x-show="showLoginModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
<h2 class="text-xl font-bold mb-4">로그인</h2>
<form @submit.prevent="login()">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input
type="email"
x-model="loginForm.email"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
<input
type="password"
x-model="loginForm.password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="loginError"></span>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
@click="showLoginModal = false"
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
>
취소
</button>
<button
type="submit"
:disabled="loggingIn"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
<span x-show="!loggingIn">로그인</span>
<span x-show="loggingIn">로그인 중...</span>
</button>
</div>
</form>
</div>
</div>
<!-- 업로드 모달 -->
<div x-data="uploadModal()" x-show="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-xl font-bold mb-4">문서 업로드</h2>
<form @submit.prevent="upload()">
<!-- 파일 선택 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">HTML 파일 *</label>
<input
type="file"
accept=".html,.htm"
@change="handleFileSelect($event, 'html_file')"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">PDF 파일 (선택사항)</label>
<input
type="file"
accept=".pdf"
@change="handleFileSelect($event, 'pdf_file')"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<!-- 서적 선택 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">서적 분류</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="existing" class="mr-2 text-blue-600" name="bookSelectionMode">
기존 서적 선택
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="new" class="mr-2 text-blue-600" name="bookSelectionMode">
새 서적 생성
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="none" class="mr-2 text-blue-600" name="bookSelectionMode">
서적 없이 업로드
</label>
</div>
</div>
<!-- 기존 서적 선택 -->
<div x-show="bookSelectionMode === 'existing'" class="mb-4 space-y-3">
<input
type="text"
x-model="bookSearchQuery"
@input="searchBooks"
placeholder="서적 제목으로 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div x-show="searchedBooks.length > 0" class="max-h-40 overflow-y-auto border border-gray-200 rounded-md">
<template x-for="book in searchedBooks" :key="book.id">
<div
@click="selectBook(book)"
class="p-3 hover:bg-gray-50 cursor-pointer border-b last:border-b-0"
>
<div class="font-medium" x-text="book.title"></div>
<div class="text-sm text-gray-600" x-text="book.author || '저자 미상'"></div>
</div>
</template>
</div>
<div x-show="selectedBook" class="p-3 bg-blue-50 border border-blue-200 rounded-md">
<div class="font-medium text-blue-900" x-text="selectedBook?.title"></div>
<div class="text-sm text-blue-700" x-text="selectedBook?.author || '저자 미상'"></div>
</div>
</div>
<!-- 새 서적 생성 -->
<div x-show="bookSelectionMode === 'new'" class="mb-4 space-y-3">
<input
type="text"
x-model="newBook.title"
@input="getSuggestions"
placeholder="서적 제목 *"
:required="bookSelectionMode === 'new'"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<input
type="text"
x-model="newBook.author"
placeholder="저자"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div x-show="suggestions.length > 0" class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p class="text-sm text-yellow-800 mb-2">비슷한 서적이 있습니다:</p>
<template x-for="suggestion in suggestions" :key="suggestion.id">
<div
@click="selectExistingFromSuggestion(suggestion)"
class="p-2 hover:bg-yellow-100 cursor-pointer rounded text-sm"
>
<span class="font-medium" x-text="suggestion.title"></span>
<span class="text-yellow-700" x-text="suggestion.author ? ` - ${suggestion.author}` : ''"></span>
</div>
</template>
</div>
</div>
<!-- 문서 정보 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">문서 제목 *</label>
<input
type="text"
x-model="uploadForm.title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서 제목"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">문서 날짜</label>
<input
type="date"
x-model="uploadForm.document_date"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea
x-model="uploadForm.description"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서에 대한 간단한 설명"
></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
<input
type="text"
x-model="uploadForm.tags"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="태그를 쉼표로 구분하여 입력 (예: 중요, 회의록, 2024)"
>
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="checkbox" x-model="uploadForm.is_public" class="mr-2">
<span class="text-sm text-gray-700">공개 문서로 설정</span>
</label>
</div>
<div x-show="uploadError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="uploadError"></span>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
@click="closeUploadModal()"
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
>
취소
</button>
<button
type="submit"
:disabled="uploading || !uploadForm.html_file || !uploadForm.title"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!uploading">업로드</span>
<span x-show="uploading">
<i class="fas fa-spinner fa-spin mr-2"></i>
업로드 중...
</span>
</button>
</div>
</form>
</div>
</div>
<!-- JavaScript -->
<script src="static/js/api.js?v=2025012225"></script>
<script src="static/js/auth.js?v=2025012225"></script>
<script src="static/js/hierarchy.js?v=2025012225"></script>
</body>
</html>

View File

@@ -12,9 +12,9 @@
</head> </head>
<body class="bg-gray-50 min-h-screen"> <body class="bg-gray-50 min-h-screen">
<!-- 메인 앱 --> <!-- 메인 앱 -->
<div x-data="documentApp" x-init="init()"> <div x-data="documentApp()" x-init="init()">
<!-- 로그인 모달 --> <!-- 로그인 모달 -->
<div x-data="authModal" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div x-data="authModal()" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4"> <div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">로그인</h2> <h2 class="text-2xl font-bold text-gray-900">로그인</h2>
@@ -53,11 +53,15 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<!-- 로고 --> <!-- 로고 -->
<div class="flex items-center"> <div class="flex items-center space-x-4">
<h1 class="text-xl font-bold text-gray-900"> <h1 class="text-xl font-bold text-gray-900">
<i class="fas fa-file-alt mr-2"></i> <i class="fas fa-file-alt mr-2"></i>
Document Server Document Server
</h1> </h1>
<div class="flex space-x-2">
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">그리드 뷰</span>
<a href="hierarchy.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">계층구조 뷰</a>
</div>
</div> </div>
<!-- 검색바 --> <!-- 검색바 -->
@@ -181,7 +185,16 @@
<i class="fas fa-file-alt text-blue-500 text-xl"></i> <i class="fas fa-file-alt text-blue-500 text-xl"></i>
</div> </div>
<p class="text-gray-600 text-sm mb-4 line-clamp-3" x-text="doc.description || '설명 없음'"></p> <p class="text-gray-600 text-sm mb-3 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
<!-- 서적 정보 -->
<div x-show="doc.book_title" class="mb-3 p-2 bg-green-50 border border-green-200 rounded-md">
<div class="flex items-center text-sm">
<i class="fas fa-book text-green-600 mr-2"></i>
<span class="font-medium text-green-800" x-text="doc.book_title"></span>
<span x-show="doc.book_author" class="text-green-600 ml-1" x-text="' by ' + doc.book_author"></span>
</div>
</div>
<div class="flex flex-wrap gap-1 mb-4"> <div class="flex flex-wrap gap-1 mb-4">
<template x-for="tag in doc.tags" :key="tag"> <template x-for="tag in doc.tags" :key="tag">
@@ -191,7 +204,24 @@
<div class="flex justify-between items-center text-sm text-gray-500"> <div class="flex justify-between items-center text-sm text-gray-500">
<span x-text="formatDate(doc.created_at)"></span> <span x-text="formatDate(doc.created_at)"></span>
<span x-text="doc.uploader_name"></span> <div class="flex items-center space-x-2">
<span x-text="doc.uploader_name"></span>
<!-- 액션 버튼들 -->
<div class="flex space-x-1 ml-2">
<button @click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="문서 수정">
<i class="fas fa-edit text-sm"></i>
</button>
<!-- 관리자만 삭제 버튼 표시 -->
<button x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
title="문서 삭제 (관리자 전용)">
<i class="fas fa-trash text-sm"></i>
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -225,7 +255,7 @@
</button> </button>
</div> </div>
<div x-data="uploadModal"> <div x-data="uploadModal()">
<form @submit.prevent="upload"> <form @submit.prevent="upload">
<!-- 파일 업로드 영역 --> <!-- 파일 업로드 영역 -->
<div class="mb-6"> <div class="mb-6">
@@ -272,6 +302,98 @@
</div> </div>
</div> </div>
<!-- 서적 선택/생성 -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<label class="block text-sm font-medium text-gray-700 mb-3">📚 서적 설정</label>
<!-- 서적 선택 방식 -->
<div class="flex space-x-4 mb-4">
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="existing"
class="mr-2 text-blue-600">
<span class="text-sm">기존 서적에 추가</span>
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="new"
class="mr-2 text-blue-600">
<span class="text-sm">새 서적 생성</span>
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="none"
class="mr-2 text-blue-600">
<span class="text-sm">서적 없이 업로드</span>
</label>
</div>
<!-- 기존 서적 선택 -->
<div x-show="bookSelectionMode === 'existing'" class="space-y-3">
<div>
<input type="text" x-model="bookSearchQuery" @input="searchBooks"
placeholder="서적 제목으로 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 검색 결과 -->
<div x-show="searchedBooks.length > 0" class="max-h-40 overflow-y-auto border border-gray-200 rounded-md">
<template x-for="book in searchedBooks" :key="book.id">
<div @click="selectBook(book)"
:class="selectedBook?.id === book.id ? 'bg-blue-100 border-blue-500' : 'hover:bg-gray-50'"
class="p-3 border-b border-gray-200 cursor-pointer">
<div class="font-medium text-sm" x-text="book.title"></div>
<div class="text-xs text-gray-500">
<span x-text="book.author || '저자 미상'"></span> ·
<span x-text="book.document_count + '개 문서'"></span>
</div>
</div>
</template>
</div>
<!-- 선택된 서적 표시 -->
<div x-show="selectedBook" class="p-3 bg-blue-50 border border-blue-200 rounded-md">
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-blue-900" x-text="selectedBook?.title"></div>
<div class="text-sm text-blue-700" x-text="selectedBook?.author || '저자 미상'"></div>
</div>
<button @click="selectedBook = null" class="text-blue-500 hover:text-blue-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- 새 서적 생성 -->
<div x-show="bookSelectionMode === 'new'" class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<input type="text" x-model="newBook.title" @input="getSuggestions"
placeholder="서적 제목 *" :required="bookSelectionMode === 'new'"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<input type="text" x-model="newBook.author"
placeholder="저자"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- 유사 서적 추천 -->
<div x-show="suggestions.length > 0" class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="text-sm font-medium text-yellow-800 mb-2">💡 유사한 서적이 있습니다:</div>
<template x-for="suggestion in suggestions" :key="suggestion.id">
<div @click="selectExistingFromSuggestion(suggestion)"
class="p-2 bg-white border border-yellow-300 rounded cursor-pointer hover:bg-yellow-50 mb-1">
<div class="text-sm font-medium" x-text="suggestion.title"></div>
<div class="text-xs text-gray-600">
<span x-text="suggestion.author || '저자 미상'"></span> ·
<span x-text="Math.round(suggestion.similarity_score * 100) + '% 유사'"></span>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 문서 정보 --> <!-- 문서 정보 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div> <div>
@@ -356,5 +478,10 @@
overflow: hidden; overflow: hidden;
} }
</style> </style>
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012222"></script>
<script src="/static/js/auth.js?v=2025012222"></script>
<script src="/static/js/main.js?v=2025012222"></script>
</body> </body>
</html> </html>

View File

@@ -1,9 +1,9 @@
/** /**
* API 통신 유틸리티 * API 통신 유틸리티
*/ */
class API { class DocumentServerAPI {
constructor() { constructor() {
this.baseURL = 'http://localhost:24102/api'; this.baseURL = '/api'; // Nginx 프록시를 통해 접근
this.token = localStorage.getItem('access_token'); this.token = localStorage.getItem('access_token');
} }
@@ -143,10 +143,18 @@ class API {
return await this.get('/documents/', params); return await this.get('/documents/', params);
} }
async getDocumentsHierarchy() {
return await this.get('/documents/hierarchy/structured');
}
async getDocument(documentId) { async getDocument(documentId) {
return await this.get(`/documents/${documentId}`); return await this.get(`/documents/${documentId}`);
} }
async getDocumentContent(documentId) {
return await this.get(`/documents/${documentId}/content`);
}
async uploadDocument(formData) { async uploadDocument(formData) {
return await this.uploadFile('/documents/', formData); return await this.uploadFile('/documents/', formData);
} }
@@ -326,7 +334,87 @@ class API {
if (documentId) params.append('document_id', documentId); if (documentId) params.append('document_id', documentId);
return await this.get(`/search/notes?${params}`); return await this.get(`/search/notes?${params}`);
} }
// === 서적 관련 API ===
async getBooks(skip = 0, limit = 50, search = null) {
const params = new URLSearchParams({ skip, limit });
if (search) params.append('search', search);
return await this.get(`/books?${params}`);
}
async createBook(bookData) {
return await this.post('/books', bookData);
}
async getBook(bookId) {
return await this.get(`/books/${bookId}`);
}
async searchBooks(query, limit = 10) {
const params = new URLSearchParams({ q: query, limit });
return await this.get(`/books/search/?${params}`);
}
async getBookSuggestions(title, limit = 5) {
const params = new URLSearchParams({ title, limit });
return await this.get(`/books/suggestions/?${params}`);
}
// === 서적 소분류 관련 API ===
async createBookCategory(categoryData) {
return await this.post('/book-categories/', categoryData);
}
async getBookCategories(bookId) {
return await this.get(`/book-categories/book/${bookId}`);
}
async updateBookCategory(categoryId, categoryData) {
return await this.put(`/book-categories/${categoryId}`, categoryData);
}
async deleteBookCategory(categoryId) {
return await this.delete(`/book-categories/${categoryId}`);
}
async updateDocumentOrder(orderData) {
return await this.put('/book-categories/documents/reorder', orderData);
}
// === 하이라이트 관련 API ===
async getDocumentHighlights(documentId) {
return await this.get(`/highlights/document/${documentId}`);
}
async createHighlight(highlightData) {
return await this.post('/highlights/', highlightData);
}
async updateHighlight(highlightId, highlightData) {
return await this.put(`/highlights/${highlightId}`, highlightData);
}
async deleteHighlight(highlightId) {
return await this.delete(`/highlights/${highlightId}`);
}
// === 메모 관련 API ===
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
async createNote(noteData) {
return await this.post('/notes/', noteData);
}
async updateNote(noteId, noteData) {
return await this.put(`/notes/${noteId}`, noteData);
}
async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`);
}
} }
// 전역 API 인스턴스 // 전역 API 인스턴스
window.api = new API(); window.api = new DocumentServerAPI();

View File

@@ -18,14 +18,14 @@ window.authModal = () => ({
try { try {
// 실제 API 호출 // 실제 API 호출
const response = await api.login(this.loginForm.email, this.loginForm.password); const response = await window.api.login(this.loginForm.email, this.loginForm.password);
// 토큰 저장 // 토큰 저장
api.setToken(response.access_token); window.api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token); localStorage.setItem('refresh_token', response.refresh_token);
// 사용자 정보 가져오기 // 사용자 정보 가져오기
const userResponse = await api.getCurrentUser(); const userResponse = await window.api.getCurrentUser();
// 전역 상태 업데이트 // 전역 상태 업데이트
window.dispatchEvent(new CustomEvent('auth-changed', { window.dispatchEvent(new CustomEvent('auth-changed', {
@@ -45,7 +45,7 @@ window.authModal = () => ({
async logout() { async logout() {
try { try {
await api.logout(); await window.api.logout();
} catch (error) { } catch (error) {
console.error('Logout error:', error); console.error('Logout error:', error);
} finally { } finally {
@@ -79,7 +79,7 @@ async function refreshTokenIfNeeded() {
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error('Token refresh failed:', error);
// 갱신 실패시 로그아웃 // 갱신 실패시 로그아웃
api.setToken(null); window.api.setToken(null);
localStorage.removeItem('refresh_token'); localStorage.removeItem('refresh_token');
window.dispatchEvent(new CustomEvent('auth-changed', { window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: false, user: null } detail: { isAuthenticated: false, user: null }

File diff suppressed because it is too large Load Diff

View File

@@ -1,201 +1,239 @@
/** // 메인 애플리케이션 컴포넌트
* 메인 애플리케이션 Alpine.js 컴포넌트
*/
// 메인 문서 앱 컴포넌트
window.documentApp = () => ({ window.documentApp = () => ({
// 상태 // 상태 관리
isAuthenticated: false,
user: null,
loading: false,
// 문서 관련
documents: [], documents: [],
tags: [], filteredDocuments: [],
selectedTag: '', loading: false,
viewMode: 'grid', // 'grid' 또는 'list' error: '',
// 검색 // 인증 상태
isAuthenticated: false,
currentUser: null,
showLoginModal: false,
// 필터링 및 검색
searchQuery: '', searchQuery: '',
searchResults: [], selectedTag: '',
availableTags: [],
// UI 상태
viewMode: 'grid', // 'grid' 또는 'list'
user: null, // currentUser의 별칭
tags: [], // availableTags의 별칭
// 모달 상태 // 모달 상태
showLoginModal: false,
showUploadModal: false, showUploadModal: false,
showProfile: false,
showMyNotes: false,
showBookmarks: false,
showAdmin: false,
// 초기화 // 초기화
async init() { async init() {
// 인증 상태 확인 await this.checkAuthStatus();
await this.checkAuth();
// 인증 상태 변경 이벤트 리스너
window.addEventListener('auth-changed', (event) => {
this.isAuthenticated = event.detail.isAuthenticated;
this.user = event.detail.user;
if (this.isAuthenticated) {
this.loadInitialData();
} else {
this.resetData();
}
});
// 로그인 모달 닫기 이벤트 리스너
window.addEventListener('close-login-modal', () => {
this.showLoginModal = false;
});
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
if (this.isAuthenticated) {
this.loadDocuments();
this.loadTags();
}
});
// 업로드 모달 닫기 이벤트 리스너
window.addEventListener('close-upload-modal', () => {
this.showUploadModal = false;
});
// 알림 표시 이벤트 리스너
window.addEventListener('show-notification', (event) => {
if (this.showNotification) {
this.showNotification(event.detail.message, event.detail.type);
}
});
// 초기 데이터 로드
if (this.isAuthenticated) { if (this.isAuthenticated) {
await this.loadInitialData(); await this.loadDocuments();
} }
this.setupEventListeners();
}, },
// 인증 상태 확인 // 인증 상태 확인
async checkAuth() { async checkAuthStatus() {
if (!api.token) {
this.isAuthenticated = false;
return;
}
try { try {
this.user = await api.getCurrentUser(); const token = localStorage.getItem('access_token');
this.isAuthenticated = true; if (token) {
} catch (error) { window.api.setToken(token);
console.error('Auth check failed:', error); const user = await window.api.getCurrentUser();
this.isAuthenticated = false; this.isAuthenticated = true;
api.setToken(null); this.currentUser = user;
} this.syncUIState(); // UI 상태 동기화
},
// 초기 데이터 로드
async loadInitialData() {
this.loading = true;
try {
await Promise.all([
this.loadDocuments(),
this.loadTags()
]);
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
this.loading = false;
}
},
// 데이터 리셋
resetData() {
this.documents = [];
this.tags = [];
this.selectedTag = '';
this.searchQuery = '';
this.searchResults = [];
},
// 문서 목록 로드
async loadDocuments() {
try {
const response = await api.getDocuments();
let allDocuments = response || [];
// 태그 필터링
let filteredDocs = allDocuments;
if (this.selectedTag) {
filteredDocs = allDocuments.filter(doc =>
doc.tags && doc.tags.includes(this.selectedTag)
);
} }
this.documents = filteredDocs;
} catch (error) { } catch (error) {
console.error('Failed to load documents:', error); console.log('Not authenticated or token expired');
this.documents = []; this.isAuthenticated = false;
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error'); this.currentUser = null;
localStorage.removeItem('access_token');
this.syncUIState(); // UI 상태 동기화
} }
}, },
// 태그 목록 로드 // 로그인 모달 열기
async loadTags() { openLoginModal() {
try { this.showLoginModal = true;
const response = await api.getTags();
this.tags = response || [];
} catch (error) {
console.error('Failed to load tags:', error);
this.tags = [];
}
},
// 문서 검색
async searchDocuments() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
return;
}
try {
const results = await api.search({
q: this.searchQuery,
limit: 20
});
this.searchResults = results.results;
} catch (error) {
console.error('Search failed:', error);
}
},
// 문서 열기
openDocument(document) {
// 문서 뷰어 페이지로 이동
window.location.href = `/viewer.html?id=${document.id}`;
}, },
// 로그아웃 // 로그아웃
async logout() { async logout() {
try { try {
await api.logout(); await window.api.logout();
this.isAuthenticated = false;
this.user = null;
this.resetData();
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout error:', error);
} finally {
this.isAuthenticated = false;
this.currentUser = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
this.documents = [];
this.filteredDocuments = [];
}
},
// 이벤트 리스너 설정
setupEventListeners() {
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
this.loadDocuments();
});
// 알림 이벤트 리스너
window.addEventListener('show-notification', (event) => {
this.showNotification(event.detail.message, event.detail.type);
});
// 인증 상태 변경 이벤트 리스너
window.addEventListener('auth-changed', (event) => {
this.isAuthenticated = event.detail.isAuthenticated;
this.currentUser = event.detail.user;
this.showLoginModal = false;
this.syncUIState(); // UI 상태 동기화
if (this.isAuthenticated) {
this.loadDocuments();
}
});
// 로그인 모달 닫기 이벤트 리스너
window.addEventListener('close-login-modal', () => {
this.showLoginModal = false;
});
},
// 문서 목록 로드
async loadDocuments() {
this.loading = true;
this.error = '';
try {
this.documents = await window.api.getDocuments();
this.updateAvailableTags();
this.filterDocuments();
this.syncUIState(); // UI 상태 동기화
} catch (error) {
console.error('Failed to load documents:', error);
this.error = 'Failed to load documents: ' + error.message;
this.documents = [];
} finally {
this.loading = false;
} }
}, },
// 문서 뷰어 열기 // 사용 가능한 태그 업데이트
updateAvailableTags() {
const tagSet = new Set();
this.documents.forEach(doc => {
if (doc.tags) {
doc.tags.forEach(tag => tagSet.add(tag));
}
});
this.availableTags = Array.from(tagSet).sort();
this.tags = this.availableTags; // 별칭 동기화
},
// UI 상태 동기화
syncUIState() {
this.user = this.currentUser;
this.tags = this.availableTags;
},
// 문서 필터링
filterDocuments() {
let filtered = this.documents;
// 검색어 필터링
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
filtered = filtered.filter(doc =>
doc.title.toLowerCase().includes(query) ||
(doc.description && doc.description.toLowerCase().includes(query)) ||
(doc.tags && doc.tags.some(tag => tag.toLowerCase().includes(query)))
);
}
// 태그 필터링
if (this.selectedTag) {
filtered = filtered.filter(doc =>
doc.tags && doc.tags.includes(this.selectedTag)
);
}
this.filteredDocuments = filtered;
},
// 검색어 변경 시
onSearchChange() {
this.filterDocuments();
},
// 문서 검색 (HTML에서 사용)
searchDocuments() {
this.filterDocuments();
},
// 태그 선택 시
onTagSelect(tag) {
this.selectedTag = this.selectedTag === tag ? '' : tag;
this.filterDocuments();
},
// 태그 필터 초기화
clearTagFilter() {
this.selectedTag = '';
this.filterDocuments();
},
// 문서 삭제
async deleteDocument(documentId) {
if (!confirm('정말로 이 문서를 삭제하시겠습니까?')) {
return;
}
try {
await window.api.deleteDocument(documentId);
await this.loadDocuments();
this.showNotification('문서가 삭제되었습니다', 'success');
} catch (error) {
console.error('Failed to delete document:', error);
this.showNotification('문서 삭제에 실패했습니다: ' + error.message, 'error');
}
},
// 문서 보기
viewDocument(documentId) {
window.open(`/viewer.html?id=${documentId}`, '_blank');
},
// 문서 열기 (HTML에서 사용)
openDocument(documentId) { openDocument(documentId) {
window.location.href = `/viewer.html?id=${documentId}`; this.viewDocument(documentId);
},
// 문서 수정 (HTML에서 사용)
editDocument(document) {
// TODO: 문서 수정 모달 구현
console.log('문서 수정:', document);
alert('문서 수정 기능은 곧 구현됩니다!');
},
// 업로드 모달 열기
openUploadModal() {
this.showUploadModal = true;
},
// 업로드 모달 닫기
closeUploadModal() {
this.showUploadModal = false;
}, },
// 날짜 포맷팅 // 날짜 포맷팅
formatDate(dateString) { formatDate(dateString) {
const date = new Date(dateString); return new Date(dateString).toLocaleDateString('ko-KR', {
return date.toLocaleDateString('ko-KR', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'long',
day: 'numeric' day: 'numeric'
}); });
}, },
@@ -221,7 +259,6 @@ window.documentApp = () => ({
// 파일 업로드 컴포넌트 // 파일 업로드 컴포넌트
window.uploadModal = () => ({ window.uploadModal = () => ({
show: false,
uploading: false, uploading: false,
uploadForm: { uploadForm: {
title: '', title: '',
@@ -233,6 +270,19 @@ window.uploadModal = () => ({
pdf_file: null pdf_file: null
}, },
uploadError: '', uploadError: '',
// 서적 관련 상태
bookSelectionMode: 'none', // 'existing', 'new', 'none'
bookSearchQuery: '',
searchedBooks: [],
selectedBook: null,
newBook: {
title: '',
author: '',
description: ''
},
suggestions: [],
searchTimeout: null,
// 파일 선택 // 파일 선택
onFileSelect(event, fileType) { onFileSelect(event, fileType) {
@@ -247,15 +297,44 @@ window.uploadModal = () => ({
} }
}, },
// 드래그 앤 드롭 처리
handleFileDrop(event, fileType) {
event.target.classList.remove('dragover');
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
// 파일 타입 검증
if (fileType === 'html_file' && !file.name.match(/\.(html|htm)$/i)) {
this.uploadError = 'HTML 파일만 업로드 가능합니다';
return;
}
if (fileType === 'pdf_file' && !file.name.match(/\.pdf$/i)) {
this.uploadError = 'PDF 파일만 업로드 가능합니다';
return;
}
this.uploadForm[fileType] = file;
this.uploadError = '';
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 업로드 실행 // 업로드 실행
async upload() { async upload() {
// 필수 필드 검증
if (!this.uploadForm.html_file) { if (!this.uploadForm.html_file) {
this.uploadError = 'HTML 파일을 선택해주세요'; this.uploadError = 'HTML 파일을 선택해주세요';
return; return;
} }
if (!this.uploadForm.title.trim()) { if (!this.uploadForm.title.trim()) {
this.uploadError = '제목을 입력해주세요'; this.uploadError = '문서 제목을 입력해주세요';
return; return;
} }
@@ -263,25 +342,52 @@ window.uploadModal = () => ({
this.uploadError = ''; this.uploadError = '';
try { try {
let bookId = null;
// 서적 처리
if (this.bookSelectionMode === 'new' && this.newBook.title.trim()) {
const newBook = await window.api.createBook({
title: this.newBook.title,
author: this.newBook.author || null,
description: this.newBook.description || null,
language: this.uploadForm.language || 'ko',
is_public: this.uploadForm.is_public
});
bookId = newBook.id;
} else if (this.bookSelectionMode === 'existing' && this.selectedBook) {
bookId = this.selectedBook.id;
}
// FormData 생성
const formData = new FormData(); const formData = new FormData();
formData.append('title', this.uploadForm.title); formData.append('title', this.uploadForm.title);
formData.append('description', this.uploadForm.description); formData.append('description', this.uploadForm.description || '');
formData.append('tags', this.uploadForm.tags);
formData.append('is_public', this.uploadForm.is_public);
formData.append('html_file', this.uploadForm.html_file); formData.append('html_file', this.uploadForm.html_file);
if (this.uploadForm.pdf_file) { if (this.uploadForm.pdf_file) {
formData.append('pdf_file', this.uploadForm.pdf_file); formData.append('pdf_file', this.uploadForm.pdf_file);
} }
// 서적 ID 추가
if (bookId) {
formData.append('book_id', bookId);
}
formData.append('language', this.uploadForm.language || 'ko');
formData.append('is_public', this.uploadForm.is_public);
if (this.uploadForm.tags) {
formData.append('tags', this.uploadForm.tags);
}
if (this.uploadForm.document_date) { if (this.uploadForm.document_date) {
formData.append('document_date', this.uploadForm.document_date); formData.append('document_date', this.uploadForm.document_date);
} }
await api.uploadDocument(formData); // 업로드 실행
await window.api.uploadDocument(formData);
// 성공시 모달 닫기 및 목록 새로고침 // 성공 처리
this.show = false;
this.resetForm(); this.resetForm();
// 문서 목록 새로고침 // 문서 목록 새로고침
@@ -315,138 +421,66 @@ window.uploadModal = () => ({
// 파일 입력 필드 리셋 // 파일 입력 필드 리셋
const fileInputs = document.querySelectorAll('input[type="file"]'); const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => input.value = ''); fileInputs.forEach(input => input.value = '');
}
});
// 파일 업로드 컴포넌트
window.uploadModal = () => ({
uploading: false,
uploadForm: {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
},
uploadError: '',
// 파일 선택
onFileSelect(event, fileType) {
const file = event.target.files[0];
if (file) {
this.uploadForm[fileType] = file;
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 드래그 앤 드롭 처리
handleFileDrop(event, fileType) {
event.target.classList.remove('dragover');
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
// 파일 타입 검증
if (fileType === 'html_file' && !file.name.match(/\.(html|htm)$/i)) {
this.uploadError = 'HTML 파일만 업로드 가능합니다';
return;
}
if (fileType === 'pdf_file' && !file.name.match(/\.pdf$/i)) {
this.uploadError = 'PDF 파일만 업로드 가능합니다';
return;
}
this.uploadForm[fileType] = file;
this.uploadError = '';
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 업로드 실행 (목업)
async upload() {
if (!this.uploadForm.html_file) {
this.uploadError = 'HTML 파일을 선택해주세요';
return;
}
if (!this.uploadForm.title.trim()) {
this.uploadError = '제목을 입력해주세요';
return;
}
this.uploading = true;
this.uploadError = '';
try {
// FormData 생성
const formData = new FormData();
formData.append('title', this.uploadForm.title);
formData.append('description', this.uploadForm.description || '');
formData.append('html_file', this.uploadForm.html_file);
if (this.uploadForm.pdf_file) {
formData.append('pdf_file', this.uploadForm.pdf_file);
}
formData.append('language', this.uploadForm.language);
formData.append('is_public', this.uploadForm.is_public);
// 태그 추가
if (this.uploadForm.tags && this.uploadForm.tags.length > 0) {
this.uploadForm.tags.forEach(tag => {
formData.append('tags', tag);
});
}
// 실제 API 호출
await api.uploadDocument(formData);
// 성공시 모달 닫기 및 목록 새로고침
window.dispatchEvent(new CustomEvent('close-upload-modal'));
this.resetForm();
// 문서 목록 새로고침
window.dispatchEvent(new CustomEvent('documents-changed'));
// 성공 알림
window.dispatchEvent(new CustomEvent('show-notification', {
detail: { message: '문서가 성공적으로 업로드되었습니다', type: 'success' }
}));
} catch (error) {
this.uploadError = error.message || '업로드에 실패했습니다';
} finally {
this.uploading = false;
}
},
// 폼 리셋
resetForm() {
this.uploadForm = {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
};
this.uploadError = '';
// 파일 입력 필드 리셋 // 서적 관련 상태 리셋
const fileInputs = document.querySelectorAll('input[type="file"]'); this.bookSelectionMode = 'none';
fileInputs.forEach(input => input.value = ''); this.bookSearchQuery = '';
this.searchedBooks = [];
this.selectedBook = null;
this.newBook = { title: '', author: '', description: '' };
this.suggestions = [];
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
},
// 서적 검색
async searchBooks() {
if (!this.bookSearchQuery.trim()) {
this.searchedBooks = [];
return;
}
try {
const books = await window.api.searchBooks(this.bookSearchQuery, 10);
this.searchedBooks = books;
} catch (error) {
console.error('서적 검색 실패:', error);
this.searchedBooks = [];
}
},
// 서적 선택
selectBook(book) {
this.selectedBook = book;
this.bookSearchQuery = book.title;
this.searchedBooks = [];
},
// 유사도 추천 가져오기
async getSuggestions() {
if (!this.newBook.title.trim()) {
this.suggestions = [];
return;
}
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(async () => {
try {
const suggestions = await window.api.getBookSuggestions(this.newBook.title, 3);
this.suggestions = suggestions.filter(s => s.similarity_score > 0.5); // 50% 이상 유사한 것만
} catch (error) {
console.error('추천 가져오기 실패:', error);
this.suggestions = [];
}
}, 300);
},
// 추천에서 기존 서적 선택
selectExistingFromSuggestion(suggestion) {
this.bookSelectionMode = 'existing';
this.selectedBook = suggestion;
this.bookSearchQuery = suggestion.title;
this.suggestions = [];
this.newBook = { title: '', author: '', description: '' };
} }
}); });

24
frontend/test.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>테스트 페이지</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</head>
<body class="bg-gray-100 p-8">
<div x-data="{ message: '페이지가 정상 작동합니다!' }">
<h1 class="text-3xl font-bold text-blue-600 mb-4" x-text="message"></h1>
<p class="text-gray-700">이 페이지가 보이면 기본 설정은 정상입니다.</p>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4">링크</h2>
<div class="space-y-2">
<a href="index.html" class="block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">그리드 뷰</a>
<a href="hierarchy.html" class="block px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">계층구조 뷰</a>
</div>
</div>
</div>
</body>
</html>