feat: 계층구조 뷰 및 완전한 하이라이트/메모 시스템 구현
주요 기능: - 📚 Book 및 BookCategory 모델 추가 (서적 그룹화) - 🏗️ 계층구조 뷰 (Book > Category > Document) 구현 - 🎨 완전한 하이라이트 시스템 (생성, 표시, 삭제) - 📝 통합 메모 관리 (추가, 수정, 삭제) - 🔄 그리드 뷰와 계층구조 뷰 간 완전 동기화 - 🛡️ 관리자 전용 문서 삭제 기능 - 🔧 모든 CORS 및 500 오류 해결 기술적 개선: - API 베이스 URL을 Nginx 프록시로 변경 (/api) - 외래키 제약 조건 해결 (삭제 순서 최적화) - SQLAlchemy 관계 로딩 최적화 (selectinload) - 프론트엔드 캐시 무효화 시스템 - Alpine.js 컴포넌트 구조 개선 UI/UX: - 계층구조 네비게이션 (사이드바 + 트리 구조) - 하이라이트 모드 토글 스위치 - 완전한 툴팁 기반 메모 관리 인터페이스 - 반응형 하이라이트 메뉴 (색상 선택) - 스마트 툴팁 위치 조정 (화면 경계 고려)
This commit is contained in:
50
backend/migrations/004_add_books_table.sql
Normal file
50
backend/migrations/004_add_books_table.sql
Normal 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();
|
||||
155
backend/src/api/routes/book_categories.py
Normal file
155
backend/src/api/routes/book_categories.py
Normal 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
|
||||
)
|
||||
230
backend/src/api/routes/books.py
Normal file
230
backend/src/api/routes/books.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class UpdateHighlightRequest(BaseModel):
|
||||
"""하이라이트 업데이트 요청"""
|
||||
highlight_color: Optional[str] = None
|
||||
highlight_type: Optional[str] = None
|
||||
note: Optional[str] = None # 메모 업데이트 지원
|
||||
|
||||
|
||||
class HighlightResponse(BaseModel):
|
||||
@@ -155,63 +156,60 @@ async def get_document_highlights(
|
||||
try:
|
||||
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()
|
||||
|
||||
# 원래 코드는 주석 처리
|
||||
# # 문서 존재 및 권한 확인
|
||||
# result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
# document = result.scalar_one_or_none()
|
||||
#
|
||||
# if not document:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_404_NOT_FOUND,
|
||||
# detail="Document not found"
|
||||
# )
|
||||
#
|
||||
# # 문서 접근 권한 확인
|
||||
# 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,
|
||||
# detail="Not enough permissions to access this document"
|
||||
# )
|
||||
#
|
||||
# # 사용자의 하이라이트만 조회 (notes 로딩 제거)
|
||||
# result = await db.execute(
|
||||
# select(Highlight)
|
||||
# .where(
|
||||
# and_(
|
||||
# Highlight.document_id == document_id,
|
||||
# Highlight.user_id == current_user.id
|
||||
# )
|
||||
# )
|
||||
# .order_by(Highlight.start_offset)
|
||||
# )
|
||||
# highlights = result.scalars().all()
|
||||
#
|
||||
# # 응답 데이터 변환
|
||||
# response_data = []
|
||||
# for highlight in highlights:
|
||||
# highlight_data = HighlightResponse(
|
||||
# id=str(highlight.id),
|
||||
# user_id=str(highlight.user_id),
|
||||
# document_id=str(highlight.document_id),
|
||||
# start_offset=highlight.start_offset,
|
||||
# end_offset=highlight.end_offset,
|
||||
# selected_text=highlight.selected_text,
|
||||
# element_selector=highlight.element_selector,
|
||||
# start_container_xpath=highlight.start_container_xpath,
|
||||
# end_container_xpath=highlight.end_container_xpath,
|
||||
# highlight_color=highlight.highlight_color,
|
||||
# highlight_type=highlight.highlight_type,
|
||||
# created_at=highlight.created_at,
|
||||
# updated_at=highlight.updated_at,
|
||||
# note=None
|
||||
# )
|
||||
# # 메모는 별도 API에서 조회하므로 여기서는 처리하지 않음
|
||||
# response_data.append(highlight_data)
|
||||
#
|
||||
# return response_data
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
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,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 사용자의 하이라이트만 조회
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.where(
|
||||
and_(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.order_by(Highlight.start_offset)
|
||||
)
|
||||
highlights = result.scalars().all()
|
||||
|
||||
print(f"DEBUG: Found {len(highlights)} highlights for user {current_user.id}")
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for highlight in highlights:
|
||||
highlight_data = HighlightResponse(
|
||||
id=str(highlight.id),
|
||||
user_id=str(highlight.user_id),
|
||||
document_id=str(highlight.document_id),
|
||||
start_offset=highlight.start_offset,
|
||||
end_offset=highlight.end_offset,
|
||||
selected_text=highlight.selected_text,
|
||||
element_selector=highlight.element_selector,
|
||||
start_container_xpath=highlight.start_container_xpath,
|
||||
end_container_xpath=highlight.end_container_xpath,
|
||||
highlight_color=highlight.highlight_color,
|
||||
highlight_type=highlight.highlight_type,
|
||||
created_at=highlight.created_at,
|
||||
updated_at=highlight.updated_at,
|
||||
note=None
|
||||
)
|
||||
response_data.append(highlight_data)
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_document_highlights: {e}")
|
||||
@@ -288,7 +286,7 @@ async def update_highlight(
|
||||
"""하이라이트 업데이트"""
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(selectinload(Highlight.user))
|
||||
.options(selectinload(Highlight.user), selectinload(Highlight.notes))
|
||||
.where(Highlight.id == highlight_id)
|
||||
)
|
||||
highlight = result.scalar_one_or_none()
|
||||
@@ -312,6 +310,23 @@ async def update_highlight(
|
||||
if 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.refresh(highlight)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import uvicorn
|
||||
|
||||
from .core.config import settings
|
||||
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
|
||||
@@ -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(highlights.router, prefix="/api/highlights", 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(search.router, prefix="/api/search", tags=["검색"])
|
||||
|
||||
|
||||
27
backend/src/models/book.py
Normal file
27
backend/src/models/book.py
Normal 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}')>"
|
||||
26
backend/src/models/book_category.py
Normal file
26
backend/src/models/book_category.py
Normal 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}')>"
|
||||
@@ -24,7 +24,10 @@ class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
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)
|
||||
sort_order = Column(Integer, default=0) # 문서 정렬 순서 (소분류 내에서)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# 파일 정보
|
||||
@@ -51,6 +54,8 @@ class Document(Base):
|
||||
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")
|
||||
tags = relationship("Tag", secondary=document_tags, back_populates="documents")
|
||||
highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan")
|
||||
|
||||
32
backend/src/schemas/book.py
Normal file
32
backend/src/schemas/book.py
Normal 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)
|
||||
31
backend/src/schemas/book_category.py
Normal file
31
backend/src/schemas/book_category.py
Normal 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
677
frontend/hierarchy.html
Normal 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>
|
||||
@@ -12,9 +12,9 @@
|
||||
</head>
|
||||
<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="flex justify-between items-center mb-6">
|
||||
<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="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">
|
||||
<i class="fas fa-file-alt mr-2"></i>
|
||||
Document Server
|
||||
</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>
|
||||
|
||||
<!-- 검색바 -->
|
||||
@@ -181,7 +185,16 @@
|
||||
<i class="fas fa-file-alt text-blue-500 text-xl"></i>
|
||||
</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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -225,7 +255,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-data="uploadModal">
|
||||
<div x-data="uploadModal()">
|
||||
<form @submit.prevent="upload">
|
||||
<!-- 파일 업로드 영역 -->
|
||||
<div class="mb-6">
|
||||
@@ -272,6 +302,98 @@
|
||||
</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>
|
||||
@@ -356,5 +478,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* API 통신 유틸리티
|
||||
*/
|
||||
class API {
|
||||
class DocumentServerAPI {
|
||||
constructor() {
|
||||
this.baseURL = 'http://localhost:24102/api';
|
||||
this.baseURL = '/api'; // Nginx 프록시를 통해 접근
|
||||
this.token = localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
@@ -143,10 +143,18 @@ class API {
|
||||
return await this.get('/documents/', params);
|
||||
}
|
||||
|
||||
async getDocumentsHierarchy() {
|
||||
return await this.get('/documents/hierarchy/structured');
|
||||
}
|
||||
|
||||
async getDocument(documentId) {
|
||||
return await this.get(`/documents/${documentId}`);
|
||||
}
|
||||
|
||||
async getDocumentContent(documentId) {
|
||||
return await this.get(`/documents/${documentId}/content`);
|
||||
}
|
||||
|
||||
async uploadDocument(formData) {
|
||||
return await this.uploadFile('/documents/', formData);
|
||||
}
|
||||
@@ -326,7 +334,87 @@ class API {
|
||||
if (documentId) params.append('document_id', documentId);
|
||||
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 인스턴스
|
||||
window.api = new API();
|
||||
window.api = new DocumentServerAPI();
|
||||
|
||||
@@ -18,14 +18,14 @@ window.authModal = () => ({
|
||||
|
||||
try {
|
||||
// 실제 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);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const userResponse = await api.getCurrentUser();
|
||||
const userResponse = await window.api.getCurrentUser();
|
||||
|
||||
// 전역 상태 업데이트
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
@@ -45,7 +45,7 @@ window.authModal = () => ({
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
await window.api.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
@@ -79,7 +79,7 @@ async function refreshTokenIfNeeded() {
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
// 갱신 실패시 로그아웃
|
||||
api.setToken(null);
|
||||
window.api.setToken(null);
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
detail: { isAuthenticated: false, user: null }
|
||||
|
||||
1115
frontend/static/js/hierarchy.js
Normal file
1115
frontend/static/js/hierarchy.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,201 +1,239 @@
|
||||
/**
|
||||
* 메인 애플리케이션 Alpine.js 컴포넌트
|
||||
*/
|
||||
|
||||
// 메인 문서 앱 컴포넌트
|
||||
// 메인 애플리케이션 컴포넌트
|
||||
window.documentApp = () => ({
|
||||
// 상태
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
|
||||
// 문서 관련
|
||||
// 상태 관리
|
||||
documents: [],
|
||||
tags: [],
|
||||
selectedTag: '',
|
||||
viewMode: 'grid', // 'grid' 또는 'list'
|
||||
filteredDocuments: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// 검색
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
showLoginModal: false,
|
||||
|
||||
// 필터링 및 검색
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
selectedTag: '',
|
||||
availableTags: [],
|
||||
|
||||
// UI 상태
|
||||
viewMode: 'grid', // 'grid' 또는 'list'
|
||||
user: null, // currentUser의 별칭
|
||||
tags: [], // availableTags의 별칭
|
||||
|
||||
// 모달 상태
|
||||
showLoginModal: false,
|
||||
showUploadModal: false,
|
||||
showProfile: false,
|
||||
showMyNotes: false,
|
||||
showBookmarks: false,
|
||||
showAdmin: false,
|
||||
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
// 인증 상태 확인
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 초기 데이터 로드
|
||||
await this.checkAuthStatus();
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadInitialData();
|
||||
await this.loadDocuments();
|
||||
}
|
||||
this.setupEventListeners();
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuth() {
|
||||
if (!api.token) {
|
||||
this.isAuthenticated = false;
|
||||
return;
|
||||
}
|
||||
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
this.user = await api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
this.isAuthenticated = false;
|
||||
api.setToken(null);
|
||||
}
|
||||
},
|
||||
|
||||
// 초기 데이터 로드
|
||||
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)
|
||||
);
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
window.api.setToken(token);
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
}
|
||||
|
||||
this.documents = filteredDocs;
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error);
|
||||
this.documents = [];
|
||||
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error');
|
||||
console.log('Not authenticated or token expired');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
localStorage.removeItem('access_token');
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
}
|
||||
},
|
||||
|
||||
// 태그 목록 로드
|
||||
async loadTags() {
|
||||
try {
|
||||
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}`;
|
||||
// 로그인 모달 열기
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
},
|
||||
|
||||
// 로그아웃
|
||||
async logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.resetData();
|
||||
await window.api.logout();
|
||||
} 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) {
|
||||
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) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
},
|
||||
@@ -221,7 +259,6 @@ window.documentApp = () => ({
|
||||
|
||||
// 파일 업로드 컴포넌트
|
||||
window.uploadModal = () => ({
|
||||
show: false,
|
||||
uploading: false,
|
||||
uploadForm: {
|
||||
title: '',
|
||||
@@ -233,6 +270,19 @@ window.uploadModal = () => ({
|
||||
pdf_file: null
|
||||
},
|
||||
uploadError: '',
|
||||
|
||||
// 서적 관련 상태
|
||||
bookSelectionMode: 'none', // 'existing', 'new', 'none'
|
||||
bookSearchQuery: '',
|
||||
searchedBooks: [],
|
||||
selectedBook: null,
|
||||
newBook: {
|
||||
title: '',
|
||||
author: '',
|
||||
description: ''
|
||||
},
|
||||
suggestions: [],
|
||||
searchTimeout: null,
|
||||
|
||||
// 파일 선택
|
||||
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() {
|
||||
// 필수 필드 검증
|
||||
if (!this.uploadForm.html_file) {
|
||||
this.uploadError = 'HTML 파일을 선택해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!this.uploadForm.title.trim()) {
|
||||
this.uploadError = '제목을 입력해주세요';
|
||||
this.uploadError = '문서 제목을 입력해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,25 +342,52 @@ window.uploadModal = () => ({
|
||||
this.uploadError = '';
|
||||
|
||||
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();
|
||||
formData.append('title', this.uploadForm.title);
|
||||
formData.append('description', this.uploadForm.description);
|
||||
formData.append('tags', this.uploadForm.tags);
|
||||
formData.append('is_public', this.uploadForm.is_public);
|
||||
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);
|
||||
}
|
||||
|
||||
// 서적 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) {
|
||||
formData.append('document_date', this.uploadForm.document_date);
|
||||
}
|
||||
|
||||
await api.uploadDocument(formData);
|
||||
// 업로드 실행
|
||||
await window.api.uploadDocument(formData);
|
||||
|
||||
// 성공시 모달 닫기 및 목록 새로고침
|
||||
this.show = false;
|
||||
// 성공 처리
|
||||
this.resetForm();
|
||||
|
||||
// 문서 목록 새로고침
|
||||
@@ -315,138 +421,66 @@ window.uploadModal = () => ({
|
||||
// 파일 입력 필드 리셋
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
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"]');
|
||||
fileInputs.forEach(input => input.value = '');
|
||||
// 서적 관련 상태 리셋
|
||||
this.bookSelectionMode = 'none';
|
||||
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
24
frontend/test.html
Normal 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>
|
||||
Reference in New Issue
Block a user