Compare commits
65 Commits
1e2e66d8fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ac1b2f22 | ||
|
|
5854301c5d | ||
|
|
4755d27eb9 | ||
|
|
f6bbf54f55 | ||
|
|
141e66e52c | ||
|
|
164dcd879d | ||
|
|
f49edaf06b | ||
|
|
16eec06b7c | ||
|
|
e96f8b92f8 | ||
|
|
a900574263 | ||
|
|
217642b44d | ||
|
|
c5911467fc | ||
|
|
ef2e20f7f5 | ||
|
|
87d0335bfd | ||
|
|
ca5e7525f9 | ||
|
|
b5167b3569 | ||
|
|
b68fd89f5d | ||
|
|
a4fd233ba1 | ||
|
|
f221a5611c | ||
|
|
43e7466195 | ||
|
|
3ba804276c | ||
|
|
0786bdc86d | ||
|
|
8f776a5281 | ||
|
|
6c19af3731 | ||
|
|
b8c1bee06a | ||
|
|
d68bfd45b2 | ||
|
|
d74cb070ca | ||
|
|
ec7d13ced7 | ||
|
|
e983d01a83 | ||
|
|
77c18d31a9 | ||
|
|
ca49ffec40 | ||
|
|
185db89585 | ||
|
|
8f12eb4f76 | ||
|
|
b5602cbf44 | ||
|
|
0833b99e6f | ||
|
|
43e2614253 | ||
|
|
62675e9bd5 | ||
|
|
6e01dbdeb3 | ||
|
|
d4b10b16b1 | ||
|
|
2d25c04457 | ||
|
|
5f9fe07317 | ||
|
|
960ee84356 | ||
|
|
4b65d45584 | ||
|
|
0afe6dcf65 | ||
|
|
4329a1c9a6 | ||
|
|
97d60554a9 | ||
|
|
c5d09ed948 | ||
|
|
ea9f4dfaa9 | ||
|
|
0dc4e3523f | ||
|
|
d01cdeb2f5 | ||
|
|
f711998ce9 | ||
|
|
397c63979d | ||
|
|
222e5bcb9e | ||
|
|
8414c9b40e | ||
|
|
7f68c19481 | ||
|
|
844587c86f | ||
|
|
5d4465b15c | ||
|
|
3e0a03f149 | ||
|
|
8d7f4c04bb | ||
|
|
04ae64fc4d | ||
|
|
4038040faa | ||
|
|
f95f67364a | ||
|
|
5bfa3822ca | ||
|
|
c7f55ac50d | ||
|
|
46546da55f |
223
QUICK-START.md
Normal file
223
QUICK-START.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 🚀 빠른 시작 가이드
|
||||
|
||||
Document Server를 Synology DS1525+에 배포하는 가장 간단한 방법입니다.
|
||||
|
||||
## 📋 준비사항
|
||||
|
||||
- Synology DS1525+ (32GB RAM, SSD 캐시 활성화)
|
||||
- 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 파일 드래그&드롭
|
||||
|
||||
## 🔄 업데이트 방법
|
||||
|
||||
### Git 사용 (권장)
|
||||
```bash
|
||||
# NAS에 SSH 접속
|
||||
ssh admin@your-nas-ip
|
||||
cd /volume1/docker/document-server
|
||||
|
||||
# 자동 업데이트 (백업 + 업데이트 + 헬스체크)
|
||||
./scripts/update-synology.sh
|
||||
```
|
||||
|
||||
### 수동 업데이트
|
||||
```bash
|
||||
# 1. 코드 업데이트
|
||||
git pull origin main
|
||||
|
||||
# 2. 컨테이너 재시작
|
||||
docker-compose -f docker-compose.synology-optimized.yml restart
|
||||
```
|
||||
|
||||
## 📊 모니터링
|
||||
|
||||
```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를 사용할 준비가 완료되었습니다!**
|
||||
279
README-DEPLOYMENT.md
Normal file
279
README-DEPLOYMENT.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# 🚀 Synology DS1525+ 배포 가이드
|
||||
|
||||
Document Server를 Synology DS1525+ NAS에 최적화하여 배포하는 가이드입니다.
|
||||
|
||||
## 🏗️ 하드웨어 사양
|
||||
|
||||
### 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
|
||||
# 코드 업데이트
|
||||
git pull origin main
|
||||
|
||||
# 컨테이너 재빌드
|
||||
docker-compose -f docker-compose.synology-optimized.yml build --no-cache
|
||||
docker-compose -f docker-compose.synology-optimized.yml up -d
|
||||
```
|
||||
267
README.md
267
README.md
@@ -6,6 +6,35 @@ 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 변환 (필요시 번역 포함)
|
||||
@@ -74,7 +103,8 @@ PDF 문서를 OCR 처리하고 AI로 HTML로 변환한 후, 웹에서 효율적
|
||||
|
||||
### 인프라 & 배포
|
||||
- **컨테이너**: Docker 24+ & Docker Compose
|
||||
- **배포 환경**: Mac Mini / Synology NAS
|
||||
- **주 배포 환경**: Synology DS1525+ (32GB RAM, SSD 캐싱)
|
||||
- **보조 배포 환경**: Mac Mini (개발/테스트)
|
||||
- **프로세스 관리**: Docker (컨테이너 오케스트레이션)
|
||||
- **로그 관리**: Python logging + 파일 로테이션
|
||||
- **모니터링**: 기본 헬스체크 (향후 Prometheus + Grafana)
|
||||
@@ -202,14 +232,36 @@ notes (
|
||||
- [x] API 오류 처리 및 사용자 피드백
|
||||
- [x] 실시간 문서 목록 새로고침
|
||||
|
||||
### Phase 7: 향후 개선사항 (예정)
|
||||
### 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-08-21)
|
||||
## 현재 상태 (2025-01-26)
|
||||
|
||||
### ✅ 완료된 기능
|
||||
- **완전한 백엔드 API**: FastAPI + SQLAlchemy + PostgreSQL
|
||||
@@ -220,12 +272,19 @@ notes (
|
||||
- **책갈피**: 페이지 북마크 및 빠른 이동
|
||||
- **통합 검색**: 문서 내용 + 메모 통합 검색
|
||||
- **실시간 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
|
||||
@@ -250,8 +309,206 @@ 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 엔드포인트 (예상)
|
||||
|
||||
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';
|
||||
|
||||
@@ -23,6 +23,8 @@ 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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
API 의존성
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -12,8 +12,8 @@ from ..core.security import verify_token, get_user_id_from_token
|
||||
from ..models.user import User
|
||||
|
||||
|
||||
# HTTP Bearer 토큰 스키마
|
||||
security = HTTPBearer()
|
||||
# HTTP Bearer 토큰 스키마 (선택적)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -86,3 +86,64 @@ async def get_optional_current_user(
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -46,8 +46,11 @@ async def login(
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# 토큰 생성
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
# 사용자별 세션 타임아웃을 적용한 토큰 생성
|
||||
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)})
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
|
||||
155
backend/src/api/routes/book_categories.py
Normal file
155
backend/src/api/routes/book_categories.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.book_category import BookCategory
|
||||
from ...models.document import Document
|
||||
from ...schemas.book_category import (
|
||||
CreateBookCategoryRequest,
|
||||
UpdateBookCategoryRequest,
|
||||
BookCategoryResponse,
|
||||
UpdateDocumentOrderRequest
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=BookCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book_category(
|
||||
category_data: CreateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 소분류 생성"""
|
||||
# 서적 존재 확인
|
||||
book_result = await db.execute(select(Book).where(Book.id == category_data.book_id))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# 권한 확인 (관리자만)
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories")
|
||||
|
||||
new_category = BookCategory(**category_data.model_dump())
|
||||
db.add(new_category)
|
||||
await db.commit()
|
||||
await db.refresh(new_category)
|
||||
|
||||
return await _get_category_response(db, new_category)
|
||||
|
||||
@router.get("/book/{book_id}", response_model=List[BookCategoryResponse])
|
||||
async def get_book_categories(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적의 소분류 목록 조회"""
|
||||
result = await db.execute(
|
||||
select(BookCategory)
|
||||
.where(BookCategory.book_id == book_id)
|
||||
.order_by(BookCategory.sort_order, BookCategory.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
|
||||
response_categories = []
|
||||
for category in categories:
|
||||
response_categories.append(await _get_category_response(db, category))
|
||||
return response_categories
|
||||
|
||||
@router.put("/{category_id}", response_model=BookCategoryResponse)
|
||||
async def update_book_category(
|
||||
category_id: UUID,
|
||||
category_data: UpdateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 수정"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
for field, value in category_data.model_dump(exclude_unset=True).items():
|
||||
setattr(category, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(category)
|
||||
return await _get_category_response(db, category)
|
||||
|
||||
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book_category(
|
||||
category_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 삭제 (포함된 문서들은 미분류로 이동)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
# 포함된 문서들을 미분류로 이동 (category_id를 NULL로 설정)
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.category_id == category_id)
|
||||
.values(category_id=None)
|
||||
)
|
||||
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
return {"message": "Category deleted successfully"}
|
||||
|
||||
@router.put("/documents/reorder", status_code=status.HTTP_200_OK)
|
||||
async def update_document_order(
|
||||
order_data: UpdateDocumentOrderRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 순서 변경"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reorder documents")
|
||||
|
||||
# 문서 순서 업데이트
|
||||
for item in order_data.document_orders:
|
||||
document_id = item.get("document_id")
|
||||
sort_order = item.get("sort_order", 0)
|
||||
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.id == document_id)
|
||||
.values(sort_order=sort_order)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Document order updated successfully"}
|
||||
|
||||
# Helper function
|
||||
async def _get_category_response(db: AsyncSession, category: BookCategory) -> BookCategoryResponse:
|
||||
"""BookCategory를 BookCategoryResponse로 변환"""
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.category_id == category.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
|
||||
return BookCategoryResponse(
|
||||
id=category.id,
|
||||
book_id=category.book_id,
|
||||
name=category.name,
|
||||
description=category.description,
|
||||
sort_order=category.sort_order,
|
||||
created_at=category.created_at,
|
||||
updated_at=category.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
230
backend/src/api/routes/books.py
Normal file
230
backend/src/api/routes/books.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import difflib # For similarity suggestions
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.document import Document
|
||||
from ...schemas.book import CreateBookRequest, UpdateBookRequest, BookResponse, BookSearchResponse, BookSuggestionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Helper to convert Book ORM object to BookResponse
|
||||
async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.book_id == book.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
return BookResponse(
|
||||
id=book.id,
|
||||
title=book.title,
|
||||
author=book.author,
|
||||
description=book.description,
|
||||
language=book.language,
|
||||
is_public=book.is_public,
|
||||
created_at=book.created_at,
|
||||
updated_at=book.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
|
||||
@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book(
|
||||
book_data: CreateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 생성"""
|
||||
# Check if a book with the same title and author already exists for the user
|
||||
existing_book_query = select(Book).where(Book.title == book_data.title)
|
||||
if book_data.author:
|
||||
existing_book_query = existing_book_query.where(Book.author == book_data.author)
|
||||
|
||||
existing_book = await db.execute(existing_book_query)
|
||||
if existing_book.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A book with this title and author already exists."
|
||||
)
|
||||
|
||||
new_book = Book(**book_data.model_dump())
|
||||
db.add(new_book)
|
||||
await db.commit()
|
||||
await db.refresh(new_book)
|
||||
return await _get_book_response(db, new_book)
|
||||
|
||||
@router.get("", response_model=List[BookResponse])
|
||||
async def get_books(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = Query(None, description="Search by book title or author"),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""모든 서적 목록 조회"""
|
||||
query = select(Book)
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{search}%"),
|
||||
Book.author.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# Only show public books or books owned by the current user/admin
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True) # For simplicity, assuming all books are public for now or user can only see public ones.
|
||||
# In a real app, you'd link books to users.
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Book.title)
|
||||
result = await db.execute(query)
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/{book_id}", response_model=BookResponse)
|
||||
async def get_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적 상세 정보 조회"""
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Access control (simplified)
|
||||
if not book.is_public and not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to access this book")
|
||||
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.put("/{book_id}", response_model=BookResponse)
|
||||
async def update_book(
|
||||
book_id: UUID,
|
||||
book_data: UpdateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 정보 업데이트"""
|
||||
if not current_user.is_admin: # Only admin can update books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
for field, value in book_data.model_dump(exclude_unset=True).items():
|
||||
setattr(book, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(book)
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 삭제"""
|
||||
if not current_user.is_admin: # Only admin can delete books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Disassociate documents from this book before deleting
|
||||
await db.execute(
|
||||
select(Document).where(Document.book_id == book_id)
|
||||
)
|
||||
documents_to_update = (await db.execute(select(Document).where(Document.book_id == book_id))).scalars().all()
|
||||
for doc in documents_to_update:
|
||||
doc.book_id = None
|
||||
|
||||
await db.delete(book)
|
||||
await db.commit()
|
||||
return {"message": "Book deleted successfully"}
|
||||
|
||||
@router.get("/search/", response_model=List[BookSearchResponse])
|
||||
async def search_books(
|
||||
q: str = Query(..., min_length=1, description="Search query for book title or author"),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 검색 (제목 또는 저자)"""
|
||||
query = select(Book).where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{q}%"),
|
||||
Book.author.ilike(f"%{q}%")
|
||||
)
|
||||
)
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True)
|
||||
|
||||
result = await db.execute(query.limit(limit))
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/suggestions/", response_model=List[BookSuggestionResponse])
|
||||
async def get_book_suggestions(
|
||||
title: str = Query(..., min_length=1, description="Book title for suggestions"),
|
||||
limit: int = Query(5, ge=1, le=10),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""제목 유사도 기반 서적 추천"""
|
||||
all_books_query = select(Book)
|
||||
if not current_user.is_admin:
|
||||
all_books_query = all_books_query.where(Book.is_public == True)
|
||||
|
||||
all_books_result = await db.execute(all_books_query)
|
||||
all_books = all_books_result.scalars().all()
|
||||
|
||||
suggestions = []
|
||||
for book in all_books:
|
||||
# Calculate similarity score using difflib
|
||||
score = difflib.SequenceMatcher(None, title.lower(), book.title.lower()).ratio()
|
||||
if score > 0.1: # Only consider if there's some similarity
|
||||
suggestions.append({
|
||||
"book": book,
|
||||
"similarity_score": score
|
||||
})
|
||||
|
||||
# Sort by similarity score in descending order
|
||||
suggestions.sort(key=lambda x: x["similarity_score"], reverse=True)
|
||||
|
||||
response_suggestions = []
|
||||
for s in suggestions[:limit]:
|
||||
book_response = await _get_book_response(db, s["book"])
|
||||
response_suggestions.append(BookSuggestionResponse(
|
||||
**book_response.model_dump(),
|
||||
similarity_score=s["similarity_score"]
|
||||
))
|
||||
return response_suggestions
|
||||
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
|
||||
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
문서 관리 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, and_, or_
|
||||
from sqlalchemy import select, delete, and_, or_, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,7 +16,8 @@ from ...core.database import get_db
|
||||
from ...core.config import settings
|
||||
from ...models.user import User
|
||||
from ...models.document import Document, Tag
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
||||
from ...models.book import Book
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user, get_current_user_with_token_param
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
@@ -25,7 +27,7 @@ class DocumentResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
html_path: str
|
||||
html_path: Optional[str] # PDF만 업로드하는 경우 None 가능
|
||||
pdf_path: Optional[str]
|
||||
thumbnail_path: Optional[str]
|
||||
file_size: Optional[int]
|
||||
@@ -38,6 +40,19 @@ class DocumentResponse(BaseModel):
|
||||
document_date: Optional[datetime]
|
||||
uploader_name: Optional[str]
|
||||
tags: List[str] = []
|
||||
|
||||
# 서적 정보
|
||||
book_id: Optional[str] = None
|
||||
book_title: Optional[str] = None
|
||||
book_author: Optional[str] = None
|
||||
|
||||
# 소분류 정보
|
||||
category_id: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
# PDF 매칭 정보
|
||||
matched_pdf_id: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -77,7 +92,9 @@ async def list_documents(
|
||||
"""문서 목록 조회"""
|
||||
query = select(Document).options(
|
||||
selectinload(Document.uploader),
|
||||
selectinload(Document.tags)
|
||||
selectinload(Document.tags),
|
||||
selectinload(Document.book), # 서적 정보 추가
|
||||
selectinload(Document.category) # 소분류 정보 추가
|
||||
)
|
||||
|
||||
# 권한 필터링 (관리자가 아니면 공개 문서 + 자신이 업로드한 문서만)
|
||||
@@ -114,7 +131,7 @@ async def list_documents(
|
||||
id=str(doc.id),
|
||||
title=doc.title,
|
||||
description=doc.description,
|
||||
html_path=doc.html_path,
|
||||
html_path=doc.html_path, # None 가능 (PDF만 업로드한 경우)
|
||||
pdf_path=doc.pdf_path,
|
||||
thumbnail_path=doc.thumbnail_path,
|
||||
file_size=doc.file_size,
|
||||
@@ -126,13 +143,110 @@ async def list_documents(
|
||||
updated_at=doc.updated_at,
|
||||
document_date=doc.document_date,
|
||||
uploader_name=doc.uploader.full_name or doc.uploader.email,
|
||||
tags=[tag.name for tag in doc.tags]
|
||||
tags=[tag.name for tag in doc.tags],
|
||||
# 서적 정보 추가
|
||||
book_id=str(doc.book.id) if doc.book else None,
|
||||
book_title=doc.book.title if doc.book else None,
|
||||
book_author=doc.book.author if doc.book else None,
|
||||
# 소분류 정보 추가
|
||||
category_id=str(doc.category.id) if doc.category else None,
|
||||
category_name=doc.category.name if doc.category else None,
|
||||
sort_order=doc.sort_order,
|
||||
# PDF 매칭 정보 추가
|
||||
matched_pdf_id=str(doc.matched_pdf_id) if doc.matched_pdf_id else None
|
||||
)
|
||||
response_data.append(doc_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/hierarchy/structured", response_model=dict)
|
||||
async def get_documents_by_hierarchy(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""계층구조별 문서 조회 (서적 > 소분류 > 문서)"""
|
||||
# 모든 문서 조회 (기존 로직 재사용)
|
||||
query = select(Document).options(
|
||||
selectinload(Document.uploader),
|
||||
selectinload(Document.tags),
|
||||
selectinload(Document.book),
|
||||
selectinload(Document.category)
|
||||
)
|
||||
|
||||
# 권한 필터링
|
||||
if not current_user.is_admin:
|
||||
query = query.where(
|
||||
or_(
|
||||
Document.is_public == True,
|
||||
Document.uploaded_by == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
# 정렬: 서적별 > 소분류별 > 문서 순서별
|
||||
query = query.order_by(
|
||||
Document.book_id.nulls_last(), # 서적 있는 것 먼저
|
||||
Document.category_id.nulls_last(), # 소분류 있는 것 먼저
|
||||
Document.sort_order,
|
||||
Document.created_at.desc()
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
documents = result.scalars().all()
|
||||
|
||||
# 계층구조로 그룹화
|
||||
hierarchy = {
|
||||
"books": {}, # 서적별 그룹
|
||||
"uncategorized": [] # 미분류 문서들
|
||||
}
|
||||
|
||||
for doc in documents:
|
||||
doc_data = {
|
||||
"id": str(doc.id),
|
||||
"title": doc.title,
|
||||
"description": doc.description,
|
||||
"created_at": doc.created_at.isoformat(),
|
||||
"uploader_name": doc.uploader.full_name or doc.uploader.email,
|
||||
"tags": [tag.name for tag in doc.tags],
|
||||
"sort_order": doc.sort_order,
|
||||
"book_id": str(doc.book.id) if doc.book else None,
|
||||
"book_title": doc.book.title if doc.book else None,
|
||||
"category_id": str(doc.category.id) if doc.category else None,
|
||||
"category_name": doc.category.name if doc.category else None
|
||||
}
|
||||
|
||||
if doc.book:
|
||||
# 서적이 있는 문서
|
||||
book_id = str(doc.book.id)
|
||||
if book_id not in hierarchy["books"]:
|
||||
hierarchy["books"][book_id] = {
|
||||
"id": book_id,
|
||||
"title": doc.book.title,
|
||||
"author": doc.book.author,
|
||||
"categories": {},
|
||||
"uncategorized_documents": []
|
||||
}
|
||||
|
||||
if doc.category:
|
||||
# 소분류가 있는 문서
|
||||
category_id = str(doc.category.id)
|
||||
if category_id not in hierarchy["books"][book_id]["categories"]:
|
||||
hierarchy["books"][book_id]["categories"][category_id] = {
|
||||
"id": category_id,
|
||||
"name": doc.category.name,
|
||||
"documents": []
|
||||
}
|
||||
hierarchy["books"][book_id]["categories"][category_id]["documents"].append(doc_data)
|
||||
else:
|
||||
# 서적은 있지만 소분류가 없는 문서
|
||||
hierarchy["books"][book_id]["uncategorized_documents"].append(doc_data)
|
||||
else:
|
||||
# 서적이 없는 미분류 문서
|
||||
hierarchy["uncategorized"].append(doc_data)
|
||||
|
||||
return hierarchy
|
||||
|
||||
|
||||
@router.post("/", response_model=DocumentResponse)
|
||||
async def upload_document(
|
||||
title: str = Form(...),
|
||||
@@ -141,17 +255,22 @@ async def upload_document(
|
||||
language: Optional[str] = Form("ko"),
|
||||
is_public: bool = Form(False),
|
||||
tags: Optional[List[str]] = Form(None), # 태그 리스트
|
||||
book_id: Optional[str] = Form(None), # 서적 ID 추가
|
||||
html_file: UploadFile = File(...),
|
||||
pdf_file: Optional[UploadFile] = File(None),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 업로드"""
|
||||
# 파일 확장자 확인
|
||||
if not html_file.filename.lower().endswith(('.html', '.htm')):
|
||||
# 파일 확장자 확인 (HTML 또는 PDF 허용)
|
||||
file_extension = html_file.filename.lower()
|
||||
is_pdf_file = file_extension.endswith('.pdf')
|
||||
is_html_file = file_extension.endswith(('.html', '.htm'))
|
||||
|
||||
if not (is_html_file or is_pdf_file):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only HTML files are allowed for the main document"
|
||||
detail="Only HTML and PDF files are allowed"
|
||||
)
|
||||
|
||||
if pdf_file and not pdf_file.filename.lower().endswith('.pdf'):
|
||||
@@ -162,25 +281,61 @@ async def upload_document(
|
||||
|
||||
# 고유 파일명 생성
|
||||
doc_id = str(uuid.uuid4())
|
||||
html_filename = f"{doc_id}.html"
|
||||
pdf_filename = f"{doc_id}.pdf" if pdf_file else None
|
||||
|
||||
# 파일 저장 경로
|
||||
html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename)
|
||||
pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None
|
||||
# 메인 파일 처리 (HTML 또는 PDF) - 폴더 분리
|
||||
if is_pdf_file:
|
||||
main_filename = f"{doc_id}.pdf"
|
||||
pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs")
|
||||
os.makedirs(pdf_dir, exist_ok=True) # PDF 폴더 생성
|
||||
main_path = os.path.join(pdf_dir, main_filename)
|
||||
html_path = None # PDF만 업로드하는 경우 html_path는 None
|
||||
pdf_path = main_path # PDF 파일인 경우 pdf_path에 저장
|
||||
else:
|
||||
main_filename = f"{doc_id}.html"
|
||||
html_dir = os.path.join(settings.UPLOAD_DIR, "documents")
|
||||
os.makedirs(html_dir, exist_ok=True) # HTML 폴더 생성
|
||||
main_path = os.path.join(html_dir, main_filename)
|
||||
html_path = main_path
|
||||
pdf_path = None
|
||||
|
||||
# 추가 PDF 파일 처리 (HTML 파일과 함께 업로드된 경우)
|
||||
additional_pdf_path = None
|
||||
if pdf_file:
|
||||
additional_pdf_filename = f"{doc_id}_additional.pdf"
|
||||
pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs")
|
||||
os.makedirs(pdf_dir, exist_ok=True) # PDF 폴더 생성
|
||||
additional_pdf_path = os.path.join(pdf_dir, additional_pdf_filename)
|
||||
|
||||
try:
|
||||
# HTML 파일 저장
|
||||
async with aiofiles.open(html_path, 'wb') as f:
|
||||
# 메인 파일 저장 (HTML 또는 PDF)
|
||||
async with aiofiles.open(main_path, 'wb') as f:
|
||||
content = await html_file.read()
|
||||
await f.write(content)
|
||||
|
||||
# PDF 파일 저장 (있는 경우)
|
||||
if pdf_file and pdf_path:
|
||||
async with aiofiles.open(pdf_path, 'wb') as f:
|
||||
content = await pdf_file.read()
|
||||
await f.write(content)
|
||||
# 추가 PDF 파일 저장 (HTML과 함께 업로드된 경우)
|
||||
if pdf_file and additional_pdf_path:
|
||||
async with aiofiles.open(additional_pdf_path, 'wb') as f:
|
||||
additional_content = await pdf_file.read()
|
||||
await f.write(additional_content)
|
||||
# HTML 파일인 경우 추가 PDF를 pdf_path로 설정
|
||||
if is_html_file:
|
||||
pdf_path = additional_pdf_path
|
||||
|
||||
# 서적 ID 검증 (있는 경우)
|
||||
validated_book_id = None
|
||||
if book_id:
|
||||
try:
|
||||
# UUID 형식 검증 및 서적 존재 확인
|
||||
from uuid import UUID
|
||||
book_uuid = UUID(book_id)
|
||||
book_result = await db.execute(select(Book).where(Book.id == book_uuid))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if book:
|
||||
validated_book_id = book_uuid
|
||||
except (ValueError, Exception):
|
||||
# 잘못된 UUID 형식이거나 서적이 없으면 무시
|
||||
pass
|
||||
|
||||
# 문서 메타데이터 생성
|
||||
document = Document(
|
||||
id=doc_id,
|
||||
@@ -193,7 +348,8 @@ async def upload_document(
|
||||
uploaded_by=current_user.id,
|
||||
original_filename=html_file.filename,
|
||||
is_public=is_public,
|
||||
document_date=datetime.fromisoformat(document_date) if document_date else None
|
||||
document_date=datetime.fromisoformat(document_date) if document_date else None,
|
||||
book_id=validated_book_id # 서적 ID 추가
|
||||
)
|
||||
|
||||
db.add(document)
|
||||
@@ -244,15 +400,16 @@ async def upload_document(
|
||||
updated_at=document_with_tags.updated_at,
|
||||
document_date=document_with_tags.document_date,
|
||||
uploader_name=current_user.full_name or current_user.email,
|
||||
tags=[tag.name for tag in document_with_tags.tags]
|
||||
tags=[tag.name for tag in document_with_tags.tags],
|
||||
matched_pdf_id=str(document_with_tags.matched_pdf_id) if document_with_tags.matched_pdf_id else None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 파일 정리
|
||||
if os.path.exists(html_path):
|
||||
os.remove(html_path)
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
os.remove(pdf_path)
|
||||
if os.path.exists(main_path):
|
||||
os.remove(main_path)
|
||||
if additional_pdf_path and os.path.exists(additional_pdf_path):
|
||||
os.remove(additional_pdf_path)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -303,10 +460,516 @@ async def get_document(
|
||||
updated_at=document.updated_at,
|
||||
document_date=document.document_date,
|
||||
uploader_name=document.uploader.full_name or document.uploader.email,
|
||||
tags=[tag.name for tag in document.tags]
|
||||
tags=[tag.name for tag in document.tags],
|
||||
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/content")
|
||||
async def get_document_content(
|
||||
document_id: str,
|
||||
_token: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user_with_token_param),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 HTML 콘텐츠 조회"""
|
||||
try:
|
||||
doc_uuid = UUID(document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid document ID format")
|
||||
|
||||
# 문서 조회
|
||||
query = select(Document).where(Document.id == doc_uuid)
|
||||
result = await db.execute(query)
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# 권한 확인
|
||||
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# HTML 파일 읽기
|
||||
import os
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
# html_path는 이미 전체 경로를 포함하고 있음
|
||||
file_path = document.html_path
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="Document file not found")
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return HTMLResponse(content=content)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{document_id}/pdf")
|
||||
async def get_document_pdf(
|
||||
document_id: str,
|
||||
_token: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user_with_token_param),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 PDF 파일 조회"""
|
||||
print(f"🔍 PDF 요청 - 문서 ID: {document_id}")
|
||||
print(f"🔍 토큰 파라미터: {_token[:50] if _token else 'None'}...")
|
||||
print(f"🔍 현재 사용자: {current_user.email if current_user else 'None'}")
|
||||
|
||||
try:
|
||||
doc_uuid = UUID(document_id)
|
||||
except ValueError:
|
||||
print(f"❌ 잘못된 문서 ID 형식: {document_id}")
|
||||
raise HTTPException(status_code=400, detail="Invalid document ID format")
|
||||
|
||||
# 문서 조회
|
||||
query = select(Document).where(Document.id == doc_uuid)
|
||||
result = await db.execute(query)
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
print(f"❌ 문서를 찾을 수 없음: {document_id}")
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
print(f"📄 문서 정보: {document.title}")
|
||||
print(f"🔐 문서 권한: is_public={document.is_public}, uploaded_by={document.uploaded_by}")
|
||||
print(f"👤 사용자 권한: is_admin={current_user.is_admin}, user_id={current_user.id}")
|
||||
|
||||
# 권한 확인
|
||||
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
|
||||
print(f"❌ 접근 권한 없음 - 관리자: {current_user.is_admin}, 공개: {document.is_public}, 소유자: {document.uploaded_by == current_user.id}")
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# PDF 파일 확인
|
||||
if not document.pdf_path:
|
||||
print(f"🚫 PDF 경로가 데이터베이스에 없음: {document.title}")
|
||||
raise HTTPException(status_code=404, detail="PDF file not found for this document")
|
||||
|
||||
# PDF 파일 경로 처리
|
||||
import os
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
if document.pdf_path.startswith('/'):
|
||||
file_path = document.pdf_path
|
||||
else:
|
||||
# PDF 파일은 /app/uploads에 저장됨
|
||||
file_path = os.path.join("/app", document.pdf_path)
|
||||
|
||||
print(f"🔍 PDF 파일 경로 확인: {file_path}")
|
||||
print(f"📁 데이터베이스 PDF 경로: {document.pdf_path}")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"🚫 PDF 파일이 디스크에 없음: {file_path}")
|
||||
# 디렉토리 내용 확인
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if os.path.exists(dir_path):
|
||||
files = os.listdir(dir_path)
|
||||
print(f"📂 디렉토리 내용: {files[:10]}")
|
||||
else:
|
||||
print(f"📂 디렉토리도 없음: {dir_path}")
|
||||
raise HTTPException(status_code=404, detail="PDF file not found on disk")
|
||||
|
||||
# PDF 인라인 표시를 위한 헤더 설정
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
response = FileResponse(
|
||||
path=file_path,
|
||||
media_type='application/pdf',
|
||||
filename=f"{document.title}.pdf"
|
||||
)
|
||||
|
||||
# 브라우저에서 인라인으로 표시하도록 설정 (다운로드 방지)
|
||||
response.headers["Content-Disposition"] = f"inline; filename=\"{document.title}.pdf\""
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN" # iframe 허용
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{document_id}/search-in-content")
|
||||
async def search_in_document_content(
|
||||
document_id: str,
|
||||
q: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 문서 내에서 텍스트 검색 및 페이지 위치 반환"""
|
||||
try:
|
||||
doc_uuid = UUID(document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid document ID format")
|
||||
|
||||
# 문서 조회
|
||||
query = select(Document).where(Document.id == doc_uuid)
|
||||
result = await db.execute(query)
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# 권한 확인
|
||||
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
search_results = []
|
||||
|
||||
# HTML 파일에서 검색 (OCR 결과)
|
||||
if document.html_path:
|
||||
try:
|
||||
import os
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
# 절대 경로 처리
|
||||
if document.html_path.startswith('/'):
|
||||
html_file_path = document.html_path
|
||||
else:
|
||||
html_file_path = os.path.join("/app/data/documents", document.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')
|
||||
|
||||
# 페이지 구분자 찾기 (OCR 결과에서 페이지 정보)
|
||||
pages = soup.find_all(['div', 'section'], class_=re.compile(r'page|Page'))
|
||||
|
||||
if not pages:
|
||||
# 페이지 구분이 없으면 전체 텍스트에서 검색
|
||||
text_content = soup.get_text()
|
||||
matches = []
|
||||
start = 0
|
||||
while True:
|
||||
pos = text_content.lower().find(q.lower(), start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
# 컨텍스트 추출
|
||||
context_start = max(0, pos - 100)
|
||||
context_end = min(len(text_content), pos + len(q) + 100)
|
||||
context = text_content[context_start:context_end]
|
||||
|
||||
matches.append({
|
||||
"page": 1,
|
||||
"position": pos,
|
||||
"context": context,
|
||||
"match_text": text_content[pos:pos + len(q)]
|
||||
})
|
||||
|
||||
start = pos + 1
|
||||
if len(matches) >= 10: # 최대 10개 결과
|
||||
break
|
||||
|
||||
search_results.extend(matches)
|
||||
else:
|
||||
# 페이지별로 검색
|
||||
for page_num, page_elem in enumerate(pages, 1):
|
||||
page_text = page_elem.get_text()
|
||||
matches = []
|
||||
start = 0
|
||||
|
||||
while True:
|
||||
pos = page_text.lower().find(q.lower(), start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
# 컨텍스트 추출
|
||||
context_start = max(0, pos - 100)
|
||||
context_end = min(len(page_text), pos + len(q) + 100)
|
||||
context = page_text[context_start:context_end]
|
||||
|
||||
matches.append({
|
||||
"page": page_num,
|
||||
"position": pos,
|
||||
"context": context,
|
||||
"match_text": page_text[pos:pos + len(q)]
|
||||
})
|
||||
|
||||
start = pos + 1
|
||||
if len(matches) >= 5: # 페이지당 최대 5개
|
||||
break
|
||||
|
||||
search_results.extend(matches)
|
||||
|
||||
except Exception as e:
|
||||
print(f"HTML 검색 오류: {e}")
|
||||
|
||||
return {
|
||||
"document_id": document_id,
|
||||
"query": q,
|
||||
"total_matches": len(search_results),
|
||||
"matches": search_results[:20], # 최대 20개 결과
|
||||
"has_pdf": bool(document.pdf_path),
|
||||
"has_html": bool(document.html_path)
|
||||
}
|
||||
|
||||
|
||||
class UpdateDocumentRequest(BaseModel):
|
||||
"""문서 업데이트 요청"""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
matched_pdf_id: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@router.put("/{document_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
document_id: str,
|
||||
update_data: UpdateDocumentRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 정보 업데이트"""
|
||||
try:
|
||||
doc_uuid = UUID(document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid document ID format")
|
||||
|
||||
# 문서 조회
|
||||
result = await db.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.tags), selectinload(Document.uploader), selectinload(Document.book))
|
||||
.where(Document.id == doc_uuid)
|
||||
)
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 권한 확인 (관리자이거나 문서 소유자)
|
||||
if not current_user.is_admin and document.uploaded_by != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to update this document"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_fields = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_fields.items():
|
||||
if field == "matched_pdf_id":
|
||||
# PDF 매칭 처리
|
||||
if value:
|
||||
try:
|
||||
pdf_uuid = UUID(value)
|
||||
# PDF 문서가 실제로 존재하는지 확인
|
||||
pdf_result = await db.execute(select(Document).where(Document.id == pdf_uuid))
|
||||
pdf_doc = pdf_result.scalar_one_or_none()
|
||||
if pdf_doc:
|
||||
setattr(document, field, pdf_uuid)
|
||||
except ValueError:
|
||||
# 잘못된 UUID 형식이면 무시
|
||||
pass
|
||||
else:
|
||||
# None으로 설정하여 매칭 해제
|
||||
setattr(document, field, None)
|
||||
elif field == "tags":
|
||||
# 태그 처리
|
||||
if value is not None:
|
||||
# 기존 태그 관계 제거
|
||||
document.tags.clear()
|
||||
|
||||
# 새 태그 추가
|
||||
for tag_name in value:
|
||||
tag_name = tag_name.strip()
|
||||
if tag_name:
|
||||
# 기존 태그 찾기 또는 생성
|
||||
tag_result = await db.execute(select(Tag).where(Tag.name == tag_name))
|
||||
tag = tag_result.scalar_one_or_none()
|
||||
|
||||
if not tag:
|
||||
tag = Tag(
|
||||
name=tag_name,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
|
||||
document.tags.append(tag)
|
||||
else:
|
||||
# 일반 필드 업데이트
|
||||
setattr(document, field, value)
|
||||
|
||||
# 업데이트 시간 갱신
|
||||
document.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(document)
|
||||
|
||||
# 응답 데이터 생성
|
||||
return DocumentResponse(
|
||||
id=str(document.id),
|
||||
title=document.title,
|
||||
description=document.description,
|
||||
html_path=document.html_path,
|
||||
pdf_path=document.pdf_path,
|
||||
thumbnail_path=document.thumbnail_path,
|
||||
file_size=document.file_size,
|
||||
page_count=document.page_count,
|
||||
language=document.language,
|
||||
is_public=document.is_public,
|
||||
is_processed=document.is_processed,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
document_date=document.document_date,
|
||||
uploader_name=document.uploader.full_name or document.uploader.email if document.uploader else "Unknown",
|
||||
tags=[tag.name for tag in document.tags],
|
||||
book_id=str(document.book.id) if document.book else None,
|
||||
book_title=document.book.title if document.book else None,
|
||||
book_author=document.book.author if document.book else None,
|
||||
sort_order=document.sort_order,
|
||||
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None,
|
||||
original_filename=document.original_filename
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/download")
|
||||
async def download_document(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 파일 다운로드"""
|
||||
try:
|
||||
doc_uuid = UUID(document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid document ID format")
|
||||
|
||||
# 문서 조회
|
||||
query = select(Document).where(Document.id == doc_uuid)
|
||||
result = await db.execute(query)
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# 권한 확인
|
||||
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# 다운로드할 파일 경로 결정 (PDF 우선, 없으면 HTML)
|
||||
file_path = document.pdf_path if document.pdf_path else document.html_path
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="Document file not found")
|
||||
|
||||
# 파일 응답
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# 파일명 설정
|
||||
filename = document.original_filename
|
||||
if not filename:
|
||||
extension = '.pdf' if document.pdf_path else '.html'
|
||||
filename = f"{document.title}{extension}"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type='application/octet-stream'
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/navigation")
|
||||
async def get_document_navigation(
|
||||
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))
|
||||
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"
|
||||
)
|
||||
|
||||
navigation_info = {
|
||||
"current": {
|
||||
"id": str(current_doc.id),
|
||||
"title": current_doc.title,
|
||||
"sort_order": current_doc.sort_order
|
||||
},
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"book_info": None
|
||||
}
|
||||
|
||||
# 서적에 속한 문서인 경우 이전/다음 문서 조회
|
||||
if current_doc.book_id:
|
||||
# 같은 서적의 HTML 문서들만 조회 (PDF 제외)
|
||||
book_docs_result = await db.execute(
|
||||
select(Document)
|
||||
.where(
|
||||
and_(
|
||||
Document.book_id == current_doc.book_id,
|
||||
Document.html_path.isnot(None), # HTML 문서만
|
||||
or_(Document.is_public == True, Document.uploaded_by == current_user.id, current_user.is_admin == True)
|
||||
)
|
||||
)
|
||||
.order_by(Document.sort_order.asc().nulls_last(), Document.created_at.asc())
|
||||
)
|
||||
book_docs = book_docs_result.scalars().all()
|
||||
|
||||
# 현재 문서의 인덱스 찾기
|
||||
current_index = None
|
||||
for i, doc in enumerate(book_docs):
|
||||
if doc.id == current_doc.id:
|
||||
current_index = i
|
||||
break
|
||||
|
||||
if current_index is not None:
|
||||
# 이전 문서
|
||||
if current_index > 0:
|
||||
prev_doc = book_docs[current_index - 1]
|
||||
navigation_info["previous"] = {
|
||||
"id": str(prev_doc.id),
|
||||
"title": prev_doc.title,
|
||||
"sort_order": prev_doc.sort_order
|
||||
}
|
||||
|
||||
# 다음 문서
|
||||
if current_index < len(book_docs) - 1:
|
||||
next_doc = book_docs[current_index + 1]
|
||||
navigation_info["next"] = {
|
||||
"id": str(next_doc.id),
|
||||
"title": next_doc.title,
|
||||
"sort_order": next_doc.sort_order
|
||||
}
|
||||
|
||||
# 서적 정보 추가
|
||||
from ...models.book import Book
|
||||
book_result = await db.execute(select(Book).where(Book.id == current_doc.book_id))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if book:
|
||||
navigation_info["book_info"] = {
|
||||
"id": str(book.id),
|
||||
"title": book.title,
|
||||
"author": book.author
|
||||
}
|
||||
|
||||
return navigation_info
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
async def delete_document(
|
||||
document_id: str,
|
||||
@@ -323,11 +986,11 @@ async def delete_document(
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 권한 확인 (업로더 또는 관리자만)
|
||||
if document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
# 권한 확인 (관리자만)
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
detail="Only administrators can delete documents"
|
||||
)
|
||||
|
||||
# 파일 삭제
|
||||
@@ -338,9 +1001,64 @@ async def delete_document(
|
||||
if document.thumbnail_path and os.path.exists(document.thumbnail_path):
|
||||
os.remove(document.thumbnail_path)
|
||||
|
||||
# 데이터베이스에서 삭제
|
||||
await db.execute(delete(Document).where(Document.id == document_id))
|
||||
await db.commit()
|
||||
# 관련 데이터 안전하게 삭제 (외래키 제약 조건 해결)
|
||||
from ...models.highlight import Highlight
|
||||
from ...models.note import Note
|
||||
from ...models.bookmark import Bookmark
|
||||
|
||||
try:
|
||||
print(f"DEBUG: Starting deletion of document {document_id}")
|
||||
|
||||
# 0. PDF 참조 해제 (외래키 제약조건 해결)
|
||||
# 이 문서를 matched_pdf_id로 참조하는 모든 문서의 참조를 NULL로 설정
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.matched_pdf_id == document_id)
|
||||
.values(matched_pdf_id=None)
|
||||
)
|
||||
print(f"DEBUG: Cleared matched_pdf_id references to document {document_id}")
|
||||
|
||||
# 1. 먼저 해당 문서의 모든 하이라이트 ID 조회
|
||||
highlight_ids_result = await db.execute(select(Highlight.id).where(Highlight.document_id == document_id))
|
||||
highlight_ids = [row[0] for row in highlight_ids_result.fetchall()]
|
||||
print(f"DEBUG: Found {len(highlight_ids)} highlights to delete")
|
||||
|
||||
# 2. 하이라이트에 연결된 모든 메모 삭제
|
||||
total_notes_deleted = 0
|
||||
for highlight_id in highlight_ids:
|
||||
note_result = await db.execute(delete(Note).where(Note.highlight_id == highlight_id))
|
||||
total_notes_deleted += note_result.rowcount
|
||||
print(f"DEBUG: Deleted {total_notes_deleted} notes by highlight_id")
|
||||
|
||||
# 3. document_id로 직접 연결된 메모도 삭제 (혹시 있다면)
|
||||
direct_note_result = await db.execute(delete(Note).where(Note.document_id == document_id))
|
||||
print(f"DEBUG: Deleted {direct_note_result.rowcount} notes by document_id")
|
||||
|
||||
# 4. 북마크 삭제
|
||||
bookmark_result = await db.execute(delete(Bookmark).where(Bookmark.document_id == document_id))
|
||||
print(f"DEBUG: Deleted {bookmark_result.rowcount} bookmarks")
|
||||
|
||||
# 5. 하이라이트 삭제 (이제 메모가 모두 삭제되었으므로 안전)
|
||||
highlight_result = await db.execute(delete(Highlight).where(Highlight.document_id == document_id))
|
||||
print(f"DEBUG: Deleted {highlight_result.rowcount} highlights")
|
||||
|
||||
# 6. 문서-태그 관계는 SQLAlchemy가 자동으로 처리
|
||||
|
||||
# 7. 마지막으로 문서 삭제
|
||||
doc_result = await db.execute(delete(Document).where(Document.id == document_id))
|
||||
print(f"DEBUG: Deleted {doc_result.rowcount} documents")
|
||||
|
||||
# 8. 커밋
|
||||
await db.commit()
|
||||
print(f"DEBUG: Successfully deleted document {document_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to delete document {document_id}: {e}")
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete document: {str(e)}"
|
||||
)
|
||||
|
||||
return {"message": "Document deleted successfully"}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class UpdateHighlightRequest(BaseModel):
|
||||
"""하이라이트 업데이트 요청"""
|
||||
highlight_color: Optional[str] = None
|
||||
highlight_type: Optional[str] = None
|
||||
note: Optional[str] = None # 메모 업데이트 지원
|
||||
|
||||
|
||||
class HighlightResponse(BaseModel):
|
||||
@@ -62,6 +63,10 @@ class HighlightResponse(BaseModel):
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/", response_model=HighlightResponse)
|
||||
async def create_highlight(
|
||||
highlight_data: CreateHighlightRequest,
|
||||
@@ -155,63 +160,72 @@ async def get_document_highlights(
|
||||
try:
|
||||
print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}")
|
||||
|
||||
# 임시로 빈 배열 반환 (테스트용)
|
||||
return []
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
# 원래 코드는 주석 처리
|
||||
# # 문서 존재 및 권한 확인
|
||||
# result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
# document = result.scalar_one_or_none()
|
||||
#
|
||||
# if not document:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_404_NOT_FOUND,
|
||||
# detail="Document not found"
|
||||
# )
|
||||
#
|
||||
# # 문서 접근 권한 확인
|
||||
# if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_403_FORBIDDEN,
|
||||
# detail="Not enough permissions to access this document"
|
||||
# )
|
||||
#
|
||||
# # 사용자의 하이라이트만 조회 (notes 로딩 제거)
|
||||
# result = await db.execute(
|
||||
# select(Highlight)
|
||||
# .where(
|
||||
# and_(
|
||||
# Highlight.document_id == document_id,
|
||||
# Highlight.user_id == current_user.id
|
||||
# )
|
||||
# )
|
||||
# .order_by(Highlight.start_offset)
|
||||
# )
|
||||
# highlights = result.scalars().all()
|
||||
#
|
||||
# # 응답 데이터 변환
|
||||
# response_data = []
|
||||
# for highlight in highlights:
|
||||
# highlight_data = HighlightResponse(
|
||||
# id=str(highlight.id),
|
||||
# user_id=str(highlight.user_id),
|
||||
# document_id=str(highlight.document_id),
|
||||
# start_offset=highlight.start_offset,
|
||||
# end_offset=highlight.end_offset,
|
||||
# selected_text=highlight.selected_text,
|
||||
# element_selector=highlight.element_selector,
|
||||
# start_container_xpath=highlight.start_container_xpath,
|
||||
# end_container_xpath=highlight.end_container_xpath,
|
||||
# highlight_color=highlight.highlight_color,
|
||||
# highlight_type=highlight.highlight_type,
|
||||
# created_at=highlight.created_at,
|
||||
# updated_at=highlight.updated_at,
|
||||
# note=None
|
||||
# )
|
||||
# # 메모는 별도 API에서 조회하므로 여기서는 처리하지 않음
|
||||
# response_data.append(highlight_data)
|
||||
#
|
||||
# return response_data
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 사용자의 하이라이트만 조회 (연관된 메모도 함께 로드)
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.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}")
|
||||
@@ -288,7 +302,7 @@ async def update_highlight(
|
||||
"""하이라이트 업데이트"""
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(selectinload(Highlight.user))
|
||||
.options(selectinload(Highlight.user), selectinload(Highlight.notes))
|
||||
.where(Highlight.id == highlight_id)
|
||||
)
|
||||
highlight = result.scalar_one_or_none()
|
||||
@@ -312,6 +326,23 @@ async def update_highlight(
|
||||
if highlight_data.highlight_type:
|
||||
highlight.highlight_type = highlight_data.highlight_type
|
||||
|
||||
# 메모 업데이트 처리
|
||||
if highlight_data.note is not None:
|
||||
if highlight.notes:
|
||||
# 기존 메모 업데이트
|
||||
highlight.notes.content = highlight_data.note
|
||||
highlight.notes.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# 새 메모 생성
|
||||
new_note = Note(
|
||||
user_id=current_user.id,
|
||||
document_id=highlight.document_id,
|
||||
highlight_id=highlight.id,
|
||||
content=highlight_data.note,
|
||||
tags=""
|
||||
)
|
||||
db.add(new_note)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(highlight)
|
||||
|
||||
@@ -366,9 +397,30 @@ async def delete_highlight(
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 하이라이트 삭제 (CASCADE로 메모도 함께 삭제됨)
|
||||
await db.execute(delete(Highlight).where(Highlight.id == highlight_id))
|
||||
await db.commit()
|
||||
# 안전한 하이라이트 삭제 (연결된 메모 먼저 삭제)
|
||||
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"}
|
||||
|
||||
|
||||
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"}
|
||||
@@ -1,452 +1,532 @@
|
||||
"""
|
||||
메모 관리 API 라우터
|
||||
노트 문서 (Note Document) 관리 API
|
||||
|
||||
용어 정의:
|
||||
- 노트 (Note Document): 독립적인 HTML 기반 문서 작성
|
||||
- 노트북 (Notebook): 노트들을 그룹화하는 폴더
|
||||
- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API - highlights.py)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, and_, or_
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from sqlalchemy import func, desc, asc, select
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
# import markdown # 임시로 비활성화
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.database import get_sync_db
|
||||
from ...models.note_document import (
|
||||
NoteDocument,
|
||||
NoteDocumentCreate,
|
||||
NoteDocumentUpdate,
|
||||
NoteDocumentResponse,
|
||||
NoteDocumentListItem,
|
||||
NoteStats
|
||||
)
|
||||
from ...models.user import User
|
||||
from ...models.highlight import Highlight
|
||||
from ...models.note import Note
|
||||
from ...models.document import Document
|
||||
from ..dependencies import get_current_active_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateNoteRequest(BaseModel):
|
||||
"""메모 생성 요청"""
|
||||
highlight_id: str
|
||||
content: str
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class UpdateNoteRequest(BaseModel):
|
||||
"""메모 업데이트 요청"""
|
||||
content: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
is_private: Optional[bool] = None
|
||||
|
||||
|
||||
class NoteResponse(BaseModel):
|
||||
"""메모 응답"""
|
||||
id: str
|
||||
user_id: str
|
||||
highlight_id: str
|
||||
content: str
|
||||
is_private: bool
|
||||
tags: Optional[List[str]]
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
# 연결된 하이라이트 정보
|
||||
highlight: dict
|
||||
# 문서 정보
|
||||
document: dict
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
from ..dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# === 하이라이트 메모 (Highlight Memo) API ===
|
||||
# 용어 정의: 하이라이트에 달리는 짧은 코멘트
|
||||
|
||||
@router.post("/", response_model=NoteResponse)
|
||||
async def create_note(
|
||||
note_data: CreateNoteRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@router.post("/")
|
||||
def create_note(
|
||||
note_data: dict,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""메모 생성 (하이라이트에 연결)"""
|
||||
# 하이라이트 존재 및 소유권 확인
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(joinedload(Highlight.document))
|
||||
.where(Highlight.id == note_data.highlight_id)
|
||||
)
|
||||
highlight = result.scalar_one_or_none()
|
||||
"""하이라이트 메모 생성"""
|
||||
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=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"
|
||||
)
|
||||
|
||||
# 중복 확인 제거 - 하나의 하이라이트에 여러 메모 허용
|
||||
raise HTTPException(status_code=404, detail="하이라이트를 찾을 수 없습니다")
|
||||
|
||||
# 메모 생성
|
||||
note = Note(
|
||||
highlight_id=note_data.highlight_id,
|
||||
content=note_data.content,
|
||||
tags=note_data.tags or []
|
||||
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)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
# 응답 데이터 생성
|
||||
response_data = NoteResponse(
|
||||
id=str(note.id),
|
||||
user_id=str(note.highlight.user_id),
|
||||
highlight_id=str(note.highlight_id),
|
||||
content=note.content,
|
||||
is_private=note.is_private,
|
||||
tags=note.tags,
|
||||
created_at=note.created_at,
|
||||
updated_at=note.updated_at,
|
||||
highlight={},
|
||||
document={}
|
||||
)
|
||||
response_data.highlight = {
|
||||
"id": str(highlight.id),
|
||||
"selected_text": highlight.selected_text,
|
||||
"highlight_color": highlight.highlight_color,
|
||||
"start_offset": highlight.start_offset,
|
||||
"end_offset": highlight.end_offset
|
||||
}
|
||||
response_data.document = {
|
||||
"id": str(highlight.document.id),
|
||||
"title": highlight.document.title
|
||||
}
|
||||
|
||||
return response_data
|
||||
return note
|
||||
|
||||
|
||||
@router.get("/", response_model=List[NoteResponse])
|
||||
async def list_user_notes(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
document_id: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자의 모든 메모 조회 (검색 가능)"""
|
||||
query = (
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.join(Highlight)
|
||||
.where(Highlight.user_id == current_user.id)
|
||||
)
|
||||
|
||||
# 문서 필터링
|
||||
if document_id:
|
||||
query = query.where(Highlight.document_id == document_id)
|
||||
|
||||
# 태그 필터링
|
||||
if tag:
|
||||
query = query.where(Note.tags.contains([tag]))
|
||||
|
||||
# 검색 필터링 (메모 내용 + 하이라이트된 텍스트)
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Note.content.ilike(f"%{search}%"),
|
||||
Highlight.selected_text.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Note.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
notes = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for note in notes:
|
||||
note_data = NoteResponse(
|
||||
id=str(note.id),
|
||||
user_id=str(note.highlight.user_id),
|
||||
highlight_id=str(note.highlight_id),
|
||||
content=note.content,
|
||||
is_private=note.is_private,
|
||||
tags=note.tags,
|
||||
created_at=note.created_at,
|
||||
updated_at=note.updated_at,
|
||||
highlight={},
|
||||
document={}
|
||||
)
|
||||
note_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
note_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
response_data.append(note_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/{note_id}", response_model=NoteResponse)
|
||||
async def get_note(
|
||||
@router.put("/{note_id}")
|
||||
def update_note(
|
||||
note_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
note_data: dict,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""메모 상세 조회"""
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.where(Note.id == note_id)
|
||||
)
|
||||
note = result.scalar_one_or_none()
|
||||
"""하이라이트 메모 업데이트"""
|
||||
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=status.HTTP_404_NOT_FOUND,
|
||||
detail="Note not found"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
# 소유자 확인
|
||||
if note.highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
# 메모 업데이트
|
||||
if 'content' in note_data:
|
||||
note.content = note_data['content']
|
||||
if 'tags' in note_data:
|
||||
note.tags = note_data['tags']
|
||||
|
||||
response_data = NoteResponse(
|
||||
id=str(note.id),
|
||||
user_id=str(note.highlight.user_id),
|
||||
highlight_id=str(note.highlight_id),
|
||||
content=note.content,
|
||||
is_private=note.is_private,
|
||||
tags=note.tags,
|
||||
created_at=note.created_at,
|
||||
updated_at=note.updated_at,
|
||||
highlight={},
|
||||
document={}
|
||||
)
|
||||
response_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
response_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
note.updated_at = datetime.utcnow()
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{note_id}", response_model=NoteResponse)
|
||||
async def update_note(
|
||||
note_id: str,
|
||||
note_data: UpdateNoteRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 업데이트"""
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.where(Note.id == note_id)
|
||||
)
|
||||
note = result.scalar_one_or_none()
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Note not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if note.highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 업데이트
|
||||
if note_data.content is not None:
|
||||
note.content = note_data.content
|
||||
if note_data.tags is not None:
|
||||
note.tags = note_data.tags
|
||||
if note_data.is_private is not None:
|
||||
note.is_private = note_data.is_private
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
|
||||
response_data = NoteResponse(
|
||||
id=str(note.id),
|
||||
user_id=str(note.highlight.user_id),
|
||||
highlight_id=str(note.highlight_id),
|
||||
content=note.content,
|
||||
is_private=note.is_private,
|
||||
tags=note.tags,
|
||||
created_at=note.created_at,
|
||||
updated_at=note.updated_at,
|
||||
highlight={},
|
||||
document={}
|
||||
)
|
||||
response_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
response_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
return note
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
async def delete_note(
|
||||
def delete_highlight_note(
|
||||
note_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""메모 삭제 (하이라이트는 유지)"""
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(joinedload(Note.highlight))
|
||||
.where(Note.id == note_id)
|
||||
)
|
||||
note = result.scalar_one_or_none()
|
||||
"""하이라이트 메모 삭제"""
|
||||
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=status.HTTP_404_NOT_FOUND,
|
||||
detail="Note not found"
|
||||
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 note.highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
if published_only:
|
||||
query = query.filter(NoteDocument.is_published == True)
|
||||
|
||||
# 메모만 삭제 (하이라이트는 유지)
|
||||
await db.execute(delete(Note).where(Note.id == note_id))
|
||||
await db.commit()
|
||||
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("/document/{document_id}", response_model=List[NoteResponse])
|
||||
async def get_document_notes(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@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)
|
||||
):
|
||||
"""특정 문서의 모든 메모 조회"""
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
"""노트의 자식 노트들 조회"""
|
||||
children = db.query(NoteDocument).filter(
|
||||
NoteDocument.parent_note_id == note_id
|
||||
).order_by(asc(NoteDocument.sort_order), desc(NoteDocument.updated_at)).all()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
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)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 해당 문서의 사용자 메모 조회
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.join(Highlight)
|
||||
.where(
|
||||
and_(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.order_by(Highlight.start_offset)
|
||||
)
|
||||
notes = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for note in notes:
|
||||
note_data = NoteResponse(
|
||||
id=str(note.id),
|
||||
user_id=str(note.highlight.user_id),
|
||||
highlight_id=str(note.highlight_id),
|
||||
content=note.content,
|
||||
is_private=note.is_private,
|
||||
tags=note.tags,
|
||||
created_at=note.created_at,
|
||||
updated_at=note.updated_at,
|
||||
highlight={},
|
||||
document={}
|
||||
)
|
||||
note_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
note_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
response_data.append(note_data)
|
||||
|
||||
return response_data
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tags/popular")
|
||||
async def get_popular_note_tags(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@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)
|
||||
):
|
||||
"""인기 메모 태그 조회"""
|
||||
# 사용자의 메모에서 태그 빈도 계산
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.join(Highlight)
|
||||
.where(Highlight.user_id == current_user.id)
|
||||
"""노트를 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}"}
|
||||
)
|
||||
notes = result.scalars().all()
|
||||
|
||||
@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
|
||||
|
||||
# 태그 빈도 계산
|
||||
tag_counts = {}
|
||||
for note in notes:
|
||||
if note.tags:
|
||||
for tag in note.tags:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 빈도순 정렬
|
||||
popular_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
# 메타데이터 포함한 마크다운
|
||||
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 ''}
|
||||
"""
|
||||
|
||||
return [{"tag": tag, "count": count} for tag, count in popular_tags]
|
||||
filename = f"{note.title.replace(' ', '_')}.md"
|
||||
|
||||
return Response(
|
||||
content=markdown_content,
|
||||
media_type="text/plain",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
|
||||
@@ -47,7 +49,7 @@ router = APIRouter()
|
||||
@router.get("/", response_model=SearchResponse)
|
||||
async def search_all(
|
||||
q: str = Query(..., description="검색어"),
|
||||
type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, highlight"),
|
||||
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),
|
||||
@@ -63,16 +65,36 @@ async def search_all(
|
||||
document_results = await search_documents(q, document_id, tag, current_user, db)
|
||||
results.extend(document_results)
|
||||
|
||||
# 2. 메모 검색
|
||||
# 2. 노트 문서 검색
|
||||
if not type_filter or type_filter == "note":
|
||||
note_results = await search_notes(q, document_id, tag, current_user, db)
|
||||
note_results = await search_note_documents(q, current_user, db)
|
||||
results.extend(note_results)
|
||||
|
||||
# 3. 하이라이트 검색
|
||||
# 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)
|
||||
|
||||
@@ -352,3 +374,298 @@ async def get_search_suggestions(
|
||||
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)}"
|
||||
)
|
||||
@@ -1,92 +1,276 @@
|
||||
"""
|
||||
사용자 관리 API 라우터
|
||||
사용자 관리 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from typing import List
|
||||
from 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 ...schemas.auth import UserInfo
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""프로필 업데이트 요청"""
|
||||
full_name: str = None
|
||||
theme: str = None
|
||||
language: str = None
|
||||
timezone: str = None
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""사용자 정보 업데이트 요청 (관리자용)"""
|
||||
full_name: str = None
|
||||
is_active: bool = None
|
||||
is_admin: bool = None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserInfo)
|
||||
async def get_profile(
|
||||
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 UserInfo.from_orm(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.put("/profile", response_model=UserInfo)
|
||||
async def update_profile(
|
||||
@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_data = {}
|
||||
"""현재 사용자 프로필 업데이트"""
|
||||
update_fields = profile_data.model_dump(exclude_unset=True)
|
||||
|
||||
if profile_data.full_name is not None:
|
||||
update_data["full_name"] = profile_data.full_name
|
||||
if profile_data.theme is not None:
|
||||
update_data["theme"] = profile_data.theme
|
||||
if profile_data.language is not None:
|
||||
update_data["language"] = profile_data.language
|
||||
if profile_data.timezone is not None:
|
||||
update_data["timezone"] = profile_data.timezone
|
||||
for field, value in update_fields.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
if update_data:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == current_user.id)
|
||||
.values(**update_data)
|
||||
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"
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserInfo.from_orm(current_user)
|
||||
# 새 비밀번호 설정
|
||||
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[UserInfo])
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
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()))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
return [UserInfo.from_orm(user) for user in users]
|
||||
|
||||
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.get("/{user_id}", response_model=UserInfo)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
@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()
|
||||
|
||||
@@ -96,18 +280,34 @@ async def get_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserInfo.from_orm(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.put("/{user_id}", response_model=UserInfo)
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
user_data: UpdateUserRequest,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
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()
|
||||
|
||||
@@ -117,42 +317,55 @@ async def update_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# 자기 자신의 관리자 권한은 제거할 수 없음
|
||||
if user.id == admin_user.id and user_data.is_admin is False:
|
||||
# 권한 확인 (root만 admin/root 계정 수정 가능)
|
||||
if user.role in ["admin", "root"] and current_user.role != "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove admin privileges from yourself"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only root users can modify admin accounts"
|
||||
)
|
||||
|
||||
# 업데이트할 데이터 준비
|
||||
update_data = {}
|
||||
if user_data.full_name is not None:
|
||||
update_data["full_name"] = user_data.full_name
|
||||
if user_data.is_active is not None:
|
||||
update_data["is_active"] = user_data.is_active
|
||||
if user_data.is_admin is not None:
|
||||
update_data["is_admin"] = user_data.is_admin
|
||||
# 업데이트할 필드들 적용
|
||||
update_fields = user_data.model_dump(exclude_unset=True)
|
||||
|
||||
if update_data:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
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)
|
||||
|
||||
return UserInfo.from_orm(user)
|
||||
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,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
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()
|
||||
|
||||
@@ -162,15 +375,28 @@ async def delete_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# 자기 자신은 삭제할 수 없음
|
||||
if user.id == admin_user.id:
|
||||
# 자기 자신 삭제 방지
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete yourself"
|
||||
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"}
|
||||
return {"message": "User deleted successfully"}
|
||||
@@ -28,6 +28,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
데이터베이스 설정 및 연결
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy import MetaData
|
||||
from typing import AsyncGenerator
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
||||
from sqlalchemy import MetaData, create_engine
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
from .config import settings
|
||||
|
||||
@@ -35,6 +35,15 @@ engine = create_async_engine(
|
||||
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,
|
||||
@@ -42,9 +51,16 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
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
|
||||
@@ -55,6 +71,18 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
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
|
||||
|
||||
@@ -24,11 +24,18 @@ def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
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)
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import uvicorn
|
||||
|
||||
from .core.config import settings
|
||||
from .core.database import init_db
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup, todos
|
||||
from .api.routes import note_documents, note_links
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -29,12 +30,12 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS 설정 (개발용 - 더 관대한 설정)
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 개발용으로 모든 오리진 허용
|
||||
allow_origins=settings.ALLOWED_ORIGINS if hasattr(settings, 'ALLOWED_ORIGINS') else ["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -42,13 +43,26 @@ app.add_middleware(
|
||||
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/notes", 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("/")
|
||||
|
||||
@@ -3,15 +3,33 @@
|
||||
"""
|
||||
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}')>"
|
||||
@@ -24,13 +24,17 @@ 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=False) # HTML 파일 경로
|
||||
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) # 바이트 단위
|
||||
@@ -51,6 +55,8 @@ class Document(Base):
|
||||
document_date = Column(DateTime(timezone=True), nullable=True) # 문서 작성일 (사용자 입력)
|
||||
|
||||
# 관계
|
||||
book = relationship("Book", back_populates="documents") # 서적 관계
|
||||
category = relationship("BookCategory", back_populates="documents") # 소분류 관계
|
||||
uploader = relationship("User", backref="uploaded_documents")
|
||||
tags = relationship("Tag", secondary=document_tags, back_populates="documents")
|
||||
highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan")
|
||||
|
||||
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]}...')>"
|
||||
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])
|
||||
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]}...')>"
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
사용자 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text
|
||||
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
|
||||
|
||||
@@ -20,6 +21,17 @@ class User(Base):
|
||||
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())
|
||||
@@ -30,5 +42,10 @@ class User(Base):
|
||||
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}')>"
|
||||
|
||||
@@ -33,10 +33,16 @@ class UserInfo(BaseModel):
|
||||
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:
|
||||
|
||||
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]
|
||||
@@ -1,504 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>압력용기 설계 매뉴얼 - Pressure Vessel Design Manual</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.language-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
}
|
||||
.language-toggle:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.content {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
h2 {
|
||||
color: #34495e;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h3 {
|
||||
color: #7f8c8d;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.publisher-info {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.toc {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.toc-item {
|
||||
margin: 5px 0;
|
||||
padding: 5px 0;
|
||||
}
|
||||
.procedure {
|
||||
margin-left: 30px;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
.chapter {
|
||||
font-weight: bold;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.page-number {
|
||||
float: right;
|
||||
color: #999;
|
||||
}
|
||||
.copyright {
|
||||
background: #e9ecef;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.preface {
|
||||
text-align: justify;
|
||||
line-height: 1.8;
|
||||
}
|
||||
/* 한국어 스타일 */
|
||||
[lang="ko"] {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="language-toggle" onclick="toggleLanguage()">🌐 한국어/English</button>
|
||||
|
||||
<div class="content">
|
||||
<!-- 영어 버전 -->
|
||||
<div id="english-content">
|
||||
<h1>Pressure Vessel Design Manual</h1>
|
||||
<h2>Fourth Edition</h2>
|
||||
|
||||
<div class="publisher-info">
|
||||
<p><strong>Dennis R. Moss<br>Michael Basic</strong></p>
|
||||
<p>AMSTERDAM • BOSTON • HEIDELBERG • LONDON • NEW YORK • OXFORD<br>
|
||||
PARIS • SAN DIEGO • SAN FRANCISCO • SINGAPORE • SYDNEY • TOKYO</p>
|
||||
<p>Butterworth-Heinemann is an imprint of Elsevier</p>
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
<h3>Copyright Information</h3>
|
||||
<p>Fourth edition 2013<br>
|
||||
Copyright © 2013 Elsevier Inc. All rights reserved</p>
|
||||
<p>No part of this publication may be reproduced, stored in a retrieval system or transmitted in any form or by any means electronic, mechanical, photocopying, recording or otherwise without the prior written permission of the publisher</p>
|
||||
<p>ISBN: 978-0-12-387000-1</p>
|
||||
</div>
|
||||
|
||||
<h2>Contents</h2>
|
||||
<div class="toc">
|
||||
<div class="chapter">Preface to the 4th Edition <span class="page-number">ix</span></div>
|
||||
|
||||
<div class="chapter">1: General Topics <span class="page-number">1</span></div>
|
||||
<div class="procedure">Design Philosophy <span class="page-number">1</span></div>
|
||||
<div class="procedure">Stress Analysis <span class="page-number">2</span></div>
|
||||
<div class="procedure">Stress/Failure Theories <span class="page-number">3</span></div>
|
||||
<div class="procedure">Failures in Pressure Vessels <span class="page-number">7</span></div>
|
||||
<div class="procedure">Loadings <span class="page-number">8</span></div>
|
||||
<div class="procedure">Stress <span class="page-number">10</span></div>
|
||||
<div class="procedure">Thermal Stresses <span class="page-number">13</span></div>
|
||||
<div class="procedure">Discontinuity Stresses <span class="page-number">14</span></div>
|
||||
<div class="procedure">Fatigue Analysis for Cyclic Service <span class="page-number">15</span></div>
|
||||
<div class="procedure">Creep <span class="page-number">24</span></div>
|
||||
<div class="procedure">Cryogenic Applications <span class="page-number">32</span></div>
|
||||
<div class="procedure">Service Considerations <span class="page-number">34</span></div>
|
||||
<div class="procedure">Miscellaneous Design Considerations <span class="page-number">35</span></div>
|
||||
<div class="procedure">Items to be Included in a User's Design Specification (UDS) for ASME VIII-2 Vessels <span class="page-number">35</span></div>
|
||||
<div class="procedure">References <span class="page-number">36</span></div>
|
||||
|
||||
<div class="chapter">2: General Design <span class="page-number">37</span></div>
|
||||
<div class="procedure">Procedure 2-1: General Vessel Formulas <span class="page-number">38</span></div>
|
||||
<div class="procedure">Procedure 2-2: External Pressure Design <span class="page-number">42</span></div>
|
||||
<div class="procedure">Procedure 2-3: Properties of Stiffening Rings <span class="page-number">51</span></div>
|
||||
<div class="procedure">Procedure 2-4: Code Case 2286 <span class="page-number">54</span></div>
|
||||
<div class="procedure">Procedure 2-5: Design of Cones <span class="page-number">58</span></div>
|
||||
<div class="procedure">Procedure 2-6: Design of Toriconical Transitions <span class="page-number">67</span></div>
|
||||
<div class="procedure">Procedure 2-7: Stresses in Heads Due to Internal Pressure <span class="page-number">70</span></div>
|
||||
<div class="procedure">Procedure 2-8: Design of Intermediate Heads <span class="page-number">74</span></div>
|
||||
<div class="procedure">Procedure 2-9: Design of Flat Heads <span class="page-number">76</span></div>
|
||||
<div class="procedure">Procedure 2-10: Design of Large Openings in Flat Heads <span class="page-number">81</span></div>
|
||||
<div class="procedure">Procedure 2-11: Calculate MAP, MAWP, and Test Pressures <span class="page-number">83</span></div>
|
||||
<div class="procedure">Procedure 2-12: Nozzle Reinforcement <span class="page-number">85</span></div>
|
||||
<div class="procedure">Procedure 2-13: Find or Revise the Center of Gravity of a Vessel <span class="page-number">90</span></div>
|
||||
<div class="procedure">Procedure 2-14: Minimum Design Metal Temperature (MDMT) <span class="page-number">90</span></div>
|
||||
<div class="procedure">Procedure 2-15: Buckling of Thin Wall Cylindrical Shells <span class="page-number">95</span></div>
|
||||
<div class="procedure">Procedure 2-16: Optimum Vessel Proportions <span class="page-number">96</span></div>
|
||||
<div class="procedure">Procedure 2-17: Estimating Weights of Vessels and Vessel Components <span class="page-number">102</span></div>
|
||||
<div class="procedure">Procedure 2-18: Design of Jacketed Vessels <span class="page-number">124</span></div>
|
||||
<div class="procedure">Procedure 2-19: Forming Strains/Fiber Elongation <span class="page-number">134</span></div>
|
||||
<div class="procedure">References <span class="page-number">138</span></div>
|
||||
|
||||
<div class="chapter">3: Flange Design <span class="page-number">139</span></div>
|
||||
<div class="procedure">Introduction <span class="page-number">140</span></div>
|
||||
<div class="procedure">Procedure 3-1: Design of Flanges <span class="page-number">148</span></div>
|
||||
<div class="procedure">Procedure 3-2: Design of Spherically Dished Covers <span class="page-number">165</span></div>
|
||||
<div class="procedure">Procedure 3-3: Design of Blind Flanges with Openings <span class="page-number">167</span></div>
|
||||
<div class="procedure">Procedure 3-4: Bolt Torque Required for Sealing Flanges <span class="page-number">169</span></div>
|
||||
<div class="procedure">Procedure 3-5: Design of Studding Outlets <span class="page-number">172</span></div>
|
||||
<div class="procedure">Procedure 3-6: Reinforcement for Studding Outlets <span class="page-number">175</span></div>
|
||||
<div class="procedure">Procedure 3-7: Studding Flanges <span class="page-number">176</span></div>
|
||||
<div class="procedure">Procedure 3-8: Design of Elliptical, Internal Manways <span class="page-number">181</span></div>
|
||||
<div class="procedure">Procedure 3-9: Through Nozzles <span class="page-number">182</span></div>
|
||||
<div class="procedure">References <span class="page-number">183</span></div>
|
||||
|
||||
<div class="chapter">4: Design of Vessel Supports <span class="page-number">185</span></div>
|
||||
<div class="procedure">Introduction: Support Structures <span class="page-number">186</span></div>
|
||||
<div class="procedure">Procedure 4-1: Wind Design Per ASCE <span class="page-number">189</span></div>
|
||||
<div class="procedure">Procedure 4-2: Seismic Design - General <span class="page-number">199</span></div>
|
||||
<div class="procedure">Procedure 4-3: Seismic Design for Vessels <span class="page-number">204</span></div>
|
||||
<div class="procedure">Procedure 4-4: Seismic Design - Vessel on Unbraced Legs <span class="page-number">208</span></div>
|
||||
<div class="procedure">Procedure 4-5: Seismic Design - Vessel on Braced Legs <span class="page-number">217</span></div>
|
||||
<div class="procedure">Procedure 4-6: Seismic Design - Vessel on Rings <span class="page-number">223</span></div>
|
||||
<div class="procedure">Procedure 4-7: Seismic Design - Vessel on Lugs <span class="page-number">229</span></div>
|
||||
<div class="procedure">Procedure 4-8: Seismic Design - Vessel on Skirt <span class="page-number">239</span></div>
|
||||
<div class="procedure">Procedure 4-9: Seismic Design - Vessel on Conical Skirt <span class="page-number">248</span></div>
|
||||
<div class="procedure">Procedure 4-10: Design of Horizontal Vessel on Saddles <span class="page-number">253</span></div>
|
||||
<div class="procedure">Procedure 4-11: Design of Saddle Supports for Large Vessels <span class="page-number">267</span></div>
|
||||
<div class="procedure">Procedure 4-12: Design of Base Plates for Legs <span class="page-number">275</span></div>
|
||||
<div class="procedure">Procedure 4-13: Design of Lug Supports <span class="page-number">278</span></div>
|
||||
<div class="procedure">Procedure 4-14: Design of Base Details for Vertical Vessels-Shifted Neutral Axis Method <span class="page-number">281</span></div>
|
||||
<div class="procedure">Procedure 4-15: Design of Base Details for Vertical Vessels - Centered Neutral Axis Method <span class="page-number">291</span></div>
|
||||
<div class="procedure">Procedure 4-16: Design of Anchor Bolts for Vertical Vessels <span class="page-number">293</span></div>
|
||||
<div class="procedure">Procedure 4-17: Properties of Concrete <span class="page-number">295</span></div>
|
||||
<div class="procedure">References <span class="page-number">296</span></div>
|
||||
|
||||
<div class="chapter">5: Vessel Internals <span class="page-number">297</span></div>
|
||||
<div class="procedure">Procedure 5-1: Design of Internal Support Beds <span class="page-number">298</span></div>
|
||||
<div class="procedure">Procedure 5-2: Design of Lattice Beams <span class="page-number">310</span></div>
|
||||
<div class="procedure">Procedure 5-3: Shell Stresses due to Loadings at Support Beam Locations <span class="page-number">316</span></div>
|
||||
<div class="procedure">Procedure 5-4: Design of Support Blocks <span class="page-number">319</span></div>
|
||||
<div class="procedure">Procedure 5-5: Hub Rings used for Bed Supports <span class="page-number">321</span></div>
|
||||
<div class="procedure">Procedure 5-6: Design of Pipe Coils for Heat Transfer <span class="page-number">326</span></div>
|
||||
<div class="procedure">Procedure 5-7: Agitators/Mixers for Vessels and Tanks <span class="page-number">345</span></div>
|
||||
<div class="procedure">Procedure 5-8: Design of Internal Pipe Distributors <span class="page-number">353</span></div>
|
||||
<div class="procedure">Procedure 5-9: Design of Trays <span class="page-number">366</span></div>
|
||||
<div class="procedure">Procedure 5-10: Flow Over Weirs <span class="page-number">375</span></div>
|
||||
<div class="procedure">Procedure 5-11: Design of Demisters <span class="page-number">376</span></div>
|
||||
<div class="procedure">Procedure 5-12: Design of Baffles <span class="page-number">381</span></div>
|
||||
<div class="procedure">Procedure 5-13: Design of Impingement Plates <span class="page-number">391</span></div>
|
||||
<div class="procedure">References <span class="page-number">392</span></div>
|
||||
|
||||
<div class="chapter">6: Special Designs <span class="page-number">393</span></div>
|
||||
<div class="procedure">Procedure 6-1: Design of Large-Diameter Nozzle Openings <span class="page-number">394</span></div>
|
||||
<div class="procedure">Large Openings—Membrane and Bending Analysis <span class="page-number">397</span></div>
|
||||
<div class="procedure">Procedure 6-2: Tower Deflection <span class="page-number">397</span></div>
|
||||
<div class="procedure">Procedure 6-3: Design of Ring Girders <span class="page-number">401</span></div>
|
||||
<div class="procedure">Procedure 6-4: Design of Vessels with Refractory Linings <span class="page-number">406</span></div>
|
||||
<div class="procedure">Procedure 6-5: Vibration of Tall Towers and Stacks <span class="page-number">418</span></div>
|
||||
<div class="procedure">Procedure 6-6: Underground Tanks & Vessels <span class="page-number">428</span></div>
|
||||
<div class="procedure">Procedure 6-7: Local Thin Area (LTA) <span class="page-number">432</span></div>
|
||||
<div class="procedure">References <span class="page-number">433</span></div>
|
||||
|
||||
<div class="chapter">7: Local Loads <span class="page-number">435</span></div>
|
||||
<div class="procedure">Procedure 7-1: Stresses in Circular Rings <span class="page-number">437</span></div>
|
||||
<div class="procedure">Procedure 7-2: Design of Partial Ring Stiffeners <span class="page-number">446</span></div>
|
||||
<div class="procedure">Procedure 7-3: Attachment Parameters <span class="page-number">448</span></div>
|
||||
<div class="procedure">Procedure 7-4: Stresses in Cylindrical Shells from External Local Loads <span class="page-number">449</span></div>
|
||||
<div class="procedure">Procedure 7-5: Stresses in Spherical Shells from External Local Loads <span class="page-number">465</span></div>
|
||||
<div class="procedure">References <span class="page-number">472</span></div>
|
||||
|
||||
<div class="chapter">8: High Pressure Vessels <span class="page-number">473</span></div>
|
||||
<div class="procedure">1.0. General <span class="page-number">474</span></div>
|
||||
<div class="procedure">2.0. Shell Design <span class="page-number">496</span></div>
|
||||
<div class="procedure">3.0. Design of Closures <span class="page-number">502</span></div>
|
||||
<div class="procedure">4.0. Nozzles <span class="page-number">551</span></div>
|
||||
<div class="procedure">5.0. References <span class="page-number">556</span></div>
|
||||
|
||||
<div class="chapter">9: Related Equipment <span class="page-number">557</span></div>
|
||||
<div class="procedure">Procedure 9-1: Design of Davits <span class="page-number">558</span></div>
|
||||
<div class="procedure">Procedure 9-2: Design of Circular Platforms <span class="page-number">563</span></div>
|
||||
<div class="procedure">Procedure 9-3: Design of Square and Rectangular Platforms <span class="page-number">571</span></div>
|
||||
<div class="procedure">Procedure 9-4: Design of Pipe Supports <span class="page-number">576</span></div>
|
||||
<div class="procedure">Procedure 9-5: Shear Loads in Bolted Connections <span class="page-number">584</span></div>
|
||||
<div class="procedure">Procedure 9-6: Design of Bins and Elevated Tanks <span class="page-number">586</span></div>
|
||||
<div class="procedure">Procedure 9-7: Field-Fabricated Spheres <span class="page-number">594</span></div>
|
||||
<div class="procedure">References <span class="page-number">630</span></div>
|
||||
|
||||
<div class="chapter">10: Transportation and Erection of Pressure Vessels <span class="page-number">631</span></div>
|
||||
<div class="procedure">Procedure 10-1: Transportation of Pressure Vessels <span class="page-number">632</span></div>
|
||||
<div class="procedure">Procedure 10-2: Erection of Pressure Vessels <span class="page-number">660</span></div>
|
||||
<div class="procedure">Procedure 10-3: Lifting Attachments and Terminology <span class="page-number">666</span></div>
|
||||
<div class="procedure">Procedure 10-4: Lifting Loads and Forces <span class="page-number">675</span></div>
|
||||
<div class="procedure">Procedure 10-5: Design of Tail Beams, Lugs, and Base Ring Details <span class="page-number">681</span></div>
|
||||
<div class="procedure">Procedure 10-6: Design of Top Head and Cone Lifting Lugs <span class="page-number">691</span></div>
|
||||
<div class="procedure">Procedure 10-7: Design of Flange Lugs <span class="page-number">695</span></div>
|
||||
<div class="procedure">Procedure 10-8: Design of Trunnions <span class="page-number">706</span></div>
|
||||
<div class="procedure">Procedure 10-9: Local Loads in Shell Due to Erection Forces <span class="page-number">710</span></div>
|
||||
<div class="procedure">Procedure 10-10: Miscellaneous <span class="page-number">713</span></div>
|
||||
|
||||
<div class="chapter">11: Materials <span class="page-number">719</span></div>
|
||||
<div class="procedure">11.1. Types of Materials <span class="page-number">720</span></div>
|
||||
<div class="procedure">11.2. Properties of Materials <span class="page-number">723</span></div>
|
||||
<div class="procedure">11.3. Bolting <span class="page-number">728</span></div>
|
||||
<div class="procedure">11.4. Testing & Examination <span class="page-number">732</span></div>
|
||||
<div class="procedure">11.5. Heat Treatment <span class="page-number">738</span></div>
|
||||
|
||||
<div class="chapter">Appendices <span class="page-number">743</span></div>
|
||||
<div class="chapter">Index <span class="page-number">803</span></div>
|
||||
</div>
|
||||
|
||||
<h2>Preface to the 4th Edition</h2>
|
||||
<div class="preface">
|
||||
<p>When I started the Pressure Vessel Design Manual 35 years ago, I had no idea where it would lead. The first edition alone took 10 years to publish. It began when I first started working for a small vessel shop in Los Angeles in 1972. I could not believe how little information was available to engineers and designers in our industry at that time. I began collecting and researching everything I could get my hands on. As I collected more and more, I began writing procedures around various topics. After a while I had a pretty substantial collection and someone suggested that it might make a good book.</p>
|
||||
|
||||
<p>However I was constantly revising them and didn't think any of them were complete enough to publish. After a while I began trying to perfect them so that they could be published. This is the point at which the effort changed from a hobby to a vocation. My goal was to provide as complete a collection of equations, data and procedures for the design of pressure vessels that I could assemble. I never thought of myself as an author in this regard... but only the editor. I was not developing equations or methods, but only collecting and collating them. The presentation of the materials was then, and still is, the focus of my efforts. As stated all along "The author makes no claim to originality, other than that of format."</p>
|
||||
|
||||
<p>My target audience was always the person in the shop who was ultimately responsible for the designs they manufactured. I have seen all my goals for the PVDM exceeded in every way possible. Through my work with Fluor, I have had the opportunity to travel to 40 countries and have visited 60 vessel shops. In the past 10 years, I have not visited a shop that was not using the PVDM. This has been my reward. This book is now, and always has been, dedicated to the end user. Thank you.</p>
|
||||
|
||||
<p>The PVDM is a "designers" manual foremost, and not an engineering textbook. The procedures are streamlined to provide a weight, size or thickness. For the most part, wherever possible, it avoids the derivation of equations or the theoretical background. I have always sought out the simplest and most direct solutions.</p>
|
||||
|
||||
<p>If I have an interest in seeing this book continuing, then it must be done under the direction of a new, younger and very talented person.</p>
|
||||
|
||||
<p>Finally, I would like to offer my warmest, heartfelt thanks to all of you that have made comments, contributions, sent me literature, or encouraged me over the past 35 years. It is immensely rewarding to have watched the book evolve over the years. This book would not have been possible without you!</p>
|
||||
|
||||
<p style="text-align: right;">Dennis R. Moss</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 한국어 버전 -->
|
||||
<div id="korean-content" style="display: none;">
|
||||
<h1>압력용기 설계 매뉴얼</h1>
|
||||
<h2>제4판</h2>
|
||||
|
||||
<div class="publisher-info">
|
||||
<p><strong>Dennis R. Moss<br>Michael Basic</strong></p>
|
||||
<p>암스테르담 • 보스턴 • 하이델베르크 • 런던 • 뉴욕 • 옥스포드<br>
|
||||
파리 • 샌디에이고 • 샌프란시스코 • 싱가포르 • 시드니 • 도쿄</p>
|
||||
<p>Butterworth-Heinemann은 Elsevier의 임프린트입니다</p>
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
<h3>저작권 정보</h3>
|
||||
<p>제4판 2013<br>
|
||||
Copyright © 2013 Elsevier Inc. 모든 권리 보유</p>
|
||||
<p>이 출판물의 어떤 부분도 출판사의 사전 서면 허가 없이 전자적, 기계적, 복사, 녹음 또는 기타 어떤 형태나 수단으로도 복제, 저장 또는 전송될 수 없습니다.</p>
|
||||
<p>ISBN: 978-0-12-387000-1</p>
|
||||
</div>
|
||||
|
||||
<h2>목차</h2>
|
||||
<div class="toc">
|
||||
<div class="chapter">제4판 서문 <span class="page-number">ix</span></div>
|
||||
|
||||
<div class="chapter">1: 일반 주제 <span class="page-number">1</span></div>
|
||||
<div class="procedure">설계 철학 <span class="page-number">1</span></div>
|
||||
<div class="procedure">응력 분석 <span class="page-number">2</span></div>
|
||||
<div class="procedure">응력/파손 이론 <span class="page-number">3</span></div>
|
||||
<div class="procedure">압력용기의 파손 <span class="page-number">7</span></div>
|
||||
<div class="procedure">하중 <span class="page-number">8</span></div>
|
||||
<div class="procedure">응력 <span class="page-number">10</span></div>
|
||||
<div class="procedure">열응력 <span class="page-number">13</span></div>
|
||||
<div class="procedure">불연속 응력 <span class="page-number">14</span></div>
|
||||
<div class="procedure">주기적 서비스를 위한 피로 분석 <span class="page-number">15</span></div>
|
||||
<div class="procedure">크리프 <span class="page-number">24</span></div>
|
||||
<div class="procedure">극저온 응용 <span class="page-number">32</span></div>
|
||||
<div class="procedure">서비스 고려사항 <span class="page-number">34</span></div>
|
||||
<div class="procedure">기타 설계 고려사항 <span class="page-number">35</span></div>
|
||||
<div class="procedure">ASME VIII-2 용기에 대한 사용자 설계 사양(UDS)에 포함될 항목 <span class="page-number">35</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">36</span></div>
|
||||
|
||||
<div class="chapter">2: 일반 설계 <span class="page-number">37</span></div>
|
||||
<div class="procedure">절차 2-1: 일반 용기 공식 <span class="page-number">38</span></div>
|
||||
<div class="procedure">절차 2-2: 외압 설계 <span class="page-number">42</span></div>
|
||||
<div class="procedure">절차 2-3: 보강링의 특성 <span class="page-number">51</span></div>
|
||||
<div class="procedure">절차 2-4: 코드 케이스 2286 <span class="page-number">54</span></div>
|
||||
<div class="procedure">절차 2-5: 원뿔 설계 <span class="page-number">58</span></div>
|
||||
<div class="procedure">절차 2-6: 토리코니컬 변환부 설계 <span class="page-number">67</span></div>
|
||||
<div class="procedure">절차 2-7: 내압으로 인한 헤드의 응력 <span class="page-number">70</span></div>
|
||||
<div class="procedure">절차 2-8: 중간 헤드 설계 <span class="page-number">74</span></div>
|
||||
<div class="procedure">절차 2-9: 평판 헤드 설계 <span class="page-number">76</span></div>
|
||||
<div class="procedure">절차 2-10: 평판 헤드의 대형 개구부 설계 <span class="page-number">81</span></div>
|
||||
<div class="procedure">절차 2-11: MAP, MAWP 및 시험 압력 계산 <span class="page-number">83</span></div>
|
||||
<div class="procedure">절차 2-12: 노즐 보강 <span class="page-number">85</span></div>
|
||||
<div class="procedure">절차 2-13: 용기의 무게중심 찾기 또는 수정 <span class="page-number">90</span></div>
|
||||
<div class="procedure">절차 2-14: 최소 설계 금속 온도 (MDMT) <span class="page-number">90</span></div>
|
||||
<div class="procedure">절차 2-15: 얇은 벽 원통형 쉘의 좌굴 <span class="page-number">95</span></div>
|
||||
<div class="procedure">절차 2-16: 최적 용기 비율 <span class="page-number">96</span></div>
|
||||
<div class="procedure">절차 2-17: 용기 및 용기 구성요소의 중량 추정 <span class="page-number">102</span></div>
|
||||
<div class="procedure">절차 2-18: 재킷 용기 설계 <span class="page-number">124</span></div>
|
||||
<div class="procedure">절차 2-19: 성형 변형률/섬유 신장 <span class="page-number">134</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">138</span></div>
|
||||
|
||||
<div class="chapter">3: 플랜지 설계 <span class="page-number">139</span></div>
|
||||
<div class="procedure">소개 <span class="page-number">140</span></div>
|
||||
<div class="procedure">절차 3-1: 플랜지 설계 <span class="page-number">148</span></div>
|
||||
<div class="procedure">절차 3-2: 구형 디시 커버 설계 <span class="page-number">165</span></div>
|
||||
<div class="procedure">절차 3-3: 개구부가 있는 블라인드 플랜지 설계 <span class="page-number">167</span></div>
|
||||
<div class="procedure">절차 3-4: 플랜지 밀봉에 필요한 볼트 토크 <span class="page-number">169</span></div>
|
||||
<div class="procedure">절차 3-5: 스터딩 아웃렛 설계 <span class="page-number">172</span></div>
|
||||
<div class="procedure">절차 3-6: 스터딩 아웃렛 보강 <span class="page-number">175</span></div>
|
||||
<div class="procedure">절차 3-7: 스터딩 플랜지 <span class="page-number">176</span></div>
|
||||
<div class="procedure">절차 3-8: 타원형 내부 맨웨이 설계 <span class="page-number">181</span></div>
|
||||
<div class="procedure">절차 3-9: 관통 노즐 <span class="page-number">182</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">183</span></div>
|
||||
|
||||
<div class="chapter">4: 용기 지지대 설계 <span class="page-number">185</span></div>
|
||||
<div class="procedure">소개: 지지 구조물 <span class="page-number">186</span></div>
|
||||
<div class="procedure">절차 4-1: ASCE에 따른 풍하중 설계 <span class="page-number">189</span></div>
|
||||
<div class="procedure">절차 4-2: 내진 설계 - 일반 <span class="page-number">199</span></div>
|
||||
<div class="procedure">절차 4-3: 용기의 내진 설계 <span class="page-number">204</span></div>
|
||||
<div class="procedure">절차 4-4: 내진 설계 - 비보강 다리 위의 용기 <span class="page-number">208</span></div>
|
||||
<div class="procedure">절차 4-5: 내진 설계 - 보강 다리 위의 용기 <span class="page-number">217</span></div>
|
||||
<div class="procedure">절차 4-6: 내진 설계 - 링 위의 용기 <span class="page-number">223</span></div>
|
||||
<div class="procedure">절차 4-7: 내진 설계 - 러그 위의 용기 <span class="page-number">229</span></div>
|
||||
<div class="procedure">절차 4-8: 내진 설계 - 스커트 위의 용기 <span class="page-number">239</span></div>
|
||||
<div class="procedure">절차 4-9: 내진 설계 - 원뿔형 스커트 위의 용기 <span class="page-number">248</span></div>
|
||||
<div class="procedure">절차 4-10: 새들 위의 수평 용기 설계 <span class="page-number">253</span></div>
|
||||
<div class="procedure">절차 4-11: 대형 용기용 새들 지지대 설계 <span class="page-number">267</span></div>
|
||||
<div class="procedure">절차 4-12: 다리용 베이스 플레이트 설계 <span class="page-number">275</span></div>
|
||||
<div class="procedure">절차 4-13: 러그 지지대 설계 <span class="page-number">278</span></div>
|
||||
<div class="procedure">절차 4-14: 수직 용기용 베이스 상세 설계 - 이동된 중립축 방법 <span class="page-number">281</span></div>
|
||||
<div class="procedure">절차 4-15: 수직 용기용 베이스 상세 설계 - 중심 중립축 방법 <span class="page-number">291</span></div>
|
||||
<div class="procedure">절차 4-16: 수직 용기용 앵커 볼트 설계 <span class="page-number">293</span></div>
|
||||
<div class="procedure">절차 4-17: 콘크리트의 특성 <span class="page-number">295</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">296</span></div>
|
||||
|
||||
<div class="chapter">5: 용기 내부 구조물 <span class="page-number">297</span></div>
|
||||
<div class="procedure">절차 5-1: 내부 지지 베드 설계 <span class="page-number">298</span></div>
|
||||
<div class="procedure">절차 5-2: 격자 빔 설계 <span class="page-number">310</span></div>
|
||||
<div class="procedure">절차 5-3: 지지 빔 위치에서의 하중으로 인한 쉘 응력 <span class="page-number">316</span></div>
|
||||
<div class="procedure">절차 5-4: 지지 블록 설계 <span class="page-number">319</span></div>
|
||||
<div class="procedure">절차 5-5: 베드 지지대용 허브 링 <span class="page-number">321</span></div>
|
||||
<div class="procedure">절차 5-6: 열전달용 파이프 코일 설계 <span class="page-number">326</span></div>
|
||||
<div class="procedure">절차 5-7: 용기 및 탱크용 교반기/믹서 <span class="page-number">345</span></div>
|
||||
<div class="procedure">절차 5-8: 내부 파이프 분배기 설계 <span class="page-number">353</span></div>
|
||||
<div class="procedure">절차 5-9: 트레이 설계 <span class="page-number">366</span></div>
|
||||
<div class="procedure">절차 5-10: 위어를 통한 유동 <span class="page-number">375</span></div>
|
||||
<div class="procedure">절차 5-11: 디미스터 설계 <span class="page-number">376</span></div>
|
||||
<div class="procedure">절차 5-12: 배플 설계 <span class="page-number">381</span></div>
|
||||
<div class="procedure">절차 5-13: 충돌 플레이트 설계 <span class="page-number">391</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">392</span></div>
|
||||
|
||||
<div class="chapter">6: 특수 설계 <span class="page-number">393</span></div>
|
||||
<div class="procedure">절차 6-1: 대직경 노즐 개구부 설계 <span class="page-number">394</span></div>
|
||||
<div class="procedure">대형 개구부—막 및 굽힘 분석 <span class="page-number">397</span></div>
|
||||
<div class="procedure">절차 6-2: 타워 처짐 <span class="page-number">397</span></div>
|
||||
<div class="procedure">절차 6-3: 링 거더 설계 <span class="page-number">401</span></div>
|
||||
<div class="procedure">절차 6-4: 내화 라이닝이 있는 용기 설계 <span class="page-number">406</span></div>
|
||||
<div class="procedure">절차 6-5: 높은 타워 및 스택의 진동 <span class="page-number">418</span></div>
|
||||
<div class="procedure">절차 6-6: 지하 탱크 및 용기 <span class="page-number">428</span></div>
|
||||
<div class="procedure">절차 6-7: 국부 얇은 영역 (LTA) <span class="page-number">432</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">433</span></div>
|
||||
|
||||
<div class="chapter">7: 국부 하중 <span class="page-number">435</span></div>
|
||||
<div class="procedure">절차 7-1: 원형 링의 응력 <span class="page-number">437</span></div>
|
||||
<div class="procedure">절차 7-2: 부분 링 보강재 설계 <span class="page-number">446</span></div>
|
||||
<div class="procedure">절차 7-3: 부착 매개변수 <span class="page-number">448</span></div>
|
||||
<div class="procedure">절차 7-4: 외부 국부 하중으로 인한 원통형 쉘의 응력 <span class="page-number">449</span></div>
|
||||
<div class="procedure">절차 7-5: 외부 국부 하중으로 인한 구형 쉘의 응력 <span class="page-number">465</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">472</span></div>
|
||||
|
||||
<div class="chapter">8: 고압 용기 <span class="page-number">473</span></div>
|
||||
<div class="procedure">1.0. 일반사항 <span class="page-number">474</span></div>
|
||||
<div class="procedure">2.0. 쉘 설계 <span class="page-number">496</span></div>
|
||||
<div class="procedure">3.0. 클로저 설계 <span class="page-number">502</span></div>
|
||||
<div class="procedure">4.0. 노즐 <span class="page-number">551</span></div>
|
||||
<div class="procedure">5.0. 참고문헌 <span class="page-number">556</span></div>
|
||||
|
||||
<div class="chapter">9: 관련 장비 <span class="page-number">557</span></div>
|
||||
<div class="procedure">절차 9-1: 데이빗 설계 <span class="page-number">558</span></div>
|
||||
<div class="procedure">절차 9-2: 원형 플랫폼 설계 <span class="page-number">563</span></div>
|
||||
<div class="procedure">절차 9-3: 정사각형 및 직사각형 플랫폼 설계 <span class="page-number">571</span></div>
|
||||
<div class="procedure">절차 9-4: 파이프 지지대 설계 <span class="page-number">576</span></div>
|
||||
<div class="procedure">절차 9-5: 볼트 연결부의 전단 하중 <span class="page-number">584</span></div>
|
||||
<div class="procedure">절차 9-6: 빈 및 고가 탱크 설계 <span class="page-number">586</span></div>
|
||||
<div class="procedure">절차 9-7: 현장 제작 구체 <span class="page-number">594</span></div>
|
||||
<div class="procedure">참고문헌 <span class="page-number">630</span></div>
|
||||
|
||||
<div class="chapter">10: 압력용기의 운송 및 설치 <span class="page-number">631</span></div>
|
||||
<div class="procedure">절차 10-1: 압력용기의 운송 <span class="page-number">632</span></div>
|
||||
<div class="procedure">절차 10-2: 압력용기의 설치 <span class="page-number">660</span></div>
|
||||
<div class="procedure">절차 10-3: 리프팅 부착물 및 용어 <span class="page-number">666</span></div>
|
||||
<div class="procedure">절차 10-4: 리프팅 하중 및 힘 <span class="page-number">675</span></div>
|
||||
<div class="procedure">절차 10-5: 테일 빔, 러그 및 베이스 링 상세 설계 <span class="page-number">681</span></div>
|
||||
<div class="procedure">절차 10-6: 상부 헤드 및 원뿔 리프팅 러그 설계 <span class="page-number">691</span></div>
|
||||
<div class="procedure">절차 10-7: 플랜지 러그 설계 <span class="page-number">695</span></div>
|
||||
<div class="procedure">절차 10-8: 트러니언 설계 <span class="page-number">706</span></div>
|
||||
<div class="procedure">절차 10-9: 설치 하중으로 인한 쉘의 국부 하중 <span class="page-number">710</span></div>
|
||||
<div class="procedure">절차 10-10: 기타 <span class="page-number">713</span></div>
|
||||
|
||||
<div class="chapter">11: 재료 <span class="page-number">719</span></div>
|
||||
<div class="procedure">11.1. 재료의 종류 <span class="page-number">720</span></div>
|
||||
<div class="procedure">11.2. 재료의 특성 <span class="page-number">723</span></div>
|
||||
<div class="procedure">11.3. 볼팅 <span class="page-number">728</span></div>
|
||||
<div class="procedure">11.4. 시험 및 검사 <span class="page-number">732</span></div>
|
||||
<div class="procedure">11.5. 열처리 <span class="page-number">738</span></div>
|
||||
|
||||
<div class="chapter">부록 <span class="page-number">743</span></div>
|
||||
<div class="chapter">색인 <span class="page-number">803</span></div>
|
||||
</div>
|
||||
|
||||
<h2>제4판 서문</h2>
|
||||
<div class="preface">
|
||||
<p>35년 전 압력용기 설계 매뉴얼을 시작했을 때, 이것이 어디로 이어질지 전혀 알지 못했습니다. 첫 번째 판만 해도 출판하는 데 10년이 걸렸습니다. 1972년 로스앤젤레스의 작은 용기 제작소에서 일하기 시작했을 때 시작되었습니다. 그 당시 우리 업계의 엔지니어와 설계자들이 이용할 수 있는 정보가 얼마나 적은지 믿을 수 없었습니다. 저는 손에 넣을 수 있는 모든 것을 수집하고 연구하기 시작했습니다. 점점 더 많이 수집하면서 다양한 주제에 대한 절차를 작성하기 시작했습니다. 얼마 후 꽤 상당한 컬렉션을 갖추게 되었고, 누군가가 이것이 좋은 책이 될 수 있을 것이라고 제안했습니다.</p>
|
||||
|
||||
<p>그러나 저는 계속해서 수정하고 있었고 그 어느 것도 출판할 만큼 완전하다고 생각하지 않았습니다. 얼마 후 출판할 수 있도록 완벽하게 만들려고 노력하기 시작했습니다. 이것이 취미에서 직업으로 바뀐 시점이었습니다. 제 목표는 제가 수집할 수 있는 압력용기 설계를 위한 방정식, 데이터 및 절차의 가능한 한 완전한 컬렉션을 제공하는 것이었습니다. 저는 이 점에서 결코 자신을 저자로 생각하지 않았습니다... 단지 편집자일 뿐입니다. 저는 방정식이나 방법을 개발하는 것이 아니라 단지 수집하고 정리하는 것이었습니다. 자료의 프레젠테이션은 그때도 지금도 제 노력의 초점입니다. 항상 말해왔듯이 "저자는 형식 이외에는 독창성을 주장하지 않습니다."</p>
|
||||
|
||||
<p>제 목표 독자는 항상 제조하는 설계에 대해 궁극적으로 책임을 지는 작업장의 사람이었습니다. 저는 PVDM에 대한 제 모든 목표가 모든 면에서 초과 달성되는 것을 보았습니다. Fluor에서의 작업을 통해 40개국을 여행하고 60개의 용기 제작소를 방문할 기회를 가졌습니다. 지난 10년 동안 PVDM을 사용하지 않는 제작소를 방문한 적이 없습니다. 이것이 제 보상이었습니다. 이 책은 지금도, 그리고 항상 최종 사용자에게 헌정되었습니다. 감사합니다.</p>
|
||||
|
||||
<p>PVDM은 무엇보다도 "설계자"를 위한 매뉴얼이며, 공학 교과서가 아닙니다. 절차는 중량, 크기 또는 두께를 제공하도록 간소화되었습니다. 대부분의 경우 가능한 한 방정식의 유도나 이론적 배경을 피합니다. 저는 항상 가장 간단하고 직접적인 해결책을 찾아왔습니다.</p>
|
||||
|
||||
<p>이 책이 계속되기를 바란다면, 새롭고 젊고 매우 재능 있는 사람의 지시에 따라 이루어져야 합니다.</p>
|
||||
|
||||
<p>마지막으로, 지난 35년 동안 의견을 주시고, 기여해 주시고, 문헌을 보내주시고, 격려해 주신 모든 분들께 따뜻한 마음을 담아 진심으로 감사드립니다. 수년에 걸쳐 책이 발전하는 것을 지켜보는 것은 엄청나게 보람 있는 일이었습니다. 여러분이 없었다면 이 책은 불가능했을 것입니다!</p>
|
||||
|
||||
<p style="text-align: right;">Dennis R. Moss</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleLanguage() {
|
||||
const englishContent = document.getElementById('english-content');
|
||||
const koreanContent = document.getElementById('korean-content');
|
||||
|
||||
if (englishContent.style.display === 'none') {
|
||||
englishContent.style.display = 'block';
|
||||
koreanContent.style.display = 'none';
|
||||
} else {
|
||||
englishContent.style.display = 'none';
|
||||
koreanContent.style.display = 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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;
|
||||
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
|
||||
|
||||
# 볼륨 정의는 제거 (직접 경로 매핑 사용)
|
||||
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>
|
||||
138
frontend/book-documents.html
Normal file
138
frontend/book-documents.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!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-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="openBookEditor()"
|
||||
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-edit mr-2"></i>서적 편집
|
||||
</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>
|
||||
<p class="text-gray-600" x-show="bookInfo.description" x-text="bookInfo.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 -->
|
||||
<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">문서 목록</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-200">
|
||||
<template x-for="doc in documents" :key="doc.id">
|
||||
<div class="p-6 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
@click="openDocument(doc.id)">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2" x-text="doc.title"></h3>
|
||||
<p class="text-gray-600 text-sm mb-3 line-clamp-2" x-text="doc.description || '설명이 없습니다'"></p>
|
||||
|
||||
<!-- 태그들 -->
|
||||
<div class="flex flex-wrap gap-1 mb-3" x-show="doc.tags && doc.tags.length > 0">
|
||||
<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 items-center text-sm text-gray-500 space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
<span x-text="formatDate(doc.created_at)"></span>
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-user mr-1"></i>
|
||||
<span x-text="doc.uploader_name"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button @click.stop="editDocument(doc)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="문서 수정">
|
||||
<i class="fas fa-edit"></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"
|
||||
title="문서 삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<i class="fas fa-chevron-right text-gray-400"></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>
|
||||
@@ -9,12 +9,18 @@
|
||||
<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="documentApp()" x-init="init()">
|
||||
<!-- 로그인 모달 -->
|
||||
<div x-data="authModal" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div x-data="authModal()" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">로그인</h2>
|
||||
@@ -48,85 +54,11 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 로고 -->
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-xl font-bold text-gray-900">
|
||||
<i class="fas fa-file-alt mr-2"></i>
|
||||
Document Server
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 검색바 -->
|
||||
<div class="flex-1 max-w-lg mx-8" x-show="isAuthenticated">
|
||||
<div class="relative">
|
||||
<input type="text" x-model="searchQuery" @input="searchDocuments"
|
||||
placeholder="문서, 메모 검색..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<template x-if="!isAuthenticated">
|
||||
<button @click="showLoginModal = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
|
||||
로그인
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template x-if="isAuthenticated">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 업로드 버튼 -->
|
||||
<button @click="showUploadModal = true"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
업로드
|
||||
</button>
|
||||
|
||||
<!-- 사용자 드롭다운 -->
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<button @click="open = !open" class="flex items-center text-gray-700 hover:text-gray-900">
|
||||
<i class="fas fa-user-circle text-2xl"></i>
|
||||
<span x-text="user?.full_name || user?.email" class="ml-2"></span>
|
||||
<i class="fas fa-chevron-down ml-1"></i>
|
||||
</button>
|
||||
|
||||
<div x-show="open" @click.away="open = false" x-cloak
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
|
||||
<a href="#" @click="showProfile = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-user mr-2"></i>프로필
|
||||
</a>
|
||||
<a href="#" @click="showMyNotes = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-sticky-note mr-2"></i>내 메모
|
||||
</a>
|
||||
<a href="#" @click="showBookmarks = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-bookmark mr-2"></i>책갈피
|
||||
</a>
|
||||
<template x-if="user?.is_admin">
|
||||
<a href="#" @click="showAdmin = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-cog mr-2"></i>관리자
|
||||
</a>
|
||||
</template>
|
||||
<hr class="my-1">
|
||||
<a href="#" @click="logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 공통 헤더 컨테이너 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<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">
|
||||
@@ -144,35 +76,74 @@
|
||||
<template x-if="isAuthenticated">
|
||||
<div>
|
||||
<!-- 필터 및 정렬 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="mb-6">
|
||||
<!-- 첫 번째 줄: 제목과 뷰 모드 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-900">문서 목록</h2>
|
||||
<select x-model="selectedTag" @change="loadDocuments"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md">
|
||||
<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-list 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>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="viewMode = 'grid'"
|
||||
:class="viewMode === 'grid' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-th-large"></i>
|
||||
|
||||
<!-- 업로드 버튼 -->
|
||||
<button @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 @click="viewMode = 'list'"
|
||||
:class="viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-list"></i>
|
||||
|
||||
<!-- 검색 초기화 버튼 -->
|
||||
<button 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 documents" :key="doc.id">
|
||||
<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">
|
||||
@@ -181,7 +152,16 @@
|
||||
<i class="fas fa-file-alt text-blue-500 text-xl"></i>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 text-sm mb-4 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
|
||||
<p class="text-gray-600 text-sm mb-3 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
|
||||
|
||||
<!-- 서적 정보 -->
|
||||
<div x-show="doc.book_title" class="mb-3 p-2 bg-green-50 border border-green-200 rounded-md">
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="fas fa-book text-green-600 mr-2"></i>
|
||||
<span class="font-medium text-green-800" x-text="doc.book_title"></span>
|
||||
<span x-show="doc.book_author" class="text-green-600 ml-1" x-text="' by ' + doc.book_author"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1 mb-4">
|
||||
<template x-for="tag in doc.tags" :key="tag">
|
||||
@@ -191,24 +171,90 @@
|
||||
|
||||
<div class="flex justify-between items-center text-sm text-gray-500">
|
||||
<span x-text="formatDate(doc.created_at)"></span>
|
||||
<span x-text="doc.uploader_name"></span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span x-text="doc.uploader_name"></span>
|
||||
<!-- 액션 버튼들 -->
|
||||
<div class="flex space-x-1 ml-2">
|
||||
<button @click.stop="editDocument(doc)"
|
||||
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="문서 수정">
|
||||
<i class="fas fa-edit text-sm"></i>
|
||||
</button>
|
||||
<!-- 관리자만 삭제 버튼 표시 -->
|
||||
<button x-show="currentUser && currentUser.is_admin"
|
||||
@click.stop="deleteDocument(doc.id)"
|
||||
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="문서 삭제 (관리자 전용)">
|
||||
<i class="fas fa-trash text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 서적별 그룹화 뷰 (목차) -->
|
||||
<div x-show="viewMode === 'books'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- 서적 카드들 -->
|
||||
<template x-for="bookGroup in groupedDocuments" :key="bookGroup.book?.id || 'no-book'">
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200"
|
||||
@click="openBookDocuments(bookGroup.book)">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-book text-white text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-gray-900 line-clamp-2" x-text="bookGroup.book?.title || '서적 미분류'"></h3>
|
||||
<p class="text-sm text-gray-600 mt-1" x-show="bookGroup.book?.author" x-text="bookGroup.book.author"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-3">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-file-alt mr-2"></i>
|
||||
<span x-text="bookGroup.documents.length"></span>개 문서
|
||||
</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 text-sm line-clamp-2" x-text="bookGroup.book?.description || '서적 설명이 없습니다'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<template x-if="documents.length === 0 && !loading">
|
||||
<template x-if="filteredDocuments.length === 0 && !loading">
|
||||
<div class="text-center py-16">
|
||||
<i class="fas fa-folder-open text-6xl text-gray-400 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">문서가 없습니다</h3>
|
||||
<p class="text-gray-600 mb-6">첫 번째 문서를 업로드해보세요</p>
|
||||
<button @click="showUploadModal = true"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
문서 업로드
|
||||
</button>
|
||||
<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>
|
||||
@@ -225,7 +271,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-data="uploadModal">
|
||||
<div x-data="uploadModal()">
|
||||
<form @submit.prevent="upload">
|
||||
<!-- 파일 업로드 영역 -->
|
||||
<div class="mb-6">
|
||||
@@ -272,6 +318,98 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서적 선택/생성 -->
|
||||
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">📚 서적 설정</label>
|
||||
|
||||
<!-- 서적 선택 방식 -->
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="bookSelectionMode" value="existing"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm">기존 서적에 추가</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="bookSelectionMode" value="new"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm">새 서적 생성</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="bookSelectionMode" value="none"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm">서적 없이 업로드</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 기존 서적 선택 -->
|
||||
<div x-show="bookSelectionMode === 'existing'" class="space-y-3">
|
||||
<div>
|
||||
<input type="text" x-model="bookSearchQuery" @input="searchBooks"
|
||||
placeholder="서적 제목으로 검색..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div x-show="searchedBooks.length > 0" class="max-h-40 overflow-y-auto border border-gray-200 rounded-md">
|
||||
<template x-for="book in searchedBooks" :key="book.id">
|
||||
<div @click="selectBook(book)"
|
||||
:class="selectedBook?.id === book.id ? 'bg-blue-100 border-blue-500' : 'hover:bg-gray-50'"
|
||||
class="p-3 border-b border-gray-200 cursor-pointer">
|
||||
<div class="font-medium text-sm" x-text="book.title"></div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span x-text="book.author || '저자 미상'"></span> ·
|
||||
<span x-text="book.document_count + '개 문서'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 서적 표시 -->
|
||||
<div x-show="selectedBook" class="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium text-blue-900" x-text="selectedBook?.title"></div>
|
||||
<div class="text-sm text-blue-700" x-text="selectedBook?.author || '저자 미상'"></div>
|
||||
</div>
|
||||
<button @click="selectedBook = null" class="text-blue-500 hover:text-blue-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 서적 생성 -->
|
||||
<div x-show="bookSelectionMode === 'new'" class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<input type="text" x-model="newBook.title" @input="getSuggestions"
|
||||
placeholder="서적 제목 *" :required="bookSelectionMode === 'new'"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" x-model="newBook.author"
|
||||
placeholder="저자"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 유사 서적 추천 -->
|
||||
<div x-show="suggestions.length > 0" class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div class="text-sm font-medium text-yellow-800 mb-2">💡 유사한 서적이 있습니다:</div>
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<div @click="selectExistingFromSuggestion(suggestion)"
|
||||
class="p-2 bg-white border border-yellow-300 rounded cursor-pointer hover:bg-yellow-50 mb-1">
|
||||
<div class="text-sm font-medium" x-text="suggestion.title"></div>
|
||||
<div class="text-xs text-gray-600">
|
||||
<span x-text="suggestion.author || '저자 미상'"></span> ·
|
||||
<span x-text="Math.round(suggestion.similarity_score * 100) + '% 유사'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 정보 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
@@ -336,10 +474,7 @@
|
||||
<!-- 인증 모달 컴포넌트 -->
|
||||
<div x-data="authModal" x-ref="authModal"></div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/auth.js"></script>
|
||||
<script src="/static/js/main.js"></script>
|
||||
<!-- 스크립트는 하단에서 로드됩니다 -->
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
@@ -356,5 +491,10 @@
|
||||
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>
|
||||
316
frontend/pdf-manager.html
Normal file
316
frontend/pdf-manager.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>PDF 파일 관리 - 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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="pdfManagerApp()">
|
||||
<!-- 헤더 -->
|
||||
<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>
|
||||
|
||||
<!-- 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">PDF 파일 목록</h2>
|
||||
|
||||
<!-- 필터 버튼 -->
|
||||
<div 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>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<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-200">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
@@ -1,6 +1,7 @@
|
||||
/* 메인 스타일 */
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
padding-top: 4rem; /* 고정 헤더를 위한 패딩 */
|
||||
}
|
||||
|
||||
/* 알림 애니메이션 */
|
||||
|
||||
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 |
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* API 통신 유틸리티
|
||||
*/
|
||||
class API {
|
||||
class DocumentServerAPI {
|
||||
constructor() {
|
||||
this.baseURL = 'http://localhost:24102/api';
|
||||
// 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 프록시 환경 설정 완료 - 상대 경로 사용');
|
||||
}
|
||||
|
||||
// 토큰 설정
|
||||
@@ -32,16 +38,25 @@ class API {
|
||||
|
||||
// GET 요청
|
||||
async get(endpoint, params = {}) {
|
||||
const url = new URL(`${this.baseURL}${endpoint}`, window.location.origin);
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== null && params[key] !== undefined) {
|
||||
url.searchParams.append(key, params[key]);
|
||||
}
|
||||
});
|
||||
// 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);
|
||||
@@ -49,21 +64,41 @@ class API {
|
||||
|
||||
// POST 요청
|
||||
async post(endpoint, data = {}) {
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||
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 response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||
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);
|
||||
@@ -71,9 +106,14 @@ class API {
|
||||
|
||||
// DELETE 요청
|
||||
async delete(endpoint) {
|
||||
const response = await fetch(`${this.baseURL}${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);
|
||||
@@ -81,15 +121,20 @@ class API {
|
||||
|
||||
// 파일 업로드
|
||||
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(`${this.baseURL}${endpoint}`, {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
@@ -119,7 +164,32 @@ class API {
|
||||
|
||||
// 인증 관련 API
|
||||
async login(email, password) {
|
||||
return await this.post('/auth/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() {
|
||||
@@ -143,14 +213,26 @@ class API {
|
||||
return await this.get('/documents/', params);
|
||||
}
|
||||
|
||||
async getDocumentsHierarchy() {
|
||||
return await this.get('/documents/hierarchy/structured');
|
||||
}
|
||||
|
||||
async getDocument(documentId) {
|
||||
return await this.get(`/documents/${documentId}`);
|
||||
}
|
||||
|
||||
async getDocumentContent(documentId) {
|
||||
return await this.get(`/documents/${documentId}/content`);
|
||||
}
|
||||
|
||||
async uploadDocument(formData) {
|
||||
return await this.uploadFile('/documents/', formData);
|
||||
}
|
||||
|
||||
async updateDocument(documentId, updateData) {
|
||||
return await this.put(`/documents/${documentId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteDocument(documentId) {
|
||||
return await this.delete(`/documents/${documentId}`);
|
||||
}
|
||||
@@ -165,6 +247,7 @@ class API {
|
||||
|
||||
// 하이라이트 관련 API
|
||||
async createHighlight(highlightData) {
|
||||
console.log('🎨 createHighlight 메서드 호출됨:', highlightData);
|
||||
return await this.post('/highlights/', highlightData);
|
||||
}
|
||||
|
||||
@@ -172,30 +255,39 @@ class API {
|
||||
return await this.get(`/highlights/document/${documentId}`);
|
||||
}
|
||||
|
||||
async updateHighlight(highlightId, data) {
|
||||
return await this.put(`/highlights/${highlightId}`, data);
|
||||
async updateHighlight(highlightId, updateData) {
|
||||
return await this.put(`/highlights/${highlightId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteHighlight(highlightId) {
|
||||
return await this.delete(`/highlights/${highlightId}`);
|
||||
}
|
||||
|
||||
// 메모 관련 API
|
||||
// === 하이라이트 메모 (Highlight Memo) 관련 API ===
|
||||
// 용어 정의: 하이라이트에 달리는 짧은 코멘트
|
||||
async createNote(noteData) {
|
||||
return await this.post('/notes/', noteData);
|
||||
return await this.post('/highlight-notes/', noteData);
|
||||
}
|
||||
|
||||
async getNotes(params = {}) {
|
||||
return await this.get('/notes/', 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(`/notes/document/${documentId}`);
|
||||
return await this.get(`/highlight-notes/`, { document_id: documentId });
|
||||
}
|
||||
|
||||
async updateNote(noteId, data) {
|
||||
return await this.put(`/notes/${noteId}`, data);
|
||||
}
|
||||
|
||||
|
||||
async deleteNote(noteId) {
|
||||
return await this.delete(`/notes/${noteId}`);
|
||||
@@ -266,6 +358,7 @@ class API {
|
||||
}
|
||||
|
||||
async createHighlight(highlightData) {
|
||||
console.log('🎨 createHighlight 메서드 호출됨:', highlightData);
|
||||
return await this.post('/highlights/', highlightData);
|
||||
}
|
||||
|
||||
@@ -278,6 +371,8 @@ class API {
|
||||
}
|
||||
|
||||
// === 메모 관련 API ===
|
||||
// === 문서 메모 조회 ===
|
||||
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
|
||||
async getDocumentNotes(documentId) {
|
||||
return await this.get(`/notes/document/${documentId}`);
|
||||
}
|
||||
@@ -286,10 +381,6 @@ class API {
|
||||
return await this.post('/notes/', noteData);
|
||||
}
|
||||
|
||||
async updateNote(noteId, noteData) {
|
||||
return await this.put(`/notes/${noteId}`, noteData);
|
||||
}
|
||||
|
||||
async deleteNote(noteId) {
|
||||
return await this.delete(`/notes/${noteId}`);
|
||||
}
|
||||
@@ -326,7 +417,334 @@ class API {
|
||||
if (documentId) params.append('document_id', documentId);
|
||||
return await this.get(`/search/notes?${params}`);
|
||||
}
|
||||
|
||||
// === 서적 관련 API ===
|
||||
async getBooks(skip = 0, limit = 50, search = null) {
|
||||
const params = new URLSearchParams({ skip, limit });
|
||||
if (search) params.append('search', search);
|
||||
return await this.get(`/books?${params}`);
|
||||
}
|
||||
|
||||
async createBook(bookData) {
|
||||
return await this.post('/books', bookData);
|
||||
}
|
||||
|
||||
async getBook(bookId) {
|
||||
return await this.get(`/books/${bookId}`);
|
||||
}
|
||||
|
||||
async 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 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
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -17,26 +17,40 @@ window.authModal = () => ({
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const response = await api.login(this.loginForm.email, this.loginForm.password);
|
||||
console.log('🔐 로그인 시도:', this.loginForm.email);
|
||||
|
||||
// 토큰 저장
|
||||
api.setToken(response.access_token);
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
// API 클래스의 login 메서드 사용 (이미 토큰 설정과 사용자 정보 가져오기 포함)
|
||||
const result = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const userResponse = await api.getCurrentUser();
|
||||
console.log('✅ 로그인 결과:', result);
|
||||
|
||||
// 전역 상태 업데이트
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
detail: { isAuthenticated: true, user: userResponse }
|
||||
}));
|
||||
|
||||
// 모달 닫기 (부모 컴포넌트의 상태 변경)
|
||||
window.dispatchEvent(new CustomEvent('close-login-modal'));
|
||||
this.loginForm = { email: '', password: '' };
|
||||
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;
|
||||
@@ -45,7 +59,7 @@ window.authModal = () => ({
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
await window.api.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
@@ -79,7 +93,7 @@ async function refreshTokenIfNeeded() {
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
// 갱신 실패시 로그아웃
|
||||
api.setToken(null);
|
||||
window.api.setToken(null);
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
detail: { isAuthenticated: false, user: null }
|
||||
|
||||
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 페이지 로드됨');
|
||||
});
|
||||
263
frontend/static/js/header-loader.js
Normal file
263
frontend/static/js/header-loader.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 공통 헤더 로더
|
||||
* 모든 페이지에서 동일한 헤더를 로드하기 위한 유틸리티
|
||||
*/
|
||||
|
||||
class HeaderLoader {
|
||||
constructor() {
|
||||
this.headerLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 HTML을 로드하고 삽입
|
||||
*/
|
||||
async loadHeader(targetSelector = '#header-container') {
|
||||
if (this.headerLoaded) {
|
||||
console.log('✅ 헤더가 이미 로드됨');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔄 헤더 로딩 중...');
|
||||
|
||||
const response = await fetch('components/header.html?v=2025012352');
|
||||
if (!response.ok) {
|
||||
throw new Error(`헤더 로드 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const headerHtml = await response.text();
|
||||
|
||||
// 헤더 컨테이너 찾기
|
||||
const container = document.querySelector(targetSelector);
|
||||
if (!container) {
|
||||
throw new Error(`헤더 컨테이너를 찾을 수 없음: ${targetSelector}`);
|
||||
}
|
||||
|
||||
// 헤더 HTML 삽입
|
||||
container.innerHTML = headerHtml;
|
||||
|
||||
this.headerLoaded = true;
|
||||
console.log('✅ 헤더 로드 완료');
|
||||
|
||||
// 헤더 로드 완료 이벤트 발생
|
||||
document.dispatchEvent(new CustomEvent('headerLoaded'));
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 헤더 로드 오류:', error);
|
||||
this.showFallbackHeader(targetSelector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 로드 실패 시 폴백 헤더 표시
|
||||
*/
|
||||
showFallbackHeader(targetSelector) {
|
||||
const container = document.querySelector(targetSelector);
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<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="flex space-x-6">
|
||||
<a href="index.html" class="text-gray-600 hover:text-blue-600">📁 문서 관리</a>
|
||||
<a href="memo-tree.html" class="text-gray-600 hover:text-blue-600">🌳 메모장</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 정보 가져오기
|
||||
*/
|
||||
getCurrentPageInfo() {
|
||||
const path = window.location.pathname;
|
||||
const filename = path.split('/').pop().replace('.html', '') || 'index';
|
||||
|
||||
const pageInfo = {
|
||||
filename,
|
||||
isDocumentPage: ['index', 'hierarchy'].includes(filename),
|
||||
isMemoPage: ['memo-tree', 'story-view'].includes(filename)
|
||||
};
|
||||
|
||||
return pageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지별 활성 상태 업데이트
|
||||
*/
|
||||
updateActiveStates() {
|
||||
const pageInfo = this.getCurrentPageInfo();
|
||||
|
||||
// 모든 활성 클래스 제거
|
||||
document.querySelectorAll('.nav-link, .nav-dropdown-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 페이지에 따라 활성 상태 설정
|
||||
console.log('현재 페이지:', pageInfo.filename);
|
||||
|
||||
if (pageInfo.isDocumentPage) {
|
||||
const docLink = document.getElementById('doc-nav-link');
|
||||
if (docLink) {
|
||||
docLink.classList.add('active');
|
||||
console.log('문서 관리 메뉴 활성화');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageInfo.isMemoPage) {
|
||||
const memoLink = document.getElementById('memo-nav-link');
|
||||
if (memoLink) {
|
||||
memoLink.classList.add('active');
|
||||
console.log('메모장 메뉴 활성화');
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 페이지 드롭다운 아이템 활성화
|
||||
const pageItemMap = {
|
||||
'index': 'index-nav-item',
|
||||
'hierarchy': 'hierarchy-nav-item',
|
||||
'memo-tree': 'memo-tree-nav-item',
|
||||
'story-view': 'story-view-nav-item',
|
||||
'search': 'search-nav-link',
|
||||
'notes': 'notes-nav-link',
|
||||
'notebooks': 'notebooks-nav-item',
|
||||
'note-editor': 'note-editor-nav-item'
|
||||
};
|
||||
|
||||
const itemId = pageItemMap[pageInfo.filename];
|
||||
if (itemId) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (item) {
|
||||
item.classList.add('active');
|
||||
console.log(`${pageInfo.filename} 페이지 아이템 활성화`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.headerLoader = new HeaderLoader();
|
||||
|
||||
// DOM 로드 완료 시 자동 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.headerLoader.loadHeader();
|
||||
});
|
||||
|
||||
// 헤더 로드 완료 후 활성 상태 업데이트
|
||||
document.addEventListener('headerLoaded', () => {
|
||||
setTimeout(() => {
|
||||
window.headerLoader.updateActiveStates();
|
||||
|
||||
// updateUserMenu 함수 정의 (헤더 로더에서 직접 정의)
|
||||
if (typeof window.updateUserMenu === 'undefined') {
|
||||
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');
|
||||
|
||||
// 사용자 정보 요소들
|
||||
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 = user.role === 'root' ? '시스템 관리자' :
|
||||
user.role === 'admin' ? '관리자' : '사용자';
|
||||
|
||||
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;
|
||||
|
||||
// 관리자 메뉴 표시/숨김
|
||||
console.log('🔍 사용자 권한 확인:', {
|
||||
role: user.role,
|
||||
is_admin: user.is_admin,
|
||||
can_manage_books: user.can_manage_books,
|
||||
can_manage_notes: user.can_manage_notes,
|
||||
can_manage_novels: user.can_manage_novels
|
||||
});
|
||||
|
||||
if (adminMenuSection) {
|
||||
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
|
||||
console.log('✅ 관리자 메뉴 표시');
|
||||
adminMenuSection.classList.remove('hidden');
|
||||
} else {
|
||||
console.log('❌ 관리자 메뉴 숨김');
|
||||
adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
console.log('❌ adminMenuSection 요소를 찾을 수 없음');
|
||||
}
|
||||
} else {
|
||||
// 로그아웃된 상태
|
||||
console.log('❌ 로그아웃 상태');
|
||||
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
||||
if (loginButton) loginButton.classList.remove('hidden');
|
||||
if (adminMenuSection) adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
console.log('✅ updateUserMenu 함수 정의 완료');
|
||||
}
|
||||
|
||||
// 사용자 메뉴 상태 설정 (현재 로그인 상태 확인)
|
||||
setTimeout(() => {
|
||||
// 전역 사용자 정보가 있으면 사용, 없으면 토큰으로 확인
|
||||
if (window.currentUser) {
|
||||
window.updateUserMenu(window.currentUser);
|
||||
} else {
|
||||
// 토큰이 있으면 사용자 정보 다시 가져오기
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
.then(response => response.ok ? response.json() : null)
|
||||
.then(user => {
|
||||
if (user) {
|
||||
window.currentUser = user;
|
||||
window.updateUserMenu(user);
|
||||
} else {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
})
|
||||
.catch(() => window.updateUserMenu(null));
|
||||
} else {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// 전역 함수들이 정의되지 않은 경우 빈 함수로 초기화
|
||||
if (typeof window.handleLanguageChange === 'undefined') {
|
||||
window.handleLanguageChange = function(lang) {
|
||||
console.log('언어 변경 함수가 아직 로드되지 않았습니다:', lang);
|
||||
};
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
@@ -1,201 +1,351 @@
|
||||
/**
|
||||
* 메인 애플리케이션 Alpine.js 컴포넌트
|
||||
*/
|
||||
|
||||
// 메인 문서 앱 컴포넌트
|
||||
// 메인 애플리케이션 컴포넌트
|
||||
window.documentApp = () => ({
|
||||
// 상태
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
|
||||
// 문서 관련
|
||||
// 상태 관리
|
||||
documents: [],
|
||||
tags: [],
|
||||
selectedTag: '',
|
||||
viewMode: 'grid', // 'grid' 또는 'list'
|
||||
filteredDocuments: [],
|
||||
groupedDocuments: [],
|
||||
expandedBooks: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// 검색
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
showLoginModal: false,
|
||||
|
||||
// 필터링 및 검색
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
selectedTag: '',
|
||||
availableTags: [],
|
||||
|
||||
// UI 상태
|
||||
viewMode: 'grid', // 'grid' 또는 'books'
|
||||
user: null, // currentUser의 별칭
|
||||
tags: [], // availableTags의 별칭
|
||||
|
||||
// 모달 상태
|
||||
showLoginModal: false,
|
||||
showUploadModal: false,
|
||||
showProfile: false,
|
||||
showMyNotes: false,
|
||||
showBookmarks: false,
|
||||
showAdmin: false,
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
},
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
// 인증 상태 확인
|
||||
await this.checkAuth();
|
||||
|
||||
// 인증 상태 변경 이벤트 리스너
|
||||
window.addEventListener('auth-changed', (event) => {
|
||||
this.isAuthenticated = event.detail.isAuthenticated;
|
||||
this.user = event.detail.user;
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
this.loadInitialData();
|
||||
} else {
|
||||
this.resetData();
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인 모달 닫기 이벤트 리스너
|
||||
window.addEventListener('close-login-modal', () => {
|
||||
this.showLoginModal = false;
|
||||
});
|
||||
|
||||
// 문서 변경 이벤트 리스너
|
||||
window.addEventListener('documents-changed', () => {
|
||||
if (this.isAuthenticated) {
|
||||
this.loadDocuments();
|
||||
this.loadTags();
|
||||
}
|
||||
});
|
||||
|
||||
// 업로드 모달 닫기 이벤트 리스너
|
||||
window.addEventListener('close-upload-modal', () => {
|
||||
this.showUploadModal = false;
|
||||
});
|
||||
|
||||
// 알림 표시 이벤트 리스너
|
||||
window.addEventListener('show-notification', (event) => {
|
||||
if (this.showNotification) {
|
||||
this.showNotification(event.detail.message, event.detail.type);
|
||||
}
|
||||
});
|
||||
|
||||
// 초기 데이터 로드
|
||||
await this.checkAuthStatus();
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadInitialData();
|
||||
await this.loadDocuments();
|
||||
} else {
|
||||
// 로그인하지 않은 경우에도 빈 배열로 초기화
|
||||
this.groupedDocuments = [];
|
||||
}
|
||||
this.setupEventListeners();
|
||||
|
||||
// 커스텀 이벤트 리스너 등록
|
||||
document.addEventListener('open-login-modal', () => {
|
||||
console.log('📨 open-login-modal 이벤트 수신 (index.html)');
|
||||
this.openLoginModal();
|
||||
});
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuth() {
|
||||
if (!api.token) {
|
||||
this.isAuthenticated = false;
|
||||
return;
|
||||
}
|
||||
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
this.user = await api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
window.api.setToken(token);
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
console.log('Not authenticated or token expired');
|
||||
this.isAuthenticated = false;
|
||||
api.setToken(null);
|
||||
}
|
||||
},
|
||||
|
||||
// 초기 데이터 로드
|
||||
async loadInitialData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadDocuments(),
|
||||
this.loadTags()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load initial data:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 데이터 리셋
|
||||
resetData() {
|
||||
this.documents = [];
|
||||
this.tags = [];
|
||||
this.selectedTag = '';
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
},
|
||||
|
||||
// 문서 목록 로드
|
||||
async loadDocuments() {
|
||||
try {
|
||||
const response = await api.getDocuments();
|
||||
let allDocuments = response || [];
|
||||
this.currentUser = null;
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
// 태그 필터링
|
||||
let filteredDocs = allDocuments;
|
||||
if (this.selectedTag) {
|
||||
filteredDocs = allDocuments.filter(doc =>
|
||||
doc.tags && doc.tags.includes(this.selectedTag)
|
||||
);
|
||||
// 로그인 페이지로 리다이렉트 (setup.html 제외)
|
||||
if (!window.location.pathname.includes('setup.html') &&
|
||||
!window.location.pathname.includes('login.html')) {
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.documents = filteredDocs;
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error);
|
||||
this.documents = [];
|
||||
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error');
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
}
|
||||
},
|
||||
|
||||
// 태그 목록 로드
|
||||
async loadTags() {
|
||||
try {
|
||||
const response = await api.getTags();
|
||||
this.tags = response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
this.tags = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 검색
|
||||
async searchDocuments() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
this.searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await api.search({
|
||||
q: this.searchQuery,
|
||||
limit: 20
|
||||
});
|
||||
this.searchResults = results.results;
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 열기
|
||||
openDocument(document) {
|
||||
// 문서 뷰어 페이지로 이동
|
||||
window.location.href = `/viewer.html?id=${document.id}`;
|
||||
// 로그인 모달 열기
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
},
|
||||
|
||||
// 로그아웃
|
||||
async logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.resetData();
|
||||
await window.api.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
this.documents = [];
|
||||
this.filteredDocuments = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners() {
|
||||
// 문서 변경 이벤트 리스너
|
||||
window.addEventListener('documents-changed', () => {
|
||||
this.loadDocuments();
|
||||
});
|
||||
|
||||
// 알림 이벤트 리스너
|
||||
window.addEventListener('show-notification', (event) => {
|
||||
this.showNotification(event.detail.message, event.detail.type);
|
||||
});
|
||||
|
||||
// 인증 상태 변경 이벤트 리스너
|
||||
window.addEventListener('auth-changed', (event) => {
|
||||
this.isAuthenticated = event.detail.isAuthenticated;
|
||||
this.currentUser = event.detail.user;
|
||||
this.showLoginModal = false;
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
if (this.isAuthenticated) {
|
||||
this.loadDocuments();
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인 모달 닫기 이벤트 리스너
|
||||
window.addEventListener('close-login-modal', () => {
|
||||
this.showLoginModal = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 문서 목록 로드
|
||||
async loadDocuments() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const allDocuments = await window.api.getDocuments();
|
||||
|
||||
// HTML 문서만 필터링 (PDF 파일 제외)
|
||||
this.documents = allDocuments.filter(doc =>
|
||||
doc.html_path &&
|
||||
doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
|
||||
);
|
||||
|
||||
console.log('📄 전체 문서:', allDocuments.length, '개');
|
||||
console.log('📄 HTML 문서:', this.documents.length, '개');
|
||||
console.log('📄 PDF 파일:', allDocuments.length - this.documents.length, '개 (제외됨)');
|
||||
|
||||
this.updateAvailableTags();
|
||||
this.filterDocuments();
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error);
|
||||
this.error = 'Failed to load documents: ' + error.message;
|
||||
this.documents = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 뷰어 열기
|
||||
// 사용 가능한 태그 업데이트
|
||||
updateAvailableTags() {
|
||||
const tagSet = new Set();
|
||||
this.documents.forEach(doc => {
|
||||
if (doc.tags) {
|
||||
doc.tags.forEach(tag => tagSet.add(tag));
|
||||
}
|
||||
});
|
||||
this.availableTags = Array.from(tagSet).sort();
|
||||
this.tags = this.availableTags; // 별칭 동기화
|
||||
},
|
||||
|
||||
// UI 상태 동기화
|
||||
syncUIState() {
|
||||
this.user = this.currentUser;
|
||||
this.tags = this.availableTags;
|
||||
},
|
||||
|
||||
// 문서 필터링
|
||||
filterDocuments() {
|
||||
let filtered = this.documents;
|
||||
|
||||
// 검색어 필터링
|
||||
if (this.searchQuery) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(doc =>
|
||||
doc.title.toLowerCase().includes(query) ||
|
||||
(doc.description && doc.description.toLowerCase().includes(query)) ||
|
||||
(doc.tags && doc.tags.some(tag => tag.toLowerCase().includes(query)))
|
||||
);
|
||||
}
|
||||
|
||||
// 태그 필터링
|
||||
if (this.selectedTag) {
|
||||
filtered = filtered.filter(doc =>
|
||||
doc.tags && doc.tags.includes(this.selectedTag)
|
||||
);
|
||||
}
|
||||
|
||||
this.filteredDocuments = filtered;
|
||||
this.groupDocumentsByBook();
|
||||
},
|
||||
|
||||
// 검색어 변경 시
|
||||
onSearchChange() {
|
||||
this.filterDocuments();
|
||||
},
|
||||
|
||||
// 필터 초기화
|
||||
clearFilters() {
|
||||
this.searchQuery = '';
|
||||
this.selectedTag = '';
|
||||
this.filterDocuments();
|
||||
},
|
||||
|
||||
// 서적별 그룹화
|
||||
groupDocumentsByBook() {
|
||||
if (!this.filteredDocuments || this.filteredDocuments.length === 0) {
|
||||
this.groupedDocuments = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
|
||||
this.filteredDocuments.forEach(doc => {
|
||||
const bookKey = doc.book_id || 'no-book';
|
||||
if (!grouped[bookKey]) {
|
||||
grouped[bookKey] = {
|
||||
book: doc.book_id ? {
|
||||
id: doc.book_id,
|
||||
title: doc.book_title,
|
||||
author: doc.book_author
|
||||
} : null,
|
||||
documents: []
|
||||
};
|
||||
}
|
||||
grouped[bookKey].documents.push(doc);
|
||||
});
|
||||
|
||||
// 배열로 변환하고 정렬 (서적 있는 것 먼저, 그 다음 서적명 순)
|
||||
this.groupedDocuments = Object.values(grouped).sort((a, b) => {
|
||||
if (!a.book && b.book) return 1;
|
||||
if (a.book && !b.book) return -1;
|
||||
if (!a.book && !b.book) return 0;
|
||||
return a.book.title.localeCompare(b.book.title);
|
||||
});
|
||||
|
||||
// 기본적으로 모든 서적을 펼친 상태로 설정
|
||||
this.expandedBooks = this.groupedDocuments.map(group => group.book?.id || 'no-book');
|
||||
},
|
||||
|
||||
// 서적 펼침/접힘 토글
|
||||
toggleBookExpansion(bookId) {
|
||||
const index = this.expandedBooks.indexOf(bookId);
|
||||
if (index > -1) {
|
||||
this.expandedBooks.splice(index, 1);
|
||||
} else {
|
||||
this.expandedBooks.push(bookId);
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 검색 (HTML에서 사용)
|
||||
searchDocuments() {
|
||||
this.filterDocuments();
|
||||
},
|
||||
|
||||
// 태그 선택 시
|
||||
onTagSelect(tag) {
|
||||
this.selectedTag = this.selectedTag === tag ? '' : tag;
|
||||
this.filterDocuments();
|
||||
},
|
||||
|
||||
// 태그 필터 초기화
|
||||
clearTagFilter() {
|
||||
this.selectedTag = '';
|
||||
this.filterDocuments();
|
||||
},
|
||||
|
||||
// 문서 삭제
|
||||
async deleteDocument(documentId) {
|
||||
if (!confirm('정말로 이 문서를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.deleteDocument(documentId);
|
||||
await this.loadDocuments();
|
||||
this.showNotification('문서가 삭제되었습니다', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document:', error);
|
||||
this.showNotification('문서 삭제에 실패했습니다: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 보기
|
||||
viewDocument(documentId) {
|
||||
// 현재 페이지 정보를 세션 스토리지에 저장
|
||||
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
|
||||
sessionStorage.setItem('previousPage', currentPage);
|
||||
|
||||
// from 파라미터 추가 - 같은 창에서 이동
|
||||
const fromParam = currentPage === 'hierarchy.html' ? 'hierarchy' : 'index';
|
||||
window.location.href = `/viewer.html?id=${documentId}&from=${fromParam}`;
|
||||
},
|
||||
|
||||
// 서적의 문서들 보기
|
||||
openBookDocuments(book) {
|
||||
if (book && book.id) {
|
||||
// 서적 ID를 URL 파라미터로 전달하여 해당 서적의 문서들만 표시
|
||||
window.location.href = `book-documents.html?bookId=${book.id}`;
|
||||
} else {
|
||||
// 서적 미분류 문서들 보기
|
||||
window.location.href = `book-documents.html?bookId=none`;
|
||||
}
|
||||
},
|
||||
|
||||
// 업로드 페이지 열기
|
||||
openUploadPage() {
|
||||
window.location.href = 'upload.html';
|
||||
},
|
||||
|
||||
// 문서 열기 (HTML에서 사용)
|
||||
openDocument(documentId) {
|
||||
window.location.href = `/viewer.html?id=${documentId}`;
|
||||
this.viewDocument(documentId);
|
||||
},
|
||||
|
||||
// 문서 수정 (HTML에서 사용)
|
||||
editDocument(document) {
|
||||
// TODO: 문서 수정 모달 구현
|
||||
console.log('문서 수정:', document);
|
||||
alert('문서 수정 기능은 곧 구현됩니다!');
|
||||
},
|
||||
|
||||
// 업로드 모달 열기
|
||||
openUploadModal() {
|
||||
this.showUploadModal = true;
|
||||
},
|
||||
|
||||
// 업로드 모달 닫기
|
||||
closeUploadModal() {
|
||||
this.showUploadModal = false;
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
},
|
||||
@@ -221,7 +371,6 @@ window.documentApp = () => ({
|
||||
|
||||
// 파일 업로드 컴포넌트
|
||||
window.uploadModal = () => ({
|
||||
show: false,
|
||||
uploading: false,
|
||||
uploadForm: {
|
||||
title: '',
|
||||
@@ -233,6 +382,19 @@ window.uploadModal = () => ({
|
||||
pdf_file: null
|
||||
},
|
||||
uploadError: '',
|
||||
|
||||
// 서적 관련 상태
|
||||
bookSelectionMode: 'none', // 'existing', 'new', 'none'
|
||||
bookSearchQuery: '',
|
||||
searchedBooks: [],
|
||||
selectedBook: null,
|
||||
newBook: {
|
||||
title: '',
|
||||
author: '',
|
||||
description: ''
|
||||
},
|
||||
suggestions: [],
|
||||
searchTimeout: null,
|
||||
|
||||
// 파일 선택
|
||||
onFileSelect(event, fileType) {
|
||||
@@ -247,15 +409,44 @@ window.uploadModal = () => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
handleFileDrop(event, fileType) {
|
||||
event.target.classList.remove('dragover');
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
|
||||
// 파일 타입 검증
|
||||
if (fileType === 'html_file' && !file.name.match(/\.(html|htm)$/i)) {
|
||||
this.uploadError = 'HTML 파일만 업로드 가능합니다';
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileType === 'pdf_file' && !file.name.match(/\.pdf$/i)) {
|
||||
this.uploadError = 'PDF 파일만 업로드 가능합니다';
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadForm[fileType] = file;
|
||||
this.uploadError = '';
|
||||
|
||||
// HTML 파일의 경우 제목 자동 설정
|
||||
if (fileType === 'html_file' && !this.uploadForm.title) {
|
||||
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 업로드 실행
|
||||
async upload() {
|
||||
// 필수 필드 검증
|
||||
if (!this.uploadForm.html_file) {
|
||||
this.uploadError = 'HTML 파일을 선택해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!this.uploadForm.title.trim()) {
|
||||
this.uploadError = '제목을 입력해주세요';
|
||||
this.uploadError = '문서 제목을 입력해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,25 +454,52 @@ window.uploadModal = () => ({
|
||||
this.uploadError = '';
|
||||
|
||||
try {
|
||||
let bookId = null;
|
||||
|
||||
// 서적 처리
|
||||
if (this.bookSelectionMode === 'new' && this.newBook.title.trim()) {
|
||||
const newBook = await window.api.createBook({
|
||||
title: this.newBook.title,
|
||||
author: this.newBook.author || null,
|
||||
description: this.newBook.description || null,
|
||||
language: this.uploadForm.language || 'ko',
|
||||
is_public: this.uploadForm.is_public
|
||||
});
|
||||
bookId = newBook.id;
|
||||
} else if (this.bookSelectionMode === 'existing' && this.selectedBook) {
|
||||
bookId = this.selectedBook.id;
|
||||
}
|
||||
|
||||
// FormData 생성
|
||||
const formData = new FormData();
|
||||
formData.append('title', this.uploadForm.title);
|
||||
formData.append('description', this.uploadForm.description);
|
||||
formData.append('tags', this.uploadForm.tags);
|
||||
formData.append('is_public', this.uploadForm.is_public);
|
||||
formData.append('description', this.uploadForm.description || '');
|
||||
formData.append('html_file', this.uploadForm.html_file);
|
||||
|
||||
if (this.uploadForm.pdf_file) {
|
||||
formData.append('pdf_file', this.uploadForm.pdf_file);
|
||||
}
|
||||
|
||||
// 서적 ID 추가
|
||||
if (bookId) {
|
||||
formData.append('book_id', bookId);
|
||||
}
|
||||
|
||||
formData.append('language', this.uploadForm.language || 'ko');
|
||||
formData.append('is_public', this.uploadForm.is_public);
|
||||
|
||||
if (this.uploadForm.tags) {
|
||||
formData.append('tags', this.uploadForm.tags);
|
||||
}
|
||||
|
||||
if (this.uploadForm.document_date) {
|
||||
formData.append('document_date', this.uploadForm.document_date);
|
||||
}
|
||||
|
||||
await api.uploadDocument(formData);
|
||||
// 업로드 실행
|
||||
await window.api.uploadDocument(formData);
|
||||
|
||||
// 성공시 모달 닫기 및 목록 새로고침
|
||||
this.show = false;
|
||||
// 성공 처리
|
||||
this.resetForm();
|
||||
|
||||
// 문서 목록 새로고침
|
||||
@@ -315,138 +533,66 @@ window.uploadModal = () => ({
|
||||
// 파일 입력 필드 리셋
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
fileInputs.forEach(input => input.value = '');
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 업로드 컴포넌트
|
||||
window.uploadModal = () => ({
|
||||
uploading: false,
|
||||
uploadForm: {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
is_public: false,
|
||||
document_date: '',
|
||||
html_file: null,
|
||||
pdf_file: null
|
||||
},
|
||||
uploadError: '',
|
||||
|
||||
// 파일 선택
|
||||
onFileSelect(event, fileType) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.uploadForm[fileType] = file;
|
||||
|
||||
// HTML 파일의 경우 제목 자동 설정
|
||||
if (fileType === 'html_file' && !this.uploadForm.title) {
|
||||
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
handleFileDrop(event, fileType) {
|
||||
event.target.classList.remove('dragover');
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
|
||||
// 파일 타입 검증
|
||||
if (fileType === 'html_file' && !file.name.match(/\.(html|htm)$/i)) {
|
||||
this.uploadError = 'HTML 파일만 업로드 가능합니다';
|
||||
return;
|
||||
}
|
||||
if (fileType === 'pdf_file' && !file.name.match(/\.pdf$/i)) {
|
||||
this.uploadError = 'PDF 파일만 업로드 가능합니다';
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadForm[fileType] = file;
|
||||
this.uploadError = '';
|
||||
|
||||
// HTML 파일의 경우 제목 자동 설정
|
||||
if (fileType === 'html_file' && !this.uploadForm.title) {
|
||||
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 업로드 실행 (목업)
|
||||
async upload() {
|
||||
if (!this.uploadForm.html_file) {
|
||||
this.uploadError = 'HTML 파일을 선택해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.uploadForm.title.trim()) {
|
||||
this.uploadError = '제목을 입력해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadError = '';
|
||||
|
||||
try {
|
||||
// FormData 생성
|
||||
const formData = new FormData();
|
||||
formData.append('title', this.uploadForm.title);
|
||||
formData.append('description', this.uploadForm.description || '');
|
||||
formData.append('html_file', this.uploadForm.html_file);
|
||||
|
||||
if (this.uploadForm.pdf_file) {
|
||||
formData.append('pdf_file', this.uploadForm.pdf_file);
|
||||
}
|
||||
|
||||
formData.append('language', this.uploadForm.language);
|
||||
formData.append('is_public', this.uploadForm.is_public);
|
||||
|
||||
// 태그 추가
|
||||
if (this.uploadForm.tags && this.uploadForm.tags.length > 0) {
|
||||
this.uploadForm.tags.forEach(tag => {
|
||||
formData.append('tags', tag);
|
||||
});
|
||||
}
|
||||
|
||||
// 실제 API 호출
|
||||
await api.uploadDocument(formData);
|
||||
|
||||
// 성공시 모달 닫기 및 목록 새로고침
|
||||
window.dispatchEvent(new CustomEvent('close-upload-modal'));
|
||||
this.resetForm();
|
||||
|
||||
// 문서 목록 새로고침
|
||||
window.dispatchEvent(new CustomEvent('documents-changed'));
|
||||
|
||||
// 성공 알림
|
||||
window.dispatchEvent(new CustomEvent('show-notification', {
|
||||
detail: { message: '문서가 성공적으로 업로드되었습니다', type: 'success' }
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
this.uploadError = error.message || '업로드에 실패했습니다';
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 폼 리셋
|
||||
resetForm() {
|
||||
this.uploadForm = {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
is_public: false,
|
||||
document_date: '',
|
||||
html_file: null,
|
||||
pdf_file: null
|
||||
};
|
||||
this.uploadError = '';
|
||||
|
||||
// 파일 입력 필드 리셋
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
fileInputs.forEach(input => input.value = '');
|
||||
// 서적 관련 상태 리셋
|
||||
this.bookSelectionMode = 'none';
|
||||
this.bookSearchQuery = '';
|
||||
this.searchedBooks = [];
|
||||
this.selectedBook = null;
|
||||
this.newBook = { title: '', author: '', description: '' };
|
||||
this.suggestions = [];
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 검색
|
||||
async searchBooks() {
|
||||
if (!this.bookSearchQuery.trim()) {
|
||||
this.searchedBooks = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const books = await window.api.searchBooks(this.bookSearchQuery, 10);
|
||||
this.searchedBooks = books;
|
||||
} catch (error) {
|
||||
console.error('서적 검색 실패:', error);
|
||||
this.searchedBooks = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 선택
|
||||
selectBook(book) {
|
||||
this.selectedBook = book;
|
||||
this.bookSearchQuery = book.title;
|
||||
this.searchedBooks = [];
|
||||
},
|
||||
|
||||
// 유사도 추천 가져오기
|
||||
async getSuggestions() {
|
||||
if (!this.newBook.title.trim()) {
|
||||
this.suggestions = [];
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const suggestions = await window.api.getBookSuggestions(this.newBook.title, 3);
|
||||
this.suggestions = suggestions.filter(s => s.similarity_score > 0.5); // 50% 이상 유사한 것만
|
||||
} catch (error) {
|
||||
console.error('추천 가져오기 실패:', error);
|
||||
this.suggestions = [];
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 추천에서 기존 서적 선택
|
||||
selectExistingFromSuggestion(suggestion) {
|
||||
this.bookSelectionMode = 'existing';
|
||||
this.selectedBook = suggestion;
|
||||
this.bookSearchQuery = suggestion.title;
|
||||
this.suggestions = [];
|
||||
this.newBook = { title: '', author: '', description: '' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
1247
frontend/static/js/memo-tree.js
Normal file
1247
frontend/static/js/memo-tree.js
Normal file
File diff suppressed because it is too large
Load Diff
349
frontend/static/js/note-editor.js
Normal file
349
frontend/static/js/note-editor.js
Normal file
@@ -0,0 +1,349 @@
|
||||
function noteEditorApp() {
|
||||
return {
|
||||
// 상태 관리
|
||||
noteData: {
|
||||
title: '',
|
||||
content: '',
|
||||
note_type: 'note',
|
||||
tags: [],
|
||||
is_published: false,
|
||||
parent_note_id: null,
|
||||
sort_order: 0,
|
||||
notebook_id: null
|
||||
},
|
||||
|
||||
// 노트북 관련
|
||||
availableNotebooks: [],
|
||||
|
||||
// UI 상태
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
isEditing: false,
|
||||
noteId: null,
|
||||
|
||||
// 에디터 관련
|
||||
quillEditor: null,
|
||||
editorMode: 'wysiwyg', // 'wysiwyg' 또는 'html'
|
||||
tagInput: '',
|
||||
|
||||
// 인증 관련
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// API 클라이언트
|
||||
api: null,
|
||||
|
||||
async init() {
|
||||
console.log('📝 노트 에디터 초기화 시작');
|
||||
|
||||
try {
|
||||
// API 클라이언트 초기화
|
||||
this.api = new DocumentServerAPI();
|
||||
console.log('🔧 API 클라이언트 초기화됨:', this.api);
|
||||
console.log('🔧 getNotebooks 메서드 존재 여부:', typeof this.api.getNotebooks);
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (!this.isAuthenticated) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// URL에서 노트 ID 및 노트북 정보 확인
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.noteId = urlParams.get('id');
|
||||
const notebookId = urlParams.get('notebook_id');
|
||||
const notebookName = urlParams.get('notebook_name');
|
||||
|
||||
// 노트북 목록 로드
|
||||
await this.loadNotebooks();
|
||||
|
||||
// URL에서 노트북이 지정된 경우 자동 설정
|
||||
if (notebookId && !this.noteId) { // 새 노트 생성 시에만
|
||||
this.noteData.notebook_id = notebookId;
|
||||
console.log('📚 노트북 자동 설정:', notebookName || notebookId);
|
||||
}
|
||||
|
||||
if (this.noteId) {
|
||||
this.isEditing = true;
|
||||
await this.loadNote(this.noteId);
|
||||
}
|
||||
|
||||
// Quill 에디터 초기화
|
||||
this.initQuillEditor();
|
||||
|
||||
console.log('✅ 노트 에디터 초기화 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 노트 에디터 초기화 실패:', error);
|
||||
this.error = '노트 에디터를 초기화하는 중 오류가 발생했습니다.';
|
||||
}
|
||||
},
|
||||
|
||||
async loadHeader() {
|
||||
try {
|
||||
if (typeof loadHeaderComponent === 'function') {
|
||||
await loadHeaderComponent();
|
||||
} else if (typeof window.loadHeaderComponent === 'function') {
|
||||
await window.loadHeaderComponent();
|
||||
} else {
|
||||
console.warn('헤더 로더 함수를 찾을 수 없습니다. 수동으로 헤더를 로드합니다.');
|
||||
// 수동으로 헤더 로드
|
||||
const headerContainer = document.getElementById('header-container');
|
||||
if (headerContainer) {
|
||||
const response = await fetch('/components/header.html');
|
||||
const headerHTML = await response.text();
|
||||
headerContainer.innerHTML = headerHTML;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const response = await this.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = response;
|
||||
console.log('✅ 인증된 사용자:', this.currentUser.username);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않은 사용자');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
}
|
||||
},
|
||||
|
||||
async loadNotebooks() {
|
||||
try {
|
||||
console.log('📚 노트북 로드 시작...');
|
||||
console.log('🔧 API 메서드 확인:', typeof this.api.getNotebooks);
|
||||
|
||||
// 임시: 직접 API 호출
|
||||
this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true });
|
||||
console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개');
|
||||
console.log('📚 노트북 데이터 상세:', this.availableNotebooks);
|
||||
|
||||
// 각 노트북의 필드 확인
|
||||
if (this.availableNotebooks.length > 0) {
|
||||
console.log('📚 첫 번째 노트북 필드:', Object.keys(this.availableNotebooks[0]));
|
||||
console.log('📚 첫 번째 노트북 title:', this.availableNotebooks[0].title);
|
||||
console.log('📚 첫 번째 노트북 name:', this.availableNotebooks[0].name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('노트북 로드 실패:', error);
|
||||
this.availableNotebooks = [];
|
||||
}
|
||||
},
|
||||
|
||||
initQuillEditor() {
|
||||
// Quill 에디터 설정
|
||||
const toolbarOptions = [
|
||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||
[{ 'font': [] }],
|
||||
[{ 'size': ['small', false, 'large', 'huge'] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||
[{ 'direction': 'rtl' }],
|
||||
[{ 'align': [] }],
|
||||
['blockquote', 'code-block'],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
];
|
||||
|
||||
this.quillEditor = new Quill('#quill-editor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: toolbarOptions
|
||||
},
|
||||
placeholder: '노트 내용을 작성하세요...'
|
||||
});
|
||||
|
||||
// 에디터 내용 변경 시 동기화
|
||||
this.quillEditor.on('text-change', () => {
|
||||
if (this.editorMode === 'wysiwyg') {
|
||||
this.noteData.content = this.quillEditor.root.innerHTML;
|
||||
}
|
||||
});
|
||||
|
||||
// 기존 내용이 있으면 로드
|
||||
if (this.noteData.content) {
|
||||
this.quillEditor.root.innerHTML = this.noteData.content;
|
||||
}
|
||||
},
|
||||
|
||||
async loadNote(noteId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
console.log('📖 노트 로드 중:', noteId);
|
||||
const note = await this.api.getNoteDocument(noteId);
|
||||
|
||||
this.noteData = {
|
||||
title: note.title || '',
|
||||
content: note.content || '',
|
||||
note_type: note.note_type || 'note',
|
||||
tags: note.tags || [],
|
||||
is_published: note.is_published || false,
|
||||
parent_note_id: note.parent_note_id || null,
|
||||
sort_order: note.sort_order || 0
|
||||
};
|
||||
|
||||
console.log('✅ 노트 로드 완료:', this.noteData.title);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 노트 로드 실패:', error);
|
||||
this.error = '노트를 불러오는 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveNote() {
|
||||
if (!this.noteData.title.trim()) {
|
||||
this.showNotification('제목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// WYSIWYG 모드에서 HTML 동기화
|
||||
if (this.editorMode === 'wysiwyg' && this.quillEditor) {
|
||||
this.noteData.content = this.quillEditor.root.innerHTML;
|
||||
}
|
||||
|
||||
console.log('💾 노트 저장 중:', this.noteData.title);
|
||||
|
||||
let result;
|
||||
if (this.isEditing && this.noteId) {
|
||||
// 기존 노트 업데이트
|
||||
result = await this.api.updateNoteDocument(this.noteId, this.noteData);
|
||||
console.log('✅ 노트 업데이트 완료');
|
||||
} else {
|
||||
// 새 노트 생성
|
||||
result = await this.api.createNoteDocument(this.noteData);
|
||||
console.log('✅ 새 노트 생성 완료');
|
||||
|
||||
// 편집 모드로 전환
|
||||
this.isEditing = true;
|
||||
this.noteId = result.id;
|
||||
|
||||
// URL 업데이트 (새로고침 없이)
|
||||
const newUrl = `${window.location.pathname}?id=${result.id}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
this.showNotification('노트가 성공적으로 저장되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 노트 저장 실패:', error);
|
||||
this.error = '노트 저장 중 오류가 발생했습니다.';
|
||||
this.showNotification('노트 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleEditorMode() {
|
||||
if (this.editorMode === 'wysiwyg') {
|
||||
// WYSIWYG → HTML 코드
|
||||
if (this.quillEditor) {
|
||||
this.noteData.content = this.quillEditor.root.innerHTML;
|
||||
}
|
||||
this.editorMode = 'html';
|
||||
} else {
|
||||
// HTML 코드 → WYSIWYG
|
||||
if (this.quillEditor) {
|
||||
this.quillEditor.root.innerHTML = this.noteData.content || '';
|
||||
}
|
||||
this.editorMode = 'wysiwyg';
|
||||
}
|
||||
},
|
||||
|
||||
addTag() {
|
||||
const tag = this.tagInput.trim();
|
||||
if (tag && !this.noteData.tags.includes(tag)) {
|
||||
this.noteData.tags.push(tag);
|
||||
this.tagInput = '';
|
||||
}
|
||||
},
|
||||
|
||||
removeTag(index) {
|
||||
this.noteData.tags.splice(index, 1);
|
||||
},
|
||||
|
||||
getWordCount() {
|
||||
if (!this.noteData.content) return 0;
|
||||
|
||||
// HTML 태그 제거 후 단어 수 계산
|
||||
const textContent = this.noteData.content.replace(/<[^>]*>/g, '');
|
||||
return textContent.length;
|
||||
},
|
||||
|
||||
goBack() {
|
||||
// 변경사항이 있으면 확인
|
||||
if (this.hasUnsavedChanges()) {
|
||||
if (!confirm('저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = '/notes.html';
|
||||
},
|
||||
|
||||
hasUnsavedChanges() {
|
||||
// 간단한 변경사항 감지 (실제로는 더 정교하게 구현 가능)
|
||||
return this.noteData.title.trim() !== '' || this.noteData.content.trim() !== '';
|
||||
},
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
// 간단한 알림 (나중에 더 정교한 토스트 시스템으로 교체 가능)
|
||||
if (type === 'error') {
|
||||
alert('❌ ' + message);
|
||||
} else if (type === 'success') {
|
||||
alert('✅ ' + message);
|
||||
} else {
|
||||
alert('ℹ️ ' + message);
|
||||
}
|
||||
},
|
||||
|
||||
// 키보드 단축키
|
||||
handleKeydown(event) {
|
||||
// Ctrl+S (또는 Cmd+S): 저장
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
this.saveNote();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 키보드 이벤트 리스너 등록
|
||||
document.addEventListener('keydown', function(event) {
|
||||
// Alpine.js 컴포넌트에 접근
|
||||
const app = Alpine.$data(document.querySelector('[x-data]'));
|
||||
if (app && app.handleKeydown) {
|
||||
app.handleKeydown(event);
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 떠날 때 확인
|
||||
window.addEventListener('beforeunload', function(event) {
|
||||
const app = Alpine.$data(document.querySelector('[x-data]'));
|
||||
if (app && app.hasUnsavedChanges && app.hasUnsavedChanges()) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '저장하지 않은 변경사항이 있습니다.';
|
||||
return event.returnValue;
|
||||
}
|
||||
});
|
||||
310
frontend/static/js/notebooks.js
Normal file
310
frontend/static/js/notebooks.js
Normal file
@@ -0,0 +1,310 @@
|
||||
// 노트북 관리 애플리케이션 컴포넌트
|
||||
window.notebooksApp = () => ({
|
||||
// 상태 관리
|
||||
notebooks: [],
|
||||
stats: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
|
||||
// 알림 시스템
|
||||
notification: {
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'info' // 'success', 'error', 'info'
|
||||
},
|
||||
|
||||
// 필터링
|
||||
searchQuery: '',
|
||||
activeOnly: true,
|
||||
sortBy: 'updated_at',
|
||||
|
||||
// 검색 디바운스
|
||||
searchTimeout: null,
|
||||
|
||||
// 모달 상태
|
||||
showCreateModal: false,
|
||||
showEditModal: false,
|
||||
showDeleteModal: false,
|
||||
editingNotebook: null,
|
||||
deletingNotebook: null,
|
||||
deleting: false,
|
||||
|
||||
// 노트북 폼
|
||||
notebookForm: {
|
||||
title: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
},
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// API 클라이언트
|
||||
api: null,
|
||||
|
||||
// 색상 옵션
|
||||
availableColors: [
|
||||
'#3B82F6', // blue
|
||||
'#10B981', // emerald
|
||||
'#F59E0B', // amber
|
||||
'#EF4444', // red
|
||||
'#8B5CF6', // violet
|
||||
'#06B6D4', // cyan
|
||||
'#84CC16', // lime
|
||||
'#F97316', // orange
|
||||
'#EC4899', // pink
|
||||
'#6B7280' // gray
|
||||
],
|
||||
|
||||
// 아이콘 옵션
|
||||
availableIcons: [
|
||||
{ value: 'book', label: '📖 책' },
|
||||
{ value: 'sticky-note', label: '📝 노트' },
|
||||
{ value: 'lightbulb', label: '💡 아이디어' },
|
||||
{ value: 'graduation-cap', label: '🎓 학습' },
|
||||
{ value: 'briefcase', label: '💼 업무' },
|
||||
{ value: 'heart', label: '❤️ 개인' },
|
||||
{ value: 'code', label: '💻 개발' },
|
||||
{ value: 'palette', label: '🎨 창작' },
|
||||
{ value: 'flask', label: '🧪 연구' },
|
||||
{ value: 'star', label: '⭐ 즐겨찾기' }
|
||||
],
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('📚 Notebooks App 초기화 시작');
|
||||
|
||||
// API 클라이언트 초기화
|
||||
this.api = new DocumentServerAPI();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadStats();
|
||||
await this.loadNotebooks();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await this.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username || user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
if (typeof loadHeaderComponent === 'function') {
|
||||
await loadHeaderComponent();
|
||||
} else if (typeof window.loadHeaderComponent === 'function') {
|
||||
await window.loadHeaderComponent();
|
||||
} else {
|
||||
console.warn('헤더 로더 함수를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 통계 정보 로드
|
||||
async loadStats() {
|
||||
try {
|
||||
this.stats = await this.api.getNotebookStats();
|
||||
console.log('📊 노트북 통계 로드됨:', this.stats);
|
||||
} catch (error) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 노트북 목록 로드
|
||||
async loadNotebooks() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const queryParams = {
|
||||
active_only: this.activeOnly,
|
||||
sort_by: this.sortBy,
|
||||
order: 'desc'
|
||||
};
|
||||
|
||||
if (this.searchQuery) {
|
||||
queryParams.search = this.searchQuery;
|
||||
}
|
||||
|
||||
this.notebooks = await this.api.getNotebooks(queryParams);
|
||||
console.log('📚 노트북 로드됨:', this.notebooks.length, '개');
|
||||
} catch (error) {
|
||||
console.error('노트북 로드 실패:', error);
|
||||
this.error = '노트북을 불러오는 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 검색 디바운스
|
||||
debounceSearch() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.loadNotebooks();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 새로고침
|
||||
async refreshNotebooks() {
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadNotebooks()
|
||||
]);
|
||||
},
|
||||
|
||||
// 노트북 열기 (노트 목록으로 이동)
|
||||
openNotebook(notebook) {
|
||||
window.location.href = `/notes.html?notebook_id=${notebook.id}¬ebook_name=${encodeURIComponent(notebook.title)}`;
|
||||
},
|
||||
|
||||
// 노트북에 노트 생성
|
||||
createNoteInNotebook(notebook) {
|
||||
window.location.href = `/note-editor.html?notebook_id=${notebook.id}¬ebook_name=${encodeURIComponent(notebook.title)}`;
|
||||
},
|
||||
|
||||
// 노트북 편집
|
||||
editNotebook(notebook) {
|
||||
this.editingNotebook = notebook;
|
||||
this.notebookForm = {
|
||||
title: notebook.title,
|
||||
description: notebook.description || '',
|
||||
color: notebook.color,
|
||||
icon: notebook.icon,
|
||||
is_active: notebook.is_active,
|
||||
sort_order: notebook.sort_order
|
||||
};
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
// 노트북 삭제 (모달 표시)
|
||||
deleteNotebook(notebook) {
|
||||
this.deletingNotebook = notebook;
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
|
||||
// 삭제 확인
|
||||
async confirmDeleteNotebook() {
|
||||
if (!this.deletingNotebook) return;
|
||||
|
||||
this.deleting = true;
|
||||
try {
|
||||
await this.api.deleteNotebook(this.deletingNotebook.id, true); // force=true
|
||||
this.showNotification('노트북이 삭제되었습니다.', 'success');
|
||||
this.closeDeleteModal();
|
||||
await this.refreshNotebooks();
|
||||
} catch (error) {
|
||||
console.error('노트북 삭제 실패:', error);
|
||||
this.showNotification('노트북 삭제에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.deleting = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 삭제 모달 닫기
|
||||
closeDeleteModal() {
|
||||
this.showDeleteModal = false;
|
||||
this.deletingNotebook = null;
|
||||
this.deleting = false;
|
||||
},
|
||||
|
||||
// 노트북 저장
|
||||
async saveNotebook() {
|
||||
if (!this.notebookForm.title.trim()) {
|
||||
this.showNotification('제목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
if (this.showEditModal && this.editingNotebook) {
|
||||
// 편집
|
||||
await this.api.updateNotebook(this.editingNotebook.id, this.notebookForm);
|
||||
this.showNotification('노트북이 수정되었습니다.', 'success');
|
||||
} else {
|
||||
// 생성
|
||||
await this.api.createNotebook(this.notebookForm);
|
||||
this.showNotification('노트북이 생성되었습니다.', 'success');
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
await this.refreshNotebooks();
|
||||
} catch (error) {
|
||||
console.error('노트북 저장 실패:', error);
|
||||
this.showNotification('노트북 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 모달 닫기
|
||||
closeModal() {
|
||||
this.showCreateModal = false;
|
||||
this.showEditModal = false;
|
||||
this.editingNotebook = null;
|
||||
this.notebookForm = {
|
||||
title: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
};
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return '오늘';
|
||||
} else if (diffDays === 2) {
|
||||
return '어제';
|
||||
} else if (diffDays <= 7) {
|
||||
return `${diffDays - 1}일 전`;
|
||||
} else {
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
message: message,
|
||||
type: type
|
||||
};
|
||||
|
||||
// 3초 후 자동으로 숨김
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
404
frontend/static/js/notes.js
Normal file
404
frontend/static/js/notes.js
Normal file
@@ -0,0 +1,404 @@
|
||||
// 노트 관리 애플리케이션 컴포넌트
|
||||
window.notesApp = () => ({
|
||||
// 상태 관리
|
||||
notes: [],
|
||||
stats: null,
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// 필터링
|
||||
searchQuery: '',
|
||||
selectedType: '',
|
||||
publishedOnly: false,
|
||||
selectedNotebook: '',
|
||||
|
||||
// 노트북 관련
|
||||
availableNotebooks: [],
|
||||
|
||||
// 일괄 선택 관련
|
||||
selectedNotes: [],
|
||||
bulkNotebookId: '',
|
||||
|
||||
// 노트북 생성 관련
|
||||
showCreateNotebookModal: false,
|
||||
creatingNotebook: false,
|
||||
newNotebookForm: {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book'
|
||||
},
|
||||
|
||||
// 색상 및 아이콘 옵션
|
||||
availableColors: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6B7280'
|
||||
],
|
||||
availableIcons: [
|
||||
{ value: 'book', label: '📖 책' },
|
||||
{ value: 'sticky-note', label: '📝 노트' },
|
||||
{ value: 'lightbulb', label: '💡 아이디어' },
|
||||
{ value: 'graduation-cap', label: '🎓 학습' },
|
||||
{ value: 'briefcase', label: '💼 업무' },
|
||||
{ value: 'heart', label: '❤️ 개인' },
|
||||
{ value: 'code', label: '💻 개발' },
|
||||
{ value: 'palette', label: '🎨 창작' },
|
||||
{ value: 'flask', label: '🧪 연구' },
|
||||
{ value: 'star', label: '⭐ 즐겨찾기' }
|
||||
],
|
||||
|
||||
// 검색 디바운스
|
||||
searchTimeout: null,
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// API 클라이언트
|
||||
api: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 Notes App 초기화 시작');
|
||||
|
||||
// API 클라이언트 초기화
|
||||
this.api = new DocumentServerAPI();
|
||||
console.log('🔧 API 클라이언트 초기화됨:', this.api);
|
||||
console.log('🔧 getNotebooks 메서드 존재 여부:', typeof this.api.getNotebooks);
|
||||
|
||||
// URL 파라미터 확인 (노트북 필터)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const notebookId = urlParams.get('notebook_id');
|
||||
const notebookName = urlParams.get('notebook_name');
|
||||
|
||||
if (notebookId) {
|
||||
this.selectedNotebook = notebookId;
|
||||
console.log('🔍 노트북 필터 적용:', notebookName || notebookId);
|
||||
}
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadNotebooks();
|
||||
await this.loadStats();
|
||||
await this.loadNotes();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await this.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username || user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
// 로그인 페이지로 리다이렉트하지 않고 메인 페이지로
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
await window.headerLoader.loadHeader();
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 노트북 목록 로드
|
||||
async loadNotebooks() {
|
||||
try {
|
||||
console.log('📚 노트북 로드 시작...');
|
||||
console.log('🔧 API 메서드 확인:', typeof this.api.getNotebooks);
|
||||
|
||||
if (typeof this.api.getNotebooks !== 'function') {
|
||||
throw new Error('getNotebooks 메서드가 존재하지 않습니다');
|
||||
}
|
||||
|
||||
// 임시: 직접 API 호출
|
||||
this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true });
|
||||
console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개');
|
||||
} catch (error) {
|
||||
console.error('노트북 로드 실패:', error);
|
||||
this.availableNotebooks = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 통계 정보 로드
|
||||
async loadStats() {
|
||||
try {
|
||||
this.stats = await this.api.get('/note-documents/stats');
|
||||
console.log('📊 통계 로드됨:', this.stats);
|
||||
} catch (error) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 노트 목록 로드
|
||||
async loadNotes() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const queryParams = {};
|
||||
|
||||
if (this.searchQuery) {
|
||||
queryParams.search = this.searchQuery;
|
||||
}
|
||||
if (this.selectedType) {
|
||||
queryParams.note_type = this.selectedType;
|
||||
}
|
||||
if (this.publishedOnly) {
|
||||
queryParams.published_only = 'true';
|
||||
}
|
||||
if (this.selectedNotebook) {
|
||||
if (this.selectedNotebook === 'unassigned') {
|
||||
queryParams.notebook_id = 'null';
|
||||
} else {
|
||||
queryParams.notebook_id = this.selectedNotebook;
|
||||
}
|
||||
}
|
||||
|
||||
this.notes = await this.api.getNoteDocuments(queryParams);
|
||||
console.log('📝 노트 로드됨:', this.notes.length, '개');
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트 로드 실패:', error);
|
||||
this.error = '노트를 불러오는데 실패했습니다: ' + error.message;
|
||||
this.notes = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 검색 디바운스
|
||||
debounceSearch() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.loadNotes();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// 노트 새로고침
|
||||
async refreshNotes() {
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadNotes()
|
||||
]);
|
||||
},
|
||||
|
||||
// 새 노트 생성
|
||||
createNewNote() {
|
||||
window.location.href = '/note-editor.html';
|
||||
},
|
||||
|
||||
// 노트 보기 (뷰어 페이지로 이동)
|
||||
viewNote(noteId) {
|
||||
window.location.href = `/viewer.html?type=note&id=${noteId}`;
|
||||
},
|
||||
|
||||
// 노트 편집
|
||||
editNote(noteId) {
|
||||
window.location.href = `/note-editor.html?id=${noteId}`;
|
||||
},
|
||||
|
||||
// 노트 삭제
|
||||
async deleteNote(note) {
|
||||
if (!confirm(`"${note.title}" 노트를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/note-documents/${note.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.showNotification('노트가 삭제되었습니다', 'success');
|
||||
await this.refreshNotes();
|
||||
} else {
|
||||
throw new Error('삭제 실패');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트 삭제 실패:', error);
|
||||
this.showNotification('노트 삭제에 실패했습니다: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 노트 타입 라벨
|
||||
getNoteTypeLabel(type) {
|
||||
const labels = {
|
||||
'note': '일반',
|
||||
'research': '연구',
|
||||
'summary': '요약',
|
||||
'idea': '아이디어',
|
||||
'guide': '가이드',
|
||||
'reference': '참고'
|
||||
};
|
||||
return labels[type] || type;
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return '오늘';
|
||||
} else if (diffDays === 2) {
|
||||
return '어제';
|
||||
} else if (diffDays <= 7) {
|
||||
return `${diffDays - 1}일 전`;
|
||||
} else {
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 표시
|
||||
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);
|
||||
},
|
||||
|
||||
// === 일괄 선택 관련 메서드 ===
|
||||
|
||||
// 노트 선택/해제
|
||||
toggleNoteSelection(noteId) {
|
||||
const index = this.selectedNotes.indexOf(noteId);
|
||||
if (index > -1) {
|
||||
this.selectedNotes.splice(index, 1);
|
||||
} else {
|
||||
this.selectedNotes.push(noteId);
|
||||
}
|
||||
},
|
||||
|
||||
// 선택 해제
|
||||
clearSelection() {
|
||||
this.selectedNotes = [];
|
||||
this.bulkNotebookId = '';
|
||||
},
|
||||
|
||||
// 선택된 노트들을 노트북에 할당
|
||||
async assignToNotebook() {
|
||||
if (!this.bulkNotebookId || this.selectedNotes.length === 0) {
|
||||
this.showNotification('노트북을 선택하고 노트를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 각 노트를 업데이트
|
||||
const updatePromises = this.selectedNotes.map(noteId =>
|
||||
this.api.put(`/note-documents/${noteId}`, { notebook_id: this.bulkNotebookId })
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
this.showNotification(`${this.selectedNotes.length}개 노트가 노트북에 할당되었습니다.`, 'success');
|
||||
|
||||
// 선택 해제 및 새로고침
|
||||
this.clearSelection();
|
||||
await this.loadNotes();
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트북 할당 실패:', error);
|
||||
this.showNotification('노트북 할당에 실패했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// === 노트북 생성 관련 메서드 ===
|
||||
|
||||
// 노트북 생성 모달 닫기
|
||||
closeCreateNotebookModal() {
|
||||
this.showCreateNotebookModal = false;
|
||||
this.newNotebookForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book'
|
||||
};
|
||||
},
|
||||
|
||||
// 노트북 생성 및 노트 할당
|
||||
async createNotebookAndAssign() {
|
||||
if (!this.newNotebookForm.name.trim()) {
|
||||
this.showNotification('노트북 이름을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingNotebook = true;
|
||||
|
||||
try {
|
||||
// 1. 노트북 생성
|
||||
const newNotebook = await this.api.post('/notebooks/', this.newNotebookForm);
|
||||
console.log('📚 새 노트북 생성됨:', newNotebook.name);
|
||||
|
||||
// 2. 선택된 노트들이 있으면 할당
|
||||
if (this.selectedNotes.length > 0) {
|
||||
const updatePromises = this.selectedNotes.map(noteId =>
|
||||
this.api.put(`/note-documents/${noteId}`, { notebook_id: newNotebook.id })
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
console.log(`📝 ${this.selectedNotes.length}개 노트가 새 노트북에 할당됨`);
|
||||
}
|
||||
|
||||
this.showNotification(
|
||||
`노트북 "${newNotebook.name}"이 생성되었습니다.${this.selectedNotes.length > 0 ? ` ${this.selectedNotes.length}개 노트가 할당되었습니다.` : ''}`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// 3. 정리 및 새로고침
|
||||
this.closeCreateNotebookModal();
|
||||
this.clearSelection();
|
||||
await this.loadNotebooks();
|
||||
await this.loadNotes();
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트북 생성 실패:', error);
|
||||
this.showNotification('노트북 생성에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.creatingNotebook = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 Notes 페이지 로드됨');
|
||||
});
|
||||
311
frontend/static/js/pdf-manager.js
Normal file
311
frontend/static/js/pdf-manager.js
Normal file
@@ -0,0 +1,311 @@
|
||||
// PDF 관리 애플리케이션 컴포넌트
|
||||
window.pdfManagerApp = () => ({
|
||||
// 상태 관리
|
||||
pdfDocuments: [],
|
||||
allDocuments: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
filterType: 'all', // 'all', 'book', 'linked', 'standalone'
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// PDF 미리보기 상태
|
||||
showPreviewModal: false,
|
||||
previewPdf: null,
|
||||
pdfPreviewSrc: '',
|
||||
pdfPreviewLoading: false,
|
||||
pdfPreviewError: false,
|
||||
pdfPreviewLoaded: false,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 PDF Manager App 초기화 시작');
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadPDFs();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
// PDF 파일들 로드
|
||||
async loadPDFs() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
// 모든 문서 가져오기
|
||||
this.allDocuments = await window.api.getDocuments();
|
||||
|
||||
// PDF 파일들만 필터링
|
||||
this.pdfDocuments = this.allDocuments.filter(doc =>
|
||||
(doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) ||
|
||||
(doc.pdf_path && doc.pdf_path !== '') ||
|
||||
(doc.html_path === null && doc.pdf_path) // PDF만 업로드된 경우
|
||||
);
|
||||
|
||||
// 연결 상태 및 서적 정보 확인
|
||||
this.pdfDocuments.forEach(pdf => {
|
||||
// 이 PDF를 참조하는 다른 문서가 있는지 확인
|
||||
const linkedDocuments = this.allDocuments.filter(doc =>
|
||||
doc.matched_pdf_id === pdf.id
|
||||
);
|
||||
pdf.isLinked = linkedDocuments.length > 0;
|
||||
pdf.linkedDocuments = linkedDocuments;
|
||||
|
||||
// 서적 정보 추가 (PDF가 속한 서적 또는 연결된 문서의 서적)
|
||||
if (pdf.book_title) {
|
||||
// PDF 자체가 서적에 속한 경우
|
||||
pdf.book_title = pdf.book_title;
|
||||
} else if (linkedDocuments.length > 0) {
|
||||
// 연결된 문서가 있는 경우, 첫 번째 연결 문서의 서적 정보 사용
|
||||
const firstLinked = linkedDocuments[0];
|
||||
pdf.book_title = firstLinked.book_title;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📕 PDF 문서들:', this.pdfDocuments.length, '개');
|
||||
console.log('📚 서적 포함 PDF:', this.bookPDFs, '개');
|
||||
console.log('🔗 HTML 연결 PDF:', this.linkedPDFs, '개');
|
||||
console.log('📄 독립 PDF:', this.standalonePDFs, '개');
|
||||
|
||||
// 디버깅: PDF 서적 정보 확인
|
||||
this.pdfDocuments.slice(0, 5).forEach(pdf => {
|
||||
console.log(`📋 ${pdf.title}: 서적=${pdf.book_title || '없음'}, 연결=${pdf.isLinked ? '예' : '아니오'}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF 로드 실패:', error);
|
||||
this.error = 'PDF 파일을 불러오는데 실패했습니다: ' + error.message;
|
||||
this.pdfDocuments = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 필터링된 PDF 목록
|
||||
get filteredPDFs() {
|
||||
switch (this.filterType) {
|
||||
case 'book':
|
||||
return this.pdfDocuments.filter(pdf => pdf.book_title);
|
||||
case 'linked':
|
||||
return this.pdfDocuments.filter(pdf => pdf.isLinked);
|
||||
case 'standalone':
|
||||
return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title);
|
||||
default:
|
||||
return this.pdfDocuments;
|
||||
}
|
||||
},
|
||||
|
||||
// 통계 계산
|
||||
get bookPDFs() {
|
||||
return this.pdfDocuments.filter(pdf => pdf.book_title).length;
|
||||
},
|
||||
|
||||
get linkedPDFs() {
|
||||
return this.pdfDocuments.filter(pdf => pdf.isLinked).length;
|
||||
},
|
||||
|
||||
get standalonePDFs() {
|
||||
return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title).length;
|
||||
},
|
||||
|
||||
// PDF 새로고침
|
||||
async refreshPDFs() {
|
||||
await this.loadPDFs();
|
||||
},
|
||||
|
||||
// PDF 다운로드
|
||||
async downloadPDF(pdf) {
|
||||
try {
|
||||
console.log('📕 PDF 다운로드 시작:', pdf.id);
|
||||
|
||||
// PDF 파일 다운로드 URL 생성
|
||||
const downloadUrl = `/api/documents/${pdf.id}/download`;
|
||||
|
||||
// 인증 헤더 추가를 위해 fetch 사용
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('PDF 다운로드에 실패했습니다');
|
||||
}
|
||||
|
||||
// Blob으로 변환하여 다운로드
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// 다운로드 링크 생성 및 클릭
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = pdf.original_filename || `${pdf.title}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// URL 정리
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ PDF 다운로드 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ PDF 다운로드 실패:', error);
|
||||
alert('PDF 다운로드에 실패했습니다: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// PDF 삭제
|
||||
async deletePDF(pdf) {
|
||||
// 연결된 문서가 있는지 확인
|
||||
if (pdf.isLinked && pdf.linkedDocuments.length > 0) {
|
||||
const linkedTitles = pdf.linkedDocuments.map(doc => doc.title).join('\n- ');
|
||||
const confirmMessage = `이 PDF는 다음 문서들과 연결되어 있습니다:\n\n- ${linkedTitles}\n\n정말 삭제하시겠습니까? 연결된 문서들의 PDF 링크가 해제됩니다.`;
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!confirm(`"${pdf.title}" PDF 파일을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.deleteDocument(pdf.id);
|
||||
|
||||
// 목록에서 제거
|
||||
this.pdfDocuments = this.pdfDocuments.filter(p => p.id !== pdf.id);
|
||||
|
||||
this.showNotification('PDF 파일이 삭제되었습니다', 'success');
|
||||
|
||||
// 목록 새로고침 (연결 상태 업데이트를 위해)
|
||||
await this.loadPDFs();
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF 삭제 실패:', error);
|
||||
this.showNotification('PDF 삭제에 실패했습니다: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== PDF 미리보기 관련 ====================
|
||||
async previewPDF(pdf) {
|
||||
console.log('👁️ PDF 미리보기:', pdf.title);
|
||||
|
||||
this.previewPdf = pdf;
|
||||
this.showPreviewModal = true;
|
||||
this.pdfPreviewLoading = true;
|
||||
this.pdfPreviewError = false;
|
||||
this.pdfPreviewLoaded = false;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token || token === 'null' || token === null) {
|
||||
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
|
||||
// PDF 미리보기 URL 설정
|
||||
this.pdfPreviewSrc = `/api/documents/${pdf.id}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
console.log('✅ PDF 미리보기 준비 완료:', this.pdfPreviewSrc);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ PDF 미리보기 로드 실패:', error);
|
||||
this.pdfPreviewError = true;
|
||||
this.showNotification('PDF 미리보기 로드에 실패했습니다: ' + error.message, 'error');
|
||||
} finally {
|
||||
this.pdfPreviewLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
closePreview() {
|
||||
this.showPreviewModal = false;
|
||||
this.previewPdf = null;
|
||||
this.pdfPreviewSrc = '';
|
||||
this.pdfPreviewLoading = false;
|
||||
this.pdfPreviewError = false;
|
||||
this.pdfPreviewLoaded = false;
|
||||
},
|
||||
|
||||
handlePdfPreviewError() {
|
||||
console.error('❌ PDF 미리보기 iframe 로드 오류');
|
||||
this.pdfPreviewError = true;
|
||||
this.pdfPreviewLoading = false;
|
||||
},
|
||||
|
||||
async retryPdfPreview() {
|
||||
if (this.previewPdf) {
|
||||
await this.previewPDF(this.previewPdf);
|
||||
}
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
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') {
|
||||
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('📄 PDF Manager 페이지 로드됨');
|
||||
});
|
||||
692
frontend/static/js/search.js
Normal file
692
frontend/static/js/search.js
Normal file
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* 통합 검색 JavaScript
|
||||
*/
|
||||
|
||||
// 검색 애플리케이션 Alpine.js 컴포넌트
|
||||
window.searchApp = function() {
|
||||
return {
|
||||
// 상태 관리
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
filteredResults: [],
|
||||
loading: false,
|
||||
hasSearched: false,
|
||||
searchTime: 0,
|
||||
|
||||
// 필터링
|
||||
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
|
||||
fileTypeFilter: '', // '', 'PDF', 'HTML'
|
||||
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
|
||||
|
||||
// 검색 디바운스
|
||||
searchTimeout: null,
|
||||
|
||||
// 미리보기 모달
|
||||
showPreviewModal: false,
|
||||
previewResult: null,
|
||||
previewLoading: false,
|
||||
pdfError: false,
|
||||
pdfLoading: false,
|
||||
pdfLoaded: false,
|
||||
pdfSrc: '',
|
||||
|
||||
// HTML 뷰어 상태
|
||||
htmlLoading: false,
|
||||
htmlRawMode: false,
|
||||
htmlSourceCode: '',
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// API 클라이언트
|
||||
api: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🔍 검색 앱 초기화 시작');
|
||||
|
||||
try {
|
||||
// API 클라이언트 초기화
|
||||
this.api = new DocumentServerAPI();
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
// URL 파라미터에서 검색어 확인
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const query = urlParams.get('q');
|
||||
if (query) {
|
||||
this.searchQuery = query;
|
||||
await this.performSearch();
|
||||
}
|
||||
|
||||
console.log('✅ 검색 앱 초기화 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 검색 앱 초기화 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await this.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username || user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
// 검색은 로그인 없이도 가능하도록 허용
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
if (typeof loadHeaderComponent === 'function') {
|
||||
await loadHeaderComponent();
|
||||
} else if (typeof window.loadHeaderComponent === 'function') {
|
||||
await window.loadHeaderComponent();
|
||||
} else {
|
||||
console.warn('헤더 로더 함수를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 검색 디바운스
|
||||
debounceSearch() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
if (this.searchQuery.trim()) {
|
||||
this.performSearch();
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// 검색 수행
|
||||
async performSearch() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
this.searchResults = [];
|
||||
this.filteredResults = [];
|
||||
this.hasSearched = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
console.log('🔍 검색 시작:', this.searchQuery);
|
||||
|
||||
// 검색 API 호출
|
||||
const response = await this.api.search({
|
||||
q: this.searchQuery,
|
||||
type_filter: this.typeFilter || undefined,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
this.searchResults = response.results || [];
|
||||
this.hasSearched = true;
|
||||
this.searchTime = Date.now() - startTime;
|
||||
|
||||
// 필터 적용
|
||||
this.applyFilters();
|
||||
|
||||
// URL 업데이트
|
||||
this.updateURL();
|
||||
|
||||
console.log('✅ 검색 완료:', this.searchResults.length, '개 결과');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 검색 실패:', error);
|
||||
this.searchResults = [];
|
||||
this.filteredResults = [];
|
||||
this.hasSearched = true;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 필터 적용
|
||||
applyFilters() {
|
||||
let results = [...this.searchResults];
|
||||
|
||||
// 중복 ID 제거 (같은 문서의 document와 document_content가 중복될 수 있음)
|
||||
const uniqueResults = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
results.forEach(result => {
|
||||
const uniqueKey = `${result.type}-${result.id}`;
|
||||
if (!seenIds.has(uniqueKey)) {
|
||||
seenIds.add(uniqueKey);
|
||||
uniqueResults.push({
|
||||
...result,
|
||||
unique_id: uniqueKey // Alpine.js x-for 키로 사용
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
results = uniqueResults;
|
||||
|
||||
// 타입 필터
|
||||
if (this.typeFilter) {
|
||||
results = results.filter(result => {
|
||||
// 문서 타입은 document와 document_content 모두 포함
|
||||
if (this.typeFilter === 'document') {
|
||||
return result.type === 'document' || result.type === 'document_content';
|
||||
}
|
||||
// 하이라이트 타입은 highlight와 highlight_note 모두 포함
|
||||
if (this.typeFilter === 'highlight') {
|
||||
return result.type === 'highlight' || result.type === 'highlight_note';
|
||||
}
|
||||
return result.type === this.typeFilter;
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 타입 필터
|
||||
if (this.fileTypeFilter) {
|
||||
results = results.filter(result => {
|
||||
return result.highlight_info?.file_type === this.fileTypeFilter;
|
||||
});
|
||||
}
|
||||
|
||||
// 정렬
|
||||
results.sort((a, b) => {
|
||||
switch (this.sortBy) {
|
||||
case 'relevance':
|
||||
return (b.relevance_score || 0) - (a.relevance_score || 0);
|
||||
case 'date_desc':
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
case 'date_asc':
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
this.filteredResults = results;
|
||||
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과 (타입:', this.typeFilter, ', 파일타입:', this.fileTypeFilter, ')');
|
||||
},
|
||||
|
||||
// URL 업데이트
|
||||
updateURL() {
|
||||
const url = new URL(window.location);
|
||||
if (this.searchQuery.trim()) {
|
||||
url.searchParams.set('q', this.searchQuery);
|
||||
} else {
|
||||
url.searchParams.delete('q');
|
||||
}
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// 미리보기 표시
|
||||
async showPreview(result) {
|
||||
console.log('👁️ 미리보기 표시:', result);
|
||||
|
||||
this.previewResult = result;
|
||||
this.showPreviewModal = true;
|
||||
this.previewLoading = true;
|
||||
|
||||
try {
|
||||
// 문서 타입인 경우 상세 정보 먼저 로드
|
||||
if (result.type === 'document' || result.type === 'document_content') {
|
||||
try {
|
||||
const docInfo = await this.api.get(`/documents/${result.document_id}`);
|
||||
// PDF 정보 업데이트
|
||||
this.previewResult = {
|
||||
...result,
|
||||
highlight_info: {
|
||||
...result.highlight_info,
|
||||
has_pdf: !!docInfo.pdf_path,
|
||||
has_html: !!docInfo.html_path
|
||||
}
|
||||
};
|
||||
|
||||
// PDF가 있으면 PDF 미리보기, 없으면 HTML 미리보기
|
||||
if (docInfo.pdf_path) {
|
||||
// PDF 미리보기 준비
|
||||
await this.loadPdfPreview(result.document_id);
|
||||
} else if (docInfo.html_path) {
|
||||
// HTML 문서 미리보기
|
||||
await this.loadHtmlPreview(result.document_id);
|
||||
}
|
||||
} catch (docError) {
|
||||
console.error('문서 정보 로드 실패:', docError);
|
||||
// 기본 내용 로드로 fallback
|
||||
const fullContent = await this.loadFullContent(result);
|
||||
if (fullContent) {
|
||||
this.previewResult = { ...result, content: fullContent };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 기타 타입 - 전체 내용 로드
|
||||
const fullContent = await this.loadFullContent(result);
|
||||
if (fullContent) {
|
||||
this.previewResult = { ...result, content: fullContent };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('미리보기 로드 실패:', error);
|
||||
} finally {
|
||||
this.previewLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 전체 내용 로드
|
||||
async loadFullContent(result) {
|
||||
try {
|
||||
let content = '';
|
||||
|
||||
switch (result.type) {
|
||||
case 'document':
|
||||
case 'document_content':
|
||||
try {
|
||||
// 문서 내용 API 호출 (HTML 응답)
|
||||
const response = await fetch(`/api/documents/${result.document_id}/content`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const htmlContent = await response.text();
|
||||
// HTML에서 텍스트만 추출
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||
content = doc.body.textContent || doc.body.innerText || '';
|
||||
// 너무 길면 자르기
|
||||
if (content.length > 2000) {
|
||||
content = content.substring(0, 2000) + '...';
|
||||
}
|
||||
} else {
|
||||
content = result.content;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('문서 내용 로드 실패, 기본 내용 사용:', err);
|
||||
content = result.content;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'note':
|
||||
try {
|
||||
// 노트 내용 API 호출
|
||||
const noteContent = await this.api.get(`/note-documents/${result.id}/content`);
|
||||
content = noteContent;
|
||||
} catch (err) {
|
||||
console.warn('노트 내용 로드 실패, 기본 내용 사용:', err);
|
||||
content = result.content;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'memo':
|
||||
try {
|
||||
// 메모 노드 상세 정보 로드
|
||||
const memoNode = await this.api.get(`/memo-trees/nodes/${result.id}`);
|
||||
content = memoNode.content || result.content;
|
||||
} catch (err) {
|
||||
console.warn('메모 내용 로드 실패, 기본 내용 사용:', err);
|
||||
content = result.content;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
content = result.content;
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error('내용 로드 실패:', error);
|
||||
return result.content;
|
||||
}
|
||||
},
|
||||
|
||||
// 미리보기 닫기
|
||||
closePreview() {
|
||||
this.showPreviewModal = false;
|
||||
this.previewResult = null;
|
||||
this.previewLoading = false;
|
||||
this.pdfError = false;
|
||||
|
||||
// PDF 리소스 정리
|
||||
this.pdfLoading = false;
|
||||
this.pdfLoaded = false;
|
||||
this.pdfSrc = '';
|
||||
|
||||
// HTML 리소스 정리
|
||||
this.htmlLoading = false;
|
||||
this.htmlRawMode = false;
|
||||
this.htmlSourceCode = '';
|
||||
},
|
||||
|
||||
// PDF 미리보기 로드
|
||||
async loadPdfPreview(documentId) {
|
||||
this.pdfLoading = true;
|
||||
this.pdfError = false;
|
||||
this.pdfLoaded = false;
|
||||
|
||||
try {
|
||||
// PDF 파일 src 직접 설정 (HEAD 요청 대신)
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('🔍 토큰 디버깅:', {
|
||||
token: token,
|
||||
tokenType: typeof token,
|
||||
tokenLength: token ? token.length : 0,
|
||||
isNull: token === null,
|
||||
isStringNull: token === 'null',
|
||||
localStorage: Object.keys(localStorage)
|
||||
});
|
||||
|
||||
if (!token || token === 'null' || token === null) {
|
||||
console.error('❌ 토큰 문제:', token);
|
||||
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
console.log('✅ PDF 미리보기 준비 완료:', this.pdfSrc);
|
||||
} catch (error) {
|
||||
console.error('PDF 미리보기 로드 실패:', error);
|
||||
this.pdfError = true;
|
||||
} finally {
|
||||
this.pdfLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// PDF 에러 처리
|
||||
handlePdfError() {
|
||||
console.error('PDF iframe 로드 오류');
|
||||
this.pdfError = true;
|
||||
this.pdfLoading = false;
|
||||
},
|
||||
|
||||
// PDF에서 검색어 찾기 (브라우저 내장 검색 활용)
|
||||
searchInPdf() {
|
||||
if (this.searchQuery && this.pdfLoaded) {
|
||||
// iframe 내에서 검색 실행 (Ctrl+F 시뮬레이션)
|
||||
const iframe = document.querySelector('#pdf-preview-iframe');
|
||||
if (iframe && iframe.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.focus();
|
||||
// 브라우저 검색 창 열기 시도
|
||||
if (iframe.contentWindow.find) {
|
||||
iframe.contentWindow.find(this.searchQuery);
|
||||
} else {
|
||||
// 대안: 사용자에게 수동 검색 안내
|
||||
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
// 보안상 직접 접근이 안 되는 경우, 사용자에게 안내
|
||||
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 표시 (간단한 토스트)
|
||||
showNotification(message, type = 'info') {
|
||||
// 간단한 알림 구현 (실제로는 더 정교한 토스트 시스템을 사용할 수 있음)
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
|
||||
type === 'info' ? 'bg-blue-500' :
|
||||
type === 'success' ? 'bg-green-500' :
|
||||
type === 'error' ? 'bg-red-500' : 'bg-gray-500'
|
||||
}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 3초 후 자동 제거
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
// HTML 미리보기 로드
|
||||
async loadHtmlPreview(documentId) {
|
||||
this.htmlLoading = true;
|
||||
|
||||
try {
|
||||
// API를 통해 HTML 내용 가져오기
|
||||
const htmlContent = await this.api.get(`/documents/${documentId}/content`);
|
||||
|
||||
if (htmlContent) {
|
||||
this.htmlSourceCode = this.escapeHtml(htmlContent);
|
||||
|
||||
// iframe에 HTML 로드
|
||||
const iframe = document.getElementById('htmlPreviewFrame');
|
||||
if (iframe) {
|
||||
// iframe src를 직접 설정 (인증 헤더 포함)
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('🔍 HTML 미리보기 토큰:', token ? '있음' : '없음', token);
|
||||
if (!token || token === 'null' || token === null) {
|
||||
console.error('❌ HTML 미리보기 토큰 문제:', token);
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
iframe.src = `/api/documents/${documentId}/content?_token=${encodeURIComponent(token)}`;
|
||||
|
||||
// iframe 로드 완료 후 검색어 하이라이트
|
||||
iframe.onload = () => {
|
||||
if (this.searchQuery) {
|
||||
setTimeout(() => {
|
||||
this.highlightInIframe(iframe, this.searchQuery);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error('HTML 내용이 비어있습니다');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('HTML 미리보기 로드 실패:', error);
|
||||
// 에러 시 기본 내용 표시
|
||||
this.htmlSourceCode = `<div class="p-4 text-center text-gray-500">
|
||||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||||
<p>HTML 내용을 로드할 수 없습니다.</p>
|
||||
<p class="text-sm">${error.message}</p>
|
||||
</div>`;
|
||||
} finally {
|
||||
this.htmlLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// HTML 소스/렌더링 모드 토글
|
||||
toggleHtmlRaw() {
|
||||
this.htmlRawMode = !this.htmlRawMode;
|
||||
},
|
||||
|
||||
// iframe 내부 검색어 하이라이트
|
||||
highlightInIframe(iframe, query) {
|
||||
try {
|
||||
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
const walker = doc.createTreeWalker(
|
||||
doc.body,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
const textNodes = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
if (node.textContent.toLowerCase().includes(query.toLowerCase())) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
textNodes.forEach(textNode => {
|
||||
const parent = textNode.parentNode;
|
||||
const text = textNode.textContent;
|
||||
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
|
||||
const highlightedHTML = text.replace(regex, '<mark style="background: yellow; color: black;">$1</mark>');
|
||||
|
||||
const wrapper = doc.createElement('span');
|
||||
wrapper.innerHTML = highlightedHTML;
|
||||
parent.replaceChild(wrapper, textNode);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('iframe 하이라이트 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// HTML 이스케이프
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
// 노트 편집기에서 열기
|
||||
toggleNoteEdit() {
|
||||
if (this.previewResult && this.previewResult.type === 'note') {
|
||||
const url = `/note-editor.html?id=${this.previewResult.id}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
// PDF에서 검색
|
||||
async searchInPdf() {
|
||||
if (!this.previewResult || !this.searchQuery) return;
|
||||
|
||||
try {
|
||||
const searchResults = await this.api.get(
|
||||
`/documents/${this.previewResult.document_id}/search-in-content?q=${encodeURIComponent(this.searchQuery)}`
|
||||
);
|
||||
|
||||
if (searchResults.total_matches > 0) {
|
||||
// 첫 번째 매치로 이동하여 뷰어에서 열기
|
||||
const firstMatch = searchResults.matches[0];
|
||||
let url = `/viewer.html?id=${this.previewResult.document_id}`;
|
||||
|
||||
if (firstMatch.page > 1) {
|
||||
url += `&page=${firstMatch.page}`;
|
||||
}
|
||||
|
||||
// 검색어 하이라이트를 위한 파라미터 추가
|
||||
url += `&search=${encodeURIComponent(this.searchQuery)}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
this.closePreview();
|
||||
} else {
|
||||
alert('PDF에서 검색 결과를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PDF 검색 실패:', error);
|
||||
alert('PDF 검색 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 검색 결과 열기
|
||||
openResult(result) {
|
||||
console.log('📂 검색 결과 열기:', result);
|
||||
|
||||
let url = '';
|
||||
|
||||
switch (result.type) {
|
||||
case 'document':
|
||||
case 'document_content':
|
||||
url = `/viewer.html?id=${result.document_id}`;
|
||||
if (result.highlight_info) {
|
||||
// 하이라이트 위치로 이동
|
||||
const { start_offset, end_offset, selected_text } = result.highlight_info;
|
||||
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'note':
|
||||
url = `/viewer.html?id=${result.id}&contentType=note`;
|
||||
break;
|
||||
|
||||
case 'memo':
|
||||
// 메모 트리에서 해당 노드로 이동
|
||||
url = `/memo-tree.html?node_id=${result.id}`;
|
||||
break;
|
||||
|
||||
case 'highlight':
|
||||
case 'highlight_note':
|
||||
url = `/viewer.html?id=${result.document_id}`;
|
||||
if (result.highlight_info) {
|
||||
const { start_offset, end_offset, selected_text } = result.highlight_info;
|
||||
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('알 수 없는 결과 타입:', result.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// 새 탭에서 열기
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
// 타입별 결과 개수
|
||||
getResultCount(type) {
|
||||
return this.searchResults.filter(result => result.type === type).length;
|
||||
},
|
||||
|
||||
// 타입 라벨
|
||||
getTypeLabel(type) {
|
||||
const labels = {
|
||||
document: '문서',
|
||||
document_content: '본문',
|
||||
note: '노트',
|
||||
memo: '메모',
|
||||
highlight: '하이라이트',
|
||||
highlight_note: '메모'
|
||||
};
|
||||
return labels[type] || type;
|
||||
},
|
||||
|
||||
// 텍스트 하이라이트
|
||||
highlightText(text, query) {
|
||||
if (!text || !query) return text;
|
||||
|
||||
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
|
||||
return text.replace(regex, '<span class="highlight-text">$1</span>');
|
||||
},
|
||||
|
||||
// 정규식 이스케이프
|
||||
escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
},
|
||||
|
||||
// 텍스트 자르기
|
||||
truncateText(text, maxLength) {
|
||||
if (!text || text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return '오늘';
|
||||
} else if (diffDays === 2) {
|
||||
return '어제';
|
||||
} else if (diffDays <= 7) {
|
||||
return `${diffDays - 1}일 전`;
|
||||
} else if (diffDays <= 30) {
|
||||
return `${Math.ceil(diffDays / 7)}주 전`;
|
||||
} else if (diffDays <= 365) {
|
||||
return `${Math.ceil(diffDays / 30)}개월 전`;
|
||||
} else {
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
console.log('🔍 검색 JavaScript 로드 완료');
|
||||
333
frontend/static/js/story-reader.js
Normal file
333
frontend/static/js/story-reader.js
Normal file
@@ -0,0 +1,333 @@
|
||||
// 스토리 읽기 애플리케이션
|
||||
function storyReaderApp() {
|
||||
return {
|
||||
// 상태 변수들
|
||||
currentUser: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// 스토리 데이터
|
||||
selectedTree: null,
|
||||
canonicalNodes: [],
|
||||
currentChapter: null,
|
||||
currentChapterIndex: 0,
|
||||
|
||||
// URL 파라미터
|
||||
treeId: null,
|
||||
nodeId: null,
|
||||
chapterIndex: null,
|
||||
|
||||
// 편집 관련
|
||||
showEditModal: false,
|
||||
editingChapter: null,
|
||||
editEditor: null,
|
||||
saving: false,
|
||||
|
||||
// 로그인 관련
|
||||
showLoginModal: false,
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 스토리 리더 초기화 시작');
|
||||
|
||||
// URL 파라미터 파싱
|
||||
this.parseUrlParams();
|
||||
|
||||
// 사용자 인증 확인
|
||||
await this.checkAuth();
|
||||
|
||||
if (this.currentUser) {
|
||||
await this.loadStoryData();
|
||||
}
|
||||
},
|
||||
|
||||
// URL 파라미터 파싱
|
||||
parseUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.treeId = urlParams.get('treeId');
|
||||
this.nodeId = urlParams.get('nodeId');
|
||||
this.chapterIndex = parseInt(urlParams.get('index')) || 0;
|
||||
|
||||
console.log('📖 URL 파라미터:', {
|
||||
treeId: this.treeId,
|
||||
nodeId: this.nodeId,
|
||||
chapterIndex: this.chapterIndex
|
||||
});
|
||||
},
|
||||
|
||||
// 인증 확인
|
||||
async checkAuth() {
|
||||
try {
|
||||
this.currentUser = await window.api.getCurrentUser();
|
||||
console.log('✅ 사용자 인증됨:', this.currentUser?.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증 실패:', error);
|
||||
this.currentUser = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 데이터 로드
|
||||
async loadStoryData() {
|
||||
if (!this.treeId) {
|
||||
this.error = '트리 ID가 필요합니다';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// 트리 정보 로드
|
||||
this.selectedTree = await window.api.getMemoTree(this.treeId);
|
||||
console.log('📚 트리 로드됨:', this.selectedTree.title);
|
||||
|
||||
// 트리의 모든 노드 로드
|
||||
const allNodes = await window.api.getMemoTreeNodes(this.treeId);
|
||||
|
||||
// 정사 노드만 필터링하고 순서대로 정렬
|
||||
this.canonicalNodes = allNodes
|
||||
.filter(node => node.is_canonical)
|
||||
.sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0));
|
||||
|
||||
console.log('📝 정사 노드 수:', this.canonicalNodes.length);
|
||||
|
||||
if (this.canonicalNodes.length === 0) {
|
||||
this.error = '정사로 설정된 노드가 없습니다';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 챕터 설정
|
||||
this.setCurrentChapter();
|
||||
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 로드 실패:', error);
|
||||
this.error = '스토리를 불러오는데 실패했습니다';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 현재 챕터 설정
|
||||
setCurrentChapter() {
|
||||
if (this.nodeId) {
|
||||
// 특정 노드 ID로 찾기
|
||||
const index = this.canonicalNodes.findIndex(node => node.id === this.nodeId);
|
||||
if (index !== -1) {
|
||||
this.currentChapterIndex = index;
|
||||
}
|
||||
} else if (this.chapterIndex >= 0 && this.chapterIndex < this.canonicalNodes.length) {
|
||||
// 인덱스로 설정
|
||||
this.currentChapterIndex = this.chapterIndex;
|
||||
} else {
|
||||
// 기본값: 첫 번째 챕터
|
||||
this.currentChapterIndex = 0;
|
||||
}
|
||||
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
console.log('📖 현재 챕터:', this.currentChapter?.title, `(${this.currentChapterIndex + 1}/${this.canonicalNodes.length})`);
|
||||
},
|
||||
|
||||
// 계산된 속성들
|
||||
get totalChapters() {
|
||||
return this.canonicalNodes.length;
|
||||
},
|
||||
|
||||
get hasPreviousChapter() {
|
||||
return this.currentChapterIndex > 0;
|
||||
},
|
||||
|
||||
get hasNextChapter() {
|
||||
return this.currentChapterIndex < this.canonicalNodes.length - 1;
|
||||
},
|
||||
|
||||
get previousChapter() {
|
||||
return this.hasPreviousChapter ? this.canonicalNodes[this.currentChapterIndex - 1] : null;
|
||||
},
|
||||
|
||||
get nextChapter() {
|
||||
return this.hasNextChapter ? this.canonicalNodes[this.currentChapterIndex + 1] : null;
|
||||
},
|
||||
|
||||
// 네비게이션 함수들
|
||||
goToPreviousChapter() {
|
||||
if (this.hasPreviousChapter) {
|
||||
this.currentChapterIndex--;
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
this.updateUrl();
|
||||
this.scrollToTop();
|
||||
}
|
||||
},
|
||||
|
||||
goToNextChapter() {
|
||||
if (this.hasNextChapter) {
|
||||
this.currentChapterIndex++;
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
this.updateUrl();
|
||||
this.scrollToTop();
|
||||
}
|
||||
},
|
||||
|
||||
goBackToStoryView() {
|
||||
window.location.href = `story-view.html?treeId=${this.treeId}`;
|
||||
},
|
||||
|
||||
editChapter() {
|
||||
if (this.currentChapter) {
|
||||
this.editingChapter = { ...this.currentChapter }; // 복사본 생성
|
||||
this.showEditModal = true;
|
||||
console.log('✅ 편집 모달 열림 (Textarea 방식)');
|
||||
}
|
||||
},
|
||||
|
||||
// 편집 취소
|
||||
cancelEdit() {
|
||||
this.showEditModal = false;
|
||||
this.editingChapter = null;
|
||||
console.log('✅ 편집 취소됨 (Textarea 방식)');
|
||||
},
|
||||
|
||||
// 편집 저장
|
||||
async saveEdit() {
|
||||
if (!this.editingChapter) {
|
||||
console.warn('⚠️ 편집 중인 챕터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.saving = true;
|
||||
|
||||
// 단어 수 계산
|
||||
const content = this.editingChapter.content || '';
|
||||
let wordCount = 0;
|
||||
if (content && content.trim()) {
|
||||
const words = content.trim().split(/\s+/);
|
||||
wordCount = words.filter(word => word.length > 0).length;
|
||||
}
|
||||
this.editingChapter.word_count = wordCount;
|
||||
|
||||
// API로 저장
|
||||
const updateData = {
|
||||
title: this.editingChapter.title,
|
||||
content: this.editingChapter.content,
|
||||
word_count: this.editingChapter.word_count
|
||||
};
|
||||
|
||||
await window.api.updateMemoNode(this.editingChapter.id, updateData);
|
||||
|
||||
// 현재 챕터 업데이트
|
||||
this.currentChapter.title = this.editingChapter.title;
|
||||
this.currentChapter.content = this.editingChapter.content;
|
||||
this.currentChapter.word_count = this.editingChapter.word_count;
|
||||
|
||||
// 정사 노드 목록에서도 업데이트
|
||||
const nodeIndex = this.canonicalNodes.findIndex(node => node.id === this.editingChapter.id);
|
||||
if (nodeIndex !== -1) {
|
||||
this.canonicalNodes[nodeIndex].title = this.editingChapter.title;
|
||||
this.canonicalNodes[nodeIndex].content = this.editingChapter.content;
|
||||
this.canonicalNodes[nodeIndex].word_count = this.editingChapter.word_count;
|
||||
}
|
||||
|
||||
console.log('✅ 챕터 저장 완료');
|
||||
this.cancelEdit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// URL 업데이트
|
||||
updateUrl() {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('nodeId', this.currentChapter.id);
|
||||
url.searchParams.set('index', this.currentChapterIndex.toString());
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// 페이지 상단으로 스크롤
|
||||
scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// 인쇄
|
||||
printChapter() {
|
||||
window.print();
|
||||
},
|
||||
|
||||
// 콘텐츠 포맷팅
|
||||
formatContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// 마크다운 스타일 간단 변환
|
||||
return content
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^/, '<p>')
|
||||
.replace(/$/, '</p>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
},
|
||||
|
||||
// 노드 타입 라벨
|
||||
getNodeTypeLabel(nodeType) {
|
||||
const labels = {
|
||||
'memo': '메모',
|
||||
'folder': '폴더',
|
||||
'chapter': '챕터',
|
||||
'character': '캐릭터',
|
||||
'plot': '플롯'
|
||||
};
|
||||
return labels[nodeType] || nodeType;
|
||||
},
|
||||
|
||||
// 상태 라벨
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': '초안',
|
||||
'writing': '작성중',
|
||||
'review': '검토중',
|
||||
'complete': '완료'
|
||||
};
|
||||
return labels[status] || status;
|
||||
},
|
||||
|
||||
// 로그인 관련
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
this.loginError = '';
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
try {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
const result = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (result.access_token) {
|
||||
this.currentUser = result.user;
|
||||
this.showLoginModal = false;
|
||||
this.loginForm = { email: '', password: '' };
|
||||
|
||||
// 로그인 후 스토리 데이터 로드
|
||||
await this.loadStoryData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 실패:', error);
|
||||
this.loginError = '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
371
frontend/static/js/story-view.js
Normal file
371
frontend/static/js/story-view.js
Normal file
@@ -0,0 +1,371 @@
|
||||
// story-view.js - 정사 경로 스토리 뷰 컴포넌트
|
||||
|
||||
console.log('📖 스토리 뷰 JavaScript 로드 완료');
|
||||
|
||||
// Alpine.js 컴포넌트
|
||||
window.storyViewApp = function() {
|
||||
return {
|
||||
// 사용자 상태
|
||||
currentUser: null,
|
||||
|
||||
// 트리 데이터
|
||||
userTrees: [],
|
||||
selectedTreeId: '',
|
||||
selectedTree: null,
|
||||
canonicalNodes: [], // 정사 경로 노드들만
|
||||
|
||||
// UI 상태
|
||||
showLoginModal: false,
|
||||
showEditModal: false,
|
||||
editingNode: {
|
||||
title: '',
|
||||
content: ''
|
||||
},
|
||||
|
||||
// 로그인 폼 상태
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
|
||||
// 계산된 속성들
|
||||
get totalWords() {
|
||||
return this.canonicalNodes.reduce((sum, node) => sum + (node.word_count || 0), 0);
|
||||
},
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('📖 스토리 뷰 초기화 중...');
|
||||
|
||||
// API 로드 대기
|
||||
let retryCount = 0;
|
||||
while (!window.api && retryCount < 50) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (!window.api) {
|
||||
console.error('❌ API가 로드되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
// 인증된 경우 트리 목록 로드
|
||||
if (this.currentUser) {
|
||||
await this.loadUserTrees();
|
||||
|
||||
// URL 파라미터에서 treeId 확인하고 자동 선택
|
||||
this.checkUrlParams();
|
||||
}
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.currentUser = user;
|
||||
console.log('✅ 사용자 인증됨:', user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음:', error.message);
|
||||
this.currentUser = null;
|
||||
|
||||
// 만료된 토큰 정리
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
},
|
||||
|
||||
// 사용자 트리 목록 로드
|
||||
async loadUserTrees() {
|
||||
try {
|
||||
console.log('📊 사용자 트리 목록 로딩...');
|
||||
console.log('🔍 API 객체 확인:', window.api);
|
||||
console.log('🔍 getUserMemoTrees 함수 확인:', typeof window.api?.getUserMemoTrees);
|
||||
|
||||
const trees = await window.api.getUserMemoTrees();
|
||||
this.userTrees = trees || [];
|
||||
console.log(`✅ ${this.userTrees.length}개 트리 로드 완료`);
|
||||
console.log('📋 트리 목록:', this.userTrees);
|
||||
|
||||
// Alpine.js 반응성 업데이트를 위한 약간의 지연 후 URL 파라미터 확인
|
||||
setTimeout(() => {
|
||||
this.checkUrlParams();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('❌ 트리 목록 로드 실패:', error);
|
||||
console.error('❌ 에러 상세:', error.message);
|
||||
console.error('❌ 에러 스택:', error.stack);
|
||||
this.userTrees = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 로드 (정사 경로만)
|
||||
async loadStory(treeId) {
|
||||
if (!treeId) {
|
||||
this.selectedTree = null;
|
||||
this.canonicalNodes = [];
|
||||
// URL에서 treeId 파라미터 제거
|
||||
this.updateUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📖 스토리 로딩:', treeId);
|
||||
|
||||
// 트리 정보 로드
|
||||
this.selectedTree = await window.api.getMemoTree(treeId);
|
||||
|
||||
// 모든 노드 로드
|
||||
const allNodes = await window.api.getMemoTreeNodes(treeId);
|
||||
|
||||
// 정사 경로 노드들만 필터링하고 순서대로 정렬
|
||||
this.canonicalNodes = allNodes
|
||||
.filter(node => node.is_canonical)
|
||||
.sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0));
|
||||
|
||||
// URL 업데이트
|
||||
this.updateUrl(treeId);
|
||||
|
||||
console.log(`✅ 스토리 로드 완료: ${this.canonicalNodes.length}개 정사 노드`);
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 로드 실패:', error);
|
||||
alert('스토리를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// URL 파라미터 확인 및 처리
|
||||
checkUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const treeId = urlParams.get('treeId');
|
||||
|
||||
if (treeId && this.userTrees.some(tree => tree.id === treeId)) {
|
||||
console.log('📖 URL에서 트리 ID 감지:', treeId);
|
||||
this.selectedTreeId = treeId;
|
||||
this.loadStory(treeId);
|
||||
}
|
||||
},
|
||||
|
||||
// URL 업데이트
|
||||
updateUrl(treeId) {
|
||||
const url = new URL(window.location);
|
||||
if (treeId) {
|
||||
url.searchParams.set('treeId', treeId);
|
||||
} else {
|
||||
url.searchParams.delete('treeId');
|
||||
}
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// 스토리 리더로 이동
|
||||
openStoryReader(nodeId, index) {
|
||||
const url = `story-reader.html?treeId=${this.selectedTreeId}&nodeId=${nodeId}&index=${index}`;
|
||||
window.location.href = url;
|
||||
},
|
||||
|
||||
// 챕터 편집 (인라인 모달)
|
||||
editChapter(node) {
|
||||
this.editingNode = { ...node }; // 복사본 생성
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
// 편집 취소
|
||||
cancelEdit() {
|
||||
this.showEditModal = false;
|
||||
this.editingNode = null;
|
||||
},
|
||||
|
||||
// 편집 저장
|
||||
async saveEdit() {
|
||||
if (!this.editingNode) return;
|
||||
|
||||
try {
|
||||
console.log('💾 챕터 저장 중:', this.editingNode.title);
|
||||
|
||||
const updatedNode = await window.api.updateMemoNode(this.editingNode.id, {
|
||||
title: this.editingNode.title,
|
||||
content: this.editingNode.content
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
const nodeIndex = this.canonicalNodes.findIndex(n => n.id === this.editingNode.id);
|
||||
if (nodeIndex !== -1) {
|
||||
this.canonicalNodes = this.canonicalNodes.map(n =>
|
||||
n.id === this.editingNode.id ? { ...n, ...updatedNode } : n
|
||||
);
|
||||
}
|
||||
|
||||
this.showEditModal = false;
|
||||
this.editingNode = null;
|
||||
|
||||
console.log('✅ 챕터 저장 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 챕터 저장 실패:', error);
|
||||
alert('챕터 저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 내보내기
|
||||
async exportStory() {
|
||||
if (!this.selectedTree || this.canonicalNodes.length === 0) {
|
||||
alert('내보낼 스토리가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 텍스트 형태로 스토리 생성
|
||||
let storyText = `${this.selectedTree.title}\n`;
|
||||
storyText += `${'='.repeat(this.selectedTree.title.length)}\n\n`;
|
||||
|
||||
if (this.selectedTree.description) {
|
||||
storyText += `${this.selectedTree.description}\n\n`;
|
||||
}
|
||||
|
||||
storyText += `작성일: ${this.formatDate(this.selectedTree.created_at)}\n`;
|
||||
storyText += `수정일: ${this.formatDate(this.selectedTree.updated_at)}\n`;
|
||||
storyText += `총 ${this.canonicalNodes.length}개 챕터, ${this.totalWords}단어\n\n`;
|
||||
storyText += `${'='.repeat(50)}\n\n`;
|
||||
|
||||
this.canonicalNodes.forEach((node, index) => {
|
||||
storyText += `${index + 1}. ${node.title}\n`;
|
||||
storyText += `${'-'.repeat(node.title.length + 3)}\n\n`;
|
||||
|
||||
if (node.content) {
|
||||
// HTML 태그 제거
|
||||
const plainText = node.content.replace(/<[^>]*>/g, '');
|
||||
storyText += `${plainText}\n\n`;
|
||||
} else {
|
||||
storyText += `[이 챕터는 아직 내용이 없습니다]\n\n`;
|
||||
}
|
||||
|
||||
storyText += `${'─'.repeat(30)}\n\n`;
|
||||
});
|
||||
|
||||
// 파일 다운로드
|
||||
const blob = new Blob([storyText], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.selectedTree.title}_정사스토리.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 스토리 내보내기 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 내보내기 실패:', error);
|
||||
alert('스토리 내보내기 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 인쇄
|
||||
printStory() {
|
||||
if (!this.selectedTree || this.canonicalNodes.length === 0) {
|
||||
alert('인쇄할 스토리가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 전체 뷰로 변경 후 인쇄
|
||||
if (this.viewMode === 'toc') {
|
||||
this.viewMode = 'full';
|
||||
this.$nextTick(() => {
|
||||
window.print();
|
||||
});
|
||||
} else {
|
||||
window.print();
|
||||
}
|
||||
},
|
||||
|
||||
// 유틸리티 함수들
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
formatContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// 간단한 마크다운 스타일 변환
|
||||
return content
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^/, '<p>')
|
||||
.replace(/$/, '</p>');
|
||||
},
|
||||
|
||||
getNodeTypeLabel(nodeType) {
|
||||
const labels = {
|
||||
'folder': '📁 폴더',
|
||||
'memo': '📝 메모',
|
||||
'chapter': '📖 챕터',
|
||||
'character': '👤 인물',
|
||||
'plot': '📋 플롯'
|
||||
};
|
||||
return labels[nodeType] || '📝 메모';
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': '📝 초안',
|
||||
'writing': '✍️ 작성중',
|
||||
'review': '👀 검토중',
|
||||
'complete': '✅ 완료'
|
||||
};
|
||||
return labels[status] || '📝 초안';
|
||||
},
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
this.loginForm = { email: '', password: '' };
|
||||
this.loginError = '';
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (response.success) {
|
||||
this.currentUser = response.user;
|
||||
this.showLoginModal = false;
|
||||
|
||||
// 트리 목록 다시 로드
|
||||
await this.loadUserTrees();
|
||||
} else {
|
||||
this.loginError = response.message || '로그인에 실패했습니다.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 오류:', error);
|
||||
this.loginError = '로그인 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await window.api.logout();
|
||||
this.currentUser = null;
|
||||
this.userTrees = [];
|
||||
this.selectedTree = null;
|
||||
this.canonicalNodes = [];
|
||||
console.log('✅ 로그아웃 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 로그아웃 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
console.log('📖 스토리 뷰 컴포넌트 등록 완료');
|
||||
728
frontend/static/js/todos.js
Normal file
728
frontend/static/js/todos.js
Normal file
@@ -0,0 +1,728 @@
|
||||
/**
|
||||
* 할일관리 애플리케이션
|
||||
*/
|
||||
|
||||
console.log('📋 할일관리 JavaScript 로드 완료');
|
||||
|
||||
function todosApp() {
|
||||
return {
|
||||
// 상태 관리
|
||||
loading: false,
|
||||
activeTab: 'todo', // draft, todo, completed
|
||||
|
||||
// 할일 데이터
|
||||
todos: [],
|
||||
stats: {
|
||||
total_count: 0,
|
||||
draft_count: 0,
|
||||
scheduled_count: 0,
|
||||
active_count: 0,
|
||||
completed_count: 0,
|
||||
delayed_count: 0,
|
||||
completion_rate: 0
|
||||
},
|
||||
|
||||
// 입력 폼
|
||||
newTodoContent: '',
|
||||
|
||||
// 모달 상태
|
||||
showScheduleModal: false,
|
||||
showDelayModal: false,
|
||||
showCommentModal: false,
|
||||
showSplitModal: false,
|
||||
|
||||
// 현재 선택된 할일
|
||||
currentTodo: null,
|
||||
currentTodoComments: [],
|
||||
|
||||
// 메모 상태 (각 할일별)
|
||||
todoMemos: {},
|
||||
showMemoForTodo: {},
|
||||
|
||||
// 폼 데이터
|
||||
scheduleForm: {
|
||||
start_date: '',
|
||||
estimated_minutes: 30
|
||||
},
|
||||
delayForm: {
|
||||
delayed_until: ''
|
||||
},
|
||||
commentForm: {
|
||||
content: ''
|
||||
},
|
||||
splitForm: {
|
||||
subtasks: ['', ''],
|
||||
estimated_minutes_per_task: [30, 30]
|
||||
},
|
||||
|
||||
// 계산된 속성들
|
||||
get draftTodos() {
|
||||
return this.todos.filter(todo => todo.status === 'draft');
|
||||
},
|
||||
|
||||
get activeTodos() {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
return this.todos.filter(todo => {
|
||||
// active 상태이거나, scheduled인데 날짜가 오늘이거나 지난 경우
|
||||
if (todo.status === 'active') return true;
|
||||
if (todo.status === 'scheduled' && todo.start_date) {
|
||||
const startDate = new Date(todo.start_date);
|
||||
const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
return startDay <= today;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
|
||||
get scheduledTodos() {
|
||||
return this.todos.filter(todo => todo.status === 'scheduled');
|
||||
},
|
||||
|
||||
get completedTodos() {
|
||||
return this.todos.filter(todo => todo.status === 'completed');
|
||||
},
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('📋 할일관리 초기화 중...');
|
||||
|
||||
// API 로드 대기
|
||||
let retryCount = 0;
|
||||
while (!window.api && retryCount < 50) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (!window.api) {
|
||||
console.error('❌ API가 로드되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadTodos();
|
||||
await this.loadStats();
|
||||
await this.loadAllTodoMemos();
|
||||
|
||||
// 주기적으로 활성 할일 업데이트 (1분마다)
|
||||
setInterval(() => {
|
||||
this.loadActiveTodos();
|
||||
}, 60000);
|
||||
},
|
||||
|
||||
// 할일 목록 로드
|
||||
async loadTodos() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const response = await window.api.get('/todos/');
|
||||
this.todos = response || [];
|
||||
console.log(`✅ ${this.todos.length}개 할일 로드 완료`);
|
||||
} catch (error) {
|
||||
console.error('❌ 할일 목록 로드 실패:', error);
|
||||
alert('할일 목록을 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 활성 할일 로드 (시간 체크 포함)
|
||||
async loadActiveTodos() {
|
||||
try {
|
||||
const response = await window.api.get('/todos/active');
|
||||
const activeTodos = response || [];
|
||||
|
||||
// 기존 todos에서 active 상태 업데이트
|
||||
this.todos = this.todos.map(todo => {
|
||||
const activeVersion = activeTodos.find(active => active.id === todo.id);
|
||||
return activeVersion || todo;
|
||||
});
|
||||
|
||||
// 새로 활성화된 할일들 추가
|
||||
activeTodos.forEach(activeTodo => {
|
||||
if (!this.todos.find(todo => todo.id === activeTodo.id)) {
|
||||
this.todos.push(activeTodo);
|
||||
}
|
||||
});
|
||||
|
||||
await this.loadStats();
|
||||
} catch (error) {
|
||||
console.error('❌ 활성 할일 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 통계 로드
|
||||
async loadStats() {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
const stats = {
|
||||
total_count: this.todos.length,
|
||||
draft_count: this.todos.filter(t => t.status === 'draft').length,
|
||||
todo_count: this.todos.filter(t => {
|
||||
// active 상태이거나, scheduled인데 날짜가 오늘이거나 지난 경우
|
||||
if (t.status === 'active') return true;
|
||||
if (t.status === 'scheduled' && t.start_date) {
|
||||
const startDate = new Date(t.start_date);
|
||||
const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
return startDay <= today;
|
||||
}
|
||||
return false;
|
||||
}).length,
|
||||
completed_count: this.todos.filter(t => t.status === 'completed').length
|
||||
};
|
||||
|
||||
stats.completion_rate = stats.total_count > 0
|
||||
? Math.round((stats.completed_count / stats.total_count) * 100)
|
||||
: 0;
|
||||
|
||||
this.stats = stats;
|
||||
},
|
||||
|
||||
// 새 할일 생성
|
||||
async createTodo() {
|
||||
if (!this.newTodoContent.trim()) return;
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
const response = await window.api.post('/todos/', {
|
||||
content: this.newTodoContent.trim()
|
||||
});
|
||||
|
||||
this.todos.unshift(response);
|
||||
|
||||
// 새 할일의 메모 상태 초기화
|
||||
this.todoMemos[response.id] = [];
|
||||
this.showMemoForTodo[response.id] = false;
|
||||
|
||||
this.newTodoContent = '';
|
||||
await this.loadStats();
|
||||
|
||||
console.log('✅ 새 할일 생성 완료');
|
||||
|
||||
// 검토필요 탭으로 이동
|
||||
this.activeTab = 'draft';
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 할일 생성 실패:', error);
|
||||
alert('할일 생성 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 일정 설정 모달 열기
|
||||
openScheduleModal(todo) {
|
||||
this.currentTodo = todo;
|
||||
|
||||
// 기존 값이 있으면 사용, 없으면 기본값
|
||||
this.scheduleForm = {
|
||||
start_date: todo.start_date ?
|
||||
new Date(todo.start_date).toISOString().slice(0, 10) :
|
||||
this.formatDateLocal(new Date()),
|
||||
estimated_minutes: todo.estimated_minutes || 30
|
||||
};
|
||||
|
||||
this.showScheduleModal = true;
|
||||
},
|
||||
|
||||
// 일정 설정 모달 닫기
|
||||
closeScheduleModal() {
|
||||
this.showScheduleModal = false;
|
||||
this.currentTodo = null;
|
||||
},
|
||||
|
||||
// 할일 일정 설정
|
||||
async scheduleTodo() {
|
||||
if (!this.currentTodo || !this.scheduleForm.start_date) return;
|
||||
|
||||
try {
|
||||
// 선택한 날짜의 총 시간 체크
|
||||
const selectedDate = this.scheduleForm.start_date;
|
||||
const newMinutes = parseInt(this.scheduleForm.estimated_minutes);
|
||||
|
||||
// 해당 날짜의 기존 할일들 시간 합계
|
||||
const existingMinutes = this.todos
|
||||
.filter(todo => {
|
||||
if (!todo.start_date || todo.id === this.currentTodo.id) return false;
|
||||
const todoDate = new Date(todo.start_date).toISOString().slice(0, 10);
|
||||
return todoDate === selectedDate && (todo.status === 'scheduled' || todo.status === 'active');
|
||||
})
|
||||
.reduce((sum, todo) => sum + (todo.estimated_minutes || 0), 0);
|
||||
|
||||
const totalMinutes = existingMinutes + newMinutes;
|
||||
const totalHours = Math.round(totalMinutes / 60 * 10) / 10;
|
||||
|
||||
// 8시간 초과 시 경고
|
||||
if (totalMinutes > 480) { // 8시간 = 480분
|
||||
const choice = await this.showOverworkWarning(selectedDate, totalHours);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'change') {
|
||||
// 모달을 닫지 않고 사용자가 다시 선택할 수 있도록 함
|
||||
return;
|
||||
}
|
||||
// choice === 'continue'인 경우 계속 진행
|
||||
}
|
||||
|
||||
// 날짜만 사용하여 해당 날짜의 시작 시간으로 설정
|
||||
const startDate = new Date(this.scheduleForm.start_date + 'T00:00:00');
|
||||
|
||||
let response;
|
||||
|
||||
// 이미 일정이 설정된 할일인지 확인
|
||||
if (this.currentTodo.status === 'draft') {
|
||||
// 새로 일정 설정
|
||||
response = await window.api.post(`/todos/${this.currentTodo.id}/schedule`, {
|
||||
start_date: startDate.toISOString(),
|
||||
estimated_minutes: newMinutes
|
||||
});
|
||||
} else {
|
||||
// 기존 일정 지연 (active 상태의 할일)
|
||||
response = await window.api.put(`/todos/${this.currentTodo.id}/delay`, {
|
||||
delayed_until: startDate.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// 할일 업데이트
|
||||
const index = this.todos.findIndex(t => t.id === this.currentTodo.id);
|
||||
if (index !== -1) {
|
||||
this.todos[index] = response;
|
||||
}
|
||||
|
||||
await this.loadStats();
|
||||
this.closeScheduleModal();
|
||||
|
||||
console.log('✅ 할일 일정 설정 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 일정 설정 실패:', error);
|
||||
if (error.message.includes('split')) {
|
||||
alert('2시간 이상의 작업은 분할하는 것을 권장합니다.');
|
||||
} else {
|
||||
alert('일정 설정 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 할일 완료
|
||||
async completeTodo(todoId) {
|
||||
try {
|
||||
const response = await window.api.put(`/todos/${todoId}/complete`);
|
||||
|
||||
// 할일 업데이트
|
||||
const index = this.todos.findIndex(t => t.id === todoId);
|
||||
if (index !== -1) {
|
||||
this.todos[index] = response;
|
||||
}
|
||||
|
||||
await this.loadStats();
|
||||
console.log('✅ 할일 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 할일 완료 실패:', error);
|
||||
alert('할일 완료 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 지연 모달 열기 (일정 설정 모달 재사용) - 더 이상 사용하지 않음
|
||||
openDelayModal(todo) {
|
||||
// 일정변경과 동일하게 처리
|
||||
this.openScheduleModal(todo);
|
||||
},
|
||||
|
||||
|
||||
// 댓글 모달 열기
|
||||
async openCommentModal(todo) {
|
||||
this.currentTodo = todo;
|
||||
this.commentForm = { content: '' };
|
||||
|
||||
try {
|
||||
const response = await window.api.get(`/todos/${todo.id}/comments`);
|
||||
this.currentTodoComments = response || [];
|
||||
} catch (error) {
|
||||
console.error('❌ 댓글 로드 실패:', error);
|
||||
this.currentTodoComments = [];
|
||||
}
|
||||
|
||||
this.showCommentModal = true;
|
||||
},
|
||||
|
||||
// 댓글 모달 닫기
|
||||
closeCommentModal() {
|
||||
this.showCommentModal = false;
|
||||
this.currentTodo = null;
|
||||
this.currentTodoComments = [];
|
||||
},
|
||||
|
||||
// 댓글 추가
|
||||
async addComment() {
|
||||
if (!this.currentTodo || !this.commentForm.content.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await window.api.post(`/todos/${this.currentTodo.id}/comments`, {
|
||||
content: this.commentForm.content.trim()
|
||||
});
|
||||
|
||||
this.currentTodoComments.push(response);
|
||||
this.commentForm.content = '';
|
||||
|
||||
// 할일의 댓글 수 업데이트
|
||||
const index = this.todos.findIndex(t => t.id === this.currentTodo.id);
|
||||
if (index !== -1) {
|
||||
this.todos[index].comment_count = this.currentTodoComments.length;
|
||||
}
|
||||
|
||||
console.log('✅ 댓글 추가 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 댓글 추가 실패:', error);
|
||||
alert('댓글 추가 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 분할 모달 열기
|
||||
openSplitModal(todo) {
|
||||
this.currentTodo = todo;
|
||||
this.splitForm = {
|
||||
subtasks: ['', ''],
|
||||
estimated_minutes_per_task: [30, 30]
|
||||
};
|
||||
this.showSplitModal = true;
|
||||
},
|
||||
|
||||
// 분할 모달 닫기
|
||||
closeSplitModal() {
|
||||
this.showSplitModal = false;
|
||||
this.currentTodo = null;
|
||||
},
|
||||
|
||||
// 할일 분할
|
||||
async splitTodo() {
|
||||
if (!this.currentTodo) return;
|
||||
|
||||
const validSubtasks = this.splitForm.subtasks.filter(s => s.trim());
|
||||
const validMinutes = this.splitForm.estimated_minutes_per_task.slice(0, validSubtasks.length);
|
||||
|
||||
if (validSubtasks.length < 2) {
|
||||
alert('최소 2개의 하위 작업이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.api.post(`/todos/${this.currentTodo.id}/split`, {
|
||||
subtasks: validSubtasks,
|
||||
estimated_minutes_per_task: validMinutes
|
||||
});
|
||||
|
||||
// 원본 할일 제거하고 분할된 할일들 추가
|
||||
this.todos = this.todos.filter(t => t.id !== this.currentTodo.id);
|
||||
this.todos.unshift(...response);
|
||||
|
||||
await this.loadStats();
|
||||
this.closeSplitModal();
|
||||
|
||||
console.log('✅ 할일 분할 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 할일 분할 실패:', error);
|
||||
alert('할일 분할 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 댓글 토글
|
||||
async toggleComments(todoId) {
|
||||
const todo = this.todos.find(t => t.id === todoId);
|
||||
if (todo) {
|
||||
await this.openCommentModal(todo);
|
||||
}
|
||||
},
|
||||
|
||||
// 유틸리티 함수들
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return '방금 전';
|
||||
if (diffMins < 60) return `${diffMins}분 전`;
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
},
|
||||
|
||||
formatDateLocal(date) {
|
||||
const d = new Date(date);
|
||||
return d.toISOString().slice(0, 10);
|
||||
},
|
||||
|
||||
formatRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMinutes < 1) return '방금 전';
|
||||
if (diffMinutes < 60) return `${diffMinutes}분 전`;
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
},
|
||||
|
||||
// 햅틱 피드백 (모바일)
|
||||
hapticFeedback(element) {
|
||||
// 진동 API 지원 확인
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(50); // 50ms 진동
|
||||
}
|
||||
|
||||
// 시각적 피드백
|
||||
if (element) {
|
||||
element.classList.add('haptic-feedback');
|
||||
setTimeout(() => {
|
||||
element.classList.remove('haptic-feedback');
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
|
||||
// 풀 투 리프레시 (모바일)
|
||||
handlePullToRefresh() {
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
let pullDistance = 0;
|
||||
const threshold = 100;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (window.scrollY === 0) {
|
||||
startY = e.touches[0].clientY;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (startY > 0) {
|
||||
currentY = e.touches[0].clientY;
|
||||
pullDistance = currentY - startY;
|
||||
|
||||
if (pullDistance > 0 && pullDistance < threshold) {
|
||||
// 당기는 중 시각적 피드백
|
||||
const opacity = pullDistance / threshold;
|
||||
document.body.style.background = `linear-gradient(to bottom, rgba(99, 102, 241, ${opacity * 0.1}) 0%, #f9fafb 100%)`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', async () => {
|
||||
if (pullDistance > threshold) {
|
||||
// 새로고침 실행
|
||||
await this.loadTodos();
|
||||
await this.loadActiveTodos();
|
||||
|
||||
// 햅틱 피드백
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate([100, 50, 100]);
|
||||
}
|
||||
}
|
||||
|
||||
// 리셋
|
||||
startY = 0;
|
||||
pullDistance = 0;
|
||||
document.body.style.background = '';
|
||||
});
|
||||
},
|
||||
|
||||
// 모든 할일의 메모 로드 (초기화용)
|
||||
async loadAllTodoMemos() {
|
||||
try {
|
||||
console.log('📋 모든 할일 메모 로드 중...');
|
||||
|
||||
// 모든 할일에 대해 메모 로드
|
||||
const memoPromises = this.todos.map(async (todo) => {
|
||||
try {
|
||||
const response = await window.api.get(`/todos/${todo.id}/comments`);
|
||||
this.todoMemos[todo.id] = response || [];
|
||||
if (this.todoMemos[todo.id].length > 0) {
|
||||
console.log(`✅ ${todo.content.slice(0, 20)}... - ${this.todoMemos[todo.id].length}개 메모 로드`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ ${todo.id} 메모 로드 실패:`, error);
|
||||
this.todoMemos[todo.id] = [];
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(memoPromises);
|
||||
console.log('✅ 모든 할일 메모 로드 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 전체 메모 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 할일 메모 로드 (인라인용)
|
||||
async loadTodoMemos(todoId) {
|
||||
try {
|
||||
const response = await window.api.get(`/todos/${todoId}/comments`);
|
||||
this.todoMemos[todoId] = response || [];
|
||||
return this.todoMemos[todoId];
|
||||
} catch (error) {
|
||||
console.error('❌ 메모 로드 실패:', error);
|
||||
this.todoMemos[todoId] = [];
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 할일 메모 추가 (인라인용)
|
||||
async addTodoMemo(todoId, content) {
|
||||
try {
|
||||
const response = await window.api.post(`/todos/${todoId}/comments`, {
|
||||
content: content.trim()
|
||||
});
|
||||
|
||||
// 메모 목록 새로고침
|
||||
await this.loadTodoMemos(todoId);
|
||||
|
||||
console.log('✅ 메모 추가 완료');
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 메모 추가 실패:', error);
|
||||
alert('메모 추가 중 오류가 발생했습니다.');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 메모 토글
|
||||
toggleMemo(todoId) {
|
||||
this.showMemoForTodo[todoId] = !this.showMemoForTodo[todoId];
|
||||
|
||||
// 메모가 처음 열릴 때만 로드
|
||||
if (this.showMemoForTodo[todoId] && !this.todoMemos[todoId]) {
|
||||
this.loadTodoMemos(todoId);
|
||||
}
|
||||
},
|
||||
|
||||
// 특정 할일의 메모 개수 가져오기
|
||||
getTodoMemoCount(todoId) {
|
||||
return this.todoMemos[todoId] ? this.todoMemos[todoId].length : 0;
|
||||
},
|
||||
|
||||
// 특정 할일의 메모 목록 가져오기
|
||||
getTodoMemos(todoId) {
|
||||
return this.todoMemos[todoId] || [];
|
||||
},
|
||||
|
||||
// 8시간 초과 경고 모달 표시
|
||||
showOverworkWarning(selectedDate, totalHours) {
|
||||
return new Promise((resolve) => {
|
||||
// 기존 경고 모달이 있으면 제거
|
||||
const existingModal = document.getElementById('overwork-warning-modal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// 모달 HTML 생성
|
||||
const modalHTML = `
|
||||
<div id="overwork-warning-modal" class="fixed inset-0 bg-black bg-opacity-50 z-[60] flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<div class="p-6 border-b border-orange-200 bg-orange-50 rounded-t-2xl">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-2xl text-orange-600"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-lg font-bold text-orange-900">⚠️ 과로 경고</h3>
|
||||
<p class="text-sm text-orange-700 mt-1">하루 권장 작업시간을 초과했습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-700 mb-2">
|
||||
<strong>${selectedDate}</strong>의 총 작업시간이
|
||||
<strong class="text-red-600">${totalHours}시간</strong>이 됩니다.
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg">
|
||||
<i class="fas fa-info-circle mr-1 text-blue-500"></i>
|
||||
건강한 작업을 위해 하루 8시간 이내로 계획하는 것을 권장합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-3">
|
||||
<button
|
||||
onclick="resolveOverworkWarning('continue')"
|
||||
class="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors"
|
||||
>
|
||||
<i class="fas fa-check mr-2"></i>그냥 쓴다 (계속 진행)
|
||||
</button>
|
||||
<button
|
||||
onclick="resolveOverworkWarning('change')"
|
||||
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors"
|
||||
>
|
||||
<i class="fas fa-edit mr-2"></i>변경한다 (다시 설정)
|
||||
</button>
|
||||
<button
|
||||
onclick="resolveOverworkWarning('cancel')"
|
||||
class="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times mr-2"></i>취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 모달을 body에 추가
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
|
||||
// 전역 함수로 resolve 설정
|
||||
window.resolveOverworkWarning = (choice) => {
|
||||
const modal = document.getElementById('overwork-warning-modal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
delete window.resolveOverworkWarning;
|
||||
resolve(choice);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 모바일 감지 및 초기화
|
||||
function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform));
|
||||
}
|
||||
|
||||
// 모바일 최적화 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (isMobile()) {
|
||||
// 모바일 전용 스타일 추가
|
||||
document.body.classList.add('mobile-optimized');
|
||||
|
||||
// iOS Safari 주소창 숨김 대응
|
||||
const setVH = () => {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
};
|
||||
|
||||
setVH();
|
||||
window.addEventListener('resize', setVH);
|
||||
window.addEventListener('orientationchange', setVH);
|
||||
|
||||
// 터치 스크롤 개선
|
||||
document.body.style.webkitOverflowScrolling = 'touch';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
console.log('📋 할일관리 컴포넌트 등록 완료');
|
||||
636
frontend/static/js/upload.js
Normal file
636
frontend/static/js/upload.js
Normal file
@@ -0,0 +1,636 @@
|
||||
// 업로드 애플리케이션 컴포넌트
|
||||
window.uploadApp = () => ({
|
||||
// 상태 관리
|
||||
currentStep: 1,
|
||||
selectedFiles: [],
|
||||
uploadedDocuments: [],
|
||||
pdfFiles: [],
|
||||
|
||||
// 업로드 상태
|
||||
uploading: false,
|
||||
finalizing: false,
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// 서적 관련
|
||||
bookSelectionMode: 'none',
|
||||
bookSearchQuery: '',
|
||||
searchedBooks: [],
|
||||
selectedBook: null,
|
||||
newBook: {
|
||||
title: '',
|
||||
author: '',
|
||||
description: ''
|
||||
},
|
||||
|
||||
// Sortable 인스턴스
|
||||
sortableInstance: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 Upload App 초기화 시작');
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
}
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
// 드래그 오버 처리
|
||||
handleDragOver(event) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
event.target.closest('.drag-area').classList.add('drag-over');
|
||||
},
|
||||
|
||||
// 드래그 리브 처리
|
||||
handleDragLeave(event) {
|
||||
event.target.closest('.drag-area').classList.remove('drag-over');
|
||||
},
|
||||
|
||||
// 드롭 처리
|
||||
handleDrop(event) {
|
||||
event.target.closest('.drag-area').classList.remove('drag-over');
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
this.processFiles(files);
|
||||
},
|
||||
|
||||
// 파일 선택 처리
|
||||
handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
this.processFiles(files);
|
||||
},
|
||||
|
||||
// 파일 처리
|
||||
processFiles(files) {
|
||||
const validFiles = files.filter(file => {
|
||||
const isValid = file.type === 'text/html' ||
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.html') ||
|
||||
file.name.toLowerCase().endsWith('.htm') ||
|
||||
file.name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
if (!isValid) {
|
||||
console.warn('지원하지 않는 파일 형식:', file.name);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
|
||||
// 기존 파일과 중복 체크
|
||||
validFiles.forEach(file => {
|
||||
const isDuplicate = this.selectedFiles.some(existing =>
|
||||
existing.name === file.name && existing.size === file.size
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
this.selectedFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📁 선택된 파일:', this.selectedFiles.length, '개');
|
||||
},
|
||||
|
||||
// 파일 제거
|
||||
removeFile(index) {
|
||||
this.selectedFiles.splice(index, 1);
|
||||
},
|
||||
|
||||
// 파일 크기 포맷팅
|
||||
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];
|
||||
},
|
||||
|
||||
// 다음 단계
|
||||
nextStep() {
|
||||
if (this.selectedFiles.length === 0) {
|
||||
alert('업로드할 파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
this.currentStep = 2;
|
||||
},
|
||||
|
||||
// 이전 단계
|
||||
prevStep() {
|
||||
this.currentStep = Math.max(1, this.currentStep - 1);
|
||||
},
|
||||
|
||||
// 서적 검색
|
||||
async searchBooks() {
|
||||
if (!this.bookSearchQuery.trim()) {
|
||||
this.searchedBooks = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const books = await window.api.searchBooks(this.bookSearchQuery, 10);
|
||||
this.searchedBooks = books;
|
||||
} catch (error) {
|
||||
console.error('서적 검색 실패:', error);
|
||||
this.searchedBooks = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 선택
|
||||
selectBook(book) {
|
||||
this.selectedBook = book;
|
||||
this.bookSearchQuery = book.title;
|
||||
this.searchedBooks = [];
|
||||
},
|
||||
|
||||
// 파일 업로드
|
||||
async uploadFiles() {
|
||||
if (this.selectedFiles.length === 0) {
|
||||
alert('업로드할 파일이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 서적 설정 검증
|
||||
if (this.bookSelectionMode === 'new' && !this.newBook.title.trim()) {
|
||||
alert('새 서적을 생성하려면 제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.bookSelectionMode === 'existing' && !this.selectedBook) {
|
||||
alert('기존 서적을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
|
||||
try {
|
||||
let bookId = null;
|
||||
|
||||
// 서적 처리
|
||||
if (this.bookSelectionMode === 'new' && this.newBook.title.trim()) {
|
||||
try {
|
||||
const newBook = await window.api.createBook({
|
||||
title: this.newBook.title,
|
||||
author: this.newBook.author,
|
||||
description: this.newBook.description
|
||||
});
|
||||
bookId = newBook.id;
|
||||
console.log('📚 새 서적 생성됨:', newBook.title);
|
||||
} catch (error) {
|
||||
if (error.message.includes('already exists')) {
|
||||
// 동일한 서적이 이미 존재하는 경우 - 선택 모달 표시
|
||||
const choice = await this.showBookConflictModal();
|
||||
|
||||
if (choice === 'existing') {
|
||||
// 기존 서적 검색해서 사용
|
||||
const existingBooks = await window.api.searchBooks(this.newBook.title, 10);
|
||||
const matchingBook = existingBooks.find(book =>
|
||||
book.title === this.newBook.title &&
|
||||
book.author === this.newBook.author
|
||||
);
|
||||
|
||||
if (matchingBook) {
|
||||
bookId = matchingBook.id;
|
||||
console.log('📚 기존 서적 사용:', matchingBook.title);
|
||||
} else {
|
||||
throw new Error('기존 서적을 찾을 수 없습니다.');
|
||||
}
|
||||
} else if (choice === 'edition') {
|
||||
// 에디션 정보 입력받아서 새 서적 생성
|
||||
const edition = await this.getEditionInfo();
|
||||
if (edition) {
|
||||
const newBookWithEdition = await window.api.createBook({
|
||||
title: `${this.newBook.title} (${edition})`,
|
||||
author: this.newBook.author,
|
||||
description: this.newBook.description
|
||||
});
|
||||
bookId = newBookWithEdition.id;
|
||||
console.log('📚 에디션 서적 생성됨:', newBookWithEdition.title);
|
||||
} else {
|
||||
throw new Error('에디션 정보가 입력되지 않았습니다.');
|
||||
}
|
||||
} else {
|
||||
throw new Error('사용자가 업로드를 취소했습니다.');
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (this.bookSelectionMode === 'existing' && this.selectedBook) {
|
||||
bookId = this.selectedBook.id;
|
||||
}
|
||||
|
||||
// HTML과 PDF 파일 분리
|
||||
const htmlFiles = this.selectedFiles.filter(file =>
|
||||
file.type === 'text/html' ||
|
||||
file.name.toLowerCase().endsWith('.html') ||
|
||||
file.name.toLowerCase().endsWith('.htm')
|
||||
);
|
||||
|
||||
const pdfFiles = this.selectedFiles.filter(file =>
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
|
||||
console.log('📄 HTML 파일:', htmlFiles.length, '개');
|
||||
console.log('📕 PDF 파일:', pdfFiles.length, '개');
|
||||
|
||||
// 업로드할 파일들 처리
|
||||
const uploadPromises = [];
|
||||
|
||||
// HTML 파일 업로드 (PDF 파일이 있으면 함께 업로드)
|
||||
htmlFiles.forEach(async (file, index) => {
|
||||
const formData = new FormData();
|
||||
formData.append('html_file', file); // 백엔드가 요구하는 필드명
|
||||
formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
|
||||
formData.append('description', `업로드된 파일: ${file.name}`);
|
||||
formData.append('language', 'ko');
|
||||
formData.append('is_public', 'false');
|
||||
|
||||
// 같은 이름의 PDF 파일이 있는지 확인
|
||||
const htmlBaseName = file.name.replace(/\.[^/.]+$/, "");
|
||||
const matchingPdf = pdfFiles.find(pdfFile => {
|
||||
const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, "");
|
||||
return pdfBaseName === htmlBaseName;
|
||||
});
|
||||
|
||||
if (matchingPdf) {
|
||||
formData.append('pdf_file', matchingPdf);
|
||||
console.log('📎 매칭된 PDF 파일 함께 업로드:', matchingPdf.name);
|
||||
}
|
||||
|
||||
if (bookId) {
|
||||
formData.append('book_id', bookId);
|
||||
}
|
||||
|
||||
const uploadPromise = (async () => {
|
||||
try {
|
||||
const response = await window.api.uploadDocument(formData);
|
||||
console.log('✅ HTML 파일 업로드 완료:', file.name);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ HTML 파일 업로드 실패:', file.name, error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
uploadPromises.push(uploadPromise);
|
||||
});
|
||||
|
||||
// HTML과 매칭되지 않은 PDF 파일들을 별도로 업로드
|
||||
const unmatchedPdfs = pdfFiles.filter(pdfFile => {
|
||||
const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, "");
|
||||
return !htmlFiles.some(htmlFile => {
|
||||
const htmlBaseName = htmlFile.name.replace(/\.[^/.]+$/, "");
|
||||
return htmlBaseName === pdfBaseName;
|
||||
});
|
||||
});
|
||||
|
||||
unmatchedPdfs.forEach(async (file, index) => {
|
||||
const formData = new FormData();
|
||||
formData.append('html_file', file); // PDF도 html_file로 전송 (백엔드에서 처리)
|
||||
formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
|
||||
formData.append('description', `PDF 파일: ${file.name}`);
|
||||
formData.append('language', 'ko');
|
||||
formData.append('is_public', 'false');
|
||||
|
||||
if (bookId) {
|
||||
formData.append('book_id', bookId);
|
||||
}
|
||||
|
||||
const uploadPromise = (async () => {
|
||||
try {
|
||||
const response = await window.api.uploadDocument(formData);
|
||||
console.log('✅ PDF 파일 업로드 완료:', file.name);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ PDF 파일 업로드 실패:', file.name, error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
uploadPromises.push(uploadPromise);
|
||||
});
|
||||
|
||||
// 모든 업로드 완료 대기
|
||||
const uploadedDocs = await Promise.all(uploadPromises);
|
||||
|
||||
// HTML 문서와 PDF 문서 분리
|
||||
const htmlDocuments = uploadedDocs.filter(doc =>
|
||||
doc.html_path && doc.html_path !== null
|
||||
);
|
||||
|
||||
const pdfDocuments = uploadedDocs.filter(doc =>
|
||||
(doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) ||
|
||||
(doc.pdf_path && !doc.html_path)
|
||||
);
|
||||
|
||||
// 업로드된 HTML 문서들만 정리 (순서 조정용)
|
||||
this.uploadedDocuments = htmlDocuments.map((doc, index) => ({
|
||||
...doc,
|
||||
display_order: index + 1,
|
||||
matched_pdf_id: null
|
||||
}));
|
||||
|
||||
// PDF 파일들을 매칭용으로 저장
|
||||
this.pdfFiles = pdfDocuments;
|
||||
|
||||
console.log('🎉 모든 파일 업로드 완료!');
|
||||
console.log('📄 HTML 문서:', this.uploadedDocuments.length, '개');
|
||||
console.log('📕 PDF 문서:', this.pdfFiles.length, '개');
|
||||
|
||||
// 3단계로 이동
|
||||
this.currentStep = 3;
|
||||
|
||||
// 다음 틱에서 Sortable 초기화
|
||||
this.$nextTick(() => {
|
||||
this.initSortable();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
alert('업로드 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Sortable 초기화
|
||||
initSortable() {
|
||||
const sortableList = document.getElementById('sortable-list');
|
||||
if (sortableList && !this.sortableInstance) {
|
||||
this.sortableInstance = Sortable.create(sortableList, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
handle: '.cursor-move',
|
||||
onEnd: (evt) => {
|
||||
// 배열 순서 업데이트
|
||||
const item = this.uploadedDocuments.splice(evt.oldIndex, 1)[0];
|
||||
this.uploadedDocuments.splice(evt.newIndex, 0, item);
|
||||
|
||||
// display_order 업데이트
|
||||
this.updateDisplayOrder();
|
||||
|
||||
console.log('📋 드래그로 순서 변경됨');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// display_order 업데이트
|
||||
updateDisplayOrder() {
|
||||
this.uploadedDocuments.forEach((doc, index) => {
|
||||
doc.display_order = index + 1;
|
||||
});
|
||||
},
|
||||
|
||||
// 위로 이동
|
||||
moveUp(index) {
|
||||
if (index > 0) {
|
||||
const item = this.uploadedDocuments.splice(index, 1)[0];
|
||||
this.uploadedDocuments.splice(index - 1, 0, item);
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 위로 이동:', item.title);
|
||||
}
|
||||
},
|
||||
|
||||
// 아래로 이동
|
||||
moveDown(index) {
|
||||
if (index < this.uploadedDocuments.length - 1) {
|
||||
const item = this.uploadedDocuments.splice(index, 1)[0];
|
||||
this.uploadedDocuments.splice(index + 1, 0, item);
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 아래로 이동:', item.title);
|
||||
}
|
||||
},
|
||||
|
||||
// 이름순 정렬
|
||||
autoSortByName() {
|
||||
this.uploadedDocuments.sort((a, b) => {
|
||||
return a.title.localeCompare(b.title, 'ko', { numeric: true });
|
||||
});
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 이름순 정렬 완료');
|
||||
},
|
||||
|
||||
// 순서 뒤집기
|
||||
reverseOrder() {
|
||||
this.uploadedDocuments.reverse();
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 순서 뒤집기 완료');
|
||||
},
|
||||
|
||||
// 섞기
|
||||
shuffleDocuments() {
|
||||
for (let i = this.uploadedDocuments.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[this.uploadedDocuments[i], this.uploadedDocuments[j]] = [this.uploadedDocuments[j], this.uploadedDocuments[i]];
|
||||
}
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 문서 섞기 완료');
|
||||
},
|
||||
|
||||
// 최종 완료 처리
|
||||
async finalizeUpload() {
|
||||
this.finalizing = true;
|
||||
|
||||
try {
|
||||
// 문서 순서 및 PDF 매칭 정보 업데이트
|
||||
const updatePromises = this.uploadedDocuments.map(async (doc) => {
|
||||
const updateData = {
|
||||
display_order: doc.display_order
|
||||
};
|
||||
|
||||
// PDF 매칭 정보 추가 (필요시 백엔드 API 확장)
|
||||
if (doc.matched_pdf_id) {
|
||||
updateData.matched_pdf_id = doc.matched_pdf_id;
|
||||
}
|
||||
|
||||
return await window.api.updateDocument(doc.id, updateData);
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
console.log('🎉 업로드 완료 처리됨!');
|
||||
alert('업로드가 완료되었습니다!');
|
||||
|
||||
// 메인 페이지로 이동
|
||||
window.location.href = 'index.html';
|
||||
|
||||
} catch (error) {
|
||||
console.error('완료 처리 실패:', error);
|
||||
alert('완료 처리 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.finalizing = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 업로드 재시작
|
||||
resetUpload() {
|
||||
if (confirm('업로드를 다시 시작하시겠습니까? 현재 진행 상황이 초기화됩니다.')) {
|
||||
this.currentStep = 1;
|
||||
this.selectedFiles = [];
|
||||
this.uploadedDocuments = [];
|
||||
this.pdfFiles = [];
|
||||
this.bookSelectionMode = 'none';
|
||||
this.selectedBook = null;
|
||||
this.newBook = { title: '', author: '', description: '' };
|
||||
|
||||
if (this.sortableInstance) {
|
||||
this.sortableInstance.destroy();
|
||||
this.sortableInstance = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
goBack() {
|
||||
if (this.currentStep > 1) {
|
||||
if (confirm('진행 중인 업로드를 취소하고 돌아가시겠습니까?')) {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
} else {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 중복 시 선택 모달
|
||||
showBookConflictModal() {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">📚 서적 중복 발견</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
"<strong>${this.newBook.title}</strong>"${this.newBook.author ? ` (${this.newBook.author})` : ''} 서적이 이미 존재합니다.
|
||||
<br><br>어떻게 처리하시겠습니까?
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<button id="use-existing" class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-left">
|
||||
<div class="font-medium">🔄 기존 서적에 추가</div>
|
||||
<div class="text-sm text-blue-100">기존 서적에 새 문서들을 추가합니다</div>
|
||||
</button>
|
||||
<button id="add-edition" class="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-left">
|
||||
<div class="font-medium">📖 에디션으로 구분</div>
|
||||
<div class="text-sm text-green-100">에디션 정보를 입력해서 별도 서적으로 생성합니다</div>
|
||||
</button>
|
||||
<button id="cancel-upload" class="w-full px-4 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors text-center">
|
||||
❌ 업로드 취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 이벤트 리스너
|
||||
modal.querySelector('#use-existing').onclick = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve('existing');
|
||||
};
|
||||
|
||||
modal.querySelector('#add-edition').onclick = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve('edition');
|
||||
};
|
||||
|
||||
modal.querySelector('#cancel-upload').onclick = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve('cancel');
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// 에디션 정보 입력
|
||||
getEditionInfo() {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">📖 에디션 정보 입력</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
서적을 구분할 에디션 정보를 입력해주세요.
|
||||
</p>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">에디션 정보</label>
|
||||
<input type="text" id="edition-input"
|
||||
placeholder="예: 2nd Edition, 2024년판, Ver 2.0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
💡 예시: "2nd Edition", "2024년판", "개정판", "Ver 2.0"
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button id="confirm-edition" class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
확인
|
||||
</button>
|
||||
<button id="cancel-edition" class="flex-1 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const input = modal.querySelector('#edition-input');
|
||||
input.focus();
|
||||
|
||||
// 이벤트 리스너
|
||||
const confirm = () => {
|
||||
const edition = input.value.trim();
|
||||
document.body.removeChild(modal);
|
||||
resolve(edition || null);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
modal.querySelector('#confirm-edition').onclick = confirm;
|
||||
modal.querySelector('#cancel-edition').onclick = cancel;
|
||||
|
||||
// Enter 키 처리
|
||||
input.onkeypress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
confirm();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 Upload 페이지 로드됨');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user