🎉 Initial commit: Document Server MVP

 Features implemented:
- FastAPI backend with JWT authentication
- PostgreSQL database with async SQLAlchemy
- HTML document viewer with smart highlighting
- Note system connected to highlights (1:1 relationship)
- Bookmark system for quick navigation
- Integrated search (documents + notes)
- Tag system for document organization
- Docker containerization with Nginx

🔧 Technical stack:
- Backend: FastAPI + PostgreSQL + Redis
- Frontend: Alpine.js + Tailwind CSS
- Authentication: JWT tokens
- File handling: HTML + PDF support
- Search: Full-text search with relevance scoring

📋 Core functionality:
- Text selection → Highlight creation
- Highlight → Note attachment
- Note management with search/filtering
- Bookmark creation at scroll positions
- Document upload with metadata
- User management (admin creates accounts)
This commit is contained in:
Hyungi Ahn
2025-08-21 16:09:17 +09:00
commit 3036b8f0fb
40 changed files with 6303 additions and 0 deletions

111
.gitignore vendored Normal file
View File

@@ -0,0 +1,111 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# FastAPI
.pytest_cache/
.coverage
htmlcov/
# Database
*.db
*.sqlite3
# Docker
.dockerignore
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Uploads (실제 파일들)
uploads/documents/
uploads/thumbnails/
# Poetry
poetry.lock
# Temporary files
*.tmp
*.temp

283
README.md Normal file
View File

@@ -0,0 +1,283 @@
# Document Server
HTML 문서 관리 및 뷰어 시스템
## 프로젝트 개요
PDF 문서를 OCR 처리하고 AI로 HTML로 변환한 후, 웹에서 효율적으로 관리하고 열람할 수 있는 시스템입니다.
### 문서 처리 워크플로우
1. PDF 스캔 후 OCR 처리
2. AI를 통한 HTML 변환 (필요시 번역 포함)
3. PDF 원본은 Paperless에 업로드
4. HTML 파일은 Document Server에서 관리
## 주요 기능
### 핵심 기능
- **사용자 인증**: 로그인 (관리자 계정 생성), JWT 기반 세션 관리
- **HTML 문서 뷰어**: 변환된 HTML 문서를 웹에서 열람
- **스마트 하이라이트**: 텍스트 선택 후 밑줄/하이라이트 표시
- **연결된 메모**: 하이라이트에 직접 메모 추가 및 편집
- **메모 관리**: 메모만 따로 보기, 검색, 정렬 기능
- **빠른 네비게이션**: 메모에서 원문 위치로 즉시 이동
- **책갈피 기능**: 페이지 북마크 및 빠른 이동
- **통합 검색**: 문서 내용 + 메모 내용 통합 검색
### 추가 기능
- **문서 관리**: HTML + PDF 원본 통합 관리 (Paperless 스타일)
- **태그 시스템**: 문서 분류 및 조직화
- **문서 업로드**: 드래그&드롭, 일괄 업로드
- **사용자 관리**: 개인별 메모, 북마크, 권한 관리
- **관리자 기능**: 사용자 생성, 문서 관리, 시스템 설정
- **문서 메타데이터**: 제목, 날짜, 카테고리, 커스텀 필드
## 기술 스택
### Backend
- **언어**: Python 3.11+
- **프레임워크**: FastAPI 0.104+
- **ORM**: SQLAlchemy 2.0+
- **데이터베이스**: PostgreSQL 15+
- **캐싱**: Redis 7+
- **비동기**: asyncio, asyncpg
- **인증**: JWT (python-jose)
- **파일 처리**: python-multipart, Pillow
- **검색**: Elasticsearch 8+ (또는 Whoosh)
### Frontend
- **기본**: HTML5, CSS3, JavaScript (ES6+)
- **CSS 프레임워크**: Tailwind CSS 3+
- **UI 컴포넌트**: Alpine.js 3+ (경량 반응형)
- **검색 UI**: Fuse.js (클라이언트 사이드 검색)
- **에디터**: Quill.js 1.3+ (메모 기능)
- **하이라이트**: Rangy.js (텍스트 선택/하이라이트)
- **아이콘**: Heroicons / Lucide
### 웹서버 & 프록시
- **리버스 프록시**: Nginx 1.24+
- **ASGI 서버**: Uvicorn 0.24+
- **정적 파일**: Nginx (직접 서빙)
### 데이터베이스 & 저장소
- **주 데이터베이스**: PostgreSQL 15+ (문서 메타데이터, 사용자 데이터)
- **전문 검색**: PostgreSQL Full-Text Search + Elasticsearch (선택)
- **캐싱**: Redis 7+ (세션, 검색 결과 캐싱)
- **파일 저장소**: 로컬 파일시스템 (향후 S3 호환 스토리지)
### 개발 도구
- **패키지 관리**: Poetry (Python 의존성)
- **코드 포맷팅**: Black, isort
- **린팅**: Flake8, mypy (타입 체킹)
- **테스팅**: pytest, pytest-asyncio
- **API 문서**: FastAPI 자동 생성 (Swagger/OpenAPI)
### 인프라 & 배포
- **컨테이너**: Docker 24+ & Docker Compose
- **배포 환경**: Mac Mini / Synology NAS
- **프로세스 관리**: Docker (컨테이너 오케스트레이션)
- **로그 관리**: Python logging + 파일 로테이션
- **모니터링**: 기본 헬스체크 (향후 Prometheus + Grafana)
### 외부 연동
- **Paperless-ngx**: REST API 연동 (원본 PDF 다운로드)
- **OCR**: Tesseract (필요시 추가 OCR 처리)
- **AI 번역**: OpenAI API / Google Translate API (선택)
## 포트 할당
- **24100**: Nginx (메인 웹서버)
- **24101**: Database (PostgreSQL/SQLite)
- **24102**: Backend API 서버
- **24103**: 추가 서비스용 예약
## 프로젝트 구조
```
document-server/
├── README.md
├── docker-compose.yml
├── nginx/
│ ├── Dockerfile
│ └── nginx.conf
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt (Python) / package.json (Node.js)
│ ├── src/
│ │ ├── main.py / app.js
│ │ ├── models/
│ │ ├── routes/
│ │ └── services/
│ └── uploads/
├── frontend/
│ ├── static/
│ │ ├── css/
│ │ ├── js/
│ │ └── assets/
│ └── templates/
├── database/
│ ├── init/
│ └── migrations/
└── docs/
└── api.md
```
## 데이터베이스 스키마 (예상)
### 주요 테이블
- **users**: 사용자 정보 (이메일, 비밀번호 해시, 권한, 생성일)
- **documents**: 문서 메타데이터 (제목, HTML/PDF 경로, 업로드자, 생성일)
- **document_tags**: 문서 태그 (다대다 관계)
- **tags**: 태그 정보 (이름, 색상, 설명)
- **highlights**: 하이라이트 정보 (사용자별, 문서별, 텍스트 범위, 색상)
- **notes**: 메모 정보 (하이라이트 연결, 메모 내용, 생성/수정일)
- **bookmarks**: 책갈피 정보 (사용자별, 문서별, 페이지 위치)
- **user_sessions**: 사용자 세션 관리 (JWT 토큰, 만료일)
- **user_preferences**: 사용자 설정 (테마, 언어, 뷰어 설정)
### 하이라이트 & 메모 스키마 상세
```sql
-- 하이라이트 테이블
highlights (
id: UUID PRIMARY KEY,
user_id: UUID REFERENCES users(id),
document_id: UUID REFERENCES documents(id),
start_offset: INTEGER, -- 텍스트 시작 위치
end_offset: INTEGER, -- 텍스트 끝 위치
selected_text: TEXT, -- 선택된 텍스트 (검색용)
highlight_color: VARCHAR(7), -- 하이라이트 색상 (#FFFF00)
element_selector: TEXT, -- DOM 요소 선택자
created_at: TIMESTAMP,
updated_at: TIMESTAMP
)
-- 메모 테이블 (하이라이트와 1:1 관계)
notes (
id: UUID PRIMARY KEY,
highlight_id: UUID REFERENCES highlights(id) ON DELETE CASCADE,
content: TEXT NOT NULL, -- 메모 내용
is_private: BOOLEAN DEFAULT true,
tags: TEXT[], -- 메모 태그
created_at: TIMESTAMP,
updated_at: TIMESTAMP
)
```
## 개발 단계
### Phase 1: 기본 구조 ✅
- [x] 프로젝트 구조 설정
- [x] Docker 환경 구성
- [x] 기본 웹서버 설정 (Nginx + FastAPI)
### Phase 2: 인증 시스템 ✅
- [x] 사용자 모델 및 데이터베이스 스키마
- [x] 로그인 API (관리자 계정 생성)
- [x] JWT 토큰 관리
- [x] 권한 미들웨어
### Phase 3: 핵심 기능 ✅
- [x] HTML 문서 뷰어 (하이라이트, 메모 기능 포함)
- [x] 문서 업로드 기능
- [x] 통합 검색 기능 (문서 + 메모)
### Phase 4: 고급 기능 ✅
- [x] 스마트 하이라이트 (텍스트 선택 → 하이라이트)
- [x] 연결된 메모 (하이라이트 ↔ 메모 1:1 연결)
- [x] 책갈피 시스템 (위치 저장 및 빠른 이동)
- [x] 메모 관리 (검색, 필터링, 태그)
- [x] 고급 검색 (문서 + 메모 통합 검색)
### Phase 5: 추가 기능 (예정)
- [ ] 문서 태그 관리 시스템
- [ ] 사용자 관리 UI
- [ ] 관리자 대시보드
- [ ] 문서 통계 및 분석
- [ ] 모바일 반응형 최적화
## 설치 및 실행
### 개발 환경
```bash
# 프로젝트 클론
git clone <repository>
cd document-server
# Docker 환경 실행
docker-compose up -d
# 개발 모드 실행
docker-compose -f docker-compose.dev.yml up
```
### 프로덕션 환경
```bash
# 프로덕션 배포
docker-compose -f docker-compose.prod.yml up -d
```
## API 엔드포인트 (예상)
### 인증 관리
- `POST /api/auth/register` - 회원가입
- `POST /api/auth/login` - 로그인
- `POST /api/auth/logout` - 로그아웃
- `POST /api/auth/refresh` - 토큰 갱신
- `GET /api/auth/me` - 현재 사용자 정보
### 사용자 관리
- `GET /api/users/profile` - 프로필 조회
- `PUT /api/users/profile` - 프로필 수정
- `PUT /api/users/password` - 비밀번호 변경
- `GET /api/users/preferences` - 사용자 설정
- `PUT /api/users/preferences` - 사용자 설정 변경
### 문서 관리
- `GET /api/documents` - 문서 목록 (사용자별 권한 적용)
- `POST /api/documents` - 문서 업로드
- `GET /api/documents/:id` - 문서 상세
- `DELETE /api/documents/:id` - 문서 삭제
### 검색
- `GET /api/search?q=keyword` - 문서 검색
- `GET /api/search/advanced` - 고급 검색
### 사용자 기능 (인증 필요)
- `POST /api/annotations` - 밑줄/하이라이트 저장
- `GET /api/annotations/:document_id` - 문서별 주석 조회
- `GET /api/bookmarks` - 책갈피 목록
- `POST /api/bookmarks` - 책갈피 추가
- `POST /api/notes` - 메모 저장
- `GET /api/notes/:document_id` - 문서별 메모 조회
### 관리자 기능
- `GET /api/admin/users` - 사용자 목록
- `PUT /api/admin/users/:id` - 사용자 권한 변경
- `GET /api/admin/documents` - 전체 문서 관리
### Paperless 연동
- `GET /api/paperless/download/:id` - 원본 PDF 다운로드
- `GET /api/paperless/sync` - Paperless 동기화
## 보안 고려사항
- 파일 업로드 검증
- XSS 방지
- CSRF 토큰
- 사용자 인증/권한
- 파일 접근 제어
## 성능 최적화
- HTML 문서 캐싱
- 검색 인덱싱
- 이미지 최적화
- CDN 활용 (필요시)
## 향후 계획
- 모바일 반응형 지원
- 다국어 지원
- 협업 기능 (공유, 댓글)
- AI 기반 문서 요약
- 문서 버전 관리

38
backend/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM python:3.11-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Poetry 설치
RUN pip install poetry
# Poetry 설정
ENV POETRY_NO_INTERACTION=1 \
POETRY_VENV_IN_PROJECT=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# 의존성 파일 복사
COPY pyproject.toml poetry.lock* ./
# 의존성 설치
RUN poetry install --only=main && rm -rf $POETRY_CACHE_DIR
# 애플리케이션 코드 복사
COPY src/ ./src/
# 업로드 디렉토리 생성
RUN mkdir -p /app/uploads
# 포트 노출
EXPOSE 8000
# 애플리케이션 실행
CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

35
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,35 @@
FROM python:3.11-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Poetry 설치
RUN pip install poetry
# Poetry 설정 (개발 모드)
ENV POETRY_NO_INTERACTION=1 \
POETRY_VENV_IN_PROJECT=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# 의존성 파일 복사
COPY pyproject.toml poetry.lock* ./
# 개발 의존성 포함하여 설치
RUN poetry install && rm -rf $POETRY_CACHE_DIR
# 업로드 디렉토리 생성
RUN mkdir -p /app/uploads
# 포트 노출
EXPOSE 8000
# 개발 모드로 실행 (핫 리로드)
CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

85
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,85 @@
[tool.poetry]
name = "document-server"
version = "0.1.0"
description = "HTML Document Management and Viewer System"
authors = ["Your Name <your.email@example.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104.0"
uvicorn = {extras = ["standard"], version = "^0.24.0"}
sqlalchemy = "^2.0.0"
asyncpg = "^0.29.0"
alembic = "^1.12.0"
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
passlib = {extras = ["bcrypt"], version = "^1.7.4"}
python-multipart = "^0.0.6"
pillow = "^10.0.0"
redis = "^5.0.0"
pydantic = {extras = ["email"], version = "^2.4.0"}
pydantic-settings = "^2.0.0"
python-dotenv = "^1.0.0"
httpx = "^0.25.0"
aiofiles = "^23.2.0"
jinja2 = "^3.1.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
pytest-asyncio = "^0.21.0"
black = "^23.9.0"
isort = "^5.12.0"
flake8 = "^6.1.0"
mypy = "^1.6.0"
pre-commit = "^3.5.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
[tool.isort]
profile = "black"
multi_line_output = 3
line_length = 88
known_first_party = ["src"]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
[[tool.mypy.overrides]]
module = [
"passlib.*",
"jose.*",
"redis.*",
]
ignore_missing_imports = true

3
backend/src/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Document Server Backend Package
"""

View File

@@ -0,0 +1,3 @@
"""
API 패키지 초기화
"""

View File

@@ -0,0 +1,88 @@
"""
API 의존성
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from src.core.database import get_db
from src.core.security import verify_token, get_user_id_from_token
from src.models.user import User
# HTTP Bearer 토큰 스키마
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""현재 로그인된 사용자 가져오기"""
try:
# 토큰에서 사용자 ID 추출
user_id = get_user_id_from_token(credentials.credentials)
# 데이터베이스에서 사용자 조회
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
return user
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""활성 사용자 확인"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
async def get_current_admin_user(
current_user: User = Depends(get_current_active_user)
) -> User:
"""관리자 권한 확인"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_optional_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> Optional[User]:
"""선택적 사용자 인증 (토큰이 없어도 됨)"""
if not credentials:
return None
try:
return await get_current_user(credentials, db)
except HTTPException:
return None

View File

@@ -0,0 +1,3 @@
"""
API 라우터 패키지 초기화
"""

View File

@@ -0,0 +1,190 @@
"""
인증 관련 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from datetime import datetime
from src.core.database import get_db
from src.core.security import verify_password, create_access_token, create_refresh_token, get_password_hash
from src.core.config import settings
from src.models.user import User
from src.schemas.auth import (
LoginRequest, TokenResponse, RefreshTokenRequest,
UserInfo, ChangePasswordRequest, CreateUserRequest
)
from src.api.dependencies import get_current_active_user, get_current_admin_user
router = APIRouter()
@router.post("/login", response_model=TokenResponse)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""사용자 로그인"""
# 사용자 조회
result = await db.execute(
select(User).where(User.email == login_data.email)
)
user = result.scalar_one_or_none()
# 사용자 존재 및 비밀번호 확인
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
# 비활성 사용자 확인
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
# 토큰 생성
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
# 마지막 로그인 시간 업데이트
await db.execute(
update(User)
.where(User.id == user.id)
.values(last_login=datetime.utcnow())
)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""토큰 갱신"""
from src.core.security import verify_token
try:
# 리프레시 토큰 검증
payload = verify_token(refresh_data.refresh_token, token_type="refresh")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# 새 토큰 생성
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
@router.get("/me", response_model=UserInfo)
async def get_current_user_info(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 정보 조회"""
return UserInfo.from_orm(current_user)
@router.put("/change-password")
async def change_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""비밀번호 변경"""
# 현재 비밀번호 확인
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# 새 비밀번호 해싱 및 업데이트
new_hashed_password = get_password_hash(password_data.new_password)
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(hashed_password=new_hashed_password)
)
await db.commit()
return {"message": "Password changed successfully"}
@router.post("/create-user", response_model=UserInfo)
async def create_user(
user_data: CreateUserRequest,
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""새 사용자 생성 (관리자 전용)"""
# 이메일 중복 확인
result = await db.execute(
select(User).where(User.email == user_data.email)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# 새 사용자 생성
new_user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
full_name=user_data.full_name,
is_admin=user_data.is_admin,
is_active=True
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserInfo.from_orm(new_user)
@router.post("/logout")
async def logout(
current_user: User = Depends(get_current_active_user)
):
"""로그아웃 (클라이언트에서 토큰 삭제)"""
# 실제로는 클라이언트에서 토큰을 삭제하면 됨
# 필요시 토큰 블랙리스트 구현 가능
return {"message": "Logged out successfully"}

View File

@@ -0,0 +1,300 @@
"""
책갈피 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import joinedload
from typing import List, Optional
from datetime import datetime
from src.core.database import get_db
from src.models.user import User
from src.models.document import Document
from src.models.bookmark import Bookmark
from src.api.dependencies import get_current_active_user
from pydantic import BaseModel
class CreateBookmarkRequest(BaseModel):
"""책갈피 생성 요청"""
document_id: str
title: str
description: Optional[str] = None
page_number: Optional[int] = None
scroll_position: int = 0
element_id: Optional[str] = None
element_selector: Optional[str] = None
class UpdateBookmarkRequest(BaseModel):
"""책갈피 업데이트 요청"""
title: Optional[str] = None
description: Optional[str] = None
page_number: Optional[int] = None
scroll_position: Optional[int] = None
element_id: Optional[str] = None
element_selector: Optional[str] = None
class BookmarkResponse(BaseModel):
"""책갈피 응답"""
id: str
document_id: str
title: str
description: Optional[str]
page_number: Optional[int]
scroll_position: int
element_id: Optional[str]
element_selector: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
document_title: str
class Config:
from_attributes = True
router = APIRouter()
@router.post("/", response_model=BookmarkResponse)
async def create_bookmark(
bookmark_data: CreateBookmarkRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 생성"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == bookmark_data.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"
)
# 책갈피 생성
bookmark = Bookmark(
user_id=current_user.id,
document_id=bookmark_data.document_id,
title=bookmark_data.title,
description=bookmark_data.description,
page_number=bookmark_data.page_number,
scroll_position=bookmark_data.scroll_position,
element_id=bookmark_data.element_id,
element_selector=bookmark_data.element_selector
)
db.add(bookmark)
await db.commit()
await db.refresh(bookmark)
# 응답 데이터 생성
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = document.title
return response_data
@router.get("/", response_model=List[BookmarkResponse])
async def list_user_bookmarks(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""사용자의 모든 책갈피 조회"""
query = (
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.user_id == current_user.id)
)
if document_id:
query = query.where(Bookmark.document_id == document_id)
query = query.order_by(Bookmark.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
bookmarks = result.scalars().all()
# 응답 데이터 변환
response_data = []
for bookmark in bookmarks:
bookmark_data = BookmarkResponse.from_orm(bookmark)
bookmark_data.document_title = bookmark.document.title
response_data.append(bookmark_data)
return response_data
@router.get("/document/{document_id}", response_model=List[BookmarkResponse])
async def get_document_bookmarks(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 문서의 책갈피 목록 조회"""
# 문서 존재 및 권한 확인
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"
)
# 사용자의 책갈피만 조회
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(
and_(
Bookmark.document_id == document_id,
Bookmark.user_id == current_user.id
)
)
.order_by(Bookmark.page_number, Bookmark.scroll_position)
)
bookmarks = result.scalars().all()
# 응답 데이터 변환
response_data = []
for bookmark in bookmarks:
bookmark_data = BookmarkResponse.from_orm(bookmark)
bookmark_data.document_title = bookmark.document.title
response_data.append(bookmark_data)
return response_data
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
async def get_bookmark(
bookmark_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 상세 조회"""
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.id == bookmark_id)
)
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = bookmark.document.title
return response_data
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
async def update_bookmark(
bookmark_id: str,
bookmark_data: UpdateBookmarkRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 업데이트"""
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.id == bookmark_id)
)
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 업데이트
if bookmark_data.title is not None:
bookmark.title = bookmark_data.title
if bookmark_data.description is not None:
bookmark.description = bookmark_data.description
if bookmark_data.page_number is not None:
bookmark.page_number = bookmark_data.page_number
if bookmark_data.scroll_position is not None:
bookmark.scroll_position = bookmark_data.scroll_position
if bookmark_data.element_id is not None:
bookmark.element_id = bookmark_data.element_id
if bookmark_data.element_selector is not None:
bookmark.element_selector = bookmark_data.element_selector
await db.commit()
await db.refresh(bookmark)
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = bookmark.document.title
return response_data
@router.delete("/{bookmark_id}")
async def delete_bookmark(
bookmark_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 삭제"""
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 책갈피 삭제
await db.execute(delete(Bookmark).where(Bookmark.id == bookmark_id))
await db.commit()
return {"message": "Bookmark deleted successfully"}

View File

@@ -0,0 +1,359 @@
"""
문서 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
import os
import uuid
import aiofiles
from pathlib import Path
from src.core.database import get_db
from src.core.config import settings
from src.models.user import User
from src.models.document import Document, Tag
from src.api.dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
from datetime import datetime
class DocumentResponse(BaseModel):
"""문서 응답"""
id: str
title: str
description: Optional[str]
html_path: str
pdf_path: Optional[str]
thumbnail_path: Optional[str]
file_size: Optional[int]
page_count: Optional[int]
language: str
is_public: bool
is_processed: bool
created_at: datetime
updated_at: Optional[datetime]
document_date: Optional[datetime]
uploader_name: Optional[str]
tags: List[str] = []
class Config:
from_attributes = True
class TagResponse(BaseModel):
"""태그 응답"""
id: str
name: str
color: str
description: Optional[str]
document_count: int = 0
class Config:
from_attributes = True
class CreateTagRequest(BaseModel):
"""태그 생성 요청"""
name: str
color: str = "#3B82F6"
description: Optional[str] = None
router = APIRouter()
@router.get("/", response_model=List[DocumentResponse])
async def list_documents(
skip: int = 0,
limit: int = 50,
tag: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 목록 조회"""
query = select(Document).options(
selectinload(Document.uploader),
selectinload(Document.tags)
)
# 권한 필터링 (관리자가 아니면 공개 문서 + 자신이 업로드한 문서만)
if not current_user.is_admin:
query = query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
# 태그 필터링
if tag:
query = query.join(Document.tags).where(Tag.name == tag)
# 검색 필터링
if search:
query = query.where(
or_(
Document.title.ilike(f"%{search}%"),
Document.description.ilike(f"%{search}%")
)
)
query = query.order_by(Document.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
documents = result.scalars().all()
# 응답 데이터 변환
response_data = []
for doc in documents:
doc_data = DocumentResponse.from_orm(doc)
doc_data.uploader_name = doc.uploader.full_name or doc.uploader.email
doc_data.tags = [tag.name for tag in doc.tags]
response_data.append(doc_data)
return response_data
@router.post("/", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
description: Optional[str] = Form(None),
document_date: Optional[str] = Form(None),
is_public: bool = Form(False),
tags: Optional[str] = Form(None), # 쉼표로 구분된 태그
html_file: UploadFile = File(...),
pdf_file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 업로드"""
# 파일 확장자 확인
if not html_file.filename.lower().endswith(('.html', '.htm')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only HTML files are allowed for the main document"
)
if pdf_file and not pdf_file.filename.lower().endswith('.pdf'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only PDF files are allowed for the original document"
)
# 고유 파일명 생성
doc_id = str(uuid.uuid4())
html_filename = f"{doc_id}.html"
pdf_filename = f"{doc_id}.pdf" if pdf_file else None
# 파일 저장 경로
html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename)
pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None
try:
# HTML 파일 저장
async with aiofiles.open(html_path, 'wb') as f:
content = await html_file.read()
await f.write(content)
# PDF 파일 저장 (있는 경우)
if pdf_file and pdf_path:
async with aiofiles.open(pdf_path, 'wb') as f:
content = await pdf_file.read()
await f.write(content)
# 문서 메타데이터 생성
document = Document(
id=doc_id,
title=title,
description=description,
html_path=html_path,
pdf_path=pdf_path,
file_size=len(await html_file.read()) if html_file else None,
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
)
db.add(document)
await db.flush() # ID 생성을 위해
# 태그 처리
if tags:
tag_names = [tag.strip() for tag in tags.split(',') if tag.strip()]
for tag_name in tag_names:
# 기존 태그 찾기 또는 생성
result = await db.execute(select(Tag).where(Tag.name == tag_name))
tag = result.scalar_one_or_none()
if not tag:
tag = Tag(
name=tag_name,
created_by=current_user.id
)
db.add(tag)
await db.flush()
document.tags.append(tag)
await db.commit()
await db.refresh(document)
# 응답 데이터 생성
response_data = DocumentResponse.from_orm(document)
response_data.uploader_name = current_user.full_name or current_user.email
response_data.tags = [tag.name for tag in document.tags]
return response_data
except Exception as e:
# 파일 정리
if os.path.exists(html_path):
os.remove(html_path)
if pdf_path and os.path.exists(pdf_path):
os.remove(pdf_path)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upload document: {str(e)}"
)
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_document(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 상세 조회"""
result = await db.execute(
select(Document)
.options(selectinload(Document.uploader), selectinload(Document.tags))
.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"
)
response_data = DocumentResponse.from_orm(document)
response_data.uploader_name = document.uploader.full_name or document.uploader.email
response_data.tags = [tag.name for tag in document.tags]
return response_data
@router.delete("/{document_id}")
async def delete_document(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 삭제"""
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 document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 파일 삭제
if document.html_path and os.path.exists(document.html_path):
os.remove(document.html_path)
if document.pdf_path and os.path.exists(document.pdf_path):
os.remove(document.pdf_path)
if document.thumbnail_path and os.path.exists(document.thumbnail_path):
os.remove(document.thumbnail_path)
# 데이터베이스에서 삭제
await db.execute(delete(Document).where(Document.id == document_id))
await db.commit()
return {"message": "Document deleted successfully"}
@router.get("/tags/", response_model=List[TagResponse])
async def list_tags(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""태그 목록 조회"""
result = await db.execute(select(Tag).order_by(Tag.name))
tags = result.scalars().all()
# 각 태그의 문서 수 계산
response_data = []
for tag in tags:
tag_data = TagResponse.from_orm(tag)
# 문서 수 계산 (권한 고려)
doc_query = select(Document).join(Document.tags).where(Tag.id == tag.id)
if not current_user.is_admin:
doc_query = doc_query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
doc_result = await db.execute(doc_query)
tag_data.document_count = len(doc_result.scalars().all())
response_data.append(tag_data)
return response_data
@router.post("/tags/", response_model=TagResponse)
async def create_tag(
tag_data: CreateTagRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""태그 생성"""
# 중복 확인
result = await db.execute(select(Tag).where(Tag.name == tag_data.name))
existing_tag = result.scalar_one_or_none()
if existing_tag:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tag already exists"
)
# 태그 생성
tag = Tag(
name=tag_data.name,
color=tag_data.color,
description=tag_data.description,
created_by=current_user.id
)
db.add(tag)
await db.commit()
await db.refresh(tag)
response_data = TagResponse.from_orm(tag)
response_data.document_count = 0
return response_data

View File

@@ -0,0 +1,340 @@
"""
하이라이트 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime
from src.core.database import get_db
from src.models.user import User
from src.models.document import Document
from src.models.highlight import Highlight
from src.models.note import Note
from src.api.dependencies import get_current_active_user
from pydantic import BaseModel
class CreateHighlightRequest(BaseModel):
"""하이라이트 생성 요청"""
document_id: str
start_offset: int
end_offset: int
selected_text: str
element_selector: Optional[str] = None
start_container_xpath: Optional[str] = None
end_container_xpath: Optional[str] = None
highlight_color: str = "#FFFF00"
highlight_type: str = "highlight"
note_content: Optional[str] = None # 바로 메모 추가
class UpdateHighlightRequest(BaseModel):
"""하이라이트 업데이트 요청"""
highlight_color: Optional[str] = None
highlight_type: Optional[str] = None
class HighlightResponse(BaseModel):
"""하이라이트 응답"""
id: str
document_id: str
start_offset: int
end_offset: int
selected_text: str
element_selector: Optional[str]
start_container_xpath: Optional[str]
end_container_xpath: Optional[str]
highlight_color: str
highlight_type: str
created_at: datetime
updated_at: Optional[datetime]
note: Optional[dict] = None # 연결된 메모 정보
class Config:
from_attributes = True
router = APIRouter()
@router.post("/", response_model=HighlightResponse)
async def create_highlight(
highlight_data: CreateHighlightRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 생성 (메모 포함 가능)"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == highlight_data.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"
)
# 하이라이트 생성
highlight = Highlight(
user_id=current_user.id,
document_id=highlight_data.document_id,
start_offset=highlight_data.start_offset,
end_offset=highlight_data.end_offset,
selected_text=highlight_data.selected_text,
element_selector=highlight_data.element_selector,
start_container_xpath=highlight_data.start_container_xpath,
end_container_xpath=highlight_data.end_container_xpath,
highlight_color=highlight_data.highlight_color,
highlight_type=highlight_data.highlight_type
)
db.add(highlight)
await db.flush() # ID 생성을 위해
# 메모가 있으면 함께 생성
note = None
if highlight_data.note_content:
note = Note(
highlight_id=highlight.id,
content=highlight_data.note_content
)
db.add(note)
await db.commit()
await db.refresh(highlight)
# 응답 데이터 생성
response_data = HighlightResponse.from_orm(highlight)
if note:
response_data.note = {
"id": str(note.id),
"content": note.content,
"tags": note.tags,
"created_at": note.created_at.isoformat(),
"updated_at": note.updated_at.isoformat() if note.updated_at else None
}
return response_data
@router.get("/document/{document_id}", response_model=List[HighlightResponse])
async def get_document_highlights(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 문서의 하이라이트 목록 조회"""
# 문서 존재 및 권한 확인
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"
)
# 사용자의 하이라이트만 조회
result = await db.execute(
select(Highlight)
.options(selectinload(Highlight.note))
.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.from_orm(highlight)
if highlight.note:
highlight_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
}
response_data.append(highlight_data)
return response_data
@router.get("/{highlight_id}", response_model=HighlightResponse)
async def get_highlight(
highlight_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 상세 조회"""
result = await db.execute(
select(Highlight)
.options(selectinload(Highlight.note))
.where(Highlight.id == highlight_id)
)
highlight = result.scalar_one_or_none()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
response_data = HighlightResponse.from_orm(highlight)
if highlight.note:
response_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
}
return response_data
@router.put("/{highlight_id}", response_model=HighlightResponse)
async def update_highlight(
highlight_id: str,
highlight_data: UpdateHighlightRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 업데이트"""
result = await db.execute(
select(Highlight)
.options(selectinload(Highlight.note))
.where(Highlight.id == highlight_id)
)
highlight = result.scalar_one_or_none()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 업데이트
if highlight_data.highlight_color:
highlight.highlight_color = highlight_data.highlight_color
if highlight_data.highlight_type:
highlight.highlight_type = highlight_data.highlight_type
await db.commit()
await db.refresh(highlight)
response_data = HighlightResponse.from_orm(highlight)
if highlight.note:
response_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
}
return response_data
@router.delete("/{highlight_id}")
async def delete_highlight(
highlight_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 삭제 (연결된 메모도 함께 삭제)"""
result = await db.execute(select(Highlight).where(Highlight.id == highlight_id))
highlight = result.scalar_one_or_none()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 하이라이트 삭제 (CASCADE로 메모도 함께 삭제됨)
await db.execute(delete(Highlight).where(Highlight.id == highlight_id))
await db.commit()
return {"message": "Highlight deleted successfully"}
@router.get("/", response_model=List[HighlightResponse])
async def list_user_highlights(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""사용자의 모든 하이라이트 조회"""
query = select(Highlight).options(selectinload(Highlight.note)).where(
Highlight.user_id == current_user.id
)
if document_id:
query = query.where(Highlight.document_id == document_id)
query = query.order_by(Highlight.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
highlights = result.scalars().all()
# 응답 데이터 변환
response_data = []
for highlight in highlights:
highlight_data = HighlightResponse.from_orm(highlight)
if highlight.note:
highlight_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
}
response_data.append(highlight_data)
return response_data

View File

@@ -0,0 +1,404 @@
"""
메모 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_
from sqlalchemy.orm import selectinload, joinedload
from typing import List, Optional
from datetime import datetime
from src.core.database import get_db
from src.models.user import User
from src.models.highlight import Highlight
from src.models.note import Note
from src.models.document import Document
from src.api.dependencies import get_current_active_user
from pydantic import BaseModel
class CreateNoteRequest(BaseModel):
"""메모 생성 요청"""
highlight_id: str
content: str
tags: Optional[List[str]] = None
class UpdateNoteRequest(BaseModel):
"""메모 업데이트 요청"""
content: Optional[str] = None
tags: Optional[List[str]] = None
is_private: Optional[bool] = None
class NoteResponse(BaseModel):
"""메모 응답"""
id: str
highlight_id: str
content: str
is_private: bool
tags: Optional[List[str]]
created_at: datetime
updated_at: Optional[datetime]
# 연결된 하이라이트 정보
highlight: dict
# 문서 정보
document: dict
class Config:
from_attributes = True
router = APIRouter()
@router.post("/", response_model=NoteResponse)
async def create_note(
note_data: CreateNoteRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 생성 (하이라이트에 연결)"""
# 하이라이트 존재 및 소유권 확인
result = await db.execute(
select(Highlight)
.options(joinedload(Highlight.document))
.where(Highlight.id == note_data.highlight_id)
)
highlight = result.scalar_one_or_none()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 하이라이트 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 이미 메모가 있는지 확인
existing_note = await db.execute(
select(Note).where(Note.highlight_id == note_data.highlight_id)
)
if existing_note.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Note already exists for this highlight"
)
# 메모 생성
note = Note(
highlight_id=note_data.highlight_id,
content=note_data.content,
tags=note_data.tags or []
)
db.add(note)
await db.commit()
await db.refresh(note)
# 응답 데이터 생성
response_data = NoteResponse.from_orm(note)
response_data.highlight = {
"id": str(highlight.id),
"selected_text": highlight.selected_text,
"highlight_color": highlight.highlight_color,
"start_offset": highlight.start_offset,
"end_offset": highlight.end_offset
}
response_data.document = {
"id": str(highlight.document.id),
"title": highlight.document.title
}
return response_data
@router.get("/", response_model=List[NoteResponse])
async def list_user_notes(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""사용자의 모든 메모 조회 (검색 가능)"""
query = (
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
# 문서 필터링
if document_id:
query = query.where(Highlight.document_id == document_id)
# 태그 필터링
if tag:
query = query.where(Note.tags.contains([tag]))
# 검색 필터링 (메모 내용 + 하이라이트된 텍스트)
if search:
query = query.where(
or_(
Note.content.ilike(f"%{search}%"),
Highlight.selected_text.ilike(f"%{search}%")
)
)
query = query.order_by(Note.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
notes = result.scalars().all()
# 응답 데이터 변환
response_data = []
for note in notes:
note_data = NoteResponse.from_orm(note)
note_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
note_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
response_data.append(note_data)
return response_data
@router.get("/{note_id}", response_model=NoteResponse)
async def get_note(
note_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 상세 조회"""
result = await db.execute(
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# 소유자 확인
if note.highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
response_data = NoteResponse.from_orm(note)
response_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
response_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
return response_data
@router.put("/{note_id}", response_model=NoteResponse)
async def update_note(
note_id: str,
note_data: UpdateNoteRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 업데이트"""
result = await db.execute(
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# 소유자 확인
if note.highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 업데이트
if note_data.content is not None:
note.content = note_data.content
if note_data.tags is not None:
note.tags = note_data.tags
if note_data.is_private is not None:
note.is_private = note_data.is_private
await db.commit()
await db.refresh(note)
response_data = NoteResponse.from_orm(note)
response_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
response_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
return response_data
@router.delete("/{note_id}")
async def delete_note(
note_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 삭제 (하이라이트는 유지)"""
result = await db.execute(
select(Note)
.options(joinedload(Note.highlight))
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# 소유자 확인
if note.highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 메모만 삭제 (하이라이트는 유지)
await db.execute(delete(Note).where(Note.id == note_id))
await db.commit()
return {"message": "Note deleted successfully"}
@router.get("/document/{document_id}", response_model=List[NoteResponse])
async def get_document_notes(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 문서의 모든 메모 조회"""
# 문서 존재 및 권한 확인
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"
)
# 해당 문서의 사용자 메모 조회
result = await db.execute(
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(
and_(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
)
)
.order_by(Highlight.start_offset)
)
notes = result.scalars().all()
# 응답 데이터 변환
response_data = []
for note in notes:
note_data = NoteResponse.from_orm(note)
note_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
note_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
response_data.append(note_data)
return response_data
@router.get("/tags/popular")
async def get_popular_note_tags(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""인기 메모 태그 조회"""
# 사용자의 메모에서 태그 빈도 계산
result = await db.execute(
select(Note)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
notes = result.scalars().all()
# 태그 빈도 계산
tag_counts = {}
for note in notes:
if note.tags:
for tag in note.tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
# 빈도순 정렬
popular_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10]
return [{"tag": tag, "count": count} for tag, count in popular_tags]

View File

@@ -0,0 +1,354 @@
"""
검색 API 라우터
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, and_, text
from sqlalchemy.orm import joinedload, selectinload
from typing import List, Optional, Dict, Any
from datetime import datetime
from src.core.database import get_db
from src.models.user import User
from src.models.document import Document, Tag
from src.models.highlight import Highlight
from src.models.note import Note
from src.api.dependencies import get_current_active_user
from pydantic import BaseModel
class SearchResult(BaseModel):
"""검색 결과"""
type: str # "document", "note", "highlight"
id: str
title: str
content: str
document_id: str
document_title: str
created_at: datetime
relevance_score: float = 0.0
highlight_info: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class SearchResponse(BaseModel):
"""검색 응답"""
query: str
total_results: int
results: List[SearchResult]
facets: Dict[str, List[Dict[str, Any]]] = {}
router = APIRouter()
@router.get("/", response_model=SearchResponse)
async def search_all(
q: str = Query(..., description="검색어"),
type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, highlight"),
document_id: Optional[str] = Query(None, description="특정 문서 내 검색"),
tag: Optional[str] = Query(None, description="태그 필터"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""통합 검색 (문서 + 메모 + 하이라이트)"""
results = []
# 1. 문서 검색
if not type_filter or type_filter == "document":
document_results = await search_documents(q, document_id, tag, current_user, db)
results.extend(document_results)
# 2. 메모 검색
if not type_filter or type_filter == "note":
note_results = await search_notes(q, document_id, tag, current_user, db)
results.extend(note_results)
# 3. 하이라이트 검색
if not type_filter or type_filter == "highlight":
highlight_results = await search_highlights(q, document_id, current_user, db)
results.extend(highlight_results)
# 관련성 점수로 정렬
results.sort(key=lambda x: x.relevance_score, reverse=True)
# 페이지네이션
total_results = len(results)
paginated_results = results[skip:skip + limit]
# 패싯 정보 생성
facets = await generate_search_facets(results, current_user, db)
return SearchResponse(
query=q,
total_results=total_results,
results=paginated_results,
facets=facets
)
async def search_documents(
query: str,
document_id: Optional[str],
tag: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""문서 검색"""
query_obj = select(Document).options(
selectinload(Document.uploader),
selectinload(Document.tags)
)
# 권한 필터링
if not current_user.is_admin:
query_obj = query_obj.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
# 특정 문서 필터
if document_id:
query_obj = query_obj.where(Document.id == document_id)
# 태그 필터
if tag:
query_obj = query_obj.join(Document.tags).where(Tag.name == tag)
# 텍스트 검색
search_condition = or_(
Document.title.ilike(f"%{query}%"),
Document.description.ilike(f"%{query}%")
)
query_obj = query_obj.where(search_condition)
result = await db.execute(query_obj)
documents = result.scalars().all()
search_results = []
for doc in documents:
# 관련성 점수 계산 (제목 매치가 더 높은 점수)
score = 0.0
if query.lower() in doc.title.lower():
score += 2.0
if doc.description and query.lower() in doc.description.lower():
score += 1.0
search_results.append(SearchResult(
type="document",
id=str(doc.id),
title=doc.title,
content=doc.description or "",
document_id=str(doc.id),
document_title=doc.title,
created_at=doc.created_at,
relevance_score=score
))
return search_results
async def search_notes(
query: str,
document_id: Optional[str],
tag: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""메모 검색"""
query_obj = (
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
# 특정 문서 필터
if document_id:
query_obj = query_obj.where(Highlight.document_id == document_id)
# 태그 필터
if tag:
query_obj = query_obj.where(Note.tags.contains([tag]))
# 텍스트 검색 (메모 내용 + 하이라이트된 텍스트)
search_condition = or_(
Note.content.ilike(f"%{query}%"),
Highlight.selected_text.ilike(f"%{query}%")
)
query_obj = query_obj.where(search_condition)
result = await db.execute(query_obj)
notes = result.scalars().all()
search_results = []
for note in notes:
# 관련성 점수 계산
score = 0.0
if query.lower() in note.content.lower():
score += 2.0
if query.lower() in note.highlight.selected_text.lower():
score += 1.5
search_results.append(SearchResult(
type="note",
id=str(note.id),
title=f"메모: {note.highlight.selected_text[:50]}...",
content=note.content,
document_id=str(note.highlight.document.id),
document_title=note.highlight.document.title,
created_at=note.created_at,
relevance_score=score,
highlight_info={
"highlight_id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
))
return search_results
async def search_highlights(
query: str,
document_id: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""하이라이트 검색"""
query_obj = (
select(Highlight)
.options(joinedload(Highlight.document))
.where(Highlight.user_id == current_user.id)
)
# 특정 문서 필터
if document_id:
query_obj = query_obj.where(Highlight.document_id == document_id)
# 텍스트 검색
query_obj = query_obj.where(Highlight.selected_text.ilike(f"%{query}%"))
result = await db.execute(query_obj)
highlights = result.scalars().all()
search_results = []
for highlight in highlights:
# 관련성 점수 계산
score = 1.0 if query.lower() in highlight.selected_text.lower() else 0.5
search_results.append(SearchResult(
type="highlight",
id=str(highlight.id),
title=f"하이라이트: {highlight.selected_text[:50]}...",
content=highlight.selected_text,
document_id=str(highlight.document.id),
document_title=highlight.document.title,
created_at=highlight.created_at,
relevance_score=score,
highlight_info={
"highlight_id": str(highlight.id),
"selected_text": highlight.selected_text,
"start_offset": highlight.start_offset,
"end_offset": highlight.end_offset,
"highlight_color": highlight.highlight_color
}
))
return search_results
async def generate_search_facets(
results: List[SearchResult],
current_user: User,
db: AsyncSession
) -> Dict[str, List[Dict[str, Any]]]:
"""검색 결과 패싯 생성"""
facets = {}
# 타입별 개수
type_counts = {}
for result in results:
type_counts[result.type] = type_counts.get(result.type, 0) + 1
facets["types"] = [
{"name": type_name, "count": count}
for type_name, count in type_counts.items()
]
# 문서별 개수
document_counts = {}
for result in results:
doc_title = result.document_title
document_counts[doc_title] = document_counts.get(doc_title, 0) + 1
facets["documents"] = [
{"name": doc_title, "count": count}
for doc_title, count in sorted(document_counts.items(), key=lambda x: x[1], reverse=True)[:10]
]
return facets
@router.get("/suggestions")
async def get_search_suggestions(
q: str = Query(..., min_length=2, description="검색어 (최소 2글자)"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""검색어 자동완성 제안"""
suggestions = []
# 문서 제목에서 제안
doc_result = await db.execute(
select(Document.title)
.where(
and_(
Document.title.ilike(f"%{q}%"),
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
) if not current_user.is_admin else text("true")
)
)
.limit(5)
)
doc_titles = doc_result.scalars().all()
suggestions.extend([{"text": title, "type": "document"} for title in doc_titles])
# 태그에서 제안
tag_result = await db.execute(
select(Tag.name)
.where(Tag.name.ilike(f"%{q}%"))
.limit(5)
)
tag_names = tag_result.scalars().all()
suggestions.extend([{"text": name, "type": "tag"} for name in tag_names])
# 메모 태그에서 제안
note_result = await db.execute(
select(Note.tags)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
notes = note_result.scalars().all()
note_tags = set()
for note in notes:
if note and isinstance(note, list):
for tag in note:
if q.lower() in tag.lower():
note_tags.add(tag)
suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]])
return {"suggestions": suggestions[:10]}

View File

@@ -0,0 +1,176 @@
"""
사용자 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from typing import List
from src.core.database import get_db
from src.models.user import User
from src.schemas.auth import UserInfo
from src.api.dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
class UpdateProfileRequest(BaseModel):
"""프로필 업데이트 요청"""
full_name: str = None
theme: str = None
language: str = None
timezone: str = None
class UpdateUserRequest(BaseModel):
"""사용자 정보 업데이트 요청 (관리자용)"""
full_name: str = None
is_active: bool = None
is_admin: bool = None
router = APIRouter()
@router.get("/profile", response_model=UserInfo)
async def get_profile(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 프로필 조회"""
return UserInfo.from_orm(current_user)
@router.put("/profile", response_model=UserInfo)
async def update_profile(
profile_data: UpdateProfileRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""프로필 업데이트"""
update_data = {}
if profile_data.full_name is not None:
update_data["full_name"] = profile_data.full_name
if profile_data.theme is not None:
update_data["theme"] = profile_data.theme
if profile_data.language is not None:
update_data["language"] = profile_data.language
if profile_data.timezone is not None:
update_data["timezone"] = profile_data.timezone
if update_data:
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(**update_data)
)
await db.commit()
await db.refresh(current_user)
return UserInfo.from_orm(current_user)
@router.get("/", response_model=List[UserInfo])
async def list_users(
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 목록 조회 (관리자 전용)"""
result = await db.execute(select(User).order_by(User.created_at.desc()))
users = result.scalars().all()
return [UserInfo.from_orm(user) for user in users]
@router.get("/{user_id}", response_model=UserInfo)
async def get_user(
user_id: str,
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""특정 사용자 조회 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return UserInfo.from_orm(user)
@router.put("/{user_id}", response_model=UserInfo)
async def update_user(
user_id: str,
user_data: UpdateUserRequest,
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 정보 업데이트 (관리자 전용)"""
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# 자기 자신의 관리자 권한은 제거할 수 없음
if user.id == admin_user.id and user_data.is_admin is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove admin privileges from yourself"
)
# 업데이트할 데이터 준비
update_data = {}
if user_data.full_name is not None:
update_data["full_name"] = user_data.full_name
if user_data.is_active is not None:
update_data["is_active"] = user_data.is_active
if user_data.is_admin is not None:
update_data["is_admin"] = user_data.is_admin
if update_data:
await db.execute(
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.commit()
await db.refresh(user)
return UserInfo.from_orm(user)
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 삭제 (관리자 전용)"""
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# 자기 자신은 삭제할 수 없음
if user.id == admin_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete yourself"
)
# 사용자 삭제
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
return {"message": "User deleted successfully"}

View File

@@ -0,0 +1,52 @@
"""
애플리케이션 설정
"""
from pydantic_settings import BaseSettings
from typing import List
import os
class Settings(BaseSettings):
"""애플리케이션 설정 클래스"""
# 기본 설정
APP_NAME: str = "Document Server"
DEBUG: bool = True
VERSION: str = "0.1.0"
# 데이터베이스 설정
DATABASE_URL: str = "postgresql+asyncpg://docuser:docpass@localhost:24101/document_db"
# Redis 설정
REDIS_URL: str = "redis://localhost:24103/0"
# JWT 설정
SECRET_KEY: str = "your-secret-key-change-this-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS 설정
ALLOWED_HOSTS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"]
# 파일 업로드 설정
UPLOAD_DIR: str = "uploads"
MAX_FILE_SIZE: int = 100 * 1024 * 1024 # 100MB
ALLOWED_EXTENSIONS: List[str] = [".html", ".htm", ".pdf"]
# 관리자 계정 설정 (초기 설정용)
ADMIN_EMAIL: str = "admin@document-server.local"
ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변경
class Config:
env_file = ".env"
case_sensitive = True
# 설정 인스턴스 생성
settings = Settings()
# 업로드 디렉토리 생성
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
os.makedirs(f"{settings.UPLOAD_DIR}/documents", exist_ok=True)
os.makedirs(f"{settings.UPLOAD_DIR}/thumbnails", exist_ok=True)

View File

@@ -0,0 +1,94 @@
"""
데이터베이스 설정 및 연결
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import MetaData
from typing import AsyncGenerator
from src.core.config import settings
# SQLAlchemy 메타데이터 설정
metadata = MetaData(
naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
)
class Base(DeclarativeBase):
"""SQLAlchemy Base 클래스"""
metadata = metadata
# 비동기 데이터베이스 엔진 생성
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
pool_pre_ping=True,
pool_recycle=300,
)
# 비동기 세션 팩토리
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""데이터베이스 세션 의존성"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""데이터베이스 초기화"""
from src.models import user, document, highlight, note, bookmark, tag
async with engine.begin() as conn:
# 모든 테이블 생성
await conn.run_sync(Base.metadata.create_all)
# 관리자 계정 생성
await create_admin_user()
async def create_admin_user() -> None:
"""관리자 계정 생성 (존재하지 않을 경우)"""
from src.models.user import User
from src.core.security import get_password_hash
from sqlalchemy import select
async with AsyncSessionLocal() as session:
# 관리자 계정 존재 확인
result = await session.execute(
select(User).where(User.email == settings.ADMIN_EMAIL)
)
admin_user = result.scalar_one_or_none()
if not admin_user:
# 관리자 계정 생성
admin_user = User(
email=settings.ADMIN_EMAIL,
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
is_active=True,
is_admin=True,
full_name="Administrator"
)
session.add(admin_user)
await session.commit()
print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_EMAIL}")

View File

@@ -0,0 +1,87 @@
"""
보안 관련 유틸리티
"""
from datetime import datetime, timedelta
from typing import Optional, Union
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from src.core.config import settings
# 비밀번호 해싱 컨텍스트
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""비밀번호 검증"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""비밀번호 해싱"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""액세스 토큰 생성"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""리프레시 토큰 생성"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_token(token: str, token_type: str = "access") -> dict:
"""토큰 검증"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# 토큰 타입 확인
if payload.get("type") != token_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
# 만료 시간 확인
exp = payload.get("exp")
if exp is None or datetime.utcnow() > datetime.fromtimestamp(exp):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired"
)
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
def get_user_id_from_token(token: str) -> str:
"""토큰에서 사용자 ID 추출"""
payload = verify_token(token)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
return user_id

72
backend/src/main.py Normal file
View File

@@ -0,0 +1,72 @@
"""
Document Server - FastAPI Main Application
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import uvicorn
from src.core.config import settings
from src.core.database import init_db
from src.api.routes import auth, users, documents, highlights, notes, bookmarks, search
@asynccontextmanager
async def lifespan(app: FastAPI):
"""애플리케이션 시작/종료 시 실행되는 함수"""
# 시작 시 데이터베이스 초기화
await init_db()
yield
# 종료 시 정리 작업 (필요시)
# FastAPI 앱 생성
app = FastAPI(
title="Document Server",
description="HTML Document Management and Viewer System",
version="0.1.0",
lifespan=lifespan,
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_HOSTS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 정적 파일 서빙 (업로드된 파일들)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# API 라우터 등록
app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
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(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
app.include_router(search.router, prefix="/api/search", tags=["검색"])
@app.get("/")
async def root():
"""루트 엔드포인트"""
return {"message": "Document Server API", "version": "0.1.0"}
@app.get("/health")
async def health_check():
"""헬스체크 엔드포인트"""
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run(
"src.main:app",
host="0.0.0.0",
port=8000,
reload=True if settings.DEBUG else False,
)

View File

@@ -0,0 +1,17 @@
"""
모델 패키지 초기화
"""
from src.models.user import User
from src.models.document import Document, Tag
from src.models.highlight import Highlight
from src.models.note import Note
from src.models.bookmark import Bookmark
__all__ = [
"User",
"Document",
"Tag",
"Highlight",
"Note",
"Bookmark",
]

View File

@@ -0,0 +1,42 @@
"""
책갈피 모델
"""
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 src.core.database import Base
class Bookmark(Base):
"""책갈피 테이블"""
__tablename__ = "bookmarks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 연결 정보
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
# 책갈피 정보
title = Column(String(200), nullable=False) # 책갈피 제목
description = Column(Text, nullable=True) # 설명
# 위치 정보
page_number = Column(Integer, nullable=True) # 페이지 번호 (추정)
scroll_position = Column(Integer, default=0) # 스크롤 위치 (픽셀)
element_id = Column(String(100), nullable=True) # 특정 요소 ID
element_selector = Column(Text, nullable=True) # CSS 선택자
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
user = relationship("User", backref="bookmarks")
document = relationship("Document", back_populates="bookmarks")
def __repr__(self):
return f"<Bookmark(title='{self.title}', document='{self.document_id}')>"

View File

@@ -0,0 +1,81 @@
"""
문서 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Integer, Boolean, ForeignKey, Table
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
# 문서-태그 다대다 관계 테이블
document_tags = Table(
'document_tags',
Base.metadata,
Column('document_id', UUID(as_uuid=True), ForeignKey('documents.id'), primary_key=True),
Column('tag_id', UUID(as_uuid=True), ForeignKey('tags.id'), primary_key=True)
)
class Document(Base):
"""문서 테이블"""
__tablename__ = "documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(500), nullable=False, index=True)
description = Column(Text, nullable=True)
# 파일 정보
html_path = Column(String(1000), nullable=False) # HTML 파일 경로
pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택)
thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로
# 메타데이터
file_size = Column(Integer, nullable=True) # 바이트 단위
page_count = Column(Integer, nullable=True) # 페이지 수 (추정)
language = Column(String(10), default="ko") # 문서 언어
# 업로드 정보
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
original_filename = Column(String(500), nullable=True)
# 상태
is_public = Column(Boolean, default=False) # 공개 여부
is_processed = Column(Boolean, default=True) # 처리 완료 여부
# 시간 정보
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
document_date = Column(DateTime(timezone=True), nullable=True) # 문서 작성일 (사용자 입력)
# 관계
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")
bookmarks = relationship("Bookmark", back_populates="document", cascade="all, delete-orphan")
def __repr__(self):
return f"<Document(title='{self.title}', id='{self.id}')>"
class Tag(Base):
"""태그 테이블"""
__tablename__ = "tags"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), unique=True, nullable=False, index=True)
color = Column(String(7), default="#3B82F6") # HEX 색상 코드
description = Column(Text, nullable=True)
# 메타데이터
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# 관계
creator = relationship("User", backref="created_tags")
documents = relationship("Document", secondary=document_tags, back_populates="tags")
def __repr__(self):
return f"<Tag(name='{self.name}', color='{self.color}')>"

View File

@@ -0,0 +1,47 @@
"""
하이라이트 모델
"""
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 src.core.database import Base
class Highlight(Base):
"""하이라이트 테이블"""
__tablename__ = "highlights"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 연결 정보
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
# 텍스트 위치 정보
start_offset = Column(Integer, nullable=False) # 시작 위치
end_offset = Column(Integer, nullable=False) # 끝 위치
selected_text = Column(Text, nullable=False) # 선택된 텍스트 (검색용)
# DOM 위치 정보 (정확한 복원을 위해)
element_selector = Column(Text, nullable=True) # CSS 선택자
start_container_xpath = Column(Text, nullable=True) # 시작 컨테이너 XPath
end_container_xpath = Column(Text, nullable=True) # 끝 컨테이너 XPath
# 스타일 정보
highlight_color = Column(String(7), default="#FFFF00") # HEX 색상 코드
highlight_type = Column(String(20), default="highlight") # highlight, underline, etc.
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
user = relationship("User", backref="highlights")
document = relationship("Document", back_populates="highlights")
note = relationship("Note", back_populates="highlight", uselist=False, cascade="all, delete-orphan")
def __repr__(self):
return f"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -0,0 +1,47 @@
"""
메모 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
class Note(Base):
"""메모 테이블 (하이라이트와 1:1 관계)"""
__tablename__ = "notes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 연결 정보
highlight_id = Column(UUID(as_uuid=True), ForeignKey("highlights.id"), nullable=False, unique=True)
# 메모 내용
content = Column(Text, nullable=False)
is_private = Column(Boolean, default=True) # 개인 메모 여부
# 태그 (메모 분류용)
tags = Column(ARRAY(String), nullable=True) # ["중요", "질문", "아이디어"]
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
highlight = relationship("Highlight", back_populates="note")
@property
def user_id(self):
"""하이라이트를 통해 사용자 ID 가져오기"""
return self.highlight.user_id if self.highlight else None
@property
def document_id(self):
"""하이라이트를 통해 문서 ID 가져오기"""
return self.highlight.document_id if self.highlight else None
def __repr__(self):
return f"<Note(id='{self.id}', content='{self.content[:50]}...')>"

View File

@@ -0,0 +1,34 @@
"""
사용자 모델
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
class User(Base):
"""사용자 테이블"""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
last_login = Column(DateTime(timezone=True), nullable=True)
# 사용자 설정
theme = Column(String(50), default="light") # light, dark
language = Column(String(10), default="ko") # ko, en
timezone = Column(String(50), default="Asia/Seoul")
def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>"

View File

@@ -0,0 +1,56 @@
"""
인증 관련 스키마
"""
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class LoginRequest(BaseModel):
"""로그인 요청"""
email: EmailStr
password: str
class TokenResponse(BaseModel):
"""토큰 응답"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int # 초 단위
class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청"""
refresh_token: str
class UserInfo(BaseModel):
"""사용자 정보"""
id: str
email: str
full_name: Optional[str] = None
is_active: bool
is_admin: bool
theme: str
language: str
timezone: str
created_at: datetime
last_login: Optional[datetime] = None
class Config:
from_attributes = True
class ChangePasswordRequest(BaseModel):
"""비밀번호 변경 요청"""
current_password: str
new_password: str
class CreateUserRequest(BaseModel):
"""사용자 생성 요청 (관리자용)"""
email: EmailStr
password: str
full_name: Optional[str] = None
is_admin: bool = False

68
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,68 @@
version: '3.8'
services:
# 개발용 Nginx
nginx:
build: ./nginx
container_name: document-server-nginx-dev
ports:
- "24100:80"
volumes:
- ./frontend:/usr/share/nginx/html
- ./uploads:/usr/share/nginx/html/uploads
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf
depends_on:
- backend
networks:
- document-network
# 개발용 Backend (핫 리로드)
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
container_name: document-server-backend-dev
ports:
- "24102:8000"
volumes:
- ./uploads:/app/uploads
- ./backend:/app
environment:
- DATABASE_URL=postgresql://docuser:docpass@database:5432/document_db
- PAPERLESS_URL=${PAPERLESS_URL:-http://localhost:8000}
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN:-}
- DEBUG=true
- RELOAD=true
depends_on:
- database
networks:
- document-network
# 개발용 데이터베이스 (데이터 영속성 없음)
database:
image: postgres:15-alpine
container_name: document-server-db-dev
ports:
- "24101:5432"
environment:
- POSTGRES_DB=document_db
- POSTGRES_USER=docuser
- POSTGRES_PASSWORD=docpass
volumes:
- ./database/init:/docker-entrypoint-initdb.d
networks:
- document-network
# 개발용 Redis
redis:
image: redis:7-alpine
container_name: document-server-redis-dev
ports:
- "24103:6379"
networks:
- document-network
command: redis-server
networks:
document-network:
driver: bridge

75
docker-compose.yml Normal file
View File

@@ -0,0 +1,75 @@
version: '3.8'
services:
# Nginx 리버스 프록시
nginx:
build: ./nginx
container_name: document-server-nginx
ports:
- "24100:80"
volumes:
- ./frontend:/usr/share/nginx/html
- ./uploads:/usr/share/nginx/html/uploads
depends_on:
- backend
networks:
- document-network
restart: unless-stopped
# Backend API 서버
backend:
build: ./backend
container_name: document-server-backend
ports:
- "24102:8000"
volumes:
- ./uploads:/app/uploads
- ./backend/src:/app/src
environment:
- DATABASE_URL=postgresql://docuser:docpass@database:5432/document_db
- PAPERLESS_URL=${PAPERLESS_URL:-http://localhost:8000}
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN:-}
- DEBUG=true
depends_on:
- database
networks:
- document-network
restart: unless-stopped
# PostgreSQL 데이터베이스
database:
image: postgres:15-alpine
container_name: document-server-db
ports:
- "24101:5432"
environment:
- POSTGRES_DB=document_db
- POSTGRES_USER=docuser
- POSTGRES_PASSWORD=docpass
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
networks:
- document-network
restart: unless-stopped
# Redis (캐싱 및 세션)
redis:
image: redis:7-alpine
container_name: document-server-redis
ports:
- "24103:6379"
volumes:
- redis_data:/data
networks:
- document-network
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
postgres_data:
redis_data:
networks:
document-network:
driver: bridge

243
frontend/index.html Normal file
View File

@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 로그인 모달 -->
<div x-data="authModal" x-show="showLogin" 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>
<button @click="showLogin = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<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>
<button type="submit" :disabled="loginLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50">
<span x-show="!loginLoading">로그인</span>
<span x-show="loginLoading">로그인 중...</span>
</button>
</form>
</div>
</div>
<!-- 메인 앱 -->
<div x-data="documentApp" x-init="init()">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<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">
<h1 class="text-xl font-bold text-gray-900">
<i class="fas fa-file-alt mr-2"></i>
Document Server
</h1>
</div>
<!-- 검색바 -->
<div class="flex-1 max-w-lg mx-8" x-show="isAuthenticated">
<div class="relative">
<input type="text" x-model="searchQuery" @input="searchDocuments"
placeholder="문서, 메모 검색..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<!-- 사용자 메뉴 -->
<div class="flex items-center space-x-4">
<template x-if="!isAuthenticated">
<button @click="$refs.authModal.showLogin = true"
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
로그인
</button>
</template>
<template x-if="isAuthenticated">
<div class="flex items-center space-x-4">
<!-- 업로드 버튼 -->
<button @click="showUploadModal = true"
class="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700">
<i class="fas fa-upload mr-2"></i>
업로드
</button>
<!-- 사용자 드롭다운 -->
<div class="relative" x-data="{ open: false }">
<button @click="open = !open" class="flex items-center text-gray-700 hover:text-gray-900">
<i class="fas fa-user-circle text-2xl"></i>
<span x-text="user?.full_name || user?.email" class="ml-2"></span>
<i class="fas fa-chevron-down ml-1"></i>
</button>
<div x-show="open" @click.away="open = false" x-cloak
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<a href="#" @click="showProfile = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-user mr-2"></i>프로필
</a>
<a href="#" @click="showMyNotes = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sticky-note mr-2"></i>내 메모
</a>
<a href="#" @click="showBookmarks = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-bookmark mr-2"></i>책갈피
</a>
<template x-if="user?.is_admin">
<a href="#" @click="showAdmin = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-cog mr-2"></i>관리자
</a>
</template>
<hr class="my-1">
<a href="#" @click="logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
</a>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 로그인하지 않은 경우 -->
<template x-if="!isAuthenticated">
<div class="text-center py-16">
<i class="fas fa-file-alt text-6xl text-gray-400 mb-4"></i>
<h2 class="text-3xl font-bold text-gray-900 mb-4">Document Server에 오신 것을 환영합니다</h2>
<p class="text-xl text-gray-600 mb-8">HTML 문서를 관리하고 메모, 하이라이트를 추가해보세요</p>
<button @click="$refs.authModal.showLogin = true"
class="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700">
시작하기
</button>
</div>
</template>
<!-- 로그인한 경우 - 문서 목록 -->
<template x-if="isAuthenticated">
<div>
<!-- 필터 및 정렬 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center space-x-4">
<h2 class="text-2xl font-bold text-gray-900">문서 목록</h2>
<select x-model="selectedTag" @change="loadDocuments"
class="px-3 py-2 border border-gray-300 rounded-md">
<option value="">모든 태그</option>
<template x-for="tag in tags" :key="tag.id">
<option :value="tag.name" x-text="tag.name"></option>
</template>
</select>
</div>
<div class="flex items-center space-x-2">
<button @click="viewMode = 'grid'"
:class="viewMode === 'grid' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md">
<i class="fas fa-th-large"></i>
</button>
<button @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md">
<i class="fas fa-list"></i>
</button>
</div>
</div>
<!-- 문서 그리드/리스트 -->
<div x-show="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="doc in documents" :key="doc.id">
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow cursor-pointer"
@click="openDocument(doc)">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2" x-text="doc.title"></h3>
<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>
<div class="flex flex-wrap gap-1 mb-4">
<template x-for="tag in doc.tags" :key="tag">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
</template>
</div>
<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>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<template x-if="documents.length === 0 && !loading">
<div class="text-center py-16">
<i class="fas fa-folder-open text-6xl text-gray-400 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-900 mb-2">문서가 없습니다</h3>
<p class="text-gray-600 mb-6">첫 번째 문서를 업로드해보세요</p>
<button @click="showUploadModal = true"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
<i class="fas fa-upload mr-2"></i>
문서 업로드
</button>
</div>
</template>
</div>
</template>
</main>
</div>
<!-- 인증 모달 컴포넌트 -->
<div x-data="authModal" x-ref="authModal"></div>
<!-- 스크립트 -->
<script src="/static/js/api.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/main.js"></script>
<style>
[x-cloak] { display: none !important; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</body>
</html>

View File

@@ -0,0 +1,269 @@
/* 메인 스타일 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* 알림 애니메이션 */
.notification {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 로딩 스피너 */
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 카드 호버 효과 */
.card-hover {
transition: all 0.2s ease-in-out;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
/* 태그 스타일 */
.tag {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
background-color: #dbeafe;
color: #1e40af;
}
/* 검색 입력 포커스 */
.search-input:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 드롭다운 애니메이션 */
.dropdown-enter {
opacity: 0;
transform: scale(0.95);
}
.dropdown-enter-active {
opacity: 1;
transform: scale(1);
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
/* 모달 배경 */
.modal-backdrop {
backdrop-filter: blur(4px);
}
/* 파일 드롭 영역 */
.file-drop-zone {
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: all 0.2s ease-in-out;
}
.file-drop-zone.dragover {
border-color: #3b82f6;
background-color: #eff6ff;
}
.file-drop-zone:hover {
border-color: #6b7280;
}
/* 반응형 그리드 */
@media (max-width: 768px) {
.grid-responsive {
grid-template-columns: 1fr;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.grid-responsive {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1025px) {
.grid-responsive {
grid-template-columns: repeat(3, 1fr);
}
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 텍스트 줄임표 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 라인 클램프 유틸리티 */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 포커스 링 제거 */
.focus-visible:focus {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px #3b82f6;
}
/* 버튼 상태 */
.btn-primary {
background-color: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
transition: background-color 0.2s ease-in-out;
}
.btn-primary:hover {
background-color: #2563eb;
}
.btn-primary:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6b7280;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
transition: background-color 0.2s ease-in-out;
}
.btn-secondary:hover {
background-color: #4b5563;
}
/* 입력 필드 스타일 */
.input-field {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.input-field:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.input-field:invalid {
border-color: #ef4444;
}
/* 에러 메시지 */
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* 성공 메시지 */
.success-message {
color: #10b981;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* 로딩 오버레이 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
/* 빈 상태 일러스트레이션 */
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-state i {
font-size: 4rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #6b7280;
margin-bottom: 1.5rem;
}

View File

@@ -0,0 +1,455 @@
/* 뷰어 전용 스타일 */
/* 하이라이트 스타일 */
.highlight {
position: relative;
cursor: pointer;
border-radius: 2px;
padding: 1px 2px;
margin: -1px -2px;
transition: all 0.2s ease-in-out;
}
.highlight:hover {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
z-index: 10;
}
.highlight.selected {
box-shadow: 0 0 0 2px #3B82F6;
z-index: 10;
}
/* 하이라이트 버튼 */
.highlight-button {
animation: fadeInUp 0.2s ease-out;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 검색 하이라이트 */
.search-highlight {
background-color: #FEF3C7 !important;
border: 1px solid #F59E0B;
border-radius: 2px;
padding: 1px 2px;
margin: -1px -2px;
}
/* 문서 내용 스타일 */
#document-content {
line-height: 1.7;
font-size: 16px;
color: #374151;
}
#document-content h1,
#document-content h2,
#document-content h3,
#document-content h4,
#document-content h5,
#document-content h6 {
color: #111827;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 1rem;
}
#document-content h1 {
font-size: 2.25rem;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 0.5rem;
}
#document-content h2 {
font-size: 1.875rem;
}
#document-content h3 {
font-size: 1.5rem;
}
#document-content p {
margin-bottom: 1rem;
}
#document-content ul,
#document-content ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
#document-content li {
margin-bottom: 0.25rem;
}
#document-content blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: #6b7280;
}
#document-content code {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
}
#document-content pre {
background-color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
}
#document-content pre code {
background: none;
padding: 0;
}
#document-content table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
#document-content th,
#document-content td {
border: 1px solid #e5e7eb;
padding: 0.5rem;
text-align: left;
}
#document-content th {
background-color: #f9fafb;
font-weight: 600;
}
#document-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
/* 사이드 패널 스타일 */
.side-panel {
background: white;
border-left: 1px solid #e5e7eb;
height: calc(100vh - 4rem);
overflow: hidden;
}
.panel-tab {
transition: all 0.2s ease-in-out;
}
.panel-tab.active {
background-color: #eff6ff;
color: #2563eb;
border-bottom: 2px solid #2563eb;
}
/* 메모 카드 스타일 */
.note-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease-in-out;
cursor: pointer;
}
.note-card:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
.note-card.selected {
border-color: #3b82f6;
background: #eff6ff;
}
/* 책갈피 카드 스타일 */
.bookmark-card {
background: #f0fdf4;
border: 1px solid #dcfce7;
border-radius: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease-in-out;
cursor: pointer;
}
.bookmark-card:hover {
background: #ecfdf5;
border-color: #bbf7d0;
transform: translateY(-1px);
}
/* 색상 선택기 */
.color-picker {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: #f3f4f6;
border-radius: 0.5rem;
}
.color-option {
width: 2rem;
height: 2rem;
border-radius: 0.25rem;
border: 2px solid white;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
box-shadow: 0 0 0 2px #3b82f6;
}
/* 검색 입력 */
.search-input {
background: white;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
width: 100%;
transition: all 0.2s ease-in-out;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 도구 모음 */
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.toolbar-button {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: none;
background: #f3f4f6;
color: #374151;
cursor: pointer;
transition: all 0.2s ease-in-out;
font-size: 0.875rem;
font-weight: 500;
}
.toolbar-button:hover {
background: #e5e7eb;
}
.toolbar-button.active {
background: #3b82f6;
color: white;
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 모달 스타일 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 태그 입력 */
.tag-input {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
min-height: 2.5rem;
background: white;
}
.tag-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #eff6ff;
color: #1e40af;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.tag-remove {
cursor: pointer;
color: #6b7280;
font-size: 0.75rem;
}
.tag-remove:hover {
color: #ef4444;
}
/* 스크롤 표시기 */
.scroll-indicator {
position: fixed;
top: 4rem;
right: 1rem;
width: 4px;
height: calc(100vh - 5rem);
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
z-index: 30;
}
.scroll-thumb {
width: 100%;
background: #3b82f6;
border-radius: 2px;
transition: background-color 0.2s ease-in-out;
}
.scroll-thumb:hover {
background: #2563eb;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.side-panel {
position: fixed;
top: 4rem;
right: 0;
width: 100%;
max-width: 24rem;
z-index: 40;
box-shadow: -4px 0 6px -1px rgba(0, 0, 0, 0.1);
}
#document-content {
font-size: 14px;
padding: 1rem;
}
.toolbar {
flex-wrap: wrap;
gap: 0.25rem;
}
.toolbar-button {
font-size: 0.75rem;
padding: 0.375rem 0.5rem;
}
.color-option {
width: 1.5rem;
height: 1.5rem;
}
}
/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {
.highlight {
filter: brightness(0.8);
}
.search-highlight {
background-color: #451a03 !important;
border-color: #92400e;
color: #fbbf24;
}
#document-content {
color: #e5e7eb;
}
#document-content h1,
#document-content h2,
#document-content h3,
#document-content h4,
#document-content h5,
#document-content h6 {
color: #f9fafb;
}
}
/* 인쇄 스타일 */
@media print {
.toolbar,
.side-panel,
.highlight-button {
display: none !important;
}
.highlight {
background-color: #fef3c7 !important;
box-shadow: none !important;
}
#document-content {
font-size: 12pt;
line-height: 1.5;
}
}

265
frontend/static/js/api.js Normal file
View File

@@ -0,0 +1,265 @@
/**
* API 통신 유틸리티
*/
class API {
constructor() {
this.baseURL = '/api';
this.token = localStorage.getItem('access_token');
}
// 토큰 설정
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('access_token', token);
} else {
localStorage.removeItem('access_token');
}
}
// 기본 요청 헤더
getHeaders() {
const headers = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
// GET 요청
async get(endpoint, params = {}) {
const url = new URL(`${this.baseURL}${endpoint}`, window.location.origin);
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== undefined) {
url.searchParams.append(key, params[key]);
}
});
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
});
return this.handleResponse(response);
}
// POST 요청
async post(endpoint, data = {}) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
return this.handleResponse(response);
}
// PUT 요청
async put(endpoint, data = {}) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'PUT',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
return this.handleResponse(response);
}
// DELETE 요청
async delete(endpoint) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
return this.handleResponse(response);
}
// 파일 업로드
async uploadFile(endpoint, formData) {
const headers = {};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'POST',
headers: headers,
body: formData,
});
return this.handleResponse(response);
}
// 응답 처리
async handleResponse(response) {
if (response.status === 401) {
// 토큰 만료 또는 인증 실패
this.setToken(null);
window.location.reload();
throw new Error('인증이 필요합니다');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
}
return await response.text();
}
// 인증 관련 API
async login(email, password) {
return await this.post('/auth/login', { email, password });
}
async logout() {
try {
await this.post('/auth/logout');
} finally {
this.setToken(null);
}
}
async getCurrentUser() {
return await this.get('/auth/me');
}
async refreshToken(refreshToken) {
return await this.post('/auth/refresh', { refresh_token: refreshToken });
}
// 문서 관련 API
async getDocuments(params = {}) {
return await this.get('/documents/', params);
}
async getDocument(documentId) {
return await this.get(`/documents/${documentId}`);
}
async uploadDocument(formData) {
return await this.uploadFile('/documents/', formData);
}
async deleteDocument(documentId) {
return await this.delete(`/documents/${documentId}`);
}
async getTags() {
return await this.get('/documents/tags/');
}
async createTag(tagData) {
return await this.post('/documents/tags/', tagData);
}
// 하이라이트 관련 API
async createHighlight(highlightData) {
return await this.post('/highlights/', highlightData);
}
async getDocumentHighlights(documentId) {
return await this.get(`/highlights/document/${documentId}`);
}
async updateHighlight(highlightId, data) {
return await this.put(`/highlights/${highlightId}`, data);
}
async deleteHighlight(highlightId) {
return await this.delete(`/highlights/${highlightId}`);
}
// 메모 관련 API
async createNote(noteData) {
return await this.post('/notes/', noteData);
}
async getNotes(params = {}) {
return await this.get('/notes/', params);
}
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
async updateNote(noteId, data) {
return await this.put(`/notes/${noteId}`, data);
}
async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`);
}
async getPopularNoteTags() {
return await this.get('/notes/tags/popular');
}
// 책갈피 관련 API
async createBookmark(bookmarkData) {
return await this.post('/bookmarks/', bookmarkData);
}
async getBookmarks(params = {}) {
return await this.get('/bookmarks/', params);
}
async getDocumentBookmarks(documentId) {
return await this.get(`/bookmarks/document/${documentId}`);
}
async updateBookmark(bookmarkId, data) {
return await this.put(`/bookmarks/${bookmarkId}`, data);
}
async deleteBookmark(bookmarkId) {
return await this.delete(`/bookmarks/${bookmarkId}`);
}
// 검색 관련 API
async search(params = {}) {
return await this.get('/search/', params);
}
async getSearchSuggestions(query) {
return await this.get('/search/suggestions', { q: query });
}
// 사용자 관리 API
async getUsers() {
return await this.get('/users/');
}
async createUser(userData) {
return await this.post('/auth/create-user', userData);
}
async updateUser(userId, userData) {
return await this.put(`/users/${userId}`, userData);
}
async deleteUser(userId) {
return await this.delete(`/users/${userId}`);
}
async updateProfile(profileData) {
return await this.put('/users/profile', profileData);
}
async changePassword(passwordData) {
return await this.put('/auth/change-password', passwordData);
}
}
// 전역 API 인스턴스
window.api = new API();

View File

@@ -0,0 +1,90 @@
/**
* 인증 관련 Alpine.js 컴포넌트
*/
// 인증 모달 컴포넌트
window.authModal = () => ({
showLogin: false,
loginForm: {
email: '',
password: ''
},
loginError: '',
loginLoading: false,
async login() {
this.loginLoading = true;
this.loginError = '';
try {
const response = await api.login(this.loginForm.email, this.loginForm.password);
// 토큰 저장
api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
// 사용자 정보 로드
const user = await api.getCurrentUser();
// 전역 상태 업데이트
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: true, user }
}));
// 모달 닫기
this.showLogin = false;
this.loginForm = { email: '', password: '' };
} catch (error) {
this.loginError = error.message;
} finally {
this.loginLoading = false;
}
},
async logout() {
try {
await api.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
// 로컬 스토리지 정리
localStorage.removeItem('refresh_token');
// 전역 상태 업데이트
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: false, user: null }
}));
}
}
});
// 자동 토큰 갱신
async function refreshTokenIfNeeded() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken || !api.token) return;
try {
// 토큰 만료 확인 (JWT 디코딩)
const tokenPayload = JSON.parse(atob(api.token.split('.')[1]));
const now = Date.now() / 1000;
// 토큰이 5분 내에 만료되면 갱신
if (tokenPayload.exp - now < 300) {
const response = await api.refreshToken(refreshToken);
api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
}
} catch (error) {
console.error('Token refresh failed:', error);
// 갱신 실패시 로그아웃
api.setToken(null);
localStorage.removeItem('refresh_token');
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: false, user: null }
}));
}
}
// 5분마다 토큰 갱신 체크
setInterval(refreshTokenIfNeeded, 5 * 60 * 1000);

286
frontend/static/js/main.js Normal file
View File

@@ -0,0 +1,286 @@
/**
* 메인 애플리케이션 Alpine.js 컴포넌트
*/
// 메인 문서 앱 컴포넌트
window.documentApp = () => ({
// 상태
isAuthenticated: false,
user: null,
loading: false,
// 문서 관련
documents: [],
tags: [],
selectedTag: '',
viewMode: 'grid', // 'grid' 또는 'list'
// 검색
searchQuery: '',
searchResults: [],
// 모달 상태
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();
}
});
// 초기 데이터 로드
if (this.isAuthenticated) {
await this.loadInitialData();
}
},
// 인증 상태 확인
async checkAuth() {
if (!api.token) {
this.isAuthenticated = false;
return;
}
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 params = {};
if (this.selectedTag) {
params.tag = this.selectedTag;
}
this.documents = await api.getDocuments(params);
} catch (error) {
console.error('Failed to load documents:', error);
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error');
}
},
// 태그 목록 로드
async loadTags() {
try {
this.tags = await api.getTags();
} catch (error) {
console.error('Failed to load tags:', error);
}
},
// 문서 검색
async searchDocuments() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
return;
}
try {
const results = await api.search({
q: this.searchQuery,
limit: 20
});
this.searchResults = results.results;
} catch (error) {
console.error('Search failed:', error);
}
},
// 문서 열기
openDocument(document) {
// 문서 뷰어 페이지로 이동
window.location.href = `/viewer.html?id=${document.id}`;
},
// 로그아웃
async logout() {
try {
await api.logout();
this.isAuthenticated = false;
this.user = null;
this.resetData();
} catch (error) {
console.error('Logout failed:', error);
}
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
// 알림 표시
showNotification(message, type = 'info') {
// 간단한 알림 구현 (나중에 토스트 라이브러리로 교체 가능)
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 p-4 rounded-lg text-white z-50 ${
type === 'error' ? 'bg-red-500' :
type === 'success' ? 'bg-green-500' :
'bg-blue-500'
}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
});
// 파일 업로드 컴포넌트
window.uploadModal = () => ({
show: false,
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(/\.[^/.]+$/, "");
}
}
},
// 업로드 실행
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 {
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('html_file', this.uploadForm.html_file);
if (this.uploadForm.pdf_file) {
formData.append('pdf_file', this.uploadForm.pdf_file);
}
if (this.uploadForm.document_date) {
formData.append('document_date', this.uploadForm.document_date);
}
await api.uploadDocument(formData);
// 성공시 모달 닫기 및 목록 새로고침
this.show = false;
this.resetForm();
// 문서 목록 새로고침
window.dispatchEvent(new CustomEvent('documents-changed'));
// 성공 알림
document.querySelector('[x-data="documentApp"]').__x.$data.showNotification('문서가 성공적으로 업로드되었습니다', '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 = '');
}
});
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
const app = document.querySelector('[x-data="documentApp"]').__x.$data;
if (app && app.isAuthenticated) {
app.loadDocuments();
app.loadTags();
}
});

View File

@@ -0,0 +1,641 @@
/**
* 문서 뷰어 Alpine.js 컴포넌트
*/
window.documentViewer = () => ({
// 상태
loading: true,
error: null,
document: null,
documentId: null,
// 하이라이트 및 메모
highlights: [],
notes: [],
selectedHighlightColor: '#FFFF00',
selectedText: '',
selectedRange: null,
// 책갈피
bookmarks: [],
// UI 상태
showNotesPanel: false,
showBookmarksPanel: false,
activePanel: 'notes',
// 검색
searchQuery: '',
noteSearchQuery: '',
filteredNotes: [],
// 모달
showNoteModal: false,
showBookmarkModal: false,
editingNote: null,
editingBookmark: null,
noteLoading: false,
bookmarkLoading: false,
// 폼 데이터
noteForm: {
content: '',
tags: ''
},
bookmarkForm: {
title: '',
description: ''
},
// 초기화
async init() {
// URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
if (!this.documentId) {
this.error = '문서 ID가 없습니다';
this.loading = false;
return;
}
// 인증 확인
if (!api.token) {
window.location.href = '/';
return;
}
try {
await this.loadDocument();
await this.loadDocumentData();
} catch (error) {
console.error('Failed to load document:', error);
this.error = error.message;
} finally {
this.loading = false;
}
// 초기 필터링
this.filterNotes();
},
// 문서 로드
async loadDocument() {
this.document = await api.getDocument(this.documentId);
// HTML 내용 로드
const response = await fetch(`/uploads/documents/${this.documentId}.html`);
if (!response.ok) {
throw new Error('문서를 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트
document.title = `${this.document.title} - Document Server`;
},
// 문서 관련 데이터 로드
async loadDocumentData() {
try {
const [highlights, notes, bookmarks] = await Promise.all([
api.getDocumentHighlights(this.documentId),
api.getDocumentNotes(this.documentId),
api.getDocumentBookmarks(this.documentId)
]);
this.highlights = highlights;
this.notes = notes;
this.bookmarks = bookmarks;
// 하이라이트 렌더링
this.renderHighlights();
} catch (error) {
console.error('Failed to load document data:', error);
}
},
// 하이라이트 렌더링
renderHighlights() {
const content = document.getElementById('document-content');
// 기존 하이라이트 제거
content.querySelectorAll('.highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
// 새 하이라이트 적용
this.highlights.forEach(highlight => {
this.applyHighlight(highlight);
});
},
// 개별 하이라이트 적용
applyHighlight(highlight) {
const content = document.getElementById('document-content');
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
const nodeStart = currentOffset;
const nodeEnd = currentOffset + nodeLength;
// 하이라이트 범위와 겹치는지 확인
if (nodeStart < highlight.end_offset && nodeEnd > highlight.start_offset) {
const startInNode = Math.max(0, highlight.start_offset - nodeStart);
const endInNode = Math.min(nodeLength, highlight.end_offset - nodeStart);
if (startInNode < endInNode) {
// 텍스트 노드를 분할하고 하이라이트 적용
const beforeText = node.textContent.substring(0, startInNode);
const highlightText = node.textContent.substring(startInNode, endInNode);
const afterText = node.textContent.substring(endInNode);
const parent = node.parentNode;
// 하이라이트 요소 생성
const highlightEl = document.createElement('span');
highlightEl.className = 'highlight';
highlightEl.style.backgroundColor = highlight.highlight_color;
highlightEl.textContent = highlightText;
highlightEl.dataset.highlightId = highlight.id;
// 클릭 이벤트 추가
highlightEl.addEventListener('click', (e) => {
e.stopPropagation();
this.selectHighlight(highlight.id);
});
// 노드 교체
if (beforeText) {
parent.insertBefore(document.createTextNode(beforeText), node);
}
parent.insertBefore(highlightEl, node);
if (afterText) {
parent.insertBefore(document.createTextNode(afterText), node);
}
parent.removeChild(node);
}
}
currentOffset = nodeEnd;
}
},
// 텍스트 선택 처리
handleTextSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
if (selectedText.length < 2) {
return;
}
// 문서 컨텐츠 내부의 선택인지 확인
const content = document.getElementById('document-content');
if (!content.contains(range.commonAncestorContainer)) {
return;
}
// 선택된 텍스트와 범위 저장
this.selectedText = selectedText;
this.selectedRange = range.cloneRange();
// 컨텍스트 메뉴 표시 (간단한 버튼)
this.showHighlightButton(selection);
},
// 하이라이트 버튼 표시
showHighlightButton(selection) {
// 기존 버튼 제거
const existingButton = document.querySelector('.highlight-button');
if (existingButton) {
existingButton.remove();
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const button = document.createElement('button');
button.className = 'highlight-button fixed z-50 bg-blue-600 text-white px-3 py-1 rounded shadow-lg text-sm';
button.style.left = `${rect.left + window.scrollX}px`;
button.style.top = `${rect.bottom + window.scrollY + 5}px`;
button.innerHTML = '<i class="fas fa-highlighter mr-1"></i>하이라이트';
button.addEventListener('click', () => {
this.createHighlight();
button.remove();
});
document.body.appendChild(button);
// 3초 후 자동 제거
setTimeout(() => {
if (button.parentNode) {
button.remove();
}
}, 3000);
},
// 하이라이트 생성
async createHighlight() {
if (!this.selectedText || !this.selectedRange) {
return;
}
try {
// 텍스트 오프셋 계산
const content = document.getElementById('document-content');
const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content);
const highlightData = {
document_id: this.documentId,
start_offset: startOffset,
end_offset: endOffset,
selected_text: this.selectedText,
highlight_color: this.selectedHighlightColor,
highlight_type: 'highlight'
};
const highlight = await api.createHighlight(highlightData);
this.highlights.push(highlight);
// 하이라이트 렌더링
this.renderHighlights();
// 선택 해제
window.getSelection().removeAllRanges();
this.selectedText = '';
this.selectedRange = null;
// 메모 추가 여부 확인
if (confirm('이 하이라이트에 메모를 추가하시겠습니까?')) {
this.openNoteModal(highlight);
}
} catch (error) {
console.error('Failed to create highlight:', error);
alert('하이라이트 생성에 실패했습니다');
}
},
// 텍스트 오프셋 계산
calculateTextOffsets(range, container) {
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let startOffset = -1;
let endOffset = -1;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (range.startContainer === node) {
startOffset = currentOffset + range.startOffset;
}
if (range.endContainer === node) {
endOffset = currentOffset + range.endOffset;
break;
}
currentOffset += nodeLength;
}
return { startOffset, endOffset };
},
// 하이라이트 선택
selectHighlight(highlightId) {
// 모든 하이라이트에서 selected 클래스 제거
document.querySelectorAll('.highlight').forEach(el => {
el.classList.remove('selected');
});
// 선택된 하이라이트에 selected 클래스 추가
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (highlightEl) {
highlightEl.classList.add('selected');
}
// 해당 하이라이트의 메모 찾기
const note = this.notes.find(n => n.highlight.id === highlightId);
if (note) {
this.editNote(note);
} else {
// 메모가 없으면 새로 생성
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
this.openNoteModal(highlight);
}
}
},
// 하이라이트로 스크롤
scrollToHighlight(highlightId) {
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (highlightEl) {
highlightEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
highlightEl.classList.add('selected');
// 2초 후 선택 해제
setTimeout(() => {
highlightEl.classList.remove('selected');
}, 2000);
}
},
// 메모 모달 열기
openNoteModal(highlight = null) {
this.editingNote = null;
this.noteForm = {
content: '',
tags: ''
};
if (highlight) {
this.selectedHighlight = highlight;
this.selectedText = highlight.selected_text;
}
this.showNoteModal = true;
},
// 메모 편집
editNote(note) {
this.editingNote = note;
this.noteForm = {
content: note.content,
tags: note.tags ? note.tags.join(', ') : ''
};
this.selectedText = note.highlight.selected_text;
this.showNoteModal = true;
},
// 메모 저장
async saveNote() {
this.noteLoading = true;
try {
const noteData = {
content: this.noteForm.content,
tags: this.noteForm.tags ? this.noteForm.tags.split(',').map(t => t.trim()).filter(t => t) : []
};
if (this.editingNote) {
// 메모 수정
const updatedNote = await api.updateNote(this.editingNote.id, noteData);
const index = this.notes.findIndex(n => n.id === this.editingNote.id);
if (index !== -1) {
this.notes[index] = updatedNote;
}
} else {
// 새 메모 생성
noteData.highlight_id = this.selectedHighlight.id;
const newNote = await api.createNote(noteData);
this.notes.push(newNote);
}
this.filterNotes();
this.closeNoteModal();
} catch (error) {
console.error('Failed to save note:', error);
alert('메모 저장에 실패했습니다');
} finally {
this.noteLoading = false;
}
},
// 메모 삭제
async deleteNote(noteId) {
if (!confirm('이 메모를 삭제하시겠습니까?')) {
return;
}
try {
await api.deleteNote(noteId);
this.notes = this.notes.filter(n => n.id !== noteId);
this.filterNotes();
} catch (error) {
console.error('Failed to delete note:', error);
alert('메모 삭제에 실패했습니다');
}
},
// 메모 모달 닫기
closeNoteModal() {
this.showNoteModal = false;
this.editingNote = null;
this.selectedHighlight = null;
this.selectedText = '';
this.noteForm = { content: '', tags: '' };
},
// 메모 필터링
filterNotes() {
if (!this.noteSearchQuery.trim()) {
this.filteredNotes = [...this.notes];
} else {
const query = this.noteSearchQuery.toLowerCase();
this.filteredNotes = this.notes.filter(note =>
note.content.toLowerCase().includes(query) ||
note.highlight.selected_text.toLowerCase().includes(query) ||
(note.tags && note.tags.some(tag => tag.toLowerCase().includes(query)))
);
}
},
// 책갈피 추가
async addBookmark() {
const scrollPosition = window.scrollY;
this.bookmarkForm = {
title: `${this.document.title} - ${new Date().toLocaleString()}`,
description: ''
};
this.currentScrollPosition = scrollPosition;
this.showBookmarkModal = true;
},
// 책갈피 편집
editBookmark(bookmark) {
this.editingBookmark = bookmark;
this.bookmarkForm = {
title: bookmark.title,
description: bookmark.description || ''
};
this.showBookmarkModal = true;
},
// 책갈피 저장
async saveBookmark() {
this.bookmarkLoading = true;
try {
const bookmarkData = {
title: this.bookmarkForm.title,
description: this.bookmarkForm.description,
scroll_position: this.currentScrollPosition || 0
};
if (this.editingBookmark) {
// 책갈피 수정
const updatedBookmark = await api.updateBookmark(this.editingBookmark.id, bookmarkData);
const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark.id);
if (index !== -1) {
this.bookmarks[index] = updatedBookmark;
}
} else {
// 새 책갈피 생성
bookmarkData.document_id = this.documentId;
const newBookmark = await api.createBookmark(bookmarkData);
this.bookmarks.push(newBookmark);
}
this.closeBookmarkModal();
} catch (error) {
console.error('Failed to save bookmark:', error);
alert('책갈피 저장에 실패했습니다');
} finally {
this.bookmarkLoading = false;
}
},
// 책갈피 삭제
async deleteBookmark(bookmarkId) {
if (!confirm('이 책갈피를 삭제하시겠습니까?')) {
return;
}
try {
await api.deleteBookmark(bookmarkId);
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId);
} catch (error) {
console.error('Failed to delete bookmark:', error);
alert('책갈피 삭제에 실패했습니다');
}
},
// 책갈피로 스크롤
scrollToBookmark(bookmark) {
window.scrollTo({
top: bookmark.scroll_position,
behavior: 'smooth'
});
},
// 책갈피 모달 닫기
closeBookmarkModal() {
this.showBookmarkModal = false;
this.editingBookmark = null;
this.bookmarkForm = { title: '', description: '' };
this.currentScrollPosition = null;
},
// 문서 내 검색
searchInDocument() {
// 기존 검색 하이라이트 제거
document.querySelectorAll('.search-highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
if (!this.searchQuery.trim()) {
return;
}
// 새 검색 하이라이트 적용
const content = document.getElementById('document-content');
this.highlightSearchResults(content, this.searchQuery);
},
// 검색 결과 하이라이트
highlightSearchResults(element, searchText) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
textNodes.forEach(textNode => {
const text = textNode.textContent;
const regex = new RegExp(`(${searchText})`, 'gi');
if (regex.test(text)) {
const parent = textNode.parentNode;
const highlightedHTML = text.replace(regex, '<span class="search-highlight">$1</span>');
const tempDiv = document.createElement('div');
tempDiv.innerHTML = highlightedHTML;
while (tempDiv.firstChild) {
parent.insertBefore(tempDiv.firstChild, textNode);
}
parent.removeChild(textNode);
}
});
},
// 문서 클릭 처리
handleDocumentClick(event) {
// 하이라이트 버튼 제거
const button = document.querySelector('.highlight-button');
if (button && !button.contains(event.target)) {
button.remove();
}
// 하이라이트 선택 해제
document.querySelectorAll('.highlight.selected').forEach(el => {
el.classList.remove('selected');
});
},
// 뒤로가기
goBack() {
window.history.back();
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
});

363
frontend/viewer.html Normal file
View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문서 뷰어 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/viewer.css">
</head>
<body class="bg-gray-50 min-h-screen">
<div x-data="documentViewer" x-init="init()">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b sticky top-0 z-40">
<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 space-x-4">
<button @click="goBack" class="text-gray-600 hover:text-gray-900">
<i class="fas fa-arrow-left text-xl"></i>
</button>
<div>
<h1 class="text-lg font-semibold text-gray-900" x-text="document?.title || '로딩 중...'"></h1>
<p class="text-sm text-gray-500" x-show="document" x-text="document?.uploader_name"></p>
</div>
</div>
<!-- 도구 모음 -->
<div class="flex items-center space-x-2">
<!-- 하이라이트 색상 선택 -->
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button @click="selectedHighlightColor = '#FFFF00'"
:class="selectedHighlightColor === '#FFFF00' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-yellow-300 rounded border-2 border-white"></button>
<button @click="selectedHighlightColor = '#90EE90'"
:class="selectedHighlightColor === '#90EE90' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-green-300 rounded border-2 border-white"></button>
<button @click="selectedHighlightColor = '#FFB6C1'"
:class="selectedHighlightColor === '#FFB6C1' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-pink-300 rounded border-2 border-white"></button>
<button @click="selectedHighlightColor = '#87CEEB'"
:class="selectedHighlightColor === '#87CEEB' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-blue-300 rounded border-2 border-white"></button>
</div>
<!-- 메모 패널 토글 -->
<button @click="showNotesPanel = !showNotesPanel"
:class="showNotesPanel ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md hover:bg-blue-700 transition-colors">
<i class="fas fa-sticky-note mr-1"></i>
메모 (<span x-text="notes.length"></span>)
</button>
<!-- 책갈피 패널 토글 -->
<button @click="showBookmarksPanel = !showBookmarksPanel"
:class="showBookmarksPanel ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md hover:bg-green-700 transition-colors">
<i class="fas fa-bookmark mr-1"></i>
책갈피 (<span x-text="bookmarks.length"></span>)
</button>
<!-- 검색 -->
<div class="relative">
<input type="text" x-model="searchQuery" @input="searchInDocument"
placeholder="문서 내 검색..."
class="w-64 pl-8 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<i class="fas fa-search absolute left-2 top-3 text-gray-400"></i>
</div>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<div class="flex max-w-7xl mx-auto">
<!-- 문서 뷰어 -->
<main class="flex-1 bg-white shadow-sm" :class="(showNotesPanel || showBookmarksPanel) ? 'mr-80' : ''">
<div class="p-8">
<!-- 로딩 상태 -->
<div x-show="loading" class="text-center py-16">
<i class="fas fa-spinner fa-spin text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-600">문서를 불러오는 중...</p>
</div>
<!-- 에러 상태 -->
<div x-show="error" class="text-center py-16">
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-4"></i>
<p class="text-red-600" x-text="error"></p>
<button @click="goBack" class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
돌아가기
</button>
</div>
<!-- 문서 내용 -->
<div x-show="!loading && !error"
id="document-content"
class="prose prose-lg max-w-none"
@mouseup="handleTextSelection"
@click="handleDocumentClick">
<!-- 문서 HTML이 여기에 로드됩니다 -->
</div>
</div>
</main>
<!-- 사이드 패널 -->
<aside x-show="showNotesPanel || showBookmarksPanel"
class="fixed right-0 top-16 w-80 h-screen bg-white shadow-lg border-l overflow-hidden flex flex-col">
<!-- 패널 탭 -->
<div class="flex border-b">
<button @click="activePanel = 'notes'; showNotesPanel = true; showBookmarksPanel = false"
:class="activePanel === 'notes' ? 'bg-blue-50 text-blue-600 border-b-2 border-blue-600' : 'text-gray-600'"
class="flex-1 px-4 py-3 text-sm font-medium">
<i class="fas fa-sticky-note mr-2"></i>
메모 (<span x-text="notes.length"></span>)
</button>
<button @click="activePanel = 'bookmarks'; showBookmarksPanel = true; showNotesPanel = false"
:class="activePanel === 'bookmarks' ? 'bg-green-50 text-green-600 border-b-2 border-green-600' : 'text-gray-600'"
class="flex-1 px-4 py-3 text-sm font-medium">
<i class="fas fa-bookmark mr-2"></i>
책갈피 (<span x-text="bookmarks.length"></span>)
</button>
<button @click="showNotesPanel = false; showBookmarksPanel = false"
class="px-3 py-3 text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 메모 패널 -->
<div x-show="activePanel === 'notes'" class="flex-1 overflow-hidden flex flex-col">
<div class="p-4 border-b">
<input type="text" x-model="noteSearchQuery" @input="filterNotes"
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 class="flex-1 overflow-y-auto">
<template x-if="filteredNotes.length === 0">
<div class="p-4 text-center text-gray-500">
<i class="fas fa-sticky-note text-3xl mb-2"></i>
<p>메모가 없습니다</p>
<p class="text-sm">텍스트를 선택하고 메모를 추가해보세요</p>
</div>
</template>
<div class="space-y-2 p-2">
<template x-for="note in filteredNotes" :key="note.id">
<div class="bg-gray-50 rounded-lg p-3 cursor-pointer hover:bg-gray-100"
@click="scrollToHighlight(note.highlight.id)">
<div class="flex justify-between items-start mb-2">
<div class="flex-1">
<p class="text-sm text-gray-600 line-clamp-2" x-text="note.highlight.selected_text"></p>
</div>
<div class="flex space-x-1 ml-2">
<button @click.stop="editNote(note)" class="text-blue-500 hover:text-blue-700">
<i class="fas fa-edit text-xs"></i>
</button>
<button @click.stop="deleteNote(note.id)" class="text-red-500 hover:text-red-700">
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
<p class="text-sm text-gray-800" x-text="note.content"></p>
<div class="flex justify-between items-center mt-2">
<div class="flex flex-wrap gap-1">
<template x-for="tag in note.tags || []" :key="tag">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
</template>
</div>
<span class="text-xs text-gray-500" x-text="formatDate(note.created_at)"></span>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 책갈피 패널 -->
<div x-show="activePanel === 'bookmarks'" class="flex-1 overflow-hidden flex flex-col">
<div class="p-4 border-b">
<button @click="addBookmark" class="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700">
<i class="fas fa-plus mr-2"></i>
현재 위치에 책갈피 추가
</button>
</div>
<div class="flex-1 overflow-y-auto">
<template x-if="bookmarks.length === 0">
<div class="p-4 text-center text-gray-500">
<i class="fas fa-bookmark text-3xl mb-2"></i>
<p>책갈피가 없습니다</p>
</div>
</template>
<div class="space-y-2 p-2">
<template x-for="bookmark in bookmarks" :key="bookmark.id">
<div class="bg-gray-50 rounded-lg p-3 cursor-pointer hover:bg-gray-100"
@click="scrollToBookmark(bookmark)">
<div class="flex justify-between items-start mb-2">
<h4 class="font-medium text-gray-900" x-text="bookmark.title"></h4>
<div class="flex space-x-1">
<button @click.stop="editBookmark(bookmark)" class="text-blue-500 hover:text-blue-700">
<i class="fas fa-edit text-xs"></i>
</button>
<button @click.stop="deleteBookmark(bookmark.id)" class="text-red-500 hover:text-red-700">
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
<p class="text-sm text-gray-600" x-text="bookmark.description"></p>
<span class="text-xs text-gray-500" x-text="formatDate(bookmark.created_at)"></span>
</div>
</template>
</div>
</div>
</div>
</aside>
</div>
<!-- 메모 추가/편집 모달 -->
<div x-show="showNoteModal" 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-6 max-w-2xl w-full mx-4 max-h-96 overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold" x-text="editingNote ? '메모 편집' : '메모 추가'"></h3>
<button @click="closeNoteModal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 선택된 텍스트 표시 -->
<div x-show="selectedText" class="mb-4 p-3 bg-yellow-100 rounded-lg">
<p class="text-sm text-gray-600 mb-1">선택된 텍스트:</p>
<p class="text-sm font-medium" x-text="selectedText"></p>
</div>
<form @submit.prevent="saveNote">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">메모 내용</label>
<textarea x-model="noteForm.content" rows="4" 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="메모를 입력하세요..."></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">태그 (쉼표로 구분)</label>
<input type="text" x-model="noteForm.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="예: 중요, 질문, 아이디어">
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="closeNoteModal"
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300">
취소
</button>
<button type="submit" :disabled="noteLoading"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
<span x-show="!noteLoading" x-text="editingNote ? '수정' : '저장'"></span>
<span x-show="noteLoading">저장 중...</span>
</button>
</div>
</form>
</div>
</div>
<!-- 책갈피 추가/편집 모달 -->
<div x-show="showBookmarkModal" 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-6 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold" x-text="editingBookmark ? '책갈피 편집' : '책갈피 추가'"></h3>
<button @click="closeBookmarkModal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="saveBookmark">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">제목</label>
<input type="text" x-model="bookmarkForm.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>
<textarea x-model="bookmarkForm.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="flex justify-end space-x-3">
<button type="button" @click="closeBookmarkModal"
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300">
취소
</button>
<button type="submit" :disabled="bookmarkLoading"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50">
<span x-show="!bookmarkLoading" x-text="editingBookmark ? '수정' : '저장'"></span>
<span x-show="bookmarkLoading">저장 중...</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js"></script>
<script src="/static/js/viewer.js"></script>
<style>
[x-cloak] { display: none !important; }
.highlight {
position: relative;
cursor: pointer;
border-radius: 2px;
}
.highlight:hover {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
.highlight.selected {
box-shadow: 0 0 0 2px #3B82F6;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 검색 하이라이트 */
.search-highlight {
background-color: #FEF3C7 !important;
border: 1px solid #F59E0B;
}
/* 스크롤바 스타일링 */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: #f1f1f1;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
</body>
</html>

15
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM nginx:1.24-alpine
# 설정 파일 복사
COPY nginx.conf /etc/nginx/nginx.conf
COPY default.conf /etc/nginx/conf.d/default.conf
# 정적 파일 디렉토리 생성
RUN mkdir -p /usr/share/nginx/html/uploads
# 권한 설정
RUN chown -R nginx:nginx /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

75
nginx/default.conf Normal file
View File

@@ -0,0 +1,75 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 정적 파일 캐싱
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# API 요청을 백엔드로 프록시
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 타임아웃 설정
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# 버퍼링 설정
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# 업로드된 문서 파일 서빙
location /uploads/ {
alias /usr/share/nginx/html/uploads/;
# 보안을 위해 실행 파일 차단
location ~* \.(php|pl|py|jsp|asp|sh|cgi)$ {
deny all;
}
# HTML 파일은 iframe에서 안전하게 로드
location ~* \.html$ {
add_header X-Frame-Options "SAMEORIGIN";
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline'";
}
}
# 메인 애플리케이션
location / {
try_files $uri $uri/ /index.html;
# SPA를 위한 히스토리 API 지원
location ~* ^.+\.(html|htm)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
# 헬스체크 엔드포인트
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# 에러 페이지
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

57
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,57 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 로그 포맷
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# 성능 최적화
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Gzip 압축
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# 가상 호스트 설정 포함
include /etc/nginx/conf.d/*.conf;
}