🎉 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