🎉 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:
111
.gitignore
vendored
Normal file
111
.gitignore
vendored
Normal 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
283
README.md
Normal 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
38
backend/Dockerfile
Normal 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
35
backend/Dockerfile.dev
Normal 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
85
backend/pyproject.toml
Normal 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
3
backend/src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Document Server Backend Package
|
||||
"""
|
||||
3
backend/src/api/__init__.py
Normal file
3
backend/src/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API 패키지 초기화
|
||||
"""
|
||||
88
backend/src/api/dependencies.py
Normal file
88
backend/src/api/dependencies.py
Normal 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
|
||||
3
backend/src/api/routes/__init__.py
Normal file
3
backend/src/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API 라우터 패키지 초기화
|
||||
"""
|
||||
190
backend/src/api/routes/auth.py
Normal file
190
backend/src/api/routes/auth.py
Normal 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"}
|
||||
300
backend/src/api/routes/bookmarks.py
Normal file
300
backend/src/api/routes/bookmarks.py
Normal 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"}
|
||||
359
backend/src/api/routes/documents.py
Normal file
359
backend/src/api/routes/documents.py
Normal 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
|
||||
340
backend/src/api/routes/highlights.py
Normal file
340
backend/src/api/routes/highlights.py
Normal 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
|
||||
404
backend/src/api/routes/notes.py
Normal file
404
backend/src/api/routes/notes.py
Normal 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]
|
||||
354
backend/src/api/routes/search.py
Normal file
354
backend/src/api/routes/search.py
Normal 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]}
|
||||
176
backend/src/api/routes/users.py
Normal file
176
backend/src/api/routes/users.py
Normal 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"}
|
||||
52
backend/src/core/config.py
Normal file
52
backend/src/core/config.py
Normal 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)
|
||||
94
backend/src/core/database.py
Normal file
94
backend/src/core/database.py
Normal 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}")
|
||||
87
backend/src/core/security.py
Normal file
87
backend/src/core/security.py
Normal 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
72
backend/src/main.py
Normal 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,
|
||||
)
|
||||
17
backend/src/models/__init__.py
Normal file
17
backend/src/models/__init__.py
Normal 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",
|
||||
]
|
||||
42
backend/src/models/bookmark.py
Normal file
42
backend/src/models/bookmark.py
Normal 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}')>"
|
||||
81
backend/src/models/document.py
Normal file
81
backend/src/models/document.py
Normal 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}')>"
|
||||
47
backend/src/models/highlight.py
Normal file
47
backend/src/models/highlight.py
Normal 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]}...')>"
|
||||
47
backend/src/models/note.py
Normal file
47
backend/src/models/note.py
Normal 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]}...')>"
|
||||
34
backend/src/models/user.py
Normal file
34
backend/src/models/user.py
Normal 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}')>"
|
||||
56
backend/src/schemas/auth.py
Normal file
56
backend/src/schemas/auth.py
Normal 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
68
docker-compose.dev.yml
Normal 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
75
docker-compose.yml
Normal 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
243
frontend/index.html
Normal 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>
|
||||
269
frontend/static/css/main.css
Normal file
269
frontend/static/css/main.css
Normal 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;
|
||||
}
|
||||
455
frontend/static/css/viewer.css
Normal file
455
frontend/static/css/viewer.css
Normal 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
265
frontend/static/js/api.js
Normal 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();
|
||||
90
frontend/static/js/auth.js
Normal file
90
frontend/static/js/auth.js
Normal 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
286
frontend/static/js/main.js
Normal 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();
|
||||
}
|
||||
});
|
||||
641
frontend/static/js/viewer.js
Normal file
641
frontend/static/js/viewer.js
Normal 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
363
frontend/viewer.html
Normal 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
15
nginx/Dockerfile
Normal 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
75
nginx/default.conf
Normal 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
57
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user