🚀 배포용: PDF 뷰어 개선 및 서적별 UI 데본씽크 스타일 적용
✨ 주요 개선사항: - PDF API 500 에러 수정 (한글 파일명 UTF-8 인코딩 처리) - PDF 뷰어 기능 완전 구현 (PDF.js 통합, 네비게이션, 확대/축소) - 서적별 문서 그룹화 UI 데본씽크 스타일로 개선 - PDF Manager 페이지 서적별 보기 기능 추가 - Alpine.js 로드 순서 최적화로 JavaScript 에러 해결 🎨 UI/UX 개선: - 확장/축소 가능한 아코디언 스타일 서적 목록 - 간결하고 직관적인 데본씽크 스타일 인터페이스 - PDF 상태 표시 (HTML 연결, 서적 분류) - 반응형 디자인 및 부드러운 애니메이션 🔧 기술적 개선: - PDF.js 워커 설정 및 토큰 인증 처리 - 서적별 PDF 자동 그룹화 로직 - Alpine.js 컴포넌트 초기화 최적화
This commit is contained in:
113
.gitignore
vendored
Normal file
113
.gitignore
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
# 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
|
||||
# 업로드된 문서들 (개발용)
|
||||
backend/uploads/documents/*.html
|
||||
276
QUICK-START.md
Normal file
276
QUICK-START.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 🚀 빠른 시작 가이드
|
||||
|
||||
Document Server를 Synology DS1525+에 배포하는 가장 간단한 방법입니다.
|
||||
|
||||
## ⚠️ 실제 서비스 환경 주의사항
|
||||
|
||||
### 🚨 중요: 이 시스템은 실제 서비스 중입니다!
|
||||
이 Document Server는 **실제 운영 중인 맥미니**에 설치되어 사용되고 있습니다.
|
||||
|
||||
**절대 삭제하면 안 되는 원본 데이터:**
|
||||
- 모든 업로드된 문서 파일들
|
||||
- 사용자가 작성한 메모, 하이라이트, 노트북
|
||||
- 데이터베이스의 모든 사용자 데이터
|
||||
- 설정 파일 및 사용자 환경설정
|
||||
|
||||
### 🛡️ 업데이트 시 필수 원칙
|
||||
1. **백업 우선**: 모든 작업 전 반드시 백업
|
||||
2. **데이터 보존**: 기존 데이터 디렉토리는 절대 삭제 금지
|
||||
3. **점진적 적용**: 코드만 업데이트하고 데이터는 보존
|
||||
4. **즉시 롤백**: 문제 발생 시 바로 이전 버전으로 복구
|
||||
|
||||
## 📋 준비사항
|
||||
|
||||
- Synology DS1525+ (32GB RAM, SSD 캐시 활성화) 또는 Mac Mini
|
||||
- Docker 패키지 설치 (Package Center에서 설치)
|
||||
- SSH 접속 가능
|
||||
- Git 설치 (선택사항)
|
||||
- **중요**: 기존 서비스 중단 최소화를 위한 계획
|
||||
|
||||
## 🎯 한 번에 배포하기
|
||||
|
||||
### 방법 1: Git 클론 (권장) ⭐
|
||||
|
||||
```bash
|
||||
# 1. NAS에 SSH 접속
|
||||
ssh admin@your-nas-ip
|
||||
|
||||
# 2. 프로젝트 클론
|
||||
cd /volume1/docker/
|
||||
git clone https://git.hyungi.net/hyungi/document-server.git
|
||||
cd document-server
|
||||
|
||||
# 3. 자동 배포 (환경 설정 + 배포)
|
||||
./scripts/deploy-synology.sh
|
||||
```
|
||||
|
||||
### 방법 2: 파일 업로드
|
||||
|
||||
```bash
|
||||
# 1. 로컬에서 NAS로 파일 전송
|
||||
scp -r ./document-server admin@your-nas-ip:/volume1/docker/
|
||||
|
||||
# 2. NAS에 SSH 접속
|
||||
ssh admin@your-nas-ip
|
||||
cd /volume1/docker/document-server
|
||||
|
||||
# 3. 자동 배포
|
||||
./scripts/deploy-synology.sh
|
||||
```
|
||||
|
||||
## ⚙️ 환경 설정 (자동)
|
||||
|
||||
배포 스크립트 실행 시 자동으로 환경 설정이 시작됩니다:
|
||||
|
||||
```
|
||||
=== 🔧 Document Server 환경 변수 설정 ===
|
||||
|
||||
1. 데이터베이스 비밀번호
|
||||
기본값: AbC123XyZ (자동생성)
|
||||
입력: [엔터 = 기본값 사용]
|
||||
|
||||
2. JWT 시크릿 키 (보안용)
|
||||
기본값: kL9mN2pQ... (자동생성)
|
||||
입력: [엔터 = 기본값 사용]
|
||||
|
||||
3. 관리자 이메일
|
||||
기본값: admin@document-server.local
|
||||
입력: admin@mydomain.com
|
||||
|
||||
4. 관리자 비밀번호
|
||||
기본값: MyPass123 (자동생성)
|
||||
입력: [엔터 = 기본값 사용]
|
||||
|
||||
5. 도메인 이름 (외부 접속용)
|
||||
기본값: localhost
|
||||
입력: nas.mydomain.com
|
||||
```
|
||||
|
||||
**💡 팁**: 대부분 엔터만 눌러도 안전한 기본값이 자동 설정됩니다!
|
||||
|
||||
## 🎉 배포 완료 후
|
||||
|
||||
### 접속 확인
|
||||
```
|
||||
🌐 웹 인터페이스: http://your-nas-ip:24100
|
||||
📧 관리자 이메일: admin@document-server.local
|
||||
🔑 관리자 비밀번호: (설정 시 표시된 비밀번호)
|
||||
```
|
||||
|
||||
### 주요 기능 테스트
|
||||
1. **할일관리**: `http://your-nas-ip:24100/todos.html`
|
||||
2. **메모 트리**: `http://your-nas-ip:24100/memo-tree.html`
|
||||
3. **노트북**: `http://your-nas-ip:24100/notebooks.html`
|
||||
4. **문서 업로드**: 메인 페이지에서 HTML 파일 드래그&드롭
|
||||
|
||||
## 🔄 안전한 업데이트 방법
|
||||
|
||||
### ⚠️ 업데이트 전 필수 체크리스트
|
||||
```bash
|
||||
# 1. 현재 서비스 상태 확인
|
||||
docker-compose ps
|
||||
./scripts/monitor-synology.sh
|
||||
|
||||
# 2. 전체 백업 실행 (필수!)
|
||||
./scripts/backup.sh
|
||||
|
||||
# 3. 백업 파일 확인
|
||||
ls -la /volume2/document-storage/backups/
|
||||
|
||||
# 4. 사용자 알림 (서비스 중단 예고)
|
||||
echo "⚠️ 시스템 업데이트 예정 - 잠시 중단될 수 있습니다"
|
||||
```
|
||||
|
||||
### Git 사용 (권장) - 데이터 보호 포함
|
||||
```bash
|
||||
# NAS 또는 Mac Mini에 SSH 접속
|
||||
ssh admin@your-server-ip
|
||||
cd /volume1/docker/document-server # 또는 실제 설치 경로
|
||||
|
||||
# ⚠️ 중요: 자동 업데이트 스크립트는 백업을 포함합니다
|
||||
# 백업 + 업데이트 + 헬스체크 + 롤백 기능
|
||||
./scripts/update-synology.sh
|
||||
```
|
||||
|
||||
### 수동 업데이트 (고급 사용자용)
|
||||
```bash
|
||||
# 1. 필수 백업 실행
|
||||
./scripts/backup.sh
|
||||
|
||||
# 2. 로컬 변경사항 보존
|
||||
git stash # 설정 파일 등 로컬 변경사항 임시 저장
|
||||
|
||||
# 3. 코드 업데이트 (데이터 디렉토리 제외)
|
||||
git pull origin main
|
||||
|
||||
# 4. 로컬 변경사항 복원 (필요시)
|
||||
git stash pop
|
||||
|
||||
# 5. 컨테이너 재시작 (데이터 볼륨 보존)
|
||||
docker-compose -f docker-compose.synology-optimized.yml restart
|
||||
|
||||
# 6. 서비스 상태 확인
|
||||
./scripts/monitor-synology.sh
|
||||
```
|
||||
|
||||
### 🚨 데이터 보호 원칙
|
||||
- **볼륨 매핑 보존**: Docker 볼륨은 절대 삭제하지 않음
|
||||
- **백업 우선**: 모든 변경 전 반드시 백업 실행
|
||||
- **점진적 업데이트**: 한 번에 하나씩 컴포넌트 업데이트
|
||||
- **롤백 준비**: 문제 발생 시 즉시 이전 버전으로 복구
|
||||
|
||||
## 📊 모니터링
|
||||
|
||||
```bash
|
||||
# 시스템 상태 확인
|
||||
./scripts/monitor-synology.sh
|
||||
|
||||
# 실시간 모니터링 (5초 간격)
|
||||
watch -n 5 './scripts/monitor-synology.sh'
|
||||
|
||||
# 로그 확인
|
||||
docker-compose -f docker-compose.synology-optimized.yml logs -f
|
||||
```
|
||||
|
||||
## 🚨 문제 해결
|
||||
|
||||
### 포트 충돌
|
||||
```bash
|
||||
# 포트 사용 확인
|
||||
netstat -tuln | grep -E "(24100|24101|24102|24103)"
|
||||
|
||||
# 다른 포트 사용 시 .env.synology 파일 수정
|
||||
nano .env.synology
|
||||
# EXTERNAL_PORT=24200 (예시)
|
||||
```
|
||||
|
||||
### 권한 문제
|
||||
```bash
|
||||
# 디렉토리 권한 수정
|
||||
sudo chown -R 1000:1000 /volume1/docker/document-server/
|
||||
sudo chown -R 1000:1000 /volume2/document-storage/
|
||||
```
|
||||
|
||||
### 서비스 재시작
|
||||
```bash
|
||||
# 전체 재시작
|
||||
docker-compose -f docker-compose.synology-optimized.yml restart
|
||||
|
||||
# 특정 서비스만 재시작
|
||||
docker-compose -f docker-compose.synology-optimized.yml restart backend
|
||||
```
|
||||
|
||||
### 롤백 (업데이트 실패 시)
|
||||
```bash
|
||||
# 이전 버전으로 롤백
|
||||
./scripts/update-synology.sh rollback
|
||||
```
|
||||
|
||||
## 💾 백업
|
||||
|
||||
### 자동 백업 설정
|
||||
```bash
|
||||
# Synology 작업 스케줄러에서 설정
|
||||
# 제어판 > 작업 스케줄러 > 생성 > 사용자 정의 스크립트
|
||||
|
||||
# 매일 새벽 2시 백업
|
||||
0 2 * * * /volume1/docker/document-server/backup.sh
|
||||
```
|
||||
|
||||
### 수동 백업
|
||||
```bash
|
||||
# 즉시 백업 실행
|
||||
/volume1/docker/document-server/backup.sh
|
||||
|
||||
# 백업 파일 확인
|
||||
ls -la /volume2/document-storage/backups/
|
||||
```
|
||||
|
||||
## 🔒 보안 설정
|
||||
|
||||
### 방화벽 (권장)
|
||||
```bash
|
||||
# Synology 제어판 > 보안 > 방화벽
|
||||
# 규칙 추가: 포트 24100 허용
|
||||
```
|
||||
|
||||
### SSL 인증서 (외부 접속 시)
|
||||
```bash
|
||||
# Let's Encrypt 인증서 발급
|
||||
certbot certonly --webroot -w /volume2/document-storage/documents -d your-domain.com
|
||||
```
|
||||
|
||||
## 📞 도움말
|
||||
|
||||
### 로그 수집 (문제 보고 시)
|
||||
```bash
|
||||
# 시스템 리포트 생성
|
||||
./scripts/monitor-synology.sh > system-report.txt
|
||||
|
||||
# 로그 파일 위치
|
||||
/volume1/docker/document-server/logs/
|
||||
```
|
||||
|
||||
### 유용한 명령어
|
||||
```bash
|
||||
# Docker 상태 확인
|
||||
docker ps
|
||||
docker stats
|
||||
|
||||
# 디스크 사용량 확인
|
||||
df -h /volume1 /volume2
|
||||
|
||||
# 메모리 사용량 확인
|
||||
free -h
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 요약
|
||||
|
||||
1. **배포**: `./scripts/deploy-synology.sh` (한 번만)
|
||||
2. **업데이트**: `./scripts/update-synology.sh` (필요시)
|
||||
3. **모니터링**: `./scripts/monitor-synology.sh` (상태 확인)
|
||||
4. **접속**: `http://your-nas-ip:24100`
|
||||
|
||||
**🎉 이제 Document Server를 사용할 준비가 완료되었습니다!**
|
||||
319
README-DEPLOYMENT.md
Normal file
319
README-DEPLOYMENT.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# 🚀 Synology DS1525+ 배포 가이드
|
||||
|
||||
Document Server를 Synology DS1525+ NAS에 최적화하여 배포하는 가이드입니다.
|
||||
|
||||
## ⚠️ 실제 서비스 환경 경고
|
||||
|
||||
### 🚨 중요: 이 시스템은 실제 운영 중입니다!
|
||||
이 Document Server는 **실제 서비스 중인 맥미니**에 설치되어 운영되고 있습니다.
|
||||
|
||||
**절대 삭제하면 안 되는 원본 데이터:**
|
||||
- 모든 업로드된 문서 파일들 (`/volume2/document-storage/uploads/`)
|
||||
- 변환된 HTML 문서들 (`/volume2/document-storage/documents/`)
|
||||
- 데이터베이스 파일들 (`/volume1/docker/document-server/database/`)
|
||||
- 사용자가 작성한 모든 메모, 하이라이트, 노트북 데이터
|
||||
- Redis 캐시 데이터 (`/volume1/docker/document-server/redis/`)
|
||||
- 설정 파일들 (`/volume1/docker/document-server/config/`)
|
||||
|
||||
### 🛡️ 데이터 보호 필수 원칙
|
||||
1. **백업 우선**: 모든 작업 전 반드시 전체 백업 실행
|
||||
2. **볼륨 보존**: Docker 볼륨 매핑은 절대 변경/삭제 금지
|
||||
3. **점진적 업데이트**: 코드만 업데이트하고 데이터는 보존
|
||||
4. **즉시 롤백**: 문제 발생 시 바로 이전 상태로 복구
|
||||
|
||||
## 🏗️ 하드웨어 사양
|
||||
|
||||
### Synology DS1525+ 최적화 구성
|
||||
- **CPU**: AMD Ryzen R1600 (4코어/8스레드)
|
||||
- **메모리**: 32GB DDR4 ECC
|
||||
- **스토리지**: SSD 읽기/쓰기 캐시 활성화
|
||||
- **볼륨 구성**:
|
||||
- **Volume1 (SSD)**: 고성능 데이터 (데이터베이스, 캐시, 로그)
|
||||
- **Volume2 (HDD)**: 대용량 저장소 (문서, 업로드, 백업)
|
||||
|
||||
## 📁 스토리지 전략
|
||||
|
||||
### SSD 볼륨 (/volume1) - 성능 최우선
|
||||
```
|
||||
/volume1/docker/document-server/
|
||||
├── database/ # PostgreSQL 데이터 (8GB shared_buffers)
|
||||
├── redis/ # Redis 캐시 (8GB maxmemory)
|
||||
├── logs/ # 애플리케이션 로그
|
||||
├── config/ # 설정 파일
|
||||
├── nginx/
|
||||
│ ├── conf.d/ # Nginx 설정
|
||||
│ └── cache/ # Nginx 캐시 (2GB)
|
||||
└── cache/ # 애플리케이션 캐시
|
||||
```
|
||||
|
||||
### HDD 볼륨 (/volume2) - 대용량 저장
|
||||
```
|
||||
/volume2/document-storage/
|
||||
├── uploads/ # 업로드된 파일 (HTML, PDF)
|
||||
├── documents/ # 변환된 문서
|
||||
├── thumbnails/ # 썸네일 이미지
|
||||
├── backups/ # 자동 백업 파일
|
||||
└── archives/ # 아카이브 데이터
|
||||
```
|
||||
|
||||
## 🚀 배포 방법
|
||||
|
||||
### 1. 자동 배포 (권장)
|
||||
```bash
|
||||
# 저장소 클론
|
||||
git clone <repository-url>
|
||||
cd document-server
|
||||
|
||||
# 자동 배포 스크립트 실행
|
||||
./scripts/deploy-synology.sh
|
||||
```
|
||||
|
||||
### 2. 수동 배포
|
||||
```bash
|
||||
# 1. 디렉토리 생성
|
||||
sudo mkdir -p /volume1/docker/document-server/{database,redis,logs,config,nginx/conf.d,nginx/cache,cache}
|
||||
sudo mkdir -p /volume2/document-storage/{uploads,documents,thumbnails,backups,archives}
|
||||
|
||||
# 2. 권한 설정
|
||||
sudo chown -R 1000:1000 /volume1/docker/document-server/
|
||||
sudo chown -R 1000:1000 /volume2/document-storage/
|
||||
|
||||
# 3. 환경 변수 설정
|
||||
cp .env.example .env.synology
|
||||
# .env.synology 파일 편집
|
||||
|
||||
# 4. Docker Compose 실행
|
||||
docker-compose -f docker-compose.synology-optimized.yml up -d
|
||||
```
|
||||
|
||||
## ⚙️ 성능 최적화 설정
|
||||
|
||||
### PostgreSQL (32GB RAM 최적화)
|
||||
```ini
|
||||
# /volume1/docker/document-server/config/postgresql.synology.conf
|
||||
shared_buffers = 8GB # RAM의 25%
|
||||
effective_cache_size = 24GB # RAM의 75%
|
||||
work_mem = 512MB # 복잡한 쿼리용
|
||||
maintenance_work_mem = 4GB # 인덱스 구축용
|
||||
max_worker_processes = 8 # 4코어/8스레드 최적화
|
||||
max_parallel_workers_per_gather = 4
|
||||
random_page_cost = 1.1 # SSD 최적화
|
||||
effective_io_concurrency = 200 # SSD 동시 I/O
|
||||
```
|
||||
|
||||
### Redis (대용량 메모리 활용)
|
||||
```conf
|
||||
maxmemory 8gb # 캐시 메모리 제한
|
||||
maxmemory-policy allkeys-lru # LRU 정책
|
||||
appendonly yes # 데이터 지속성
|
||||
auto-aof-rewrite-percentage 100 # AOF 최적화
|
||||
```
|
||||
|
||||
### Nginx (SSD 캐시 최적화)
|
||||
```nginx
|
||||
# 캐시 존 설정 (SSD에 저장)
|
||||
proxy_cache_path /var/cache/nginx/documents
|
||||
levels=1:2
|
||||
keys_zone=documents:100m
|
||||
max_size=2g
|
||||
inactive=60m;
|
||||
|
||||
# Gzip 압축
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# 정적 파일 캐시
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 모니터링
|
||||
|
||||
### 실시간 모니터링
|
||||
```bash
|
||||
# 시스템 리소스 및 서비스 상태 확인
|
||||
./scripts/monitor-synology.sh
|
||||
|
||||
# 실시간 모니터링 (5초 간격)
|
||||
watch -n 5 './scripts/monitor-synology.sh'
|
||||
|
||||
# Docker 컨테이너 상태
|
||||
docker-compose -f docker-compose.synology-optimized.yml ps
|
||||
|
||||
# 실시간 로그
|
||||
docker-compose -f docker-compose.synology-optimized.yml logs -f
|
||||
|
||||
# 리소스 사용량
|
||||
docker stats
|
||||
```
|
||||
|
||||
### 주요 메트릭
|
||||
- **CPU 사용률**: 평상시 < 30%, 피크 < 70%
|
||||
- **메모리 사용률**: < 80% (32GB 중 25GB 이하)
|
||||
- **디스크 I/O**: SSD 캐시 효과로 응답 시간 < 100ms
|
||||
- **네트워크**: 기가비트 이더넷 활용
|
||||
|
||||
## 💾 백업 및 복구
|
||||
|
||||
### 자동 백업 설정
|
||||
```bash
|
||||
# Synology 작업 스케줄러에서 설정
|
||||
# 매일 새벽 2시 실행
|
||||
0 2 * * * /volume1/docker/document-server/backup.sh
|
||||
```
|
||||
|
||||
### 백업 내용
|
||||
- **데이터베이스**: PostgreSQL 덤프 (매일)
|
||||
- **설정 파일**: 압축 아카이브 (매일)
|
||||
- **문서 파일**: 증분 백업 (주간)
|
||||
- **보관 정책**: 7일간 보관 후 자동 삭제
|
||||
|
||||
### 복구 방법
|
||||
```bash
|
||||
# 데이터베이스 복구
|
||||
docker exec document-server-db psql -U docuser -d document_db < backup_file.sql
|
||||
|
||||
# 설정 파일 복구
|
||||
tar -xzf config_backup_YYYYMMDD_HHMMSS.tar.gz -C /volume1/docker/document-server/
|
||||
```
|
||||
|
||||
## 🔧 유지보수
|
||||
|
||||
### 정기 작업
|
||||
1. **주간**: 로그 파일 정리 및 압축
|
||||
2. **월간**: 데이터베이스 VACUUM 및 REINDEX
|
||||
3. **분기**: 전체 시스템 백업 및 복구 테스트
|
||||
4. **연간**: 하드웨어 점검 및 업그레이드 계획
|
||||
|
||||
### 로그 관리
|
||||
```bash
|
||||
# 로그 로테이션 설정
|
||||
/volume1/docker/document-server/logs/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 644 1000 1000
|
||||
}
|
||||
```
|
||||
|
||||
### 성능 튜닝
|
||||
```bash
|
||||
# PostgreSQL 통계 확인
|
||||
docker exec document-server-db psql -U docuser -d document_db -c "SELECT * FROM pg_stat_activity;"
|
||||
|
||||
# Redis 메모리 사용량 확인
|
||||
docker exec document-server-redis redis-cli info memory
|
||||
|
||||
# Nginx 캐시 효율성 확인
|
||||
docker exec document-server-nginx nginx -T
|
||||
```
|
||||
|
||||
## 🚨 트러블슈팅
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
#### 1. 메모리 부족
|
||||
```bash
|
||||
# 증상: 서비스 응답 지연, OOM 킬
|
||||
# 해결: PostgreSQL/Redis 메모리 설정 조정
|
||||
shared_buffers = 6GB # 8GB에서 감소
|
||||
maxmemory 6gb # 8GB에서 감소
|
||||
```
|
||||
|
||||
#### 2. 디스크 공간 부족
|
||||
```bash
|
||||
# SSD 공간 확보
|
||||
docker system prune -a
|
||||
find /volume1/docker/document-server/logs -name "*.log" -mtime +7 -delete
|
||||
|
||||
# HDD 공간 확보
|
||||
find /volume2/document-storage/backups -name "*.sql" -mtime +30 -delete
|
||||
```
|
||||
|
||||
#### 3. 네트워크 연결 문제
|
||||
```bash
|
||||
# 포트 확인
|
||||
netstat -tuln | grep -E "(24100|24101|24102|24103)"
|
||||
|
||||
# 방화벽 설정 확인
|
||||
iptables -L | grep -E "(24100|24101|24102|24103)"
|
||||
```
|
||||
|
||||
### 로그 위치
|
||||
- **애플리케이션**: `/volume1/docker/document-server/logs/`
|
||||
- **Nginx**: `/volume1/docker/document-server/logs/nginx/`
|
||||
- **PostgreSQL**: `docker logs document-server-db`
|
||||
- **Redis**: `docker logs document-server-redis`
|
||||
|
||||
## 📈 성능 벤치마크
|
||||
|
||||
### 예상 성능 (DS1525+ 32GB)
|
||||
- **동시 사용자**: 50-100명
|
||||
- **문서 처리**: 1000+ 문서
|
||||
- **응답 시간**: < 200ms (평균)
|
||||
- **업로드 속도**: 100MB/s (기가비트 네트워크)
|
||||
- **검색 속도**: < 100ms (인덱스 기반)
|
||||
|
||||
### 확장성
|
||||
- **수직 확장**: RAM 64GB까지 지원
|
||||
- **수평 확장**: 로드 밸런서 + 다중 백엔드
|
||||
- **스토리지**: 추가 볼륨 마운트 가능
|
||||
|
||||
## 🔒 보안 설정
|
||||
|
||||
### 네트워크 보안
|
||||
```bash
|
||||
# 방화벽 규칙 (필요한 포트만 개방)
|
||||
iptables -A INPUT -p tcp --dport 24100 -j ACCEPT # Nginx
|
||||
iptables -A INPUT -p tcp --dport 22 -j ACCEPT # SSH
|
||||
iptables -A INPUT -j DROP # 기본 차단
|
||||
```
|
||||
|
||||
### 데이터 보안
|
||||
- **암호화**: 데이터베이스 및 Redis 암호 설정
|
||||
- **백업 암호화**: GPG를 이용한 백업 파일 암호화
|
||||
- **접근 제어**: 사용자별 권한 관리
|
||||
- **SSL/TLS**: Let's Encrypt 인증서 적용
|
||||
|
||||
## 📞 지원 및 문의
|
||||
|
||||
### 문제 보고
|
||||
1. **로그 수집**: `./scripts/monitor-synology.sh > system-report.txt`
|
||||
2. **환경 정보**: Docker 버전, 시스템 사양
|
||||
3. **재현 단계**: 문제 발생 과정 상세 기록
|
||||
|
||||
### 안전한 업데이트 절차
|
||||
```bash
|
||||
# ⚠️ 업데이트 전 필수 백업
|
||||
./scripts/backup.sh
|
||||
|
||||
# 현재 상태 확인
|
||||
docker-compose -f docker-compose.synology-optimized.yml ps
|
||||
|
||||
# 로컬 변경사항 보존
|
||||
git stash # 설정 파일 등 로컬 변경사항 임시 저장
|
||||
|
||||
# 코드 업데이트 (데이터 보존)
|
||||
git pull origin main
|
||||
|
||||
# 로컬 변경사항 복원 (필요시)
|
||||
git stash pop
|
||||
|
||||
# 컨테이너 재빌드 (데이터 볼륨 보존)
|
||||
docker-compose -f docker-compose.synology-optimized.yml build --no-cache
|
||||
docker-compose -f docker-compose.synology-optimized.yml up -d
|
||||
|
||||
# 서비스 상태 확인
|
||||
./scripts/monitor-synology.sh
|
||||
```
|
||||
|
||||
### 🚨 업데이트 시 주의사항
|
||||
- **데이터 볼륨**: 절대 `docker-compose down -v` 사용 금지 (볼륨 삭제됨)
|
||||
- **백업 확인**: 업데이트 전 반드시 백업 파일 존재 확인
|
||||
- **롤백 준비**: 문제 발생 시 즉시 이전 버전으로 복구 가능해야 함
|
||||
- **서비스 중단**: 최소한의 중단 시간으로 업데이트 진행
|
||||
630
README.md
Normal file
630
README.md
Normal file
@@ -0,0 +1,630 @@
|
||||
# Document Server
|
||||
|
||||
HTML 문서 관리 및 뷰어 시스템
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
PDF 문서를 OCR 처리하고 AI로 HTML로 변환한 후, 웹에서 효율적으로 관리하고 열람할 수 있는 시스템입니다.
|
||||
|
||||
## 📝 용어 정의
|
||||
|
||||
시스템에서 사용하는 주요 용어들을 명확히 구분합니다:
|
||||
|
||||
### 핵심 용어
|
||||
- **메모 (Memo)**: 하이라이트 기반의 메모 기능
|
||||
- 하이라이트에 달리는 짧은 코멘트
|
||||
- 문서 뷰어에서 텍스트 선택 → 하이라이트 → 메모 작성
|
||||
- API: `/api/notes/` (하이라이트 메모 전용)
|
||||
|
||||
- **노트 (Note)**: 독립적인 문서 작성 기능
|
||||
- HTML 기반의 완전한 문서
|
||||
- 기본 뷰어 페이지에서 확인 및 편집
|
||||
- 하이라이트, 메모, 링크 등 모든 기능 사용 가능
|
||||
- 노트북에 그룹화 가능
|
||||
- API: `/api/note-documents/`
|
||||
|
||||
- **노트북 (Notebook)**: 노트 문서들을 그룹화하는 폴더
|
||||
- 노트들의 컨테이너 역할
|
||||
- 계층적 구조 지원
|
||||
- API: `/api/notebooks/`
|
||||
|
||||
### 기능별 구분
|
||||
| 기능 | 용어 | 설명 | 주요 API | 뷰어 지원 |
|
||||
|------|------|------|----------|----------|
|
||||
| 하이라이트 메모 | 메모 (Memo) | 하이라이트에 달리는 짧은 코멘트 | `/api/notes/` | ✅ 문서 뷰어 |
|
||||
| 독립 문서 작성 | 노트 (Note) | HTML 기반 완전한 문서 | `/api/note-documents/` | ✅ 동일 뷰어 (모든 기능) |
|
||||
| 문서 그룹화 | 노트북 (Notebook) | 노트들을 담는 폴더 | `/api/notebooks/` | - |
|
||||
|
||||
### 문서 처리 워크플로우
|
||||
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
|
||||
- **주 배포 환경**: Synology DS1525+ (32GB RAM, SSD 캐싱)
|
||||
- **보조 배포 환경**: Mac Mini (개발/테스트)
|
||||
- **프로세스 관리**: 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: 문서 관리 시스템 ✅
|
||||
- [x] 문서 태그 관리 시스템 (태그 생성, 필터링)
|
||||
- [x] 문서 메타데이터 관리 (제목, 설명, 날짜, 언어)
|
||||
- [x] 사용자별 권한 시스템
|
||||
- [x] 관리자 계정 기반 사용자 생성
|
||||
- [x] Paperless 스타일 문서 관리
|
||||
|
||||
### Phase 6: 시스템 안정성 및 통합 ✅
|
||||
- [x] 프론트엔드-백엔드 완전 연동
|
||||
- [x] Pydantic v2 호환성 수정
|
||||
- [x] Alpine.js 컴포넌트 간 안전한 통신
|
||||
- [x] API 오류 처리 및 사용자 피드백
|
||||
- [x] 실시간 문서 목록 새로고침
|
||||
|
||||
### Phase 7: 최우선 개선사항 ✅
|
||||
- [x] **메모-하이라이트 통합**: 하이라이트 기반 메모 기능 완전 통합
|
||||
- [x] **노트 뷰어 기능**: 노트에서 하이라이트, 메모, 링크 등 모든 기능 지원
|
||||
- [x] **API 구조 정리**: 메모(`/api/notes/`) vs 노트(`/api/note-documents/`) 명확한 분리
|
||||
- [x] **용어 통일**: 전체 시스템에서 메모/노트 용어 일관성 확보
|
||||
- [x] **노트북-서적 링크 시스템**: 양방향 링크/백링크 완전 구현
|
||||
|
||||
### Phase 8: 미완성 핵심 기능 (우선순위) 🚧
|
||||
- [x] **노트 편집기**: 노트 생성/편집 UI 완성 (`/note-editor.html`) ✅
|
||||
- [x] **노트북 관리 API**: 노트북 CRUD 백엔드 완성 ✅
|
||||
- [x] **노트북 관리 UI**: 프론트엔드 CRUD 기능 완성 (`/notebooks.html`) ✅
|
||||
- 노트북 목록 조회/표시, 생성/편집/삭제 모달
|
||||
- 토스트 알림 시스템, 통계 대시보드
|
||||
- 노트북별 노트 관리 및 빠른 노트 생성
|
||||
- [x] **메모 트리 시스템**: 계층적 메모 구조 및 관리 (`/memo-tree.html`) ✅
|
||||
- 트리 구조 메모 생성/편집/삭제, Monaco 에디터 통합
|
||||
- 드래그 앤 드롭으로 노드 재배치, 정사 경로 설정
|
||||
- 다양한 노드 타입 (메모, 폴더, 챕터, 캐릭터, 플롯)
|
||||
- 실시간 시각적 피드백 및 토스트 알림
|
||||
- [ ] **고급 검색**: 문서/노트/메모 통합 검색 필터링
|
||||
- [ ] **사용자 관리**: 다중 사용자 지원 및 권한 관리
|
||||
|
||||
### Phase 9: 관리 및 최적화 (예정)
|
||||
- [ ] 관리자 대시보드 UI
|
||||
- [ ] 문서 통계 및 분석
|
||||
- [ ] 모바일 반응형 최적화
|
||||
- [ ] 문서 버전 관리
|
||||
- [ ] 성능 최적화 및 캐싱
|
||||
|
||||
## 현재 상태 (2025-01-26)
|
||||
|
||||
### ✅ 완료된 기능
|
||||
- **완전한 백엔드 API**: FastAPI + SQLAlchemy + PostgreSQL
|
||||
- **사용자 인증**: JWT 기반 로그인/로그아웃
|
||||
- **문서 관리**: 업로드, 조회, 목록, 삭제 (드래그&드롭 지원)
|
||||
- **태그 시스템**: 문서 분류 및 필터링
|
||||
- **하이라이트 & 메모**: 텍스트 선택 → 하이라이트 → 메모 추가
|
||||
- **책갈피**: 페이지 북마크 및 빠른 이동
|
||||
- **통합 검색**: 문서 내용 + 메모 통합 검색
|
||||
- **실시간 UI**: 업로드 후 즉시 목록 새로고침
|
||||
- **할일관리 시스템**: 검토필요/TODO/완료된일 3단계 워크플로우
|
||||
- **메모 트리**: 계층적 메모 구조 및 Monaco 에디터
|
||||
- **노트북 시스템**: 노트 문서 그룹화 및 관리
|
||||
- **모바일 최적화**: 햅틱 피드백, 풀투리프레시, 반응형 디자인
|
||||
|
||||
### 🚀 테스트 가능한 기능
|
||||
1. **로그인**: `admin@test.com` / `admin123`
|
||||
2. **문서 업로드**: HTML 파일 드래그&드롭 또는 선택
|
||||
3. **문서 뷰어**: 업로드된 문서 클릭하여 뷰어 페이지 이동
|
||||
4. **태그 관리**: 업로드 시 태그 추가, 목록에서 태그별 필터링
|
||||
5. **할일관리**: `/todos.html` - 검토필요 → TODO → 완료된일 워크플로우
|
||||
6. **메모 트리**: `/memo-tree.html` - 계층적 메모 작성 및 관리
|
||||
7. **노트북**: `/notebooks.html` - 노트 문서 그룹화 및 편집
|
||||
|
||||
### 🔧 실행 중인 서비스
|
||||
- **프론트엔드**: http://localhost:24100
|
||||
- **백엔드 API**: http://localhost:24102
|
||||
- **데이터베이스**: PostgreSQL (포트 24101)
|
||||
- **캐시**: Redis (포트 24103)
|
||||
|
||||
## ⚠️ 실제 서비스 환경 주의사항
|
||||
|
||||
### 🚨 중요: 원본 데이터 보호
|
||||
이 시스템은 **실제 서비스 중인 맥미니**에 설치되어 운영되고 있습니다.
|
||||
|
||||
**절대 삭제하면 안 되는 원본 데이터:**
|
||||
- `/Users/hyungi/document-server/uploads/` - 업로드된 원본 문서들
|
||||
- `/Users/hyungi/document-server/frontend/uploads/` - 프론트엔드 업로드 파일들
|
||||
- 데이터베이스의 모든 사용자 데이터 (메모, 하이라이트, 노트북)
|
||||
- 사용자가 작성한 모든 콘텐츠
|
||||
|
||||
### 📋 업데이트/수정 전 필수 체크리스트
|
||||
```bash
|
||||
# 1. 현재 서비스 상태 확인
|
||||
docker-compose ps
|
||||
|
||||
# 2. 전체 백업 실행 (필수!)
|
||||
./scripts/backup.sh
|
||||
|
||||
# 3. 백업 파일 확인
|
||||
ls -la ./backups/
|
||||
|
||||
# 4. 데이터 디렉토리 보존 확인
|
||||
ls -la uploads/
|
||||
ls -la frontend/uploads/
|
||||
```
|
||||
|
||||
### 🛡️ 데이터 보호 원칙
|
||||
1. **원본 보존**: 업데이트 시 기존 데이터 디렉토리는 절대 삭제하지 않음
|
||||
2. **백업 우선**: 모든 변경 전 반드시 백업 실행
|
||||
3. **점진적 업데이트**: 코드만 업데이트하고 데이터는 보존
|
||||
4. **롤백 준비**: 문제 발생 시 즉시 이전 버전으로 복구 가능
|
||||
|
||||
### 🔄 안전한 업데이트 방법
|
||||
```bash
|
||||
# 1. 백업 실행
|
||||
cp -r uploads/ uploads_backup_$(date +%Y%m%d_%H%M%S)/
|
||||
cp -r frontend/uploads/ frontend_uploads_backup_$(date +%Y%m%d_%H%M%S)/
|
||||
|
||||
# 2. 코드 업데이트 (데이터 보존)
|
||||
git stash # 로컬 변경사항 임시 저장
|
||||
git pull origin main
|
||||
git stash pop # 필요시 로컬 변경사항 복원
|
||||
|
||||
# 3. 서비스 재시작 (데이터 볼륨 보존)
|
||||
docker-compose restart
|
||||
|
||||
# 4. 서비스 상태 확인
|
||||
docker-compose ps
|
||||
curl http://localhost:24100/health # 헬스체크
|
||||
```
|
||||
|
||||
## 설치 및 실행
|
||||
|
||||
### 개발 환경
|
||||
```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
|
||||
|
||||
# Synology DS1525+ 최적화 배포 (권장)
|
||||
./scripts/deploy-synology.sh
|
||||
```
|
||||
|
||||
### 📋 Synology NAS 배포
|
||||
DS1525+ (32GB RAM, SSD 캐시) 환경에 최적화된 배포 가이드는 [README-DEPLOYMENT.md](README-DEPLOYMENT.md)를 참조하세요.
|
||||
|
||||
**주요 특징:**
|
||||
- 32GB RAM 최적화 (PostgreSQL 8GB, Redis 8GB)
|
||||
- SSD/HDD 하이브리드 스토리지 전략
|
||||
- 자동 배포 스크립트 및 모니터링 도구
|
||||
- 성능 최적화된 설정 (Nginx 캐시, DB 튜닝)
|
||||
|
||||
## 🏢 Synology DS1525+ 최적화 배포
|
||||
|
||||
### 하드웨어 사양
|
||||
- **모델**: Synology DS1525+ (5-Bay NAS)
|
||||
- **CPU**: AMD Ryzen R1600 (4코어/8스레드)
|
||||
- **메모리**: 32GB DDR4 ECC
|
||||
- **스토리지**: SSD 읽기/쓰기 캐싱 활성화
|
||||
- **네트워크**: 기가비트 이더넷
|
||||
|
||||
### 스토리지 최적화 전략
|
||||
|
||||
#### SSD 배치 (고성능 요구)
|
||||
```bash
|
||||
# 시스템 및 고빈도 액세스 데이터
|
||||
/volume1/docker/document-server/
|
||||
├── database/ # PostgreSQL 데이터 (SSD)
|
||||
├── redis/ # Redis 캐시 (SSD)
|
||||
├── logs/ # 애플리케이션 로그 (SSD)
|
||||
└── config/ # 설정 파일 (SSD)
|
||||
```
|
||||
|
||||
#### HDD 배치 (대용량 저장)
|
||||
```bash
|
||||
# 대용량 파일 저장소
|
||||
/volume2/document-storage/
|
||||
├── documents/ # HTML 문서 파일 (HDD)
|
||||
├── uploads/ # 업로드된 원본 파일 (HDD)
|
||||
├── backups/ # 데이터베이스 백업 (HDD)
|
||||
└── archives/ # 아카이브 파일 (HDD)
|
||||
```
|
||||
|
||||
### Docker Compose 최적화 설정
|
||||
|
||||
#### 볼륨 매핑 (docker-compose.synology.yml)
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
database:
|
||||
volumes:
|
||||
# SSD: 데이터베이스 성능 최적화
|
||||
- /volume1/docker/document-server/database:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
volumes:
|
||||
# SSD: 캐시 성능 최적화
|
||||
- /volume1/docker/document-server/redis:/data
|
||||
|
||||
backend:
|
||||
volumes:
|
||||
# SSD: 로그 및 설정
|
||||
- /volume1/docker/document-server/logs:/app/logs
|
||||
- /volume1/docker/document-server/config:/app/config
|
||||
# HDD: 대용량 파일 저장
|
||||
- /volume2/document-storage/uploads:/app/uploads
|
||||
- /volume2/document-storage/documents:/app/documents
|
||||
|
||||
nginx:
|
||||
volumes:
|
||||
# SSD: 설정 및 캐시
|
||||
- /volume1/docker/document-server/nginx:/etc/nginx/conf.d
|
||||
# HDD: 정적 파일 서빙
|
||||
- /volume2/document-storage/documents:/usr/share/nginx/html/documents:ro
|
||||
```
|
||||
|
||||
### 시놀로지 환경 배포 명령어
|
||||
|
||||
```bash
|
||||
# 1. 디렉토리 생성
|
||||
sudo mkdir -p /volume1/docker/document-server/{database,redis,logs,config,nginx}
|
||||
sudo mkdir -p /volume2/document-storage/{documents,uploads,backups,archives}
|
||||
|
||||
# 2. 권한 설정
|
||||
sudo chown -R 1000:1000 /volume1/docker/document-server/
|
||||
sudo chown -R 1000:1000 /volume2/document-storage/
|
||||
|
||||
# 3. 시놀로지 최적화 배포
|
||||
docker-compose -f docker-compose.synology.yml up -d
|
||||
|
||||
# 4. 서비스 상태 확인
|
||||
docker-compose -f docker-compose.synology.yml ps
|
||||
```
|
||||
|
||||
### 성능 최적화 설정
|
||||
|
||||
#### PostgreSQL 튜닝 (32GB RAM 환경)
|
||||
```ini
|
||||
# postgresql.conf
|
||||
shared_buffers = 8GB # RAM의 25%
|
||||
effective_cache_size = 24GB # RAM의 75%
|
||||
work_mem = 256MB # 복잡한 쿼리용
|
||||
maintenance_work_mem = 2GB # 인덱스 구축용
|
||||
checkpoint_completion_target = 0.9 # SSD 최적화
|
||||
wal_buffers = 64MB # WAL 버퍼
|
||||
random_page_cost = 1.1 # SSD 환경 최적화
|
||||
```
|
||||
|
||||
#### Redis 설정 (캐싱 최적화)
|
||||
```conf
|
||||
# redis.conf
|
||||
maxmemory 4gb # 캐시 메모리 제한
|
||||
maxmemory-policy allkeys-lru # LRU 정책
|
||||
save 900 1 # 자동 저장 설정
|
||||
save 300 10
|
||||
save 60 10000
|
||||
```
|
||||
|
||||
### 백업 전략
|
||||
|
||||
#### 자동 백업 스크립트
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /volume1/docker/document-server/scripts/backup.sh
|
||||
|
||||
BACKUP_DIR="/volume2/document-storage/backups"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 데이터베이스 백업
|
||||
docker-compose -f docker-compose.synology.yml exec -T database \
|
||||
pg_dump -U postgres document_server > "$BACKUP_DIR/db_backup_$DATE.sql"
|
||||
|
||||
# 설정 파일 백업
|
||||
tar -czf "$BACKUP_DIR/config_backup_$DATE.tar.gz" \
|
||||
/volume1/docker/document-server/config/
|
||||
|
||||
# 7일 이상 된 백업 파일 삭제
|
||||
find "$BACKUP_DIR" -name "*.sql" -mtime +7 -delete
|
||||
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete
|
||||
|
||||
echo "Backup completed: $DATE"
|
||||
```
|
||||
|
||||
#### 시놀로지 작업 스케줄러 설정
|
||||
```bash
|
||||
# 매일 새벽 2시 자동 백업
|
||||
# 제어판 > 작업 스케줄러 > 생성 > 사용자 정의 스크립트
|
||||
0 2 * * * /volume1/docker/document-server/scripts/backup.sh
|
||||
```
|
||||
|
||||
### 모니터링 및 유지보수
|
||||
|
||||
#### 리소스 모니터링
|
||||
```bash
|
||||
# 컨테이너 리소스 사용량 확인
|
||||
docker stats
|
||||
|
||||
# 디스크 사용량 확인
|
||||
df -h /volume1 /volume2
|
||||
|
||||
# 시놀로지 시스템 상태
|
||||
cat /proc/meminfo | grep -E "MemTotal|MemAvailable"
|
||||
```
|
||||
|
||||
#### 로그 로테이션 설정
|
||||
```bash
|
||||
# /etc/logrotate.d/document-server
|
||||
/volume1/docker/document-server/logs/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 644 1000 1000
|
||||
postrotate
|
||||
docker-compose -f docker-compose.synology.yml restart backend
|
||||
endscript
|
||||
}
|
||||
```
|
||||
|
||||
### 네트워크 최적화
|
||||
|
||||
#### 포트 포워딩 설정
|
||||
- **외부 포트**: 24100 (HTTPS 리버스 프록시 권장)
|
||||
- **내부 포트**: 24100 (Nginx)
|
||||
- **방화벽**: 필요한 포트만 개방
|
||||
|
||||
#### SSL/TLS 설정 (Let's Encrypt)
|
||||
```bash
|
||||
# Certbot을 통한 SSL 인증서 자동 갱신
|
||||
docker run --rm -v /volume1/docker/document-server/ssl:/etc/letsencrypt \
|
||||
certbot/certbot certonly --webroot \
|
||||
-w /volume2/document-storage/documents \
|
||||
-d your-domain.com
|
||||
```
|
||||
|
||||
## 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 기반 문서 요약
|
||||
- 문서 버전 관리
|
||||
52
backend/Dockerfile
Normal file
52
backend/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
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 대신 pip 사용)
|
||||
RUN pip install --no-cache-dir \
|
||||
fastapi==0.104.1 \
|
||||
uvicorn[standard]==0.24.0 \
|
||||
sqlalchemy==2.0.23 \
|
||||
asyncpg==0.29.0 \
|
||||
psycopg2-binary==2.9.7 \
|
||||
alembic==1.12.1 \
|
||||
python-jose[cryptography]==3.3.0 \
|
||||
passlib[bcrypt]==1.7.4 \
|
||||
python-multipart==0.0.6 \
|
||||
pillow==10.1.0 \
|
||||
redis==5.0.1 \
|
||||
pydantic[email]==2.5.0 \
|
||||
pydantic-settings==2.1.0 \
|
||||
python-dotenv==1.0.0 \
|
||||
httpx==0.25.2 \
|
||||
aiofiles==23.2.1 \
|
||||
jinja2==3.1.2 \
|
||||
greenlet==3.0.0
|
||||
|
||||
# 애플리케이션 코드 복사
|
||||
COPY src/ ./src/
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
# 환경변수 설정
|
||||
ENV PYTHONPATH=/app
|
||||
ENV DATABASE_URL=postgresql+asyncpg://docuser:docpass@database:5432/document_db
|
||||
ENV SECRET_KEY=production-secret-key-change-this
|
||||
ENV ADMIN_EMAIL=admin@test.com
|
||||
ENV ADMIN_PASSWORD=admin123
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 애플리케이션 실행 (직접 uvicorn 실행)
|
||||
CMD ["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"]
|
||||
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal file
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- 트리 구조 메모장 테이블 생성
|
||||
-- 005_create_memo_tree_tables.sql
|
||||
|
||||
-- 메모 트리 (프로젝트/워크스페이스)
|
||||
CREATE TABLE memo_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general'
|
||||
template_data JSONB, -- 템플릿별 메타데이터
|
||||
settings JSONB DEFAULT '{}', -- 트리별 설정
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 메모 노드 (트리의 각 노드)
|
||||
CREATE TABLE memo_nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 기본 정보
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT, -- 실제 메모 내용 (Markdown)
|
||||
node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
-- 트리 구조 관리
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
depth_level INTEGER DEFAULT 0,
|
||||
path TEXT, -- 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 메타데이터
|
||||
tags TEXT[], -- 태그 배열
|
||||
node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등)
|
||||
|
||||
-- 상태 관리
|
||||
status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete'
|
||||
word_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 시간 정보
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 제약 조건
|
||||
CONSTRAINT no_self_reference CHECK (id != parent_id)
|
||||
);
|
||||
|
||||
-- 메모 노드 버전 관리 (선택적)
|
||||
CREATE TABLE memo_node_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT,
|
||||
node_metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(node_id, version_number)
|
||||
);
|
||||
|
||||
-- 메모 트리 공유 (협업 기능)
|
||||
CREATE TABLE memo_tree_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin'
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(tree_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id);
|
||||
CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type);
|
||||
CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id);
|
||||
CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id);
|
||||
CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id);
|
||||
CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/'));
|
||||
CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags);
|
||||
CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type);
|
||||
CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id);
|
||||
CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id);
|
||||
|
||||
-- 트리거 함수: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
CREATE TRIGGER memo_trees_updated_at
|
||||
BEFORE UPDATE ON memo_trees
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
CREATE TRIGGER memo_nodes_updated_at
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
-- 트리거 함수: 경로 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_node_path()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 루트 노드인 경우
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
NEW.path = '/' || NEW.id::text;
|
||||
NEW.depth_level = 0;
|
||||
ELSE
|
||||
-- 부모 노드의 경로를 가져와서 확장
|
||||
SELECT path || '/' || NEW.id::text, depth_level + 1
|
||||
INTO NEW.path, NEW.depth_level
|
||||
FROM memo_nodes
|
||||
WHERE id = NEW.parent_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 경로 업데이트 트리거
|
||||
CREATE TRIGGER memo_nodes_path_update
|
||||
BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_node_path();
|
||||
|
||||
-- 샘플 데이터 (개발용)
|
||||
-- 소설 템플릿 예시
|
||||
INSERT INTO memo_trees (user_id, title, description, tree_type, template_data)
|
||||
SELECT
|
||||
u.id,
|
||||
'내 첫 번째 소설',
|
||||
'판타지 소설 프로젝트',
|
||||
'novel',
|
||||
'{
|
||||
"genre": "fantasy",
|
||||
"target_length": 100000,
|
||||
"chapters_planned": 20,
|
||||
"main_characters": [],
|
||||
"world_building": {}
|
||||
}'::jsonb
|
||||
FROM users u
|
||||
WHERE u.email = 'admin@test.com'
|
||||
LIMIT 1;
|
||||
98
backend/database/migrations/006_add_canonical_path.sql
Normal file
98
backend/database/migrations/006_add_canonical_path.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- 006_add_canonical_path.sql
|
||||
-- 정사 경로 표시를 위한 필드 추가
|
||||
|
||||
-- memo_nodes 테이블에 정사 경로 관련 필드 추가
|
||||
ALTER TABLE memo_nodes
|
||||
ADD COLUMN is_canonical BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN canonical_order INTEGER DEFAULT NULL,
|
||||
ADD COLUMN story_path TEXT DEFAULT NULL; -- 정사 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 정사 경로 순서를 위한 인덱스 추가
|
||||
CREATE INDEX idx_memo_nodes_canonical_order ON memo_nodes(tree_id, canonical_order) WHERE is_canonical = TRUE;
|
||||
|
||||
-- 트리별 정사 경로 통계를 위한 뷰 생성
|
||||
CREATE OR REPLACE VIEW memo_tree_canonical_stats AS
|
||||
SELECT
|
||||
t.id as tree_id,
|
||||
t.title as tree_title,
|
||||
COUNT(n.id) as total_nodes,
|
||||
COUNT(CASE WHEN n.is_canonical = TRUE THEN 1 END) as canonical_nodes,
|
||||
MAX(n.canonical_order) as max_canonical_order,
|
||||
STRING_AGG(
|
||||
CASE WHEN n.is_canonical = TRUE THEN n.title END,
|
||||
' → '
|
||||
ORDER BY n.canonical_order
|
||||
) as canonical_story_path
|
||||
FROM memo_trees t
|
||||
LEFT JOIN memo_nodes n ON t.id = n.tree_id
|
||||
GROUP BY t.id, t.title;
|
||||
|
||||
-- 정사 경로 순서 자동 업데이트 함수 (분기점에서 하나만 선택 가능)
|
||||
CREATE OR REPLACE FUNCTION update_canonical_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 정사로 설정될 때
|
||||
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
|
||||
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
|
||||
IF NEW.parent_id IS NOT NULL THEN
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND parent_id = NEW.parent_id
|
||||
AND id != NEW.id
|
||||
AND is_canonical = TRUE;
|
||||
END IF;
|
||||
|
||||
-- 부모 노드의 순서를 기준으로 순서 계산
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
-- 루트 노드는 항상 1
|
||||
NEW.canonical_order = 1;
|
||||
ELSE
|
||||
-- 부모 노드의 순서 + 1
|
||||
SELECT COALESCE(parent.canonical_order, 0) + 1
|
||||
INTO NEW.canonical_order
|
||||
FROM memo_nodes parent
|
||||
WHERE parent.id = NEW.parent_id AND parent.is_canonical = TRUE;
|
||||
|
||||
-- 부모가 정사가 아니면 순서 할당 안함
|
||||
IF NEW.canonical_order IS NULL THEN
|
||||
NEW.canonical_order = NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- 정사 경로 업데이트
|
||||
NEW.story_path = COALESCE(NEW.path, '');
|
||||
END IF;
|
||||
|
||||
-- 정사에서 제외될 때 순서 제거
|
||||
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
|
||||
NEW.canonical_order = NULL;
|
||||
NEW.story_path = NULL;
|
||||
|
||||
-- 뒤의 순서들을 앞으로 당기기
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = canonical_order - 1
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND is_canonical = TRUE
|
||||
AND canonical_order > OLD.canonical_order;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
|
||||
CREATE TRIGGER trigger_update_canonical_order
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_canonical_order();
|
||||
|
||||
-- 기존 루트 노드들을 정사로 설정 (기본값)
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = TRUE, canonical_order = 1
|
||||
WHERE parent_id IS NULL AND is_canonical = FALSE;
|
||||
|
||||
COMMENT ON COLUMN memo_nodes.is_canonical IS '정사 경로 여부 (소설의 메인 스토리라인)';
|
||||
COMMENT ON COLUMN memo_nodes.canonical_order IS '정사 경로에서의 순서 (1부터 시작)';
|
||||
COMMENT ON COLUMN memo_nodes.story_path IS '정사 경로 문자열 표현';
|
||||
58
backend/database/migrations/006_create_todo_tables.sql
Normal file
58
backend/database/migrations/006_create_todo_tables.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- 할일관리 시스템 테이블 생성
|
||||
|
||||
-- 할일 아이템 테이블
|
||||
CREATE TABLE IF NOT EXISTS todo_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 기본 정보
|
||||
content TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'active', 'completed', 'delayed', 'split')),
|
||||
|
||||
-- 시간 관리
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
start_date TIMESTAMP WITH TIME ZONE,
|
||||
estimated_minutes INTEGER CHECK (estimated_minutes > 0 AND estimated_minutes <= 120),
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
delayed_until TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- 분할 관리
|
||||
parent_id UUID REFERENCES todo_items(id) ON DELETE CASCADE,
|
||||
split_order INTEGER,
|
||||
|
||||
-- 인덱스
|
||||
CONSTRAINT unique_split_order UNIQUE (parent_id, split_order)
|
||||
);
|
||||
|
||||
-- 할일 댓글 테이블
|
||||
CREATE TABLE IF NOT EXISTS todo_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
todo_item_id UUID NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_status ON todo_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_start_date ON todo_items(start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_items_parent_id ON todo_items(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_comments_todo_item_id ON todo_comments(todo_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_comments_user_id ON todo_comments(user_id);
|
||||
|
||||
-- 트리거: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_todo_comments_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_todo_comments_updated_at
|
||||
BEFORE UPDATE ON todo_comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_todo_comments_updated_at();
|
||||
97
backend/database/migrations/007_fix_canonical_order.sql
Normal file
97
backend/database/migrations/007_fix_canonical_order.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- 007_fix_canonical_order.sql
|
||||
-- 정사 경로 순서 계산 로직 수정
|
||||
|
||||
-- 기존 트리거 삭제
|
||||
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
|
||||
DROP FUNCTION IF EXISTS update_canonical_order();
|
||||
|
||||
-- 정사 경로 순서를 올바르게 계산하는 함수
|
||||
CREATE OR REPLACE FUNCTION update_canonical_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 정사로 설정될 때
|
||||
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
|
||||
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
|
||||
IF NEW.parent_id IS NOT NULL THEN
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND parent_id = NEW.parent_id
|
||||
AND id != NEW.id
|
||||
AND is_canonical = TRUE;
|
||||
END IF;
|
||||
|
||||
-- 정사 경로 업데이트
|
||||
NEW.story_path = COALESCE(NEW.path, '');
|
||||
|
||||
-- 순서는 별도 함수에서 일괄 계산
|
||||
PERFORM recalculate_canonical_orders(NEW.tree_id);
|
||||
END IF;
|
||||
|
||||
-- 정사에서 제외될 때
|
||||
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
|
||||
NEW.canonical_order = NULL;
|
||||
NEW.story_path = NULL;
|
||||
|
||||
-- 순서 재계산
|
||||
PERFORM recalculate_canonical_orders(NEW.tree_id);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리별 정사 경로 순서를 DFS로 재계산하는 함수
|
||||
CREATE OR REPLACE FUNCTION recalculate_canonical_orders(tree_uuid UUID)
|
||||
RETURNS VOID AS $$
|
||||
DECLARE
|
||||
current_order INTEGER := 1;
|
||||
BEGIN
|
||||
-- 모든 정사 노드의 순서를 NULL로 초기화
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = NULL
|
||||
WHERE tree_id = tree_uuid AND is_canonical = TRUE;
|
||||
|
||||
-- DFS로 순서 할당 (재귀 CTE 사용)
|
||||
WITH RECURSIVE canonical_path AS (
|
||||
-- 루트 노드들 (정사인 것만)
|
||||
SELECT id, parent_id, title, 1 as order_num, ARRAY[id] as path
|
||||
FROM memo_nodes
|
||||
WHERE tree_id = tree_uuid
|
||||
AND parent_id IS NULL
|
||||
AND is_canonical = TRUE
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 자식 노드들 (정사인 것만)
|
||||
SELECT n.id, n.parent_id, n.title,
|
||||
cp.order_num + 1 as order_num,
|
||||
cp.path || n.id
|
||||
FROM memo_nodes n
|
||||
INNER JOIN canonical_path cp ON n.parent_id = cp.id
|
||||
WHERE n.tree_id = tree_uuid
|
||||
AND n.is_canonical = TRUE
|
||||
)
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = cp.order_num
|
||||
FROM canonical_path cp
|
||||
WHERE memo_nodes.id = cp.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 다시 생성
|
||||
CREATE TRIGGER trigger_update_canonical_order
|
||||
AFTER UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_canonical_order();
|
||||
|
||||
-- 기존 데이터의 순서 재계산
|
||||
DO $$
|
||||
DECLARE
|
||||
tree_rec RECORD;
|
||||
BEGIN
|
||||
FOR tree_rec IN SELECT DISTINCT tree_id FROM memo_nodes WHERE is_canonical = TRUE
|
||||
LOOP
|
||||
PERFORM recalculate_canonical_orders(tree_rec.tree_id);
|
||||
END LOOP;
|
||||
END $$;
|
||||
50
backend/migrations/004_add_books_table.sql
Normal file
50
backend/migrations/004_add_books_table.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- 서적 테이블 및 관계 추가
|
||||
-- 2025-08-22: 서적 그룹화 기능 추가
|
||||
|
||||
-- 서적 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(500) NOT NULL,
|
||||
author VARCHAR(200),
|
||||
publisher VARCHAR(200),
|
||||
isbn VARCHAR(20) UNIQUE,
|
||||
description TEXT,
|
||||
language VARCHAR(10) DEFAULT 'ko',
|
||||
total_pages INTEGER DEFAULT 0,
|
||||
cover_image_path VARCHAR(500),
|
||||
is_public BOOLEAN DEFAULT true,
|
||||
tags VARCHAR(1000),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_books_title ON books(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_books_author ON books(author);
|
||||
CREATE INDEX IF NOT EXISTS idx_books_created_at ON books(created_at);
|
||||
|
||||
-- documents 테이블에 book_id 컬럼 추가
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS book_id UUID;
|
||||
|
||||
-- 외래키 제약조건 추가
|
||||
ALTER TABLE documents ADD CONSTRAINT IF NOT EXISTS fk_documents_book_id
|
||||
FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE SET NULL;
|
||||
|
||||
-- book_id 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_book_id ON documents(book_id);
|
||||
|
||||
-- 업데이트 트리거 함수 생성 (updated_at 자동 업데이트)
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- books 테이블에 업데이트 트리거 추가
|
||||
DROP TRIGGER IF EXISTS update_books_updated_at ON books;
|
||||
CREATE TRIGGER update_books_updated_at
|
||||
BEFORE UPDATE ON books
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
12
backend/migrations/005_add_matched_pdf_id.sql
Normal file
12
backend/migrations/005_add_matched_pdf_id.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 문서에 PDF 매칭 필드 추가
|
||||
-- Migration: 005_add_matched_pdf_id.sql
|
||||
|
||||
-- matched_pdf_id 컬럼 추가
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN matched_pdf_id UUID REFERENCES documents(id);
|
||||
|
||||
-- 인덱스 추가 (성능 향상)
|
||||
CREATE INDEX idx_documents_matched_pdf_id ON documents(matched_pdf_id);
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON COLUMN documents.matched_pdf_id IS '매칭된 PDF 문서 ID (HTML 문서에 연결된 원본 PDF)';
|
||||
9
backend/migrations/006_make_html_path_nullable.sql
Normal file
9
backend/migrations/006_make_html_path_nullable.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- HTML 경로를 nullable로 변경 (PDF만 업로드하는 경우 대응)
|
||||
-- Migration: 006_make_html_path_nullable.sql
|
||||
|
||||
-- html_path 컬럼을 nullable로 변경
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN html_path DROP NOT NULL;
|
||||
|
||||
-- 코멘트 업데이트
|
||||
COMMENT ON COLUMN documents.html_path IS 'HTML 파일 경로 (PDF만 업로드하는 경우 null 가능)';
|
||||
34
backend/migrations/007_add_document_links.sql
Normal file
34
backend/migrations/007_add_document_links.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- 문서 링크 테이블 생성
|
||||
CREATE TABLE document_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
target_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
selected_text TEXT NOT NULL,
|
||||
start_offset INTEGER NOT NULL,
|
||||
end_offset INTEGER NOT NULL,
|
||||
link_text VARCHAR(500),
|
||||
description TEXT,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_document_links_source_document_id ON document_links(source_document_id);
|
||||
CREATE INDEX idx_document_links_target_document_id ON document_links(target_document_id);
|
||||
CREATE INDEX idx_document_links_created_by ON document_links(created_by);
|
||||
CREATE INDEX idx_document_links_start_offset ON document_links(start_offset);
|
||||
|
||||
-- 업데이트 트리거 생성
|
||||
CREATE OR REPLACE FUNCTION update_document_links_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_document_links_updated_at
|
||||
BEFORE UPDATE ON document_links
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_document_links_updated_at();
|
||||
24
backend/migrations/008_enhance_document_links.sql
Normal file
24
backend/migrations/008_enhance_document_links.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- 문서 링크 테이블에 고급 기능을 위한 컬럼 추가
|
||||
|
||||
-- 도착점 텍스트 정보 컬럼 추가
|
||||
ALTER TABLE document_links
|
||||
ADD COLUMN target_text TEXT,
|
||||
ADD COLUMN target_start_offset INTEGER,
|
||||
ADD COLUMN target_end_offset INTEGER;
|
||||
|
||||
-- 링크 타입 컬럼 추가 (기본값: document)
|
||||
ALTER TABLE document_links
|
||||
ADD COLUMN link_type VARCHAR(20) DEFAULT 'document' NOT NULL;
|
||||
|
||||
-- 기존 데이터의 link_type을 'document'로 설정 (이미 기본값이지만 명시적으로)
|
||||
UPDATE document_links SET link_type = 'document' WHERE link_type IS NULL;
|
||||
|
||||
-- 인덱스 추가 (성능 향상)
|
||||
CREATE INDEX idx_document_links_link_type ON document_links(link_type);
|
||||
CREATE INDEX idx_document_links_target_offset ON document_links(target_document_id, target_start_offset, target_end_offset);
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON COLUMN document_links.target_text IS '대상 문서에서 선택된 텍스트';
|
||||
COMMENT ON COLUMN document_links.target_start_offset IS '대상 문서에서 텍스트 시작 위치';
|
||||
COMMENT ON COLUMN document_links.target_end_offset IS '대상 문서에서 텍스트 끝 위치';
|
||||
COMMENT ON COLUMN document_links.link_type IS '링크 타입: document(전체 문서) 또는 text_fragment(특정 텍스트 부분)';
|
||||
81
backend/migrations/009_create_notes_system.sql
Normal file
81
backend/migrations/009_create_notes_system.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- 노트 관리 시스템 생성
|
||||
-- 009_create_notes_system.sql
|
||||
|
||||
-- 노트 문서 테이블
|
||||
CREATE TABLE IF NOT EXISTS notes_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT, -- 마크다운 내용
|
||||
html_content TEXT, -- 변환된 HTML 내용
|
||||
note_type VARCHAR(50) DEFAULT 'note', -- note, research, summary, idea 등
|
||||
tags TEXT[] DEFAULT '{}', -- 태그 배열
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by VARCHAR(100) NOT NULL,
|
||||
is_published BOOLEAN DEFAULT false, -- 공개 여부
|
||||
parent_note_id UUID REFERENCES notes_documents(id) ON DELETE SET NULL, -- 계층 구조
|
||||
sort_order INTEGER DEFAULT 0, -- 정렬 순서
|
||||
word_count INTEGER DEFAULT 0, -- 단어 수
|
||||
reading_time INTEGER DEFAULT 0, -- 예상 읽기 시간 (분)
|
||||
|
||||
-- 인덱스
|
||||
CONSTRAINT notes_documents_title_check CHECK (char_length(title) > 0)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_documents_created_by ON notes_documents(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_documents_created_at ON notes_documents(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_documents_note_type ON notes_documents(note_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_documents_parent_note_id ON notes_documents(parent_note_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_documents_tags ON notes_documents USING GIN(tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_documents_is_published ON notes_documents(is_published);
|
||||
|
||||
-- 업데이트 시간 자동 갱신 트리거
|
||||
CREATE OR REPLACE FUNCTION update_notes_documents_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_notes_documents_updated_at
|
||||
BEFORE UPDATE ON notes_documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_notes_documents_updated_at();
|
||||
|
||||
-- 기존 document_links 테이블에 노트 지원 추가
|
||||
-- (이미 존재하는 테이블이므로 ALTER 사용)
|
||||
DO $$
|
||||
BEGIN
|
||||
-- source_type, target_type 컬럼이 없다면 추가
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'document_links' AND column_name = 'source_type'
|
||||
) THEN
|
||||
ALTER TABLE document_links
|
||||
ADD COLUMN source_type VARCHAR(20) DEFAULT 'document',
|
||||
ADD COLUMN target_type VARCHAR(20) DEFAULT 'document';
|
||||
|
||||
-- 기존 데이터는 모두 'document' 타입으로 설정
|
||||
UPDATE document_links SET source_type = 'document', target_type = 'document';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 노트 관련 링크를 위한 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_source_type ON document_links(source_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_target_type ON document_links(target_type);
|
||||
|
||||
-- 샘플 노트 타입 데이터
|
||||
INSERT INTO notes_documents (title, content, html_content, note_type, tags, created_by, is_published)
|
||||
VALUES
|
||||
('노트 시스템 사용법',
|
||||
'# 노트 시스템 사용법\n\n## 기본 기능\n- 마크다운으로 노트 작성\n- HTML로 자동 변환\n- 태그 기반 분류\n\n## 고급 기능\n- 서적과 링크 연결\n- 계층 구조 지원\n- 내보내기 기능',
|
||||
'<h1>노트 시스템 사용법</h1><h2>기본 기능</h2><ul><li>마크다운으로 노트 작성</li><li>HTML로 자동 변환</li><li>태그 기반 분류</li></ul><h2>고급 기능</h2><ul><li>서적과 링크 연결</li><li>계층 구조 지원</li><li>내보내기 기능</li></ul>',
|
||||
'guide',
|
||||
ARRAY['가이드', '사용법', '시스템'],
|
||||
'Administrator',
|
||||
true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
25
backend/migrations/010_create_notebooks.sql
Normal file
25
backend/migrations/010_create_notebooks.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- 노트북 시스템 생성
|
||||
CREATE TABLE notebooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7) DEFAULT '#3B82F6', -- 헥스 컬러 코드
|
||||
icon VARCHAR(50) DEFAULT 'book', -- FontAwesome 아이콘 이름
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_by VARCHAR(100) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 노트북-노트 관계 테이블 (기존 notes_documents의 parent_note_id 대신 사용)
|
||||
ALTER TABLE notes_documents ADD COLUMN notebook_id UUID REFERENCES notebooks(id);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_notebooks_created_by ON notebooks(created_by);
|
||||
CREATE INDEX idx_notebooks_created_at ON notebooks(created_at);
|
||||
CREATE INDEX idx_notes_notebook_id ON notes_documents(notebook_id);
|
||||
|
||||
-- 기본 노트북 생성 (기존 노트들을 위한)
|
||||
INSERT INTO notebooks (title, description, created_by, color, icon)
|
||||
VALUES ('기본 노트북', '분류되지 않은 노트들', 'admin@test.com', '#6B7280', 'sticky-note');
|
||||
48
backend/migrations/011_create_note_highlights_and_notes.sql
Normal file
48
backend/migrations/011_create_note_highlights_and_notes.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- 노트용 하이라이트 테이블 생성
|
||||
CREATE TABLE note_highlights (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE,
|
||||
start_offset INTEGER NOT NULL,
|
||||
end_offset INTEGER NOT NULL,
|
||||
selected_text TEXT NOT NULL,
|
||||
highlight_color VARCHAR(50) NOT NULL DEFAULT '#FFFF00',
|
||||
highlight_type VARCHAR(50) NOT NULL DEFAULT 'highlight',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
-- 노트용 메모 테이블 생성
|
||||
CREATE TABLE note_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE,
|
||||
highlight_id UUID REFERENCES note_highlights(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX ix_note_highlights_note_id ON note_highlights (note_id);
|
||||
CREATE INDEX ix_note_highlights_created_by ON note_highlights (created_by);
|
||||
CREATE INDEX ix_note_notes_note_id ON note_notes (note_id);
|
||||
CREATE INDEX ix_note_notes_highlight_id ON note_notes (highlight_id);
|
||||
CREATE INDEX ix_note_notes_created_by ON note_notes (created_by);
|
||||
|
||||
-- updated_at 자동 업데이트 트리거
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_note_highlights_updated_at
|
||||
BEFORE UPDATE ON note_highlights
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_note_notes_updated_at
|
||||
BEFORE UPDATE ON note_notes
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
75
backend/migrations/011_create_note_links.sql
Normal file
75
backend/migrations/011_create_note_links.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- 노트 링크 테이블 생성
|
||||
-- 노트 문서 간 또는 노트-문서 간 링크를 관리하는 테이블
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- 링크 출발점 (노트 또는 문서 중 하나)
|
||||
source_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
|
||||
source_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
|
||||
|
||||
-- 링크 도착점 (노트 또는 문서 중 하나)
|
||||
target_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
|
||||
target_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
|
||||
|
||||
-- 출발점 텍스트 정보
|
||||
selected_text TEXT NOT NULL,
|
||||
start_offset INTEGER NOT NULL,
|
||||
end_offset INTEGER NOT NULL,
|
||||
|
||||
-- 도착점 텍스트 정보 (선택사항)
|
||||
target_text TEXT,
|
||||
target_start_offset INTEGER,
|
||||
target_end_offset INTEGER,
|
||||
|
||||
-- 링크 메타데이터
|
||||
link_text VARCHAR(500),
|
||||
description TEXT,
|
||||
link_type VARCHAR(20) DEFAULT 'note' NOT NULL,
|
||||
|
||||
-- 생성자 및 시간 정보
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- 제약 조건
|
||||
CONSTRAINT note_links_source_check CHECK (
|
||||
(source_note_id IS NOT NULL AND source_document_id IS NULL) OR
|
||||
(source_note_id IS NULL AND source_document_id IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT note_links_target_check CHECK (
|
||||
(target_note_id IS NOT NULL AND target_document_id IS NULL) OR
|
||||
(target_note_id IS NULL AND target_document_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_source_note ON note_links(source_note_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_source_document ON note_links(source_document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target_note ON note_links(target_note_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target_document ON note_links(target_document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_created_by ON note_links(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_created_at ON note_links(created_at);
|
||||
|
||||
-- updated_at 자동 업데이트 트리거
|
||||
CREATE OR REPLACE FUNCTION update_note_links_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_note_links_updated_at
|
||||
BEFORE UPDATE ON note_links
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_note_links_updated_at();
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON TABLE note_links IS '노트 문서 간 링크 관리 테이블';
|
||||
COMMENT ON COLUMN note_links.source_note_id IS '출발점 노트 ID (노트에서 시작하는 링크)';
|
||||
COMMENT ON COLUMN note_links.source_document_id IS '출발점 문서 ID (문서에서 시작하는 링크)';
|
||||
COMMENT ON COLUMN note_links.target_note_id IS '도착점 노트 ID';
|
||||
COMMENT ON COLUMN note_links.target_document_id IS '도착점 문서 ID';
|
||||
COMMENT ON COLUMN note_links.link_type IS '링크 타입: note, document, text_fragment';
|
||||
|
||||
87
backend/pyproject.toml
Normal file
87
backend/pyproject.toml
Normal file
@@ -0,0 +1,87 @@
|
||||
[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"
|
||||
beautifulsoup4 = "^4.13.0"
|
||||
pypdf2 = "^3.0.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 패키지 초기화
|
||||
"""
|
||||
149
backend/src/api/dependencies.py
Normal file
149
backend/src/api/dependencies.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
API 의존성
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import verify_token, get_user_id_from_token
|
||||
from ..models.user import User
|
||||
|
||||
|
||||
# HTTP Bearer 토큰 스키마 (선택적)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def get_current_user_with_token_param(
|
||||
_token: Optional[str] = Query(None),
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""URL 파라미터 또는 헤더에서 토큰을 가져와서 사용자 인증"""
|
||||
print(f"🔍 토큰 인증 시작 - URL 파라미터: {_token[:50] if _token else 'None'}...")
|
||||
print(f"🔍 Authorization 헤더: {credentials.credentials[:50] if credentials else 'None'}...")
|
||||
|
||||
token = None
|
||||
|
||||
# URL 파라미터에서 토큰 확인
|
||||
if _token:
|
||||
token = _token
|
||||
print("✅ URL 파라미터에서 토큰 사용")
|
||||
# Authorization 헤더에서 토큰 확인
|
||||
elif credentials:
|
||||
token = credentials.credentials
|
||||
print("✅ Authorization 헤더에서 토큰 사용")
|
||||
|
||||
if not token:
|
||||
print("❌ 토큰이 제공되지 않음")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="No authentication token provided"
|
||||
)
|
||||
|
||||
try:
|
||||
# 토큰에서 사용자 ID 추출
|
||||
user_id = get_user_id_from_token(token)
|
||||
print(f"✅ 토큰에서 사용자 ID 추출: {user_id}")
|
||||
|
||||
# 데이터베이스에서 사용자 조회
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
print(f"❌ 사용자를 찾을 수 없음: {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
print(f"❌ 비활성 사용자: {user.email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
print(f"✅ 사용자 인증 성공: {user.email}")
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
print(f"🚫 토큰 인증 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
3
backend/src/api/routes/__init__.py
Normal file
3
backend/src/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API 라우터 패키지 초기화
|
||||
"""
|
||||
193
backend/src/api/routes/auth.py
Normal file
193
backend/src/api/routes/auth.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
인증 관련 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from datetime import datetime
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.security import verify_password, create_access_token, create_refresh_token, get_password_hash
|
||||
from ...core.config import settings
|
||||
from ...models.user import User
|
||||
from ...schemas.auth import (
|
||||
LoginRequest, TokenResponse, RefreshTokenRequest,
|
||||
UserInfo, ChangePasswordRequest, CreateUserRequest
|
||||
)
|
||||
from ..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)},
|
||||
timeout_minutes=user.session_timeout_minutes
|
||||
)
|
||||
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 ...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.model_validate(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"}
|
||||
155
backend/src/api/routes/book_categories.py
Normal file
155
backend/src/api/routes/book_categories.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.book_category import BookCategory
|
||||
from ...models.document import Document
|
||||
from ...schemas.book_category import (
|
||||
CreateBookCategoryRequest,
|
||||
UpdateBookCategoryRequest,
|
||||
BookCategoryResponse,
|
||||
UpdateDocumentOrderRequest
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=BookCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book_category(
|
||||
category_data: CreateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 소분류 생성"""
|
||||
# 서적 존재 확인
|
||||
book_result = await db.execute(select(Book).where(Book.id == category_data.book_id))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# 권한 확인 (관리자만)
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories")
|
||||
|
||||
new_category = BookCategory(**category_data.model_dump())
|
||||
db.add(new_category)
|
||||
await db.commit()
|
||||
await db.refresh(new_category)
|
||||
|
||||
return await _get_category_response(db, new_category)
|
||||
|
||||
@router.get("/book/{book_id}", response_model=List[BookCategoryResponse])
|
||||
async def get_book_categories(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적의 소분류 목록 조회"""
|
||||
result = await db.execute(
|
||||
select(BookCategory)
|
||||
.where(BookCategory.book_id == book_id)
|
||||
.order_by(BookCategory.sort_order, BookCategory.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
|
||||
response_categories = []
|
||||
for category in categories:
|
||||
response_categories.append(await _get_category_response(db, category))
|
||||
return response_categories
|
||||
|
||||
@router.put("/{category_id}", response_model=BookCategoryResponse)
|
||||
async def update_book_category(
|
||||
category_id: UUID,
|
||||
category_data: UpdateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 수정"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
for field, value in category_data.model_dump(exclude_unset=True).items():
|
||||
setattr(category, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(category)
|
||||
return await _get_category_response(db, category)
|
||||
|
||||
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book_category(
|
||||
category_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 삭제 (포함된 문서들은 미분류로 이동)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
# 포함된 문서들을 미분류로 이동 (category_id를 NULL로 설정)
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.category_id == category_id)
|
||||
.values(category_id=None)
|
||||
)
|
||||
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
return {"message": "Category deleted successfully"}
|
||||
|
||||
@router.put("/documents/reorder", status_code=status.HTTP_200_OK)
|
||||
async def update_document_order(
|
||||
order_data: UpdateDocumentOrderRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 순서 변경"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reorder documents")
|
||||
|
||||
# 문서 순서 업데이트
|
||||
for item in order_data.document_orders:
|
||||
document_id = item.get("document_id")
|
||||
sort_order = item.get("sort_order", 0)
|
||||
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.id == document_id)
|
||||
.values(sort_order=sort_order)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Document order updated successfully"}
|
||||
|
||||
# Helper function
|
||||
async def _get_category_response(db: AsyncSession, category: BookCategory) -> BookCategoryResponse:
|
||||
"""BookCategory를 BookCategoryResponse로 변환"""
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.category_id == category.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
|
||||
return BookCategoryResponse(
|
||||
id=category.id,
|
||||
book_id=category.book_id,
|
||||
name=category.name,
|
||||
description=category.description,
|
||||
sort_order=category.sort_order,
|
||||
created_at=category.created_at,
|
||||
updated_at=category.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
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 ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.document import Document
|
||||
from ...models.bookmark import Bookmark
|
||||
from ..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"}
|
||||
230
backend/src/api/routes/books.py
Normal file
230
backend/src/api/routes/books.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import difflib # For similarity suggestions
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.document import Document
|
||||
from ...schemas.book import CreateBookRequest, UpdateBookRequest, BookResponse, BookSearchResponse, BookSuggestionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Helper to convert Book ORM object to BookResponse
|
||||
async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.book_id == book.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
return BookResponse(
|
||||
id=book.id,
|
||||
title=book.title,
|
||||
author=book.author,
|
||||
description=book.description,
|
||||
language=book.language,
|
||||
is_public=book.is_public,
|
||||
created_at=book.created_at,
|
||||
updated_at=book.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
|
||||
@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book(
|
||||
book_data: CreateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 생성"""
|
||||
# Check if a book with the same title and author already exists for the user
|
||||
existing_book_query = select(Book).where(Book.title == book_data.title)
|
||||
if book_data.author:
|
||||
existing_book_query = existing_book_query.where(Book.author == book_data.author)
|
||||
|
||||
existing_book = await db.execute(existing_book_query)
|
||||
if existing_book.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A book with this title and author already exists."
|
||||
)
|
||||
|
||||
new_book = Book(**book_data.model_dump())
|
||||
db.add(new_book)
|
||||
await db.commit()
|
||||
await db.refresh(new_book)
|
||||
return await _get_book_response(db, new_book)
|
||||
|
||||
@router.get("", response_model=List[BookResponse])
|
||||
async def get_books(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = Query(None, description="Search by book title or author"),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""모든 서적 목록 조회"""
|
||||
query = select(Book)
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{search}%"),
|
||||
Book.author.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# Only show public books or books owned by the current user/admin
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True) # For simplicity, assuming all books are public for now or user can only see public ones.
|
||||
# In a real app, you'd link books to users.
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Book.title)
|
||||
result = await db.execute(query)
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/{book_id}", response_model=BookResponse)
|
||||
async def get_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적 상세 정보 조회"""
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Access control (simplified)
|
||||
if not book.is_public and not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to access this book")
|
||||
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.put("/{book_id}", response_model=BookResponse)
|
||||
async def update_book(
|
||||
book_id: UUID,
|
||||
book_data: UpdateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 정보 업데이트"""
|
||||
if not current_user.is_admin: # Only admin can update books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
for field, value in book_data.model_dump(exclude_unset=True).items():
|
||||
setattr(book, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(book)
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 삭제"""
|
||||
if not current_user.is_admin: # Only admin can delete books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Disassociate documents from this book before deleting
|
||||
await db.execute(
|
||||
select(Document).where(Document.book_id == book_id)
|
||||
)
|
||||
documents_to_update = (await db.execute(select(Document).where(Document.book_id == book_id))).scalars().all()
|
||||
for doc in documents_to_update:
|
||||
doc.book_id = None
|
||||
|
||||
await db.delete(book)
|
||||
await db.commit()
|
||||
return {"message": "Book deleted successfully"}
|
||||
|
||||
@router.get("/search/", response_model=List[BookSearchResponse])
|
||||
async def search_books(
|
||||
q: str = Query(..., min_length=1, description="Search query for book title or author"),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 검색 (제목 또는 저자)"""
|
||||
query = select(Book).where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{q}%"),
|
||||
Book.author.ilike(f"%{q}%")
|
||||
)
|
||||
)
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True)
|
||||
|
||||
result = await db.execute(query.limit(limit))
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/suggestions/", response_model=List[BookSuggestionResponse])
|
||||
async def get_book_suggestions(
|
||||
title: str = Query(..., min_length=1, description="Book title for suggestions"),
|
||||
limit: int = Query(5, ge=1, le=10),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""제목 유사도 기반 서적 추천"""
|
||||
all_books_query = select(Book)
|
||||
if not current_user.is_admin:
|
||||
all_books_query = all_books_query.where(Book.is_public == True)
|
||||
|
||||
all_books_result = await db.execute(all_books_query)
|
||||
all_books = all_books_result.scalars().all()
|
||||
|
||||
suggestions = []
|
||||
for book in all_books:
|
||||
# Calculate similarity score using difflib
|
||||
score = difflib.SequenceMatcher(None, title.lower(), book.title.lower()).ratio()
|
||||
if score > 0.1: # Only consider if there's some similarity
|
||||
suggestions.append({
|
||||
"book": book,
|
||||
"similarity_score": score
|
||||
})
|
||||
|
||||
# Sort by similarity score in descending order
|
||||
suggestions.sort(key=lambda x: x["similarity_score"], reverse=True)
|
||||
|
||||
response_suggestions = []
|
||||
for s in suggestions[:limit]:
|
||||
book_response = await _get_book_response(db, s["book"])
|
||||
response_suggestions.append(BookSuggestionResponse(
|
||||
**book_response.model_dump(),
|
||||
similarity_score=s["similarity_score"]
|
||||
))
|
||||
return response_suggestions
|
||||
690
backend/src/api/routes/document_links.py
Normal file
690
backend/src/api/routes/document_links.py
Normal file
@@ -0,0 +1,690 @@
|
||||
"""
|
||||
문서 링크 관련 API 엔드포인트
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import uuid
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models import User, Document, DocumentLink
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class DocumentLinkCreate(BaseModel):
|
||||
target_document_id: str
|
||||
selected_text: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
link_text: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 고급 링크 기능 (모두 Optional로 설정)
|
||||
target_text: Optional[str] = None
|
||||
target_start_offset: Optional[int] = None
|
||||
target_end_offset: Optional[int] = None
|
||||
link_type: Optional[str] = "document" # "document" or "text_fragment"
|
||||
|
||||
|
||||
class DocumentLinkUpdate(BaseModel):
|
||||
target_document_id: Optional[str] = None
|
||||
link_text: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 고급 링크 기능
|
||||
target_text: Optional[str] = None
|
||||
target_start_offset: Optional[int] = None
|
||||
target_end_offset: Optional[int] = None
|
||||
link_type: Optional[str] = None
|
||||
|
||||
|
||||
class DocumentLinkResponse(BaseModel):
|
||||
id: str
|
||||
source_document_id: str
|
||||
target_document_id: str
|
||||
selected_text: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
link_text: Optional[str]
|
||||
description: Optional[str]
|
||||
created_at: str
|
||||
updated_at: Optional[str]
|
||||
|
||||
# 고급 링크 기능
|
||||
target_text: Optional[str]
|
||||
target_start_offset: Optional[int]
|
||||
target_end_offset: Optional[int]
|
||||
link_type: Optional[str] = "document"
|
||||
|
||||
# 대상 문서 정보
|
||||
target_document_title: str
|
||||
target_document_book_id: Optional[str]
|
||||
target_content_type: Optional[str] = "document" # "document" 또는 "note"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LinkableDocumentResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
book_id: Optional[str]
|
||||
book_title: Optional[str]
|
||||
sort_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/{document_id}/links", response_model=DocumentLinkResponse)
|
||||
async def create_document_link(
|
||||
document_id: str,
|
||||
link_data: DocumentLinkCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 링크 생성"""
|
||||
print(f"🔗 링크 생성 요청 - 문서 ID: {document_id}")
|
||||
print(f"📋 링크 데이터: {link_data}")
|
||||
print(f"🎯 target_text: '{link_data.target_text}'")
|
||||
print(f"🎯 target_start_offset: {link_data.target_start_offset}")
|
||||
print(f"🎯 target_end_offset: {link_data.target_end_offset}")
|
||||
print(f"🎯 link_type: {link_data.link_type}")
|
||||
|
||||
if link_data.link_type == 'text_fragment' and not link_data.target_text:
|
||||
print("🚨 CRITICAL: text_fragment 링크인데 target_text가 없습니다!")
|
||||
|
||||
# 출발 문서 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
source_doc = result.scalar_one_or_none()
|
||||
|
||||
if not source_doc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Source document not found"
|
||||
)
|
||||
|
||||
# 권한 확인
|
||||
if not source_doc.is_public and source_doc.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to source document"
|
||||
)
|
||||
|
||||
# 대상 문서 또는 노트 확인
|
||||
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
|
||||
target_doc = result.scalar_one_or_none()
|
||||
|
||||
target_note = None
|
||||
if not target_doc:
|
||||
# 문서에서 찾지 못하면 노트에서 찾기
|
||||
print(f"🔍 문서에서 찾지 못함, 노트에서 검색: {link_data.target_document_id}")
|
||||
from ...models.note_document import NoteDocument
|
||||
result = await db.execute(select(NoteDocument).where(NoteDocument.id == link_data.target_document_id))
|
||||
target_note = result.scalar_one_or_none()
|
||||
|
||||
if target_note:
|
||||
print(f"✅ 노트 찾음: {target_note.title}")
|
||||
else:
|
||||
print(f"❌ 노트도 찾지 못함: {link_data.target_document_id}")
|
||||
# 디버깅: 실제 존재하는 노트들 확인
|
||||
all_notes_result = await db.execute(select(NoteDocument).limit(5))
|
||||
all_notes = all_notes_result.scalars().all()
|
||||
print(f"🔍 존재하는 노트 예시 (최대 5개):")
|
||||
for note in all_notes:
|
||||
print(f" - ID: {note.id}, 제목: {note.title}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Target document or note not found"
|
||||
)
|
||||
|
||||
# 대상 문서/노트 권한 확인
|
||||
if target_doc:
|
||||
if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to target document"
|
||||
)
|
||||
|
||||
# HTML 문서만 링크 가능
|
||||
if not target_doc.html_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Can only link to HTML documents"
|
||||
)
|
||||
elif target_note:
|
||||
# 노트 권한 확인 (노트는 기본적으로 생성자만 접근 가능)
|
||||
if target_note.created_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to target note"
|
||||
)
|
||||
|
||||
# 링크 생성
|
||||
new_link = DocumentLink(
|
||||
source_document_id=uuid.UUID(document_id),
|
||||
target_document_id=uuid.UUID(link_data.target_document_id),
|
||||
selected_text=link_data.selected_text,
|
||||
start_offset=link_data.start_offset,
|
||||
end_offset=link_data.end_offset,
|
||||
link_text=link_data.link_text,
|
||||
description=link_data.description,
|
||||
# 고급 링크 기능
|
||||
target_text=link_data.target_text,
|
||||
target_start_offset=link_data.target_start_offset,
|
||||
target_end_offset=link_data.target_end_offset,
|
||||
link_type=link_data.link_type,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(new_link)
|
||||
await db.commit()
|
||||
await db.refresh(new_link)
|
||||
|
||||
target_title = target_doc.title if target_doc else target_note.title
|
||||
target_type = "document" if target_doc else "note"
|
||||
print(f"✅ 링크 생성 완료: {source_doc.title} -> {target_title} ({target_type})")
|
||||
print(f" - 링크 타입: {new_link.link_type}")
|
||||
print(f" - 선택된 텍스트: {new_link.selected_text}")
|
||||
print(f" - 대상 텍스트: {new_link.target_text}")
|
||||
|
||||
# 백링크는 자동으로 생성되지 않음 - 기존 링크를 역방향으로 조회하는 방식 사용
|
||||
|
||||
# 응답 데이터 구성
|
||||
return DocumentLinkResponse(
|
||||
id=str(new_link.id),
|
||||
source_document_id=str(new_link.source_document_id),
|
||||
target_document_id=str(new_link.target_document_id),
|
||||
selected_text=new_link.selected_text,
|
||||
start_offset=new_link.start_offset,
|
||||
end_offset=new_link.end_offset,
|
||||
link_text=new_link.link_text,
|
||||
description=new_link.description,
|
||||
# 고급 링크 기능
|
||||
target_text=new_link.target_text,
|
||||
target_start_offset=new_link.target_start_offset,
|
||||
target_end_offset=new_link.target_end_offset,
|
||||
link_type=new_link.link_type,
|
||||
created_at=new_link.created_at.isoformat(),
|
||||
updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None,
|
||||
target_document_title=target_title,
|
||||
target_document_book_id=str(target_doc.book_id) if target_doc and target_doc.book_id else (str(target_note.notebook_id) if target_note and target_note.notebook_id else None)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/links", response_model=List[DocumentLinkResponse])
|
||||
async def get_document_links(
|
||||
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="Access denied"
|
||||
)
|
||||
|
||||
# 모든 링크 조회 (문서→문서 + 문서→노트)
|
||||
result = await db.execute(
|
||||
select(DocumentLink)
|
||||
.where(DocumentLink.source_document_id == document_id)
|
||||
.order_by(DocumentLink.start_offset.asc())
|
||||
)
|
||||
|
||||
all_links = result.scalars().all()
|
||||
print(f"🔍 문서 링크 조회 완료: {len(all_links)}개 발견")
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_links = []
|
||||
for link in all_links:
|
||||
print(f"🔗 링크 처리 중: {link.id} -> {link.target_document_id}")
|
||||
|
||||
# 대상이 문서인지 노트인지 확인
|
||||
target_doc = None
|
||||
target_note = None
|
||||
|
||||
# 먼저 Document 테이블에서 찾기
|
||||
doc_result = await db.execute(select(Document).where(Document.id == link.target_document_id))
|
||||
target_doc = doc_result.scalar_one_or_none()
|
||||
|
||||
if target_doc:
|
||||
print(f"✅ 대상 문서 찾음: {target_doc.title}")
|
||||
target_title = target_doc.title
|
||||
target_book_id = str(target_doc.book_id) if target_doc.book_id else None
|
||||
target_content_type = "document"
|
||||
else:
|
||||
# Document에서 찾지 못하면 NoteDocument에서 찾기
|
||||
from ...models.note_document import NoteDocument
|
||||
note_result = await db.execute(select(NoteDocument).where(NoteDocument.id == link.target_document_id))
|
||||
target_note = note_result.scalar_one_or_none()
|
||||
|
||||
if target_note:
|
||||
print(f"✅ 대상 노트 찾음: {target_note.title}")
|
||||
target_title = f"📝 {target_note.title}" # 노트임을 표시
|
||||
target_book_id = str(target_note.notebook_id) if target_note.notebook_id else None
|
||||
target_content_type = "note"
|
||||
else:
|
||||
print(f"❌ 대상을 찾을 수 없음: {link.target_document_id}")
|
||||
target_title = "Unknown Target"
|
||||
target_book_id = None
|
||||
target_content_type = "document" # 기본값
|
||||
|
||||
response_links.append(DocumentLinkResponse(
|
||||
id=str(link.id),
|
||||
source_document_id=str(link.source_document_id),
|
||||
target_document_id=str(link.target_document_id),
|
||||
selected_text=link.selected_text,
|
||||
start_offset=link.start_offset,
|
||||
end_offset=link.end_offset,
|
||||
link_text=link.link_text,
|
||||
description=link.description,
|
||||
created_at=link.created_at.isoformat(),
|
||||
updated_at=link.updated_at.isoformat() if link.updated_at else None,
|
||||
# 고급 링크 기능 (기존 링크는 None일 수 있음)
|
||||
target_text=getattr(link, 'target_text', None),
|
||||
target_start_offset=getattr(link, 'target_start_offset', None),
|
||||
target_end_offset=getattr(link, 'target_end_offset', None),
|
||||
link_type=getattr(link, 'link_type', 'document'),
|
||||
# 대상 문서/노트 정보 추가
|
||||
target_document_title=target_title,
|
||||
target_document_book_id=target_book_id,
|
||||
target_content_type=target_content_type
|
||||
))
|
||||
|
||||
return response_links
|
||||
|
||||
|
||||
@router.get("/{document_id}/linkable-documents", response_model=List[LinkableDocumentResponse])
|
||||
async def get_linkable_documents(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""링크 가능한 문서 목록 조회 (같은 서적 우선, 전체 HTML 문서)"""
|
||||
# 현재 문서 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
current_doc = result.scalar_one_or_none()
|
||||
|
||||
if not current_doc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 권한 확인
|
||||
if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# 링크 가능한 HTML 문서들 조회
|
||||
# 1. 같은 서적의 문서들 (우선순위)
|
||||
# 2. 다른 서적의 문서들
|
||||
from ...models import Book
|
||||
|
||||
query = select(Document, Book).outerjoin(Book, Document.book_id == Book.id).where(
|
||||
and_(
|
||||
Document.html_path.isnot(None), # HTML 문서만
|
||||
Document.id != document_id, # 자기 자신 제외
|
||||
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
|
||||
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
|
||||
)
|
||||
).order_by(
|
||||
# 같은 서적 우선, 그 다음 정렬 순서
|
||||
(Document.book_id == current_doc.book_id).desc(),
|
||||
Document.sort_order.asc().nulls_last(),
|
||||
Document.created_at.asc()
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
documents_with_books = result.all()
|
||||
|
||||
# 응답 데이터 구성
|
||||
linkable_docs = []
|
||||
for doc, book in documents_with_books:
|
||||
linkable_docs.append(LinkableDocumentResponse(
|
||||
id=str(doc.id),
|
||||
title=doc.title,
|
||||
book_id=str(doc.book_id) if doc.book_id else None,
|
||||
book_title=book.title if book else None,
|
||||
sort_order=doc.sort_order or 0
|
||||
))
|
||||
|
||||
return linkable_docs
|
||||
|
||||
|
||||
@router.put("/links/{link_id}", response_model=DocumentLinkResponse)
|
||||
async def update_document_link(
|
||||
link_id: str,
|
||||
link_data: DocumentLinkUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 링크 수정"""
|
||||
# 링크 조회
|
||||
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Link not found"
|
||||
)
|
||||
|
||||
# 권한 확인 (생성자만 수정 가능)
|
||||
if link.created_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# 대상 문서 변경 시 검증
|
||||
if link_data.target_document_id:
|
||||
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
|
||||
target_doc = result.scalar_one_or_none()
|
||||
|
||||
if not target_doc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Target document not found"
|
||||
)
|
||||
|
||||
if not target_doc.html_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Can only link to HTML documents"
|
||||
)
|
||||
|
||||
link.target_document_id = uuid.UUID(link_data.target_document_id)
|
||||
|
||||
# 필드 업데이트
|
||||
if link_data.link_text is not None:
|
||||
link.link_text = link_data.link_text
|
||||
if link_data.description is not None:
|
||||
link.description = link_data.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
|
||||
# 대상 문서 정보 조회
|
||||
result = await db.execute(select(Document).where(Document.id == link.target_document_id))
|
||||
target_doc = result.scalar_one()
|
||||
|
||||
return DocumentLinkResponse(
|
||||
id=str(link.id),
|
||||
source_document_id=str(link.source_document_id),
|
||||
target_document_id=str(link.target_document_id),
|
||||
selected_text=link.selected_text,
|
||||
start_offset=link.start_offset,
|
||||
end_offset=link.end_offset,
|
||||
link_text=link.link_text,
|
||||
description=link.description,
|
||||
created_at=link.created_at.isoformat(),
|
||||
updated_at=link.updated_at.isoformat() if link.updated_at else None,
|
||||
target_document_title=target_doc.title,
|
||||
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/links/{link_id}")
|
||||
@router.delete("/document-links/{link_id}") # 프론트엔드 호환성을 위한 추가 경로
|
||||
async def delete_document_link(
|
||||
link_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 링크 삭제"""
|
||||
# 링크 조회
|
||||
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Link not found"
|
||||
)
|
||||
|
||||
# 권한 확인 (생성자만 삭제 가능)
|
||||
if link.created_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
await db.delete(link)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Link deleted successfully"}
|
||||
|
||||
|
||||
# 백링크 관련 모델
|
||||
class BacklinkResponse(BaseModel):
|
||||
id: str
|
||||
source_document_id: str
|
||||
source_document_title: str
|
||||
source_document_book_id: Optional[str]
|
||||
source_content_type: Optional[str] = "document" # "document" or "note"
|
||||
target_document_id: str
|
||||
target_document_title: str
|
||||
selected_text: str # 소스 문서에서 선택한 텍스트
|
||||
start_offset: int # 소스 문서 오프셋
|
||||
end_offset: int # 소스 문서 오프셋
|
||||
link_text: Optional[str]
|
||||
description: Optional[str]
|
||||
link_type: str
|
||||
target_text: Optional[str] # 🎯 타겟 문서의 텍스트 (백링크 렌더링용)
|
||||
target_start_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
|
||||
target_end_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/{document_id}/backlinks", response_model=List[BacklinkResponse])
|
||||
async def get_document_backlinks(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서의 백링크 조회 (이 문서를 참조하는 모든 링크)"""
|
||||
print(f"🔍 백링크 API 호출됨 - 문서 ID: {document_id}, 사용자: {current_user.email}")
|
||||
|
||||
# 문서 존재 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
print(f"❌ 문서를 찾을 수 없음: {document_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
print(f"✅ 문서 찾음: {document.title}")
|
||||
|
||||
# 권한 확인
|
||||
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="Access denied"
|
||||
)
|
||||
|
||||
# 이 문서를 대상으로 하는 모든 링크 조회 (백링크)
|
||||
from ...models import Book
|
||||
from ...models.note_link import NoteLink
|
||||
from ...models.note_document import NoteDocument
|
||||
from ...models.notebook import Notebook
|
||||
|
||||
# 1. 일반 문서에서 오는 백링크 (DocumentLink)
|
||||
doc_query = select(DocumentLink, Document, Book).join(
|
||||
Document, DocumentLink.source_document_id == Document.id
|
||||
).outerjoin(Book, Document.book_id == Book.id).where(
|
||||
and_(
|
||||
DocumentLink.target_document_id == document_id,
|
||||
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
|
||||
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
|
||||
)
|
||||
).order_by(DocumentLink.created_at.desc())
|
||||
|
||||
doc_result = await db.execute(doc_query)
|
||||
backlinks = []
|
||||
|
||||
print(f"🔍 문서 백링크 쿼리 실행 완료")
|
||||
|
||||
# 일반 문서 백링크 처리
|
||||
for link, source_doc, book in doc_result.fetchall():
|
||||
print(f"📋 백링크 발견: {source_doc.title} -> {document.title}")
|
||||
print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
|
||||
print(f" - 타겟 텍스트 (target_text): {link.target_text}")
|
||||
print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}")
|
||||
print(f" - 링크 타입: {link.link_type}")
|
||||
|
||||
backlinks.append(BacklinkResponse(
|
||||
id=str(link.id),
|
||||
source_document_id=str(link.source_document_id),
|
||||
source_document_title=source_doc.title,
|
||||
source_document_book_id=str(book.id) if book else None,
|
||||
source_content_type="document", # 일반 문서
|
||||
target_document_id=str(link.target_document_id),
|
||||
target_document_title=document.title,
|
||||
selected_text=link.selected_text, # 소스 문서에서 선택한 텍스트 (참고용)
|
||||
start_offset=link.start_offset, # 소스 문서 오프셋 (참고용)
|
||||
end_offset=link.end_offset, # 소스 문서 오프셋 (참고용)
|
||||
link_text=link.link_text,
|
||||
description=link.description,
|
||||
link_type=link.link_type,
|
||||
target_text=link.target_text, # 🎯 타겟 문서의 텍스트 (백링크 렌더링용)
|
||||
target_start_offset=link.target_start_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
|
||||
target_end_offset=link.target_end_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
|
||||
created_at=link.created_at.isoformat()
|
||||
))
|
||||
|
||||
# 2. 노트에서 오는 백링크 (NoteLink) - 동기 쿼리 사용
|
||||
try:
|
||||
from ...core.database import get_sync_db
|
||||
sync_db = next(get_sync_db())
|
||||
|
||||
# 노트에서 이 문서를 대상으로 하는 링크들 조회
|
||||
note_links = sync_db.query(NoteLink).join(
|
||||
NoteDocument, NoteLink.source_note_id == NoteDocument.id
|
||||
).outerjoin(Notebook, NoteDocument.notebook_id == Notebook.id).filter(
|
||||
NoteLink.target_document_id == document_id
|
||||
).all()
|
||||
|
||||
print(f"🔍 노트 백링크 쿼리 실행 완료: {len(note_links)}개 발견")
|
||||
|
||||
# 노트 백링크 처리
|
||||
for link in note_links:
|
||||
source_note = sync_db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
|
||||
notebook = sync_db.query(Notebook).filter(Notebook.id == source_note.notebook_id).first() if source_note else None
|
||||
|
||||
if source_note:
|
||||
print(f"📋 노트 백링크 발견: {source_note.title} -> {document.title}")
|
||||
print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
|
||||
print(f" - 타겟 텍스트 (target_text): {link.target_text}")
|
||||
print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}")
|
||||
print(f" - 링크 타입: {link.link_type}")
|
||||
|
||||
backlinks.append(BacklinkResponse(
|
||||
id=str(link.id),
|
||||
source_document_id=str(link.source_note_id), # 노트 ID를 문서 ID로 사용
|
||||
source_document_title=f"📝 {source_note.title}", # 노트임을 표시
|
||||
source_document_book_id=str(notebook.id) if notebook else None,
|
||||
source_content_type="note", # 노트 문서
|
||||
target_document_id=str(link.target_document_id) if link.target_document_id else document_id,
|
||||
target_document_title=document.title,
|
||||
selected_text=link.selected_text,
|
||||
start_offset=link.start_offset,
|
||||
end_offset=link.end_offset,
|
||||
link_text=link.link_text,
|
||||
description=link.description,
|
||||
link_type=link.link_type,
|
||||
target_text=link.target_text,
|
||||
target_start_offset=link.target_start_offset,
|
||||
target_end_offset=link.target_end_offset,
|
||||
created_at=link.created_at.isoformat() if link.created_at else None
|
||||
))
|
||||
|
||||
sync_db.close()
|
||||
except Exception as e:
|
||||
print(f"❌ 노트 백링크 조회 실패: {e}")
|
||||
|
||||
print(f"✅ 총 {len(backlinks)}개의 백링크 반환 (문서 + 노트)")
|
||||
return backlinks
|
||||
|
||||
|
||||
@router.get("/{document_id}/link-fragments")
|
||||
async def get_document_link_fragments(
|
||||
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="Access denied"
|
||||
)
|
||||
|
||||
# 이 문서에서 출발하는 모든 링크 조회
|
||||
query = select(DocumentLink, Document).join(
|
||||
Document, DocumentLink.target_document_id == Document.id
|
||||
).where(
|
||||
and_(
|
||||
DocumentLink.source_document_id == document_id,
|
||||
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
|
||||
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
|
||||
)
|
||||
).order_by(DocumentLink.start_offset.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
fragments = []
|
||||
|
||||
for link, target_doc in result.fetchall():
|
||||
fragments.append({
|
||||
"link_id": str(link.id),
|
||||
"start_offset": link.start_offset,
|
||||
"end_offset": link.end_offset,
|
||||
"selected_text": link.selected_text,
|
||||
"target_document_id": str(link.target_document_id),
|
||||
"target_document_title": target_doc.title,
|
||||
"link_text": link.link_text,
|
||||
"description": link.description,
|
||||
"link_type": link.link_type,
|
||||
"target_text": link.target_text,
|
||||
"target_start_offset": link.target_start_offset,
|
||||
"target_end_offset": link.target_end_offset
|
||||
})
|
||||
|
||||
return fragments
|
||||
1140
backend/src/api/routes/documents.py
Normal file
1140
backend/src/api/routes/documents.py
Normal file
File diff suppressed because it is too large
Load Diff
471
backend/src/api/routes/highlights.py
Normal file
471
backend/src/api/routes/highlights.py
Normal file
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
하이라이트 관리 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 uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.document import Document
|
||||
from ...models.highlight import Highlight
|
||||
from ...models.note import Note
|
||||
from ..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
|
||||
note: Optional[str] = None # 메모 업데이트 지원
|
||||
|
||||
|
||||
class HighlightResponse(BaseModel):
|
||||
"""하이라이트 응답"""
|
||||
id: str
|
||||
user_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)
|
||||
|
||||
# 응답 데이터 생성 (Pydantic v2 호환)
|
||||
response_data = HighlightResponse(
|
||||
id=str(highlight.id),
|
||||
user_id=str(highlight.user_id),
|
||||
document_id=str(highlight.document_id),
|
||||
start_offset=highlight.start_offset,
|
||||
end_offset=highlight.end_offset,
|
||||
selected_text=highlight.selected_text,
|
||||
element_selector=highlight.element_selector,
|
||||
start_container_xpath=highlight.start_container_xpath,
|
||||
end_container_xpath=highlight.end_container_xpath,
|
||||
highlight_color=highlight.highlight_color,
|
||||
highlight_type=highlight.highlight_type,
|
||||
created_at=highlight.created_at,
|
||||
updated_at=highlight.updated_at,
|
||||
note=None
|
||||
)
|
||||
|
||||
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: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 문서의 하이라이트 목록 조회"""
|
||||
try:
|
||||
print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}")
|
||||
|
||||
# 문서 존재 및 권한 확인
|
||||
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.notes)) # 메모 관계 로드
|
||||
.where(
|
||||
and_(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.order_by(Highlight.start_offset)
|
||||
)
|
||||
highlights = result.scalars().all()
|
||||
|
||||
print(f"DEBUG: Found {len(highlights)} highlights for user {current_user.id}")
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for highlight in highlights:
|
||||
# 연관된 메모 정보 포함 (notes는 리스트이므로 첫 번째 메모 사용)
|
||||
note_data = None
|
||||
if highlight.notes and len(highlight.notes) > 0:
|
||||
first_note = highlight.notes[0] # 첫 번째 메모 사용
|
||||
note_data = {
|
||||
"id": str(first_note.id),
|
||||
"content": first_note.content,
|
||||
"created_at": first_note.created_at.isoformat(),
|
||||
"updated_at": first_note.updated_at.isoformat() if first_note.updated_at else None
|
||||
}
|
||||
|
||||
highlight_data = HighlightResponse(
|
||||
id=str(highlight.id),
|
||||
user_id=str(highlight.user_id),
|
||||
document_id=str(highlight.document_id),
|
||||
start_offset=highlight.start_offset,
|
||||
end_offset=highlight.end_offset,
|
||||
selected_text=highlight.selected_text,
|
||||
element_selector=highlight.element_selector,
|
||||
start_container_xpath=highlight.start_container_xpath,
|
||||
end_container_xpath=highlight.end_container_xpath,
|
||||
highlight_color=highlight.highlight_color,
|
||||
highlight_type=highlight.highlight_type,
|
||||
created_at=highlight.created_at,
|
||||
updated_at=highlight.updated_at,
|
||||
note=note_data
|
||||
)
|
||||
response_data.append(highlight_data)
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_document_highlights: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@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.user))
|
||||
.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(
|
||||
id=str(highlight.id),
|
||||
user_id=str(highlight.user_id),
|
||||
document_id=str(highlight.document_id),
|
||||
start_offset=highlight.start_offset,
|
||||
end_offset=highlight.end_offset,
|
||||
selected_text=highlight.selected_text,
|
||||
element_selector=highlight.element_selector,
|
||||
start_container_xpath=highlight.start_container_xpath,
|
||||
end_container_xpath=highlight.end_container_xpath,
|
||||
highlight_color=highlight.highlight_color,
|
||||
highlight_type=highlight.highlight_type,
|
||||
created_at=highlight.created_at,
|
||||
updated_at=highlight.updated_at,
|
||||
note=None
|
||||
)
|
||||
if highlight.notes:
|
||||
response_data.note = {
|
||||
"id": str(highlight.notes.id),
|
||||
"content": highlight.notes.content,
|
||||
"tags": highlight.notes.tags,
|
||||
"created_at": highlight.notes.created_at.isoformat(),
|
||||
"updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.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.user), selectinload(Highlight.notes))
|
||||
.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
|
||||
|
||||
# 메모 업데이트 처리
|
||||
if highlight_data.note is not None:
|
||||
if highlight.notes:
|
||||
# 기존 메모 업데이트
|
||||
highlight.notes.content = highlight_data.note
|
||||
highlight.notes.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# 새 메모 생성
|
||||
new_note = Note(
|
||||
user_id=current_user.id,
|
||||
document_id=highlight.document_id,
|
||||
highlight_id=highlight.id,
|
||||
content=highlight_data.note,
|
||||
tags=""
|
||||
)
|
||||
db.add(new_note)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(highlight)
|
||||
|
||||
response_data = HighlightResponse(
|
||||
id=str(highlight.id),
|
||||
user_id=str(highlight.user_id),
|
||||
document_id=str(highlight.document_id),
|
||||
start_offset=highlight.start_offset,
|
||||
end_offset=highlight.end_offset,
|
||||
selected_text=highlight.selected_text,
|
||||
element_selector=highlight.element_selector,
|
||||
start_container_xpath=highlight.start_container_xpath,
|
||||
end_container_xpath=highlight.end_container_xpath,
|
||||
highlight_color=highlight.highlight_color,
|
||||
highlight_type=highlight.highlight_type,
|
||||
created_at=highlight.created_at,
|
||||
updated_at=highlight.updated_at,
|
||||
note=None
|
||||
)
|
||||
if highlight.notes:
|
||||
response_data.note = {
|
||||
"id": str(highlight.notes.id),
|
||||
"content": highlight.notes.content,
|
||||
"tags": highlight.notes.tags,
|
||||
"created_at": highlight.notes.created_at.isoformat(),
|
||||
"updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.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"
|
||||
)
|
||||
|
||||
# 안전한 하이라이트 삭제 (연결된 메모 먼저 삭제)
|
||||
try:
|
||||
print(f"DEBUG: Starting deletion of highlight {highlight_id}")
|
||||
|
||||
# 1. 먼저 연결된 메모 삭제
|
||||
from ...models.note import Note
|
||||
note_result = await db.execute(delete(Note).where(Note.highlight_id == highlight_id))
|
||||
print(f"DEBUG: Deleted {note_result.rowcount} notes for highlight {highlight_id}")
|
||||
|
||||
# 2. 하이라이트 삭제
|
||||
highlight_result = await db.execute(delete(Highlight).where(Highlight.id == highlight_id))
|
||||
print(f"DEBUG: Deleted {highlight_result.rowcount} highlights")
|
||||
|
||||
# 3. 커밋
|
||||
await db.commit()
|
||||
print(f"DEBUG: Successfully deleted highlight {highlight_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to delete highlight {highlight_id}: {e}")
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete highlight: {str(e)}"
|
||||
)
|
||||
|
||||
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.user)).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(
|
||||
id=str(highlight.id),
|
||||
user_id=str(highlight.user_id),
|
||||
document_id=str(highlight.document_id),
|
||||
start_offset=highlight.start_offset,
|
||||
end_offset=highlight.end_offset,
|
||||
selected_text=highlight.selected_text,
|
||||
element_selector=highlight.element_selector,
|
||||
start_container_xpath=highlight.start_container_xpath,
|
||||
end_container_xpath=highlight.end_container_xpath,
|
||||
highlight_color=highlight.highlight_color,
|
||||
highlight_type=highlight.highlight_type,
|
||||
created_at=highlight.created_at,
|
||||
updated_at=highlight.updated_at,
|
||||
note=None
|
||||
)
|
||||
# 메모는 별도 API에서 조회하므로 여기서는 처리하지 않음
|
||||
response_data.append(highlight_data)
|
||||
|
||||
return response_data
|
||||
700
backend/src/api/routes/memo_trees.py
Normal file
700
backend/src/api/routes/memo_trees.py
Normal file
@@ -0,0 +1,700 @@
|
||||
"""
|
||||
트리 구조 메모장 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.memo_tree import MemoTree, MemoNode, MemoNodeVersion, MemoTreeShare
|
||||
from ...schemas.memo_tree import (
|
||||
MemoTreeCreate, MemoTreeUpdate, MemoTreeResponse, MemoTreeWithNodes,
|
||||
MemoNodeCreate, MemoNodeUpdate, MemoNodeResponse, MemoNodeMove,
|
||||
MemoTreeStats, MemoSearchRequest, MemoSearchResult
|
||||
)
|
||||
from ..dependencies import get_current_active_user
|
||||
|
||||
router = APIRouter(prefix="/memo-trees", tags=["memo-trees"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 메모 트리 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response_model=List[MemoTreeResponse])
|
||||
async def get_user_memo_trees(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
include_archived: bool = Query(False, description="보관된 트리 포함 여부")
|
||||
):
|
||||
"""사용자의 메모 트리 목록 조회"""
|
||||
try:
|
||||
query = select(MemoTree).where(MemoTree.user_id == current_user.id)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(MemoTree.is_archived == False)
|
||||
|
||||
query = query.order_by(MemoTree.updated_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
trees = result.scalars().all()
|
||||
|
||||
# 각 트리의 노드 개수 계산
|
||||
tree_responses = []
|
||||
for tree in trees:
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
tree_responses.append(MemoTreeResponse(**tree_dict))
|
||||
|
||||
return tree_responses
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_user_memo_trees: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo trees: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=MemoTreeResponse)
|
||||
async def create_memo_tree(
|
||||
tree_data: MemoTreeCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 메모 트리 생성"""
|
||||
try:
|
||||
new_tree = MemoTree(
|
||||
user_id=current_user.id,
|
||||
title=tree_data.title,
|
||||
description=tree_data.description,
|
||||
tree_type=tree_data.tree_type,
|
||||
template_data=tree_data.template_data or {},
|
||||
settings=tree_data.settings or {},
|
||||
is_public=tree_data.is_public
|
||||
)
|
||||
|
||||
db.add(new_tree)
|
||||
await db.commit()
|
||||
await db.refresh(new_tree)
|
||||
|
||||
tree_dict = {
|
||||
"id": str(new_tree.id),
|
||||
"user_id": str(new_tree.user_id),
|
||||
"title": new_tree.title,
|
||||
"description": new_tree.description,
|
||||
"tree_type": new_tree.tree_type,
|
||||
"template_data": new_tree.template_data,
|
||||
"settings": new_tree.settings,
|
||||
"created_at": new_tree.created_at,
|
||||
"updated_at": new_tree.updated_at,
|
||||
"is_public": new_tree.is_public,
|
||||
"is_archived": new_tree.is_archived,
|
||||
"node_count": 0
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tree_id}", response_model=MemoTreeResponse)
|
||||
async def get_memo_tree(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 상세 조회"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
or_(
|
||||
MemoTree.user_id == current_user.id,
|
||||
MemoTree.is_public == True
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 노드 개수 계산
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{tree_id}", response_model=MemoTreeResponse)
|
||||
async def update_memo_tree(
|
||||
tree_id: UUID,
|
||||
tree_data: MemoTreeUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 업데이트"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_data = tree_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tree, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
|
||||
# 노드 개수 계산
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in update_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tree_id}")
|
||||
async def delete_memo_tree(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 삭제"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 트리 삭제 (CASCADE로 관련 노드들도 자동 삭제됨)
|
||||
await db.delete(tree)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Memo tree deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in delete_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 메모 노드 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/{tree_id}/nodes", response_model=List[MemoNodeResponse])
|
||||
async def get_memo_tree_nodes(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리의 모든 노드 조회"""
|
||||
try:
|
||||
# 트리 접근 권한 확인
|
||||
tree_result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
or_(
|
||||
MemoTree.user_id == current_user.id,
|
||||
MemoTree.is_public == True
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 노드들 조회
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.where(MemoNode.tree_id == tree_id)
|
||||
.order_by(MemoNode.path, MemoNode.sort_order)
|
||||
)
|
||||
nodes = result.scalars().all()
|
||||
|
||||
# 각 노드의 자식 개수 계산
|
||||
node_responses = []
|
||||
for node in nodes:
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
node_responses.append(MemoNodeResponse(**node_dict))
|
||||
|
||||
return node_responses
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_tree_nodes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo tree nodes: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tree_id}/nodes", response_model=MemoNodeResponse)
|
||||
async def create_memo_node(
|
||||
tree_id: UUID,
|
||||
node_data: MemoNodeCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 메모 노드 생성"""
|
||||
try:
|
||||
# 트리 접근 권한 확인
|
||||
tree_result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 부모 노드 확인 (있다면)
|
||||
if node_data.parent_id:
|
||||
parent_result = await db.execute(
|
||||
select(MemoNode).where(
|
||||
and_(
|
||||
MemoNode.id == UUID(node_data.parent_id),
|
||||
MemoNode.tree_id == tree_id
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_node = parent_result.scalar_one_or_none()
|
||||
if not parent_node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent node not found"
|
||||
)
|
||||
|
||||
# 단어 수 계산
|
||||
word_count = 0
|
||||
if node_data.content:
|
||||
word_count = len(node_data.content.replace('\n', ' ').split())
|
||||
|
||||
new_node = MemoNode(
|
||||
tree_id=tree_id,
|
||||
parent_id=UUID(node_data.parent_id) if node_data.parent_id else None,
|
||||
user_id=current_user.id,
|
||||
title=node_data.title,
|
||||
content=node_data.content,
|
||||
node_type=node_data.node_type,
|
||||
sort_order=node_data.sort_order,
|
||||
tags=node_data.tags or [],
|
||||
node_metadata=node_data.node_metadata or {},
|
||||
status=node_data.status,
|
||||
word_count=word_count,
|
||||
is_canonical=node_data.is_canonical or False
|
||||
)
|
||||
|
||||
db.add(new_node)
|
||||
await db.commit()
|
||||
await db.refresh(new_node)
|
||||
|
||||
node_dict = {
|
||||
"id": str(new_node.id),
|
||||
"tree_id": str(new_node.tree_id),
|
||||
"parent_id": str(new_node.parent_id) if new_node.parent_id else None,
|
||||
"user_id": str(new_node.user_id),
|
||||
"title": new_node.title,
|
||||
"content": new_node.content,
|
||||
"node_type": new_node.node_type,
|
||||
"sort_order": new_node.sort_order,
|
||||
"depth_level": new_node.depth_level,
|
||||
"path": new_node.path,
|
||||
"tags": new_node.tags or [],
|
||||
"node_metadata": new_node.node_metadata or {},
|
||||
"status": new_node.status,
|
||||
"word_count": new_node.word_count,
|
||||
"is_canonical": new_node.is_canonical,
|
||||
"canonical_order": new_node.canonical_order,
|
||||
"story_path": new_node.story_path,
|
||||
"created_at": new_node.created_at,
|
||||
"updated_at": new_node.updated_at,
|
||||
"children_count": 0
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_id}", response_model=MemoNodeResponse)
|
||||
async def get_memo_node(
|
||||
node_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 상세 조회"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인
|
||||
if node.tree.user_id != current_user.id and not node.tree.is_public:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this node"
|
||||
)
|
||||
|
||||
# 자식 개수 계산
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/nodes/{node_id}", response_model=MemoNodeResponse)
|
||||
async def update_memo_node(
|
||||
node_id: UUID,
|
||||
node_data: MemoNodeUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 업데이트"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인 (소유자만 수정 가능)
|
||||
if node.tree.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to update this node"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_data = node_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == "parent_id" and value:
|
||||
# 부모 노드 유효성 검사
|
||||
parent_result = await db.execute(
|
||||
select(MemoNode).where(
|
||||
and_(
|
||||
MemoNode.id == UUID(value),
|
||||
MemoNode.tree_id == node.tree_id
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_node = parent_result.scalar_one_or_none()
|
||||
if not parent_node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent node not found"
|
||||
)
|
||||
setattr(node, field, UUID(value))
|
||||
elif field == "parent_id" and value is None:
|
||||
setattr(node, field, None)
|
||||
else:
|
||||
setattr(node, field, value)
|
||||
|
||||
# 내용이 업데이트되면 단어 수 재계산
|
||||
if "content" in update_data:
|
||||
word_count = 0
|
||||
if node.content:
|
||||
word_count = len(node.content.replace('\n', ' ').split())
|
||||
node.word_count = word_count
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
|
||||
# 자식 개수 계산
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in update_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/nodes/{node_id}")
|
||||
async def delete_memo_node(
|
||||
node_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 삭제"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인 (소유자만 삭제 가능)
|
||||
if node.tree.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to delete this node"
|
||||
)
|
||||
|
||||
# 노드 삭제 (CASCADE로 자식 노드들도 자동 삭제됨)
|
||||
await db.delete(node)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Memo node deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in delete_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete memo node: {str(e)}"
|
||||
)
|
||||
271
backend/src/api/routes/note_documents.py
Normal file
271
backend/src/api/routes/note_documents.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
노트 문서 관련 API 엔드포인트
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc, asc
|
||||
from typing import List, Optional
|
||||
import html
|
||||
|
||||
from ...core.database import get_sync_db
|
||||
from ..dependencies import get_current_user
|
||||
from ...models.user import User
|
||||
from ...models.note_document import (
|
||||
NoteDocument,
|
||||
NoteDocumentCreate,
|
||||
NoteDocumentUpdate,
|
||||
NoteDocumentResponse,
|
||||
NoteDocumentListItem,
|
||||
NoteStats
|
||||
)
|
||||
from ...models.notebook import Notebook
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def calculate_reading_time(content: str) -> int:
|
||||
"""HTML 내용에서 예상 읽기 시간 계산 (분)"""
|
||||
if not content:
|
||||
return 0
|
||||
|
||||
# HTML 태그 제거
|
||||
text_content = html.unescape(content)
|
||||
# 간단한 HTML 태그 제거 (정확하지 않지만 대략적인 계산용)
|
||||
import re
|
||||
text_content = re.sub(r'<[^>]+>', '', text_content)
|
||||
|
||||
# 단어 수 계산 (한국어 + 영어)
|
||||
words = len(text_content.split())
|
||||
korean_chars = len([c for c in text_content if '\uac00' <= c <= '\ud7af'])
|
||||
|
||||
# 대략적인 읽기 속도: 영어 200단어/분, 한국어 300자/분
|
||||
english_time = words / 200
|
||||
korean_time = korean_chars / 300
|
||||
|
||||
return max(1, int(english_time + korean_time))
|
||||
|
||||
|
||||
@router.get("/", response_model=List[NoteDocumentListItem])
|
||||
def get_note_documents(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
note_type: Optional[str] = Query(None),
|
||||
published_only: bool = Query(False),
|
||||
notebook_id: Optional[str] = Query(None),
|
||||
sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|word_count)$"),
|
||||
order: str = Query("desc", regex="^(asc|desc)$"),
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 문서 목록 조회"""
|
||||
query = db.query(NoteDocument)
|
||||
|
||||
# 필터링
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(NoteDocument.title.ilike(search_term)) |
|
||||
(NoteDocument.content.ilike(search_term))
|
||||
)
|
||||
|
||||
if note_type:
|
||||
query = query.filter(NoteDocument.note_type == note_type)
|
||||
|
||||
if published_only:
|
||||
query = query.filter(NoteDocument.is_published == True)
|
||||
|
||||
if notebook_id:
|
||||
query = query.filter(NoteDocument.notebook_id == notebook_id)
|
||||
|
||||
# 정렬
|
||||
if sort_by == 'title':
|
||||
query = query.order_by(asc(NoteDocument.title) if order == 'asc' else desc(NoteDocument.title))
|
||||
elif sort_by == 'created_at':
|
||||
query = query.order_by(asc(NoteDocument.created_at) if order == 'asc' else desc(NoteDocument.created_at))
|
||||
elif sort_by == 'word_count':
|
||||
query = query.order_by(asc(NoteDocument.word_count) if order == 'asc' else desc(NoteDocument.word_count))
|
||||
else:
|
||||
query = query.order_by(desc(NoteDocument.updated_at))
|
||||
|
||||
# 페이지네이션
|
||||
notes = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 자식 노트 개수 계산
|
||||
result = []
|
||||
for note in notes:
|
||||
child_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.parent_note_id == note.id
|
||||
).scalar()
|
||||
|
||||
note_item = NoteDocumentListItem.from_orm(note, child_count)
|
||||
result.append(note_item)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/stats", response_model=NoteStats)
|
||||
def get_note_stats(
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 통계 정보"""
|
||||
total_notes = db.query(func.count(NoteDocument.id)).scalar()
|
||||
published_notes = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.is_published == True
|
||||
).scalar()
|
||||
draft_notes = total_notes - published_notes
|
||||
|
||||
# 노트 타입별 통계
|
||||
type_stats = db.query(
|
||||
NoteDocument.note_type,
|
||||
func.count(NoteDocument.id)
|
||||
).group_by(NoteDocument.note_type).all()
|
||||
note_types = {note_type: count for note_type, count in type_stats}
|
||||
|
||||
# 총 단어 수와 읽기 시간
|
||||
total_words = db.query(func.sum(NoteDocument.word_count)).scalar() or 0
|
||||
total_reading_time = db.query(func.sum(NoteDocument.reading_time)).scalar() or 0
|
||||
|
||||
# 최근 노트들
|
||||
recent_notes_query = db.query(NoteDocument).order_by(
|
||||
desc(NoteDocument.updated_at)
|
||||
).limit(5).all()
|
||||
|
||||
recent_notes = [NoteDocumentListItem.from_orm(note) for note in recent_notes_query]
|
||||
|
||||
return NoteStats(
|
||||
total_notes=total_notes,
|
||||
published_notes=published_notes,
|
||||
draft_notes=draft_notes,
|
||||
note_types=note_types,
|
||||
total_words=total_words,
|
||||
total_reading_time=total_reading_time,
|
||||
recent_notes=recent_notes
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{note_id}", response_model=NoteDocumentResponse)
|
||||
def get_note_document(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 노트 문서 조회"""
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
return NoteDocumentResponse.from_orm(note)
|
||||
|
||||
|
||||
@router.post("/", response_model=NoteDocumentResponse)
|
||||
def create_note_document(
|
||||
note_data: NoteDocumentCreate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""새 노트 문서 생성"""
|
||||
# 단어 수 및 읽기 시간 계산
|
||||
word_count = len(note_data.content or '') if note_data.content else 0
|
||||
reading_time = calculate_reading_time(note_data.content or '')
|
||||
|
||||
note = NoteDocument(
|
||||
title=note_data.title,
|
||||
content=note_data.content,
|
||||
note_type=note_data.note_type,
|
||||
tags=note_data.tags,
|
||||
is_published=note_data.is_published,
|
||||
parent_note_id=note_data.parent_note_id,
|
||||
sort_order=note_data.sort_order,
|
||||
notebook_id=note_data.notebook_id,
|
||||
created_by=current_user.email,
|
||||
word_count=word_count,
|
||||
reading_time=reading_time
|
||||
)
|
||||
|
||||
db.add(note)
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return NoteDocumentResponse.from_orm(note)
|
||||
|
||||
|
||||
@router.put("/{note_id}", response_model=NoteDocumentResponse)
|
||||
def update_note_document(
|
||||
note_id: str,
|
||||
note_data: NoteDocumentUpdate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 문서 업데이트"""
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 권한 확인
|
||||
if note.created_by != current_user.email and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# 업데이트할 필드만 적용
|
||||
update_data = note_data.dict(exclude_unset=True)
|
||||
|
||||
# 내용이 변경되면 단어 수와 읽기 시간 재계산
|
||||
if 'content' in update_data:
|
||||
update_data['word_count'] = len(update_data['content'] or '')
|
||||
update_data['reading_time'] = calculate_reading_time(update_data['content'] or '')
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(note, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return NoteDocumentResponse.from_orm(note)
|
||||
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
def delete_note_document(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 문서 삭제"""
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 권한 확인
|
||||
if note.created_by != current_user.email and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# 자식 노트들이 있는지 확인
|
||||
child_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.parent_note_id == note.id
|
||||
).scalar()
|
||||
|
||||
if child_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot delete note with {child_count} child notes"
|
||||
)
|
||||
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Note deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/{note_id}/content")
|
||||
def get_note_document_content(
|
||||
note_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_sync_db)
|
||||
):
|
||||
"""노트 문서의 HTML 콘텐츠만 반환"""
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note document not found")
|
||||
|
||||
return note.content or ""
|
||||
103
backend/src/api/routes/note_highlights.py
Normal file
103
backend/src/api/routes/note_highlights.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from ...core.database import get_sync_db
|
||||
from ..dependencies import get_current_user
|
||||
from ...models.user import User
|
||||
from ...models.note_highlight import NoteHighlight, NoteHighlightCreate, NoteHighlightUpdate, NoteHighlightResponse
|
||||
from ...models.note_document import NoteDocument
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/note/{note_id}/highlights", response_model=List[NoteHighlightResponse])
|
||||
def get_note_highlights(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 노트의 하이라이트 목록 조회"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 하이라이트 조회
|
||||
highlights = db.query(NoteHighlight).filter(
|
||||
NoteHighlight.note_id == note_id
|
||||
).order_by(NoteHighlight.start_offset).all()
|
||||
|
||||
return [NoteHighlightResponse.from_orm(highlight) for highlight in highlights]
|
||||
|
||||
@router.post("/note-highlights/", response_model=NoteHighlightResponse)
|
||||
def create_note_highlight(
|
||||
highlight_data: NoteHighlightCreate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 하이라이트 생성"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == highlight_data.note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 하이라이트 생성
|
||||
highlight = NoteHighlight(
|
||||
note_id=highlight_data.note_id,
|
||||
start_offset=highlight_data.start_offset,
|
||||
end_offset=highlight_data.end_offset,
|
||||
selected_text=highlight_data.selected_text,
|
||||
highlight_color=highlight_data.highlight_color,
|
||||
highlight_type=highlight_data.highlight_type,
|
||||
created_by=current_user.email
|
||||
)
|
||||
|
||||
db.add(highlight)
|
||||
db.commit()
|
||||
db.refresh(highlight)
|
||||
|
||||
return NoteHighlightResponse.from_orm(highlight)
|
||||
|
||||
@router.put("/note-highlights/{highlight_id}", response_model=NoteHighlightResponse)
|
||||
def update_note_highlight(
|
||||
highlight_id: str,
|
||||
highlight_data: NoteHighlightUpdate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 하이라이트 수정"""
|
||||
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
|
||||
if not highlight:
|
||||
raise HTTPException(status_code=404, detail="Highlight not found")
|
||||
|
||||
# 권한 확인
|
||||
if highlight.created_by != current_user.email and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# 업데이트
|
||||
for field, value in highlight_data.dict(exclude_unset=True).items():
|
||||
setattr(highlight, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(highlight)
|
||||
|
||||
return NoteHighlightResponse.from_orm(highlight)
|
||||
|
||||
@router.delete("/note-highlights/{highlight_id}")
|
||||
def delete_note_highlight(
|
||||
highlight_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 하이라이트 삭제"""
|
||||
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
|
||||
if not highlight:
|
||||
raise HTTPException(status_code=404, detail="Highlight not found")
|
||||
|
||||
# 권한 확인
|
||||
if highlight.created_by != current_user.email and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
db.delete(highlight)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Highlight deleted successfully"}
|
||||
291
backend/src/api/routes/note_links.py
Normal file
291
backend/src/api/routes/note_links.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
노트 문서 링크 관련 API 엔드포인트
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import uuid
|
||||
|
||||
from ...core.database import get_sync_db
|
||||
from ..dependencies import get_current_user
|
||||
from ...models.user import User
|
||||
from ...models.note_document import NoteDocument
|
||||
from ...models.document import Document
|
||||
from ...models.note_link import NoteLink
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class NoteLinkCreate(BaseModel):
|
||||
target_note_id: Optional[str] = None
|
||||
target_document_id: Optional[str] = None
|
||||
selected_text: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
link_text: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
target_text: Optional[str] = None
|
||||
target_start_offset: Optional[int] = None
|
||||
target_end_offset: Optional[int] = None
|
||||
link_type: Optional[str] = "note"
|
||||
|
||||
|
||||
class NoteLinkUpdate(BaseModel):
|
||||
target_note_id: Optional[str] = None
|
||||
target_document_id: Optional[str] = None
|
||||
link_text: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
target_text: Optional[str] = None
|
||||
target_start_offset: Optional[int] = None
|
||||
target_end_offset: Optional[int] = None
|
||||
link_type: Optional[str] = None
|
||||
|
||||
|
||||
class NoteLinkResponse(BaseModel):
|
||||
id: str
|
||||
source_note_id: Optional[str] = None
|
||||
source_document_id: Optional[str] = None
|
||||
target_note_id: Optional[str] = None
|
||||
target_document_id: Optional[str] = None
|
||||
target_content_type: Optional[str] = None # "document" or "note"
|
||||
selected_text: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
link_text: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
target_text: Optional[str] = None
|
||||
target_start_offset: Optional[int] = None
|
||||
target_end_offset: Optional[int] = None
|
||||
link_type: str
|
||||
created_at: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
# 추가 정보
|
||||
target_note_title: Optional[str] = None
|
||||
target_document_title: Optional[str] = None
|
||||
source_note_title: Optional[str] = None
|
||||
source_document_title: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/note-documents/{note_id}/links", response_model=List[NoteLinkResponse])
|
||||
def get_note_links(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트에서 나가는 링크 목록 조회"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 노트에서 나가는 링크들 조회
|
||||
links = db.query(NoteLink).filter(
|
||||
NoteLink.source_note_id == note_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for link in links:
|
||||
link_data = {
|
||||
"id": str(link.id),
|
||||
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
|
||||
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
|
||||
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
|
||||
"target_document_id": str(link.target_document_id) if link.target_document_id else None,
|
||||
"selected_text": link.selected_text,
|
||||
"start_offset": link.start_offset,
|
||||
"end_offset": link.end_offset,
|
||||
"link_text": link.link_text,
|
||||
"description": link.description,
|
||||
"target_text": link.target_text,
|
||||
"target_start_offset": link.target_start_offset,
|
||||
"target_end_offset": link.target_end_offset,
|
||||
"link_type": link.link_type,
|
||||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
|
||||
}
|
||||
|
||||
# 대상 제목 및 타입 추가
|
||||
if link.target_note_id:
|
||||
target_note = db.query(NoteDocument).filter(NoteDocument.id == link.target_note_id).first()
|
||||
if target_note:
|
||||
link_data["target_note_title"] = target_note.title
|
||||
link_data["target_content_type"] = "note"
|
||||
elif link.target_document_id:
|
||||
target_doc = db.query(Document).filter(Document.id == link.target_document_id).first()
|
||||
if target_doc:
|
||||
link_data["target_document_title"] = target_doc.title
|
||||
link_data["target_content_type"] = "document"
|
||||
|
||||
result.append(NoteLinkResponse(**link_data))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/note-documents/{note_id}/backlinks", response_model=List[NoteLinkResponse])
|
||||
def get_note_backlinks(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트로 들어오는 백링크 목록 조회"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 노트로 들어오는 백링크들 조회
|
||||
backlinks = db.query(NoteLink).filter(
|
||||
NoteLink.target_note_id == note_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for link in backlinks:
|
||||
link_data = {
|
||||
"id": str(link.id),
|
||||
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
|
||||
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
|
||||
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
|
||||
"target_document_id": str(link.target_document_id) if link.target_document_id else None,
|
||||
"selected_text": link.selected_text,
|
||||
"start_offset": link.start_offset,
|
||||
"end_offset": link.end_offset,
|
||||
"link_text": link.link_text,
|
||||
"description": link.description,
|
||||
"target_text": link.target_text,
|
||||
"target_start_offset": link.target_start_offset,
|
||||
"target_end_offset": link.target_end_offset,
|
||||
"link_type": link.link_type,
|
||||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
|
||||
}
|
||||
|
||||
# 출발지 제목 추가
|
||||
if link.source_note_id:
|
||||
source_note = db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
|
||||
if source_note:
|
||||
link_data["source_note_title"] = source_note.title
|
||||
elif link.source_document_id:
|
||||
source_doc = db.query(Document).filter(Document.id == link.source_document_id).first()
|
||||
if source_doc:
|
||||
link_data["source_document_title"] = source_doc.title
|
||||
|
||||
result.append(NoteLinkResponse(**link_data))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/note-documents/{note_id}/links", response_model=NoteLinkResponse)
|
||||
def create_note_link(
|
||||
note_id: str,
|
||||
link_data: NoteLinkCreate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트에서 다른 노트/문서로의 링크 생성"""
|
||||
# 출발지 노트 존재 확인
|
||||
source_note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not source_note:
|
||||
raise HTTPException(status_code=404, detail="Source note not found")
|
||||
|
||||
# 대상 확인 (노트 또는 문서 중 하나는 반드시 있어야 함)
|
||||
if not link_data.target_note_id and not link_data.target_document_id:
|
||||
raise HTTPException(status_code=400, detail="Either target_note_id or target_document_id is required")
|
||||
|
||||
if link_data.target_note_id and link_data.target_document_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot specify both target_note_id and target_document_id")
|
||||
|
||||
# 대상 존재 확인
|
||||
if link_data.target_note_id:
|
||||
target_note = db.query(NoteDocument).filter(NoteDocument.id == link_data.target_note_id).first()
|
||||
if not target_note:
|
||||
raise HTTPException(status_code=404, detail="Target note not found")
|
||||
|
||||
if link_data.target_document_id:
|
||||
target_doc = db.query(Document).filter(Document.id == link_data.target_document_id).first()
|
||||
if not target_doc:
|
||||
raise HTTPException(status_code=404, detail="Target document not found")
|
||||
|
||||
# 링크 생성
|
||||
note_link = NoteLink(
|
||||
source_note_id=note_id,
|
||||
target_note_id=link_data.target_note_id,
|
||||
target_document_id=link_data.target_document_id,
|
||||
selected_text=link_data.selected_text,
|
||||
start_offset=link_data.start_offset,
|
||||
end_offset=link_data.end_offset,
|
||||
link_text=link_data.link_text,
|
||||
description=link_data.description,
|
||||
target_text=link_data.target_text,
|
||||
target_start_offset=link_data.target_start_offset,
|
||||
target_end_offset=link_data.target_end_offset,
|
||||
link_type=link_data.link_type or "note",
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(note_link)
|
||||
db.commit()
|
||||
db.refresh(note_link)
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_data = {
|
||||
"id": str(note_link.id),
|
||||
"source_note_id": str(note_link.source_note_id) if note_link.source_note_id else None,
|
||||
"source_document_id": str(note_link.source_document_id) if note_link.source_document_id else None,
|
||||
"target_note_id": str(note_link.target_note_id) if note_link.target_note_id else None,
|
||||
"target_document_id": str(note_link.target_document_id) if note_link.target_document_id else None,
|
||||
"selected_text": note_link.selected_text,
|
||||
"start_offset": note_link.start_offset,
|
||||
"end_offset": note_link.end_offset,
|
||||
"link_text": note_link.link_text,
|
||||
"description": note_link.description,
|
||||
"target_text": note_link.target_text,
|
||||
"target_start_offset": note_link.target_start_offset,
|
||||
"target_end_offset": note_link.target_end_offset,
|
||||
"link_type": note_link.link_type,
|
||||
"created_at": note_link.created_at.isoformat() if note_link.created_at else None,
|
||||
"updated_at": note_link.updated_at.isoformat() if note_link.updated_at else None,
|
||||
}
|
||||
|
||||
# 소스 및 타겟 타입 설정
|
||||
response_data["source_content_type"] = "note" # 노트에서 출발하는 링크
|
||||
|
||||
if note_link.target_note_id:
|
||||
target_note = db.query(NoteDocument).filter(NoteDocument.id == note_link.target_note_id).first()
|
||||
if target_note:
|
||||
response_data["target_note_title"] = target_note.title
|
||||
response_data["target_content_type"] = "note"
|
||||
elif note_link.target_document_id:
|
||||
target_doc = db.query(Document).filter(Document.id == note_link.target_document_id).first()
|
||||
if target_doc:
|
||||
response_data["target_document_title"] = target_doc.title
|
||||
response_data["target_content_type"] = "document"
|
||||
|
||||
return NoteLinkResponse(**response_data)
|
||||
|
||||
|
||||
@router.delete("/note-links/{link_id}")
|
||||
def delete_note_link(
|
||||
link_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 링크 삭제"""
|
||||
link = db.query(NoteLink).filter(NoteLink.id == link_id).first()
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
|
||||
# 권한 확인 (링크 생성자 또는 관리자만 삭제 가능)
|
||||
if link.created_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
db.delete(link)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Link deleted successfully"}
|
||||
128
backend/src/api/routes/note_notes.py
Normal file
128
backend/src/api/routes/note_notes.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from typing import List
|
||||
from ...core.database import get_sync_db
|
||||
from ..dependencies import get_current_user
|
||||
from ...models.user import User
|
||||
from ...models.note_note import NoteNote, NoteNoteCreate, NoteNoteUpdate, NoteNoteResponse
|
||||
from ...models.note_document import NoteDocument
|
||||
from ...models.note_highlight import NoteHighlight
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/note/{note_id}/notes", response_model=List[NoteNoteResponse])
|
||||
def get_note_notes(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 노트의 메모 목록 조회"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 메모 조회
|
||||
notes = db.query(NoteNote).filter(
|
||||
NoteNote.note_id == note_id
|
||||
).options(
|
||||
selectinload(NoteNote.highlight)
|
||||
).order_by(NoteNote.created_at.desc()).all()
|
||||
|
||||
return [NoteNoteResponse.from_orm(note) for note in notes]
|
||||
|
||||
@router.get("/note-highlights/{highlight_id}/notes", response_model=List[NoteNoteResponse])
|
||||
def get_highlight_notes(
|
||||
highlight_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 하이라이트의 메모 목록 조회"""
|
||||
# 하이라이트 존재 확인
|
||||
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
|
||||
if not highlight:
|
||||
raise HTTPException(status_code=404, detail="Highlight not found")
|
||||
|
||||
# 메모 조회
|
||||
notes = db.query(NoteNote).filter(
|
||||
NoteNote.highlight_id == highlight_id
|
||||
).order_by(NoteNote.created_at.desc()).all()
|
||||
|
||||
return [NoteNoteResponse.from_orm(note) for note in notes]
|
||||
|
||||
@router.post("/note-notes/", response_model=NoteNoteResponse)
|
||||
def create_note_note(
|
||||
note_data: NoteNoteCreate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 메모 생성"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_data.note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 하이라이트 존재 확인 (선택사항)
|
||||
if note_data.highlight_id:
|
||||
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == note_data.highlight_id).first()
|
||||
if not highlight:
|
||||
raise HTTPException(status_code=404, detail="Highlight not found")
|
||||
|
||||
# 메모 생성
|
||||
note_note = NoteNote(
|
||||
note_id=note_data.note_id,
|
||||
highlight_id=note_data.highlight_id,
|
||||
content=note_data.content,
|
||||
created_by=current_user.email
|
||||
)
|
||||
|
||||
db.add(note_note)
|
||||
db.commit()
|
||||
db.refresh(note_note)
|
||||
|
||||
return NoteNoteResponse.from_orm(note_note)
|
||||
|
||||
@router.put("/note-notes/{note_note_id}", response_model=NoteNoteResponse)
|
||||
def update_note_note(
|
||||
note_note_id: str,
|
||||
note_data: NoteNoteUpdate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 메모 수정"""
|
||||
note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first()
|
||||
if not note_note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 권한 확인
|
||||
if note_note.created_by != current_user.email and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# 업데이트
|
||||
for field, value in note_data.dict(exclude_unset=True).items():
|
||||
setattr(note_note, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(note_note)
|
||||
|
||||
return NoteNoteResponse.from_orm(note_note)
|
||||
|
||||
@router.delete("/note-notes/{note_note_id}")
|
||||
def delete_note_note(
|
||||
note_note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 메모 삭제"""
|
||||
note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first()
|
||||
if not note_note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 권한 확인
|
||||
if note_note.created_by != current_user.email and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
db.delete(note_note)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Note deleted successfully"}
|
||||
270
backend/src/api/routes/notebooks.py
Normal file
270
backend/src/api/routes/notebooks.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
노트북 (Notebook) 관리 API
|
||||
|
||||
용어 정의:
|
||||
- 노트북 (Notebook): 노트 문서들을 그룹화하는 폴더
|
||||
- 노트 (Note Document): 독립적인 HTML 기반 문서 작성
|
||||
- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc, asc, select
|
||||
from typing import List, Optional
|
||||
|
||||
from ...core.database import get_sync_db
|
||||
from ...models.notebook import (
|
||||
Notebook,
|
||||
NotebookCreate,
|
||||
NotebookUpdate,
|
||||
NotebookResponse,
|
||||
NotebookListItem,
|
||||
NotebookStats
|
||||
)
|
||||
from ...models.note_document import NoteDocument
|
||||
from ...models.user import User
|
||||
from ..dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[NotebookListItem])
|
||||
def get_notebooks(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
active_only: bool = Query(True),
|
||||
sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|sort_order)$"),
|
||||
order: str = Query("desc", regex="^(asc|desc)$"),
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트북 목록 조회"""
|
||||
query = db.query(Notebook)
|
||||
|
||||
# 필터링
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Notebook.title.ilike(search_term)) |
|
||||
(Notebook.description.ilike(search_term))
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Notebook.is_active == True)
|
||||
|
||||
# 정렬
|
||||
if sort_by == 'title':
|
||||
query = query.order_by(asc(Notebook.title) if order == 'asc' else desc(Notebook.title))
|
||||
elif sort_by == 'created_at':
|
||||
query = query.order_by(asc(Notebook.created_at) if order == 'asc' else desc(Notebook.created_at))
|
||||
elif sort_by == 'sort_order':
|
||||
query = query.order_by(asc(Notebook.sort_order) if order == 'asc' else desc(Notebook.sort_order))
|
||||
else:
|
||||
query = query.order_by(desc(Notebook.updated_at))
|
||||
|
||||
# 페이지네이션
|
||||
notebooks = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 노트 개수 계산
|
||||
result = []
|
||||
for notebook in notebooks:
|
||||
note_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.notebook_id == notebook.id
|
||||
).scalar()
|
||||
|
||||
notebook_item = NotebookListItem.from_orm(notebook, note_count)
|
||||
result.append(notebook_item)
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/stats", response_model=NotebookStats)
|
||||
def get_notebook_stats(
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트북 통계 정보"""
|
||||
total_notebooks = db.query(func.count(Notebook.id)).scalar()
|
||||
active_notebooks = db.query(func.count(Notebook.id)).filter(
|
||||
Notebook.is_active == True
|
||||
).scalar()
|
||||
|
||||
total_notes = db.query(func.count(NoteDocument.id)).scalar()
|
||||
notes_without_notebook = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.notebook_id.is_(None)
|
||||
).scalar()
|
||||
|
||||
return NotebookStats(
|
||||
total_notebooks=total_notebooks,
|
||||
active_notebooks=active_notebooks,
|
||||
total_notes=total_notes,
|
||||
notes_without_notebook=notes_without_notebook
|
||||
)
|
||||
|
||||
@router.get("/{notebook_id}", response_model=NotebookResponse)
|
||||
def get_notebook(
|
||||
notebook_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 노트북 조회"""
|
||||
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
|
||||
if not notebook:
|
||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||
|
||||
# 노트 개수 계산
|
||||
note_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.notebook_id == notebook.id
|
||||
).scalar()
|
||||
|
||||
return NotebookResponse.from_orm(notebook, note_count)
|
||||
|
||||
@router.post("/", response_model=NotebookResponse)
|
||||
def create_notebook(
|
||||
notebook_data: NotebookCreate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""새 노트북 생성"""
|
||||
notebook = Notebook(
|
||||
title=notebook_data.title,
|
||||
description=notebook_data.description,
|
||||
color=notebook_data.color,
|
||||
icon=notebook_data.icon,
|
||||
is_active=notebook_data.is_active,
|
||||
sort_order=notebook_data.sort_order,
|
||||
created_by=current_user.email
|
||||
)
|
||||
|
||||
db.add(notebook)
|
||||
db.commit()
|
||||
db.refresh(notebook)
|
||||
|
||||
return NotebookResponse.from_orm(notebook, 0)
|
||||
|
||||
@router.put("/{notebook_id}", response_model=NotebookResponse)
|
||||
def update_notebook(
|
||||
notebook_id: str,
|
||||
notebook_data: NotebookUpdate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트북 업데이트"""
|
||||
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
|
||||
if not notebook:
|
||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||
|
||||
# 업데이트할 필드만 적용
|
||||
update_data = notebook_data.dict(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(notebook, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(notebook)
|
||||
|
||||
# 노트 개수 계산
|
||||
note_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.notebook_id == notebook.id
|
||||
).scalar()
|
||||
|
||||
return NotebookResponse.from_orm(notebook, note_count)
|
||||
|
||||
@router.delete("/{notebook_id}")
|
||||
def delete_notebook(
|
||||
notebook_id: str,
|
||||
force: bool = Query(False, description="강제 삭제 (노트가 있어도 삭제)"),
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트북 삭제"""
|
||||
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
|
||||
if not notebook:
|
||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||
|
||||
# 노트북에 포함된 노트 확인
|
||||
note_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.notebook_id == notebook.id
|
||||
).scalar()
|
||||
|
||||
if note_count > 0 and not force:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot delete notebook with {note_count} notes. Use force=true to delete anyway."
|
||||
)
|
||||
|
||||
if force and note_count > 0:
|
||||
# 노트들의 notebook_id를 NULL로 설정 (기본 노트북으로 이동)
|
||||
db.query(NoteDocument).filter(
|
||||
NoteDocument.notebook_id == notebook.id
|
||||
).update({NoteDocument.notebook_id: None})
|
||||
|
||||
db.delete(notebook)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notebook deleted successfully"}
|
||||
|
||||
@router.get("/{notebook_id}/notes")
|
||||
def get_notebook_notes(
|
||||
notebook_id: str,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트북에 포함된 노트들 조회"""
|
||||
# 노트북 존재 확인
|
||||
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
|
||||
if not notebook:
|
||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||
|
||||
# 노트들 조회
|
||||
notes = db.query(NoteDocument).filter(
|
||||
NoteDocument.notebook_id == notebook_id
|
||||
).order_by(desc(NoteDocument.updated_at)).offset(skip).limit(limit).all()
|
||||
|
||||
return notes
|
||||
|
||||
@router.post("/{notebook_id}/notes/{note_id}")
|
||||
def add_note_to_notebook(
|
||||
notebook_id: str,
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트를 노트북에 추가"""
|
||||
# 노트북과 노트 존재 확인
|
||||
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
|
||||
if not notebook:
|
||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 노트를 노트북에 할당
|
||||
note.notebook_id = notebook_id
|
||||
db.commit()
|
||||
|
||||
return {"message": "Note added to notebook successfully"}
|
||||
|
||||
@router.delete("/{notebook_id}/notes/{note_id}")
|
||||
def remove_note_from_notebook(
|
||||
notebook_id: str,
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트를 노트북에서 제거"""
|
||||
note = db.query(NoteDocument).filter(
|
||||
NoteDocument.id == note_id,
|
||||
NoteDocument.notebook_id == notebook_id
|
||||
).first()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found in this notebook")
|
||||
|
||||
# 노트북에서 제거 (기본 노트북으로 이동)
|
||||
note.notebook_id = None
|
||||
db.commit()
|
||||
|
||||
return {"message": "Note removed from notebook successfully"}
|
||||
532
backend/src/api/routes/notes.py
Normal file
532
backend/src/api/routes/notes.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
노트 문서 (Note Document) 관리 API
|
||||
|
||||
용어 정의:
|
||||
- 노트 (Note Document): 독립적인 HTML 기반 문서 작성
|
||||
- 노트북 (Notebook): 노트들을 그룹화하는 폴더
|
||||
- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API - highlights.py)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from sqlalchemy import func, desc, asc, select
|
||||
from typing import List, Optional
|
||||
# import markdown # 임시로 비활성화
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ...core.database import get_sync_db
|
||||
from ...models.note_document import (
|
||||
NoteDocument,
|
||||
NoteDocumentCreate,
|
||||
NoteDocumentUpdate,
|
||||
NoteDocumentResponse,
|
||||
NoteDocumentListItem,
|
||||
NoteStats
|
||||
)
|
||||
from ...models.user import User
|
||||
from ..dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# === 하이라이트 메모 (Highlight Memo) API ===
|
||||
# 용어 정의: 하이라이트에 달리는 짧은 코멘트
|
||||
|
||||
@router.post("/")
|
||||
def create_note(
|
||||
note_data: dict,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""하이라이트 메모 생성"""
|
||||
from ...models.note import Note
|
||||
from ...models.highlight import Highlight
|
||||
|
||||
# 하이라이트 소유권 확인
|
||||
highlight = db.query(Highlight).filter(
|
||||
Highlight.id == note_data.get('highlight_id'),
|
||||
Highlight.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not highlight:
|
||||
raise HTTPException(status_code=404, detail="하이라이트를 찾을 수 없습니다")
|
||||
|
||||
# 메모 생성
|
||||
note = Note(
|
||||
highlight_id=note_data.get('highlight_id'),
|
||||
content=note_data.get('content', ''),
|
||||
is_private=note_data.get('is_private', False),
|
||||
tags=note_data.get('tags', [])
|
||||
)
|
||||
|
||||
db.add(note)
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return note
|
||||
|
||||
@router.put("/{note_id}")
|
||||
def update_note(
|
||||
note_id: str,
|
||||
note_data: dict,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""하이라이트 메모 업데이트"""
|
||||
from ...models.note import Note
|
||||
from ...models.highlight import Highlight
|
||||
|
||||
# 메모 존재 및 소유권 확인
|
||||
note = db.query(Note).join(Highlight).filter(
|
||||
Note.id == note_id,
|
||||
Highlight.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
# 메모 업데이트
|
||||
if 'content' in note_data:
|
||||
note.content = note_data['content']
|
||||
if 'tags' in note_data:
|
||||
note.tags = note_data['tags']
|
||||
|
||||
note.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return note
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
def delete_highlight_note(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""하이라이트 메모 삭제"""
|
||||
from ...models.note import Note
|
||||
from ...models.highlight import Highlight
|
||||
|
||||
note = db.query(Note).join(Highlight).filter(
|
||||
Note.id == note_id,
|
||||
Highlight.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
|
||||
return {"message": "메모가 삭제되었습니다"}
|
||||
|
||||
@router.get("/document/{document_id}")
|
||||
async def get_document_notes(
|
||||
document_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 문서의 모든 하이라이트 메모 조회"""
|
||||
from ...models.note import Note
|
||||
from ...models.highlight import Highlight
|
||||
|
||||
notes = db.query(Note).join(Highlight).filter(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
).options(
|
||||
selectinload(Note.highlight)
|
||||
).all()
|
||||
|
||||
return notes
|
||||
|
||||
def clean_html_content(content: str) -> str:
|
||||
"""HTML 내용 정리 및 검증"""
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
# 기본적인 HTML 정리 (나중에 더 정교하게 할 수 있음)
|
||||
return content.strip()
|
||||
|
||||
def calculate_reading_time(content: str) -> int:
|
||||
"""읽기 시간 계산 (분 단위)"""
|
||||
if not content:
|
||||
return 0
|
||||
|
||||
# 단어 수 계산 (한글, 영문 모두 고려)
|
||||
korean_chars = len(re.findall(r'[가-힣]', content))
|
||||
english_words = len(re.findall(r'\b[a-zA-Z]+\b', content))
|
||||
|
||||
# 한글: 분당 500자, 영문: 분당 200단어 기준
|
||||
korean_time = korean_chars / 500
|
||||
english_time = english_words / 200
|
||||
|
||||
total_minutes = max(1, int(korean_time + english_time))
|
||||
return total_minutes
|
||||
|
||||
def calculate_word_count(content: str) -> int:
|
||||
"""단어/글자 수 계산"""
|
||||
if not content:
|
||||
return 0
|
||||
|
||||
korean_chars = len(re.findall(r'[가-힣]', content))
|
||||
english_words = len(re.findall(r'\b[a-zA-Z]+\b', content))
|
||||
|
||||
return korean_chars + english_words
|
||||
|
||||
@router.get("/")
|
||||
def get_notes(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
note_type: Optional[str] = Query(None),
|
||||
tags: Optional[str] = Query(None), # 쉼표로 구분된 태그
|
||||
search: Optional[str] = Query(None),
|
||||
published_only: bool = Query(False),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
notebook_id: Optional[str] = Query(None), # 노트북 필터
|
||||
document_id: Optional[str] = Query(None), # 하이라이트 메모 조회용
|
||||
note_document_id: Optional[str] = Query(None), # 노트 문서의 하이라이트 메모 조회용
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 목록 조회 또는 하이라이트 메모 조회"""
|
||||
|
||||
# 하이라이트 메모 조회 요청인 경우
|
||||
if document_id or note_document_id:
|
||||
from ...models.note import Note
|
||||
from ...models.highlight import Highlight
|
||||
|
||||
if document_id:
|
||||
# 일반 문서의 하이라이트 메모 조회
|
||||
notes = db.query(Note).join(Highlight).filter(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
).options(
|
||||
selectinload(Note.highlight)
|
||||
).all()
|
||||
else:
|
||||
# 노트 문서의 하이라이트 메모 조회 (note_document_id)
|
||||
# 노트 하이라이트 모델이 있다면 사용, 없다면 빈 리스트 반환
|
||||
notes = []
|
||||
|
||||
return notes
|
||||
|
||||
# 일반 노트 문서 목록 조회
|
||||
# 동기 SQLAlchemy 스타일
|
||||
query = db.query(NoteDocument)
|
||||
|
||||
# 필터링
|
||||
if note_type:
|
||||
query = query.filter(NoteDocument.note_type == note_type)
|
||||
|
||||
if tags:
|
||||
tag_list = [tag.strip() for tag in tags.split(',')]
|
||||
query = query.filter(NoteDocument.tags.overlap(tag_list))
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(NoteDocument.title.ilike(search_term)) |
|
||||
(NoteDocument.content.ilike(search_term))
|
||||
)
|
||||
|
||||
if published_only:
|
||||
query = query.filter(NoteDocument.is_published == True)
|
||||
|
||||
if notebook_id:
|
||||
if notebook_id == 'null':
|
||||
# 미분류 노트 (notebook_id가 None인 것들)
|
||||
query = query.filter(NoteDocument.notebook_id.is_(None))
|
||||
else:
|
||||
query = query.filter(NoteDocument.notebook_id == notebook_id)
|
||||
|
||||
if parent_id:
|
||||
query = query.filter(NoteDocument.parent_note_id == parent_id)
|
||||
else:
|
||||
# 최상위 노트만 (parent_id가 None인 것들)
|
||||
query = query.filter(NoteDocument.parent_note_id.is_(None))
|
||||
|
||||
# 정렬 및 페이징
|
||||
query = query.order_by(desc(NoteDocument.updated_at))
|
||||
notes = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 자식 노트 개수 계산
|
||||
result = []
|
||||
for note in notes:
|
||||
child_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.parent_note_id == note.id
|
||||
).scalar()
|
||||
|
||||
note_item = NoteDocumentListItem.from_orm(note, child_count)
|
||||
result.append(note_item)
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/stats", response_model=NoteStats)
|
||||
def get_note_stats(
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 통계 정보"""
|
||||
total_notes = db.query(func.count(NoteDocument.id)).scalar()
|
||||
published_notes = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.is_published == True
|
||||
).scalar()
|
||||
draft_notes = total_notes - published_notes
|
||||
|
||||
# 노트 타입별 통계
|
||||
type_stats = db.query(
|
||||
NoteDocument.note_type,
|
||||
func.count(NoteDocument.id)
|
||||
).group_by(NoteDocument.note_type).all()
|
||||
|
||||
note_types = {note_type: count for note_type, count in type_stats}
|
||||
|
||||
# 총 단어 수와 읽기 시간
|
||||
totals = db.query(
|
||||
func.sum(NoteDocument.word_count),
|
||||
func.sum(NoteDocument.reading_time)
|
||||
).first()
|
||||
|
||||
total_words = totals[0] or 0
|
||||
total_reading_time = totals[1] or 0
|
||||
|
||||
# 최근 노트 (5개)
|
||||
recent_notes_query = db.query(NoteDocument).order_by(
|
||||
desc(NoteDocument.updated_at)
|
||||
).limit(5)
|
||||
|
||||
recent_notes = []
|
||||
for note in recent_notes_query.all():
|
||||
child_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.parent_note_id == note.id
|
||||
).scalar()
|
||||
|
||||
note_item = NoteDocumentListItem.from_orm(note, child_count)
|
||||
recent_notes.append(note_item)
|
||||
|
||||
return NoteStats(
|
||||
total_notes=total_notes,
|
||||
published_notes=published_notes,
|
||||
draft_notes=draft_notes,
|
||||
note_types=note_types,
|
||||
total_words=total_words,
|
||||
total_reading_time=total_reading_time,
|
||||
recent_notes=recent_notes
|
||||
)
|
||||
|
||||
@router.get("/{note_id}", response_model=NoteDocumentResponse)
|
||||
def get_note(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 노트 조회"""
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
return NoteDocumentResponse.from_orm(note)
|
||||
|
||||
@router.post("/", response_model=NoteDocumentResponse)
|
||||
def create_note(
|
||||
note_data: NoteDocumentCreate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""새 노트 생성"""
|
||||
# HTML 내용 정리
|
||||
cleaned_content = clean_html_content(note_data.content or "")
|
||||
|
||||
# 통계 계산
|
||||
word_count = calculate_word_count(note_data.content or "")
|
||||
reading_time = calculate_reading_time(note_data.content or "")
|
||||
|
||||
note = NoteDocument(
|
||||
title=note_data.title,
|
||||
content=cleaned_content,
|
||||
note_type=note_data.note_type,
|
||||
tags=note_data.tags,
|
||||
is_published=note_data.is_published,
|
||||
parent_note_id=note_data.parent_note_id,
|
||||
sort_order=note_data.sort_order,
|
||||
word_count=word_count,
|
||||
reading_time=reading_time,
|
||||
created_by=current_user.email
|
||||
)
|
||||
|
||||
db.add(note)
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return NoteDocumentResponse.from_orm(note)
|
||||
|
||||
@router.put("/{note_id}", response_model=NoteDocumentResponse)
|
||||
def update_note(
|
||||
note_id: str,
|
||||
note_data: NoteDocumentUpdate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 수정"""
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 수정 권한 확인 (작성자만 수정 가능)
|
||||
if note.created_by != current_user.username and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# 필드 업데이트
|
||||
update_data = note_data.dict(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(note, field, value)
|
||||
|
||||
# 내용이 변경된 경우 통계 재계산
|
||||
if 'content' in update_data:
|
||||
note.content = clean_html_content(note.content or "")
|
||||
note.word_count = calculate_word_count(note.content or "")
|
||||
note.reading_time = calculate_reading_time(note.content or "")
|
||||
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return NoteDocumentResponse.from_orm(note)
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
def delete_note(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 삭제"""
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 삭제 권한 확인
|
||||
if note.created_by != current_user.email and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# 자식 노트들의 parent_note_id를 NULL로 설정
|
||||
db.query(NoteDocument).filter(
|
||||
NoteDocument.parent_note_id == note_id
|
||||
).update({"parent_note_id": None})
|
||||
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Note deleted successfully"}
|
||||
|
||||
@router.get("/{note_id}/children", response_model=List[NoteDocumentListItem])
|
||||
async def get_note_children(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트의 자식 노트들 조회"""
|
||||
children = db.query(NoteDocument).filter(
|
||||
NoteDocument.parent_note_id == note_id
|
||||
).order_by(asc(NoteDocument.sort_order), desc(NoteDocument.updated_at)).all()
|
||||
|
||||
result = []
|
||||
for child in children:
|
||||
child_count = db.query(func.count(NoteDocument.id)).filter(
|
||||
NoteDocument.parent_note_id == child.id
|
||||
).scalar()
|
||||
|
||||
child_item = NoteDocumentListItem.from_orm(child)
|
||||
child_item.child_count = child_count
|
||||
result.append(child_item)
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/{note_id}/export/html")
|
||||
async def export_note_html(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트를 HTML 파일로 내보내기"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# HTML 템플릿 생성
|
||||
html_template = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{note.title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }}
|
||||
h1, h2, h3 {{ color: #333; }}
|
||||
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
|
||||
pre {{ background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }}
|
||||
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 20px; color: #666; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
||||
th {{ background-color: #f2f2f2; }}
|
||||
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="meta">
|
||||
<strong>제목:</strong> {note.title}<br>
|
||||
<strong>타입:</strong> {note.note_type}<br>
|
||||
<strong>작성자:</strong> {note.created_by}<br>
|
||||
<strong>작성일:</strong> {note.created_at.strftime('%Y-%m-%d %H:%M')}<br>
|
||||
<strong>태그:</strong> {', '.join(note.tags) if note.tags else '없음'}
|
||||
</div>
|
||||
<hr>
|
||||
{note.content or ''}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
filename = f"{note.title.replace(' ', '_')}.html"
|
||||
|
||||
return Response(
|
||||
content=html_template,
|
||||
media_type="text/html",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
@router.get("/{note_id}/export/markdown")
|
||||
async def export_note_markdown(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트를 마크다운 파일로 내보내기"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 메타데이터 포함한 마크다운
|
||||
markdown_content = f"""---
|
||||
title: {note.title}
|
||||
type: {note.note_type}
|
||||
author: {note.created_by}
|
||||
created: {note.created_at.strftime('%Y-%m-%d %H:%M')}
|
||||
tags: [{', '.join(note.tags) if note.tags else ''}]
|
||||
---
|
||||
|
||||
# {note.title}
|
||||
|
||||
{note.content or ''}
|
||||
"""
|
||||
|
||||
filename = f"{note.title.replace(' ', '_')}.md"
|
||||
|
||||
return Response(
|
||||
content=markdown_content,
|
||||
media_type="text/plain",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
671
backend/src/api/routes/search.py
Normal file
671
backend/src/api/routes/search.py
Normal file
@@ -0,0 +1,671 @@
|
||||
"""
|
||||
검색 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 ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.document import Document, Tag
|
||||
from ...models.highlight import Highlight
|
||||
from ...models.note import Note
|
||||
from ...models.memo_tree import MemoTree, MemoNode
|
||||
from ...models.note_document import NoteDocument
|
||||
from ..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, memo, 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_note_documents(q, current_user, db)
|
||||
results.extend(note_results)
|
||||
|
||||
# 3. 메모 트리 노드 검색
|
||||
if not type_filter or type_filter == "memo":
|
||||
memo_results = await search_memo_nodes(q, current_user, db)
|
||||
results.extend(memo_results)
|
||||
|
||||
# 4. 기존 메모 검색 (하위 호환성)
|
||||
if not type_filter or type_filter == "note":
|
||||
old_note_results = await search_notes(q, document_id, tag, current_user, db)
|
||||
results.extend(old_note_results)
|
||||
|
||||
# 5. 하이라이트 검색
|
||||
if not type_filter or type_filter == "highlight":
|
||||
highlight_results = await search_highlights(q, document_id, current_user, db)
|
||||
results.extend(highlight_results)
|
||||
|
||||
# 6. 하이라이트 메모 검색
|
||||
if not type_filter or type_filter == "highlight_note":
|
||||
highlight_note_results = await search_highlight_notes(q, document_id, current_user, db)
|
||||
results.extend(highlight_note_results)
|
||||
|
||||
# 7. 문서 본문 검색 (OCR 데이터)
|
||||
if not type_filter or type_filter == "document_content":
|
||||
content_results = await search_document_content(q, document_id, current_user, db)
|
||||
results.extend(content_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]}
|
||||
|
||||
|
||||
async def search_highlight_notes(
|
||||
query: str,
|
||||
document_id: Optional[str],
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[SearchResult]:
|
||||
"""하이라이트 메모 내용 검색"""
|
||||
query_obj = select(Note).options(
|
||||
selectinload(Note.highlight).selectinload(Highlight.document)
|
||||
)
|
||||
|
||||
# 하이라이트가 있는 노트만
|
||||
query_obj = query_obj.where(Note.highlight_id.isnot(None))
|
||||
|
||||
# Highlight와 조인 (권한 및 문서 필터링을 위해)
|
||||
query_obj = query_obj.join(Highlight)
|
||||
|
||||
# 권한 필터링 - 사용자의 노트만
|
||||
query_obj = query_obj.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(Note.content.ilike(f"%{query}%"))
|
||||
|
||||
result = await db.execute(query_obj)
|
||||
notes = result.scalars().all()
|
||||
|
||||
search_results = []
|
||||
for note in notes:
|
||||
if not note.highlight or not note.highlight.document:
|
||||
continue
|
||||
|
||||
# 관련성 점수 계산
|
||||
score = 1.5 # 메모 내용 매치는 높은 점수
|
||||
content_lower = (note.content or "").lower()
|
||||
if query.lower() in content_lower:
|
||||
score += 2.0
|
||||
|
||||
search_results.append(SearchResult(
|
||||
type="highlight_note",
|
||||
id=str(note.id),
|
||||
title=f"하이라이트 메모: {note.highlight.selected_text[:30]}...",
|
||||
content=note.content or "",
|
||||
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,
|
||||
"note_content": note.content
|
||||
}
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
|
||||
async def search_note_documents(
|
||||
query: str,
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[SearchResult]:
|
||||
"""노트 문서 검색"""
|
||||
query_obj = select(NoteDocument).where(
|
||||
or_(
|
||||
NoteDocument.title.ilike(f"%{query}%"),
|
||||
NoteDocument.content.ilike(f"%{query}%")
|
||||
)
|
||||
)
|
||||
|
||||
# 권한 필터링 - 사용자의 노트만
|
||||
query_obj = query_obj.where(NoteDocument.created_by == current_user.email)
|
||||
|
||||
result = await db.execute(query_obj)
|
||||
notes = result.scalars().all()
|
||||
|
||||
search_results = []
|
||||
for note in notes:
|
||||
# 관련성 점수 계산
|
||||
score = 1.0
|
||||
if query.lower() in note.title.lower():
|
||||
score += 2.0
|
||||
if note.content and query.lower() in note.content.lower():
|
||||
score += 1.0
|
||||
|
||||
search_results.append(SearchResult(
|
||||
type="note",
|
||||
id=str(note.id),
|
||||
title=note.title,
|
||||
content=note.content or "",
|
||||
document_id=str(note.id), # 노트 자체가 문서
|
||||
document_title=note.title,
|
||||
created_at=note.created_at,
|
||||
relevance_score=score
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
|
||||
async def search_memo_nodes(
|
||||
query: str,
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[SearchResult]:
|
||||
"""메모 트리 노드 검색"""
|
||||
query_obj = select(MemoNode).options(
|
||||
selectinload(MemoNode.tree)
|
||||
).where(
|
||||
or_(
|
||||
MemoNode.title.ilike(f"%{query}%"),
|
||||
MemoNode.content.ilike(f"%{query}%")
|
||||
)
|
||||
)
|
||||
|
||||
# 권한 필터링 - 사용자의 트리에 속한 노드만
|
||||
query_obj = query_obj.join(MemoTree).where(MemoTree.user_id == current_user.id)
|
||||
|
||||
result = await db.execute(query_obj)
|
||||
nodes = result.scalars().all()
|
||||
|
||||
search_results = []
|
||||
for node in nodes:
|
||||
# 관련성 점수 계산
|
||||
score = 1.0
|
||||
if query.lower() in node.title.lower():
|
||||
score += 2.0
|
||||
if node.content and query.lower() in node.content.lower():
|
||||
score += 1.0
|
||||
|
||||
search_results.append(SearchResult(
|
||||
type="memo",
|
||||
id=str(node.id),
|
||||
title=node.title,
|
||||
content=node.content or "",
|
||||
document_id=str(node.tree.id), # 트리 ID를 문서 ID로 사용
|
||||
document_title=f"📚 {node.tree.title}",
|
||||
created_at=node.created_at,
|
||||
relevance_score=score
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
|
||||
async def search_document_content(
|
||||
query: str,
|
||||
document_id: Optional[str],
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[SearchResult]:
|
||||
"""문서 본문 내용 검색 (OCR 데이터 포함)"""
|
||||
# 문서 권한 확인
|
||||
doc_query = select(Document)
|
||||
if not current_user.is_admin:
|
||||
doc_query = doc_query.where(
|
||||
or_(
|
||||
Document.is_public == True,
|
||||
Document.uploaded_by == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
if document_id:
|
||||
doc_query = doc_query.where(Document.id == document_id)
|
||||
|
||||
result = await db.execute(doc_query)
|
||||
documents = result.scalars().all()
|
||||
|
||||
search_results = []
|
||||
|
||||
for doc in documents:
|
||||
text_content = ""
|
||||
file_type = ""
|
||||
|
||||
# HTML 파일에서 텍스트 검색 (PDF OCR 결과 또는 서적 HTML)
|
||||
if doc.html_path:
|
||||
try:
|
||||
import os
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# 절대 경로 처리
|
||||
if doc.html_path.startswith('/'):
|
||||
html_file_path = doc.html_path
|
||||
else:
|
||||
html_file_path = os.path.join("/app", doc.html_path)
|
||||
|
||||
if os.path.exists(html_file_path):
|
||||
with open(html_file_path, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
|
||||
# HTML에서 텍스트 추출
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
text_content = soup.get_text()
|
||||
|
||||
# PDF인지 서적인지 구분
|
||||
if doc.pdf_path:
|
||||
file_type = "PDF"
|
||||
else:
|
||||
file_type = "HTML"
|
||||
|
||||
except Exception as e:
|
||||
print(f"HTML 파일 읽기 오류 ({doc.html_path}): {e}")
|
||||
continue
|
||||
|
||||
# PDF 파일 직접 텍스트 추출 (HTML이 없는 경우)
|
||||
elif doc.pdf_path:
|
||||
try:
|
||||
import os
|
||||
import PyPDF2
|
||||
|
||||
# 절대 경로 처리
|
||||
if doc.pdf_path.startswith('/'):
|
||||
pdf_file_path = doc.pdf_path
|
||||
else:
|
||||
pdf_file_path = os.path.join("/app", doc.pdf_path)
|
||||
|
||||
if os.path.exists(pdf_file_path):
|
||||
with open(pdf_file_path, 'rb') as f:
|
||||
pdf_reader = PyPDF2.PdfReader(f)
|
||||
text_pages = []
|
||||
|
||||
# 모든 페이지에서 텍스트 추출
|
||||
for page_num in range(len(pdf_reader.pages)):
|
||||
page = pdf_reader.pages[page_num]
|
||||
page_text = page.extract_text()
|
||||
if page_text.strip():
|
||||
text_pages.append(f"[페이지 {page_num + 1}]\n{page_text}")
|
||||
|
||||
text_content = "\n\n".join(text_pages)
|
||||
file_type = "PDF (직접추출)"
|
||||
|
||||
except Exception as e:
|
||||
print(f"PDF 파일 읽기 오류 ({doc.pdf_path}): {e}")
|
||||
continue
|
||||
|
||||
# 검색어가 포함된 경우
|
||||
if text_content and query.lower() in text_content.lower():
|
||||
# 검색어 주변 컨텍스트 추출
|
||||
context = extract_search_context(text_content, query, context_length=300)
|
||||
|
||||
# 관련성 점수 계산
|
||||
score = 2.0 # 본문 매치는 높은 점수
|
||||
|
||||
# 검색어 매치 횟수로 점수 조정
|
||||
match_count = text_content.lower().count(query.lower())
|
||||
score += min(match_count * 0.1, 1.0) # 최대 1점 추가
|
||||
|
||||
search_results.append(SearchResult(
|
||||
type="document_content",
|
||||
id=str(doc.id),
|
||||
title=f"📄 {doc.title} ({file_type} 본문)",
|
||||
content=context,
|
||||
document_id=str(doc.id),
|
||||
document_title=doc.title,
|
||||
created_at=doc.created_at,
|
||||
relevance_score=score,
|
||||
highlight_info={
|
||||
"file_type": file_type,
|
||||
"match_count": match_count,
|
||||
"has_pdf": bool(doc.pdf_path),
|
||||
"has_html": bool(doc.html_path)
|
||||
}
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
|
||||
def extract_search_context(text: str, query: str, context_length: int = 200) -> str:
|
||||
"""검색어 주변 컨텍스트 추출"""
|
||||
text_lower = text.lower()
|
||||
query_lower = query.lower()
|
||||
|
||||
# 첫 번째 매치 위치 찾기
|
||||
match_pos = text_lower.find(query_lower)
|
||||
if match_pos == -1:
|
||||
return text[:context_length] + "..."
|
||||
|
||||
# 컨텍스트 시작/끝 위치 계산
|
||||
start = max(0, match_pos - context_length // 2)
|
||||
end = min(len(text), match_pos + len(query) + context_length // 2)
|
||||
|
||||
context = text[start:end]
|
||||
|
||||
# 앞뒤에 ... 추가
|
||||
if start > 0:
|
||||
context = "..." + context
|
||||
if end < len(text):
|
||||
context = context + "..."
|
||||
|
||||
return context
|
||||
104
backend/src/api/routes/setup.py
Normal file
104
backend/src/api/routes/setup.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
시스템 초기 설정 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.security import get_password_hash
|
||||
from ...models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class InitialSetupRequest(BaseModel):
|
||||
"""초기 설정 요청"""
|
||||
admin_email: EmailStr
|
||||
admin_password: str
|
||||
admin_full_name: Optional[str] = None
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""설정 상태 응답"""
|
||||
is_setup_required: bool
|
||||
has_admin_user: bool
|
||||
total_users: int
|
||||
|
||||
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
async def get_setup_status(db: AsyncSession = Depends(get_db)):
|
||||
"""시스템 설정 상태 확인"""
|
||||
# 전체 사용자 수 조회
|
||||
total_users_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_users_result.scalar()
|
||||
|
||||
# 관리자 사용자 존재 여부 확인
|
||||
admin_result = await db.execute(
|
||||
select(User).where(User.role == "root")
|
||||
)
|
||||
has_admin_user = admin_result.scalar_one_or_none() is not None
|
||||
|
||||
return SetupStatusResponse(
|
||||
is_setup_required=total_users == 0 or not has_admin_user,
|
||||
has_admin_user=has_admin_user,
|
||||
total_users=total_users
|
||||
)
|
||||
|
||||
|
||||
@router.post("/initialize")
|
||||
async def initialize_system(
|
||||
setup_data: InitialSetupRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""시스템 초기 설정 (root 계정 생성)"""
|
||||
# 이미 설정된 시스템인지 확인
|
||||
existing_admin = await db.execute(
|
||||
select(User).where(User.role == "root")
|
||||
)
|
||||
if existing_admin.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System is already initialized"
|
||||
)
|
||||
|
||||
# 이메일 중복 확인
|
||||
existing_user = await db.execute(
|
||||
select(User).where(User.email == setup_data.admin_email)
|
||||
)
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Root 관리자 계정 생성
|
||||
hashed_password = get_password_hash(setup_data.admin_password)
|
||||
|
||||
admin_user = User(
|
||||
email=setup_data.admin_email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=setup_data.admin_full_name or "시스템 관리자",
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role="root",
|
||||
can_manage_books=True,
|
||||
can_manage_notes=True,
|
||||
can_manage_novels=True
|
||||
)
|
||||
|
||||
db.add(admin_user)
|
||||
await db.commit()
|
||||
await db.refresh(admin_user)
|
||||
|
||||
return {
|
||||
"message": "System initialized successfully",
|
||||
"admin_user": {
|
||||
"id": str(admin_user.id),
|
||||
"email": admin_user.email,
|
||||
"full_name": admin_user.full_name,
|
||||
"role": admin_user.role
|
||||
}
|
||||
}
|
||||
663
backend/src/api/routes/todos.py
Normal file
663
backend/src/api/routes/todos.py
Normal file
@@ -0,0 +1,663 @@
|
||||
"""
|
||||
할일관리 시스템 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.todo import TodoItem, TodoComment
|
||||
from ...schemas.todo import (
|
||||
TodoItemCreate, TodoItemSchedule, TodoItemUpdate, TodoItemDelay, TodoItemSplit,
|
||||
TodoItemResponse, TodoItemWithComments, TodoCommentCreate, TodoCommentUpdate,
|
||||
TodoCommentResponse, TodoStats, TodoDashboard
|
||||
)
|
||||
from ..dependencies import get_current_active_user
|
||||
|
||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 할일 아이템 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/", response_model=TodoItemResponse)
|
||||
async def create_todo_item(
|
||||
todo_data: TodoItemCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 할일 생성 (draft 상태)"""
|
||||
try:
|
||||
new_todo = TodoItem(
|
||||
user_id=current_user.id,
|
||||
content=todo_data.content,
|
||||
status="draft"
|
||||
)
|
||||
|
||||
db.add(new_todo)
|
||||
await db.commit()
|
||||
await db.refresh(new_todo)
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_data = TodoItemResponse(
|
||||
id=new_todo.id,
|
||||
user_id=new_todo.user_id,
|
||||
content=new_todo.content,
|
||||
status=new_todo.status,
|
||||
created_at=new_todo.created_at,
|
||||
start_date=new_todo.start_date,
|
||||
estimated_minutes=new_todo.estimated_minutes,
|
||||
completed_at=new_todo.completed_at,
|
||||
delayed_until=new_todo.delayed_until,
|
||||
parent_id=new_todo.parent_id,
|
||||
split_order=new_todo.split_order,
|
||||
comment_count=0
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_todo_item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create todo item: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{todo_id}/schedule", response_model=TodoItemResponse)
|
||||
async def schedule_todo_item(
|
||||
todo_id: UUID,
|
||||
schedule_data: TodoItemSchedule,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 일정 설정 (draft -> scheduled)"""
|
||||
try:
|
||||
# 할일 조회
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id,
|
||||
TodoItem.status == "draft"
|
||||
)
|
||||
)
|
||||
)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
|
||||
if not todo_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found or not in draft status"
|
||||
)
|
||||
|
||||
# 2시간 이상인 경우 분할 제안
|
||||
if schedule_data.estimated_minutes > 120:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tasks longer than 2 hours should be split into smaller tasks"
|
||||
)
|
||||
|
||||
# 일정 설정
|
||||
todo_item.start_date = schedule_data.start_date
|
||||
todo_item.estimated_minutes = schedule_data.estimated_minutes
|
||||
todo_item.status = "scheduled"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(todo_item)
|
||||
|
||||
# 댓글 수 계산
|
||||
comment_count_result = await db.execute(
|
||||
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
|
||||
)
|
||||
comment_count = comment_count_result.scalar() or 0
|
||||
|
||||
response_data = TodoItemResponse(
|
||||
id=todo_item.id,
|
||||
user_id=todo_item.user_id,
|
||||
content=todo_item.content,
|
||||
status=todo_item.status,
|
||||
created_at=todo_item.created_at,
|
||||
start_date=todo_item.start_date,
|
||||
estimated_minutes=todo_item.estimated_minutes,
|
||||
completed_at=todo_item.completed_at,
|
||||
delayed_until=todo_item.delayed_until,
|
||||
parent_id=todo_item.parent_id,
|
||||
split_order=todo_item.split_order,
|
||||
comment_count=comment_count
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in schedule_todo_item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to schedule todo item: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{todo_id}/split", response_model=List[TodoItemResponse])
|
||||
async def split_todo_item(
|
||||
todo_id: UUID,
|
||||
split_data: TodoItemSplit,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 분할"""
|
||||
try:
|
||||
# 원본 할일 조회
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id,
|
||||
TodoItem.status == "draft"
|
||||
)
|
||||
)
|
||||
)
|
||||
original_todo = result.scalar_one_or_none()
|
||||
|
||||
if not original_todo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found or not in draft status"
|
||||
)
|
||||
|
||||
# 분할된 할일들 생성
|
||||
subtasks = []
|
||||
for i, (subtask_content, estimated_minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)):
|
||||
if estimated_minutes > 120:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Subtask {i+1} is longer than 2 hours"
|
||||
)
|
||||
|
||||
subtask = TodoItem(
|
||||
user_id=current_user.id,
|
||||
content=subtask_content,
|
||||
status="draft",
|
||||
parent_id=original_todo.id,
|
||||
split_order=i + 1
|
||||
)
|
||||
db.add(subtask)
|
||||
subtasks.append(subtask)
|
||||
|
||||
# 원본 할일 상태 변경 (분할됨 표시)
|
||||
original_todo.status = "split"
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_data = []
|
||||
for subtask in subtasks:
|
||||
await db.refresh(subtask)
|
||||
response_data.append(TodoItemResponse(
|
||||
id=subtask.id,
|
||||
user_id=subtask.user_id,
|
||||
content=subtask.content,
|
||||
status=subtask.status,
|
||||
created_at=subtask.created_at,
|
||||
start_date=subtask.start_date,
|
||||
estimated_minutes=subtask.estimated_minutes,
|
||||
completed_at=subtask.completed_at,
|
||||
delayed_until=subtask.delayed_until,
|
||||
parent_id=subtask.parent_id,
|
||||
split_order=subtask.split_order,
|
||||
comment_count=0
|
||||
))
|
||||
|
||||
return response_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in split_todo_item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to split todo item: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TodoItemResponse])
|
||||
async def get_todo_items(
|
||||
status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 목록 조회"""
|
||||
try:
|
||||
query = select(TodoItem).where(TodoItem.user_id == current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.where(TodoItem.status == status)
|
||||
|
||||
query = query.order_by(TodoItem.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
todo_items = result.scalars().all()
|
||||
|
||||
# 각 할일의 댓글 수 계산
|
||||
response_data = []
|
||||
for todo_item in todo_items:
|
||||
comment_count_result = await db.execute(
|
||||
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
|
||||
)
|
||||
comment_count = comment_count_result.scalar() or 0
|
||||
|
||||
response_data.append(TodoItemResponse(
|
||||
id=todo_item.id,
|
||||
user_id=todo_item.user_id,
|
||||
content=todo_item.content,
|
||||
status=todo_item.status,
|
||||
created_at=todo_item.created_at,
|
||||
start_date=todo_item.start_date,
|
||||
estimated_minutes=todo_item.estimated_minutes,
|
||||
completed_at=todo_item.completed_at,
|
||||
delayed_until=todo_item.delayed_until,
|
||||
parent_id=todo_item.parent_id,
|
||||
split_order=todo_item.split_order,
|
||||
comment_count=comment_count
|
||||
))
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_todo_items: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get todo items: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/active", response_model=List[TodoItemResponse])
|
||||
async def get_active_todos(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""오늘 활성화된 할일들 조회"""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# scheduled 상태이면서 시작일이 지난 것들을 active로 변경
|
||||
update_result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.user_id == current_user.id,
|
||||
TodoItem.status == "scheduled",
|
||||
TodoItem.start_date <= now
|
||||
)
|
||||
)
|
||||
)
|
||||
scheduled_items = update_result.scalars().all()
|
||||
|
||||
for item in scheduled_items:
|
||||
item.status = "active"
|
||||
|
||||
if scheduled_items:
|
||||
await db.commit()
|
||||
|
||||
# active 상태인 할일들 조회
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.user_id == current_user.id,
|
||||
TodoItem.status == "active"
|
||||
)
|
||||
).order_by(TodoItem.start_date.asc())
|
||||
)
|
||||
active_todos = result.scalars().all()
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_data = []
|
||||
for todo_item in active_todos:
|
||||
comment_count_result = await db.execute(
|
||||
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
|
||||
)
|
||||
comment_count = comment_count_result.scalar() or 0
|
||||
|
||||
response_data.append(TodoItemResponse(
|
||||
id=todo_item.id,
|
||||
user_id=todo_item.user_id,
|
||||
content=todo_item.content,
|
||||
status=todo_item.status,
|
||||
created_at=todo_item.created_at,
|
||||
start_date=todo_item.start_date,
|
||||
estimated_minutes=todo_item.estimated_minutes,
|
||||
completed_at=todo_item.completed_at,
|
||||
delayed_until=todo_item.delayed_until,
|
||||
parent_id=todo_item.parent_id,
|
||||
split_order=todo_item.split_order,
|
||||
comment_count=comment_count
|
||||
))
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_active_todos: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get active todos: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{todo_id}/complete", response_model=TodoItemResponse)
|
||||
async def complete_todo_item(
|
||||
todo_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 완료"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id,
|
||||
TodoItem.status == "active"
|
||||
)
|
||||
)
|
||||
)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
|
||||
if not todo_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found or not active"
|
||||
)
|
||||
|
||||
todo_item.status = "completed"
|
||||
todo_item.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(todo_item)
|
||||
|
||||
# 댓글 수 계산
|
||||
comment_count_result = await db.execute(
|
||||
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
|
||||
)
|
||||
comment_count = comment_count_result.scalar() or 0
|
||||
|
||||
response_data = TodoItemResponse(
|
||||
id=todo_item.id,
|
||||
user_id=todo_item.user_id,
|
||||
content=todo_item.content,
|
||||
status=todo_item.status,
|
||||
created_at=todo_item.created_at,
|
||||
start_date=todo_item.start_date,
|
||||
estimated_minutes=todo_item.estimated_minutes,
|
||||
completed_at=todo_item.completed_at,
|
||||
delayed_until=todo_item.delayed_until,
|
||||
parent_id=todo_item.parent_id,
|
||||
split_order=todo_item.split_order,
|
||||
comment_count=comment_count
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in complete_todo_item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to complete todo item: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{todo_id}/delay", response_model=TodoItemResponse)
|
||||
async def delay_todo_item(
|
||||
todo_id: UUID,
|
||||
delay_data: TodoItemDelay,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 지연"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id,
|
||||
TodoItem.status == "active"
|
||||
)
|
||||
)
|
||||
)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
|
||||
if not todo_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found or not active"
|
||||
)
|
||||
|
||||
todo_item.status = "delayed"
|
||||
todo_item.delayed_until = delay_data.delayed_until
|
||||
todo_item.start_date = delay_data.delayed_until # 새로운 시작일로 업데이트
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(todo_item)
|
||||
|
||||
# 댓글 수 계산
|
||||
comment_count_result = await db.execute(
|
||||
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
|
||||
)
|
||||
comment_count = comment_count_result.scalar() or 0
|
||||
|
||||
response_data = TodoItemResponse(
|
||||
id=todo_item.id,
|
||||
user_id=todo_item.user_id,
|
||||
content=todo_item.content,
|
||||
status=todo_item.status,
|
||||
created_at=todo_item.created_at,
|
||||
start_date=todo_item.start_date,
|
||||
estimated_minutes=todo_item.estimated_minutes,
|
||||
completed_at=todo_item.completed_at,
|
||||
delayed_until=todo_item.delayed_until,
|
||||
parent_id=todo_item.parent_id,
|
||||
split_order=todo_item.split_order,
|
||||
comment_count=comment_count
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in delay_todo_item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delay todo item: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 댓글 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/{todo_id}/comments", response_model=TodoCommentResponse)
|
||||
async def create_todo_comment(
|
||||
todo_id: UUID,
|
||||
comment_data: TodoCommentCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일에 댓글 추가"""
|
||||
try:
|
||||
# 할일 존재 확인
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
|
||||
if not todo_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found"
|
||||
)
|
||||
|
||||
new_comment = TodoComment(
|
||||
todo_item_id=todo_id,
|
||||
user_id=current_user.id,
|
||||
content=comment_data.content
|
||||
)
|
||||
|
||||
db.add(new_comment)
|
||||
await db.commit()
|
||||
await db.refresh(new_comment)
|
||||
|
||||
return TodoCommentResponse(
|
||||
id=new_comment.id,
|
||||
todo_item_id=new_comment.todo_item_id,
|
||||
user_id=new_comment.user_id,
|
||||
content=new_comment.content,
|
||||
created_at=new_comment.created_at,
|
||||
updated_at=new_comment.updated_at
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_todo_comment: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create todo comment: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse])
|
||||
async def get_todo_comments(
|
||||
todo_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 댓글 목록 조회"""
|
||||
try:
|
||||
# 할일 존재 확인
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
|
||||
if not todo_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found"
|
||||
)
|
||||
|
||||
# 댓글 조회
|
||||
result = await db.execute(
|
||||
select(TodoComment).where(TodoComment.todo_item_id == todo_id)
|
||||
.order_by(TodoComment.created_at.asc())
|
||||
)
|
||||
comments = result.scalars().all()
|
||||
|
||||
return [
|
||||
TodoCommentResponse(
|
||||
id=comment.id,
|
||||
todo_item_id=comment.todo_item_id,
|
||||
user_id=comment.user_id,
|
||||
content=comment.content,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_todo_comments: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get todo comments: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{todo_id}", response_model=TodoItemWithComments)
|
||||
async def get_todo_item_with_comments(
|
||||
todo_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""댓글이 포함된 할일 상세 조회"""
|
||||
try:
|
||||
# 할일 조회
|
||||
result = await db.execute(
|
||||
select(TodoItem).options(selectinload(TodoItem.comments))
|
||||
.where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
|
||||
if not todo_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found"
|
||||
)
|
||||
|
||||
# 댓글 데이터 구성
|
||||
comments = [
|
||||
TodoCommentResponse(
|
||||
id=comment.id,
|
||||
todo_item_id=comment.todo_item_id,
|
||||
user_id=comment.user_id,
|
||||
content=comment.content,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at
|
||||
)
|
||||
for comment in todo_item.comments
|
||||
]
|
||||
|
||||
return TodoItemWithComments(
|
||||
id=todo_item.id,
|
||||
user_id=todo_item.user_id,
|
||||
content=todo_item.content,
|
||||
status=todo_item.status,
|
||||
created_at=todo_item.created_at,
|
||||
start_date=todo_item.start_date,
|
||||
estimated_minutes=todo_item.estimated_minutes,
|
||||
completed_at=todo_item.completed_at,
|
||||
delayed_until=todo_item.delayed_until,
|
||||
parent_id=todo_item.parent_id,
|
||||
split_order=todo_item.split_order,
|
||||
comment_count=len(comments),
|
||||
comments=comments
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_todo_item_with_comments: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get todo item with comments: {str(e)}"
|
||||
)
|
||||
402
backend/src/api/routes/users.py
Normal file
402
backend/src/api/routes/users.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
사용자 관리 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.security import get_password_hash, verify_password
|
||||
from ...models.user import User
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""사용자 응답"""
|
||||
id: str
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
role: str
|
||||
can_manage_books: bool
|
||||
can_manage_notes: bool
|
||||
can_manage_novels: bool
|
||||
session_timeout_minutes: int
|
||||
theme: str
|
||||
language: str
|
||||
timezone: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
last_login: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
"""사용자 생성 요청"""
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
role: str = "user"
|
||||
can_manage_books: bool = True
|
||||
can_manage_notes: bool = True
|
||||
can_manage_novels: bool = True
|
||||
session_timeout_minutes: int = 5
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""사용자 업데이트 요청"""
|
||||
full_name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
role: Optional[str] = None
|
||||
can_manage_books: Optional[bool] = None
|
||||
can_manage_notes: Optional[bool] = None
|
||||
can_manage_novels: Optional[bool] = None
|
||||
session_timeout_minutes: Optional[int] = None
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""프로필 업데이트 요청"""
|
||||
full_name: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""비밀번호 변경 요청"""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_profile(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""현재 사용자 프로필 조회"""
|
||||
return UserResponse(
|
||||
id=str(current_user.id),
|
||||
email=current_user.email,
|
||||
full_name=current_user.full_name,
|
||||
is_active=current_user.is_active,
|
||||
is_admin=current_user.is_admin,
|
||||
role=current_user.role,
|
||||
can_manage_books=current_user.can_manage_books,
|
||||
can_manage_notes=current_user.can_manage_notes,
|
||||
can_manage_novels=current_user.can_manage_novels,
|
||||
session_timeout_minutes=current_user.session_timeout_minutes,
|
||||
theme=current_user.theme,
|
||||
language=current_user.language,
|
||||
timezone=current_user.timezone,
|
||||
created_at=current_user.created_at,
|
||||
updated_at=current_user.updated_at,
|
||||
last_login=current_user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_current_user_profile(
|
||||
profile_data: UpdateProfileRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""현재 사용자 프로필 업데이트"""
|
||||
update_fields = profile_data.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_fields.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserResponse(
|
||||
id=str(current_user.id),
|
||||
email=current_user.email,
|
||||
full_name=current_user.full_name,
|
||||
is_active=current_user.is_active,
|
||||
is_admin=current_user.is_admin,
|
||||
role=current_user.role,
|
||||
can_manage_books=current_user.can_manage_books,
|
||||
can_manage_notes=current_user.can_manage_notes,
|
||||
can_manage_novels=current_user.can_manage_novels,
|
||||
session_timeout_minutes=current_user.session_timeout_minutes,
|
||||
theme=current_user.theme,
|
||||
language=current_user.language,
|
||||
timezone=current_user.timezone,
|
||||
created_at=current_user.created_at,
|
||||
updated_at=current_user.updated_at,
|
||||
last_login=current_user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/change-password")
|
||||
async def change_current_user_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="Current password is incorrect"
|
||||
)
|
||||
|
||||
# 새 비밀번호 설정
|
||||
current_user.hashed_password = get_password_hash(password_data.new_password)
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 목록 조회 (관리자 전용)"""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
return [
|
||||
UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
role=user.role,
|
||||
can_manage_books=user.can_manage_books,
|
||||
can_manage_notes=user.can_manage_notes,
|
||||
can_manage_novels=user.can_manage_novels,
|
||||
session_timeout_minutes=user.session_timeout_minutes,
|
||||
theme=user.theme,
|
||||
language=user.language,
|
||||
timezone=user.timezone,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_login=user.last_login
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse)
|
||||
async def create_user(
|
||||
user_data: CreateUserRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 생성 (관리자 전용)"""
|
||||
# 이메일 중복 확인
|
||||
existing_user = await db.execute(
|
||||
select(User).where(User.email == user_data.email)
|
||||
)
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# 권한 확인 (root만 admin/root 계정 생성 가능)
|
||||
if user_data.role in ["admin", "root"] and current_user.role != "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only root users can create admin accounts"
|
||||
)
|
||||
|
||||
# 사용자 생성
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=user_data.full_name,
|
||||
is_active=True,
|
||||
is_admin=user_data.role in ["admin", "root"],
|
||||
role=user_data.role,
|
||||
can_manage_books=user_data.can_manage_books,
|
||||
can_manage_notes=user_data.can_manage_notes,
|
||||
can_manage_novels=user_data.can_manage_novels,
|
||||
session_timeout_minutes=user_data.session_timeout_minutes
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
return UserResponse(
|
||||
id=str(new_user.id),
|
||||
email=new_user.email,
|
||||
full_name=new_user.full_name,
|
||||
is_active=new_user.is_active,
|
||||
is_admin=new_user.is_admin,
|
||||
role=new_user.role,
|
||||
can_manage_books=new_user.can_manage_books,
|
||||
can_manage_notes=new_user.can_manage_notes,
|
||||
can_manage_novels=new_user.can_manage_novels,
|
||||
session_timeout_minutes=new_user.session_timeout_minutes,
|
||||
theme=new_user.theme,
|
||||
language=new_user.language,
|
||||
timezone=new_user.timezone,
|
||||
created_at=new_user.created_at,
|
||||
updated_at=new_user.updated_at,
|
||||
last_login=new_user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
current_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 UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
role=user.role,
|
||||
can_manage_books=user.can_manage_books,
|
||||
can_manage_notes=user.can_manage_notes,
|
||||
can_manage_novels=user.can_manage_novels,
|
||||
session_timeout_minutes=user.session_timeout_minutes,
|
||||
theme=user.theme,
|
||||
language=user.language,
|
||||
timezone=user.timezone,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_login=user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
user_data: UpdateUserRequest,
|
||||
current_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"
|
||||
)
|
||||
|
||||
# 권한 확인 (root만 admin/root 계정 수정 가능)
|
||||
if user.role in ["admin", "root"] and current_user.role != "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only root users can modify admin accounts"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_fields = user_data.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_fields.items():
|
||||
if field == "role":
|
||||
# 역할 변경 시 is_admin도 함께 업데이트
|
||||
setattr(user, field, value)
|
||||
user.is_admin = value in ["admin", "root"]
|
||||
else:
|
||||
setattr(user, field, value)
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
role=user.role,
|
||||
can_manage_books=user.can_manage_books,
|
||||
can_manage_notes=user.can_manage_notes,
|
||||
can_manage_novels=user.can_manage_novels,
|
||||
session_timeout_minutes=user.session_timeout_minutes,
|
||||
theme=user.theme,
|
||||
language=user.language,
|
||||
timezone=user.timezone,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_login=user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
current_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 == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete your own account"
|
||||
)
|
||||
|
||||
# root 계정 삭제 방지
|
||||
if user.role == "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete root account"
|
||||
)
|
||||
|
||||
# 권한 확인 (root만 admin 계정 삭제 가능)
|
||||
if user.role == "admin" and current_user.role != "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only root users can delete admin accounts"
|
||||
)
|
||||
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
53
backend/src/core/config.py
Normal file
53
backend/src/core/config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
애플리케이션 설정
|
||||
"""
|
||||
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"]
|
||||
ALLOWED_ORIGINS: 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)
|
||||
122
backend/src/core/database.py
Normal file
122
backend/src/core/database.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
데이터베이스 설정 및 연결
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
||||
from sqlalchemy import MetaData, create_engine
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
from .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,
|
||||
)
|
||||
|
||||
# 동기 데이터베이스 엔진 생성 (노트 API용)
|
||||
sync_database_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
|
||||
sync_engine = create_engine(
|
||||
sync_database_url,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300,
|
||||
)
|
||||
|
||||
# 비동기 세션 팩토리
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
# 동기 세션 팩토리
|
||||
SyncSessionLocal = sessionmaker(
|
||||
sync_engine,
|
||||
class_=Session,
|
||||
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()
|
||||
|
||||
|
||||
def get_sync_db() -> Generator[Session, None, None]:
|
||||
"""동기 데이터베이스 세션 의존성 (노트 API용)"""
|
||||
session = SyncSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""데이터베이스 초기화"""
|
||||
from ..models import user, document, highlight, note, bookmark
|
||||
|
||||
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 ..models.user import User
|
||||
from .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}")
|
||||
94
backend/src/core/security.py
Normal file
94
backend/src/core/security.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
보안 관련 유틸리티
|
||||
"""
|
||||
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 .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, timeout_minutes: Optional[int] = None) -> str:
|
||||
"""액세스 토큰 생성"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
elif timeout_minutes is not None:
|
||||
if timeout_minutes == 0:
|
||||
# 무제한 토큰 (1년으로 설정)
|
||||
expire = datetime.utcnow() + timedelta(days=365)
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=timeout_minutes)
|
||||
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
|
||||
86
backend/src/main.py
Normal file
86
backend/src/main.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
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 .core.config import settings
|
||||
from .core.database import init_db
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup, todos
|
||||
from .api.routes import note_documents, note_links
|
||||
|
||||
|
||||
@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_ORIGINS if hasattr(settings, 'ALLOWED_ORIGINS') else ["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 정적 파일 서빙 (업로드된 파일들)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
# API 라우터 등록
|
||||
app.include_router(setup.router, prefix="/api/setup", tags=["시스템 설정"])
|
||||
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/highlight-notes", tags=["하이라이트 메모"])
|
||||
app.include_router(books.router, prefix="/api/books", tags=["서적"])
|
||||
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
|
||||
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
||||
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
|
||||
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
|
||||
# 링크 삭제를 위한 추가 라우터 (document-links 경로 지원)
|
||||
app.include_router(document_links.router, prefix="/api", tags=["문서 링크 (호환성)"])
|
||||
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서"])
|
||||
app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
|
||||
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
|
||||
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
|
||||
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])
|
||||
app.include_router(todos.router, prefix="/api", 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,
|
||||
)
|
||||
35
backend/src/models/__init__.py
Normal file
35
backend/src/models/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
모델 패키지 초기화
|
||||
"""
|
||||
from .user import User
|
||||
from .document import Document, Tag
|
||||
from .book import Book
|
||||
from .highlight import Highlight
|
||||
from .note import Note
|
||||
from .bookmark import Bookmark
|
||||
from .document_link import DocumentLink
|
||||
from .note_document import NoteDocument
|
||||
from .notebook import Notebook
|
||||
from .note_highlight import NoteHighlight
|
||||
from .note_note import NoteNote
|
||||
from .note_link import NoteLink
|
||||
from .memo_tree import MemoTree, MemoNode, MemoTreeShare
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Document",
|
||||
"Tag",
|
||||
"Book",
|
||||
"Highlight",
|
||||
"Note",
|
||||
"Bookmark",
|
||||
"DocumentLink",
|
||||
"NoteDocument",
|
||||
"Notebook",
|
||||
"NoteHighlight",
|
||||
"NoteNote",
|
||||
"NoteLink",
|
||||
"MemoTree",
|
||||
"MemoNode",
|
||||
"MemoTreeShare"
|
||||
]
|
||||
27
backend/src/models/book.py
Normal file
27
backend/src/models/book.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy import Column, String, DateTime, Text, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
class Book(Base):
|
||||
"""서적 테이블 (여러 문서를 묶는 단위)"""
|
||||
__tablename__ = "books"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title = Column(String(500), nullable=False, index=True)
|
||||
author = Column(String(255), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
language = Column(String(10), default="ko")
|
||||
is_public = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
documents = relationship("Document", back_populates="book", cascade="all, delete-orphan")
|
||||
categories = relationship("BookCategory", back_populates="book", cascade="all, delete-orphan", order_by="BookCategory.sort_order")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Book(title='{self.title}', author='{self.author}')>"
|
||||
26
backend/src/models/book_category.py
Normal file
26
backend/src/models/book_category.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
class BookCategory(Base):
|
||||
"""서적 소분류 테이블 (서적 내 문서 그룹화)"""
|
||||
__tablename__ = "book_categories"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
book_id = Column(UUID(as_uuid=True), ForeignKey('books.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False) # 소분류 이름 (예: "Chapter 1", "설계 기준", "계산서")
|
||||
description = Column(Text, nullable=True) # 소분류 설명
|
||||
sort_order = Column(Integer, default=0) # 소분류 정렬 순서
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
book = relationship("Book", back_populates="categories")
|
||||
documents = relationship("Document", back_populates="category", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BookCategory(name='{self.name}', book='{self.book.title if self.book else None}')>"
|
||||
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 ..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}')>"
|
||||
87
backend/src/models/document.py
Normal file
87
backend/src/models/document.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
문서 모델
|
||||
"""
|
||||
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 ..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)
|
||||
book_id = Column(UUID(as_uuid=True), ForeignKey('books.id'), nullable=True, index=True) # 서적 ID
|
||||
category_id = Column(UUID(as_uuid=True), ForeignKey('book_categories.id'), nullable=True, index=True) # 소분류 ID
|
||||
title = Column(String(500), nullable=False, index=True)
|
||||
sort_order = Column(Integer, default=0) # 문서 정렬 순서 (소분류 내에서)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# 파일 정보
|
||||
html_path = Column(String(1000), nullable=True) # HTML 파일 경로 (PDF만 업로드하는 경우 null 가능)
|
||||
pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택)
|
||||
thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로
|
||||
matched_pdf_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True) # 매칭된 PDF 문서 ID
|
||||
|
||||
# 메타데이터
|
||||
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) # 문서 작성일 (사용자 입력)
|
||||
|
||||
# 관계
|
||||
book = relationship("Book", back_populates="documents") # 서적 관계
|
||||
category = relationship("BookCategory", back_populates="documents") # 소분류 관계
|
||||
uploader = relationship("User", backref="uploaded_documents")
|
||||
tags = relationship("Tag", secondary=document_tags, back_populates="documents")
|
||||
highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan")
|
||||
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}')>"
|
||||
53
backend/src/models/document_link.py
Normal file
53
backend/src/models/document_link.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
문서 링크 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class DocumentLink(Base):
|
||||
"""문서 링크 테이블"""
|
||||
__tablename__ = "document_links"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# 링크가 생성된 문서 (출발점)
|
||||
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True)
|
||||
|
||||
# 링크 대상 문서 또는 노트 (도착점) - 외래키 제약 조건 제거하여 노트 ID도 허용
|
||||
target_document_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# 출발점 텍스트 정보 (기존)
|
||||
selected_text = Column(Text, nullable=False) # 선택된 텍스트
|
||||
start_offset = Column(Integer, nullable=False) # 시작 위치
|
||||
end_offset = Column(Integer, nullable=False) # 끝 위치
|
||||
|
||||
# 도착점 텍스트 정보 (새로 추가)
|
||||
target_text = Column(Text, nullable=True) # 대상 문서에서 선택된 텍스트
|
||||
target_start_offset = Column(Integer, nullable=True) # 대상 문서에서 시작 위치
|
||||
target_end_offset = Column(Integer, nullable=True) # 대상 문서에서 끝 위치
|
||||
|
||||
# 링크 메타데이터
|
||||
link_text = Column(String(500), nullable=True) # 사용자 정의 링크 텍스트 (선택사항)
|
||||
description = Column(Text, nullable=True) # 링크 설명 (선택사항)
|
||||
|
||||
# 링크 타입 (전체 문서 vs 특정 부분)
|
||||
link_type = Column(String(20), default="document", nullable=False) # "document" or "text_fragment"
|
||||
|
||||
# 생성자 정보
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계 - target_document는 외래키 제약 조건이 없으므로 relationship 제거
|
||||
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_links")
|
||||
# target_document relationship 제거 (노트 ID도 포함할 수 있으므로)
|
||||
creator = relationship("User", backref="created_links")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DocumentLink(id='{self.id}', text='{self.selected_text[:50]}...')>"
|
||||
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 ..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")
|
||||
notes = relationship("Note", back_populates="highlight", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>"
|
||||
111
backend/src/models/memo_tree.py
Normal file
111
backend/src/models/memo_tree.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
트리 구조 메모장 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class MemoTree(Base):
|
||||
"""메모 트리 (프로젝트/워크스페이스)"""
|
||||
__tablename__ = "memo_trees"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
tree_type = Column(String(50), default="general") # 'novel', 'research', 'project', 'general'
|
||||
template_data = Column(JSON) # 템플릿별 메타데이터
|
||||
settings = Column(JSON, default={}) # 트리별 설정
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
is_public = Column(Boolean, default=False)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
|
||||
# 관계
|
||||
user = relationship("User", back_populates="memo_trees")
|
||||
nodes = relationship("MemoNode", back_populates="tree", cascade="all, delete-orphan")
|
||||
shares = relationship("MemoTreeShare", back_populates="tree", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MemoNode(Base):
|
||||
"""메모 노드 (트리의 각 노드)"""
|
||||
__tablename__ = "memo_nodes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"))
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 기본 정보
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text) # Markdown 형식
|
||||
node_type = Column(String(50), default="memo") # 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
# 트리 구조 관리
|
||||
sort_order = Column(Integer, default=0)
|
||||
depth_level = Column(Integer, default=0)
|
||||
path = Column(Text) # 경로 저장 (예: /1/3/7)
|
||||
|
||||
# 메타데이터
|
||||
tags = Column(ARRAY(String)) # 태그 배열
|
||||
node_metadata = Column(JSON, default={}) # 노드별 메타데이터
|
||||
|
||||
# 상태 관리
|
||||
status = Column(String(50), default="draft") # 'draft', 'writing', 'review', 'complete'
|
||||
word_count = Column(Integer, default=0)
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical = Column(Boolean, default=False) # 정사 경로 여부
|
||||
canonical_order = Column(Integer, nullable=True) # 정사 경로 순서
|
||||
story_path = Column(Text, nullable=True) # 정사 경로 문자열
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
tree = relationship("MemoTree", back_populates="nodes")
|
||||
user = relationship("User", back_populates="memo_nodes")
|
||||
parent = relationship("MemoNode", remote_side=[id], back_populates="children")
|
||||
children = relationship("MemoNode", back_populates="parent", cascade="all, delete-orphan")
|
||||
versions = relationship("MemoNodeVersion", back_populates="node", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MemoNodeVersion(Base):
|
||||
"""메모 노드 버전 관리"""
|
||||
__tablename__ = "memo_node_versions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
node_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||
version_number = Column(Integer, nullable=False)
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text)
|
||||
node_metadata = Column(JSON, default={})
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 관계
|
||||
node = relationship("MemoNode", back_populates="versions")
|
||||
creator = relationship("User")
|
||||
|
||||
|
||||
class MemoTreeShare(Base):
|
||||
"""메모 트리 공유 (협업 기능)"""
|
||||
__tablename__ = "memo_tree_shares"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
|
||||
shared_with_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
permission_level = Column(String(20), default="read") # 'read', 'write', 'admin'
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 관계
|
||||
tree = relationship("MemoTree", back_populates="shares")
|
||||
shared_with_user = relationship("User", foreign_keys=[shared_with_user_id])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
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 ..core.database import Base
|
||||
|
||||
|
||||
class Note(Base):
|
||||
"""메모 테이블 (하이라이트와 1:N 관계)"""
|
||||
__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)
|
||||
|
||||
# 메모 내용
|
||||
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="notes")
|
||||
|
||||
@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]}...')>"
|
||||
151
backend/src/models/note_document.py
Normal file
151
backend/src/models/note_document.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
class NoteDocument(Base):
|
||||
"""노트 문서 모델"""
|
||||
__tablename__ = "notes_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text) # HTML 내용 (기본)
|
||||
markdown_content = Column(Text) # 마크다운 내용 (선택사항)
|
||||
note_type = Column(String(50), default='note') # note, research, summary, idea 등
|
||||
tags = Column(ARRAY(String), default=[])
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
created_by = Column(String(100), nullable=False)
|
||||
is_published = Column(Boolean, default=False)
|
||||
parent_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True)
|
||||
notebook_id = Column(UUID(as_uuid=True), ForeignKey('notebooks.id'), nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
# 관계 설정
|
||||
notebook = relationship("Notebook", back_populates="notes")
|
||||
highlights = relationship("NoteHighlight", back_populates="note", cascade="all, delete-orphan")
|
||||
notes = relationship("NoteNote", back_populates="note", cascade="all, delete-orphan")
|
||||
word_count = Column(Integer, default=0)
|
||||
reading_time = Column(Integer, default=0) # 예상 읽기 시간 (분)
|
||||
|
||||
# 관계
|
||||
parent_note = relationship("NoteDocument", remote_side=[id], back_populates="child_notes")
|
||||
child_notes = relationship("NoteDocument", back_populates="parent_note")
|
||||
|
||||
# Pydantic 모델들
|
||||
class NoteDocumentBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
note_type: str = Field(default='note', pattern='^(note|research|summary|idea|guide|reference)$')
|
||||
tags: List[str] = Field(default=[])
|
||||
is_published: bool = Field(default=False)
|
||||
parent_note_id: Optional[str] = None
|
||||
notebook_id: Optional[str] = None
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
class NoteDocumentCreate(NoteDocumentBase):
|
||||
pass
|
||||
|
||||
class NoteDocumentUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
note_type: Optional[str] = Field(None, pattern='^(note|research|summary|idea|guide|reference)$')
|
||||
tags: Optional[List[str]] = None
|
||||
is_published: Optional[bool] = None
|
||||
parent_note_id: Optional[str] = None
|
||||
notebook_id: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
class NoteDocumentResponse(NoteDocumentBase):
|
||||
id: str
|
||||
markdown_content: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
word_count: int
|
||||
reading_time: int
|
||||
|
||||
# 계층 구조 정보
|
||||
parent_note: Optional['NoteDocumentResponse'] = None
|
||||
child_notes: List['NoteDocumentResponse'] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj):
|
||||
"""ORM 객체에서 Pydantic 모델로 변환"""
|
||||
data = {
|
||||
'id': str(obj.id), # UUID를 문자열로 변환
|
||||
'title': obj.title,
|
||||
'content': obj.content,
|
||||
'note_type': obj.note_type,
|
||||
'tags': obj.tags or [],
|
||||
'is_published': obj.is_published,
|
||||
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
|
||||
'notebook_id': str(obj.notebook_id) if obj.notebook_id else None,
|
||||
'sort_order': obj.sort_order,
|
||||
'markdown_content': obj.markdown_content,
|
||||
'created_at': obj.created_at,
|
||||
'updated_at': obj.updated_at,
|
||||
'created_by': obj.created_by,
|
||||
'word_count': obj.word_count or 0,
|
||||
'reading_time': obj.reading_time or 0,
|
||||
}
|
||||
return cls(**data)
|
||||
|
||||
# 자기 참조 관계를 위한 모델 업데이트
|
||||
NoteDocumentResponse.model_rebuild()
|
||||
|
||||
class NoteDocumentListItem(BaseModel):
|
||||
"""노트 목록용 간소화된 모델"""
|
||||
id: str
|
||||
title: str
|
||||
note_type: str
|
||||
tags: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
is_published: bool
|
||||
word_count: int
|
||||
reading_time: int
|
||||
parent_note_id: Optional[str] = None
|
||||
child_count: int = 0 # 자식 노트 개수
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj, child_count=0):
|
||||
"""ORM 객체에서 Pydantic 모델로 변환"""
|
||||
data = {
|
||||
'id': str(obj.id), # UUID를 문자열로 변환
|
||||
'title': obj.title,
|
||||
'note_type': obj.note_type,
|
||||
'tags': obj.tags or [],
|
||||
'created_at': obj.created_at,
|
||||
'updated_at': obj.updated_at,
|
||||
'created_by': obj.created_by,
|
||||
'is_published': obj.is_published,
|
||||
'word_count': obj.word_count or 0,
|
||||
'reading_time': obj.reading_time or 0,
|
||||
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
|
||||
'child_count': child_count,
|
||||
}
|
||||
return cls(**data)
|
||||
|
||||
class NoteStats(BaseModel):
|
||||
"""노트 통계 정보"""
|
||||
total_notes: int
|
||||
published_notes: int
|
||||
draft_notes: int
|
||||
note_types: dict # {type: count}
|
||||
total_words: int
|
||||
total_reading_time: int
|
||||
recent_notes: List[NoteDocumentListItem]
|
||||
69
backend/src/models/note_highlight.py
Normal file
69
backend/src/models/note_highlight.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
class NoteHighlight(Base):
|
||||
"""노트 하이라이트 모델"""
|
||||
__tablename__ = "note_highlights"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False)
|
||||
start_offset = Column(Integer, nullable=False)
|
||||
end_offset = Column(Integer, nullable=False)
|
||||
selected_text = Column(Text, nullable=False)
|
||||
highlight_color = Column(String(50), nullable=False, default='#FFFF00')
|
||||
highlight_type = Column(String(50), nullable=False, default='highlight')
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
created_by = Column(String(100), nullable=False)
|
||||
|
||||
# 관계
|
||||
note = relationship("NoteDocument", back_populates="highlights")
|
||||
notes = relationship("NoteNote", back_populates="highlight", cascade="all, delete-orphan")
|
||||
|
||||
# Pydantic 모델들
|
||||
class NoteHighlightBase(BaseModel):
|
||||
note_id: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
selected_text: str
|
||||
highlight_color: str = '#FFFF00'
|
||||
highlight_type: str = 'highlight'
|
||||
|
||||
class NoteHighlightCreate(NoteHighlightBase):
|
||||
pass
|
||||
|
||||
class NoteHighlightUpdate(BaseModel):
|
||||
highlight_color: Optional[str] = None
|
||||
highlight_type: Optional[str] = None
|
||||
|
||||
class NoteHighlightResponse(NoteHighlightBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj):
|
||||
return cls(
|
||||
id=str(obj.id),
|
||||
note_id=str(obj.note_id),
|
||||
start_offset=obj.start_offset,
|
||||
end_offset=obj.end_offset,
|
||||
selected_text=obj.selected_text,
|
||||
highlight_color=obj.highlight_color,
|
||||
highlight_type=obj.highlight_type,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
created_by=obj.created_by
|
||||
)
|
||||
58
backend/src/models/note_link.py
Normal file
58
backend/src/models/note_link.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
노트 문서 링크 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class NoteLink(Base):
|
||||
"""노트 문서 링크 테이블"""
|
||||
__tablename__ = "note_links"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# 링크가 생성된 노트 (출발점) - 노트 문서 또는 일반 문서 가능
|
||||
source_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
|
||||
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True)
|
||||
|
||||
# 링크 대상 노트 (도착점) - 노트 문서 또는 일반 문서 가능
|
||||
target_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
|
||||
target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True)
|
||||
|
||||
# 출발점 텍스트 정보
|
||||
selected_text = Column(Text, nullable=False) # 선택된 텍스트
|
||||
start_offset = Column(Integer, nullable=False) # 시작 위치
|
||||
end_offset = Column(Integer, nullable=False) # 끝 위치
|
||||
|
||||
# 도착점 텍스트 정보
|
||||
target_text = Column(Text, nullable=True) # 대상에서 선택된 텍스트
|
||||
target_start_offset = Column(Integer, nullable=True) # 대상에서 시작 위치
|
||||
target_end_offset = Column(Integer, nullable=True) # 대상에서 끝 위치
|
||||
|
||||
# 링크 메타데이터
|
||||
link_text = Column(String(500), nullable=True) # 사용자 정의 링크 텍스트
|
||||
description = Column(Text, nullable=True) # 링크 설명
|
||||
|
||||
# 링크 타입
|
||||
link_type = Column(String(20), default="note", nullable=False) # "note", "document", "text_fragment"
|
||||
|
||||
# 생성자 정보
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계 설정
|
||||
source_note = relationship("NoteDocument", foreign_keys=[source_note_id], backref="outgoing_note_links")
|
||||
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_note_links")
|
||||
target_note = relationship("NoteDocument", foreign_keys=[target_note_id], backref="incoming_note_links")
|
||||
target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_note_links")
|
||||
creator = relationship("User", backref="created_note_links")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"
|
||||
|
||||
59
backend/src/models/note_note.py
Normal file
59
backend/src/models/note_note.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from sqlalchemy import Column, String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
class NoteNote(Base):
|
||||
"""노트의 메모 모델 (노트 안의 하이라이트에 대한 메모)"""
|
||||
__tablename__ = "note_notes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False)
|
||||
highlight_id = Column(UUID(as_uuid=True), ForeignKey("note_highlights.id", ondelete="CASCADE"), nullable=True)
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
created_by = Column(String(100), nullable=False)
|
||||
|
||||
# 관계
|
||||
note = relationship("NoteDocument", back_populates="notes")
|
||||
highlight = relationship("NoteHighlight", back_populates="notes")
|
||||
|
||||
# Pydantic 모델들
|
||||
class NoteNoteBase(BaseModel):
|
||||
note_id: str
|
||||
highlight_id: Optional[str] = None
|
||||
content: str
|
||||
|
||||
class NoteNoteCreate(NoteNoteBase):
|
||||
pass
|
||||
|
||||
class NoteNoteUpdate(BaseModel):
|
||||
content: Optional[str] = None
|
||||
|
||||
class NoteNoteResponse(NoteNoteBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj):
|
||||
return cls(
|
||||
id=str(obj.id),
|
||||
note_id=str(obj.note_id),
|
||||
highlight_id=str(obj.highlight_id) if obj.highlight_id else None,
|
||||
content=obj.content,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
created_by=obj.created_by
|
||||
)
|
||||
126
backend/src/models/notebook.py
Normal file
126
backend/src/models/notebook.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
노트북 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class Notebook(Base):
|
||||
"""노트북 테이블"""
|
||||
__tablename__ = "notebooks"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
color = Column(String(7), default='#3B82F6') # 헥스 컬러 코드
|
||||
icon = Column(String(50), default='book') # FontAwesome 아이콘
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
created_by = Column(String(100), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
# 관계 설정 (노트들)
|
||||
notes = relationship("NoteDocument", back_populates="notebook")
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class NotebookBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
description: Optional[str] = None
|
||||
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
icon: str = Field(default='book', min_length=1, max_length=50)
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class NotebookCreate(NotebookBase):
|
||||
pass
|
||||
|
||||
|
||||
class NotebookUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
icon: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
is_active: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class NotebookResponse(NotebookBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
note_count: int = 0 # 포함된 노트 개수
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj, note_count=0):
|
||||
"""ORM 객체에서 Pydantic 모델로 변환"""
|
||||
data = {
|
||||
'id': str(obj.id),
|
||||
'title': obj.title,
|
||||
'description': obj.description,
|
||||
'color': obj.color,
|
||||
'icon': obj.icon,
|
||||
'created_at': obj.created_at,
|
||||
'updated_at': obj.updated_at,
|
||||
'created_by': obj.created_by,
|
||||
'is_active': obj.is_active,
|
||||
'sort_order': obj.sort_order,
|
||||
'note_count': note_count,
|
||||
}
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class NotebookListItem(BaseModel):
|
||||
"""노트북 목록용 간소화된 모델"""
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
color: str
|
||||
icon: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
is_active: bool
|
||||
note_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj, note_count=0):
|
||||
"""ORM 객체에서 Pydantic 모델로 변환"""
|
||||
data = {
|
||||
'id': str(obj.id),
|
||||
'title': obj.title,
|
||||
'description': obj.description,
|
||||
'color': obj.color,
|
||||
'icon': obj.icon,
|
||||
'created_at': obj.created_at,
|
||||
'updated_at': obj.updated_at,
|
||||
'created_by': obj.created_by,
|
||||
'is_active': obj.is_active,
|
||||
'note_count': note_count,
|
||||
}
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class NotebookStats(BaseModel):
|
||||
"""노트북 통계 정보"""
|
||||
total_notebooks: int
|
||||
active_notebooks: int
|
||||
total_notes: int
|
||||
notes_without_notebook: int
|
||||
63
backend/src/models/todo.py
Normal file
63
backend/src/models/todo.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
할일관리 시스템 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class TodoItem(Base):
|
||||
"""할일 아이템"""
|
||||
__tablename__ = "todo_items"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# 기본 정보
|
||||
content = Column(Text, nullable=False) # 할일 내용
|
||||
status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed
|
||||
|
||||
# 시간 관리
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정일
|
||||
estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
delayed_until = Column(DateTime(timezone=True), nullable=True) # 지연된 경우 새로운 시작일
|
||||
|
||||
# 분할 관리
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할일의 부모
|
||||
split_order = Column(Integer, nullable=True) # 분할 순서
|
||||
|
||||
# 관계
|
||||
user = relationship("User", back_populates="todo_items")
|
||||
comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan")
|
||||
|
||||
# 자기 참조 관계 (분할된 할일들)
|
||||
subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TodoItem(id={self.id}, content='{self.content[:50]}...', status='{self.status}')>"
|
||||
|
||||
|
||||
class TodoComment(Base):
|
||||
"""할일 댓글/메모"""
|
||||
__tablename__ = "todo_comments"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계
|
||||
todo_item = relationship("TodoItem", back_populates="comments")
|
||||
user = relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"
|
||||
51
backend/src/models/user.py
Normal file
51
backend/src/models/user.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
사용자 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..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)
|
||||
|
||||
# 권한 시스템 (서적관리, 노트관리, 소설관리)
|
||||
can_manage_books = Column(Boolean, default=True) # 서적 관리 권한
|
||||
can_manage_notes = Column(Boolean, default=True) # 노트 관리 권한
|
||||
can_manage_novels = Column(Boolean, default=True) # 소설 관리 권한
|
||||
|
||||
# 사용자 역할 (root, admin, user)
|
||||
role = Column(String(20), default="user") # root, admin, user
|
||||
|
||||
# 세션 타임아웃 설정 (분 단위, 0 = 무제한)
|
||||
session_timeout_minutes = Column(Integer, default=5) # 기본 5분
|
||||
|
||||
# 메타데이터
|
||||
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")
|
||||
|
||||
# 관계 (lazy loading을 위해 문자열로 참조)
|
||||
memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic")
|
||||
memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic")
|
||||
todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(email='{self.email}', full_name='{self.full_name}')>"
|
||||
63
backend/src/schemas/auth.py
Normal file
63
backend/src/schemas/auth.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
인증 관련 스키마
|
||||
"""
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
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: UUID
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
role: str
|
||||
can_manage_books: bool
|
||||
can_manage_notes: bool
|
||||
can_manage_novels: bool
|
||||
session_timeout_minutes: int
|
||||
theme: str
|
||||
language: str
|
||||
timezone: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
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
|
||||
32
backend/src/schemas/book.py
Normal file
32
backend/src/schemas/book.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
class BookBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
author: Optional[str] = Field(None, max_length=255)
|
||||
description: Optional[str] = None
|
||||
language: str = Field("ko", max_length=10)
|
||||
is_public: bool = False
|
||||
|
||||
class CreateBookRequest(BookBase):
|
||||
pass
|
||||
|
||||
class UpdateBookRequest(BookBase):
|
||||
pass
|
||||
|
||||
class BookResponse(BookBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
document_count: int = 0 # 문서 개수 추가
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class BookSearchResponse(BookResponse):
|
||||
pass
|
||||
|
||||
class BookSuggestionResponse(BookResponse):
|
||||
similarity_score: float = Field(..., ge=0.0, le=1.0)
|
||||
31
backend/src/schemas/book_category.py
Normal file
31
backend/src/schemas/book_category.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
class BookCategoryBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
sort_order: int = Field(0, ge=0)
|
||||
|
||||
class CreateBookCategoryRequest(BookCategoryBase):
|
||||
book_id: UUID
|
||||
|
||||
class UpdateBookCategoryRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
sort_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
class BookCategoryResponse(BookCategoryBase):
|
||||
id: UUID
|
||||
book_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
document_count: int = 0 # 포함된 문서 수
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UpdateDocumentOrderRequest(BaseModel):
|
||||
document_orders: List[dict] = Field(..., description="문서 ID와 순서 정보")
|
||||
# 예: [{"document_id": "uuid", "sort_order": 1}, ...]
|
||||
205
backend/src/schemas/memo_tree.py
Normal file
205
backend/src/schemas/memo_tree.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
트리 구조 메모장 Pydantic 스키마
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# 기본 스키마들
|
||||
class MemoTreeBase(BaseModel):
|
||||
"""메모 트리 기본 스키마"""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
tree_type: str = Field(default="general", pattern="^(general|novel|research|project)$")
|
||||
template_data: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class MemoTreeCreate(MemoTreeBase):
|
||||
"""메모 트리 생성 요청"""
|
||||
pass
|
||||
|
||||
|
||||
class MemoTreeUpdate(BaseModel):
|
||||
"""메모 트리 업데이트 요청"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
tree_type: Optional[str] = Field(None, pattern="^(general|novel|research|project)$")
|
||||
template_data: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
|
||||
class MemoTreeResponse(MemoTreeBase):
|
||||
"""메모 트리 응답"""
|
||||
id: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
is_archived: bool
|
||||
node_count: Optional[int] = 0 # 노드 개수
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 메모 노드 스키마들
|
||||
class MemoNodeBase(BaseModel):
|
||||
"""메모 노드 기본 스키마"""
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
node_type: str = Field(default="memo", pattern="^(folder|memo|chapter|character|plot)$")
|
||||
tags: Optional[List[str]] = None
|
||||
node_metadata: Optional[Dict[str, Any]] = None
|
||||
status: str = Field(default="draft", pattern="^(draft|writing|review|complete)$")
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: Optional[bool] = False
|
||||
canonical_order: Optional[int] = None
|
||||
|
||||
|
||||
class MemoNodeCreate(MemoNodeBase):
|
||||
"""메모 노드 생성 요청"""
|
||||
tree_id: str
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class MemoNodeUpdate(BaseModel):
|
||||
"""메모 노드 업데이트 요청"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
node_type: Optional[str] = Field(None, pattern="^(folder|memo|chapter|character|plot)$")
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
tags: Optional[List[str]] = None
|
||||
node_metadata: Optional[Dict[str, Any]] = None
|
||||
status: Optional[str] = Field(None, pattern="^(draft|writing|review|complete)$")
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: Optional[bool] = None
|
||||
canonical_order: Optional[int] = None
|
||||
|
||||
|
||||
class MemoNodeMove(BaseModel):
|
||||
"""메모 노드 이동 요청"""
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class MemoNodeResponse(MemoNodeBase):
|
||||
"""메모 노드 응답"""
|
||||
id: str
|
||||
tree_id: str
|
||||
parent_id: Optional[str]
|
||||
user_id: str
|
||||
sort_order: int
|
||||
depth_level: int
|
||||
path: Optional[str]
|
||||
word_count: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: bool
|
||||
canonical_order: Optional[int]
|
||||
story_path: Optional[str]
|
||||
|
||||
# 관계 데이터
|
||||
children_count: Optional[int] = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 트리 구조 응답
|
||||
class MemoTreeWithNodes(MemoTreeResponse):
|
||||
"""노드가 포함된 메모 트리 응답"""
|
||||
nodes: List[MemoNodeResponse] = []
|
||||
|
||||
|
||||
# 노드 버전 스키마들
|
||||
class MemoNodeVersionResponse(BaseModel):
|
||||
"""메모 노드 버전 응답"""
|
||||
id: str
|
||||
node_id: str
|
||||
version_number: int
|
||||
title: str
|
||||
content: Optional[str]
|
||||
node_metadata: Optional[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 공유 스키마들
|
||||
class MemoTreeShareCreate(BaseModel):
|
||||
"""메모 트리 공유 생성 요청"""
|
||||
shared_with_user_email: str
|
||||
permission_level: str = Field(default="read", pattern="^(read|write|admin)$")
|
||||
|
||||
|
||||
class MemoTreeShareResponse(BaseModel):
|
||||
"""메모 트리 공유 응답"""
|
||||
id: str
|
||||
tree_id: str
|
||||
shared_with_user_id: str
|
||||
shared_with_user_email: str
|
||||
shared_with_user_name: str
|
||||
permission_level: str
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 검색 및 필터링
|
||||
class MemoSearchRequest(BaseModel):
|
||||
"""메모 검색 요청"""
|
||||
query: str = Field(..., min_length=1)
|
||||
tree_id: Optional[str] = None
|
||||
node_types: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
status: Optional[List[str]] = None
|
||||
|
||||
|
||||
class MemoSearchResult(BaseModel):
|
||||
"""메모 검색 결과"""
|
||||
node: MemoNodeResponse
|
||||
tree: MemoTreeResponse
|
||||
matches: List[Dict[str, Any]] # 매치된 부분들
|
||||
relevance_score: float
|
||||
|
||||
|
||||
# 통계 스키마
|
||||
class MemoTreeStats(BaseModel):
|
||||
"""메모 트리 통계"""
|
||||
total_nodes: int
|
||||
nodes_by_type: Dict[str, int]
|
||||
nodes_by_status: Dict[str, int]
|
||||
total_words: int
|
||||
last_updated: Optional[datetime]
|
||||
|
||||
|
||||
# 내보내기 스키마
|
||||
class ExportRequest(BaseModel):
|
||||
"""내보내기 요청"""
|
||||
tree_id: str
|
||||
format: str = Field(..., pattern="^(markdown|html|pdf|docx)$")
|
||||
include_metadata: bool = True
|
||||
node_types: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ExportResponse(BaseModel):
|
||||
"""내보내기 응답"""
|
||||
file_url: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
created_at: datetime
|
||||
108
backend/src/schemas/todo.py
Normal file
108
backend/src/schemas/todo.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
할일관리 시스템 스키마
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class TodoCommentBase(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class TodoCommentCreate(TodoCommentBase):
|
||||
pass
|
||||
|
||||
|
||||
class TodoCommentUpdate(BaseModel):
|
||||
content: Optional[str] = Field(None, min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class TodoCommentResponse(TodoCommentBase):
|
||||
id: UUID
|
||||
todo_item_id: UUID
|
||||
user_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TodoItemBase(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class TodoItemCreate(TodoItemBase):
|
||||
"""초기 할일 생성 (draft 상태)"""
|
||||
pass
|
||||
|
||||
|
||||
class TodoItemSchedule(BaseModel):
|
||||
"""할일 일정 설정"""
|
||||
start_date: datetime
|
||||
estimated_minutes: int = Field(..., ge=1, le=120) # 1분~2시간
|
||||
|
||||
|
||||
class TodoItemUpdate(BaseModel):
|
||||
"""할일 수정"""
|
||||
content: Optional[str] = Field(None, min_length=1, max_length=2000)
|
||||
status: Optional[str] = Field(None, pattern="^(draft|scheduled|active|completed|delayed)$")
|
||||
start_date: Optional[datetime] = None
|
||||
estimated_minutes: Optional[int] = Field(None, ge=1, le=120)
|
||||
delayed_until: Optional[datetime] = None
|
||||
|
||||
|
||||
class TodoItemDelay(BaseModel):
|
||||
"""할일 지연"""
|
||||
delayed_until: datetime
|
||||
|
||||
|
||||
class TodoItemSplit(BaseModel):
|
||||
"""할일 분할"""
|
||||
subtasks: List[str] = Field(..., min_items=2, max_items=10)
|
||||
estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10)
|
||||
|
||||
|
||||
class TodoItemResponse(TodoItemBase):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
status: str
|
||||
created_at: datetime
|
||||
start_date: Optional[datetime]
|
||||
estimated_minutes: Optional[int]
|
||||
completed_at: Optional[datetime]
|
||||
delayed_until: Optional[datetime]
|
||||
parent_id: Optional[UUID]
|
||||
split_order: Optional[int]
|
||||
|
||||
# 댓글 수
|
||||
comment_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TodoItemWithComments(TodoItemResponse):
|
||||
"""댓글이 포함된 할일 응답"""
|
||||
comments: List[TodoCommentResponse] = []
|
||||
|
||||
|
||||
class TodoStats(BaseModel):
|
||||
"""할일 통계"""
|
||||
total_count: int
|
||||
draft_count: int
|
||||
scheduled_count: int
|
||||
active_count: int
|
||||
completed_count: int
|
||||
delayed_count: int
|
||||
completion_rate: float # 완료율 (%)
|
||||
|
||||
|
||||
class TodoDashboard(BaseModel):
|
||||
"""할일 대시보드"""
|
||||
stats: TodoStats
|
||||
today_todos: List[TodoItemResponse]
|
||||
overdue_todos: List[TodoItemResponse]
|
||||
upcoming_todos: List[TodoItemResponse]
|
||||
92
config/postgresql.synology.conf
Normal file
92
config/postgresql.synology.conf
Normal file
@@ -0,0 +1,92 @@
|
||||
# PostgreSQL 설정 - Synology DS1525+ 최적화 (32GB RAM)
|
||||
# /volume1/docker/document-server/config/postgresql.conf
|
||||
|
||||
# 메모리 설정 (32GB RAM 환경)
|
||||
shared_buffers = 8GB # RAM의 25% (8GB)
|
||||
effective_cache_size = 24GB # RAM의 75% (24GB)
|
||||
work_mem = 256MB # 복잡한 쿼리용 (정렬, 해시 조인)
|
||||
maintenance_work_mem = 2GB # 인덱스 구축, VACUUM용
|
||||
|
||||
# 체크포인트 설정 (SSD 최적화)
|
||||
checkpoint_completion_target = 0.9 # 체크포인트 분산 (SSD 수명 연장)
|
||||
checkpoint_timeout = 15min # 체크포인트 간격
|
||||
max_wal_size = 4GB # WAL 파일 최대 크기
|
||||
min_wal_size = 1GB # WAL 파일 최소 크기
|
||||
|
||||
# WAL 설정
|
||||
wal_buffers = 64MB # WAL 버퍼 크기
|
||||
wal_writer_delay = 200ms # WAL 쓰기 지연
|
||||
commit_delay = 0 # 커밋 지연 (SSD에서는 0)
|
||||
|
||||
# 비용 기반 최적화 (SSD 환경)
|
||||
random_page_cost = 1.1 # SSD는 랜덤 액세스가 빠름
|
||||
seq_page_cost = 1.0 # 순차 액세스 기준값
|
||||
cpu_tuple_cost = 0.01 # CPU 튜플 처리 비용
|
||||
cpu_index_tuple_cost = 0.005 # 인덱스 튜플 처리 비용
|
||||
cpu_operator_cost = 0.0025 # 연산자 처리 비용
|
||||
|
||||
# 연결 설정
|
||||
max_connections = 200 # 최대 연결 수
|
||||
superuser_reserved_connections = 3 # 슈퍼유저 예약 연결
|
||||
|
||||
# 쿼리 플래너 설정
|
||||
default_statistics_target = 100 # 통계 정확도
|
||||
constraint_exclusion = partition # 파티션 제약 조건 최적화
|
||||
enable_partitionwise_join = on # 파티션별 조인 최적화
|
||||
enable_partitionwise_aggregate = on # 파티션별 집계 최적화
|
||||
|
||||
# 백그라운드 작업자 설정
|
||||
max_worker_processes = 8 # 최대 워커 프로세스 (CPU 코어 수)
|
||||
max_parallel_workers_per_gather = 4 # 병렬 쿼리 워커
|
||||
max_parallel_workers = 8 # 전체 병렬 워커
|
||||
max_parallel_maintenance_workers = 4 # 병렬 유지보수 워커
|
||||
|
||||
# 자동 VACUUM 설정
|
||||
autovacuum = on # 자동 VACUUM 활성화
|
||||
autovacuum_max_workers = 3 # VACUUM 워커 수
|
||||
autovacuum_naptime = 1min # VACUUM 실행 간격
|
||||
autovacuum_vacuum_threshold = 50 # VACUUM 임계값
|
||||
autovacuum_analyze_threshold = 50 # ANALYZE 임계값
|
||||
autovacuum_vacuum_scale_factor = 0.2 # VACUUM 스케일 팩터
|
||||
autovacuum_analyze_scale_factor = 0.1 # ANALYZE 스케일 팩터
|
||||
|
||||
# 로깅 설정
|
||||
log_destination = 'stderr' # 로그 출력 대상
|
||||
logging_collector = off # Docker 환경에서는 off
|
||||
log_min_messages = warning # 최소 로그 레벨
|
||||
log_min_error_statement = error # 에러 문장 로그
|
||||
log_min_duration_statement = 1000 # 1초 이상 쿼리 로깅
|
||||
log_checkpoints = on # 체크포인트 로깅
|
||||
log_connections = off # 연결 로깅 (성능상 off)
|
||||
log_disconnections = off # 연결 해제 로깅 (성능상 off)
|
||||
log_lock_waits = on # 락 대기 로깅
|
||||
log_temp_files = 10MB # 임시 파일 로깅 (10MB 이상)
|
||||
|
||||
# 전문 검색 설정
|
||||
default_text_search_config = 'pg_catalog.english'
|
||||
|
||||
# 시간대 설정
|
||||
timezone = 'Asia/Seoul'
|
||||
log_timezone = 'Asia/Seoul'
|
||||
|
||||
# 문자 인코딩
|
||||
lc_messages = 'C'
|
||||
lc_monetary = 'C'
|
||||
lc_numeric = 'C'
|
||||
lc_time = 'C'
|
||||
|
||||
# 기타 성능 설정
|
||||
effective_io_concurrency = 200 # SSD 동시 I/O (SSD는 높게)
|
||||
maintenance_io_concurrency = 10 # 유지보수 I/O 동시성
|
||||
wal_compression = on # WAL 압축 (디스크 절약)
|
||||
full_page_writes = on # 전체 페이지 쓰기 (안정성)
|
||||
|
||||
# JIT 컴파일 설정 (PostgreSQL 11+)
|
||||
jit = on # JIT 컴파일 활성화
|
||||
jit_above_cost = 100000 # JIT 활성화 비용 임계값
|
||||
jit_inline_above_cost = 500000 # 인라인 JIT 비용 임계값
|
||||
jit_optimize_above_cost = 500000 # 최적화 JIT 비용 임계값
|
||||
|
||||
# 확장 모듈 설정
|
||||
shared_preload_libraries = 'pg_stat_statements' # 쿼리 통계 모듈
|
||||
|
||||
153
database/init/005_create_memo_tree_tables.sql
Normal file
153
database/init/005_create_memo_tree_tables.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- 트리 구조 메모장 테이블 생성
|
||||
-- 005_create_memo_tree_tables.sql
|
||||
|
||||
-- 메모 트리 (프로젝트/워크스페이스)
|
||||
CREATE TABLE memo_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general'
|
||||
template_data JSONB, -- 템플릿별 메타데이터
|
||||
settings JSONB DEFAULT '{}', -- 트리별 설정
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 메모 노드 (트리의 각 노드)
|
||||
CREATE TABLE memo_nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 기본 정보
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT, -- 실제 메모 내용 (Markdown)
|
||||
node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
-- 트리 구조 관리
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
depth_level INTEGER DEFAULT 0,
|
||||
path TEXT, -- 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 메타데이터
|
||||
tags TEXT[], -- 태그 배열
|
||||
node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등)
|
||||
|
||||
-- 상태 관리
|
||||
status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete'
|
||||
word_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 시간 정보
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 제약 조건
|
||||
CONSTRAINT no_self_reference CHECK (id != parent_id)
|
||||
);
|
||||
|
||||
-- 메모 노드 버전 관리 (선택적)
|
||||
CREATE TABLE memo_node_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT,
|
||||
node_metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(node_id, version_number)
|
||||
);
|
||||
|
||||
-- 메모 트리 공유 (협업 기능)
|
||||
CREATE TABLE memo_tree_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin'
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(tree_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id);
|
||||
CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type);
|
||||
CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id);
|
||||
CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id);
|
||||
CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id);
|
||||
CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/'));
|
||||
CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags);
|
||||
CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type);
|
||||
CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id);
|
||||
CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id);
|
||||
|
||||
-- 트리거 함수: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
CREATE TRIGGER memo_trees_updated_at
|
||||
BEFORE UPDATE ON memo_trees
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
CREATE TRIGGER memo_nodes_updated_at
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
-- 트리거 함수: 경로 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_node_path()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 루트 노드인 경우
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
NEW.path = '/' || NEW.id::text;
|
||||
NEW.depth_level = 0;
|
||||
ELSE
|
||||
-- 부모 노드의 경로를 가져와서 확장
|
||||
SELECT path || '/' || NEW.id::text, depth_level + 1
|
||||
INTO NEW.path, NEW.depth_level
|
||||
FROM memo_nodes
|
||||
WHERE id = NEW.parent_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 경로 업데이트 트리거
|
||||
CREATE TRIGGER memo_nodes_path_update
|
||||
BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_node_path();
|
||||
|
||||
-- 샘플 데이터 (개발용)
|
||||
-- 소설 템플릿 예시
|
||||
INSERT INTO memo_trees (user_id, title, description, tree_type, template_data)
|
||||
SELECT
|
||||
u.id,
|
||||
'내 첫 번째 소설',
|
||||
'판타지 소설 프로젝트',
|
||||
'novel',
|
||||
'{
|
||||
"genre": "fantasy",
|
||||
"target_length": 100000,
|
||||
"chapters_planned": 20,
|
||||
"main_characters": [],
|
||||
"world_building": {}
|
||||
}'::jsonb
|
||||
FROM users u
|
||||
WHERE u.email = 'admin@test.com'
|
||||
LIMIT 1;
|
||||
11
database/init/01_init.sql
Normal file
11
database/init/01_init.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 데이터베이스 초기화 스크립트
|
||||
-- FastAPI가 자동으로 테이블을 생성하므로 여기서는 기본 설정만
|
||||
|
||||
-- 확장 기능 활성화
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- 전문 검색을 위한 설정
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- 데이터베이스 설정
|
||||
ALTER DATABASE document_db SET timezone TO 'Asia/Seoul';
|
||||
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
|
||||
178
docker-compose.synology.yml
Normal file
178
docker-compose.synology.yml
Normal file
@@ -0,0 +1,178 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL 데이터베이스 (SSD 최적화 - 32GB RAM 활용)
|
||||
database:
|
||||
image: postgres:15-alpine
|
||||
container_name: document-server-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: document_db
|
||||
POSTGRES_USER: docuser
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-docpass}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
# SSD: 데이터베이스 (성능 최우선)
|
||||
- /volume3/docker/document-server/database:/var/lib/postgresql/data
|
||||
- /volume3/docker/document-server/config/postgresql.synology.conf:/etc/postgresql/postgresql.conf:ro
|
||||
- ./database/init:/docker-entrypoint-initdb.d:ro
|
||||
ports:
|
||||
- "24101:5432"
|
||||
command: >
|
||||
postgres
|
||||
-c config_file=/etc/postgresql/postgresql.conf
|
||||
-c shared_buffers=8GB
|
||||
-c effective_cache_size=24GB
|
||||
-c work_mem=512MB
|
||||
-c maintenance_work_mem=4GB
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_buffers=128MB
|
||||
-c random_page_cost=1.1
|
||||
-c effective_io_concurrency=200
|
||||
-c max_worker_processes=8
|
||||
-c max_parallel_workers_per_gather=4
|
||||
-c max_parallel_workers=8
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U docuser -d document_db"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- document-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 10G
|
||||
reservations:
|
||||
memory: 2G
|
||||
|
||||
# Redis 캐시 (SSD 최적화 - 대용량 메모리 활용)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: document-server-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# SSD: Redis 데이터 (빠른 캐시)
|
||||
- /volume3/docker/document-server/redis:/data
|
||||
ports:
|
||||
- "24103:6379"
|
||||
command: >
|
||||
redis-server
|
||||
--maxmemory 8gb
|
||||
--maxmemory-policy allkeys-lru
|
||||
--save 900 1
|
||||
--save 300 10
|
||||
--save 60 10000
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--auto-aof-rewrite-percentage 100
|
||||
--auto-aof-rewrite-min-size 64mb
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- document-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 10G
|
||||
reservations:
|
||||
memory: 1G
|
||||
|
||||
# FastAPI 백엔드 (SSD에서 실행, HDD 스토리지 연결)
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: document-server-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://docuser:${DB_PASSWORD:-docpass}@database:5432/document_db
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- SECRET_KEY=${SECRET_KEY:-production-secret-key-change-this}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@test.com}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- DEBUG=false
|
||||
- ALLOWED_ORIGINS=http://localhost:24100,https://${DOMAIN_NAME:-localhost}
|
||||
- UPLOAD_DIR=/app/uploads
|
||||
- MAX_FILE_SIZE=500000000
|
||||
volumes:
|
||||
# SSD: 애플리케이션 로그 및 설정 (빠른 액세스)
|
||||
- /volume3/docker/document-server/logs:/app/logs
|
||||
- /volume3/docker/document-server/config:/app/config
|
||||
- /volume3/docker/document-server/cache:/app/cache
|
||||
|
||||
# HDD: 대용량 파일 저장소 (비용 효율적)
|
||||
- /volume1/document-storage/uploads:/app/uploads
|
||||
- /volume1/document-storage/documents:/app/documents
|
||||
- /volume1/document-storage/thumbnails:/app/thumbnails
|
||||
- /volume1/document-storage/backups:/app/backups
|
||||
ports:
|
||||
- "24102:8000"
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- document-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
# Nginx 웹서버 (SSD 캐시, HDD 스토리지)
|
||||
nginx:
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: document-server-nginx
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# SSD: Nginx 설정, 로그, 캐시 (성능 최적화)
|
||||
- /volume3/docker/document-server/nginx/conf.d:/etc/nginx/conf.d
|
||||
- /volume3/docker/document-server/nginx/cache:/var/cache/nginx
|
||||
- /volume3/docker/document-server/logs/nginx:/var/log/nginx
|
||||
|
||||
# SSD: 프론트엔드 정적 파일 (빠른 서빙)
|
||||
- ./frontend:/usr/share/nginx/html:ro
|
||||
|
||||
# HDD: 대용량 문서 파일 (읽기 전용)
|
||||
- /volume1/document-storage/uploads:/usr/share/nginx/html/uploads:ro
|
||||
- /volume1/document-storage/documents:/usr/share/nginx/html/documents:ro
|
||||
ports:
|
||||
- "24100:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- document-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
networks:
|
||||
document-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
# 볼륨 정의는 제거 (직접 경로 매핑 사용)
|
||||
76
docker-compose.yml
Normal file
76
docker-compose.yml
Normal file
@@ -0,0 +1,76 @@
|
||||
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+asyncpg://docuser:docpass@database:5432/document_db
|
||||
- SECRET_KEY=${SECRET_KEY:-production-secret-key-change-this}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@test.com}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- DEBUG=false
|
||||
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
|
||||
317
frontend/backup-restore.html
Normal file
317
frontend/backup-restore.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<!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="static/js/auth-guard.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8" x-data="backupRestoreApp()">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">백업/복원 관리</h1>
|
||||
<p class="text-gray-600">시스템 데이터를 백업하고 복원할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 백업 섹션 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-download text-blue-500 mr-3"></i>
|
||||
데이터 백업
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 전체 백업 -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">전체 백업</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">데이터베이스와 업로드된 파일을 모두 백업합니다.</p>
|
||||
<button @click="createBackup('full')"
|
||||
:disabled="loading"
|
||||
class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-database mr-2"></i>
|
||||
<span x-show="!loading">전체 백업 생성</span>
|
||||
<span x-show="loading">백업 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 데이터베이스만 백업 -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">데이터베이스 백업</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">데이터베이스만 백업합니다 (파일 제외).</p>
|
||||
<button @click="createBackup('db')"
|
||||
:disabled="loading"
|
||||
class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-table mr-2"></i>
|
||||
<span x-show="!loading">DB 백업 생성</span>
|
||||
<span x-show="loading">백업 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 백업 상태 -->
|
||||
<div x-show="backupStatus" class="mt-4 p-4 rounded-lg" :class="backupStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
|
||||
<div class="flex items-center">
|
||||
<i :class="backupStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
|
||||
<span x-text="backupStatus.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 백업 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-history text-green-500 mr-3"></i>
|
||||
백업 목록
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div x-show="backups.length === 0" class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-inbox text-4xl mb-4"></i>
|
||||
<p>백업 파일이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="backups.length > 0" class="space-y-4">
|
||||
<template x-for="backup in backups" :key="backup.id">
|
||||
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-file-archive text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900" x-text="backup.name"></h4>
|
||||
<p class="text-sm text-gray-500">
|
||||
<span x-text="backup.type === 'full' ? '전체 백업' : 'DB 백업'"></span>
|
||||
• <span x-text="backup.size"></span>
|
||||
• <span x-text="backup.date"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="downloadBackup(backup)"
|
||||
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<i class="fas fa-download mr-1"></i>
|
||||
다운로드
|
||||
</button>
|
||||
<button @click="deleteBackup(backup)"
|
||||
class="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복원 섹션 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-upload text-orange-500 mr-3"></i>
|
||||
데이터 복원
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mr-2"></i>
|
||||
<span class="text-yellow-800 font-medium">주의사항</span>
|
||||
</div>
|
||||
<p class="text-yellow-700 text-sm mt-2">
|
||||
복원 작업은 현재 데이터를 완전히 덮어씁니다. 복원 전에 반드시 현재 데이터를 백업하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">백업 파일 선택</label>
|
||||
<input type="file"
|
||||
@change="handleFileSelect"
|
||||
accept=".sql,.tar.gz,.zip"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
</div>
|
||||
|
||||
<div x-show="selectedFile">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-gray-900 mb-2">선택된 파일</h4>
|
||||
<p class="text-sm text-gray-600" x-text="selectedFile?.name"></p>
|
||||
<p class="text-sm text-gray-500" x-text="selectedFile ? formatFileSize(selectedFile.size) : ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="restoreBackup()"
|
||||
:disabled="!selectedFile || loading"
|
||||
class="w-full bg-orange-600 text-white px-4 py-3 rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
<span x-show="!loading">복원 실행</span>
|
||||
<span x-show="loading">복원 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 복원 상태 -->
|
||||
<div x-show="restoreStatus" class="mt-4 p-4 rounded-lg" :class="restoreStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
|
||||
<div class="flex items-center">
|
||||
<i :class="restoreStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
|
||||
<span x-text="restoreStatus.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
<script>
|
||||
function backupRestoreApp() {
|
||||
return {
|
||||
loading: false,
|
||||
backups: [],
|
||||
selectedFile: null,
|
||||
backupStatus: null,
|
||||
restoreStatus: null,
|
||||
|
||||
init() {
|
||||
this.loadBackups();
|
||||
},
|
||||
|
||||
async loadBackups() {
|
||||
try {
|
||||
// 실제 구현에서는 백엔드 API 호출
|
||||
// this.backups = await window.api.getBackups();
|
||||
|
||||
// 임시 데모 데이터
|
||||
this.backups = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'backup_2025_09_03_full.tar.gz',
|
||||
type: 'full',
|
||||
size: '125.4 MB',
|
||||
date: '2025년 9월 3일 15:30'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'backup_2025_09_02_db.sql',
|
||||
type: 'db',
|
||||
size: '2.1 MB',
|
||||
date: '2025년 9월 2일 10:15'
|
||||
}
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('백업 목록 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createBackup(type) {
|
||||
this.loading = true;
|
||||
this.backupStatus = null;
|
||||
|
||||
try {
|
||||
// 실제 구현에서는 백엔드 API 호출
|
||||
// const result = await window.api.createBackup(type);
|
||||
|
||||
// 임시 시뮬레이션
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
this.backupStatus = {
|
||||
type: 'success',
|
||||
message: `${type === 'full' ? '전체' : 'DB'} 백업이 성공적으로 생성되었습니다.`
|
||||
};
|
||||
|
||||
this.loadBackups();
|
||||
} catch (error) {
|
||||
this.backupStatus = {
|
||||
type: 'error',
|
||||
message: '백업 생성 중 오류가 발생했습니다: ' + error.message
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
downloadBackup(backup) {
|
||||
// 실제 구현에서는 백엔드에서 파일 다운로드
|
||||
alert(`${backup.name} 다운로드를 시작합니다.`);
|
||||
},
|
||||
|
||||
async deleteBackup(backup) {
|
||||
if (!confirm(`${backup.name}을(를) 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 실제 구현에서는 백엔드 API 호출
|
||||
// await window.api.deleteBackup(backup.id);
|
||||
|
||||
this.backups = this.backups.filter(b => b.id !== backup.id);
|
||||
alert('백업이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
alert('백업 삭제 중 오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
this.selectedFile = event.target.files[0];
|
||||
this.restoreStatus = null;
|
||||
},
|
||||
|
||||
async restoreBackup() {
|
||||
if (!this.selectedFile) return;
|
||||
|
||||
if (!confirm('현재 데이터가 모두 삭제되고 백업 데이터로 복원됩니다. 계속하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.restoreStatus = null;
|
||||
|
||||
try {
|
||||
// 실제 구현에서는 백엔드 API 호출
|
||||
// const formData = new FormData();
|
||||
// formData.append('backup_file', this.selectedFile);
|
||||
// await window.api.restoreBackup(formData);
|
||||
|
||||
// 임시 시뮬레이션
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
this.restoreStatus = {
|
||||
type: 'success',
|
||||
message: '백업이 성공적으로 복원되었습니다. 페이지를 새로고침해주세요.'
|
||||
};
|
||||
} catch (error) {
|
||||
this.restoreStatus = {
|
||||
type: 'error',
|
||||
message: '복원 중 오류가 발생했습니다: ' + error.message
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
160
frontend/book-documents.html
Normal file
160
frontend/book-documents.html
Normal file
@@ -0,0 +1,160 @@
|
||||
<!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.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
.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>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="bookDocumentsApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 뒤로가기 및 서적 정보 -->
|
||||
<div class="mb-6">
|
||||
<!-- 네비게이션 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="goBack()"
|
||||
class="flex items-center px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
<span>뒤로</span>
|
||||
</button>
|
||||
|
||||
<button @click="openBookEditor()"
|
||||
class="flex items-center px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm">
|
||||
<i class="fas fa-edit mr-2"></i>편집
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 서적 정보 (간결한 스타일) -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-book text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900" x-text="bookInfo.title || '서적 미분류'"></h1>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span x-show="bookInfo.author" x-text="bookInfo.author"></span>
|
||||
<span x-show="bookInfo.author" class="text-gray-300">•</span>
|
||||
<span x-text="documents.length + '개 문서'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설명 (있을 때만 표시) -->
|
||||
<div x-show="bookInfo.description" class="mt-3 pt-3 border-t border-gray-100">
|
||||
<p class="text-sm text-gray-600" x-text="bookInfo.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 -->
|
||||
<div class="bg-white rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<h2 class="font-medium text-gray-900">문서 목록</h2>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="p-8 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-500">문서를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 (데본씽크 스타일) -->
|
||||
<div x-show="!loading && documents.length > 0" class="divide-y divide-gray-100">
|
||||
<template x-for="doc in documents" :key="doc.id">
|
||||
<div class="p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
|
||||
@click="openDocument(doc.id)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<!-- 문서 타입 아이콘 -->
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
:class="doc.pdf_path ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'">
|
||||
<i :class="doc.pdf_path ? 'fas fa-file-pdf' : 'fas fa-file-alt'" class="text-sm"></i>
|
||||
</div>
|
||||
|
||||
<!-- 문서 정보 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<h3 class="font-medium text-gray-900 truncate" x-text="doc.title"></h3>
|
||||
<!-- PDF 연결 상태 표시 -->
|
||||
<span x-show="doc.pdf_path"
|
||||
class="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded-full">
|
||||
PDF
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 truncate mb-1" x-text="doc.description || '설명이 없습니다'"></p>
|
||||
|
||||
<!-- 메타 정보 -->
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-400">
|
||||
<span x-text="formatDate(doc.created_at)"></span>
|
||||
<span x-show="doc.uploader_name" x-text="doc.uploader_name"></span>
|
||||
<!-- 태그 표시 (간단하게) -->
|
||||
<span x-show="doc.tags && doc.tags.length > 0"
|
||||
class="flex items-center">
|
||||
<i class="fas fa-tags mr-1"></i>
|
||||
<span x-text="doc.tags.length + '개 태그'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼들 -->
|
||||
<div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button @click.stop="editDocument(doc)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors rounded-md hover:bg-blue-50"
|
||||
title="문서 수정">
|
||||
<i class="fas fa-edit text-sm"></i>
|
||||
</button>
|
||||
<button x-show="currentUser && currentUser.is_admin"
|
||||
@click.stop="deleteDocument(doc.id)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors rounded-md hover:bg-red-50"
|
||||
title="문서 삭제">
|
||||
<i class="fas fa-trash text-sm"></i>
|
||||
</button>
|
||||
<i class="fas fa-chevron-right text-gray-300 ml-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="!loading && documents.length === 0" class="p-8 text-center">
|
||||
<i class="fas fa-file-alt text-gray-400 text-4xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">문서가 없습니다</h3>
|
||||
<p class="text-gray-500">이 서적에 등록된 문서가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/api.js?v=2025012384"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/header-loader.js?v=2025012351"></script>
|
||||
<script src="/static/js/book-documents.js?v=2025012401"></script>
|
||||
</body>
|
||||
</html>
|
||||
199
frontend/book-editor.html
Normal file
199
frontend/book-editor.html
Normal file
@@ -0,0 +1,199 @@
|
||||
<!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.0.0/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<style>
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.sortable-chosen {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.sortable-drag {
|
||||
background-color: white;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="bookEditorApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 헤더 섹션 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="goBack()"
|
||||
class="flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>서적으로 돌아가기
|
||||
</button>
|
||||
|
||||
<button @click="saveChanges()"
|
||||
:disabled="saving"
|
||||
class="flex items-center px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
<span x-text="saving ? '저장 중...' : '변경사항 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center mr-6">
|
||||
<i class="fas fa-book text-white text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900" x-text="bookInfo.title"></h1>
|
||||
<p class="text-gray-600 mt-1" x-show="bookInfo.author" x-text="bookInfo.author"></p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
<span x-text="documents.length"></span>개 문서 편집
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<i class="fas fa-spinner fa-spin text-3xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 편집 섹션 -->
|
||||
<div x-show="!loading" class="space-y-8">
|
||||
<!-- 서적 정보 편집 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-info-circle mr-2 text-blue-600"></i>
|
||||
서적 정보
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">서적 제목</label>
|
||||
<input type="text"
|
||||
x-model="bookInfo.title"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">저자</label>
|
||||
<input type="text"
|
||||
x-model="bookInfo.author"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea x-model="bookInfo.description"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 순서 및 PDF 매칭 편집 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-list-ol mr-2 text-green-600"></i>
|
||||
문서 순서 및 PDF 매칭
|
||||
</h2>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="autoSortByName()"
|
||||
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors text-sm">
|
||||
<i class="fas fa-sort-alpha-down mr-1"></i>이름순 정렬
|
||||
</button>
|
||||
<button @click="reverseOrder()"
|
||||
class="px-3 py-1.5 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors text-sm">
|
||||
<i class="fas fa-exchange-alt mr-1"></i>순서 뒤집기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div id="sortable-list" class="space-y-3">
|
||||
<template x-for="(doc, index) in documents" :key="doc.id">
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 cursor-move hover:bg-gray-100 transition-colors"
|
||||
:data-id="doc.id">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-1">
|
||||
<!-- 드래그 핸들 -->
|
||||
<div class="mr-4 text-gray-400">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</div>
|
||||
|
||||
<!-- 순서 번호 -->
|
||||
<div class="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-medium mr-4">
|
||||
<span x-text="index + 1"></span>
|
||||
</div>
|
||||
|
||||
<!-- 문서 정보 -->
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-gray-900" x-text="doc.title"></h3>
|
||||
<p class="text-sm text-gray-500" x-text="doc.description || '설명 없음'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 매칭 및 컨트롤 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- PDF 매칭 드롭다운 -->
|
||||
<div class="min-w-48 relative">
|
||||
<select x-model="doc.matched_pdf_id"
|
||||
:class="doc.matched_pdf_id ? 'border-green-300 bg-green-50' : 'border-gray-300'"
|
||||
class="w-full px-3 py-2 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<option value="">PDF 매칭 없음</option>
|
||||
<template x-for="pdf in availablePDFs" :key="pdf.id">
|
||||
<option :value="pdf.id" x-text="pdf.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
<!-- 매칭 상태 표시 -->
|
||||
<div x-show="doc.matched_pdf_id" class="absolute -top-1 -right-1">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full border-2 border-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이동 버튼 -->
|
||||
<div class="flex flex-col space-y-1">
|
||||
<button @click="moveUp(index)"
|
||||
:disabled="index === 0"
|
||||
class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-chevron-up text-xs"></i>
|
||||
</button>
|
||||
<button @click="moveDown(index)"
|
||||
:disabled="index === documents.length - 1"
|
||||
class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="documents.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-file-alt text-gray-400 text-3xl mb-4"></i>
|
||||
<p class="text-gray-500">편집할 문서가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/api.js?v=2025012384"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/header-loader.js?v=2025012351"></script>
|
||||
<script src="/static/js/book-editor.js?v=2025012461"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/cache-buster.html
Normal file
38
frontend/cache-buster.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!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>
|
||||
// 강제 캐시 무효화
|
||||
const timestamp = new Date().getTime();
|
||||
console.log('🔧 캐시 무효화 타임스탬프:', timestamp);
|
||||
|
||||
// localStorage 캐시 정리
|
||||
localStorage.removeItem('api_cache');
|
||||
sessionStorage.clear();
|
||||
|
||||
// 3초 후 업로드 페이지로 리다이렉트
|
||||
setTimeout(() => {
|
||||
window.location.href = `upload.html?t=${timestamp}`;
|
||||
}, 3000);
|
||||
</script>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center;">
|
||||
<h1>🔧 캐시 무효화 중...</h1>
|
||||
<p>잠시만 기다려주세요. 3초 후 업로드 페이지로 이동합니다.</p>
|
||||
<div style="margin-top: 20px;">
|
||||
<div style="width: 50px; height: 50px; border: 5px solid #f3f3f3; border-top: 5px solid #3498db; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
585
frontend/components/header.html
Normal file
585
frontend/components/header.html
Normal file
@@ -0,0 +1,585 @@
|
||||
<!-- 공통 헤더 컴포넌트 -->
|
||||
<header class="header-modern fade-in fixed top-0 left-0 right-0 z-50">
|
||||
<div class="max-w-full 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-2">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
|
||||
</div>
|
||||
|
||||
<!-- 메인 네비게이션 -->
|
||||
<nav class="hidden md:flex items-center space-x-1 relative">
|
||||
<!-- 문서 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button class="nav-link-modern" id="doc-nav-link">
|
||||
<i class="fas fa-folder-open text-blue-600"></i>
|
||||
<span>문서 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a href="index.html" class="nav-dropdown-card" id="index-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-th-large text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">문서 관리</div>
|
||||
<div class="text-xs text-gray-500">HTML 문서 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="pdf-manager.html" class="nav-dropdown-card" id="pdf-manager-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-file-pdf text-red-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">PDF 관리</div>
|
||||
<div class="text-xs text-gray-500">PDF 파일 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통합 검색 -->
|
||||
<a href="search.html" class="nav-link-modern" id="search-nav-link">
|
||||
<i class="fas fa-search text-green-600"></i>
|
||||
<span>통합 검색</span>
|
||||
</a>
|
||||
|
||||
<!-- 할일관리 -->
|
||||
<a href="todos.html" class="nav-link-modern" id="todos-nav-link">
|
||||
<i class="fas fa-tasks text-indigo-600"></i>
|
||||
<span>할일관리</span>
|
||||
</a>
|
||||
|
||||
<!-- 소설 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button class="nav-link-modern" id="novel-nav-link">
|
||||
<i class="fas fa-feather-alt text-purple-600"></i>
|
||||
<span>소설 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a href="memo-tree.html" class="nav-dropdown-card" id="memo-tree-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-sitemap text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">트리 뷰</div>
|
||||
<div class="text-xs text-gray-500">계층형 메모 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="story-view.html" class="nav-dropdown-card" id="story-view-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-book-open text-orange-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">스토리 뷰</div>
|
||||
<div class="text-xs text-gray-500">스토리 읽기 모드</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button class="nav-link-modern" id="notes-nav-link">
|
||||
<i class="fas fa-sticky-note text-yellow-600"></i>
|
||||
<span>노트 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a href="notebooks.html" class="nav-dropdown-card" id="notebooks-nav-item">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-book text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 text-sm">노트북 관리</div>
|
||||
<div class="text-xs text-gray-500">그룹 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="notes.html" class="nav-dropdown-card" id="notes-list-nav-item">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-list text-green-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 text-sm">노트 목록</div>
|
||||
<div class="text-xs text-gray-500">전체 보기</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="note-editor.html" class="nav-dropdown-card" id="note-editor-nav-item">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-edit text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 text-sm">새 노트 작성</div>
|
||||
<div class="text-xs text-gray-500">노트 만들기</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 모바일 메뉴 버튼 -->
|
||||
<div class="md:hidden">
|
||||
<button x-data="{ open: false }" @click="open = !open" class="mobile-menu-btn">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
|
||||
|
||||
<!-- 사용자 계정 메뉴 -->
|
||||
<div class="flex items-center space-x-3" id="user-menu">
|
||||
<!-- 로그인된 사용자 드롭다운 -->
|
||||
<div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
|
||||
<button @click="open = !open" class="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="text-sm font-medium text-gray-900" id="user-name">User</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
|
||||
<!-- 심플한 드롭다운 메뉴 -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
|
||||
<!-- 사용자 정보 헤더 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<div class="text-sm font-medium text-gray-900" id="dropdown-user-name">User</div>
|
||||
<div class="text-sm text-gray-500" id="dropdown-user-email">user@example.com</div>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 항목들 -->
|
||||
<div class="py-1">
|
||||
<a href="profile.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i class="fas fa-user-edit w-4 h-4 mr-3 text-gray-400"></i>
|
||||
프로필 관리
|
||||
</a>
|
||||
|
||||
<!-- 관리자 메뉴 (관리자만 표시) -->
|
||||
<div class="hidden" id="admin-menu-section">
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<div class="px-4 py-2">
|
||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">관리자</div>
|
||||
</div>
|
||||
<a href="user-management.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i class="fas fa-users-cog w-4 h-4 mr-3 text-indigo-500"></i>
|
||||
계정 관리
|
||||
</a>
|
||||
<a href="system-settings.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i class="fas fa-server w-4 h-4 mr-3 text-green-500"></i>
|
||||
시스템 설정
|
||||
</a>
|
||||
<a href="backup-restore.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i class="fas fa-database w-4 h-4 mr-3 text-blue-500"></i>
|
||||
백업/복원
|
||||
</a>
|
||||
<a href="logs.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i class="fas fa-file-alt w-4 h-4 mr-3 text-orange-500"></i>
|
||||
시스템 로그
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 로그아웃 -->
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<button onclick="handleLogout()" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||
<i class="fas fa-sign-out-alt w-4 h-4 mr-3"></i>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 버튼 -->
|
||||
<div class="" id="login-button">
|
||||
<button id="login-btn" class="login-btn-modern">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span>로그인</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 헤더 관련 스타일 -->
|
||||
<style>
|
||||
/* 모던 네비게이션 링크 스타일 */
|
||||
.nav-link-modern {
|
||||
@apply flex items-center space-x-2 px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link-modern:hover {
|
||||
@apply bg-gradient-to-r from-gray-50 to-gray-100 shadow-sm;
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-link-modern.active {
|
||||
@apply text-blue-700 bg-blue-50 border-blue-200;
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 와이드 드롭다운 메뉴 스타일 */
|
||||
.nav-dropdown-wide {
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
margin-top: 0.5rem !important;
|
||||
z-index: 9999 !important;
|
||||
min-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
padding: 1rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nav-dropdown-card {
|
||||
@apply block p-4 bg-white border border-gray-100 rounded-lg hover:border-gray-200 hover:shadow-md transition-all duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.nav-dropdown-card:hover {
|
||||
@apply bg-gradient-to-br from-gray-50 to-blue-50 transform -translate-y-1;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-dropdown-card.active {
|
||||
@apply border-blue-200 bg-blue-50;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* 드롭다운 컨테이너 안정성 */
|
||||
nav > div.relative {
|
||||
position: relative !important;
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
/* 애니메이션 중 위치 고정 */
|
||||
.nav-dropdown-wide[x-show] {
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
|
||||
/* 모바일 메뉴 버튼 */
|
||||
.mobile-menu-btn {
|
||||
@apply p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* 사용자 메뉴 스타일 */
|
||||
/* 사용자 메뉴 관련 스타일은 인라인으로 처리됨 */
|
||||
|
||||
/* 로그인 버튼 모던 스타일 */
|
||||
.login-btn-modern {
|
||||
@apply flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all duration-200;
|
||||
}
|
||||
|
||||
.login-btn-modern:hover {
|
||||
@apply from-blue-700 to-blue-800 transform -translate-y-0.5;
|
||||
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.login-btn-modern:active {
|
||||
@apply transform translate-y-0;
|
||||
}
|
||||
|
||||
/* 헤더 모던 스타일 */
|
||||
.header-modern {
|
||||
@apply bg-white/95 backdrop-blur-md border-b border-gray-200/50 shadow-lg;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 헤더 호버 효과 */
|
||||
.header-modern:hover {
|
||||
@apply bg-white shadow-xl;
|
||||
}
|
||||
|
||||
/* 언어 전환 스타일 */
|
||||
.lang-ko .lang-en,
|
||||
.lang-ko [lang="en"],
|
||||
.lang-ko .english {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.lang-en .lang-ko,
|
||||
.lang-en [lang="ko"],
|
||||
.lang-en .korean {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 헤더 관련 JavaScript 함수들 -->
|
||||
<script>
|
||||
// 헤더 관련 유틸리티 함수들
|
||||
window.headerUtils = {
|
||||
getCurrentPage() {
|
||||
const path = window.location.pathname;
|
||||
const filename = path.split('/').pop().replace('.html', '');
|
||||
return filename || 'index';
|
||||
},
|
||||
|
||||
isDocumentPage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['index', 'hierarchy', 'pdf-manager'].includes(page);
|
||||
},
|
||||
|
||||
isNovelPage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['memo-tree', 'story-view', 'story-reader'].includes(page);
|
||||
},
|
||||
|
||||
isNotePage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['notes', 'note-editor'].includes(page);
|
||||
}
|
||||
};
|
||||
|
||||
// Alpine.js 전역 함수로 등록 - 즉시 실행
|
||||
if (typeof Alpine !== 'undefined') {
|
||||
// Alpine이 이미 로드된 경우
|
||||
Alpine.store('header', {
|
||||
getCurrentPage: () => headerUtils.getCurrentPage(),
|
||||
isDocumentPage: () => headerUtils.isDocumentPage(),
|
||||
isNovelPage: () => headerUtils.isNovelPage(),
|
||||
isNotePage: () => headerUtils.isNotePage(),
|
||||
});
|
||||
} else {
|
||||
// Alpine 로드 대기
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('header', {
|
||||
getCurrentPage: () => headerUtils.getCurrentPage(),
|
||||
isDocumentPage: () => headerUtils.isDocumentPage(),
|
||||
isNovelPage: () => headerUtils.isNovelPage(),
|
||||
isNotePage: () => headerUtils.isNotePage(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 함수로도 등록 (Alpine 외부에서도 사용 가능)
|
||||
window.getCurrentPage = () => headerUtils.getCurrentPage();
|
||||
window.isDocumentPage = () => headerUtils.isDocumentPage();
|
||||
window.isMemoPage = () => headerUtils.isMemoPage();
|
||||
|
||||
// 로그인 관련 함수들
|
||||
window.handleLogin = () => {
|
||||
console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
};
|
||||
|
||||
window.handleLogout = () => {
|
||||
// 각 페이지의 로그아웃 함수 호출
|
||||
if (typeof logout === 'function') {
|
||||
logout();
|
||||
} else {
|
||||
console.log('로그아웃 함수를 찾을 수 없습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 상태 업데이트 함수
|
||||
window.updateUserMenu = (user) => {
|
||||
console.log('🔄 updateUserMenu 호출됨:', user);
|
||||
|
||||
const loggedInMenu = document.getElementById('logged-in-menu');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const adminMenuSection = document.getElementById('admin-menu-section');
|
||||
|
||||
console.log('🔍 요소 찾기:', {
|
||||
loggedInMenu: !!loggedInMenu,
|
||||
loginButton: !!loginButton,
|
||||
adminMenuSection: !!adminMenuSection
|
||||
});
|
||||
|
||||
// 사용자 정보 요소들
|
||||
const userName = document.getElementById('user-name');
|
||||
const userRole = document.getElementById('user-role');
|
||||
const dropdownUserName = document.getElementById('dropdown-user-name');
|
||||
const dropdownUserEmail = document.getElementById('dropdown-user-email');
|
||||
const dropdownUserRole = document.getElementById('dropdown-user-role');
|
||||
|
||||
if (user) {
|
||||
// 로그인된 상태
|
||||
console.log('✅ 사용자 로그인 상태 - UI 업데이트 시작');
|
||||
if (loggedInMenu) {
|
||||
loggedInMenu.classList.remove('hidden');
|
||||
console.log('✅ 로그인 메뉴 표시');
|
||||
}
|
||||
if (loginButton) {
|
||||
loginButton.classList.add('hidden');
|
||||
console.log('✅ 로그인 버튼 숨김');
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
const displayName = user.full_name || user.email || 'User';
|
||||
const roleText = getRoleText(user.role);
|
||||
|
||||
if (userName) userName.textContent = displayName;
|
||||
if (userRole) userRole.textContent = roleText;
|
||||
if (dropdownUserName) dropdownUserName.textContent = displayName;
|
||||
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
|
||||
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
|
||||
|
||||
// 관리자 메뉴 표시/숨김
|
||||
if (adminMenuSection) {
|
||||
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
|
||||
adminMenuSection.classList.remove('hidden');
|
||||
} else {
|
||||
adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 로그아웃된 상태
|
||||
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
||||
if (loginButton) loginButton.classList.remove('hidden');
|
||||
if (adminMenuSection) adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// 역할 텍스트 변환 함수
|
||||
function getRoleText(role) {
|
||||
const roleMap = {
|
||||
'root': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || '사용자';
|
||||
}
|
||||
|
||||
// 언어 토글 함수 (전역)
|
||||
// 통합 언어 변경 함수
|
||||
window.handleLanguageChange = (lang) => {
|
||||
console.log('🌐 언어 변경 요청:', lang);
|
||||
localStorage.setItem('preferred_language', lang);
|
||||
|
||||
// HTML lang 속성 변경
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
// body에 언어 클래스 추가/제거
|
||||
document.body.classList.remove('lang-ko', 'lang-en');
|
||||
document.body.classList.add(`lang-${lang}`);
|
||||
|
||||
// 뷰어 페이지인 경우 뷰어의 언어 전환 함수 호출
|
||||
if (window.documentViewerInstance && typeof window.documentViewerInstance.toggleLanguage === 'function') {
|
||||
window.documentViewerInstance.toggleLanguage();
|
||||
}
|
||||
|
||||
// 문서 내용에서 언어별 요소 처리
|
||||
toggleDocumentLanguage(lang);
|
||||
|
||||
// 헤더 언어 표시 업데이트
|
||||
updateLanguageDisplay(lang);
|
||||
|
||||
console.log(`✅ 언어가 ${lang === 'ko' ? '한국어' : 'English'}로 설정되었습니다.`);
|
||||
};
|
||||
|
||||
// 문서 내용 언어 전환
|
||||
function toggleDocumentLanguage(lang) {
|
||||
// 언어별 요소 숨기기/보이기
|
||||
const koElements = document.querySelectorAll('[lang="ko"], .lang-ko, .korean');
|
||||
const enElements = document.querySelectorAll('[lang="en"], .lang-en, .english');
|
||||
|
||||
if (lang === 'ko') {
|
||||
koElements.forEach(el => el.style.display = '');
|
||||
enElements.forEach(el => el.style.display = 'none');
|
||||
} else {
|
||||
koElements.forEach(el => el.style.display = 'none');
|
||||
enElements.forEach(el => el.style.display = '');
|
||||
}
|
||||
|
||||
console.log(`🔄 문서 언어 전환: ${koElements.length}개 한국어, ${enElements.length}개 영어 요소 처리`);
|
||||
}
|
||||
|
||||
// 헤더 언어 표시 업데이트
|
||||
function updateLanguageDisplay(lang) {
|
||||
const langSpan = document.querySelector('.nav-link span:contains("한국어"), .nav-link span:contains("English")');
|
||||
if (langSpan) {
|
||||
langSpan.textContent = lang === 'ko' ? '한국어' : 'English';
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 setLanguage 함수 (호환성 유지)
|
||||
window.setLanguage = window.handleLanguageChange;
|
||||
|
||||
window.toggleLanguage = () => {
|
||||
console.log('🌐 언어 토글 기능 (미구현)');
|
||||
// 향후 다국어 지원 시 구현
|
||||
};
|
||||
|
||||
// 헤더 로드 완료 후 이벤트 바인딩
|
||||
document.addEventListener('headerLoaded', () => {
|
||||
console.log('🔧 헤더 로드 완료 - 이벤트 바인딩 시작');
|
||||
|
||||
// 로그인 버튼 이벤트 리스너 추가
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener('click', () => {
|
||||
console.log('🔐 로그인 버튼 클릭됨');
|
||||
if (typeof window.handleLogin === 'function') {
|
||||
window.handleLogin();
|
||||
} else {
|
||||
console.error('❌ handleLogin 함수를 찾을 수 없습니다');
|
||||
}
|
||||
});
|
||||
console.log('✅ 로그인 버튼 이벤트 리스너 등록 완료');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 언어 설정 적용
|
||||
const savedLang = localStorage.getItem('preferred_language') || 'ko';
|
||||
console.log('💾 저장된 언어 설정 적용:', savedLang);
|
||||
|
||||
// 약간의 지연 후 적용 (DOM 완전 로드 대기)
|
||||
setTimeout(() => {
|
||||
handleLanguageChange(savedLang);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// DOMContentLoaded 백업 (헤더가 직접 로드된 경우)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => {
|
||||
if (typeof window.handleLanguageChange === 'function') {
|
||||
const savedLang = localStorage.getItem('preferred_language') || 'ko';
|
||||
console.log('💾 DOMContentLoaded - 언어 설정 적용:', savedLang);
|
||||
handleLanguageChange(savedLang);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
</script>
|
||||
562
frontend/index.html
Normal file
562
frontend/index.html
Normal file
@@ -0,0 +1,562 @@
|
||||
<!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>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📄</text></svg>">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></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">
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 메인 앱 -->
|
||||
<div x-data="documentApp()" x-init="init()">
|
||||
<!-- 로그인 모달 -->
|
||||
<div x-data="authModal()" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div 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="showLoginModal = 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 id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-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="showLoginModal = 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="mb-6">
|
||||
<!-- 첫 번째 줄: 제목과 뷰 모드 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-900">문서 목록</h2>
|
||||
<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 mr-2"></i>전체 문서
|
||||
</button>
|
||||
<button @click="viewMode = 'books'"
|
||||
:class="viewMode === 'books' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-book mr-2"></i>서적별 보기
|
||||
</button>
|
||||
|
||||
<button onclick="window.location.href='/notes.html'"
|
||||
class="px-3 py-2 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-sticky-note mr-2"></i>노트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 두 번째 줄: 검색과 필터 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 검색 입력창 -->
|
||||
<div class="flex-1 relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterDocuments"
|
||||
placeholder="문서 제목, 내용, 태그로 검색..."
|
||||
class="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm transition-all duration-200">
|
||||
<i class="fas fa-search absolute left-3 top-3.5 text-gray-400"></i>
|
||||
<!-- 검색 결과 개수 표시 -->
|
||||
<span x-show="searchQuery && filteredDocuments.length !== documents.length"
|
||||
class="absolute right-3 top-3 text-sm text-gray-500">
|
||||
<span x-text="filteredDocuments.length"></span>개 결과
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 태그 필터 -->
|
||||
<select x-model="selectedTag" @change="filterDocuments"
|
||||
class="px-4 py-2.5 border border-gray-200 rounded-xl bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[140px]">
|
||||
<option value="">모든 태그</option>
|
||||
<template x-for="tag in tags" :key="tag.id">
|
||||
<option :value="tag.name" x-text="tag.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- 업로드 버튼 -->
|
||||
<button @click="openUploadPage()"
|
||||
class="px-6 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg flex items-center space-x-2">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span>업로드</span>
|
||||
</button>
|
||||
|
||||
<!-- 검색 초기화 버튼 -->
|
||||
<button x-show="searchQuery || selectedTag"
|
||||
@click="clearFilters"
|
||||
class="px-4 py-2.5 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-all duration-200">
|
||||
<i class="fas fa-times mr-2"></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 filteredDocuments" :key="doc.id">
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="openDocument(doc.id)">
|
||||
<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-3 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
|
||||
|
||||
<!-- 서적 정보 -->
|
||||
<div x-show="doc.book_title" class="mb-3 p-2 bg-green-50 border border-green-200 rounded-md">
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="fas fa-book text-green-600 mr-2"></i>
|
||||
<span class="font-medium text-green-800" x-text="doc.book_title"></span>
|
||||
<span x-show="doc.book_author" class="text-green-600 ml-1" x-text="' by ' + doc.book_author"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1 mb-4">
|
||||
<template x-for="tag in doc.tags" :key="tag">
|
||||
<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>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span x-text="doc.uploader_name"></span>
|
||||
<!-- 액션 버튼들 -->
|
||||
<div class="flex space-x-1 ml-2">
|
||||
<button @click.stop="editDocument(doc)"
|
||||
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="문서 수정">
|
||||
<i class="fas fa-edit text-sm"></i>
|
||||
</button>
|
||||
<!-- 관리자만 삭제 버튼 표시 -->
|
||||
<button x-show="currentUser && currentUser.is_admin"
|
||||
@click.stop="deleteDocument(doc.id)"
|
||||
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="문서 삭제 (관리자 전용)">
|
||||
<i class="fas fa-trash text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 서적별 그룹화 뷰 (데본씽크 스타일) -->
|
||||
<div x-show="viewMode === 'books'" class="space-y-4">
|
||||
<!-- 서적 목록 -->
|
||||
<template x-for="bookGroup in groupedDocuments" :key="bookGroup.book?.id || 'no-book'">
|
||||
<div class="bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-200">
|
||||
<!-- 서적 헤더 -->
|
||||
<div class="p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
@click="bookGroup.expanded = !bookGroup.expanded">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- 서적 아이콘 -->
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-md flex items-center justify-center">
|
||||
<i class="fas fa-book text-white text-sm"></i>
|
||||
</div>
|
||||
|
||||
<!-- 서적 정보 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900" x-text="bookGroup.book?.title || '서적 미분류'"></h3>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span x-show="bookGroup.book?.author" x-text="bookGroup.book.author"></span>
|
||||
<span class="text-gray-300">•</span>
|
||||
<span x-text="bookGroup.documents.length + '개 문서'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 확장/축소 아이콘 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click.stop="openBookDocuments(bookGroup.book)"
|
||||
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors">
|
||||
편집
|
||||
</button>
|
||||
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
|
||||
:class="{'rotate-180': bookGroup.expanded}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 (확장 시 표시) -->
|
||||
<div x-show="bookGroup.expanded" x-collapse class="border-t border-gray-100">
|
||||
<div class="divide-y divide-gray-50">
|
||||
<template x-for="(doc, index) in bookGroup.documents.slice(0, 10)" :key="doc.id">
|
||||
<div class="p-3 hover:bg-gray-50 cursor-pointer transition-colors flex items-center justify-between"
|
||||
@click="openDocument(doc.id)">
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<!-- 문서 타입 아이콘 -->
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center"
|
||||
:class="doc.pdf_path ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600'">
|
||||
<i :class="doc.pdf_path ? 'fas fa-file-pdf' : 'fas fa-file-alt'" class="text-xs"></i>
|
||||
</div>
|
||||
|
||||
<!-- 문서 정보 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate" x-text="doc.title"></h4>
|
||||
<p class="text-xs text-gray-500 truncate" x-text="doc.description || '설명 없음'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 메타 정보 -->
|
||||
<div class="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span x-text="formatDate(doc.created_at)"></span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 더 많은 문서가 있을 때 -->
|
||||
<div x-show="bookGroup.documents.length > 10"
|
||||
class="p-3 text-center border-t border-gray-100">
|
||||
<button @click="openBookDocuments(bookGroup.book)"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 font-medium">
|
||||
<span x-text="`${bookGroup.documents.length - 10}개 문서 더 보기`"></span>
|
||||
<i class="fas fa-arrow-right ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 서적이 없을 때 -->
|
||||
<div x-show="groupedDocuments.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-book text-4xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">서적이 없습니다</h3>
|
||||
<p class="text-gray-500 mb-4">문서를 업로드하고 서적으로 분류해보세요</p>
|
||||
<button onclick="window.location.href='/upload.html'"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
문서 업로드하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<template x-if="filteredDocuments.length === 0 && !loading">
|
||||
<div class="text-center py-16">
|
||||
<template x-if="searchQuery || selectedTag">
|
||||
<!-- 검색 결과 없음 -->
|
||||
<div>
|
||||
<i class="fas fa-search 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="clearFilters"
|
||||
class="bg-gray-600 text-white px-6 py-3 rounded-lg hover:bg-gray-700">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
필터 초기화
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!searchQuery && !selectedTag">
|
||||
<!-- 문서 없음 -->
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<!-- 문서 업로드 모달 -->
|
||||
<div x-show="showUploadModal" 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-2xl w-full mx-4 max-h-96 overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">문서 업로드</h2>
|
||||
<button @click="showUploadModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-data="uploadModal()">
|
||||
<form @submit.prevent="upload">
|
||||
<!-- 파일 업로드 영역 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">HTML 문서 *</label>
|
||||
<div class="file-drop-zone"
|
||||
@dragover.prevent="$el.classList.add('dragover')"
|
||||
@dragleave.prevent="$el.classList.remove('dragover')"
|
||||
@drop.prevent="handleFileDrop($event, 'html_file')">
|
||||
<input type="file" @change="onFileSelect($event, 'html_file')"
|
||||
accept=".html,.htm" class="hidden" id="html-file">
|
||||
<label for="html-file" class="cursor-pointer">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-lg text-gray-600 mb-2">HTML 파일을 드래그하거나 클릭하여 선택</p>
|
||||
<p class="text-sm text-gray-500">지원 형식: .html, .htm</p>
|
||||
</label>
|
||||
<div x-show="uploadForm.html_file" class="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-blue-800">
|
||||
<i class="fas fa-file-alt mr-2"></i>
|
||||
<span x-text="uploadForm.html_file?.name"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 파일 (선택사항) -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">PDF 원본 (선택사항)</label>
|
||||
<div class="file-drop-zone"
|
||||
@dragover.prevent="$el.classList.add('dragover')"
|
||||
@dragleave.prevent="$el.classList.remove('dragover')"
|
||||
@drop.prevent="handleFileDrop($event, 'pdf_file')">
|
||||
<input type="file" @change="onFileSelect($event, 'pdf_file')"
|
||||
accept=".pdf" class="hidden" id="pdf-file">
|
||||
<label for="pdf-file" class="cursor-pointer">
|
||||
<i class="fas fa-file-pdf text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600">PDF 원본 파일 (선택사항)</p>
|
||||
</label>
|
||||
<div x-show="uploadForm.pdf_file" class="mt-4 p-3 bg-red-50 rounded-lg">
|
||||
<p class="text-sm text-red-800">
|
||||
<i class="fas fa-file-pdf mr-2"></i>
|
||||
<span x-text="uploadForm.pdf_file?.name"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서적 선택/생성 -->
|
||||
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">📚 서적 설정</label>
|
||||
|
||||
<!-- 서적 선택 방식 -->
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="bookSelectionMode" value="existing"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm">기존 서적에 추가</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="bookSelectionMode" value="new"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm">새 서적 생성</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="bookSelectionMode" value="none"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm">서적 없이 업로드</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 기존 서적 선택 -->
|
||||
<div x-show="bookSelectionMode === 'existing'" class="space-y-3">
|
||||
<div>
|
||||
<input type="text" x-model="bookSearchQuery" @input="searchBooks"
|
||||
placeholder="서적 제목으로 검색..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div x-show="searchedBooks.length > 0" class="max-h-40 overflow-y-auto border border-gray-200 rounded-md">
|
||||
<template x-for="book in searchedBooks" :key="book.id">
|
||||
<div @click="selectBook(book)"
|
||||
:class="selectedBook?.id === book.id ? 'bg-blue-100 border-blue-500' : 'hover:bg-gray-50'"
|
||||
class="p-3 border-b border-gray-200 cursor-pointer">
|
||||
<div class="font-medium text-sm" x-text="book.title"></div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span x-text="book.author || '저자 미상'"></span> ·
|
||||
<span x-text="book.document_count + '개 문서'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 서적 표시 -->
|
||||
<div x-show="selectedBook" class="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium text-blue-900" x-text="selectedBook?.title"></div>
|
||||
<div class="text-sm text-blue-700" x-text="selectedBook?.author || '저자 미상'"></div>
|
||||
</div>
|
||||
<button @click="selectedBook = null" class="text-blue-500 hover:text-blue-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 서적 생성 -->
|
||||
<div x-show="bookSelectionMode === 'new'" class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<input type="text" x-model="newBook.title" @input="getSuggestions"
|
||||
placeholder="서적 제목 *" :required="bookSelectionMode === 'new'"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" x-model="newBook.author"
|
||||
placeholder="저자"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 유사 서적 추천 -->
|
||||
<div x-show="suggestions.length > 0" class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div class="text-sm font-medium text-yellow-800 mb-2">💡 유사한 서적이 있습니다:</div>
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<div @click="selectExistingFromSuggestion(suggestion)"
|
||||
class="p-2 bg-white border border-yellow-300 rounded cursor-pointer hover:bg-yellow-50 mb-1">
|
||||
<div class="text-sm font-medium" x-text="suggestion.title"></div>
|
||||
<div class="text-xs text-gray-600">
|
||||
<span x-text="suggestion.author || '저자 미상'"></span> ·
|
||||
<span x-text="Math.round(suggestion.similarity_score * 100) + '% 유사'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 정보 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
|
||||
<input type="text" x-model="uploadForm.title" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="문서 제목">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">문서 날짜</label>
|
||||
<input type="date" x-model="uploadForm.document_date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea x-model="uploadForm.description" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="문서에 대한 간단한 설명"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
|
||||
<input type="text" x-model="uploadForm.tags"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="태그를 쉼표로 구분하여 입력 (예: 중요, 회의록, 2024)">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="uploadForm.is_public" class="mr-2">
|
||||
<span class="text-sm text-gray-700">공개 문서로 설정 (다른 사용자도 볼 수 있음)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 에러 메시지 -->
|
||||
<div x-show="uploadError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
<span x-text="uploadError"></span>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="showUploadModal = false; resetForm()"
|
||||
class="px-6 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" :disabled="uploading || !uploadForm.html_file || !uploadForm.title"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!uploading">업로드</span>
|
||||
<span x-show="uploading">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>업로드 중...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인증 모달 컴포넌트 -->
|
||||
<div x-data="authModal" x-ref="authModal"></div>
|
||||
|
||||
<!-- 스크립트는 하단에서 로드됩니다 -->
|
||||
|
||||
<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>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/api.js?v=2025012380"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/main.js?v=2025012462"></script>
|
||||
</body>
|
||||
</html>
|
||||
316
frontend/login.html
Normal file
316
frontend/login.html
Normal file
@@ -0,0 +1,316 @@
|
||||
<!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>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
|
||||
<style>
|
||||
/* 배경 이미지 스타일 */
|
||||
.login-background {
|
||||
background: url('static/images/login-bg.jpg') center/cover;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 기본 배경 (이미지가 없을 때) */
|
||||
.login-background-fallback {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(147, 51, 234, 0.3));
|
||||
}
|
||||
|
||||
/* 글래스모피즘 효과 */
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 입력 필드 포커스 효과 */
|
||||
.input-glow:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 로그인 버튼 호버 효과 */
|
||||
.btn-login:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* 파티클 애니메이션 */
|
||||
.particle {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(180deg); }
|
||||
}
|
||||
|
||||
/* 로고 영역 개선 */
|
||||
.logo-container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen login-background-fallback" x-data="loginApp()">
|
||||
<!-- 배경 파티클 -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="particle w-2 h-2" style="left: 10%; top: 20%; animation-delay: 0s;"></div>
|
||||
<div class="particle w-3 h-3" style="left: 20%; top: 80%; animation-delay: 2s;"></div>
|
||||
<div class="particle w-1 h-1" style="left: 80%; top: 30%; animation-delay: 4s;"></div>
|
||||
<div class="particle w-2 h-2" style="left: 90%; top: 70%; animation-delay: 1s;"></div>
|
||||
<div class="particle w-1 h-1" style="left: 30%; top: 10%; animation-delay: 3s;"></div>
|
||||
<div class="particle w-2 h-2" style="left: 70%; top: 90%; animation-delay: 5s;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative">
|
||||
<!-- 로그인 영역 -->
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- 로고 및 제목 -->
|
||||
<div class="text-center fade-in">
|
||||
<div class="mx-auto h-24 w-24 glass-effect rounded-full flex items-center justify-center mb-6 shadow-2xl">
|
||||
<i class="fas fa-book text-white text-4xl"></i>
|
||||
</div>
|
||||
<h2 class="text-4xl font-bold text-white mb-2 drop-shadow-lg">Document Server</h2>
|
||||
<p class="text-blue-100 text-lg">지식을 관리하고 공유하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 폼 -->
|
||||
<div class="glass-effect rounded-2xl shadow-2xl p-8 slide-up">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-semibold text-white text-center mb-2">로그인</h3>
|
||||
<p class="text-blue-100 text-center text-sm">계정에 로그인하여 시작하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-500/20 text-green-100 border border-green-400/30' : 'bg-red-500/20 text-red-100 border border-red-400/30'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-400' : 'fas fa-exclamation-circle text-red-400'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="login()" class="space-y-6">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-blue-100 mb-2">
|
||||
<i class="fas fa-envelope mr-2"></i>이메일
|
||||
</label>
|
||||
<input type="email" id="email" x-model="loginForm.email" required
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300"
|
||||
placeholder="이메일을 입력하세요">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-blue-100 mb-2">
|
||||
<i class="fas fa-lock mr-2"></i>비밀번호
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input :type="showPassword ? 'text' : 'password'" id="password" x-model="loginForm.password" required
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300 pr-12"
|
||||
placeholder="비밀번호를 입력하세요">
|
||||
<button type="button" @click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-blue-200 hover:text-white transition-colors">
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 유지 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center text-sm text-blue-100">
|
||||
<input type="checkbox" x-model="loginForm.remember"
|
||||
class="mr-2 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-blue-500 focus:ring-offset-0">
|
||||
로그인 상태 유지
|
||||
</label>
|
||||
<a href="#" class="text-sm text-blue-200 hover:text-white transition-colors">
|
||||
비밀번호를 잊으셨나요?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed btn-login transition-all duration-300">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="loading"></i>
|
||||
<i class="fas fa-sign-in-alt mr-2" x-show="!loading"></i>
|
||||
<span x-text="loading ? '로그인 중...' : '로그인'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 추가 옵션 -->
|
||||
<div class="mt-6 text-center">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-white/20"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-transparent text-blue-200">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button @click="goToSetup()" class="text-blue-200 hover:text-white text-sm transition-colors">
|
||||
<i class="fas fa-cog mr-1"></i>시스템 초기 설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div class="text-center text-blue-200 text-sm fade-in mt-8">
|
||||
<p>© 2024 Document Server. All rights reserved.</p>
|
||||
<p class="mt-1">
|
||||
<i class="fas fa-shield-alt mr-1"></i>
|
||||
안전하고 신뢰할 수 있는 문서 관리 시스템
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 배경 이미지 로드 스크립트 -->
|
||||
<script>
|
||||
// 배경 이미지 로드 시도
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
document.body.classList.remove('login-background-fallback');
|
||||
document.body.classList.add('login-background');
|
||||
};
|
||||
img.onerror = function() {
|
||||
console.log('배경 이미지를 찾을 수 없어 기본 그라디언트를 사용합니다.');
|
||||
};
|
||||
img.src = 'static/images/login-bg.jpg';
|
||||
</script>
|
||||
|
||||
<!-- API 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
|
||||
<!-- 로그인 앱 스크립트 -->
|
||||
<script>
|
||||
function loginApp() {
|
||||
return {
|
||||
loading: false,
|
||||
showPassword: false,
|
||||
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false
|
||||
},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔐 로그인 앱 초기화');
|
||||
|
||||
// 이미 로그인된 경우 메인 페이지로 리다이렉트
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
try {
|
||||
await api.getCurrentUser();
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
} catch (error) {
|
||||
// 토큰이 유효하지 않으면 제거
|
||||
localStorage.removeItem('access_token');
|
||||
}
|
||||
}
|
||||
|
||||
// URL 파라미터에서 리다이렉트 URL 확인
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.redirectUrl = urlParams.get('redirect') || 'index.html';
|
||||
},
|
||||
|
||||
async login() {
|
||||
if (!this.loginForm.email || !this.loginForm.password) {
|
||||
this.showNotification('이메일과 비밀번호를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
console.log('🔐 로그인 시도:', this.loginForm.email);
|
||||
|
||||
const result = await api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (result.success) {
|
||||
this.showNotification('로그인 성공! 페이지를 이동합니다...', 'success');
|
||||
|
||||
// 잠시 후 리다이렉트
|
||||
setTimeout(() => {
|
||||
window.location.href = this.redirectUrl || 'index.html';
|
||||
}, 1000);
|
||||
} else {
|
||||
this.showNotification(result.message || '로그인에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 오류:', error);
|
||||
this.showNotification('로그인 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
goToSetup() {
|
||||
window.location.href = 'setup.html';
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
type,
|
||||
message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
331
frontend/logs.html
Normal file
331
frontend/logs.html
Normal file
@@ -0,0 +1,331 @@
|
||||
<!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="static/js/auth-guard.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8" x-data="logsApp()">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">시스템 로그</h1>
|
||||
<p class="text-gray-600">시스템 활동과 오류 로그를 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 컨트롤 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- 로그 레벨 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">로그 레벨</label>
|
||||
<select x-model="filters.level" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="INFO">정보</option>
|
||||
<option value="WARNING">경고</option>
|
||||
<option value="ERROR">오류</option>
|
||||
<option value="DEBUG">디버그</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 날짜 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">날짜</label>
|
||||
<input type="date" x-model="filters.date" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
|
||||
<input type="text" x-model="filters.search" @input="filterLogs()" placeholder="메시지 검색..." class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<!-- 컨트롤 버튼 -->
|
||||
<div class="flex items-end space-x-2">
|
||||
<button @click="refreshLogs()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
|
||||
<i class="fas fa-sync-alt mr-1"></i>
|
||||
새로고침
|
||||
</button>
|
||||
<button @click="clearLogs()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-info-circle text-blue-600"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">정보</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" x-text="stats.info"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">경고</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" x-text="stats.warning"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-times-circle text-red-600"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">오류</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" x-text="stats.error"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-bug text-gray-600"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">디버그</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" x-text="stats.debug"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-list text-gray-500 mr-3"></i>
|
||||
로그 목록
|
||||
<span class="ml-2 text-sm font-normal text-gray-500">(<span x-text="filteredLogs.length"></span>개)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">시간</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">레벨</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">소스</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template x-for="log in paginatedLogs" :key="log.id">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900" x-text="log.timestamp"></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800': log.level === 'INFO',
|
||||
'bg-yellow-100 text-yellow-800': log.level === 'WARNING',
|
||||
'bg-red-100 text-red-800': log.level === 'ERROR',
|
||||
'bg-gray-100 text-gray-800': log.level === 'DEBUG'
|
||||
}"
|
||||
x-text="log.level">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" x-text="log.source"></td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
<div class="max-w-md truncate" x-text="log.message" :title="log.message"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span x-text="(currentPage - 1) * pageSize + 1"></span>-<span x-text="Math.min(currentPage * pageSize, filteredLogs.length)"></span> / <span x-text="filteredLogs.length"></span>개
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="currentPage > 1 && currentPage--"
|
||||
:disabled="currentPage <= 1"
|
||||
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
이전
|
||||
</button>
|
||||
<button @click="currentPage < totalPages && currentPage++"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
<script>
|
||||
function logsApp() {
|
||||
return {
|
||||
logs: [],
|
||||
filteredLogs: [],
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
filters: {
|
||||
level: '',
|
||||
date: '',
|
||||
search: ''
|
||||
},
|
||||
stats: {
|
||||
info: 0,
|
||||
warning: 0,
|
||||
error: 0,
|
||||
debug: 0
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filteredLogs.length / this.pageSize);
|
||||
},
|
||||
|
||||
get paginatedLogs() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.filteredLogs.slice(start, end);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.loadLogs();
|
||||
// 자동 새로고침 (30초마다)
|
||||
setInterval(() => {
|
||||
this.refreshLogs();
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
async loadLogs() {
|
||||
try {
|
||||
// 실제 구현에서는 백엔드 API 호출
|
||||
// this.logs = await window.api.getLogs();
|
||||
|
||||
// 임시 데모 데이터
|
||||
this.logs = this.generateDemoLogs();
|
||||
this.filterLogs();
|
||||
this.updateStats();
|
||||
} catch (error) {
|
||||
console.error('로그 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
generateDemoLogs() {
|
||||
const levels = ['INFO', 'WARNING', 'ERROR', 'DEBUG'];
|
||||
const sources = ['auth', 'documents', 'database', 'nginx', 'system'];
|
||||
const messages = [
|
||||
'사용자 로그인 성공: admin@test.com',
|
||||
'문서 업로드 완료: test.pdf',
|
||||
'데이터베이스 연결 실패',
|
||||
'API 요청 처리 완료',
|
||||
'메모리 사용량 경고: 85%',
|
||||
'백업 작업 시작',
|
||||
'인증 토큰 만료',
|
||||
'파일 업로드 오류',
|
||||
'시스템 재시작 완료',
|
||||
'캐시 정리 작업 완료'
|
||||
];
|
||||
|
||||
const logs = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const date = new Date();
|
||||
date.setMinutes(date.getMinutes() - i * 5);
|
||||
|
||||
logs.push({
|
||||
id: i + 1,
|
||||
timestamp: date.toLocaleString('ko-KR'),
|
||||
level: levels[Math.floor(Math.random() * levels.length)],
|
||||
source: sources[Math.floor(Math.random() * sources.length)],
|
||||
message: messages[Math.floor(Math.random() * messages.length)]
|
||||
});
|
||||
}
|
||||
return logs.reverse();
|
||||
},
|
||||
|
||||
filterLogs() {
|
||||
this.filteredLogs = this.logs.filter(log => {
|
||||
let matches = true;
|
||||
|
||||
if (this.filters.level && log.level !== this.filters.level) {
|
||||
matches = false;
|
||||
}
|
||||
|
||||
if (this.filters.date) {
|
||||
const logDate = new Date(log.timestamp).toISOString().split('T')[0];
|
||||
if (logDate !== this.filters.date) {
|
||||
matches = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.filters.search && !log.message.toLowerCase().includes(this.filters.search.toLowerCase())) {
|
||||
matches = false;
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
this.currentPage = 1;
|
||||
},
|
||||
|
||||
updateStats() {
|
||||
this.stats = {
|
||||
info: this.logs.filter(log => log.level === 'INFO').length,
|
||||
warning: this.logs.filter(log => log.level === 'WARNING').length,
|
||||
error: this.logs.filter(log => log.level === 'ERROR').length,
|
||||
debug: this.logs.filter(log => log.level === 'DEBUG').length
|
||||
};
|
||||
},
|
||||
|
||||
async refreshLogs() {
|
||||
await this.loadLogs();
|
||||
},
|
||||
|
||||
async clearLogs() {
|
||||
if (!confirm('모든 로그를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 실제 구현에서는 백엔드 API 호출
|
||||
// await window.api.clearLogs();
|
||||
|
||||
this.logs = [];
|
||||
this.filteredLogs = [];
|
||||
this.updateStats();
|
||||
alert('로그가 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
alert('로그 삭제 중 오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1281
frontend/memo-tree.html
Normal file
1281
frontend/memo-tree.html
Normal file
File diff suppressed because it is too large
Load Diff
202
frontend/note-editor.html
Normal file
202
frontend/note-editor.html
Normal file
@@ -0,0 +1,202 @@
|
||||
<!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.0.0/css/all.min.css">
|
||||
|
||||
<!-- Quill.js (WYSIWYG HTML 에디터) -->
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||
|
||||
<style>
|
||||
.ql-editor {
|
||||
min-height: 400px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ql-toolbar {
|
||||
border-top: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
.ql-container {
|
||||
border-bottom: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="noteEditorApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-edit text-blue-600 mr-3"></i>
|
||||
<span x-text="isEditing ? '노트 편집' : '새 노트 작성'"></span>
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">HTML 에디터로 풍부한 노트를 작성하세요</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button @click="goBack()"
|
||||
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
<span>돌아가기</span>
|
||||
</button>
|
||||
|
||||
<button @click="saveNote()"
|
||||
:disabled="saving || !noteData.title"
|
||||
:class="saving || !noteData.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
class="flex items-center px-4 py-2 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-save mr-2" :class="{'fa-spin': saving}"></i>
|
||||
<span x-text="saving ? '저장 중...' : '저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 설정 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- 제목 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
|
||||
<input type="text"
|
||||
x-model="noteData.title"
|
||||
placeholder="노트 제목을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 선택 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
|
||||
<select x-model="noteData.notebook_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">미분류</option>
|
||||
<template x-for="notebook in availableNotebooks" :key="notebook.id">
|
||||
<option :value="notebook.id" x-text="notebook.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<!-- 노트 타입 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
|
||||
<select x-model="noteData.note_type"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="note">일반 노트</option>
|
||||
<option value="research">연구 노트</option>
|
||||
<option value="summary">요약</option>
|
||||
<option value="idea">아이디어</option>
|
||||
<option value="guide">가이드</option>
|
||||
<option value="reference">참고 자료</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<!-- 태그 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
|
||||
<input type="text"
|
||||
x-model="tagInput"
|
||||
@keydown.enter.prevent="addTag()"
|
||||
placeholder="태그를 입력하고 Enter를 누르세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
|
||||
<!-- 태그 목록 -->
|
||||
<div x-show="noteData.tags.length > 0" class="mt-3 flex flex-wrap gap-2">
|
||||
<template x-for="(tag, index) in noteData.tags" :key="index">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
|
||||
<span x-text="tag"></span>
|
||||
<button @click="removeTag(index)" class="ml-2 text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공개 설정 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">공개 설정</label>
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="radio"
|
||||
x-model="noteData.is_published"
|
||||
:value="false"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm text-gray-700">초안</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio"
|
||||
x-model="noteData.is_published"
|
||||
:value="true"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm text-gray-700">공개</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML 에디터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
|
||||
<div class="p-4 border-b bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">노트 내용</h3>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="toggleEditorMode()"
|
||||
class="text-sm text-gray-600 hover:text-gray-800">
|
||||
<i class="fas fa-code mr-1"></i>
|
||||
<span x-text="editorMode === 'wysiwyg' ? 'HTML 코드' : 'WYSIWYG'"></span>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500" x-text="getWordCount() + '자'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WYSIWYG 에디터 -->
|
||||
<div x-show="editorMode === 'wysiwyg'" id="quill-editor"></div>
|
||||
|
||||
<!-- HTML 코드 에디터 -->
|
||||
<div x-show="editorMode === 'html'" class="p-4">
|
||||
<textarea x-model="noteData.content"
|
||||
rows="20"
|
||||
placeholder="HTML 코드를 직접 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
style="resize: vertical;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 -->
|
||||
<div x-show="noteData.content" class="mt-6 bg-white rounded-lg shadow-sm border">
|
||||
<div class="p-4 border-b bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">미리보기</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div x-html="noteData.content" class="prose max-w-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/note-editor.js?v=2025012608"></script>
|
||||
</body>
|
||||
</html>
|
||||
453
frontend/notebooks.html
Normal file
453
frontend/notebooks.html
Normal file
@@ -0,0 +1,453 @@
|
||||
<!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.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
.notebook-card {
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid var(--notebook-color);
|
||||
}
|
||||
.notebook-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
.notebook-icon {
|
||||
color: var(--notebook-color);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.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>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="notebooksApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-book text-blue-600 mr-3"></i>
|
||||
노트북 관리
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">노트들을 체계적으로 분류하고 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="window.location.href='/notes.html'"
|
||||
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-sticky-note mr-2"></i>
|
||||
<span>노트 관리</span>
|
||||
</button>
|
||||
|
||||
<button @click="refreshNotebooks()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
|
||||
<button @click="showCreateModal = true"
|
||||
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span>새 노트북</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div x-show="stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-blue-100">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">전체 노트북</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notebooks || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-green-100">
|
||||
<i class="fas fa-check-circle text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">활성 노트북</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.active_notebooks || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-purple-100">
|
||||
<i class="fas fa-sticky-note text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">전체 노트</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-orange-100">
|
||||
<i class="fas fa-folder-open text-orange-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">미분류 노트</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.notes_without_notebook || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="debounceSearch()"
|
||||
placeholder="노트북 검색..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
x-model="activeOnly"
|
||||
@change="loadNotebooks()"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm text-gray-700">활성 노트북만</span>
|
||||
</label>
|
||||
|
||||
<select x-model="sortBy"
|
||||
@change="loadNotebooks()"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="updated_at">최근 수정순</option>
|
||||
<option value="created_at">생성일순</option>
|
||||
<option value="title">제목순</option>
|
||||
<option value="sort_order">정렬순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<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="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 그리드 -->
|
||||
<div x-show="!loading && notebooks.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<template x-for="notebook in notebooks" :key="notebook.id">
|
||||
<div class="notebook-card bg-white rounded-lg shadow-sm border p-6 cursor-pointer"
|
||||
:style="`--notebook-color: ${notebook.color}`"
|
||||
@click="openNotebook(notebook)">
|
||||
|
||||
<!-- 노트북 헤더 -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<i :class="`fas fa-${notebook.icon} notebook-icon text-2xl mr-3`"></i>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="notebook.title"></h3>
|
||||
<p class="text-sm text-gray-500" x-text="`${notebook.note_count}개 노트`"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click.stop="editNotebook(notebook)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button @click.stop="deleteNotebook(notebook)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 설명 -->
|
||||
<div x-show="notebook.description" class="mb-4">
|
||||
<p class="text-gray-600 text-sm line-clamp-2" x-text="notebook.description"></p>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 메타데이터 -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-2">
|
||||
<span x-text="formatDate(notebook.updated_at)"></span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span x-show="!notebook.is_active" class="px-2 py-1 bg-gray-100 text-gray-600 rounded-full">
|
||||
비활성
|
||||
</span>
|
||||
<span class="px-2 py-1 rounded-full text-white"
|
||||
:style="`background-color: ${notebook.color}`"
|
||||
x-text="notebook.created_by">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 액션 -->
|
||||
<div x-show="notebook.note_count === 0" class="text-center py-2">
|
||||
<p class="text-xs text-gray-400 mb-2">노트가 없습니다</p>
|
||||
<button @click.stop="createNoteInNotebook(notebook)"
|
||||
class="text-xs bg-blue-50 text-blue-600 px-3 py-1 rounded-full hover:bg-blue-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
첫 노트 작성
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="notebook.note_count > 0" class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span x-text="formatDate(notebook.last_note_created_at || notebook.updated_at)"></span>
|
||||
</span>
|
||||
<button @click.stop="createNoteInNotebook(notebook)"
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
노트 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="!loading && notebooks.length === 0" class="text-center py-16">
|
||||
<i class="fas fa-book text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">노트북이 없습니다</h3>
|
||||
<p class="text-gray-500 mb-6">첫 번째 노트북을 만들어 노트들을 정리해보세요</p>
|
||||
<button @click="showCreateModal = true"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
새 노트북 만들기
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div x-show="notification.show"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform translate-x-full"
|
||||
x-transition:enter-end="opacity-100 transform translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-x-0"
|
||||
x-transition:leave-end="opacity-0 transform translate-x-full"
|
||||
class="fixed top-4 right-4 z-50 max-w-sm">
|
||||
<div class="rounded-lg shadow-lg border p-4"
|
||||
:class="{
|
||||
'bg-green-50 border-green-200 text-green-800': notification.type === 'success',
|
||||
'bg-red-50 border-red-200 text-red-800': notification.type === 'error',
|
||||
'bg-blue-50 border-blue-200 text-blue-800': notification.type === 'info'
|
||||
}">
|
||||
<div class="flex items-center">
|
||||
<i :class="{
|
||||
'fas fa-check-circle text-green-600': notification.type === 'success',
|
||||
'fas fa-exclamation-circle text-red-600': notification.type === 'error',
|
||||
'fas fa-info-circle text-blue-600': notification.type === 'info'
|
||||
}" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
<button @click="notification.show = false" class="ml-auto text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 생성/편집 모달 -->
|
||||
<div x-show="showCreateModal || showEditModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900"
|
||||
x-text="showEditModal ? '노트북 편집' : '새 노트북 만들기'"></h3>
|
||||
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveNotebook()">
|
||||
<!-- 제목 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
|
||||
<input type="text"
|
||||
x-model="notebookForm.title"
|
||||
placeholder="노트북 제목을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea x-model="notebookForm.description"
|
||||
rows="3"
|
||||
placeholder="노트북에 대한 설명을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 색상과 아이콘 -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="color in availableColors" :key="color">
|
||||
<button type="button"
|
||||
@click="notebookForm.color = color"
|
||||
:class="notebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
|
||||
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
|
||||
:style="`background-color: ${color}`">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
|
||||
<select x-model="notebookForm.icon"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<template x-for="icon in availableIcons" :key="icon.value">
|
||||
<option :value="icon.value" x-text="icon.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
@click="closeModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
:disabled="saving || !notebookForm.title"
|
||||
:class="saving || !notebookForm.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
class="px-4 py-2 text-white rounded-lg transition-colors">
|
||||
<span x-show="!saving" x-text="showEditModal ? '수정' : '생성'"></span>
|
||||
<span x-show="saving">저장 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div x-show="showDeleteModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95">
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 rounded-full bg-red-100 mr-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">노트북 삭제</h3>
|
||||
<p class="text-sm text-gray-600">이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-700 mb-2">
|
||||
<strong x-text="deletingNotebook?.title"></strong> 노트북을 삭제하시겠습니까?
|
||||
</p>
|
||||
<div x-show="deletingNotebook?.note_count > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-yellow-600 mr-2"></i>
|
||||
<span class="text-sm text-yellow-800">
|
||||
포함된 <strong x-text="deletingNotebook?.note_count"></strong>개의 노트는 미분류 상태가 됩니다.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
@click="closeDeleteModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="confirmDeleteNotebook()"
|
||||
:disabled="deleting"
|
||||
:class="deleting ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-600 hover:bg-red-700'"
|
||||
class="px-4 py-2 text-white rounded-lg transition-colors">
|
||||
<span x-show="!deleting">삭제</span>
|
||||
<span x-show="deleting">삭제 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/notebooks.js?v=2025012609"></script>
|
||||
</body>
|
||||
</html>
|
||||
423
frontend/notes.html
Normal file
423
frontend/notes.html
Normal file
@@ -0,0 +1,423 @@
|
||||
<!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.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
.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>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="notesApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-sticky-note text-blue-600 mr-3"></i>
|
||||
노트 관리
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">마크다운으로 노트를 작성하고 서적과 연결하여 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="window.location.href='/'"
|
||||
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
<span>돌아가기</span>
|
||||
</button>
|
||||
|
||||
<button @click="refreshNotes()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
|
||||
<button onclick="window.location.href='/note-editor.html'"
|
||||
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span>새 노트 작성</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" x-show="stats">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-sticky-note text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">전체 노트</h3>
|
||||
<p class="text-2xl font-bold text-blue-600" x-text="stats?.total_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-eye text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">공개 노트</h3>
|
||||
<p class="text-2xl font-bold text-green-600" x-text="stats?.published_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-edit text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">초안</h3>
|
||||
<p class="text-2xl font-bold text-yellow-600" x-text="stats?.draft_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-clock text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">읽기 시간</h3>
|
||||
<p class="text-2xl font-bold text-purple-600" x-text="(stats?.total_reading_time || 0) + '분'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 작업 도구 -->
|
||||
<div x-show="selectedNotes.length > 0"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-medium text-blue-900">
|
||||
<span x-text="selectedNotes.length"></span>개 노트 선택됨
|
||||
</span>
|
||||
<button @click="clearSelection()"
|
||||
class="text-sm text-blue-600 hover:text-blue-800">
|
||||
선택 해제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- 노트북 할당 -->
|
||||
<select x-model="bulkNotebookId"
|
||||
class="px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<option value="">노트북 선택...</option>
|
||||
<template x-for="notebook in availableNotebooks" :key="notebook.id">
|
||||
<option :value="notebook.id" x-text="notebook.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<button @click="assignToNotebook()"
|
||||
:disabled="!bulkNotebookId"
|
||||
:class="!bulkNotebookId ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
class="px-4 py-2 text-white rounded-lg transition-colors text-sm">
|
||||
노트북에 할당
|
||||
</button>
|
||||
|
||||
<button @click="showCreateNotebookModal = true"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm">
|
||||
새 노트북 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<!-- 검색 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="debounceSearch()"
|
||||
placeholder="제목이나 내용으로 검색..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
|
||||
<select x-model="selectedNotebook" @change="loadNotes()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">전체</option>
|
||||
<option value="unassigned">미분류</option>
|
||||
<template x-for="notebook in availableNotebooks" :key="notebook.id">
|
||||
<option :value="notebook.id" x-text="notebook.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 노트 타입 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
|
||||
<select x-model="selectedType" @change="loadNotes()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">전체</option>
|
||||
<option value="note">일반 노트</option>
|
||||
<option value="research">연구 노트</option>
|
||||
<option value="summary">요약</option>
|
||||
<option value="idea">아이디어</option>
|
||||
<option value="guide">가이드</option>
|
||||
<option value="reference">참고 자료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 공개 상태 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">상태</label>
|
||||
<select x-model="publishedOnly" @change="loadNotes()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option :value="false">전체</option>
|
||||
<option :value="true">공개만</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border">
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="p-8 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-500">노트를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 노트 카드들 -->
|
||||
<div x-show="!loading && notes.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
<template x-for="note in notes" :key="note.id">
|
||||
<div class="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow cursor-pointer relative"
|
||||
:class="selectedNotes.includes(note.id) ? 'ring-2 ring-blue-500 bg-blue-50' : ''"
|
||||
@click="viewNote(note.id)">
|
||||
|
||||
<!-- 선택 체크박스 -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<input type="checkbox"
|
||||
:checked="selectedNotes.includes(note.id)"
|
||||
@click.stop="toggleNoteSelection(note.id)"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 노트 헤더 -->
|
||||
<div class="flex items-start justify-between mb-4 ml-8">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2 mb-2" x-text="note.title"></h3>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span class="px-2 py-1 bg-gray-100 rounded-full text-xs" x-text="getNoteTypeLabel(note.note_type)"></span>
|
||||
<span x-show="note.is_published" class="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">공개</span>
|
||||
<span x-show="!note.is_published" class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs">초안</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button @click.stop="editNote(note.id)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="편집">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button @click.stop="deleteNote(note)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 태그 -->
|
||||
<div x-show="note.tags && note.tags.length > 0" class="mb-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="tag in note.tags.slice(0, 3)" :key="tag">
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
|
||||
</template>
|
||||
<span x-show="note.tags.length > 3" class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
+<span x-text="note.tags.length - 3"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 정보 -->
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-font mr-1"></i>
|
||||
<span x-text="note.word_count"></span>자
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span x-text="note.reading_time"></span>분
|
||||
</span>
|
||||
<span x-show="note.child_count > 0" class="flex items-center">
|
||||
<i class="fas fa-sitemap mr-1"></i>
|
||||
<span x-text="note.child_count"></span>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작성 정보 -->
|
||||
<div class="text-xs text-gray-400 border-t pt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span x-text="note.created_by"></span>
|
||||
<span x-text="formatDate(note.updated_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="!loading && notes.length === 0" class="p-8 text-center">
|
||||
<i class="fas fa-sticky-note text-gray-400 text-4xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">노트가 없습니다</h3>
|
||||
<p class="text-gray-500 mb-6">첫 번째 노트를 작성해보세요</p>
|
||||
<button @click="createNewNote()"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
새 노트 작성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 노트북 생성 모달 -->
|
||||
<div x-show="showCreateNotebookModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900">새 노트북 만들기</h3>
|
||||
<button @click="closeCreateNotebookModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createNotebookAndAssign()">
|
||||
<!-- 제목 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">노트북 이름 *</label>
|
||||
<input type="text"
|
||||
x-model="newNotebookForm.name"
|
||||
placeholder="노트북 이름을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea x-model="newNotebookForm.description"
|
||||
rows="3"
|
||||
placeholder="노트북에 대한 설명을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 색상과 아이콘 -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="color in availableColors" :key="color">
|
||||
<button type="button"
|
||||
@click="newNotebookForm.color = color"
|
||||
:class="newNotebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
|
||||
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
|
||||
:style="`background-color: ${color}`">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
|
||||
<select x-model="newNotebookForm.icon"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<template x-for="icon in availableIcons" :key="icon.value">
|
||||
<option :value="icon.value" x-text="icon.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 노트 정보 -->
|
||||
<div x-show="selectedNotes.length > 0" class="mb-4 p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-blue-800">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
선택된 <span x-text="selectedNotes.length"></span>개의 노트가 이 노트북에 할당됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
@click="closeCreateNotebookModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
:disabled="creatingNotebook || !newNotebookForm.name"
|
||||
:class="creatingNotebook || !newNotebookForm.name ? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'"
|
||||
class="px-4 py-2 text-white rounded-lg transition-colors">
|
||||
<span x-show="!creatingNotebook">생성 및 할당</span>
|
||||
<span x-show="creatingNotebook">생성 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/notes.js?v=2025012610"></script>
|
||||
</body>
|
||||
</html>
|
||||
444
frontend/pdf-manager.html
Normal file
444
frontend/pdf-manager.html
Normal file
@@ -0,0 +1,444 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PDF 파일 관리 - Document Server</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="pdfManagerApp()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">PDF 파일 관리</h1>
|
||||
<p class="text-gray-600 mt-2">업로드된 PDF 파일들을 관리하고 삭제할 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
<button @click="refreshPDFs()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">전체 PDF</h3>
|
||||
<p class="text-2xl font-bold text-red-600" x-text="pdfDocuments.length"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">서적 포함</h3>
|
||||
<p class="text-2xl font-bold text-blue-600" x-text="bookPDFs"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-link text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">HTML 연결</h3>
|
||||
<p class="text-2xl font-bold text-green-600" x-text="linkedPDFs"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-unlink text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">독립 파일</h3>
|
||||
<p class="text-2xl font-bold text-yellow-600" x-text="standalonePDFs"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰 모드 및 필터 -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900">PDF 파일 관리</h2>
|
||||
|
||||
<!-- 뷰 모드 선택 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<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 text-sm">
|
||||
<i class="fas fa-list mr-2"></i>전체 목록
|
||||
</button>
|
||||
<button @click="viewMode = 'books'"
|
||||
:class="viewMode === 'books' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md text-sm">
|
||||
<i class="fas fa-book mr-2"></i>서적별 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 버튼 (목록 뷰에서만 표시) -->
|
||||
<div x-show="viewMode === 'list'" class="flex flex-wrap gap-2">
|
||||
<button @click="filterType = 'all'"
|
||||
:class="filterType === 'all' ? 'bg-gray-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
|
||||
전체
|
||||
</button>
|
||||
<button @click="filterType = 'book'"
|
||||
:class="filterType === 'book' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
|
||||
서적 포함
|
||||
</button>
|
||||
<button @click="filterType = 'linked'"
|
||||
:class="filterType === 'linked' ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
|
||||
HTML 연결
|
||||
</button>
|
||||
<button @click="filterType = 'standalone'"
|
||||
:class="filterType === 'standalone' ? 'bg-yellow-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
|
||||
독립 파일
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 목록 뷰 -->
|
||||
<div x-show="viewMode === 'list'" class="bg-white rounded-lg border border-gray-200">
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="p-8 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-500">PDF 파일을 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- PDF 목록 -->
|
||||
<div x-show="!loading && filteredPDFs.length > 0" class="divide-y divide-gray-100">
|
||||
<template x-for="pdf in filteredPDFs" :key="pdf.id">
|
||||
<div class="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start space-x-4 flex-1">
|
||||
<!-- PDF 아이콘 -->
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
|
||||
</div>
|
||||
|
||||
<!-- PDF 정보 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1" x-text="pdf.title"></h3>
|
||||
<p class="text-sm text-gray-500 mb-2" x-text="pdf.original_filename"></p>
|
||||
<p class="text-sm text-gray-600 line-clamp-2" x-text="pdf.description || '설명이 없습니다'"></p>
|
||||
|
||||
<!-- 서적 정보 및 연결 상태 -->
|
||||
<div class="mt-3 space-y-2">
|
||||
<!-- 서적 정보 -->
|
||||
<div x-show="pdf.book_title" class="flex items-center space-x-2">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
|
||||
<i class="fas fa-book mr-1"></i>
|
||||
<span x-text="pdf.book_title"></span>
|
||||
</span>
|
||||
<span x-show="pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
<i class="fas fa-link mr-1"></i>
|
||||
HTML 연결됨
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 서적 없는 경우 -->
|
||||
<div x-show="!pdf.book_title" class="flex items-center space-x-2">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-gray-100 text-gray-600 text-sm rounded-full">
|
||||
<i class="fas fa-file mr-1"></i>
|
||||
서적 미분류
|
||||
</span>
|
||||
<span x-show="pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
<i class="fas fa-link mr-1"></i>
|
||||
HTML 연결됨
|
||||
</span>
|
||||
<span x-show="!pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full">
|
||||
<i class="fas fa-unlink mr-1"></i>
|
||||
독립 파일
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 업로드 날짜 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-500">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
<span x-text="formatDate(pdf.created_at)"></span>
|
||||
</span>
|
||||
<span x-show="pdf.uploaded_by" class="text-sm text-gray-500">
|
||||
<i class="fas fa-user mr-1"></i>
|
||||
<span x-text="pdf.uploaded_by"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button @click="previewPDF(pdf)"
|
||||
class="p-2 text-gray-400 hover:text-green-600 transition-colors"
|
||||
title="PDF 미리보기">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
|
||||
<button @click="downloadPDF(pdf)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="PDF 다운로드">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
|
||||
<button x-show="currentUser && currentUser.is_admin"
|
||||
@click="deletePDF(pdf)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="PDF 삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="!loading && filteredPDFs.length === 0" class="p-8 text-center">
|
||||
<i class="fas fa-file-pdf text-gray-400 text-4xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3>
|
||||
<p class="text-gray-500">
|
||||
<span x-show="filterType === 'all'">업로드된 PDF 파일이 없습니다</span>
|
||||
<span x-show="filterType === 'book'">서적에 포함된 PDF 파일이 없습니다</span>
|
||||
<span x-show="filterType === 'linked'">HTML과 연결된 PDF 파일이 없습니다</span>
|
||||
<span x-show="filterType === 'standalone'">독립 PDF 파일이 없습니다</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서적별 뷰 (데본씽크 스타일) -->
|
||||
<div x-show="viewMode === 'books'" class="space-y-4">
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-500">PDF 파일을 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 서적별 그룹 -->
|
||||
<template x-for="bookGroup in groupedPDFs" :key="bookGroup.book?.id || 'no-book'">
|
||||
<div class="bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-200">
|
||||
<!-- 서적 헤더 -->
|
||||
<div class="p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
@click="bookGroup.expanded = !bookGroup.expanded">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- 서적 아이콘 -->
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-red-500 to-red-600 rounded-md flex items-center justify-center">
|
||||
<i class="fas fa-file-pdf text-white text-sm"></i>
|
||||
</div>
|
||||
|
||||
<!-- 서적 정보 -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900" x-text="bookGroup.book?.title || 'PDF 미분류'"></h3>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span x-show="bookGroup.book?.author" x-text="bookGroup.book.author"></span>
|
||||
<span x-show="bookGroup.book?.author" class="text-gray-300">•</span>
|
||||
<span x-text="bookGroup.pdfs.length + '개 PDF'"></span>
|
||||
<span class="text-gray-300">•</span>
|
||||
<span x-text="bookGroup.linkedCount + '개 연결됨'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 확장/축소 아이콘 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-500" x-text="bookGroup.pdfs.length + '개'"></span>
|
||||
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
|
||||
:class="{'rotate-180': bookGroup.expanded}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 목록 (확장 시 표시) -->
|
||||
<div x-show="bookGroup.expanded" x-collapse class="border-t border-gray-100">
|
||||
<div class="divide-y divide-gray-50">
|
||||
<template x-for="(pdf, index) in bookGroup.pdfs.slice(0, 20)" :key="pdf.id">
|
||||
<div class="p-3 hover:bg-gray-50 cursor-pointer transition-colors flex items-center justify-between group"
|
||||
@click="previewPDF(pdf)">
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<!-- PDF 아이콘 -->
|
||||
<div class="w-8 h-8 bg-red-100 text-red-600 rounded-md flex items-center justify-center">
|
||||
<i class="fas fa-file-pdf text-xs"></i>
|
||||
</div>
|
||||
|
||||
<!-- PDF 정보 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate" x-text="pdf.title"></h4>
|
||||
<div class="flex items-center space-x-2 text-xs text-gray-500">
|
||||
<span x-text="pdf.original_filename"></span>
|
||||
<span x-show="pdf.isLinked" class="flex items-center text-green-600">
|
||||
<i class="fas fa-link mr-1"></i>HTML
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼들 -->
|
||||
<div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button @click.stop="downloadPDF(pdf)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors rounded-md hover:bg-blue-50"
|
||||
title="다운로드">
|
||||
<i class="fas fa-download text-xs"></i>
|
||||
</button>
|
||||
<button @click.stop="deletePDF(pdf.id)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors rounded-md hover:bg-red-50"
|
||||
title="삭제">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
<i class="fas fa-chevron-right text-gray-300 ml-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 더 많은 PDF가 있을 때 -->
|
||||
<div x-show="bookGroup.pdfs.length > 20"
|
||||
class="p-3 text-center border-t border-gray-100">
|
||||
<button @click="viewMode = 'list'; filterType = 'book'"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 font-medium">
|
||||
<span x-text="`${bookGroup.pdfs.length - 20}개 PDF 더 보기`"></span>
|
||||
<i class="fas fa-arrow-right ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 서적이 없을 때 -->
|
||||
<div x-show="!loading && groupedPDFs.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-file-pdf text-4xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3>
|
||||
<p class="text-gray-500 mb-4">PDF 파일을 업로드하고 서적으로 분류해보세요</p>
|
||||
<button onclick="window.location.href='/upload.html'"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
파일 업로드하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- PDF 미리보기 모달 -->
|
||||
<div x-show="showPreviewModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
@click.self="closePreview()">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900" x-text="previewPdf?.title"></h3>
|
||||
<p class="text-sm text-gray-500" x-text="previewPdf?.original_filename"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="downloadPDF(previewPdf)"
|
||||
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>다운로드</span>
|
||||
</button>
|
||||
<button @click="closePreview()"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 뷰어 -->
|
||||
<div class="p-6 overflow-y-auto" style="max-height: calc(90vh - 120px);">
|
||||
<!-- PDF 미리보기 -->
|
||||
<div x-show="previewPdf" class="mb-4">
|
||||
<!-- PDF 뷰어 컨테이너 -->
|
||||
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 600px;">
|
||||
<!-- PDF iframe 뷰어 -->
|
||||
<iframe x-show="!pdfPreviewError && !pdfPreviewLoading && pdfPreviewSrc"
|
||||
class="w-full h-full border-0"
|
||||
:src="pdfPreviewSrc"
|
||||
@load="pdfPreviewLoaded = true"
|
||||
@error="handlePdfPreviewError()">
|
||||
</iframe>
|
||||
|
||||
<!-- PDF 로딩 상태 -->
|
||||
<div x-show="pdfPreviewLoading" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-3xl text-gray-500 mb-4"></i>
|
||||
<p class="text-gray-600 text-lg">PDF를 로드하는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 에러 상태 -->
|
||||
<div x-show="pdfPreviewError" class="flex items-center justify-center h-full text-gray-500">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-3xl mb-4 text-red-500"></i>
|
||||
<p class="text-lg mb-4">PDF를 로드할 수 없습니다</p>
|
||||
<button @click="retryPdfPreview()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-2">
|
||||
다시 시도
|
||||
</button>
|
||||
<button @click="downloadPDF(previewPdf)"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
파일 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/api.js?v=2025012384"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/header-loader.js?v=2025012351"></script>
|
||||
<script src="/static/js/pdf-manager.js?v=2025012627"></script>
|
||||
|
||||
<!-- Alpine.js (JavaScript 파일들 다음에 로드) -->
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
363
frontend/profile.html
Normal file
363
frontend/profile.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>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="profileApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- 페이지 제목 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">프로필 관리</h1>
|
||||
<p class="text-gray-600">개인 정보와 계정 설정을 관리하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 카드 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center space-x-6 mb-6">
|
||||
<!-- 프로필 아바타 -->
|
||||
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user text-blue-600 text-2xl"></i>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900" x-text="user.full_name || user.email"></h2>
|
||||
<p class="text-gray-600" x-text="user.email"></p>
|
||||
<div class="flex items-center mt-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="user.role === 'root' ? 'bg-red-100 text-red-800' : user.role === 'admin' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'">
|
||||
<i class="fas fa-crown mr-1" x-show="user.role === 'root'"></i>
|
||||
<i class="fas fa-shield-alt mr-1" x-show="user.role === 'admin'"></i>
|
||||
<i class="fas fa-user mr-1" x-show="user.role === 'user'"></i>
|
||||
<span x-text="getRoleText(user.role)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="mb-8">
|
||||
<nav class="flex space-x-8" aria-label="Tabs">
|
||||
<button @click="activeTab = 'profile'"
|
||||
:class="activeTab === 'profile' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-user mr-2"></i>프로필 정보
|
||||
</button>
|
||||
<button @click="activeTab = 'security'"
|
||||
:class="activeTab === 'security' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-lock mr-2"></i>보안 설정
|
||||
</button>
|
||||
<button @click="activeTab = 'preferences'"
|
||||
:class="activeTab === 'preferences' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-cog mr-2"></i>환경 설정
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 정보 탭 -->
|
||||
<div x-show="activeTab === 'profile'" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-6">프로필 정보</h3>
|
||||
|
||||
<form @submit.prevent="updateProfile()" class="space-y-6">
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">이름</label>
|
||||
<input type="text" id="full_name" x-model="profileForm.full_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
|
||||
<input type="email" id="email" x-model="user.email" disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500">
|
||||
<p class="mt-1 text-sm text-gray-500">이메일은 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="profileLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="profileLoading"></i>
|
||||
<span x-text="profileLoading ? '저장 중...' : '프로필 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보안 설정 탭 -->
|
||||
<div x-show="activeTab === 'security'" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-6">비밀번호 변경</h3>
|
||||
|
||||
<form @submit.prevent="changePassword()" class="space-y-6">
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-2">현재 비밀번호</label>
|
||||
<input type="password" id="current_password" x-model="passwordForm.current_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호</label>
|
||||
<input type="password" id="new_password" x-model="passwordForm.new_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required minlength="6">
|
||||
<p class="mt-1 text-sm text-gray-500">최소 6자 이상 입력해주세요.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호 확인</label>
|
||||
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="passwordLoading || passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="passwordLoading"></i>
|
||||
<span x-text="passwordLoading ? '변경 중...' : '비밀번호 변경'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 환경 설정 탭 -->
|
||||
<div x-show="activeTab === 'preferences'" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-6">환경 설정</h3>
|
||||
|
||||
<form @submit.prevent="updatePreferences()" class="space-y-6">
|
||||
<div>
|
||||
<label for="theme" class="block text-sm font-medium text-gray-700 mb-2">테마</label>
|
||||
<select id="theme" x-model="preferencesForm.theme"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="light">라이트 모드</option>
|
||||
<option value="dark">다크 모드</option>
|
||||
<option value="auto">시스템 설정 따름</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700 mb-2">언어</label>
|
||||
<select id="language" x-model="preferencesForm.language"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="ko">한국어</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700 mb-2">시간대</label>
|
||||
<select id="timezone" x-model="preferencesForm.timezone"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="Asia/Seoul">서울 (UTC+9)</option>
|
||||
<option value="UTC">UTC (UTC+0)</option>
|
||||
<option value="America/New_York">뉴욕 (UTC-5)</option>
|
||||
<option value="Europe/London">런던 (UTC+0)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="preferencesLoading"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="preferencesLoading"></i>
|
||||
<span x-text="preferencesLoading ? '저장 중...' : '설정 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
|
||||
<!-- 프로필 관리 스크립트 -->
|
||||
<script>
|
||||
function profileApp() {
|
||||
return {
|
||||
user: {},
|
||||
activeTab: 'profile',
|
||||
profileLoading: false,
|
||||
passwordLoading: false,
|
||||
preferencesLoading: false,
|
||||
|
||||
profileForm: {
|
||||
full_name: ''
|
||||
},
|
||||
|
||||
passwordForm: {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
},
|
||||
|
||||
preferencesForm: {
|
||||
theme: 'light',
|
||||
language: 'ko',
|
||||
timezone: 'Asia/Seoul'
|
||||
},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 프로필 앱 초기화');
|
||||
await this.loadUserProfile();
|
||||
},
|
||||
|
||||
async loadUserProfile() {
|
||||
try {
|
||||
const response = await api.get('/users/me');
|
||||
this.user = response;
|
||||
|
||||
// 폼 데이터 초기화
|
||||
this.profileForm.full_name = response.full_name || '';
|
||||
this.preferencesForm.theme = response.theme || 'light';
|
||||
this.preferencesForm.language = response.language || 'ko';
|
||||
this.preferencesForm.timezone = response.timezone || 'Asia/Seoul';
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (window.updateUserMenu) {
|
||||
window.updateUserMenu(response);
|
||||
}
|
||||
|
||||
console.log('✅ 사용자 프로필 로드 완료:', response);
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 프로필 로드 실패:', error);
|
||||
this.showNotification('사용자 정보를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async updateProfile() {
|
||||
this.profileLoading = true;
|
||||
try {
|
||||
const response = await api.put('/users/me', this.profileForm);
|
||||
this.user = { ...this.user, ...response };
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (window.updateUserMenu) {
|
||||
window.updateUserMenu(this.user);
|
||||
}
|
||||
|
||||
this.showNotification('프로필이 성공적으로 업데이트되었습니다.', 'success');
|
||||
console.log('✅ 프로필 업데이트 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 프로필 업데이트 실패:', error);
|
||||
this.showNotification('프로필 업데이트에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.profileLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
this.showNotification('새 비밀번호가 일치하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.passwordLoading = true;
|
||||
try {
|
||||
await api.post('/users/me/change-password', {
|
||||
current_password: this.passwordForm.current_password,
|
||||
new_password: this.passwordForm.new_password
|
||||
});
|
||||
|
||||
// 폼 초기화
|
||||
this.passwordForm = {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
};
|
||||
|
||||
this.showNotification('비밀번호가 성공적으로 변경되었습니다.', 'success');
|
||||
console.log('✅ 비밀번호 변경 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 비밀번호 변경 실패:', error);
|
||||
this.showNotification('비밀번호 변경에 실패했습니다. 현재 비밀번호를 확인해주세요.', 'error');
|
||||
} finally {
|
||||
this.passwordLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async updatePreferences() {
|
||||
this.preferencesLoading = true;
|
||||
try {
|
||||
const response = await api.put('/users/me', this.preferencesForm);
|
||||
this.user = { ...this.user, ...response };
|
||||
|
||||
this.showNotification('환경 설정이 성공적으로 저장되었습니다.', 'success');
|
||||
console.log('✅ 환경 설정 업데이트 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 환경 설정 업데이트 실패:', error);
|
||||
this.showNotification('환경 설정 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.preferencesLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
type,
|
||||
message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
getRoleText(role) {
|
||||
const roleMap = {
|
||||
'root': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || '사용자';
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
740
frontend/search.html
Normal file
740
frontend/search.html
Normal file
@@ -0,0 +1,740 @@
|
||||
<!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.0.0/css/all.min.css">
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- PDF.js 라이브러리 -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
// PDF.js 워커 설정 (전역)
|
||||
if (typeof pdfjsLib !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.search-result-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.highlight-text {
|
||||
background: linear-gradient(120deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #92400e;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-filter-chip {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-filter-chip.active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.search-stats {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.result-type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-document {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-note {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-memo {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-highlight {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-highlight_note {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-document_content {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input-container:focus-within {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 18px;
|
||||
padding: 20px 60px 20px 24px;
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="searchApp()" x-init="init()" x-cloak>
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
<i class="fas fa-search text-blue-600 mr-3"></i>
|
||||
통합 검색
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600">문서, 노트, 메모를 한 번에 검색하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 검색 입력 -->
|
||||
<div class="max-w-4xl mx-auto mb-8">
|
||||
<form @submit.prevent="performSearch()">
|
||||
<div class="search-input-container">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
placeholder="검색어를 입력하세요..."
|
||||
class="search-input"
|
||||
@input="debounceSearch()"
|
||||
>
|
||||
<button type="submit" class="search-button" :disabled="loading">
|
||||
<i class="fas fa-search" :class="{'loading-spinner': loading}"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 검색 필터 -->
|
||||
<div class="max-w-4xl mx-auto mb-8" x-show="searchResults.length > 0 || hasSearched">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- 타입 필터 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700">타입:</span>
|
||||
<button
|
||||
@click="typeFilter = ''"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === '' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'document'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'document' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-file-alt mr-1"></i>문서
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'note'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-sticky-note mr-1"></i>노트
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'memo'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'memo' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-tree mr-1"></i>메모
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'highlight'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'highlight' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-highlighter mr-1"></i>하이라이트
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'highlight_note'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'highlight_note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-comment mr-1"></i>메모
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'document_content'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'document_content' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-file-text mr-1"></i>본문
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 파일 타입 필터 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700">파일 타입:</span>
|
||||
<button
|
||||
@click="fileTypeFilter = fileTypeFilter === 'PDF' ? '' : 'PDF'; applyFilters()"
|
||||
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
|
||||
:class="fileTypeFilter === 'PDF' ? 'bg-red-100 text-red-800 border-red-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-file-pdf mr-1"></i>PDF
|
||||
</button>
|
||||
<button
|
||||
@click="fileTypeFilter = fileTypeFilter === 'HTML' ? '' : 'HTML'; applyFilters()"
|
||||
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
|
||||
:class="fileTypeFilter === 'HTML' ? 'bg-orange-100 text-orange-800 border-orange-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-code mr-1"></i>HTML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 정렬 -->
|
||||
<div class="flex items-center space-x-2 ml-auto">
|
||||
<span class="text-sm font-medium text-gray-700">정렬:</span>
|
||||
<select x-model="sortBy" @change="applyFilters()"
|
||||
class="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="relevance">관련도순</option>
|
||||
<option value="date_desc">최신순</option>
|
||||
<option value="date_asc">오래된순</option>
|
||||
<option value="title">제목순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 통계 -->
|
||||
<div class="max-w-4xl mx-auto mb-6" x-show="searchResults.length > 0">
|
||||
<div class="search-stats rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
<strong x-text="filteredResults.length"></strong>개 결과
|
||||
<span x-show="searchQuery" class="text-gray-500">
|
||||
"<span x-text="searchQuery"></span>" 검색
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-500">
|
||||
<span x-show="getResultCount('document') > 0">
|
||||
📄 문서 <strong x-text="getResultCount('document')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('note') > 0">
|
||||
📝 노트 <strong x-text="getResultCount('note')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('memo') > 0">
|
||||
🌳 메모 <strong x-text="getResultCount('memo')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('highlight') > 0">
|
||||
🖍️ 하이라이트 <strong x-text="getResultCount('highlight')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('highlight_note') > 0">
|
||||
💬 메모 <strong x-text="getResultCount('highlight_note')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('document_content') > 0">
|
||||
📖 본문 <strong x-text="getResultCount('document_content')"></strong>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span x-text="searchTime"></span>ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="max-w-4xl mx-auto text-center py-12">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-blue-600 mb-4"></i>
|
||||
<p class="text-gray-600">검색 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div x-show="!loading && filteredResults.length > 0" class="max-w-4xl mx-auto space-y-4">
|
||||
<template x-for="result in filteredResults" :key="result.unique_id || result.id">
|
||||
<div class="search-result-card bg-white rounded-lg shadow-sm border p-6 fade-in">
|
||||
<!-- 결과 헤더 -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="result-type-badge"
|
||||
:class="`badge-${result.type}`"
|
||||
x-text="getTypeLabel(result.type)"></span>
|
||||
|
||||
<!-- 파일 타입 정보 (PDF/HTML) -->
|
||||
<span x-show="result.highlight_info?.file_type"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="{
|
||||
'bg-red-100 text-red-800': result.highlight_info?.file_type === 'PDF',
|
||||
'bg-orange-100 text-orange-800': result.highlight_info?.file_type === 'HTML'
|
||||
}">
|
||||
<i class="fas mr-1"
|
||||
:class="{
|
||||
'fa-file-pdf': result.highlight_info?.file_type === 'PDF',
|
||||
'fa-code': result.highlight_info?.file_type === 'HTML'
|
||||
}"></i>
|
||||
<span x-text="result.highlight_info?.file_type"></span>
|
||||
</span>
|
||||
|
||||
<!-- 매치 개수 -->
|
||||
<span x-show="result.highlight_info && result.highlight_info.match_count > 0"
|
||||
class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
<i class="fas fa-search mr-1"></i>
|
||||
<span x-text="result.highlight_info?.match_count || 0"></span>개 매치
|
||||
</span>
|
||||
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(result.created_at)"></span>
|
||||
<div x-show="result.relevance_score > 0" class="flex items-center text-xs text-gray-500">
|
||||
<i class="fas fa-star text-yellow-500 mr-1"></i>
|
||||
<span x-text="Math.round(result.relevance_score * 100) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2 cursor-pointer hover:text-blue-600"
|
||||
@click="openResult(result)"
|
||||
x-html="highlightText(result.title, searchQuery)"></h3>
|
||||
<p class="text-sm text-gray-600 mb-2" x-text="result.document_title"></p>
|
||||
</div>
|
||||
<div class="ml-4 flex space-x-2">
|
||||
<button @click="showPreview(result)"
|
||||
class="px-3 py-1 bg-gray-600 text-white rounded-lg text-sm hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i>미리보기
|
||||
</button>
|
||||
<button @click="openResult(result)"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>열기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 내용 -->
|
||||
<div class="text-gray-700 text-sm leading-relaxed"
|
||||
x-html="highlightText(truncateText(result.content, 200), searchQuery)"></div>
|
||||
|
||||
<!-- 하이라이트 정보 -->
|
||||
<div x-show="result.type === 'highlight' && result.highlight_info" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="text-xs text-yellow-800 mb-1">
|
||||
<i class="fas fa-highlighter mr-1"></i>하이라이트 정보
|
||||
</div>
|
||||
<div class="text-sm text-yellow-900" x-show="result.highlight_info?.selected_text">
|
||||
"<span x-text="result.highlight_info?.selected_text"></span>"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 검색 결과 -->
|
||||
<div x-show="!loading && hasSearched && filteredResults.length === 0" class="max-w-4xl mx-auto">
|
||||
<div class="empty-state text-center py-16">
|
||||
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색 결과가 없습니다</h3>
|
||||
<p class="text-gray-500 mb-6">
|
||||
<span x-show="searchQuery">
|
||||
"<span x-text="searchQuery"></span>"에 대한 결과를 찾을 수 없습니다.
|
||||
</span>
|
||||
<span x-show="!searchQuery">검색어를 입력해주세요.</span>
|
||||
</p>
|
||||
<div class="text-sm text-gray-500">
|
||||
<p class="mb-2">검색 팁:</p>
|
||||
<ul class="text-left inline-block space-y-1">
|
||||
<li>• 다른 키워드로 검색해보세요</li>
|
||||
<li>• 검색어를 줄여보세요</li>
|
||||
<li>• 필터를 변경해보세요</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 초기 상태 -->
|
||||
<div x-show="!loading && !hasSearched" class="max-w-4xl mx-auto">
|
||||
<div class="text-center py-16">
|
||||
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색을 시작하세요</h3>
|
||||
<p class="text-gray-500 mb-8">문서, 노트, 메모, 하이라이트를 통합 검색할 수 있습니다</p>
|
||||
|
||||
<!-- 빠른 검색 예시 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 max-w-2xl mx-auto">
|
||||
<button @click="searchQuery = '설계'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-blue-500 hover:bg-blue-50 transition-colors">
|
||||
<i class="fas fa-drafting-compass text-blue-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">설계</div>
|
||||
</button>
|
||||
<button @click="searchQuery = '연구'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-green-500 hover:bg-green-50 transition-colors">
|
||||
<i class="fas fa-flask text-green-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">연구</div>
|
||||
</button>
|
||||
<button @click="searchQuery = '프로젝트'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-purple-500 hover:bg-purple-50 transition-colors">
|
||||
<i class="fas fa-project-diagram text-purple-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">프로젝트</div>
|
||||
</button>
|
||||
<button @click="searchQuery = '분석'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-orange-500 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-chart-line text-orange-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">분석</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 모달 -->
|
||||
<div x-show="showPreviewModal"
|
||||
@keydown.escape.window="closePreview()"
|
||||
@click.self="closePreview()"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95">
|
||||
|
||||
<!-- 모달 헤더 -->
|
||||
<div class="flex items-center justify-between p-6 border-b">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="result-type-badge"
|
||||
:class="`badge-${previewResult?.type}`"
|
||||
x-text="getTypeLabel(previewResult?.type)"></span>
|
||||
<span class="text-sm text-gray-500" x-text="formatDate(previewResult?.created_at)"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900" x-text="previewResult?.title"></h3>
|
||||
<p class="text-sm text-gray-600" x-text="previewResult?.document_title"></p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="openResult(previewResult)"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-external-link-alt mr-2"></i>열기
|
||||
</button>
|
||||
<button @click="closePreview()"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달 내용 -->
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<!-- PDF 미리보기 (데본씽크 스타일) -->
|
||||
<div x-show="(previewResult?.type === 'document_content' || previewResult?.type === 'document') && previewResult?.highlight_info?.has_pdf"
|
||||
class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-sm font-medium text-gray-800">
|
||||
<i class="fas fa-file-pdf mr-2 text-red-600"></i>PDF 미리보기
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="searchInPdf()"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700">
|
||||
<i class="fas fa-search mr-1"></i>PDF에서 검색
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 뷰어 컨테이너 -->
|
||||
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 500px;">
|
||||
<!-- PDF iframe 뷰어 -->
|
||||
<iframe id="pdf-preview-iframe"
|
||||
x-show="!pdfError && !pdfLoading"
|
||||
class="w-full h-full border-0"
|
||||
:src="pdfSrc"
|
||||
@load="pdfLoaded = true"
|
||||
@error="handlePdfError()">
|
||||
</iframe>
|
||||
|
||||
<!-- PDF 로딩 상태 -->
|
||||
<div x-show="pdfLoading" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-500 mb-2"></i>
|
||||
<p class="text-gray-600">PDF를 로드하는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="pdfError" class="flex items-center justify-center h-full text-gray-500">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||||
<p>PDF를 로드할 수 없습니다</p>
|
||||
<button @click="openResult(previewResult)"
|
||||
class="mt-2 px-3 py-1 bg-blue-600 text-white rounded text-sm">
|
||||
뷰어에서 열기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML 문서 미리보기 -->
|
||||
<div x-show="(previewResult?.type === 'document' || previewResult?.type === 'document_content') && !previewResult?.highlight_info?.has_pdf"
|
||||
class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-sm font-medium text-gray-800">
|
||||
<i class="fas fa-code mr-2 text-green-600"></i>HTML 문서 미리보기
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="toggleHtmlRaw()"
|
||||
class="px-3 py-1 bg-gray-600 text-white rounded text-xs hover:bg-gray-700">
|
||||
<i class="fas fa-code mr-1"></i><span x-text="htmlRawMode ? '렌더링' : '소스'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg overflow-hidden bg-white relative" style="height: 500px;">
|
||||
<!-- HTML 렌더링 뷰 -->
|
||||
<iframe id="htmlPreviewFrame"
|
||||
x-show="!htmlRawMode && !htmlLoading"
|
||||
class="w-full h-full border-0"
|
||||
sandbox="allow-same-origin">
|
||||
</iframe>
|
||||
|
||||
<!-- HTML 소스 뷰 -->
|
||||
<div x-show="htmlRawMode && !htmlLoading"
|
||||
class="w-full h-full overflow-auto p-4 bg-gray-900 text-green-400 font-mono text-sm">
|
||||
<pre x-html="htmlSourceCode"></pre>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="htmlLoading" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-500 mb-2"></i>
|
||||
<p class="text-gray-600">HTML을 로드하는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메모 트리 노드 미리보기 -->
|
||||
<div x-show="previewResult?.type === 'memo'"
|
||||
class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-sm font-medium text-gray-800">
|
||||
<i class="fas fa-tree mr-2 text-purple-600"></i>메모 노드 미리보기
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-600" x-text="previewResult?.document_title"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg overflow-hidden bg-white" style="height: 400px;">
|
||||
<div class="h-full overflow-auto p-6">
|
||||
<!-- 메모 제목 -->
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4" x-text="previewResult?.title"></h3>
|
||||
|
||||
<!-- 메모 내용 -->
|
||||
<div class="prose max-w-none">
|
||||
<div class="text-gray-700 leading-relaxed whitespace-pre-wrap"
|
||||
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 문서 미리보기 -->
|
||||
<div x-show="previewResult?.type === 'note'"
|
||||
class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-sm font-medium text-gray-800">
|
||||
<i class="fas fa-sticky-note mr-2 text-blue-600"></i>노트 미리보기
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="toggleNoteEdit()"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700">
|
||||
<i class="fas fa-edit mr-1"></i>편집기에서 열기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg overflow-hidden bg-white" style="height: 450px;">
|
||||
<div class="h-full overflow-auto">
|
||||
<!-- 노트 헤더 -->
|
||||
<div class="p-4 border-b bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="previewResult?.title"></h3>
|
||||
<div class="text-sm text-gray-600 mt-1">
|
||||
<span x-text="formatDate(previewResult?.created_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 내용 -->
|
||||
<div class="p-6">
|
||||
<div class="prose max-w-none">
|
||||
<div class="text-gray-700 leading-relaxed"
|
||||
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하이라이트 정보 -->
|
||||
<div x-show="previewResult?.type === 'highlight' && previewResult?.highlight_info"
|
||||
class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="text-sm font-medium text-yellow-800 mb-2">
|
||||
<i class="fas fa-highlighter mr-2"></i>하이라이트된 텍스트
|
||||
</div>
|
||||
<div class="text-yellow-900 font-medium mb-2"
|
||||
x-text="previewResult?.highlight_info?.selected_text"></div>
|
||||
<div x-show="previewResult?.highlight_info?.note_content" class="text-sm text-yellow-800">
|
||||
<strong>메모:</strong> <span x-text="previewResult?.highlight_info?.note_content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메모 내용 -->
|
||||
<div x-show="previewResult?.type === 'highlight_note' && previewResult?.highlight_info"
|
||||
class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div class="text-sm font-medium text-blue-800 mb-2">
|
||||
<i class="fas fa-quote-left mr-2"></i>원본 하이라이트
|
||||
</div>
|
||||
<div class="text-blue-900 mb-2" x-text="previewResult?.highlight_info?.selected_text"></div>
|
||||
<div class="text-sm font-medium text-blue-800 mb-1">메모 내용:</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 검색 결과 정보 -->
|
||||
<div x-show="previewResult?.type === 'document_content' && previewResult?.highlight_info"
|
||||
class="mb-4 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-sm font-medium text-gray-800">
|
||||
<i class="fas fa-search mr-2"></i>본문 검색 결과
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
<span x-text="previewResult?.highlight_info?.file_type"></span>
|
||||
• <span x-text="previewResult?.highlight_info?.match_count"></span>개 매치
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 내용 (PDF/HTML이 아닌 경우) -->
|
||||
<div x-show="!previewResult?.highlight_info?.has_pdf && !previewResult?.highlight_info?.has_html"
|
||||
class="prose max-w-none">
|
||||
<div class="text-gray-700 leading-relaxed"
|
||||
style="white-space: pre-wrap; word-wrap: break-word; max-height: 400px; overflow-y: auto;"
|
||||
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 텍스트 내용 (fallback) -->
|
||||
<div x-show="!previewResult?.highlight_info?.has_pdf && !previewResult?.highlight_info?.has_html && (!previewResult?.content || previewResult?.content.length < 10)"
|
||||
class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-file-alt text-3xl mb-3"></i>
|
||||
<p>미리보기할 수 있는 내용이 없습니다.</p>
|
||||
<button @click="openResult(previewResult)"
|
||||
class="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
원본에서 보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 추가 정보 -->
|
||||
<div x-show="previewResult?.type === 'memo'" class="mt-4 p-3 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<div class="text-sm font-medium text-purple-800 mb-1">
|
||||
<i class="fas fa-tree mr-2"></i>메모 트리 정보
|
||||
</div>
|
||||
<div class="text-purple-700 text-sm" x-text="previewResult?.document_title"></div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="previewLoading" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600">내용을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/search.js?v=2025012610"></script>
|
||||
</body>
|
||||
</html>
|
||||
274
frontend/setup.html
Normal file
274
frontend/setup.html
Normal file
@@ -0,0 +1,274 @@
|
||||
<!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>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen" x-data="setupApp()">
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- 로고 및 제목 -->
|
||||
<div class="text-center">
|
||||
<div class="mx-auto h-20 w-20 bg-blue-600 rounded-full flex items-center justify-center mb-6">
|
||||
<i class="fas fa-book text-white text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">Document Server</h2>
|
||||
<p class="text-gray-600">시스템 초기 설정</p>
|
||||
</div>
|
||||
|
||||
<!-- 설정 상태 확인 중 -->
|
||||
<div x-show="loading" class="text-center">
|
||||
<div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-blue-600 bg-white">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
시스템 상태 확인 중...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이미 설정된 시스템 -->
|
||||
<div x-show="!loading && !setupRequired" class="bg-white rounded-lg shadow-md p-8 text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">시스템이 이미 설정되었습니다</h3>
|
||||
<p class="text-gray-600 mb-6">Document Server가 정상적으로 구성되어 있습니다.</p>
|
||||
<a href="index.html" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 초기 설정 폼 -->
|
||||
<div x-show="!loading && setupRequired" class="bg-white rounded-lg shadow-md p-8">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">관리자 계정 생성</h3>
|
||||
<p class="text-gray-600">시스템 관리자(Root) 계정을 생성해주세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="initializeSystem()" class="space-y-6">
|
||||
<div>
|
||||
<label for="admin_email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-envelope mr-1"></i>관리자 이메일
|
||||
</label>
|
||||
<input type="email" id="admin_email" x-model="setupForm.admin_email" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="admin@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-lock mr-1"></i>관리자 비밀번호
|
||||
</label>
|
||||
<input type="password" id="admin_password" x-model="setupForm.admin_password" required minlength="6"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="최소 6자 이상">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-lock mr-1"></i>비밀번호 확인
|
||||
</label>
|
||||
<input type="password" id="confirm_password" x-model="confirmPassword" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="비밀번호를 다시 입력하세요">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admin_full_name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-user mr-1"></i>관리자 이름 (선택사항)
|
||||
</label>
|
||||
<input type="text" id="admin_full_name" x-model="setupForm.admin_full_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="시스템 관리자">
|
||||
</div>
|
||||
|
||||
<!-- 주의사항 -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
|
||||
<div class="text-sm text-yellow-800">
|
||||
<p class="font-medium mb-1">주의사항:</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>이 계정은 시스템의 최고 관리자 권한을 가집니다.</li>
|
||||
<li>안전한 비밀번호를 사용하고 잘 보관해주세요.</li>
|
||||
<li>설정 완료 후에는 이 페이지에 다시 접근할 수 없습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="setupLoading || setupForm.admin_password !== confirmPassword"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="setupLoading"></i>
|
||||
<i class="fas fa-rocket mr-2" x-show="!setupLoading"></i>
|
||||
<span x-text="setupLoading ? '설정 중...' : '시스템 초기화'"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 설정 완료 -->
|
||||
<div x-show="setupComplete" class="bg-white rounded-lg shadow-md p-8 text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">설정이 완료되었습니다!</h3>
|
||||
<p class="text-gray-600 mb-6">Document Server가 성공적으로 초기화되었습니다.</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="text-sm text-blue-800">
|
||||
<p class="font-medium mb-2">생성된 관리자 계정:</p>
|
||||
<p><strong>이메일:</strong> <span x-text="createdAdmin.email"></span></p>
|
||||
<p><strong>이름:</strong> <span x-text="createdAdmin.full_name"></span></p>
|
||||
<p><strong>역할:</strong> 시스템 관리자</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="index.html" class="w-full inline-flex justify-center items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
|
||||
</a>
|
||||
<button @click="goToLogin()" class="w-full inline-flex justify-center items-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i>로그인하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 스크립트 -->
|
||||
<script>
|
||||
// 간단한 API 클라이언트
|
||||
const setupApi = {
|
||||
async get(endpoint) {
|
||||
const response = await fetch(`/api${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async post(endpoint, data) {
|
||||
const response = await fetch(`/api${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- 설정 앱 스크립트 -->
|
||||
<script>
|
||||
function setupApp() {
|
||||
return {
|
||||
loading: true,
|
||||
setupRequired: false,
|
||||
setupComplete: false,
|
||||
setupLoading: false,
|
||||
confirmPassword: '',
|
||||
createdAdmin: {},
|
||||
|
||||
setupForm: {
|
||||
admin_email: '',
|
||||
admin_password: '',
|
||||
admin_full_name: ''
|
||||
},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 설정 앱 초기화');
|
||||
await this.checkSetupStatus();
|
||||
},
|
||||
|
||||
async checkSetupStatus() {
|
||||
try {
|
||||
const status = await setupApi.get('/setup/status');
|
||||
this.setupRequired = status.is_setup_required;
|
||||
|
||||
console.log('✅ 설정 상태 확인 완료:', status);
|
||||
} catch (error) {
|
||||
console.error('❌ 설정 상태 확인 실패:', error);
|
||||
this.showNotification('시스템 상태를 확인할 수 없습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async initializeSystem() {
|
||||
if (this.setupForm.admin_password !== this.confirmPassword) {
|
||||
this.showNotification('비밀번호가 일치하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupLoading = true;
|
||||
try {
|
||||
const result = await setupApi.post('/setup/initialize', this.setupForm);
|
||||
|
||||
this.createdAdmin = result.admin_user;
|
||||
this.setupComplete = true;
|
||||
this.setupRequired = false;
|
||||
|
||||
console.log('✅ 시스템 초기화 완료:', result);
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 초기화 실패:', error);
|
||||
this.showNotification(error.message || '시스템 초기화에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.setupLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
goToLogin() {
|
||||
// 로그인 모달을 열거나 로그인 페이지로 이동
|
||||
window.location.href = 'index.html';
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
type,
|
||||
message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
270
frontend/static/css/main.css
Normal file
270
frontend/static/css/main.css
Normal file
@@ -0,0 +1,270 @@
|
||||
/* 메인 스타일 */
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
padding-top: 4rem; /* 고정 헤더를 위한 패딩 */
|
||||
}
|
||||
|
||||
/* 알림 애니메이션 */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
41
frontend/static/images/README.md
Normal file
41
frontend/static/images/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 로그인 페이지 이미지
|
||||
|
||||
이 폴더에는 로그인 페이지에서 사용되는 배경 이미지를 저장합니다.
|
||||
|
||||
## 필요한 이미지 파일
|
||||
|
||||
### 배경 이미지
|
||||
- `login-bg.jpg` - 전체 페이지 배경 이미지 (권장 크기: 1920x1080px 이상)
|
||||
|
||||
## 이미지 사양
|
||||
|
||||
- **형식**: JPG, PNG 지원
|
||||
- **품질**: 웹 최적화된 고품질 이미지
|
||||
- **용량**: 1MB 이하 권장
|
||||
- **비율**: 16:9 또는 16:10 비율 권장
|
||||
- **색상**: 어두운 톤 또는 블러 처리된 이미지 권장 (텍스트 가독성을 위해)
|
||||
|
||||
## 폴백 동작
|
||||
|
||||
배경 이미지 파일이 없는 경우:
|
||||
- 파란색-보라색 그라디언트 배경으로 자동 폴백
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```
|
||||
static/images/
|
||||
└── login-bg.jpg (전체 배경)
|
||||
```
|
||||
|
||||
## 배경 이미지 선택 가이드
|
||||
|
||||
- **문서/도서관 테마**: 책장, 도서관, 서재 등
|
||||
- **기술/현대적 테마**: 추상적 패턴, 기하학적 형태
|
||||
- **자연 테마**: 차분한 풍경, 블러 처리된 자연 이미지
|
||||
- **미니멀 테마**: 단순한 패턴, 텍스처
|
||||
|
||||
## 변경 사항 (v2.0)
|
||||
|
||||
- 갤러리 액자 기능 제거
|
||||
- 중앙 집중형 로그인 레이아웃으로 변경
|
||||
- 배경 이미지만 사용하는 심플한 디자인
|
||||
BIN
frontend/static/images/login-bg-2.jpg
Normal file
BIN
frontend/static/images/login-bg-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
BIN
frontend/static/images/login-bg-3.jpg
Normal file
BIN
frontend/static/images/login-bg-3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 KiB |
BIN
frontend/static/images/login-bg.jpg
Normal file
BIN
frontend/static/images/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 690 KiB |
750
frontend/static/js/api.js
Normal file
750
frontend/static/js/api.js
Normal file
@@ -0,0 +1,750 @@
|
||||
/**
|
||||
* API 통신 유틸리티
|
||||
*/
|
||||
class DocumentServerAPI {
|
||||
constructor() {
|
||||
// nginx 프록시를 통한 API 호출 (절대 경로로 강제)
|
||||
this.baseURL = `${window.location.origin}/api`;
|
||||
this.token = localStorage.getItem('access_token');
|
||||
|
||||
console.log('🌐 API Base URL (NGINX PROXY):', this.baseURL);
|
||||
console.log('🔧 현재 브라우저 위치:', window.location.origin);
|
||||
console.log('🔧 현재 브라우저 전체 URL:', window.location.href);
|
||||
console.log('🔧 nginx 프록시 환경 설정 완료 - 상대 경로 사용');
|
||||
}
|
||||
|
||||
// 토큰 설정
|
||||
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 = {}) {
|
||||
// URL 생성 시 포트 유지를 위해 단순 문자열 연결 사용
|
||||
let url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
if (Object.keys(params).length > 0) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== null && params[key] !== undefined) {
|
||||
searchParams.append(key, params[key]);
|
||||
}
|
||||
});
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// POST 요청
|
||||
async post(endpoint, data = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
console.log('🌐 POST 요청 시작');
|
||||
console.log(' - baseURL:', this.baseURL);
|
||||
console.log(' - endpoint:', endpoint);
|
||||
console.log(' - 최종 URL:', url);
|
||||
console.log(' - 데이터:', data);
|
||||
|
||||
console.log('🔍 fetch 호출 직전 URL 검증:', url);
|
||||
console.log('🔍 URL 타입:', typeof url);
|
||||
console.log('🔍 URL 절대/상대 여부:', url.startsWith('http') ? '절대경로' : '상대경로');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
console.log('📡 POST 응답 받음:', response.url, response.status);
|
||||
console.log('📡 실제 요청된 URL:', response.url);
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// PUT 요청
|
||||
async put(endpoint, data = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
console.log('🌐 PUT 요청 URL:', url); // 디버깅용
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// DELETE 요청
|
||||
async delete(endpoint) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
console.log('🌐 DELETE 요청 URL:', url); // 디버깅용
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// 파일 업로드
|
||||
async uploadFile(endpoint, formData) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
console.log('🌐 UPLOAD 요청 URL:', url); // 디버깅용
|
||||
|
||||
const headers = {};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
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) {
|
||||
const response = await this.post('/auth/login', { email, password });
|
||||
|
||||
// 토큰 저장
|
||||
if (response.access_token) {
|
||||
this.setToken(response.access_token);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
return {
|
||||
success: true,
|
||||
user: user,
|
||||
token: response.access_token
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: '사용자 정보를 가져올 수 없습니다.'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: '로그인에 실패했습니다.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 getDocumentsHierarchy() {
|
||||
return await this.get('/documents/hierarchy/structured');
|
||||
}
|
||||
|
||||
async getDocument(documentId) {
|
||||
return await this.get(`/documents/${documentId}`);
|
||||
}
|
||||
|
||||
async getDocumentContent(documentId) {
|
||||
return await this.get(`/documents/${documentId}/content`);
|
||||
}
|
||||
|
||||
async uploadDocument(formData) {
|
||||
return await this.uploadFile('/documents/', formData);
|
||||
}
|
||||
|
||||
async updateDocument(documentId, updateData) {
|
||||
return await this.put(`/documents/${documentId}`, updateData);
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('🎨 createHighlight 메서드 호출됨:', highlightData);
|
||||
return await this.post('/highlights/', highlightData);
|
||||
}
|
||||
|
||||
async getDocumentHighlights(documentId) {
|
||||
return await this.get(`/highlights/document/${documentId}`);
|
||||
}
|
||||
|
||||
async updateHighlight(highlightId, updateData) {
|
||||
return await this.put(`/highlights/${highlightId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteHighlight(highlightId) {
|
||||
return await this.delete(`/highlights/${highlightId}`);
|
||||
}
|
||||
|
||||
// === 하이라이트 메모 (Highlight Memo) 관련 API ===
|
||||
// 용어 정의: 하이라이트에 달리는 짧은 코멘트
|
||||
async createNote(noteData) {
|
||||
return await this.post('/highlight-notes/', noteData);
|
||||
}
|
||||
|
||||
async getNotes(params = {}) {
|
||||
return await this.get('/highlight-notes/', params);
|
||||
}
|
||||
|
||||
async updateNote(noteId, updateData) {
|
||||
return await this.put(`/highlight-notes/${noteId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteNote(noteId) {
|
||||
return await this.delete(`/highlight-notes/${noteId}`);
|
||||
}
|
||||
|
||||
// === 문서 메모 조회 ===
|
||||
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
|
||||
async getDocumentNotes(documentId) {
|
||||
return await this.get(`/highlight-notes/`, { document_id: documentId });
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 ===
|
||||
async getDocumentHighlights(documentId) {
|
||||
return await this.get(`/highlights/document/${documentId}`);
|
||||
}
|
||||
|
||||
async createHighlight(highlightData) {
|
||||
console.log('🎨 createHighlight 메서드 호출됨:', highlightData);
|
||||
return await this.post('/highlights/', highlightData);
|
||||
}
|
||||
|
||||
async updateHighlight(highlightId, highlightData) {
|
||||
return await this.put(`/highlights/${highlightId}`, highlightData);
|
||||
}
|
||||
|
||||
async deleteHighlight(highlightId) {
|
||||
return await this.delete(`/highlights/${highlightId}`);
|
||||
}
|
||||
|
||||
// === 메모 관련 API ===
|
||||
// === 문서 메모 조회 ===
|
||||
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
|
||||
async getDocumentNotes(documentId) {
|
||||
return await this.get(`/notes/document/${documentId}`);
|
||||
}
|
||||
|
||||
async createNote(noteData) {
|
||||
return await this.post('/notes/', noteData);
|
||||
}
|
||||
|
||||
async deleteNote(noteId) {
|
||||
return await this.delete(`/notes/${noteId}`);
|
||||
}
|
||||
|
||||
async getNotesByHighlight(highlightId) {
|
||||
return await this.get(`/notes/highlight/${highlightId}`);
|
||||
}
|
||||
|
||||
// === 책갈피 관련 API ===
|
||||
async getDocumentBookmarks(documentId) {
|
||||
return await this.get(`/bookmarks/document/${documentId}`);
|
||||
}
|
||||
|
||||
async createBookmark(bookmarkData) {
|
||||
return await this.post('/bookmarks/', bookmarkData);
|
||||
}
|
||||
|
||||
async updateBookmark(bookmarkId, bookmarkData) {
|
||||
return await this.put(`/bookmarks/${bookmarkId}`, bookmarkData);
|
||||
}
|
||||
|
||||
async deleteBookmark(bookmarkId) {
|
||||
return await this.delete(`/bookmarks/${bookmarkId}`);
|
||||
}
|
||||
|
||||
// === 검색 관련 API ===
|
||||
async searchDocuments(query, filters = {}) {
|
||||
const params = new URLSearchParams({ q: query, ...filters });
|
||||
return await this.get(`/search/documents?${params}`);
|
||||
}
|
||||
|
||||
async searchNotes(query, documentId = null) {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (documentId) params.append('document_id', documentId);
|
||||
return await this.get(`/search/notes?${params}`);
|
||||
}
|
||||
|
||||
// === 서적 관련 API ===
|
||||
async getBooks(skip = 0, limit = 50, search = null) {
|
||||
const params = new URLSearchParams({ skip, limit });
|
||||
if (search) params.append('search', search);
|
||||
return await this.get(`/books?${params}`);
|
||||
}
|
||||
|
||||
async createBook(bookData) {
|
||||
return await this.post('/books', bookData);
|
||||
}
|
||||
|
||||
async getBook(bookId) {
|
||||
return await this.get(`/books/${bookId}`);
|
||||
}
|
||||
|
||||
async updateBook(bookId, bookData) {
|
||||
return await this.put(`/books/${bookId}`, bookData);
|
||||
}
|
||||
|
||||
// 문서 네비게이션 정보 조회
|
||||
async getDocumentNavigation(documentId) {
|
||||
return await this.get(`/documents/${documentId}/navigation`);
|
||||
}
|
||||
|
||||
async searchBooks(query, limit = 10) {
|
||||
const params = new URLSearchParams({ q: query, limit });
|
||||
return await this.get(`/books/search/?${params}`);
|
||||
}
|
||||
|
||||
async getBookSuggestions(title, limit = 5) {
|
||||
const params = new URLSearchParams({ title, limit });
|
||||
return await this.get(`/books/suggestions/?${params}`);
|
||||
}
|
||||
|
||||
// === 서적 소분류 관련 API ===
|
||||
async createBookCategory(categoryData) {
|
||||
return await this.post('/book-categories/', categoryData);
|
||||
}
|
||||
|
||||
async getBookCategories(bookId) {
|
||||
return await this.get(`/book-categories/book/${bookId}`);
|
||||
}
|
||||
|
||||
async updateBookCategory(categoryId, categoryData) {
|
||||
return await this.put(`/book-categories/${categoryId}`, categoryData);
|
||||
}
|
||||
|
||||
async deleteBookCategory(categoryId) {
|
||||
return await this.delete(`/book-categories/${categoryId}`);
|
||||
}
|
||||
|
||||
async updateDocumentOrder(orderData) {
|
||||
return await this.put('/book-categories/documents/reorder', orderData);
|
||||
}
|
||||
|
||||
// === 하이라이트 관련 API ===
|
||||
async getDocumentHighlights(documentId) {
|
||||
return await this.get(`/highlights/document/${documentId}`);
|
||||
}
|
||||
|
||||
async createHighlight(highlightData) {
|
||||
console.log('🎨 createHighlight 메서드 호출됨:', highlightData);
|
||||
return await this.post('/highlights/', highlightData);
|
||||
}
|
||||
|
||||
async updateHighlight(highlightId, highlightData) {
|
||||
return await this.put(`/highlights/${highlightId}`, highlightData);
|
||||
}
|
||||
|
||||
async deleteHighlight(highlightId) {
|
||||
return await this.delete(`/highlights/${highlightId}`);
|
||||
}
|
||||
|
||||
// === 메모 관련 API ===
|
||||
// === 문서 메모 조회 ===
|
||||
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
|
||||
async getDocumentNotes(documentId) {
|
||||
return await this.get(`/notes/document/${documentId}`);
|
||||
}
|
||||
|
||||
async createNote(noteData) {
|
||||
return await this.post('/notes/', noteData);
|
||||
}
|
||||
|
||||
async deleteNote(noteId) {
|
||||
return await this.delete(`/notes/${noteId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 트리 메모장 API
|
||||
// ============================================================================
|
||||
|
||||
// 메모 트리 관리
|
||||
async getUserMemoTrees(includeArchived = false) {
|
||||
const params = includeArchived ? '?include_archived=true' : '';
|
||||
return await this.get(`/memo-trees/${params}`);
|
||||
}
|
||||
|
||||
async createMemoTree(treeData) {
|
||||
return await this.post('/memo-trees/', treeData);
|
||||
}
|
||||
|
||||
async getMemoTree(treeId) {
|
||||
return await this.get(`/memo-trees/${treeId}`);
|
||||
}
|
||||
|
||||
async updateMemoTree(treeId, treeData) {
|
||||
return await this.put(`/memo-trees/${treeId}`, treeData);
|
||||
}
|
||||
|
||||
async deleteMemoTree(treeId) {
|
||||
return await this.delete(`/memo-trees/${treeId}`);
|
||||
}
|
||||
|
||||
// 메모 노드 관리
|
||||
async getMemoTreeNodes(treeId) {
|
||||
return await this.get(`/memo-trees/${treeId}/nodes`);
|
||||
}
|
||||
|
||||
async createMemoNode(nodeData) {
|
||||
return await this.post(`/memo-trees/${nodeData.tree_id}/nodes`, nodeData);
|
||||
}
|
||||
|
||||
async getMemoNode(nodeId) {
|
||||
return await this.get(`/memo-trees/nodes/${nodeId}`);
|
||||
}
|
||||
|
||||
async updateMemoNode(nodeId, nodeData) {
|
||||
return await this.put(`/memo-trees/nodes/${nodeId}`, nodeData);
|
||||
}
|
||||
|
||||
async deleteMemoNode(nodeId) {
|
||||
return await this.delete(`/memo-trees/nodes/${nodeId}`);
|
||||
}
|
||||
|
||||
// 노드 이동
|
||||
async moveMemoNode(nodeId, moveData) {
|
||||
return await this.put(`/memo-trees/nodes/${nodeId}/move`, moveData);
|
||||
}
|
||||
|
||||
// 트리 통계
|
||||
async getMemoTreeStats(treeId) {
|
||||
return await this.get(`/memo-trees/${treeId}/stats`);
|
||||
}
|
||||
|
||||
// 검색
|
||||
async searchMemoNodes(searchData) {
|
||||
return await this.post('/memo-trees/search', searchData);
|
||||
}
|
||||
|
||||
// 내보내기
|
||||
async exportMemoTree(exportData) {
|
||||
return await this.post('/memo-trees/export', exportData);
|
||||
}
|
||||
|
||||
// 문서 링크 관련 API
|
||||
async createDocumentLink(documentId, linkData) {
|
||||
return await this.post(`/documents/${documentId}/links`, linkData);
|
||||
}
|
||||
|
||||
async getDocumentLinks(documentId) {
|
||||
return await this.get(`/documents/${documentId}/links`);
|
||||
}
|
||||
|
||||
async getLinkableDocuments(documentId) {
|
||||
return await this.get(`/documents/${documentId}/linkable-documents`);
|
||||
}
|
||||
|
||||
async updateDocumentLink(linkId, linkData) {
|
||||
return await this.put(`/documents/links/${linkId}`, linkData);
|
||||
}
|
||||
|
||||
async deleteDocumentLink(linkId) {
|
||||
return await this.delete(`/documents/links/${linkId}`);
|
||||
}
|
||||
|
||||
// 백링크 관련 API
|
||||
async getDocumentBacklinks(documentId) {
|
||||
return await this.get(`/documents/${documentId}/backlinks`);
|
||||
}
|
||||
|
||||
async getDocumentLinkFragments(documentId) {
|
||||
return await this.get(`/documents/${documentId}/link-fragments`);
|
||||
}
|
||||
|
||||
// ===== 노트 문서 관련 API =====
|
||||
|
||||
// 모든 노트 조회
|
||||
async getNoteDocuments(params = {}) {
|
||||
return await this.get('/note-documents/', params);
|
||||
}
|
||||
|
||||
// 특정 노트 조회
|
||||
async getNoteDocument(noteId) {
|
||||
return await this.get(`/note-documents/${noteId}`);
|
||||
}
|
||||
|
||||
// 특정 노트북의 노트들 조회
|
||||
async getNotesInNotebook(notebookId) {
|
||||
return await this.get('/note-documents/', { notebook_id: notebookId });
|
||||
}
|
||||
|
||||
// === 노트 문서 (Note Document) 관련 API ===
|
||||
// 용어 정의: 독립적인 문서 작성 (HTML 기반)
|
||||
async createNoteDocument(noteData) {
|
||||
return await this.post('/note-documents/', noteData);
|
||||
}
|
||||
|
||||
// 노트 업데이트
|
||||
async updateNoteDocument(noteId, noteData) {
|
||||
return await this.put(`/note-documents/${noteId}`, noteData);
|
||||
}
|
||||
|
||||
// 노트 삭제
|
||||
async deleteNoteDocument(noteId) {
|
||||
return await this.delete(`/note-documents/${noteId}`);
|
||||
}
|
||||
|
||||
// 노트 HTML 내보내기
|
||||
async exportNoteAsHTML(noteId) {
|
||||
const response = await fetch(`${this.baseURL}/note-documents/${noteId}/export/html`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ===== 노트북 관련 API =====
|
||||
|
||||
// 모든 노트북 조회
|
||||
async getNotebooks(params = {}) {
|
||||
return await this.get('/notebooks/', params);
|
||||
}
|
||||
|
||||
// 특정 노트북 조회
|
||||
async getNotebook(notebookId) {
|
||||
return await this.get(`/notebooks/${notebookId}`);
|
||||
}
|
||||
|
||||
// === 노트북 (Notebook) 관련 API ===
|
||||
// 용어 정의: 노트 문서들을 그룹화하는 폴더
|
||||
async createNotebook(notebookData) {
|
||||
return await this.post('/notebooks/', notebookData);
|
||||
}
|
||||
|
||||
// 노트북 업데이트
|
||||
async updateNotebook(notebookId, notebookData) {
|
||||
return await this.put(`/notebooks/${notebookId}`, notebookData);
|
||||
}
|
||||
|
||||
// 노트북 삭제
|
||||
async deleteNotebook(notebookId, force = false) {
|
||||
return await this.delete(`/notebooks/${notebookId}?force=${force}`);
|
||||
}
|
||||
|
||||
// 노트북 통계
|
||||
async getNotebookStats() {
|
||||
return await this.get('/notebooks/stats');
|
||||
}
|
||||
|
||||
// 노트북의 노트들 조회
|
||||
async getNotebookNotes(notebookId, params = {}) {
|
||||
return await this.get(`/notebooks/${notebookId}/notes`, params);
|
||||
}
|
||||
|
||||
// 노트를 노트북에 추가
|
||||
async addNoteToNotebook(notebookId, noteId) {
|
||||
return await this.post(`/notebooks/${notebookId}/notes/${noteId}`);
|
||||
}
|
||||
|
||||
// 노트를 노트북에서 제거
|
||||
async removeNoteFromNotebook(notebookId, noteId) {
|
||||
return await this.delete(`/notebooks/${notebookId}/notes/${noteId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 할일관리 API
|
||||
// ============================================================================
|
||||
|
||||
// 할일 아이템 관리
|
||||
async getTodos(status = null) {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return await this.get(`/todos/${params}`);
|
||||
}
|
||||
|
||||
async createTodo(todoData) {
|
||||
return await this.post('/todos/', todoData);
|
||||
}
|
||||
|
||||
async getTodo(todoId) {
|
||||
return await this.get(`/todos/${todoId}`);
|
||||
}
|
||||
|
||||
async scheduleTodo(todoId, scheduleData) {
|
||||
return await this.post(`/todos/${todoId}/schedule`, scheduleData);
|
||||
}
|
||||
|
||||
async splitTodo(todoId, splitData) {
|
||||
return await this.post(`/todos/${todoId}/split`, splitData);
|
||||
}
|
||||
|
||||
async getActiveTodos() {
|
||||
return await this.get('/todos/active');
|
||||
}
|
||||
|
||||
async completeTodo(todoId) {
|
||||
return await this.put(`/todos/${todoId}/complete`);
|
||||
}
|
||||
|
||||
async delayTodo(todoId, delayData) {
|
||||
return await this.put(`/todos/${todoId}/delay`, delayData);
|
||||
}
|
||||
|
||||
// 댓글 관리
|
||||
async getTodoComments(todoId) {
|
||||
return await this.get(`/todos/${todoId}/comments`);
|
||||
}
|
||||
|
||||
async createTodoComment(todoId, commentData) {
|
||||
return await this.post(`/todos/${todoId}/comments`, commentData);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 API 인스턴스
|
||||
window.api = new DocumentServerAPI();
|
||||
92
frontend/static/js/auth-guard.js
Normal file
92
frontend/static/js/auth-guard.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 인증 가드 - 모든 보호된 페이지에서 사용
|
||||
* 로그인하지 않은 사용자를 자동으로 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 인증이 필요하지 않은 페이지들
|
||||
const PUBLIC_PAGES = [
|
||||
'login.html',
|
||||
'setup.html'
|
||||
];
|
||||
|
||||
// 현재 페이지가 공개 페이지인지 확인
|
||||
function isPublicPage() {
|
||||
const currentPath = window.location.pathname;
|
||||
return PUBLIC_PAGES.some(page => currentPath.includes(page));
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
function redirectToLogin() {
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
console.log('🔐 인증되지 않은 접근. 로그인 페이지로 이동합니다.');
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
}
|
||||
|
||||
// 인증 체크 함수
|
||||
async function checkAuthentication() {
|
||||
// 공개 페이지는 체크하지 않음
|
||||
if (isPublicPage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
// 토큰이 없으면 즉시 리다이렉트
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 토큰 유효성 검사
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('🔐 토큰이 유효하지 않습니다. 상태:', response.status);
|
||||
localStorage.removeItem('access_token');
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// 인증 성공
|
||||
const user = await response.json();
|
||||
console.log('✅ 인증 성공:', user.email);
|
||||
|
||||
// 전역 사용자 정보 설정
|
||||
window.currentUser = user;
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(user);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔐 인증 확인 중 오류:', error);
|
||||
localStorage.removeItem('access_token');
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// DOM 로드 완료 전에 인증 체크 실행
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', checkAuthentication);
|
||||
} else {
|
||||
checkAuthentication();
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.authGuard = {
|
||||
checkAuthentication,
|
||||
redirectToLogin,
|
||||
isPublicPage
|
||||
};
|
||||
|
||||
})();
|
||||
105
frontend/static/js/auth.js
Normal file
105
frontend/static/js/auth.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 인증 관련 Alpine.js 컴포넌트
|
||||
*/
|
||||
|
||||
// 인증 모달 컴포넌트
|
||||
window.authModal = () => ({
|
||||
showLogin: false,
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
|
||||
async login() {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
console.log('🔐 로그인 시도:', this.loginForm.email);
|
||||
|
||||
// API 클래스의 login 메서드 사용 (이미 토큰 설정과 사용자 정보 가져오기 포함)
|
||||
const result = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
console.log('✅ 로그인 결과:', result);
|
||||
|
||||
if (result.success) {
|
||||
// refresh_token 저장 (access_token은 API 클래스에서 이미 처리됨)
|
||||
const loginResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.loginForm)
|
||||
});
|
||||
const tokenData = await loginResponse.json();
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token);
|
||||
|
||||
console.log('💾 토큰 저장 완료');
|
||||
|
||||
// 전역 상태 업데이트
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
detail: { isAuthenticated: true, user: result.user }
|
||||
}));
|
||||
|
||||
// 모달 닫기
|
||||
window.dispatchEvent(new CustomEvent('close-login-modal'));
|
||||
this.loginForm = { email: '', password: '' };
|
||||
|
||||
} else {
|
||||
this.loginError = result.message || '로그인에 실패했습니다';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 오류:', error);
|
||||
this.loginError = error.message || '로그인에 실패했습니다';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await window.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);
|
||||
// 갱신 실패시 로그아웃
|
||||
window.api.setToken(null);
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
detail: { isAuthenticated: false, user: null }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 5분마다 토큰 갱신 체크
|
||||
setInterval(refreshTokenIfNeeded, 5 * 60 * 1000);
|
||||
294
frontend/static/js/book-documents.js
Normal file
294
frontend/static/js/book-documents.js
Normal file
@@ -0,0 +1,294 @@
|
||||
// 서적 문서 목록 애플리케이션 컴포넌트
|
||||
window.bookDocumentsApp = () => ({
|
||||
// 상태 관리
|
||||
documents: [],
|
||||
availablePDFs: [],
|
||||
bookInfo: {},
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// URL 파라미터
|
||||
bookId: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 Book Documents App 초기화 시작');
|
||||
|
||||
// URL 파라미터 파싱
|
||||
this.parseUrlParams();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadBookDocuments();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// URL 파라미터 파싱
|
||||
parseUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.bookId = urlParams.get('book_id') || urlParams.get('bookId'); // 둘 다 지원
|
||||
console.log('📖 서적 ID:', this.bookId);
|
||||
console.log('🔍 전체 URL 파라미터:', window.location.search);
|
||||
console.log('🔍 URLSearchParams 객체:', urlParams);
|
||||
console.log('🔍 book_id 파라미터:', urlParams.get('book_id'));
|
||||
console.log('🔍 bookId 파라미터:', urlParams.get('bookId'));
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
// 로그인 페이지로 리다이렉트하거나 로그인 모달 표시
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
await window.headerLoader.loadHeader();
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 문서 목록 로드
|
||||
async loadBookDocuments() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
// 모든 문서 가져오기
|
||||
const allDocuments = await window.api.getDocuments();
|
||||
|
||||
if (this.bookId === 'none') {
|
||||
// 서적 미분류 HTML 문서들만 (폴더로 구분)
|
||||
this.documents = allDocuments.filter(doc =>
|
||||
!doc.book_id &&
|
||||
doc.html_path &&
|
||||
doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
|
||||
);
|
||||
|
||||
// 서적 미분류 PDF 문서들 (매칭용)
|
||||
this.availablePDFs = allDocuments.filter(doc =>
|
||||
!doc.book_id &&
|
||||
doc.pdf_path &&
|
||||
doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨
|
||||
);
|
||||
|
||||
this.bookInfo = {
|
||||
title: '서적 미분류',
|
||||
description: '서적에 속하지 않은 문서들입니다.'
|
||||
};
|
||||
} else {
|
||||
// 특정 서적의 HTML 문서들만 (폴더로 구분)
|
||||
this.documents = allDocuments.filter(doc =>
|
||||
doc.book_id === this.bookId &&
|
||||
doc.html_path &&
|
||||
doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
|
||||
);
|
||||
|
||||
// 특정 서적의 PDF 문서들 (매칭용)
|
||||
this.availablePDFs = allDocuments.filter(doc =>
|
||||
doc.book_id === this.bookId &&
|
||||
doc.pdf_path &&
|
||||
doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨
|
||||
);
|
||||
|
||||
if (this.documents.length > 0) {
|
||||
// 첫 번째 문서에서 서적 정보 추출
|
||||
const firstDoc = this.documents[0];
|
||||
this.bookInfo = {
|
||||
id: firstDoc.book_id,
|
||||
title: firstDoc.book_title,
|
||||
author: firstDoc.book_author,
|
||||
description: firstDoc.book_description || '서적 설명이 없습니다.'
|
||||
};
|
||||
} else {
|
||||
// 서적 정보만 가져오기 (문서가 없는 경우)
|
||||
try {
|
||||
this.bookInfo = await window.api.getBook(this.bookId);
|
||||
} catch (error) {
|
||||
console.error('서적 정보 로드 실패:', error);
|
||||
this.bookInfo = {
|
||||
title: '알 수 없는 서적',
|
||||
description: '서적 정보를 불러올 수 없습니다.'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📚 서적 문서 로드 완료:', this.documents.length, '개');
|
||||
console.log('📕 사용 가능한 PDF:', this.availablePDFs.length, '개');
|
||||
console.log('📎 PDF 목록:', this.availablePDFs.map(pdf => ({ title: pdf.title, book_id: pdf.book_id })));
|
||||
console.log('🔍 현재 서적 ID:', this.bookId);
|
||||
|
||||
// 디버깅: 문서들의 original_filename 확인
|
||||
console.log('🔍 문서들 확인:');
|
||||
this.documents.slice(0, 5).forEach(doc => {
|
||||
console.log(`- ${doc.title}: ${doc.original_filename}`);
|
||||
});
|
||||
|
||||
console.log('🔍 PDF들 확인:');
|
||||
this.availablePDFs.slice(0, 5).forEach(doc => {
|
||||
console.log(`- ${doc.title}: ${doc.original_filename}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('서적 문서 로드 실패:', error);
|
||||
this.error = '문서를 불러오는데 실패했습니다: ' + error.message;
|
||||
this.documents = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 열기
|
||||
openDocument(documentId) {
|
||||
// 현재 페이지 정보를 세션 스토리지에 저장
|
||||
sessionStorage.setItem('previousPage', 'book-documents.html');
|
||||
|
||||
// 뷰어로 이동 - 같은 창에서 이동
|
||||
window.location.href = `/viewer.html?id=${documentId}&from=book`;
|
||||
},
|
||||
|
||||
// 서적 편집 페이지 열기
|
||||
openBookEditor() {
|
||||
console.log('🔧 서적 편집 버튼 클릭됨');
|
||||
console.log('📖 현재 bookId:', this.bookId);
|
||||
console.log('🔍 bookId 타입:', typeof this.bookId);
|
||||
|
||||
if (this.bookId === 'none') {
|
||||
alert('서적 미분류 문서들은 편집할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.bookId) {
|
||||
alert('서적 ID가 없습니다. 페이지를 새로고침해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = `book-editor.html?bookId=${this.bookId}`;
|
||||
console.log('🔗 이동할 URL:', targetUrl);
|
||||
window.location.href = targetUrl;
|
||||
},
|
||||
|
||||
// 문서 수정
|
||||
editDocument(doc) {
|
||||
// TODO: 문서 수정 모달 또는 페이지로 이동
|
||||
console.log('문서 수정:', doc.title);
|
||||
alert('문서 수정 기능은 준비 중입니다.');
|
||||
},
|
||||
|
||||
// 문서 삭제
|
||||
async deleteDocument(documentId) {
|
||||
if (!confirm('이 문서를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.deleteDocument(documentId);
|
||||
await this.loadBookDocuments(); // 목록 새로고침
|
||||
this.showNotification('문서가 삭제되었습니다', 'success');
|
||||
} catch (error) {
|
||||
console.error('문서 삭제 실패:', error);
|
||||
this.showNotification('문서 삭제에 실패했습니다: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
goBack() {
|
||||
window.location.href = 'index.html';
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
// TODO: 알림 시스템 구현
|
||||
console.log(`${type.toUpperCase()}: ${message}`);
|
||||
if (type === 'error') {
|
||||
alert(message);
|
||||
}
|
||||
},
|
||||
|
||||
// PDF를 서적에 연결
|
||||
async matchPDFToBook(pdfId) {
|
||||
if (!this.bookId) {
|
||||
this.showNotification('서적 ID가 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('이 PDF를 현재 서적에 연결하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔗 PDF 매칭 시작:', { pdfId, bookId: this.bookId });
|
||||
|
||||
// PDF 문서를 서적에 연결
|
||||
await window.api.updateDocument(pdfId, {
|
||||
book_id: this.bookId
|
||||
});
|
||||
|
||||
this.showNotification('PDF가 서적에 성공적으로 연결되었습니다');
|
||||
|
||||
// 데이터 새로고침
|
||||
await this.loadBookData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF 매칭 실패:', error);
|
||||
this.showNotification('PDF 연결에 실패했습니다: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// PDF 열기
|
||||
openPDF(pdf) {
|
||||
if (pdf.pdf_path) {
|
||||
// PDF 뷰어로 이동
|
||||
window.open(`/viewer.html?id=${pdf.id}`, '_blank');
|
||||
} else {
|
||||
this.showNotification('PDF 파일을 찾을 수 없습니다', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 Book Documents 페이지 로드됨');
|
||||
});
|
||||
329
frontend/static/js/book-editor.js
Normal file
329
frontend/static/js/book-editor.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// 서적 편집 애플리케이션 컴포넌트
|
||||
window.bookEditorApp = () => ({
|
||||
// 상태 관리
|
||||
documents: [],
|
||||
bookInfo: {},
|
||||
availablePDFs: [],
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// URL 파라미터
|
||||
bookId: null,
|
||||
|
||||
// SortableJS 인스턴스
|
||||
sortableInstance: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 Book Editor App 초기화 시작');
|
||||
|
||||
// URL 파라미터 파싱
|
||||
this.parseUrlParams();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadBookData();
|
||||
this.initSortable();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// URL 파라미터 파싱
|
||||
parseUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.bookId = urlParams.get('bookId');
|
||||
console.log('📖 편집할 서적 ID:', this.bookId);
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
await window.headerLoader.loadHeader();
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 데이터 로드
|
||||
async loadBookData() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
// 서적 정보 로드
|
||||
this.bookInfo = await window.api.getBook(this.bookId);
|
||||
console.log('📚 서적 정보 로드:', this.bookInfo);
|
||||
|
||||
// 모든 문서 가져와서 이 서적에 속한 HTML 문서들만 필터링 (폴더로 구분)
|
||||
const allDocuments = await window.api.getDocuments();
|
||||
this.documents = allDocuments
|
||||
.filter(doc =>
|
||||
doc.book_id === this.bookId &&
|
||||
doc.html_path &&
|
||||
doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
|
||||
)
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); // 순서대로 정렬
|
||||
|
||||
console.log('📄 서적 문서들:', this.documents.length, '개');
|
||||
|
||||
// 각 문서의 PDF 매칭 상태 확인
|
||||
this.documents.forEach((doc, index) => {
|
||||
console.log(`📄 문서 ${index + 1}: ${doc.title}`);
|
||||
console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`);
|
||||
console.log(` - sort_order: ${doc.sort_order || 'null'}`);
|
||||
|
||||
// null 값을 빈 문자열로 변환 (UI 바인딩을 위해)
|
||||
if (doc.matched_pdf_id === null) {
|
||||
doc.matched_pdf_id = "";
|
||||
}
|
||||
|
||||
// 디버깅: 실제 값과 타입 확인
|
||||
console.log(` - matched_pdf_id 타입: ${typeof doc.matched_pdf_id}`);
|
||||
console.log(` - matched_pdf_id 값: "${doc.matched_pdf_id}"`);
|
||||
console.log(` - 빈 문자열인가? ${doc.matched_pdf_id === ""}`);
|
||||
console.log(` - null인가? ${doc.matched_pdf_id === null}`);
|
||||
console.log(` - undefined인가? ${doc.matched_pdf_id === undefined}`);
|
||||
});
|
||||
|
||||
// 사용 가능한 PDF 문서들 로드 (현재 서적의 PDF만)
|
||||
console.log('🔍 현재 서적 ID:', this.bookId);
|
||||
console.log('🔍 전체 문서 수:', allDocuments.length);
|
||||
|
||||
// PDF 문서들 먼저 필터링
|
||||
const allPDFs = allDocuments.filter(doc =>
|
||||
doc.pdf_path &&
|
||||
doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨
|
||||
);
|
||||
console.log('🔍 전체 PDF 문서 수:', allPDFs.length);
|
||||
|
||||
// 같은 서적의 PDF 문서들만 필터링
|
||||
this.availablePDFs = allPDFs.filter(doc => {
|
||||
const match = String(doc.book_id) === String(this.bookId);
|
||||
if (!match && allPDFs.indexOf(doc) < 5) {
|
||||
console.log(`🔍 PDF "${doc.title}": book_id="${doc.book_id}" (${typeof doc.book_id}) vs bookId="${this.bookId}" (${typeof this.bookId})`);
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
console.log('📎 현재 서적의 PDF:', this.availablePDFs.length, '개');
|
||||
console.log('📎 현재 서적 PDF 목록:', this.availablePDFs.map(pdf => ({
|
||||
id: pdf.id,
|
||||
title: pdf.title,
|
||||
book_id: pdf.book_id,
|
||||
book_title: pdf.book_title
|
||||
})));
|
||||
|
||||
// 각 PDF의 ID 확인
|
||||
this.availablePDFs.forEach((pdf, index) => {
|
||||
console.log(`📎 PDF ${index + 1}: ID="${pdf.id}", 제목="${pdf.title}"`);
|
||||
});
|
||||
|
||||
// 디버깅: 다른 서적의 PDF들도 확인
|
||||
const otherBookPDFs = allPDFs.filter(doc => doc.book_id !== this.bookId);
|
||||
console.log('🔍 다른 서적의 PDF:', otherBookPDFs.length, '개');
|
||||
if (otherBookPDFs.length > 0) {
|
||||
console.log('🔍 다른 서적 PDF 예시:', otherBookPDFs.slice(0, 3).map(pdf => ({
|
||||
title: pdf.title,
|
||||
book_id: pdf.book_id,
|
||||
book_title: pdf.book_title
|
||||
})));
|
||||
}
|
||||
|
||||
// Alpine.js DOM 업데이트 강제 실행
|
||||
this.$nextTick(() => {
|
||||
console.log('🔄 Alpine.js DOM 업데이트 완료');
|
||||
// DOM이 완전히 렌더링된 후 실행
|
||||
setTimeout(() => {
|
||||
this.documents.forEach((doc, index) => {
|
||||
if (doc.matched_pdf_id) {
|
||||
console.log(`🔧 문서 ${index + 1} 강제 업데이트: ${doc.matched_pdf_id}`);
|
||||
// Alpine.js 반응성 트리거
|
||||
const oldValue = doc.matched_pdf_id;
|
||||
doc.matched_pdf_id = "";
|
||||
doc.matched_pdf_id = oldValue;
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('서적 데이터 로드 실패:', error);
|
||||
this.error = '데이터를 불러오는데 실패했습니다: ' + error.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// SortableJS 초기화
|
||||
initSortable() {
|
||||
this.$nextTick(() => {
|
||||
const sortableList = document.getElementById('sortable-list');
|
||||
if (sortableList && !this.sortableInstance) {
|
||||
this.sortableInstance = Sortable.create(sortableList, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
handle: '.fa-grip-vertical',
|
||||
onEnd: (evt) => {
|
||||
// 배열 순서 업데이트
|
||||
const item = this.documents.splice(evt.oldIndex, 1)[0];
|
||||
this.documents.splice(evt.newIndex, 0, item);
|
||||
this.updateDisplayOrder();
|
||||
}
|
||||
});
|
||||
console.log('✅ SortableJS 초기화 완료');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 표시 순서 업데이트
|
||||
updateDisplayOrder() {
|
||||
this.documents.forEach((doc, index) => {
|
||||
doc.sort_order = index + 1;
|
||||
});
|
||||
console.log('🔢 표시 순서 업데이트됨');
|
||||
},
|
||||
|
||||
// 위로 이동
|
||||
moveUp(index) {
|
||||
if (index > 0) {
|
||||
const item = this.documents.splice(index, 1)[0];
|
||||
this.documents.splice(index - 1, 0, item);
|
||||
this.updateDisplayOrder();
|
||||
}
|
||||
},
|
||||
|
||||
// 아래로 이동
|
||||
moveDown(index) {
|
||||
if (index < this.documents.length - 1) {
|
||||
const item = this.documents.splice(index, 1)[0];
|
||||
this.documents.splice(index + 1, 0, item);
|
||||
this.updateDisplayOrder();
|
||||
}
|
||||
},
|
||||
|
||||
// 이름순 정렬
|
||||
autoSortByName() {
|
||||
this.documents.sort((a, b) => {
|
||||
return a.title.localeCompare(b.title, 'ko', { numeric: true });
|
||||
});
|
||||
this.updateDisplayOrder();
|
||||
console.log('📝 이름순 정렬 완료');
|
||||
},
|
||||
|
||||
// 순서 뒤집기
|
||||
reverseOrder() {
|
||||
this.documents.reverse();
|
||||
this.updateDisplayOrder();
|
||||
console.log('🔄 순서 뒤집기 완료');
|
||||
},
|
||||
|
||||
// 변경사항 저장
|
||||
async saveChanges() {
|
||||
if (this.saving) return;
|
||||
|
||||
this.saving = true;
|
||||
console.log('💾 저장 시작...');
|
||||
|
||||
try {
|
||||
// 저장 전에 순서 업데이트
|
||||
this.updateDisplayOrder();
|
||||
|
||||
// 서적 정보 업데이트
|
||||
console.log('📚 서적 정보 업데이트 중...');
|
||||
await window.api.updateBook(this.bookId, {
|
||||
title: this.bookInfo.title,
|
||||
author: this.bookInfo.author,
|
||||
description: this.bookInfo.description
|
||||
});
|
||||
console.log('✅ 서적 정보 업데이트 완료');
|
||||
|
||||
// 각 문서의 순서와 PDF 매칭 정보 업데이트
|
||||
console.log('📄 문서 업데이트 시작...');
|
||||
const updatePromises = this.documents.map((doc, index) => {
|
||||
console.log(`📄 문서 ${index + 1}/${this.documents.length}: ${doc.title}`);
|
||||
console.log(` - sort_order: ${doc.sort_order}`);
|
||||
console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`);
|
||||
|
||||
return window.api.updateDocument(doc.id, {
|
||||
sort_order: doc.sort_order,
|
||||
matched_pdf_id: doc.matched_pdf_id === "" || doc.matched_pdf_id === null ? null : doc.matched_pdf_id
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(updatePromises);
|
||||
console.log('✅ 모든 문서 업데이트 완료:', results.length, '개');
|
||||
|
||||
console.log('✅ 모든 변경사항 저장 완료');
|
||||
this.showNotification('변경사항이 저장되었습니다', 'success');
|
||||
|
||||
// 잠시 후 서적 페이지로 돌아가기
|
||||
setTimeout(() => {
|
||||
this.goBack();
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 실패:', error);
|
||||
this.showNotification('저장에 실패했습니다: ' + error.message, 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
goBack() {
|
||||
window.location.href = `book-documents.html?bookId=${this.bookId}`;
|
||||
},
|
||||
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
console.log(`${type.toUpperCase()}: ${message}`);
|
||||
|
||||
// 간단한 토스트 알림 생성
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
|
||||
type === 'success' ? 'bg-green-600' :
|
||||
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||||
}`;
|
||||
toast.textContent = message;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 3초 후 제거
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 Book Editor 페이지 로드됨');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user